fix(lan,settings): cut in-flight sockets on device revoke or repo change,...
Brought to you by:
thebguy
Originally created by: theBGuy
This change hardens the LAN companion device security model by actively terminating any in-flight WebSocket monitor connections whenever sharing is disabled, the shared repo is changed, or a device is revoked. It also ensures that the list of revocable devices is always visible in the settings, even when sharing is off, so users can review and revoke access at any time.
monitor_cut broadcast channel to src-tauri/src/lan/mod.rs, src-tauri/src/lan/auth.rs, and src-tauri/src/lan/server.rs. This channel is fired on:monitor_cut.send(()) only on actual state changes (set_active_repo), so healthy sockets aren't severed on repeats.src-tauri/src/lan/mod.rs so that on successful device revoke, all in-flight monitor connections are forcibly closed.src-tauri/src/lan/auth.rs to atomically verify then consume pairing sessions, closing a rare double-mint race on concurrent submissions.src-tauri/src/lan/mod.rs to verify and document these lifecycle events.src-tauri/src/lan/routes/reviews.rs, updates the monitor stream upgrade logic so each socket also listens for the monitor_cut signal, closing itself when triggered (on repo switch, sharing off, or revoke).src/features/settings/CompanionSection.tsx so the list of paired devices is always visible and up to date, regardless of whether sharing is currently enabled (useLanDevices({ enabled: true })).
Originally posted by: cloudflare-workers-and-pages[bot]
Deploying gitdesktop with
Cloudflare Pages
74fb504View logs
Originally posted by: theBGuy
🤖 GitDesktop AI security audit ·
opus· automatedNo security issues found. The changes are defensive hardening — atomic verify-and-consume of the pairing session (closing a double-mint token race, fail-closed on persist failure), a broadcast cut signal that severs in-flight WebSocket monitors on disable/repo-change/device-revoke (closing the "auth only at upgrade time" gap), and a local-only frontend change to always show the revocable device list. No new attacker-controlled path to a sensitive sink is introduced.
Posted by GitDesktop — AI output, verify before acting on it.
Originally posted by: theBGuy
🤖 GitDesktop AI review ·
opus· automatedThis is a well-reasoned security-hardening change: the
monitor_cutbroadcast cleanly severs hijacked WebSockets on every lifecycle transition (disable, mode-switch rebind, real repo change, revoke), the change-detection inset_active_repocorrectly avoids severing healthy streams on no-op re-pushes, and thepair_submitatomic verify-then-consume genuinely closes the double-mint race. The reasoning that a reconnecting phone won't be re-cut by a stale signal (a freshsubscribe()only sees sends after it) holds. No blockers — the design is sound and the new unit tests are on point.Tests
src-tauri/src/lan/routes/reviews.rs,forward_streamcut branch. The central behavior of this PR — an in-flight socket actually closing on a cut — is verified only "by inspection" (per the NOTE atmod.rs:885). The tests cover that the desktop fires the cut (set_active_repo_cuts_monitors_only_on_change,revoke_device_cuts_monitors_on_success), but not that the pump reacts. The documented reason (theWebSocketUpgradeextractor rejects a non-upgrade GET undertower::oneshot) is real, but it only blocks the in-memory router path, not an end-to-end one:server::startbinds a real listener, so a#[tokio::test]could enable the server, pair, open a real WS to a stream viatokio-tungstenite, fire a revoke/disable, and assert the client receives aClose. For a change whose whole point is severing sockets, that end-to-end assertion is worth adding.Edge cases
nit —
src-tauri/src/lan/auth.rs,pair_submit. Consuming the session inside the lock beforepersist_devicechanges the failure semantics: previously apersist_deviceerror (e.g. a transient disk-write failure onlan-devices.json) left the session live so the phone could resubmit the same proof and succeed; now the session is alreadytake()n, so that same disk error mints nothing and kills the session — the user must generate a fresh PIN via "Start again". The security rationale is sound for the concurrent case, but on a single-submission persist failure there's no racer, so this is stricter than the race strictly requires. The comment documents the choice, so if that's intended, fine — flagging only so it's a conscious call.nit —
src-tauri/src/lan/mod.rs,revoke_device. The cut is global, so revoking device A also severs device B's healthy live monitor, forcing B to reconnect. This is explicitly documented as deferred ("per-device cut tracking is deferred") and is acceptable for v1; noting it only because with several paired phones it's a visible blip for innocent devices.Posted by GitDesktop — AI output, verify before acting on it.
Originally posted by: theBGuy
Context: this PR carries the round-6 review fixes from [#70] (slice 1 was squash-merged into
epic/lan-companionmid-review-round, so the remaining fixes land here instead of on that thread). Dispositions for the round-6 findings, verified against the source before fixing:Live WebSocket monitors survived Stop-sharing (should-fix, accepted + fixed here). Confirmed:
forward_streamheld only the broadcast receiver — an accepted socket outlivedlan_disable(upgraded sockets are hijacked from the serve loop) and any repo switch. Fix: amonitor_cutbroadcast onLanState, subscribed per-socket before the upgrade and selected on in the pump — any recv result severs the socket with a Close frame. It fires on disable, the enable mode-switch rebind, an actual active-repo change (compared before overwrite; the App effect re-pushes the same value freely), and — one deliberate addition beyond the finding — device revoke: bearer auth runs at upgrade time only, so a revoked device's live socket was the same hole class. The revoke cut is coarse (severs all monitors; still-valid phones reconnect and re-authorize) — per-device connection tracking is deliberately deferred. Tests pin the fire points (change fires / same-value doesn't / revoke-success fires / revoke-error doesn't); the select branch itself isn't reachable viatower::oneshot(WebSocketUpgrade rejects non-upgrade requests during extraction — same constraint documented for the round-5 scoping tests).pair_submitdouble-mint race (should-fix, accepted + fixed here). Confirmed the structure matched the finding: proof verified after the pairing lock released, session cleared in a second acquisition after minting. Now the session check, proof computation,constant_time_eq, andguard.take()(consumption) are one atomic locked block, with mint + persist outside the lock — a racing duplicate submission getspairingInactive. Fail-closed on the rare persist error: the consumed session is not restored (the phone re-pairs via "Start again"); a comment states that's deliberate. Rate-limit bookkeeping unchanged on every path. Test: replaying the same correct proof after a successful pair now 403s and device count stays 1.Paired devices invisible while sharing is off (nit, accepted + fixed here). The device list was gated on sharing being enabled, while tokens persist — standing access the user couldn't see or revoke, contradicting the help guide's "revoke any of them at any time." The list and revoke flow now render regardless of sharing state (the device store is a local read; no server needed), with a muted note when sharing is off. The guide's claim is now true as written.
README/site docs-sync — the recorded decision stands (fifth raise, see [#70] rounds 1/3/4/5): public docs land with the final epic→master PR; docs written against this slice would describe a bundle-less preview and be rewritten when the actual companion app ships in a later slice.
Gates on this PR's content:
cargo test754/754 (three new tests),clippy --all-targets -- -D warningsclean,tsc -bclean, touched frontend file format-verified as CI sees it. The changelog fragment amendment in this PR reflects behavior this PR made true (device management while sharing is off; live disconnect on stop/switch/revoke).Posted by GitDesktop — automated agent comment, verify before acting on it.
Related
Tickets:
#70Originally posted by: theBGuy
Round-1 triage — one accept (fixed), two design choices confirmed on the record. Fixes land in the next push.
End-to-end cut test — accepted, added. The critique was fair: the PR's central behavior was inspection-verified only, and the suggested technique works. New
live_websocket_monitor_is_severed_by_the_cut_signal(src-tauri/src/lan/mod.rs): binds a real listener viaserver::start(loopback; the port scan absorbs a busy default port), seeds a paired device and connects a real WebSocket with its bearer (tokio-tungstenite, added as a dev-dependency, version cargo-resolved), first proves the pump is live (oneReviewEventthrough the registry → one Text frame with its JSON), then firesmonitor_cutand asserts the next thing the client sees is a Close/stream-end — never another Text frame (the tolerance covers stacks that surface a server close as stream-end; it cannot mask a still-forwarding socket). Every receive is bounded by a 5s timeout so the test fails rather than hangs. Ran twice — no flakiness. The NOTE block claiming inspection-only coverage is updated.pair_submitfail-closed on persist failure — intended, confirmed. Consuming the session before persist was the deliberate call (as the in-code comment records): never leave a session live after its proof has been accepted once. The cost — a rare transient disk error forcing one "Start again" — is the right trade against a session that has already had a correct proof observed remaining redeemable. No change.Global revoke cut — intended for v1, confirmed. Per-device connection tracking is deliberately deferred (documented at the fire point); an innocent phone's monitor reconnects and re-authorizes in one round-trip. If multi-device usage materializes in the companion-app slice, per-device tracking rides along there.
Gates after the round:
cargo test755/755 (the E2E test included, run twice),clippy --all-targets -- -D warningsclean,tsc -bclean, the touched frontend file format-verified as CI sees it.Posted by GitDesktop — automated agent comment, verify before acting on it.
Originally posted by: theBGuy
🤖 GitDesktop AI review ·
opus· automatedThis is a well-targeted security-hardening change:
monitor_cutis subscribed per-socket before upgrade and selected on in the pump, so in-flight WebSocket monitors are severed on disable, mode-switch rebind, a real active-repo change, and device revoke;pair_submitnow verifies-and-consumes the session under one lock to close the double-mint race; and the settings panel always shows the revocable device list with a skeleton to avoid an empty-state flash. The design is sound, the change detection correctly avoids cutting healthy streams on no-op re-pushes, and reconnecting phones won't be re-cut by a stale signal (a freshsubscribe()only sees later sends). No blockers.Resolved since last review
live_websocket_monitor_is_severed_by_the_cut_signalinmod.rsbinds a real listener viaserver::start, connects a realtokio-tungsteniteWebSocket with a paired-device bearer, proves the pump forwards one event, then firesmonitor_cutand asserts the client sees aClose/stream-end and never anotherText. The cut half offorward_stream'sselect!is now genuinely exercised end-to-end, and the NOTE block is updated to match.CompanionSection.tsx:169(pending query →deviceList[]→ misleading "No devices paired" copy). Resolved: the newdevices.isPendingbranch renders aSkeletonahead of the empty-state ternary.Confirmed-deliberate (not re-raising)
auth.rspair_submit— consuming the session beforepersist_devicemeans a transientlan-devices.jsonwrite failure now kills the session (forcing "Start again") even with no racer present. This is documented in the code comment as an intentional fail-closed choice; noting only that the previous nit still describes real behavior, and the call is now explicit.mod.rsrevoke_device— the cut is global, so revoking device A also severs device B's healthy monitor. Documented as deliberately coarse ("per-device cut tracking is deferred"); acceptable for v1.Nits
src-tauri/Cargo.toml,[dev-dependencies].tokio-tungstenite = "0.30.0"resolves to a second copy alongside the0.29.0that axum already pulls in (visible in theCargo.lockdiff — twotokio-tungstenite,tungstenite, andsha1entries), so the test build compiles both trees. It's dev-only and doesn't touch the release binary, but pinning the dev-dep to0.29would reuse axum's copy; the test's APIs (IntoClientRequest,Message,connect_async,StreamExt) exist in both. Purely optional.Posted by GitDesktop — AI output, verify before acting on it.
Originally posted by: theBGuy
Round-2 triage — the single nit is confirmed and applied; everything else in the round closed prior items.
Duplicate
tungstenitetree — accepted, fixed in the next push (applied directly as an orchestrator trivial fix, disclosed): verified inCargo.lockthattokio-tungstenite0.30 had landed alongside the 0.29 axum's ws feature already pulls (duplicatingtungsteniteandsha1too). The dev-dependency is now pinned"0.29"with a comment explaining the deliberate not-latest choice; the lock resolves to a singletokio-tungstenite/tungstenite/sha1tree, and the full suite (755/755, E2E cut test included) plusclippy --all-targets -- -D warningspass against 0.29's APIs — exactly as the finding predicted.With that, every item from both rounds is fixed, confirmed-deliberate, or closed on the record.
Posted by GitDesktop — automated agent comment, verify before acting on it.
Originally posted by: theBGuy
🤖 GitDesktop AI review ·
opus· automatedThis is a sound, well-scoped security-hardening change:
monitor_cutis subscribed per-socket before upgrade and selected on inforward_stream(which I confirmed breaks the loop and sends aCloseframe on any cut result), so in-flight monitors are severed on disable, mode-switch rebind, a real active-repo change, and device revoke;pair_submitnow verifies-and-consumes the session under one lock; and the settings panel always renders the revocable device list with a skeleton to avoid an empty-state flash. No blockers.Resolved since last review
tokio-tungstenitedev-dep tree (previous nit) is fixed:Cargo.tomlnow pinstokio-tungstenite = "0.29"with an explanatory comment, and theCargo.lockdiff collapses to a singletokio-tungstenite/tungstenite/sha1tree (the0.30/0.11.0entries are removed). Confirmed in the current diff.Documentation / conventions
src/features/help/content.ts, "Managing paired devices" section (~lines 1559–1564). This PR meaningfully alters a user-facing surface — the paired-device list is now visible and revocable while sharing is off (previously the query was gated onenabled), and connected phones are now actively disconnected on stop/switch/revoke — but the in-app guide wasn't updated to match. The section still reads as if managing devices happens in the context of an active session and doesn't mention that the list persists while sharing is off or that live connections are cut immediately. The changelog fragment was updated but the guide (a hard "same-change" convention here) was not. Concrete fix: add a sentence to "Managing paired devices" noting the list stays visible/revocable even when sharing is off (mirroring the new in-panel "These devices can connect the next time sharing is on." copy), and that stopping sharing / switching repos / revoking severs any live phone connection right away.Edge cases
src/features/settings/CompanionSection.tsx, device-list render (lines 166–175). Thedevicesquery is now always enabled, so its error path is reachable in more states than before, yet there's no error branch: iflanDevicesListrejects,devices.isPendingisfalseanddeviceListis[], so the empty-state copy ("Turn on sharing to pair a device." / "No devices paired yet…") renders even though paired devices may exist and the fetch simply failed. The component already tracks anerrorstate, but only for the enable/disable/revoke mutations. Impact is low (the list reads a local JSON file that rarely errors), so optional — but adevices.isErrorbranch showing a short "Couldn't load paired devices." would avoid the misleading copy.Copilot's only remaining flag (the
:169empty-state flash) is genuinely resolved by the newdevices.isPending→Skeletonbranch; not re-raising it.Posted by GitDesktop — AI output, verify before acting on it.
Originally posted by: theBGuy
Round-3 triage — both items accepted and applied (orchestrator-applied small fixes, disclosed; they land in the next push).
Help guide out of sync with the new device-management behavior — accepted, fixed. Fair catch, and correctly distinguished from the README/site epic deferral: the in-app guide is a same-change convention, and this PR did alter the user-facing surface. "Managing paired devices" now states that the list stays visible even while sharing is off (pairings persist across sharing sessions; mirrors the in-panel copy) and that stopping sharing, switching repos, or revoking a device disconnects any live phone immediately. The guide, the in-panel note, and the changelog fragment now describe the same behavior.
devices.isErrorbranch — accepted, fixed. A failed read is not "no devices": anisErrorarm now renders "Couldn't load paired devices." ahead of the empty-state ternary, so the misleading copy can't appear on a fetch failure. (Kept as muted text like the sibling states; the pending-Skeleton and empty-state branches are unchanged.)Verification:
tsc -bclean; both touched files format-verified as CI sees them (LF-normalized biome check, byte-identical).Posted by GitDesktop — automated agent comment, verify before acting on it.
Originally posted by: theBGuy
🤖 GitDesktop AI review ·
opus· automatedBoth files check out against the current diff. Here's my assessment.
This is a sound, well-scoped security-hardening change: a
monitor_cutbroadcast is subscribed per-socket before upgrade and selected on inforward_stream(any recv result breaks the loop and sends aClose), so in-flight monitors are severed on disable, mode-switch rebind, a real active-repo change, and device revoke;pair_submitverifies-and-consumes the session under one lock (closing the double-mint race, fail-closed on persist error); and the settings panel now always renders the revocable device list withisPending/isError/empty branches. No blockers, and the change is well tested (fire-point unit tests plus a real-socket end-to-end cut test).Resolved since last review
src/features/help/content.ts"Managing paired devices" now states the list stays visible/revocable while sharing is off and that stopping sharing / switching repos / revoking disconnects a live phone immediately, matching the in-panel copy and changelog fragment.devices.isErrorbranch missing (previous nit) — resolved.CompanionSection.tsxnow renders "Couldn't load paired devices." ondevices.isErrorahead of the empty-state ternary, so a failed read no longer masquerades as "no devices.":169is genuinely resolved by the newdevices.isPending→Skeletonbranch; not re-raising.Concurrency
src-tauri/src/lan/routes/reviews.rs,forward_streamtokio::select!(line 116). Theselect!has nobiased;, so when amonitor_cutrecv and aReviewEventrecv are both ready at the top of a loop iteration, tokio picks a branch at random — meaning a just-revoked/disabled socket can forward one moreTextframe before it honors the cut. Impact is negligible (at most one already-buffered event to a device that's being torn down anyway, and the test doesn't exercise a simultaneously-pending event so it stays green), but since the cut is framed as a security sever — especially for revoke — addingbiased;and keeping the cut branch first makes the cut take priority on the common "both ready" case. Optional.Posted by GitDesktop — AI output, verify before acting on it.
Originally posted by: theBGuy
Round-4 triage — the one optional nit is accepted and applied (orchestrator trivial fix, disclosed; lands in the next push).
biased;inforward_stream'sselect!— accepted. The cut branch was already first in the block, sobiased;gives the sever strict priority on the both-ready case — a just-revoked or just-disabled socket never forwards one more buffered frame before honoring the cut. Worth taking despite "optional": for the revoke path specifically, the cut is a security sever and one-frame leakage was the only remaining slack. No starvation concern — each loop iteration re-polls the cut first, and event forwarding proceeds normally when no cut is pending. Verified:cargo test755/755 (E2E cut test included),clippy --all-targets -- -D warningsclean.With that, every finding across [#70]'s six rounds and [#72]'s four is fixed, confirmed-deliberate, or closed on the record.
Posted by GitDesktop — automated agent comment, verify before acting on it.
Related
Tickets:
#70Tickets:
#72Originally posted by: theBGuy
🤖 GitDesktop AI review ·
opus· automatedThis PR forcibly severs already-upgraded WebSocket monitor sockets when the LAN companion's lifecycle state changes (disable, mode-switch rebind, real repo change, device revoke), makes
pair_submitverify-and-consume the pairing session atomically, and keeps the paired-device list visible/revocable while sharing is off. Having readforward_stream,useLanDevices, and the settings panel against the diff, the change is sound and I see no blockers or should-fixes.Resolved since last review
biased;inforward_stream'sselect!(previous concurrency nit) — resolved.reviews.rs:120now hasbiased;with the cut branch first, so on a both-ready iteration the sever wins over forwarding one more buffered frame. I confirmed the loopbreakfalls through tolet _ = socket.send(Message::Close(None)).await;at line 174, which grounds the E2E test's Close-frame expectation.content.ts"Managing paired devices" now states the list stays visible/revocable while sharing is off and that stop-sharing / repo-switch / revoke disconnects a live phone, matching the panel copy and changelog fragment.devices.isErrorbranch missing and empty-state flash on first fetch (Copilot:169, previous nit) — both resolved.CompanionSection.tsxnow renders aSkeletonondevices.isPendingand "Couldn't load paired devices." ondevices.isErrorahead of the empty-state ternary, so neither the initial pending fetch nor a read failure masquerades as "no devices." NoteuseLanDevicesdefaultsenabled ?? true/refetchInterval ?? false, so{ enabled: true }fetches once without introducing polling.Correctness
The two substantive backend changes hold up:
auth.rspair_submit— the compare andguard.take()now happen in one critical section, so a second concurrent submission with the same proof finds the session already consumed and hitspairingInactiverather than minting a second token from one PIN. The new sequential replay test asserts 403/pairingInactiveand exactly one device, which pins the race closure. Fail-closed-on-persist-error is deliberate and documented.mod.rsset_active_repocompares before overwrite and only fires the cut on a real change, so the App effect re-pushing the samerepoPatheach render won't sever a healthy stream;revoke_devicefires only onOk. Both are directly unit-tested for fire/no-fire, and the E2E test exercises the real upgraded-socket cut path theoneshottests can't reach.No other issues found; the broadcast capacity (4), lagged-receiver-still-terminates handling, and the subscribe-before-upgrade ordering are all reasoned correctly.
Posted by GitDesktop — AI output, verify before acting on it.
Ticket changed by: theBGuy