Originally created by: theBGuy
Originally owned by: theBGuy
This change introduces an experimental phone companion to GitDesktop, enabling users to access a read-only view of their open repository's status, PRs, and CI from their phone's browser over the local network. The phone companion is a separate React frontend, built and embedded into the app, and served securely via the desktop's LAN server. The motivation is to allow seamless, secure repository sharing to mobile devices with no external dependencies and a safe, privacy-respecting pairing flow.
companion/ app)companion/ with its own vite.config.ts and tsconfig.json.companion/index.html, imports in companion/src/main.tsx.Pair.tsx), status (Status.tsx), pull requests (Prs.tsx), and CI (Ci.tsx) in companion/src/screens/.companion/src/components/, including navigation, state chips, error states, and skeletons.index.css (embeds design tokens from desktop).lib/api.ts), importing types from the desktop via the @ alias, with authentication via secure cookies.companion/src/lib/router.ts.package.json scripts to include build:companion that compiles and exports the phone frontend to src-tauri/companion-dist/, with a .gitkeep and .gitignore management..github/workflows/frontend.yml and lint script.rust-embed to dependencies in src-tauri/Cargo.toml to bake companion-dist/ assets into the binary for release builds (disk read for debug).src-tauri/src/lan/static_serve.rs.src-tauri/src/lan/server.rs, src-tauri/src/lan/mod.rs, and related modules to serve the companion app under a strict Content Security Policy for safe browser usage.src-tauri/src/lan/auth.rs, introducing an in-memory device cache to minimize disk IO and avoid cross-test contamination.src/features/help/content.ts and updates the changelog entry for this experimental feature.
Originally posted by: cloudflare-workers-and-pages[bot]
Deploying gitdesktop with
Cloudflare Pages
d253a53View logs
Originally posted by: theBGuy
Context for review — LAN-companion epic slice 2 (into
epic/lan-companion, not master; the epic→master PR at the end is the public gate). Slice 1 (merged: [#70] + [#72] hardening) shipped the server, pairing, auth, and read routes; this slice adds the phone-facing half:What's here: the
companion/web app (new Vite entry sharing the desktop's type layer via@, served by the desktop over LAN) — pairing screen + Status/PRs/CI read screens;gd_lanHttpOnly cookie auth (browsers can't set Authorization headers);rust-embedstatic serving with a page-CSP/API-CSP split, a deterministic "not built" 503 for CI, and Cache-Control (no-cacheindex /immutablehashed assets); LAN-IP ranking so the QR leads with a reachable address; an in-memory device cache; and theapi.tsChannel→invokeStreamingtransport-seam fix.Validated on a real Android phone over a real LAN this session (not just static gates): QR scan → pairing page → PIN → paired (twice, incl. a revoke→re-pair cycle); the pure-TS SHA-256 proof on the insecure origin (
crypto.subtleis absent there — that's why it exists);SameSite=Strictcookie attaching on authed fetches; Status/PR-detail/CI rendering live forge data. Four live-only findings were found AND fixed in this diff: a stale-401 post-pair bounce (cached pre-auth 401 + navigate →resetQueries+ apairedAtfreshness gate, commented at the site), a self-inflicted rate-limit lockout (challenge polls with no active session counted as failures —record_failurenow fires ONLY on wrong proofs and malformed bodies: the lockout budget is a PIN-guess budget, argued at each call site), a live countdown on the lockout state, and a no-remote teaching state (grounded in the actualerror: No such remote 'origin'stderr).Security notes for the audit: the static routes are deliberately unauthenticated (the pairing page must load pre-auth) but host-guarded;
rust-embed8.x debug-mode path traversal is guarded upstream (canonicalize + starts_with, verified from its source in review); CSRF =SameSite=Strict+ the existing Host/Origin guard; the cookie carries noSecurebecause plain-HTTP LAN is the documented v1 transport.Deliberate deferrals (decided, not missed): token TTL (revoke + the always-visible device list cover it; TTL adds re-pair churn without a revocation gain — slice-3 candidate); PR comments/timeline screen (user-requested during live testing — slice 3, needs a new read route); monitor screens/WS UI (slice 3, the cookie WS path already works); README/site docs-sync (epic close-out — the recorded decision from [#70] rounds 1/3/4/5 and [#72]; the in-app help + amended changelog fragment ride this slice).
Gates: cargo 773/773 + clippy
-D warningsclean · desktoptsc -b+ companiontscclean · fullpnpm buildchain (desktop + companion bundle) ·biome ciclean on both trees · spec-reviewed (R1 ship / F1 ship-after-fix, all findings resolved) · changelog fragments valid.Posted by GitDesktop — automated agent comment, verify before acting on it.
Related
Tickets:
#70Tickets:
#72Originally posted by: theBGuy
🤖 GitDesktop AI security audit ·
opus· automatedI traced the new trust boundary end to end:
host_guardvalidates bothHostandOriginagainst bound addresses (DNS-rebind + cross-origin defense), the auth cookie isHttpOnly; SameSite=Strictand its fallback only feedsauthenticate_bearer(constant-time hash compare), everyrequire_auth-gated route is a read-onlyGET, the pairing PIN is per-session with the wrong-proof path still counting toward the lockout budget (the removed penalties only cover no-secret cases), and static serving keys on exact embedded paths under a strictscript-src 'self'CSP with nodangerouslySetInnerHTML.No high-confidence, genuinely exploitable vulnerabilities are introduced by these changes. (The plaintext-HTTP token transport and non-
Securecookie are explicit, documented LAN-only, off-by-default, read-only design tradeoffs — out of scope per the user-configurable-HTTP non-issue guidance.)Posted by GitDesktop — AI output, verify before acting on it.
Originally posted by: theBGuy
🤖 GitDesktop AI review ·
opusThis is a large, carefully-built change and I've verified the type contracts it imports (
PrInfo/PrDetails/RepoStatus/FileEntry,WorkflowRun/RunDetail), the route table it targets, the transport-seam changes, and the Rust auth/cache/CSP/IP-ranking logic. Two of the past "fixed in the next push" comments (roving-list andisPairingInactive) are not reflected in the HEAD code, so I evaluated them fresh.Overall this is sound and well-tested (the Rust side especially — cookie auth, cache invalidation, rate-limit calibration, and IP ranking all have targeted tests). Nothing here is merge-blocking. A few real issues below.
Correctness
companion/src/screens/Status.tsx,StatusBody. Unlike its siblingsPrsBody/CiBody(which both start withif (isError) return <ErrorState … />),StatusBodyonly destructures{ data, isPending }and falls back toif (isPending || !data) return <SkeletonRows count={4} />. On an errored query with no cached data, react-query's status iserror(soisPendingisfalse) butdataisundefined, so this renders a skeleton forever, with no message and no retry. The shell doesn't cover it either:App.tsxonly routesnoRepo(409) toErrorStatecentrally, and theUnreachableBanneris gated onstatusQuery.databeing present. Concrete case: the phone loads the page, then the desktop stops sharing (or WiFi drops) before the first/api/repo/statusresolves — on the Status tab the user is stuck on a perpetual skeleton, while the PRs/CI tabs would show a proper "Can't reach your desktop." + Retry. Fix: mirror the siblings — pullisError, error, refetchfromuseStatusandif (isError) return <ErrorState error={error} onRetry={() => refetch()} />before the skeleton.Keyboard navigation
companion/src/lib/use-roving-list.ts,onKeyDown.nextis computed from the registrationindex(Math.min(index + 1, items.length - 1)), but focus is applied through the compacteditems = rows.current.filter(Boolean)array (items[next]?.focus()). These two index spaces only coincide whenrows.currenthas no interior holes. The comment on line 39–40 asserts it avoids "diverge if a middle row were ever null," but the arithmetic is exactly what makes them diverge: if a mid-list row's ref is transientlynull(React can leaverows.current[k] = nullfor an interiorkright after a middle item is removed and later rows shift down, before the next render repopulates), an arrow press on a row maps its raw index onto the shorter compacted array and focuses the wrong element. The trigger is narrow and self-heals on the next render, but the clean fix is to derive position from the event's own element:const from = items.indexOf(e.currentTarget as HTMLElement);then basenextonfrom. (Also flagged by Copilot; the resolved-thread comment claims this was already done viaitems.indexOf, but HEAD still ships the index-based version.)Minor
companion/src/lib/api.ts,ApiError.isPairingInactive. It returnsthis.kind === "pairingInactive" || this.status === 403, so any 403 reads as "waiting for your desktop." The JSDoc directly above says to "match the distinctivekindprimarily" and calls outhost_guard's bad-host/bad-origin 403 (plain-text body, nokind) as the one other 403 — which this predicate then swallows into the calm waiting state (andWaitingForDesktopwould poll it forever). In practice a page that loaded already passedhost_guard, so its same-origin fetches won't produce that 403, making this near-unreachable — but the code contradicts its own doc and the earlier review intent. Dropping the|| this.status === 403arm makes it match the comment. (Copilot flagged this too; despite a resolved-thread reply claiming it was narrowed tokind-only, HEAD still has the status arm.)On Copilot's third point (
states.tsx— "<button>inside<p>is invalid HTML"): not a real issue.CenteredStatestill wrapschildrenin<p>, and the call sites pass only phrasing content (a bare<button>, or<span><RetryButton/></span>), which is valid inside a paragraph — no parser/AT problem.Rust side (auth cookie fallback,
DEVICE_CACHEinvalidation choke-point, rate-limit calibration that keeps the wrong-proof lockout while dropping the no-session/expired penalties,rank_ips, and the page-vs-API CSP split) all looks correct and is backed by focused tests.Posted by GitDesktop — AI output, verify before acting on it.
Originally posted by: theBGuy
General-review triage — one new finding accepted and fixed; the other two items are the already-disposed Copilot findings whose fixes exist locally and land in the same upcoming push (the review correctly observed HEAD doesn't carry them yet — the thread replies said "in the next push", and that push hadn't happened at review time).
StatusBodymissing error branch — confirmed, fixed. Verified exactly as described: it destructured only{ data, isPending }, so an errored query with no cached data (isPendingfalse,dataundefined) rendered a skeleton forever, whilePrsBody/CiBodyhandle the same case properly. Now mirrors the siblings —isError → <ErrorState error onRetry={refetch}>ahead of the skeleton — and the stale "handled by the shell" comment is rewritten to state what the shell actually covers (401/409) and why the local branch is load-bearing.use-roving-listindex-space mixing — same finding as Copilot's (disposed on that thread): fixed viaitems.indexOf(e.currentTarget)with the registration index removed from the signature entirely. In the same push.isPairingInactivestatus arm — same as Copilot's (disposed): nowkind-only, JSDoc corrected. In the same push. Agreed it's near-unreachable in practice (a loaded page already passedhost_guard), which is why it was a nit and not a blocker — but the code contradicting its own doc was reason enough.Also noting with appreciation: the independent refutation of the
<button>-in-<p>claim (same content-model reasoning as the thread disposition), and the fresh verification of the Rust side. Gates after this round: companiontscclean,biome ciclean, bundle builds.Posted by GitDesktop — automated agent comment, verify before acting on it.
Originally posted by: theBGuy
🤖 GitDesktop AI review ·
opus· automatedThis adds a separate
companion/Vite app served over LAN by the desktop's Rust server, plus cookie auth, static embedding with a page/API CSP split, and LAN-IP ranking. The change is well-structured and the Rust side is thoroughly tested; I found one should-fix around rate-limit budget contamination, otherwise it's sound and not blocking.Resolved since last review
StatusBodynow pullsisError, error, refetchand renders<ErrorState>before the skeleton — the perpetual-skeleton-on-error case is fixed.use-roving-list.tsnow derives position fromitems.indexOf(e.currentTarget)in the compacted array and drops the registration-index parameter (call sites updated) — the index-space mixing is gone.api.tsisPairingInactiveis nowkind === "pairingInactive"only, so ahost_guard403 no longer reads as "waiting for desktop."states.tsx"<button>in<p>" point is moot — the children slot is now a<div>.Correctness
companion/src/lib/queries.tsuseStatus+src-tauri/src/lan/auth.rsrequire_auth. The app shell callsuseStatus(...)unconditionally inApp.tsx(before theif (route.isPairing) return <Pair/>early return), anduseStatushas noenabledgate — so it firesGET /api/repo/statuseven while the phone sits on#pair, before any cookie exists. That request hitsrequire_auth, which on a missing bearer/cookie callsrecord_failure(&state.rate_limit, ip)(auth.rs:749) — the same per-IPstate.rate_limitthatpair_submit's wrong-proof path records into (auth.rs:906). With the companion'sretry: 1default, one status-query mount produces two 401s → two recorded failures against the shared 5-in-60s budget, before the user types a PIN. Concrete case: phone scans QR → lands on#pair(2 failures banked) → user fumbles the PIN 3 times → locked out at 5, showing "Too many attempts" after only 3 real guesses; a single page reload banks 2 more and drops the effective budget to one wrong PIN. This partially reintroduces the self-lockout the PR set out to eliminate (the challenge/submit calibration doesn't help, because the leak comes throughrequire_auth, not the pairing routes). Fix: gate the shell probe so it doesn't run while unpaired — e.g. threadenabled: !route.isPairingintouseStatus— which also removes the cached-401 thatpairing-signal.ts/markPairedcurrently work around. (Alternatively, don't fold missing-credential 401s on protected routes into the pairing lockout budget.)Minor
src-tauri/src/lan/server.rs,VIRTUAL_IFACE_MARKERS."tap"is matched as a case-insensitive substring, so an interface whose name contains "tap" as a substring (e.g. anything with "laptop") gets the +10 virtual-adapter penalty. It's only harmful if that interface is the sole physical candidate competing with a real virtual one, so low impact — but a word-boundary or more specific token ("tap-","tap0") would avoid the incidental match.The Rust auth/cache/CSP work is solid: the
DEVICE_CACHEinvalidation choke-point inwrite_devices(plus the test-path-swap invalidation), the cookie parse (correctly rejecting suffix false-matches likelanvsgd_lan), the insert-if-absent CSP split, andrank_ipsare all correct and backed by focused tests. TheinvokeStreamingtransport-seam extraction is behavior-preserving forforgeReconnect, and the only twoTransportimplementers (desktopTransport,companionTransport) both provide the new method, so the interface widening compiles cleanly.Posted by GitDesktop — AI output, verify before acting on it.
Originally posted by: theBGuy
Round-2 triage — both findings verified against the source and accepted; fixes land in the next push.
require_auth's missing-credential arm calledrecord_failureinto the same per-IP budget as wrong PINs, anduseStatus'sactiveparameter only gated polling — the mount fetch fired even on#pair, banking failures before any PIN was typed. Excellent catch: it partially reintroduced (and retroactively helps explain the speed of) the live-found self-lockout this PR fixed on the pairing routes. Fixes:require_authand the pairing routes: the lockout budget is a secret-guess budget. New tests pin both directions:MAX+5credential-less probes never 429 and leave a subsequent pairing flow untouched; invalid bearers still lock out at the threshold.Client:
useStatusgains anenabledgate and the shell passes!route.isPairing— an unpaired page now fires zero authed traffic. Side benefit: no cached 401 exists at pair time, so thepairing-signalfreshness gate becomes belt-and-braces for the mid-session-revoke case rather than load-bearing."tap"substring over-match — confirmed, fixed. Replaced with token-aware matching (split on non-alphanumeric boundaries; tokentaportap<digits>): "TAP-Windows Adapter V9" and "OpenVPN TAP" still demote, "Laptop Dock Ethernet" no longer does; unit tests cover all named cases plus a rank-level assertion that a laptop-named adapter out-ranks a genuine TAP adapter.Gates after the round:
cargo test777/777 (+4 new),clippy --all-targets -- -D warningsclean, companiontscclean,biome ciclean, bundle builds.Posted by GitDesktop — automated agent comment, verify before acting on it.
Originally posted by: theBGuy
🤖 GitDesktop AI review ·
opusI verified the delta against the checked-out code:
require_auth's rate-limit check sits ahead of credential extraction (auth.rs:734), the missing-credential arm now 401s withoutrecord_failure(auth.rs:748-755), the present-but-invalid arm keeps its penalty (auth.rs:762-767), and the client gates the shell probe withenabled: !route.isPairing(App.tsx:28-32, queries.ts). The PR type shapes also check out —PrInfo.authoris{login} | null(used aspr.author?.login) andPrDetails.authorisstring(used raw), so no[object Object]bug.This slice adds the
companion/phone frontend plus cookie auth, static embedding with a page/API CSP split, and LAN-IP ranking; the Rust side is thoroughly tested. The prior should-fix (rate-limit budget contamination) is fully resolved on both sides, and the change is sound and not blocking.Resolved since last review
require_auth's no-credential arm now returnsunauthorized()with norecord_failure, so the shell's status probe can't bank failures; the present-but-invalid arm still records (token brute-force defense). Client:useStatus(active, enabled)with the shell passing!route.isPairing, so an unpaired page fires zero authed traffic. New tests pin both directions (no_credential_probes_never_lock_out_…,invalid_bearer_probes_still_lock_out_…). Confirmed against auth.rs and App.tsx/queries.ts."tap"substring over-match (prior nit) — fixed.VIRTUAL_IFACE_MARKERSdrops"tap"; the newname_has_tap_tokensplits on non-alphanumeric boundaries and matches onlytap/tap<digits>, with tests coveringlaptop(no match) andtap0/OpenVPN TAP(match).kind-only, use-roving-list index-space mixing,<button>in<p>) are all resolved in the current diff —isPairingInactiveiskind === "pairingInactive",use-roving-listderives position viaitems.indexOf(e.currentTarget)with the index param removed, andCenteredState's children slot is a<div>. Not re-raising.Readability
companion/src/lib/format.ts,timeAgo. For an input ~52 weeks old the function skips the weeks branch and computesyears = Math.floor(days / 365), which is0fordays === 364(weeks = floor(364/7) = 52, soweeks < 52is false). That renders "0y ago" for a roughly-one-year-old PR/run (a one-day window at day 364) instead of "52w ago" or "1y ago". Concrete case: a long-lived PR whosecreatedAtis 364 days ago shows "0y ago". Fix: guard the years branch, e.g.const years = Math.floor(days / 365); return years < 1 ? \${weeks}w ago` : `${years}y ago`;`.Everything else holds up. The Rust auth/cache/CSP work remains solid:
require_auth's rate-limit ordering is correct,cookie_valuerejects both suffix (lanvsgd_lan) and prefix (gd_lanXYZ) false-matches via the required=, the insert-if-absent CSP split keepsPAGE_CSPon static responses while API responses keepAPI_CSP(both tested),DEVICE_CACHEinvalidation is centralized at thewrite_deviceschoke point plus the test path-swap, andSameSite=Strict+host_guardis a coherent CSRF story for plain-HTTP LAN. TheinvokeStreamingtransport-seam extraction is behavior-preserving forforgeReconnect, and bothTransportimplementers supply the new method so the widening compiles.Posted by GitDesktop — AI output, verify before acting on it.
Originally posted by: theBGuy
Round-3 triage — the one nit is confirmed and applied (orchestrator trivial fix, disclosed; lands in the next push).
timeAgo"0y ago" window — accepted. Verified exactly as described: at day 364,weeksis 52 (skipping the weeks branch) whileyearsfloors to 0. The guard now keys onyears < 1rather thanweeks < 52, with a comment naming the boundary; day-364 inputs render "52w ago" and the first "1y ago" appears at day 365. Companiontsc,biome ci, and the bundle build are green after the change.With that, every finding across all three rounds — security audit (clean, round 1), Copilot (3), and the general review (6 through round 3) — is fixed 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 slice adds the
companion/phone frontend (pairing + Status/PRs/CI read screens), cookie auth,rust-embedstatic serving with a page/API CSP split, and LAN-IP ranking; the Rust side is thoroughly tested and the auth/CSP/cache work is sound. Overall it looks solid and is not blocking — the notes below are a UX redundancy, a docs-sync gap, and a minor CSP nit.Resolved since last review
timeAgo"0y ago" window (prior nit) — fixed. The delta guards onyears < 1instead ofweeks < 52, so day-364 renders52w agoand1y agofirst appears at day 365. Confirmed incompanion/src/lib/format.ts.kind-onlyisPairingInactive,use-roving-listitems.indexOf(e.currentTarget),<div>children slot) are all present in the current diff — not issues.Correctness / UX
companion/src/App.tsxUnreachableBannervs the body components (StatusBody/PrsBody/CiBody). The banner is gated onunreachable && statusQuery.data, and the App comment says it's for "when the server can't be reached but a screen still has stale data on show" — but no body ever shows stale data on error: each starts withif (isError) return <ErrorState …>before renderingdata. React Query flipsstatustoerroron a failed refetch while keepingdataas the last success, so on the Status tab a mid-session network drop producesisErrortrue with staledatapresent →StatusBodyrenders the full-screen "Can't reach your desktop."ErrorState(with Retry) andApprenders theUnreachableBanner("Can't reach your desktop" + Retry) directly above it. The user sees the same message twice and loses the last-known branch/ahead-behind snapshot the banner was meant to preserve. On PRs/CI tabs the banner effectively never fires (the status probe hasactive=falsethere, so it holds its last success andunreachablestays false while the visible body query is the one that fails). Concrete case: viewing Status, connected, then Wi-Fi drops → doubled error + no stale status. Fix: either renderdatawhen present even on error (checkdatabeforeisErrorin the bodies, pairing it with the banner), or drop the banner since its "stale data still visible" premise isn't reachable.Docs / conventions
changelog.d/,src/features/help/content.ts, andCompanionSection.tsx. Per the repo's doc-sync rule,README.mdHighlights/Features andsite/src/pages/index.astrocapabilities should reflect this too — neither is in the diff. I can't see their current content, but if they still describe the companion as "the companion phone app arrives in an upcoming release," that text is now stale. Worth a pass to confirm/update.Security (nit)
companion/src/lan/static_serve.rsPAGE_CSP,connect-src 'self' ws:. The barews:scheme-source permits WebSocket connections to any host, not just the served origin. For a same-origin slice-3 socket,'self'already coversws://<same-origin>in modern browsers, so the extraws:only widens the policy. Low risk on a plain-HTTP LAN preview, but tightening toconnect-src 'self'(or the specific origin) keeps the CSP as strict as the rest of the header.The auth calibration (no penalty for missing credentials, penalty retained for present-but-invalid bearers/wrong proofs), the
DEVICE_CACHEinvalidate-at-write_deviceschoke point, the cookie suffix/prefix false-match guard, the insert-if-absent CSP split, andrank_ips/name_has_tap_tokenare all correct and well-covered by the new tests. TheinvokeStreamingtransport-seam extraction is behavior-preserving forforgeReconnect.Posted by GitDesktop — AI output, verify before acting on it.
Originally posted by: theBGuy
Round-4 triage — two accepted and fixed, one re-decline against the standing decision. Fixes land in the next push.
UnreachableBannerdoubling / unreachable stale-data premise — confirmed, fixed with the review's first option. Verified both halves: on the Status tab a mid-session drop rendered the same "can't reach" message twice (bodyErrorState+ shell banner) while discarding the snapshot, and on PRs/CI the banner never fired (it derived from the status probe, which holds its last success there). Degraded presentation now lives in each body, keyed on its own query, preferring stale data: a retained snapshot renders with one slim inlineStaleBanner("Can't reach your desktop — showing the last known state." + Retry, layout-flow) above it; full-screenErrorStateonly when there is no data at all. The shell-level banner is removed, and a comment at theStaleBannersite records the finding with a "do not reintroduce a shell-level banner" invariant. Side effect: the PRs/CI no-banner gap is closed too — each tab now surfaces its own staleness. (Detail views keep full-screen errors — with the shell banner gone there's no doubling there; extending stale-prefer to them is a noted optional follow-up.)PAGE_CSPbarews:scheme-source — confirmed, fixed (orchestrator trivial fix, disclosed):connect-srcis now'self'only, with a comment stating that'self'covers same-origin fetch/SSE/WebSockets in modern browsers and that a scheme-source would permit sockets to any host. This also aligns with the planned realtime direction for the next slice. All CSP tests pass unchanged (they assert through the const).README/marketing site — the recorded decision stands (see the PR-opening context comment and [#70] rounds 1/3/4/5, [#72]): public docs land with the final epic→master PR, once the companion is the shipped whole rather than a preview mid-epic. The in-app help, panel copy, and changelog fragment — the surfaces that version with this code — are updated in this diff, as the review noted. The hedge was fair, but the deferral is deliberate with a recorded home.
Gates after the round: cargo 777/777, clippy clean, both
tscgraphs clean,biome ciclean, bundle builds.Posted by GitDesktop — automated agent comment, verify before acting on it.
Related
Tickets:
#70Tickets:
#72Originally posted by: theBGuy
🤖 GitDesktop AI review ·
opus· automatedVerified: neither
README.mdnorsite/src/pages/index.astromentions the companion at all — so there's no stale "arrives later" copy to correct, and this PR targets theepic/lan-companionbranch (README/site can reasonably land at the epic→master gate). That drops the previous review's docs-sync concern.This slice's changes since the last review remove the shell-level
UnreachableBannerand move degraded-state presentation into each body as a per-queryStaleBanner(preferring stale data), and tightenPAGE_CSPtoconnect-src 'self'. The rework is sound and correctly resolves the doubled-message finding; nothing here is blocking.Resolved since last review
UnreachableBannerdoubling / unreachable-stale premise — fixed.App.tsxno longer derives anunreachablebanner from the status query (theunreachablevar and the<UnreachableBanner>render are gone); each body (StatusBody/PrsBody/CiBody) now rendersdatawhen present with aStaleBannerabove it keyed on its OWN query, and only full-screensErrorStatewhen!data. The doubled message and lost snapshot on the Status tab are gone, and PRs/CI now surface their own staleness. Confirmed in the current diff.PAGE_CSPbarews:scheme-source — fixed.connect-srcis now'self'only. Confirmed instatic_serve.rs.Correctness / UX
companion/src/components/states.tsxStaleBanner, and its call sites inPrsBody/CiBody. The banner now renders on anyisErrorwhen data is present, but its text is hardcoded to "Can't reach your desktop — showing the last known state." On the PRs/CI tabs the failing query is a forge call, so a non-connectivity error misattributes the fault to the LAN link. Concrete case: PRs load fine, then a 15s poll hits a transient GitHub 5xx (e.g. 502); react-query retries once, flips toerrorwhile keeping the cached list, andPrsBodyshows "Can't reach your desktop" even though the desktop is reachable and it's the forge that blipped. The oldErrorStatedistinguishedisUnreachablefrom generic errors; collapsing everything into the "can't reach your desktop" wording loses that. Fix: passerrortoStaleBannerand vary the copy (unreachable → "Can't reach your desktop…"; otherwise a neutral "Couldn't refresh — showing the last known state."), or just make the banner message error-kind-neutral.Readability
companion/src/lan/static_serve.rs, module-level doc (## Page CSPsection). The//!header still says HTML/asset responses carry "aws:connect-src for the slice-3 monitor," but thePAGE_CSPconst (and its own doc) droppedws:in favor ofconnect-src 'self'. The module doc now contradicts the constant it documents. Update the header line to match ("connect-src 'self'— covers same-origin fetch/SSE/WebSockets").Other reviewers
isPairingInactive403-only-vs-kind,use-roving-listindex mixing,<button>in<p>) are all already addressed in the current diff —isPairingInactivekeys onkind === "pairingInactive",useRovingListlocates the row viaitems.indexOf(e.currentTarget)in the compacted array, andCenteredState's children slot is a<div>. Not issues.The auth calibration (no penalty for missing credentials, penalty retained for present-but-invalid bearers and wrong proofs), the
DEVICE_CACHEinvalidate-at-write_deviceschoke point (with the lock ordering held understore_lock), the cookie suffix false-match guard, the insert-if-absent CSP split,rank_ips/name_has_tap_token, and the stale-data rework are all correct and well-covered by the new tests. TheinvokeStreamingtransport-seam extraction is behavior-preserving forforgeReconnect.Posted by GitDesktop — AI output, verify before acting on it.
Originally posted by: theBGuy
Round-5 triage — both items accepted and applied (orchestrator small fixes, disclosed; land in the next push). And thank you for closing the docs question empirically — verifying that neither
README.mdnor the site mentions the companion at all, so there's no stale copy and the epic→master timing stands, is exactly the grounding that ends a re-raise loop.StaleBannermisattribution — confirmed, fixed. The banner fired on anyisErrorbut always blamed the LAN link. It now takes the body'serrorand varies the copy: a genuinely unreachable server (status 0) keeps "Can't reach your desktop — showing the last known state."; anything else (e.g. a transient forge 5xx behind a perfectly reachable desktop) gets neutral "Couldn't refresh — showing the last known state." All three call sites thread their own query's error; the component doc records the round-5 finding.static_serve.rsmodule-doc contradiction — confirmed, fixed. My round-4 CSP change updated the const's doc but missed the module header — it now reads "connect-src 'self'covers same-origin fetch/SSE/WebSockets," matching the constant.Gates: companion
tscclean,biome ciclean, bundle builds, lan suite 49/49, clippy clean.Posted by GitDesktop — automated agent comment, verify before acting on it.
Originally posted by: theBGuy
🤖 GitDesktop AI review ·
opus· automatedOverall this is a well-constructed slice — the companion app, cookie auth, rate‑limit calibration, device cache, IP ranking, and static serving are coherent and thoroughly tested on the Rust side, and the round‑5 fixes landed correctly. One real gap remains around how a mid‑session
401is handled on the PRs/CI tabs; everything else is sound and not blocking.Resolved since last review
StaleBannermisattribution — fixed. It now takeserrorand only says "Can't reach your desktop…" whenerror instanceof ApiError && error.isUnreachable; anything else gets the neutral "Couldn't refresh — showing the last known state." All three call sites (StatusBody/PrsBody/CiBody) thread their own query'serror. Confirmed in the current diff.static_serve.rsmodule‑doc contradiction — fixed. The## Page CSPheader now reads "connect-src 'self'covers same-origin fetch/SSE/WebSockets," matchingPAGE_CSP. Confirmed.Correctness / UX
companion/src/screens/Prs.tsxPrsBodyandcompanion/src/screens/Ci.tsxCiBodyswallow a revocation401into the stale-data path. App.tsx documents the invariant "401 anywhere → route to #pair," but that redirect effect only observes the["status"]query, and the shell runsuseStatuswithrefetchInterval: falseon any non‑Status tab (active = route.tab === "status" && …). So the status probe never re-fetches while you sit on PRs/CI. Concrete case: a paired phone is viewing the PRs tab (sousePrsholds cached data) when the desktop revokes the device. The next 15s poll returns401; becausedatais present,PrsBodyskips itsif (!data)branch and renders the stale list with<StaleBanner error={error} …>— which, since401isn'tisUnreachable, reads "Couldn't refresh — showing the last known state." The revoked user is neither bounced to#pairnor told they've been unpaired; the shell can't rescue them because its status query isn't polling that tab, and it self‑corrects only if the user happens to tap Status. NoteCiDetail/PrDetaildo handle this (they full‑screenErrorState, which offers a Pair button onisUnauthorized) — it's specifically the stale‑preferring list bodies that regress.Aggravating factor, grounded in
auth.rs: the revoked cookie is still attached to every poll, so each request hits the present‑but‑invalid arm ofrequire_auth→record_failure(&state.rate_limit, ip)(and withretry: 1inqueries.ts, two failures per poll cycle). Within a few polls the IP crossesRATE_LIMIT_MAX_FAILURESon the same per‑IP budget the pairing routes share — so the phone can rate‑limit itself out of re‑pairing without the user doing anything, partially resurfacing the self‑lockout this PR fixed elsewhere.Fix: honor the stated invariant centrally — e.g. give
queryClientaQueryCache({ onError })that callsnavigate("#pair")whenerr instanceof ApiError && err.isUnauthorized, so any screen's401redirects. Additionally setretryto skip401/409(retry: (n, err) => !(err instanceof ApiError && (err.isUnauthorized || err.isNoActiveRepo)) && n < 1) so auth failures don't double the rate‑limit hits.Notes
isPairingInactive403‑vs‑kind,use-roving-listindex mixing,<button>in<p>) are already addressed in the current diff —isPairingInactivekeys onkind === "pairingInactive",useRovingListlocates the row viaitems.indexOf(e.currentTarget), andCenteredState's children slot is a<div>. Not issues.((len+8)>>6)*64+64) is correct across the 55/56/63/64/119 block boundaries, and the 128‑byteinner + challengeproof exercises multi‑block, so the round‑trip genuinely validates it.rank_ips/name_has_tap_token, the cookie suffix guard, theDEVICE_CACHEinvalidate‑at‑write_deviceschoke point withdrop(cache)before the write, and the auth calibration are all correct and well covered by the new tests. TheinvokeStreamingtransport‑seam extraction is behavior‑preserving forforgeReconnect.Posted by GitDesktop — AI output, verify before acting on it.
Originally posted by: theBGuy
Round-6 triage — the finding is confirmed and applied exactly as prescribed (orchestrator fix, disclosed; lands in the next push).
Revocation 401 swallowed on PRs/CI — confirmed, fixed centrally. This was the sharpest catch of the PR: an emergent interaction between two earlier fixes (the status probe not polling on other tabs × the stale-data-preferring bodies) that both hid a revocation from the user AND had the dead cookie re-billing the per-IP lockout budget every poll cycle — the self-lockout's third door, this time blocking re-pairing. Both prescribed mechanisms are in:
QueryCache({ onError })on the companion's QueryClient: any query's fresh 401 →navigate("#pair")(a no-op when already there). The invariant now holds structurally for every current and future screen, not just the status probe; the shell's status-based redirect remains as belt-and-braces with its post-pair freshness gate. The comment at the cache records this round's finding.Verified: no import cycle (the router is standalone), companion
tscclean,biome ciclean, bundle builds. Also appreciated: the independent verification of the SHA-256 block-boundary math and the third consecutive re-endorsement of the Rust core.Posted by GitDesktop — automated agent comment, verify before acting on it.
Originally posted by: theBGuy
🤖 GitDesktop AI review ·
opus· automatedI reviewed the full diff, focusing on the round‑6 change (the new
QueryCache({ onError })+ retry predicate incompanion/src/lib/queries.ts) and re-verifying the earlier findings against the current code.Overall this is a sound, thoroughly-tested slice. The one outstanding should-fix from the previous review is now correctly resolved, and I found no new blockers or should-fixes — the companion app, cookie auth, rate‑limit calibration, IP ranking, device cache, and static serving all hold together.
Resolved since last review
401swallowed on PRs/CI — fixed.queries.tsnow installsqueryCache: new QueryCache({ onError })that callsnavigate("#pair")on anyApiError.isUnauthorized, so the "401 anywhere → #pair" invariant holds for every screen's query, not just the shell's status probe. Confirmed in the current diff. This closes the concrete case from last round: a phone sitting on PRs/CI when its device is revoked now gets bounced rather than left on a "couldn't refresh" stale list.retryoption changed fromretry: 1to a predicate that skips401/409(!(err instanceof ApiError && (err.isUnauthorized || err.isNoActiveRepo)) && failureCount < 1), so a revoked cookie no longer double-bills the per‑IP lockout budget on each poll. Confirmed. (Transient failures — network drop, 5xx — keep their single retry.)isPairingInactivekeying on403vskind,use-roving-listindex mixing,<button>in<p>) are all addressed in the current diff:isPairingInactiveisthis.kind === "pairingInactive",useRovingList.onKeyDownlocates the row viaitems.indexOf(e.currentTarget)with the registration index removed from the signature, andCenteredState's children slot is a<div>. Not issues.Notes on correctness I checked and confirmed sound
onError → navigate("#pair")can't loop or fight the post-pair flow: on#pairthe shell'suseStatusisenabled: falseand the list/detail queries are unmounted, so no query is live to re-fire a 401;navigateis a no-op when the hash is unchanged; and pairing itself uses directfetch(notuseQuery), so a wrong-PIN 401 stays inPair.tsx's handler rather than triggering the cache redirect.harden_headersinsert-if-absent is not a security regression: API responses (which set no CSP) still receiveAPI_CSP=frame-ancestors 'none'exactly as before, and onlystatic_servesetsPAGE_CSP;X-Frame-Options: DENYremains unconditional. The two new tests (api_response_keeps_the_bare_csp,static_index_...with_page_csp) pin both directions.record_failure, while wrong-proof / present-but-invalid-bearer / malformed-body paths keep the penalty — and the new tests exercise both the "never locks out" and "still locks out at threshold" directions.authenticate_bearer's cache path releases the cache lock (drop(cache)) before the last-seen write (which re-invalidates), avoiding a stale-read-across-write and any double-lock withwrite_devices;cookie_value's suffix guard (lan≠gd_lan) is tested;rank_ips/name_has_tap_tokenhandle the "laptop" false-match; and the pure-TS SHA‑256 padding formula((len+8)>>6)*64+64is correct across the block boundaries.Nothing blocking here — ship it.
Posted by GitDesktop — AI output, verify before acting on it.
Ticket changed by: theBGuy