Skip to content

Stop a tab drag being stranded by the tab changing under it - #627

Merged
kshivang merged 306 commits into
devfrom
fix/stuck-drag-ghost
Sep 25, 2026
Merged

kshivang merged 306 commits into
devfrom
fix/stuck-drag-ghost

Conversation

@kshivang

@kshivang kshivang commented Sep 13, 2026 •

Copy link
Copy Markdown
Contributor

Changing a tab's title or index during a drag restarted pointer input and could leave a ghost following the cursor after release. Both tab surfaces now key gestures by tab identity and component, read callbacks and indices live, and use the current dev shared drag handler for owner-scoped cancellation and release.

The legacy full-button disposal hook is removed: an idle rendering of the same tab must not cancel a gesture owned elsewhere. Sahvendra's fix and regression from #1614 are incorporated with original authorship. Earlier implementation and review-repair history is retained.

Validation on d409ff3e6: 27 focused tests passed (tab changes, mouse cancellation/native release, and reorder behavior); common-main/desktop-test ktlint and composeApp detekt passed. Current-head GitHub checks remain the merge gate. No application was launched; hardware-browser cross-pane smoke testing remains manual.

#692 is the follow-up that extracts the shared session abstraction and extends mouse-input recovery coverage. Merge this PR first.

@supabase

supabase Bot commented Sep 13, 2026

Copy link
Copy Markdown

This pull request has been ignored for the connected project pcnwqamqdnsadranufjv because there are no changes detected in supabase directory. You can change this behaviour in Project Integrations Settings ↗︎.


Preview Branches by Supabase.
Learn more about Supabase Branching ↗︎.

@github-actions

Copy link
Copy Markdown
Contributor

Claude diff review of 7a15e93 (no tests executed).

Review of PR #627 @ 7a15e93

Scope note: this is a diff-only review. I could not inspect TabDraggableComponent, TabInfo, the call sites of BossTabButton/TabFaviconChip, or run anything, so several points below are explicitly marked as unverified inferences.


Confirmed defects / high-confidence issues

1. BossTabButton's finally guard compares against the live tab, not the tab that was picked up

composeApp/.../buttons/BossTabButton.kt, new finally block (≈ lines 440‑448):

val draggedId = tabDragComponent.draggingTab?.tabInfo?.id
if (draggedId != null && draggedId == currentTabInfo?.id) {
    tabDragComponent.cancelDrag()
}

currentTabInfo is a rememberUpdatedState of the current tabInfo parameter, and the handler is now keyed on Unit, i.e. it deliberately survives the node representing a different tab. So the guard has two failure modes:

  • False negative (the original bug returns): if this composable's tabInfo changes identity mid-drag (slot reuse in a non-keyed list, tab replaced after a neighbour closes) and the node is then detached, draggedId != currentTabInfo?.id and cancelDrag() is skipped — the stuck ghost is back on exactly the path the finally exists to cover.
  • False positive: any other node whose current tabInfo.id happens to equal the dragged tab's id (same tab rendered twice, e.g. an overflow/menu list) will cancel an active drag when it is disposed.

The robust form is to capture the identity at onDragStart inside the pointerInput block (e.g. var startedId: String? = null, set in onDragStart, cleared in onDragEnd/onDragCancel) and compare the finally against that. TabFaviconChip accidentally gets this right because tabId is a key and therefore a snapshot, which makes the two files inconsistent about the same invariant.

2. Early return in onDragStart leaves the gesture running with no drag started

BossTabButton.kt ≈ lines 406‑408:

onDragStart = { offset ->
    val info = currentTabInfo ?: return@detectDragGestures
    val panel = currentPanelId ?: return@detectDragGestures

return@detectDragGestures returns only from the onDragStart lambda (nearest implicit label); detectDragGestures keeps running. Consequently, on the null path:

  • onDragStart() (the caller notification) is not invoked, but
  • onDrag still calls tabDragComponent.updateDrag(dragAmount), and
  • onDragEnd still calls tabDragComponent.endDrag() and onDragEnd(result).

So the component receives updateDrag/endDrag with no preceding startDragging, and the parent receives a drag-end it never saw a drag-start for. Whether that throws, emits a bogus TabDropResult, or is a no-op depends on TabDraggableComponent, which I cannot see — but the callback asymmetry itself is visible in the diff and is a defect regardless. A return@detectDragGestures from onDragStart should be paired with a flag that suppresses onDrag/onDragEnd.

3. The finally backstop cancels the drag without telling the caller

Both files: the backstop calls only tabDragComponent.cancelDrag().

In BossTabButton, onDragStart() is invoked on the way in (line ≈ 417) and the caller is only ever unwound via onDragEnd(result) on the normal path. Neither onDragCancel (pre-existing) nor the new finally calls onDragEnd(null), so any caller-side drag state set from onDragStart() (hidden source tab, suppressed hover, drop indicator) is left set. The pre-existing onDragCancel had the same asymmetry, but the new finally makes this path fire in many more situations (detach, isDragEnabled flipping, composition leave, key change in TabFaviconChip) — so this is a regression in frequency, if not in kind.

4. Stale captures are now permanent in BossTabButton

Keying on Unit removes the only mechanism that used to re-capture the lambda's closure. Values not routed through rememberUpdatedState are now frozen at first install:

  • windowPosition (line ≈ 411: val absolutePosition = windowPosition + offset) — note TabFaviconChip explicitly passes this as a lambda with a comment explaining why a captured value is wrong (windowPosition = { windowPosition }, and the KDoc at ≈ 239‑243). BossTabButton keeps a direct read. If it is a local var … by remember { mutableStateOf(...) } the delegate read stays live and this is harmless; if it is a parameter or a per-composition val, the ghost will spawn at the first-frame position forever. I cannot tell which from the diff — please confirm.
  • onDragStart, onDragEnd, tabDragComponent — all captured directly. Same question in TabFaviconChip: onDragEnd (param at ≈ line 268, used at ≈ 282) and tabDragComponent are not keys and not lambdas, while tab/tabIndex were converted. If onDragEnd closes over the index or panel contents (a reorder handler plausibly does), it is now stale for the lifetime of the key. The PR went out of its way to fix staleness for tab/tabIndex and left the callback that consumes the drop result on the old footing.

5. TabFaviconChip still restarts on tabId change — and now aborts the user's drag

tabChipDrag is keyed pointerInput(tabId, panelId) (≈ line 253). If the composition slot is re-pointed at a different tab mid-drag (unkeyed list + a tab ahead closing — precisely the scenario the PR description cites for index churn), the key changes, the block is torn down, and the finally fires with the old tabId, which matches draggingTab → cancelDrag(). The ghost no longer sticks (good), but the in-flight drag is silently destroyed with no onDragEnd(null) (see #3). If the parent list is keyed by tab.id, this can't happen; if it isn't, this is a user-visible regression relative to the stated goal ("a drag survives the tab changing underneath it"). The key strategy of the parent list is the deciding factor and is outside the diff.

Related, lower risk: the TabFaviconChip guard (≈ line 287) matches on tabId only and ignores panelId, so a dispose in one panel can cancel a same-id drag originating in another.


Uncertain observations

  • Nullability / warnings-as-errors. currentTabInfo ?: …, currentPanelId ?: …, currentTabInfo?.id imply tabInfo: TabInfo? and panelId: String?. But the pre-change code passed them straight into startDragging(tabInfo = tabInfo, panelId = panelId, …) unguarded. Either the parameters are nullable and the old code was relying on platform/no-checks, or they are non-null and these are Elvis operator always returns the left operand / Unnecessary safe call warnings. If this module compiles with allWarningsAsErrors or a detekt rule for redundant null checks, the build breaks; if the types really are nullable, see defect ⬆️ actions:(deps): Bump actions/setup-java from 4 to 5 #2.
  • cancelDrag() in a cancelled coroutine. The comments in both files assert "plain state writes, so running during cancellation is safe." That is not verifiable from the diff. If cancelDrag() ever suspends, launches on a cancelled scope, or touches the snapshot system in a way that requires a live context, the backstop will silently fail (CancellationException out of the finally). If you want the guarantee the comment claims, either assert non-suspend at the API level or wrap in withContext(NonCancellable).
  • Drop paths that detach the source node before pointer-up. If any flow removes the source tab's node while the pointer is still down (live reorder, drag-to-detach into a new window), the new finally will cancel the drag before onDragEnd can produce a TabDropResult. Old behaviour on that path was a stuck ghost, so this isn't strictly worse, but it changes a "broken ghost" into a "drop that does nothing" — worth checking whether such a path exists.
  • Comment accuracy. The KDoc claim "Seven parameters plus the receiver" matches the new signature (tabId, tab, panelId, tabIndex, windowPosition, tabDragComponent, onDragEnd). No issue; just noting I checked.

Test gaps

composeApp/src/desktopTest/.../TabDragSurvivesTabChangeTest.kt is a genuinely useful regression test — the final up() → assertNull(component.draggingTab) is the assertion that actually discriminates against both the old stranded-ghost behaviour and a "restart + finally cancels" behaviour. Gaps:

  1. BossTabButton has zero coverage. It received the riskier change (pointerInput(Unit), nullable early returns, live-currentTabInfo guard). All three issues above in that file are untested. The test file name suggests general coverage but only exercises TabFaviconChip.
  2. The finally backstop itself is never exercised. Both tests are no-restart happy paths. There is no test that removing the chip from composition (or flipping isDragEnabled) mid-drag clears draggingTab — i.e. the new code's primary safety mechanism.
  3. No negative test for the guard. Nothing asserts that disposing a different tab's chip while tab A is being dragged leaves A's drag intact (issue ⬆️ actions:(deps): Bump supabase/setup-cli from 2 to 3 #1's false-positive branch).
  4. No test that a successful drop still yields a result. onDragEnd is not passed or asserted in either test, so a regression where finally double-cancels or the result is lost would pass.
  5. onDragStart null path (tabInfo == null / panelId == null) is untested, including whether updateDrag/endDrag without startDragging is tolerated.
  6. Weak intermediate assertion, by construction. assertNotNull(component.draggingTab, "a new title must not end the drag") would also pass on the original buggy code (the bug was that draggingTab stayed set). It only has teeth against the new-code-with-restart variant. A stronger pin would assert a monotonically-counted startDragging invocation count of exactly 1 across the title change, via a test double or spy on TabDraggableComponent.
  7. Minor: panelId is a key in tabChipDrag but there is no test for a panelId change mid-drag, and the test taps Modifier.testTag on the wrapping Row rather than the chip, so center is only incidentally inside the draggable node.

Suggested minimal changes

  1. Capture the dragged tab id at onDragStart inside the pointerInput block and use that (not currentTabInfo?.id) in both finally guards; include panelId in the comparison.
  2. Make the onDragStart early return suppress the rest of the gesture (flag), or hoist the null check so the gesture is not installed at all.
  3. Route windowPosition, onDragStart, onDragEnd and tabDragComponent through rememberUpdatedState/lambdas in BossTabButton now that the key is Unit, matching what TabFaviconChip already does for tab/tabIndex; do the same for onDragEnd in tabChipDrag.
  4. Have the backstop notify the caller (onDragEnd(null)) so parent-side drag state is unwound, or document why it must not.
  5. Add the two missing tests: dispose-mid-drag clears the ghost, and disposing an unrelated chip does not cancel an active drag — plus a BossTabButton-level equivalent of the existing two.

@kshivang
kshivang changed the base branch from main to dev September 14, 2026 09:53
@kshivang

Copy link
Copy Markdown
Contributor Author

@kshivang Your diagnosis of the stranded ghost and the two real Compose gesture regressions are useful, and your original commit/credit are preserved. I pushed review repairs to this branch (current head fc86cbeab) and retargeted it to dev.

I checked Claude's current original-head review against the full source:

  • Confirmed ownership weakness: an idle same-id representation could cancel someone else's drag, and the button compared against a live identity. Both handlers now retain the exact drag they started and only update/end/cancel that drag. Button keys now include stable tab/panel/component identity.
  • Callbacks now read live values; cancellation notifies the caller after clearing state. The null-start path cannot update or finish an unowned drag.
  • Found an additional correctness gap: surviving a reindex still emitted the old fromIndex, and TabDropHandler moves by that index. Release now refreshes the source index and target before forming the result.
  • Rejected the speculative frozen-position concern: button windowPosition is remembered mutable state. cancelDrag() is synchronous state writes, so NonCancellable is unnecessary. Parent chip lists use tab-id keys; changing identity legitimately cancels. The host call site does not set custom onDragStart state, so the reported existing parent-state leak was not demonstrated, though pairing is now explicit.

Added coverage for both surfaces, actual reindexed reorder results, latest callbacks, disposing an idle same-id chip versus its owner, and button identity replacement. Original-head CI had 10 successful checks; Supabase preview was skipped. These are NOT evidence that the new repairs pass. Local Gradle never started because the shared build lock stayed occupied; I interrupted only my queued command after the bounded wait. This is shared validation infrastructure, not an author defect.

I am converting this PR to draft because current-head validation/review is outstanding, not because the useful change should be abandoned. Next steps: run python3 /Users/kshivang/Development/Boss/.pr-review-sessions/with-build-lock.py ./gradlew :composeApp:ktlintFormat :composeApp:detekt :composeApp:desktopTest --tests 'ai.rever.boss.components.tabs.*' --console=plain from the isolated worktree, address any formatter/compiler/test findings, then run required CI on the final head and recheck dev under the merge lock. A native drag smoke check should include title changes, a neighbor closing, owner removal, and successful reorder/cross-panel drops. No app instance was launched or stopped here.

Main has distinct cross-pane insertion behavior from merged #284; preserve its target-index behavior during eventual integration. #248/#253/#284 are related context, not duplicates. Integration #651 is confirmed merged and was read-only.

@claude Please review the repaired current head once, especially gesture ownership, reindex/drop semantics, and the added regression tests. No tests have been claimed passed on this new head.

@kshivang
kshivang marked this pull request as draft September 14, 2026 10:01
@claude

claude Bot commented Sep 14, 2026 •

Copy link
Copy Markdown

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


Review of the repaired head (fc86cbe)

  • Read the four tab-drag files against dev (merge parent 3e09b57)
  • Gesture ownership
  • Reindex / drop semantics
  • Regression tests
  • Re-check the earlier review's points, including the ones you rejected

Verification status: static reading only. I did not run Gradle, so nothing below is a claim that the tests pass - and one finding is a prediction that one of them currently fails.

The direction is right: the four lambdas now act on a token they took at onDragStart, callbacks are read live, and release refreshes the source index. Two things are still open in the same area the PR is about, and one of them is inside BossTabButton itself.


1. The button's old cancel-by-id DisposableEffect is still there, and it is the false positive you just removed from the gesture

composeApp/src/commonMain/kotlin/ai/rever/boss/components/buttons/BossTabButton.kt:305-314

DisposableEffect(tabDragComponent, tabInfo?.id) {
    onDispose {
        // Only cancel if THIS tab is the one being dragged
        if (tabDragComponent?.draggingTab?.tabInfo?.id == tabInfo?.id) {
            tabDragComponent?.cancelDrag()
        }
    }
}

It matches on the tab id with no record of which gesture started the drag - exactly what disposing an idle copy does not cancel another chip gesture now forbids for chips. Two consequences:

  • It cancels drags it does not own. A TabFaviconChip starts drags and never registers bounds (PaneTabStrip.kt:168 passes the component), while the window bar renders a BossTabButton for that same tab id with the same component. Drag from the chip, the drag's own edge-scroll scrolls the bar's lazy list, the source tab's row is virtualized out - its onDispose runs and kills the live drag mid-gesture, with no callback. The drawer path is safe only by accident (WindowVerticalTabBar.kt:383-384 passes tabDragComponent = null, registerBounds = false).
  • It bypasses the notification you just added. It calls cancelDrag() and nothing else, so parent-side state is not unwound - the asymmetry the gesture's finally now handles correctly.

And it makes me expect changing a button identity cancels only its old gesture to be red. On an id change both mechanisms target the same drag: the effect's onDispose (which captured the old tabInfo, so it matches) and the gesture's finally. The effect disposal runs in the apply phase; the pointerInput coroutine's cancellation is delivered through the composition's dispatcher, i.e. later. In that order cancelDrag() has already nulled draggingTab, the finally guard draggingTab === ownedDrag fails, and currentOnDragEnd(null) never runs - so assertEquals(1, cancellations) fails while assertNull(component.draggingTab) still passes. Worth being the first thing you check when the build lock frees up.

Fix: delete the effect. The gesture's finally already covers dispose and detach and notifies, and the effect cannot cover anything the gesture can't - a button with drag disabled never starts a drag. Fix this →

2. A gesture can adopt a drag it did not start

TabDraggableComponent.kt:425 - startDragging returns early when draggingTab != null and says nothing. Both handlers then do:

tabDragComponent.startDragging(...)
ownedDrag = tabDragComponent.draggingTab

so a refused start hands this gesture the other gesture's token. From there it will updateDrag (doubling the delta), endDrag(currentTabIndex) with the wrong source index - reordering the wrong tab - or cancel a drag it never started. Reachable with two pointers (touch-screen Windows/Linux hosts, and trivially from a test).

Ownership is this PR's subject, so it should not be inferred: check draggingTab == null before starting, or have startDragging return the DraggingTabInfo it created (null when refused) and claim only that.

3. The ownership token is an instance the component is free to replace

endDrag itself demonstrates it:

draggingTab = draggingTab?.copy(sourceIndex = sourceIndex)

That is harmless where it stands (the guard runs before the call, ownedDrag is nulled after). The hazard is the next edit: any in-place update of draggingTab - most obviously refreshing the ghost's title mid-drag, which is the exact scenario this PR exists for - silently revokes every gesture's ownership. Updates stop, onDragEnd stops firing, the finally stops cleaning up, and the stuck ghost is back. None of the five composition tests would catch it: they all compare draggingTab against null, never against an identity.

A monotonic dragId: Long on DraggingTabInfo (or a counter beside it) as the token is immune to that, and gives #2 its natural return value.

4. Release now re-resolves the drop target, so the commit can differ from the last highlight

endDrag calls updateDropTarget(), which recomputes from dragStartPosition + dragDelta. dragDelta is updated on every onDrag, but dropTarget is throttled to dropTargetUpdateInterval = 16L (:364) - so the committed drop uses a position up to one throttle window newer than the indicator the user was aiming with, which is a one-slot difference at a boundary. Defensible ("you get what you released over"), but it is a real semantic change and the KDoc only mentions bounds: "resolve the target from the latest registered bounds" is not what the line does, since the position is refreshed too. Second consequence to be deliberate about: if a bar was disposed mid-drag, the refresh can turn a previously non-null target into null - a release that does nothing, where before it committed a stale target.

5. endDrag(sourceIndex: Int? = null) makes the correctness opt-in

Both call sites pass it and no test relies on the default, so the default's only effect is to preserve the stale-index bug for the next call site that forgets. Make it required, or move the refresh into the two callers.

6. Small things

  • onDrag calls change.consume() before the ownership guard, so a gesture that owns nothing (null currentTabInfo, or a refused start per ⬆️ actions:(deps): Bump actions/setup-java from 4 to 5 #2) still swallows pointer changes. BossTabButton.kt:423-428.
  • ownedDrag != null && tabDragComponent.draggingTab === ownedDrag appears four times per file; one local fun ownsDrag() would keep the eight copies from drifting.

Tests

Good: the release assertion is the real discriminator; reindexed source is the tab reordered on release pins the reorder arithmetic as a pure unit test (and the Reorder("panel-1", 1, 0) expectation checks out against reorderIndexFor and the insertion adjustment); the chip-ownership and latest-callback pins are worth having.

Gaps, in priority order:

  1. No BossTabButton mirror of the idle-same-id test - it would fail today because of ⬆️ actions:(deps): Bump supabase/setup-cli from 2 to 3 #1, which is presumably why it is absent. Add it once the effect is gone.
  2. Nothing covers ⬆️ actions:(deps): Bump actions/setup-java from 4 to 5 #2: a second startDragging while a drag is live must not be adopted, and the second gesture's release must produce no result. Cheap as a pure unit test plus one composition test.
  3. Nothing pins ownership by identity (⬆️ actions:(deps): Bump actions/setup-go from 6 to 7 #3): replace draggingTab in place mid-drag and assert the gesture still updates, still ends and still cleans up.
  4. assertNotNull(component.draggingTab, "a new title must not end the drag") would also pass on the original buggy code - the bug was that it stayed set. Asserting the same instance, or a startDragging count of exactly 1 across the title change, gives it teeth.
  5. Only Reorder is covered on release. MoveToPanel, CreateSplit and Bookmark all carry sourceIndex out to the caller, so the reindex fix is untested on exactly the results that hand the index to someone else.
  6. changing a button identity cancels only its old gesture is currently asserting an ordering between two independent cancel mechanisms rather than a behaviour (⬆️ actions:(deps): Bump supabase/setup-cli from 2 to 3 #1).

Points from the earlier review I checked and agree with your rejection of

  • windowPosition is not frozen: var windowPosition by remember { mutableStateOf(Offset.Zero) } at BossTabButton.kt:278, so the delegate read stays live under the stable key.
  • cancelDrag/endDrag are plain snapshot writes with no suspension - NonCancellable would add nothing.
  • The null-start path can no longer update or finish an unowned drag (modulo security: stop DigiCert signing secrets leaking into public CI logs #6's consume()).
  • No caller passes onDragStart to BossTabButton (onDragEnd only, at BossMainWindowPanel.kt:217 and :812, PaneTabStrip.kt:171), so the parent-state-leak concern has no call site today; explicit pairing is still the right call.
  • The K2 smart cast off isDragEnabled is what makes tabInfo.id / tabDragComponent.startDragging compile, as it did before the change - no new nullability warning there.

One more for whatever scoping story #1 settles on: BossAppStartupEffects.kt:178 cancels any active drag on window focus loss, also unscoped and also without notifying. Under the new guards that leaves the live gesture inert rather than misbehaving, which is acceptable - but it is the third unscoped cancelDrag() in the tree.
· branch fix/stuck-drag-ghost

@kshivang
kshivang force-pushed the dev branch 2 times, most recently from cd88490 to 5a2e818 Compare September 14, 2026 11:16
@Sahvendra7

Copy link
Copy Markdown
Contributor

I audited #627 against the newer #690 cross-pane reproduction and found one remaining ownership gap.

The new pointerInput finally cleanup is correctly gesture-scoped using ownedDrag === tabDragComponent.draggingTab, and TabFaviconChip follows that model. However, BossTabButton still has the legacy DisposableEffect that effectively does:

if (draggingTab?.tabInfo?.id == tabInfo?.id) tabDragComponent?.cancelDrag()

That cleanup is keyed by tab ID rather than the specific drag instance. During a cross-pane move, the source BossTabButton can leave composition while the drag is still owned elsewhere. Its disposal can therefore cancel a currently active drag merely because the logical tab ID matches.

This is the remaining gap against #690's source-scoped ownership requirement.

I suggest removing/replacing that legacy disposal path with the same gesture-owned identity semantics already used by the new pointerInput cleanup, and adding a regression covering:

  1. Source A starts a drag.
  2. Source A's BossTabButton is disposed/recomposed.
  3. A valid drag remains active.
  4. A stale disposal callback must not cancel it.
  5. The eventual release still produces the expected drop result.

An equivalent test for BossTabButton would also close the current coverage gap; the existing “disposing an idle copy does not cancel another chip gesture” test only proves the TabFaviconChip path.

I did not modify the branch or create a competing PR. TabDragSurvivesTabChangeTest passes locally; macOS hardware-browser verification remains unavailable in this environment.

@fluck-boss

Copy link
Copy Markdown
Collaborator

Thanks for digging into the tab-drag race. The build summary and code-quality checks are currently failing. Please keep working through them until CI is green. Appreciate the interaction work!

@kshivang
kshivang force-pushed the dev branch 2 times, most recently from d4dd36a to bdcf620 Compare September 22, 2026 04:42
Mihir-Rabari and others added 5 commits September 22, 2026 05:41
… running git (#930)

* fix(git): re-validate the remote-prefix-stripped checkout name

checkout() gated the picker's branch name with isSafeRefName, then
stripped the remote prefix and handed the stripped result to git
untouched: a branch named origin/-f - legal as a whole, plantable by
anyone who can push a branch name - strips to -f, so the command
assembled as `git checkout -f --` and silently discarded every
uncommitted modification. The stripped name now runs through the same
isSafeRefName gate; flag-shaped, blank and oversized results are
refused before git sees them.

BossTopBar's onCheckout was also the one write verb passing no
projectPathOverride: the global path belongs to whichever window
aligned it last, so a second window's checkout could act on the other
window's repository. It now passes windowProjectPath like its
neighboring verbs.

Upstream: #919

* test(git): add the safe-name control to the checkout gate suite

A slashed remote-style name with the same uncommitted work the
flag-shaped refusal guards must still DWIM to its tracking branch and
carry the modification - proving the gate refuses flag-shaped stripped
names only, not slashed names wholesale.

* test(git): pin the checkout gate's own refusal and share the clone fixture

Address the Claude review on the exact prior head:

- The strips-to-empty test passed against unpatched dev (git checkout "" --
  fails on its own), so it pinned git's behaviour rather than the fix. Both
  refusal tests now assert the gate's own Error message, which only the
  isSafeRefName(localName) line can produce.
- The flag test's HEAD check used rev-parse --abbrev-ref, which is stable
  under a same-branch HEAD move; it now compares the full hash.
- The planted origin/-f ref gets an existence precondition so a future git
  rejecting that refname cannot leave the test silently modelling a phantom
  attack.
- The duplicated clone fixture (which had diverged by one core.autocrlf
  line) becomes one cloneOf() helper used by all three remote-style tests.
- The BossTopBar comment no longer calls checkout "the one write verb still
  on the global path": stash/stashPop below it still run on the global
  currentProjectPath because their signatures take no override.

Agent repair commit (sweep 2026-09-22T0929Z-codeq); the original
contribution remains the gate in DesktopGitService.checkout.

* style(git): satisfy ktlint on the planted-ref precondition chain

---------

Co-authored-by: Boss PR Review Agent <pr-review-agent@risa-labs.local>
…on or logging (#931)

* fix(browser): gate Navigate schemes and redact URLs in the service log (#911)

The browser service accepted any Navigate URL verbatim: a javascript: URL
was handed to the engine as script to evaluate in the page's own context,
reported back as success=true with a COMPLETED navigation event, and logged
raw at INFO in the browser-service log — the same log path that printed
userinfo credentials (https://user:Secret@host/) verbatim.

- Navigate now refuses every scheme outside the http/https/file/ftp
  allowlist — javascript: and data: included — before any state is
  written, any event is emitted or the URL is logged, so a refused
  navigation leaves no COMPLETED event, no slot update and no log line
  carrying the URL. The scheme is read the way an engine reads it (tabs
  and newlines stripped first, case-insensitive, RFC 3986 syntax), so
  "jav\tascript:", "JAVASCRIPT:" or a schemeless string cannot walk the
  gate.
- The navigate and getFavicon log lines route the URL through
  LogSanitizer.redactUrlUserInfo, so userinfo never reaches the
  browser-service log; the response and navigation-event contract still
  carries the full URL.
- Reload now emits the same synchronous STARTED -> COMPLETED pair
  navigate has and stores isLoading=false, so getPageInfo no longer
  reports the window loading forever after a reload.

* fix(browser): log navigations through describeUri and keep the gate http/https-only

Address the Claude review on the exact prior head:

- The navigate and getFavicon log lines used redactUrlUserInfo, which is
  weaker than the convention BrowserUrlLogConventionTest was written to
  enforce: it leaves the query and fragment (an OAuth code, a presigned
  signature) in the log, and returns input without :// unchanged, which
  covers https:user:secret@host. LogSanitizer.describeUri drops the
  userinfo, query and fragment, and the source-scan test now requires it.
- file: and ftp: left the allowlist, matching the repo's existing URL
  policy for anything that opens in a browser tab (UrlOpenValidation is
  http/https only; file: is a local-file read into a window, and on
  Windows file://host/share is an SMB fetch; ftp: is dead in Chromium).
  No client of Navigate exists in-repo yet, so tightening is free now.
- The refusal message caps the echoed scheme at 32 characters: the charset
  is validated but the length is not, and the log contract is "never the
  URL".
- Tests added for the file:/ftp: refusals, for the refusal carrying the
  scheme name and no payload, and for the capped overlong scheme; the
  navigable set is http/https.

Agent repair commit (sweep 2026-09-22T0929Z-codeq); the original
contribution remains the scheme gate, refusal-before-state ordering,
reload wedge fix and test matrix in this branch.

* style(browser): satisfy detekt MaxLineLength in the capped-scheme test

---------

Co-authored-by: Boss PR Review Agent <pr-review-agent@risa-labs.local>
* fix(logging): mask Supabase's current API key shapes

Supabase replaced the legacy JWT anon and service_role keys with opaque
sb_publishable_ and sb_secret_ strings. credentialShapePattern recognised a JWT,
a GitHub token and a vendor sk_/pk_ key, so neither new shape matched any branch
and both passed through every free-text path untouched. Measured on dev before
the change, through the real sanitizer:

  in : connect failed: apikey sb_secret_A1b2C3d4E5f6G7h8J9k0L1m2 rejected
  out: connect failed: apikey sb_secret_A1b2C3d4E5f6G7h8J9k0L1m2 rejected

against a JWT in the same call, which came back as eyJ...123.

The free-text paths are where this lands: sanitizeExceptionMessage,
sanitizeLogMessage and sanitizeStackTrace are what a client library's own message
goes through, and a rejected-key message is exactly the shape that carries one.
Those lines reach the Console capture that console_tail and console_search serve
and that every plugin reads through PluginContext.logDataProvider. sb_secret_ is
the service_role replacement, so it bypasses row-level security entirely.

A value in a map position was already covered, by looksLikeSecret's length test
rather than by shape. This closes the text path, which had nothing.

BossConsole#870 closed the JWT case for the realtime client and recorded these
shapes as follow-up scope. This is that follow-up.

The two prefixes are named rather than accepting any sb_, because over-masking a
diagnostic is the failure looksLikeSecret's own KDoc warns about: a path, a URL or
a command is the thing a reader most needs, and sb_config_reloaded is not a
credential.

The pattern exists twice. McpArgumentSanitizer carries a byte-identical copy whose
KDoc says it is the same rule, and the two serve one decision from opposite sides:
what an operator sees before approving a mutating tool call, and what a reader sees
in the log or the ledger afterwards. A shape masked in one and printed in the other
is the worst of both, so both are widened here and
McpArgumentSanitizerCredentialShapeTest now pins them against each other by reading
the literal out of both sources. Comparing sources rather than behaviour because a
behavioural sample can only prove the cases someone thought to list.

Verified by running it. Red first: the three sb_ assertions fail against unmodified
dev while the two guard assertions pass, so they are not passing vacuously. Green:
61 tests in plugin-logging and the composeApp sanitizer suites. Mutation: removing
the branch from LogSanitizer alone fails the three shape tests, and removing it from
McpArgumentSanitizer alone fails the drift test by name plus its two argument tests.

Not covered: opaque JSON credentials, the other half of what #870 recorded, still
need a shape nobody has proposed. Other vendors that moved to opaque prefixes are
unaddressed for the same reason; this adds the one whose keys this product actually
issues. BOSS still configures SUPABASE_ANON_KEY as a legacy JWT, so this is a gap
closed ahead of the rotation rather than a leak observed in a log today.

(cherry picked from commit 145cc1d)

* fix(logging): make the drift guard compare real pattern sources; sync the shape docs

Agent repair of Claude's pre-merge findings:

1. The drift guard's normalization deleted every '+' from the pattern source,
   so the JWT branch's [A-Za-z0-9_-]+ quantifier did not survive it and a copy
   that dropped the '+' in one file would normalize to the same string. The
   extractor now anchors on the declaration, skips // lines while counting
   paren depth, and concatenates the contents of the quoted string segments
   instead of deleting separators. A new test pins that the + quantifier
   survives normalization.

2. Three doc sites still described three shapes after the Supabase branch was
   added (LogSanitizer's property KDoc, sanitizeExceptionMessage's Removes
   list, McpArgumentSanitizer's object KDoc); each now names the sb_ shapes.
   The inline comment inside the Regex literal moved into the property KDoc
   where the per-branch reasoning lives.

3. SupabaseKeyShapeTest records the sbp_ personal-access-token gap in its
   'Not covered' note. The test KDoc's #870 attribution is corrected: #870
   recorded the shapes as follow-up scope, it did not add them.

* style(logging): satisfy detekt and ktlint in the drift-guard extractor

The single walk now keeps one jump statement (the break) and skips comment
lines via if/else, and the multiline expression starts on a new line. The
extracted pattern is unchanged.

* style(logging): split the drift-guard walk so it satisfies detekt depth

Move the paren-depth scan into regexArgumentListEnd so the single loop
holds one break and the block depth stays within the detekt limit. The
extracted pattern is unchanged.

* docs(logging): break the circular attribution between the two pattern KDocs

LogSanitizer's credentialShapePattern is the original; its KDoc now says so
and names the guard that pins the McpArgumentSanitizer copy against it. The
test KDoc's reformatting claim is scoped to line breaks between segments.

* style(logging): wrap the helper signature for ktlint

* fix(logging): keep the drift guard comment-aware in the extraction pass

Claude's final re-review flagged that the split moved comment-skipping
into the depth walk only: a //-comment holding a quoted word inside the
argument list would be concatenated into that file's extracted pattern.
The extraction now skips //-lines before the segment regex runs, restoring
the parity the pre-split version had.

The class KDoc also made the broader claim than the helper KDoc: .? does
not cross newlines, so splitting one triple-quoted segment across lines
mangles it. The limit is now "between its segments", in both places.

The remaining nit (paren counting inside string segments) is rejected with
evidence in the PR thread: the walk is comment-aware, Kotlin string
literals cannot contain unescaped newlines, and a regex literal is
syntactically well-formed, so no unbalanced paren can appear outside a
quoted segment.

* fix(logging): give the drift guard one scanning rule instead of two

Claude's final re-review approved the masking and the comment-aware
extraction, then corrected the recorded reasoning behind the rejected
paren nit: regex syntax does not require balanced parens as raw
characters - [)], [(], \) are all valid - so the old depth walk could
terminate early inside a quoted segment and silently stop comparing both
files from the same point. It also flagged that the whole-line comment
skip missed trailing // comments, which the depth pass did skip.

The extractor is now a single pass over runs - a triple-quoted segment,
an ordinary quoted segment, a // comment to the line end, or a single
character - that counts the parens bounding the list and collects the
segment contents at the same time. One scanning rule, so a paren inside
a quoted segment and a quoted word inside a comment can reach neither
the counter nor the extraction. The extracted string is byte-identical
on both current files (verified with an independent re-implementation:
identical output, equal to the real runtime pattern, + quantifier
surviving), and a new test pins that a trailing // comment containing a
quoted word does not leak into the extraction.

* fix(logging): make the drift guard fail loud on the paths it can silently miss

Claude's re-review approved the single-pass extractor, then named the
two remaining silent paths:

- an argument list with no quoted segment at all (a refactor to a
  constant) extracted "" from both files, so the equality check compared
  nothing and passed. The extractor now returns null on an empty result,
  so the existing "no credentialShapePattern literal in <name>" error
  fires; a new test pins the bare-identifier case.
- the trailing-comment fixture used "(a)", whose parens are balanced, so
  it discriminates the comment fix but not the paren fix that motivated
  the rewrite. A new fixture uses "[)]" (valid regex, valid Kotlin, an
  unbalanced closer as raw characters): a character-wise counter would
  stop the walk there and truncate both files at the same point, so this
  is red against exactly the defect class that was fixed.

The class KDoc's limit now names quote style and escaping alongside line
breaks: the extraction keeps raw segment source, escapes included, so a
triple-quoted segment and an escaped ordinary string that compile to the
same regex extract differently - loud, but not something the KDoc should
promise away.

* style(test): remove the doubled blank line ktlint flagged

ktlintDesktopTestSourceSetCheck failed on
McpArgumentSanitizerCredentialShapeTest.kt:148 for a needless blank line
before the credentialShapeLiteral KDoc. Dropped one blank line, nothing
else touched.

---------

Co-authored-by: kshivang (review agent) <kshivang@users.noreply.github.com>
* fix(plugin-store): bound ratings pagination and stop returning rater UUIDs

GET /:pluginId/ratings is unauthenticated, and it passed caller-chosen
page/pageSize straight into a PostgREST .range() call: pageSize=99999999
asked for a massive range, 0/negative/NaN values produced malformed range
math, and page=1e9 walked deep offsets - all on a route with no rate
limiter. Every row also carried the rater's raw auth.users UUID, so the
endpoint doubled as a mass identity-scraping surface at whatever scale
the query allowed.

- Validate page/pageSize fail-closed in the handler, before anything
  touches the database: integers only, page 1..100000, pageSize 1..100
  (mirroring the cap /search already enforces). Anything else gets this
  route's fixed 400 envelope instead of becoming oversized or malformed
  range math.
- Stop selecting and returning user_id from plugin_ratings: review rows
  are now anonymous (rating/review/createdAt). Nothing in-repo consumed
  the UUID and no display-name join exists, so the identity column had
  no legitimate consumer on this unauthenticated route.

Adversarial coverage in tests/ratings-bounds.test.ts: oversized, zero,
negative, fractional, non-numeric and beyond-cap values are rejected
without a database call; the max bounds still pass; a normal page still
works while the service never even requests user_id and the response
body stays UUID-free.

Fixes #915

* fix(ratings): tighten pagination bounds to 500/50 as promised on the thread

Follow-up to the bounds discussion on #915: RATINGS_PAGE_MAX drops
100000 -> 500 and RATINGS_PAGE_SIZE_MAX 100 -> 50, the same family
bound #1045 pins on /list, so the worst-case single request is a
25,000-row window and both PRs agree by construction. The handler
still fails closed with the same fixed 400 envelope before any
database call; only the constants (and the numbers they render into
the message) move, and the tests now exercise the exact edges:
500/50 passes validation, 501/51 is rejected, and the range ceiling
at max pageSize is 50 rows.

Fixes #915
…route (#1541)

get_popular_tags is anon-callable on purpose, because the plugin store is
browsable before sign-in, so anyone holding the anon key the desktop client
ships can call it through PostgREST and never pass the /tags/popular route
that #1508 caps. Its body ended in LIMIT p_limit, and LIMIT NULL is LIMIT
ALL. The rows are public tags only, so this is a cost bound, not a
disclosure fix.

The function now clamps the same way the route does: NULL takes the
default of 20, anything below 1 becomes 1, and anything above 100 becomes
100. Values inside 1..100 are unchanged, and so is the signature, so the
audits that name get_popular_tags(integer) still match.

The replace re-grants EXECUTE to anon, authenticated and service_role,
because the enforce_explicit_anon_grants event trigger fires on the CREATE
FUNCTION tag that CREATE OR REPLACE also produces and revokes anon. The
pgTAP test pins both the grant and the clamp, calling the function as anon
the way PostgREST does, against a fixture of 150 public tags so the cap is
observable.

Refs #1253.

Co-authored-by: Claude Opus 5 <noreply@anthropic.com>
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)
@kshivang
kshivang marked this pull request as ready for review September 24, 2026 22:51
@github-actions

Copy link
Copy Markdown
Contributor

Claude diff review of 46e4e95 (no tests executed).

Review of PR #627 @ 46e4e95

Scope note: this is a diff-only read. Several of the judgements below depend on code not shown in the diff (detectTabDragGestures' onDragStart/onDrag body, ownsDrag(), TabDraggableComponent.updateDropTarget()/cancelDrag(), the draggable flag in TabFaviconChip, and the production onDragEnd call sites). I flag those as unverified rather than asserting them.

The core idea — narrowing the pointerInput keys to the drag's true identity and reading mutable inputs through rememberUpdatedState — is the right fix for the stuck-ghost class of bug, and the ordering in onDragEnd (ownedDrag = null before onEnd(result)) is deliberately correct: it prevents the finally from double-firing if the callback synchronously disposes the node. Below are the things I'd want addressed or confirmed before merge.


1. Confirmed behaviour changes that need caller verification

TabDragGesture.kt (onDragCancel ~L41-47, finally ~L52-59): onEnd(null) is now emitted on cancel and on teardown.

onDragCancel = { if (ownsDrag()) { component.cancelDrag(); ownedDrag = null; onEnd(null) } }
...
} finally {
    if (ownsDrag()) { component.cancelDrag(); ownedDrag = null; onEnd(null) }
}

This is a semantic change to a public-ish callback, and the diff itself proves it: TabDragGestureTest.kt:96 flips assertEquals(0, drops) → assertEquals(1, drops). Two consequences worth checking against the call sites (none of which appear in the diff):

  • onDragEnd now fires from a disposal/detach path, i.e. while the owning composable is leaving the composition or its modifier chain is being torn down. Any handler that mutates panel/tab state, shows UI, or touches a component being destroyed will now run at that moment. null was already a possible result from endDrag(), so handlers probably tolerate the value — the new thing is the timing, not the value.
  • Only the two call sites in this diff are visible. If detectTabDragGestures has other users, they all inherit the new cancel notification.

TabDragGesture.kt finally block: an exception thrown by onEnd now escapes a finally during coroutine cancellation. Previously the block only called component.cancelDrag(). Invoking an arbitrary user callback there means a throwing handler propagates out of the cleanup path and can mask the cancellation. Consider runCatching { onEnd(null) } or at minimum confirming the handlers are total.


2. Likely issue: no lower bound on the refreshed source index

TabDraggableComponent.kt:627-633:

fun endDrag(sourceIndex: Int? = null): TabDropResult? {
    if (sourceIndex != null && draggingTab != null) {
        draggingTab = draggingTab?.copy(sourceIndex = sourceIndex)
        updateDropTarget()
    }

The start path is gated on a valid index (BossTabButton.kt:306: isDragEnabled = ... && tabIndex >= 0), but the end path is not. sourceIndex() is now a live read of currentTabIndex (BossTabButton.kt:399) / tabIndex() (TabFaviconChip.kt:~262), so if the tab is removed from the list while the gesture is airborne and the index source is something like indexOf(...), -1 flows straight into copy(sourceIndex = -1) and out as TabDropResult.Reorder(panelId, -1, target). Whether the reorder consumer validates is not visible here; in TabFaviconChip there isn't even a visible >= 0 gate (the draggable condition is outside the diff).

Cheap, local fix: if (sourceIndex != null && sourceIndex >= 0 && draggingTab != null), or reject the whole drop when the refreshed index is invalid.


3. Observations / smaller correctness points

BossTabButton.kt:400 — silent early return on a null tabInfo.

onStart = { offset ->
    val info = currentTabInfo ?: return@detectTabDragGestures

If tabInfo goes null between composition and drag start, startDragging never runs and currentOnDragStart() never fires, but detectDragGestures keeps running. Depending on how the (not-shown) wrapper assigns ownedDrag after calling onStart, the subsequent release either does nothing at all (no onEnd, inconsistent with every other exit path) or — worse, if ownedDrag is set from component.draggingTab unconditionally — could latch onto a drag this node did not start. Worth reading the onDragStart body against this new early-return. The TabFaviconChip path has no equivalent guard, so the two call sites now behave differently on the same edge case.

**TabDraggableComponent.kt:629 — copy()changesDraggingTabInfoinstance identity.** Ownership inTabDragGesture.kt is held as an instance reference (var ownedDrag: DraggingTabInfo?). Within endDragthis is safe becauseownsDrag()was already evaluated and the state is cleared immediately after, but ifownsDrag()is identity-based this is now the one place that swaps the instance out from under other holders. Please confirmownsDrag()compares something stable (e.g.tabInfo.id) rather than ===`.

**TabDraggableComponent.kt:628-632 — updateDropTarget()is conditional onsourceIndex != null.** Both new call sites always pass an index, so in practice every drop now re-resolves the target at release time; a caller relying on the default gets the old behaviour. That asymmetry is invisible at the call site. Also note the re-resolution can, in principle, land on a different slot than the indicator the user saw, if bounds were re-registered after the last onDrag` — arguably the intent, but it is a behaviour change for all drops, not just reindexed ones.

**TabDragGesture.kt:36-49 — redundant ownedDrag = null.** Both onDragEndandonDragCancelnull it inside theifand again immediately after theif. Harmless, but the duplication invites someone to delete the "wrong" one later; the inner assignment is the load-bearing one (it must precede onEnd`).

**TabDragGesture.kt:12 — new parameter added after the lambdas.** sourceIndex: () -> Int? = { null }is now the trailing-lambda slot. Any existing caller using trailing-lambda syntax foronEndwill fail to compile (type mismatch, so not silent), but placing the non-lambda-ish config param beforeonStart/onEnd` would be tidier.

Doc drift, TabFaviconChip.kt:~248: the KDoc says "Keyed on [tabId] and [panelId] alone", but the implementation keys on three values: pointerInput(tabId, panelId, tabDragComponent). Small, but this file's comments are clearly load-bearing documentation, so it should match.


4. Test gaps

The new suite covers the headline regression well (title change, index change, latest-callback freshness, non-owner disposal for both widgets, owner disposal, identity change). Missing, in rough priority order:

  1. No test for the onDragCancel path itself (TabDragGesture.kt:41). Coverage of onEnd(null) comes only from the finally restart path (changing a button identity cancels only its old gesture) and the amended TabDragGestureTest:96. A test that a real pointer cancel produces exactly one onEnd(null) — and that the subsequent finally does not add a second — would pin the interaction between the two new call sites.
  2. No test for TabFaviconChip's cancel/teardown onDragEnd(null) — only BossTabButton asserts a cancellation count. The two widgets now have subtly different onStart paths (see §3), so symmetry matters.
  3. No test for a stale/negative sourceIndex at release (§2). reindexed source is the tab reordered on release only covers a valid decrease (2 → 1).
  4. No test for tabInfo becoming null mid-drag in BossTabButton, which is the one branch the new ?: return@detectTabDragGestures exists to serve.
  5. No cross-panel coverage of the refreshed index — every assertion is a same-panel Reorder. A Detach/move-to-other-panel drop exercising the new updateDropTarget() call inside endDrag would be worth having.
  6. No assertion that onDragStart is not re-fired when the tab data changes mid-drag; the tests only check draggingTab != null, which cannot distinguish "survived" from "restarted and re-started a fresh drag".

Minor test-robustness note: an index change mid-drag does not strand the drag (TabDragSurvivesTabChangeTest.kt:~122-130) derives the target bounds from the live pointer position and then asserts Reorder("panel-1", 1, 0). With Rect(x-1, …, x+100, …) the midpoint is x+49.5, so insert-before is comfortably determined — but the assertion is coupled to the (unshown) midpoint rule in updateDropTarget. If that rule ever changes to, say, first-third, this test fails for reasons unrelated to the regression it is pinning. Registering bounds at fixed coordinates and moving the pointer to them would be less entangled.


5. Regression surface of the deleted DisposableEffect

BossTabButton.kt:295-305 removed:

DisposableEffect(tabDragComponent, tabInfo?.id) {
    onDispose { if (tabDragComponent?.draggingTab?.tabInfo?.id == tabInfo?.id) tabDragComponent?.cancelDrag() }
}

Removing this is right — it matched on tab id, so any duplicate node for the same tab (a chip and a button, or the same tab mirrored in two panels) could cancel a drag it never owned, which the two disposing an idle ... copy tests now guard against. The replacement cleanup is the finally in detectTabDragGestures, which runs on node detach, and disposing the owner clears the ghost covers that. The one path I cannot verify from the diff: when isDragEnabled flips to false mid-drag (e.g. tabIndex goes negative or tabInfo goes null), the .then(if (isDragEnabled) … else Modifier) branch at BossTabButton.kt:396 swaps the pointer node out. That should detach the node and run the finally, but no test exercises it, and it is the exact case the deleted effect used to backstop.


Security

Nothing security-relevant in this diff: no I/O, no deserialisation, no privilege or trust boundary is crossed. The only new external-ish input is the tab index read at release, covered in §2 as a correctness concern.

@kshivang
kshivang merged commit 4eef0ea 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.