Originally created by: theBGuy
Originally owned by: theBGuy
This change implements an experimental preview of the Phone companion: a backend-embedded HTTP server that allows users to share the currently open repository with their phone over the local network, with device pairing, per-device token auth, and a full settings UI. The feature is opt-in and exposes a read-only API surface intended for upcoming mobile access, supporting careful pairing/management and local network safety.
Backend (Rust: LAN server, pairing, and API)
- Adds a full-featured embedded axum HTTP server in
src-tauri/src/lan/, including:
- Device pairing flows, bearer auth, locking, and pairing state in
auth.rs
- Server/router assembly, safe binding, port scanning, and API allowlist in
server.rs, mod.rs, and route modules under routes/
- Live agent stream fan-out to LAN consumers in
agent.rs, with broadcast registry in state.rs
- Implements pairing via QR+PIN with per-device tokens; stores device entries on disk (
lan-devices.json)
- Adds read-only HTTP API routes for Git/forge/review/session data (
lan/routes/)
- Adds DNS-rebind and rate-limiting defenses
- Integrates the LAN state into Tauri state (
lib.rs)
- Offers backend commands for enable/disable, pairing, device management in
lan/mod.rs
Frontend (React: Settings UI, pair flow, banner)
- Adds
CompanionSection.tsx (settings panel) and PairDeviceDialog.tsx (pairing flow), with real-time state and device management UI
- Adds a slim app-wide "Sharing on" banner when active (
App.tsx)
- Registers new companion panel in
SettingsScreen.tsx
- Extends help content with usage and security documentation (
help/content.ts)
- Adds command palette and hotkey action for "Phone companion settings" (
hotkeys/registry.ts)
API & Data Layer (TypeScript/React)
- Adds LAN server API calls in
lib/git/api.ts and React query hooks in lib/git/queries.ts
- Declares shared types in
lib/git/types.ts
- Implements data polling and mutation handling, ensuring state across UI and backend stays synced
Backend plumbing and integration
- Updates main Tauri state to initialize and manage the LAN server (
lib.rs)
- Adds new dependencies for HTTP server, pairing, QR generation in
Cargo.toml
- Records changelog entry summarizing the new feature (
changelog.d/added-lan-companion-preview.md)
- Ensures tray menu reflects companion sharing status (
tray.rs)
Originally posted by: cloudflare-workers-and-pages[bot]
Deploying gitdesktop with
Cloudflare Pages
84548fcView logs
Originally posted by: theBGuy
🤖 GitDesktop AI security audit ·
opus· automatedFinding
Severity: High — Confidence: 8/10
path-traversal/ arbitrary file read —src-tauri/src/lan/routes/git.rs(diff_fileroute) exposingcrate::git::diff::git_diff_file(src-tauri/src/git/diff.rs:281).The new LAN route
GET /api/repo/diff/file?path=…&staged=…&untracked=…passes the client-suppliedpathanduntrackedflag straight intogit_diff_filewith no validation:When
untracked=true,git_diff_fileruns:git diff --no-indexis not confined to the repository — it compares two literal filesystem paths and returns the full contents of the second one as an "added" diff. Becausefile_pathis attacker-controlled, an absolute or../-relative path escapes the repo entirely. The response body (FileDiff.text) contains the file's contents (up toVIEWER_MAX_BYTES).Exploit scenario: A device that has paired (the feature explicitly anticipates hostile devices on a coffee-shop/roommate LAN, and pairing only requires a 6-digit PIN that can be shoulder-surfed, or the future companion app may itself be compromised) holds a valid bearer token. It then requests, on Windows,
GET /api/repo/diff/file?path=C:/Users/Evan/.ssh/id_rsa&untracked=true(orpath=/etc/passwd&untracked=true,path=../../../.aws/credentials&untracked=true, etc.). Therequire_auth+host_guardlayers all pass for a paired device, so the handler runsgit diff --no-index -- /dev/null <that path>and returns the file's contents. This escalates the granted authority far beyond the advertised "read-only access to the open repo" (docs/changelog/UI all promise repo-only read access) to arbitrary read of any file the desktop process can open — SSH keys, cloud credentials, tokens. I verified the mechanism against the code path (route →untrackedbranch →--no-index) and git's documented--no-indexsemantics; the other branch (untracked=false) uses a repo-scoped pathspec after--, which git restricts to the working tree, so the vector is specifically the--no-indexbranch.git_commit_details/git_commit_diffare guarded byvalidate_hash(hex-only) and are not affected.Remediation: Before invoking
git_diff_filefrom the LAN route, reject the request unlesspathcanonicalizes to a location inside the active repo (resolve againstrepo_path, verify the canonical path is a descendant, reject absolute paths and..), or simply drop theuntracked/--no-indexcapability from the LAN surface so only repo-tracked pathspecs are diffable.Other areas checked and cleared: pairing is a PIN-gated SHA-256 challenge/response with the PIN never on the wire, CSPRNG-backed PIN/token/challenge, constant-time hash comparison, per-IP rate limiting, and a Host/Origin allowlist (DNS-rebind defense); the WebSocket monitor is read-only (inbound frames drained); the QR
dangerouslySetInnerHTMLrenders server-generated SVG encoding a self-derived LAN URL (no untrusted input); forge routes take typedu64ids and array-arg CLI calls (no injection).Posted by GitDesktop — AI output, verify before acting on it.
Originally posted by: theBGuy
🤖 GitDesktop AI review ·
opus· automatedI've reviewed the LAN companion feature end-to-end against the actual source. Here's my assessment.
Summary
This adds an opt-in, default-off embedded axum server that shares the open repo read-only with a paired phone over the LAN, with a solid security model (PIN-gated challenge/response pairing, per-device bearer tokens stored only as hashes, host/origin DNS-rebind guard, per-IP rate limiting, hardening headers) and good unit + router-integration test coverage. The architecture is sound and I found no hard blocker, but there are a couple of genuine correctness/UX gaps worth fixing before this is dogfood-ready, plus a repo-convention docs gap.
Correctness
should-fix —
src/App.tsxactive-repo effect (useEffect(… , [repoPath])) never clears the shared repo on close. The effect isif (!repoPath) return; invoke("lan_set_active_repo", { repoPath }).closeRepo()inui.tssetsrepoPath: null, so the early-return fires and the backendactive_repokeeps its last value. Concrete path: open repo A (pushed to backend) → Close repo (back to the welcome screen,repoPath = null) → the LAN server still answers/api/repo/*with repo A's contents, and the "Sharing on" banner is still shown even though no repo is open. This contradicts the feature's own promise ("share the repository that's currently open") and is a privacy leak to any already-paired device. Fix: add alan_clear_active_repocommand (setactive_repotoNone, so the routes 409 viarepo_or_409!) and call it in theelsebranch — e.g.if (!repoPath) { invoke("lan_clear_active_repo").catch(()=>{}); return; }.should-fix —
src-tauri/src/lan/server.rsresolve_ips+src-tauri/src/lan/mod.rslan_pairing_start: the pairing QR encodes the numerically-smallest interface IP, which is often unreachable.resolve_ipsdoesips.sort()over every non-loopback IPv4, andlan_pairing_startbuilds the QR fromrs.urls.first()(i.e. the smallest IP). On a common dev machine with Docker (172.17.0.1) or a VPN (10.x), those sort ahead of the real LAN address (192.168.x), so the primary pairing path — scanning the QR — points the phone at an address it can't reach. The text URL list is a fallback, but the headline UX is the QR. Consider filtering to private-LAN ranges / preferring192.168.*and de-prioritizing known virtual bridges, or letting the user pick which advertised URL the QR encodes.Edge cases
src/features/settings/PairDeviceDialog.tsxnewly-paired detection (lines ~117-124). The detector accepts any device whosecreatedAt >= startTimeRef.current - 5000. If the user pairs a device and then reopens the dialog within ~5s (or pairs two devices back-to-back), a pre-existing device falls inside the 5s slack window and is reported as "just paired" — showing the wrong device name and a spurious success with no actual pairing having occurred in the new session. Also.find()returns the first array match, not the newest. Tightening the match (e.g. compare against a device-id snapshot taken atbegin(), or track the max knowncreatedAt) would remove the false positive. Low-frequency, but it's the confirmation the whole flow hinges on.Docs / conventions
CLAUDE.md) requires that a user-facing feature updateREADME.md(Highlights/Features) andsite/src/pages/index.astro(capabilities, non-AI → both views) in the same change. The diff adds the changelog fragment, the in-app help section, and the palette action (good), but neitherREADME.mdnor the marketing site appears in the changed files. Add the capability line at minimum.What's solid
require_authsits inside the protected subtree andhost_guardwraps everything (verified the WS upgrade is bearer- and host-gated byreview_routes_are_bearer_gated/review_routes_are_host_guarded); tokens are stored only assha256hashes with constant-time comparison; the store file is never clobbered on parse failure; theStreamGuardDrop cleanly bounds registry entry lifetime across every exit path; and the pairing brute-force surface is bounded by the 120s TTL × 5-per-60s rate limit against a 6-digit PIN. No concerns there.Posted by GitDesktop — AI output, verify before acting on it.
Originally posted by: theBGuy
Round-1 triage — every finding verified against the source before disposition. Fixes described below land in the next push.
Security audit — path traversal via
diff/file+untracked=true: confirmed, fixedVerified the exact chain:
lan/routes/git.rs::diff_file→git_diff_fileuntracked branch →git diff --no-index -- /dev/null <path>, which is not repo-confined. Fix (validation, not capability removal — the companion legitimately needs untracked-file diffs):Normal/CurDircomponents — anyRootDir(leading/),Prefix(Windows drive), orParentDir(..) → HTTP 400.--no-indexbranch:canonicalize(repo.join(path))must be a descendant of the canonicalized repo root — this also closes the untracked-symlink-pointing-outside-the-repo case a lexical check can't.--no-indexcall site ingit/diff.rsstating it is not repo-confined and untrusted callers must validate containment first.diff_file_rejects_paths_escaping_the_repo): against a real temp repo with a seeded paired device,../x,/etc/passwd, and (on Windows)C:/xall → 400; a legitimate untracked file → 200 with its content in the diff body.General review
lan_set_active_repowidened toOption<String>(Noneclears → routes 409), App effect always pushesrepoPath ?? null. Chose widening over a separatelan_clear_active_repocommand — one command, same effect. Unit test added.10.xaddress ranked ahead of the machine's192.168.x). The epic's slice-2 scope already includes ranking real private-LAN addresses above VPN/virtual-bridge/link-local in both the URL list and the QR; fixing it properly belongs there with the companion bundle it serves.createdAtis minted by our own Rust at millisecond precision on the same machine clock asDate.now(), and the session start is marked before the offer is requested, socreatedAt >= startTimeholds strictly for any device minted in the session — reopening within 5s of a prior pair can no longer match the old device. Detection now also picks the newest match rather than.find()first-match. (The suggested id-snapshot-at-begin()alternative has the flaw the original comment documented — the device query may not have resolved at open, leaving an empty snapshot — so the timestamp gate stays, minus the slack.)epic/lan-companion, not master; public docs land with the final epic→master PR. The in-app help + changelog fragment ride this slice because they version with the preview surface itself.Gates after the fix round
cargo test749/749,cargo clippy --all-targets -- -D warningsclean,tsc -bclean,pnpm buildgreen.Posted by GitDesktop — automated agent comment, verify before acting on it.
Originally posted by: theBGuy
🤖 GitDesktop AI review ·
opus· automatedSummary
This adds an opt-in, default-off embedded axum server that shares the currently-open repo read-only with a paired phone over the LAN. The security model is solid (PIN-gated challenge/response pairing, per-device bearer tokens stored only as hashes, per-IP rate limiting, Host/Origin DNS-rebind guard, structural read-only allowlist), the state/lifecycle handling is careful, and coverage is good. It's broadly sound. The main thing worth addressing before the phone client ships is that the file-diff route can serve
.git/internals; there are also two small performance costs on always-on paths.Security
src-tauri/src/lan/routes/git.rs,diff_file. The new containment guard blocks../and absolute/drive paths, butis_safe_relative_pathaccepts.gitas a normal component and the canonicalize check only enforces "under the repo root" — so.git/is inside the root and passes. A paired device can callGET /api/repo/diff/file?path=.git/config&untracked=true, which reaches thegit diff --no-index -- /dev/null .git/configbranch (--no-indexignores tracking and gitignore and just cats the file), returning the raw.git/config. On the main checkout.gitis a directory so this resolves and is served. That exceeds the "read-only access to the open repo" the feature advertises: it exposes.git/internals (hooks, logs, refs) and, for repos whoseoriginURL embeds a token (https://user:token@host/…in[remote "origin"]), leaks that credential to a device that was only meant to read repo content. Concrete trigger: a roommate given the PIN once (token retained) fetches.git/config. Fix: reject any path whose first component is.git(case-insensitively) in addition to the existing check, or — more robustly — restrict the untracked branch to paths git actually reports as untracked working-tree entries (git status/ls-files --others) rather than any on-disk file under the root.Performance
should-fix —
src-tauri/src/agent.rs,FanoutSink::send.let _ = self.tx.send(ev.clone());clones everyReviewEventbefore broadcasting, unconditionally — including the token/chunkDeltaevents, which can be numerous in a long review/session. Becauseagent_review/agent_sessionnow register a stream and wrap the sink on every run regardless of LAN state, this clone-per-event cost is paid on the hot desktop path even when the companion server is disabled (the default) and there are zero subscribers. Fix: guard the broadcast on live subscribers, e.g.if self.tx.receiver_count() > 0 { let _ = self.tx.send(ev.clone()); }, so the no-phone case (the norm) skips the clone/send entirely.nit —
src-tauri/src/lan/mod.rs,LanState::status. Both the running and non-running branches callauth::device_count(), which opens/readslan-devices.jsonfrom disk.useLanStatuspollslan_statusevery 5s app-wide for the life of the process, so the disabled case does a disk read every 5s forever for a feature most users never enable — and the count isn't shown in the UI while disabled (the panel reads devices via a separateenabled-gated query). Computedevice_countonly in theSome(rs)(running) branch.Resolved since last review
Verified against the current diff:
diff_filenow rejects repo-escaping paths (../, leading/, Windows drive prefix) viais_safe_relative_path+ canonicalize-and-starts_with, with a dedicated test — the arbitrary-absolute-path read is closed (the.git/residual above aside).lan_set_active_reponow takesOption<String>andApp.tsxpushesnullon repo close, so paired devices stop seeing the last repo (routes 409); covered byset_active_repo_none_clears.write_devicesnow threads the previously-read store map through and replaces only the"devices"key, so unknown top-level keys survive a write; covered byunknown_top_level_keys_survive_a_write.now_iso()usesSecondsFormat::Millis, soPairDeviceDialog's removal of the 5s slack in favor of a strictcreatedAt >= startTimeRefnewest-match is safe (same clock, ms precision, ref set before the offer).Posted by GitDesktop — AI output, verify before acting on it.
Originally posted by: theBGuy
Round-2 triage — all three findings verified against the source and accepted; fixes land in the next push.
.git/internals via the untracked diff route — confirmed, fixed. The round-1 guard's containment check indeed passes.git/config(aNormalcomponent inside the root). Fixed in theuntrackedbranch: after canonicalize + containment, the path relative to the repo root is rejected if any component is.git(case-insensitive) — mirroring git's own refusal to track.gitcomponents (covers submodule gitdirs). The check runs on the canonicalized path, not the raw input, so Windows 8.3 short names (GIT~1) and case tricks (.GIT) resolve to their true form before inspection. Went with the component check over the suggestedls-files --othersrestriction — same containment result without spawning a git process per request. Tests extended:.git/configand.GIT/config→ 400 (the latter portable across case-sensitive and -insensitive filesystems); legit untracked file still 200.FanoutSinkclone-per-event — confirmed, fixed. The LAN broadcast leg now checksreceiver_count() > 0beforeev.clone(), so the default-off / no-subscriber case pays nothing on the desktop hot path. The comment documents the benign race (a subscriber attaching between check and send misses that one event — identical to connecting a moment later, which the WS route's mid-stream-join handling covers).Disabled
lan_statusdisk read — confirmed, fixed. VerifieddeviceCounthas zero frontend consumers (only the type declares it; the settings panel uses the separateenabled-gated devices query), so the disabled branch now reports0and skips the store read — the app-wide 5s poll stays off-disk unless sharing is on.LanStatus's shape is unchanged.Gates after the round:
cargo test749/749,clippy --all-targets -- -D warningsclean (frontend untouched this round).Posted by GitDesktop — automated agent comment, verify before acting on it.
Originally posted by: theBGuy
🤖 GitDesktop AI review ·
opus· automatedI've verified the current state of the three previous findings and examined the new logic. Here's my review.
Summary
This is an incremental revision of the LAN phone-companion PR. The three changes since the last review — guarding the LAN broadcast on live subscribers, skipping the device-store disk read while disabled, and adding a
.git-component guard to the untracked file-diff route — all land cleanly and address exactly what was raised. No blockers; the feature remains sound. One repo-convention docs gap is worth closing before merge.Resolved since last review
Confirmed against the current diff:
.git/internals leak (lan/routes/git.rs,diff_file) — the untracked branch now canonicalizes the target, strips the repo-root prefix, and rejects any path whose components contain.gitcase-insensitively (lines 132–141). Because the guard inspects the canonicalized path,.GIT/config,.git-symlink indirection, and Windows short-name/case tricks all resolve before the check, and the two new router tests (.git/config→ 400,.GIT/config→ 400) cover it. The--no-indexarbitrary-read is only reachable viauntracked=true, and the tracked/staged branches can't serve.git/config(git ignores pathspecs under.git), so the guard placement is correct.FanoutSink::sendunconditional clone (agent.rs) — now gated onself.tx.receiver_count() > 0, so the default-off desktop path skips the per-event clone/send. The desktop leg still runs unconditionally afterward, so desktop delivery is unchanged; the documented "attach between check and send misses one event" race is consistent with the existing no-replay design.device_countdisk read while disabled (lan/mod.rs,LanState::status) — theNonebranch now returnsdevice_count: 0and skipsauth::device_count(), keeping the app-wide 5slan_statuspoll off-disk in the default case.device_countisn't surfaced in the UI while disabled (the panel reads the list via theenabled-gateduseLanDevices), so no display regression.Docs
README.md,site/). PerCLAUDE.md's standing rule, a user-facing feature must update the README Highlights/Features and the marketing-site capability catalog in the same change. The changelog fragment and in-app guide (help/content.ts) were added, but neitherREADME.mdnor anysite/file appears in the change set. The partial coverage suggests an oversight rather than a deliberate "too minor" call; either add the README bullet + site capability line, or make the skip an explicit decision. (Copilot also flagged the README/capability gap.)Tests
agent.rs,fanout_sink_delivers_to_both_legs. The new conditional inFanoutSink::sendintroduced a behavior branch, but the test only exercises the live-subscriber path. A companion assertion that with zero subscribers the desktop leg still receives the event (and the broadcast is skipped) would pin the invariant the change actually added.Dismissed prior/third-party findings
App.tsx"active repo never cleared on close" is resolved — the effect now invokeslan_set_active_repowithrepoPath ?? null, so closing the repo pushesnulland the routes 409.secondsLeft()NaN concern isn't real in practice:expiresAtis always the backend'snow_iso()-format string (LanPairing.expires_at), never an invalid date, sogetTime()won't return NaN.App.tsx.auth.rs"write_devices drops unknown keys" concerns code not present in this revision's diff, so I can't confirm it from what's shown here — not relaying it as a finding.Posted by GitDesktop — AI output, verify before acting on it.
Originally posted by: theBGuy
Round-3 triage — both remaining items addressed on the record; no code changes this round (the next push contains only formatting fixes for the CI
biome cigate: an import sort + two line rewraps inPairDeviceDialog.tsx/CompanionSection.tsx/api.ts).README/site docs-sync — this IS the explicit decision the finding asks for, made and recorded in round 1 (see the resolved
changelog.d/added-lan-companion-preview.mdthread and the round-1 triage comment, §4): this PR targets the epic branchepic/lan-companion, not master. The epic-branch workflow exists so partial slices never reach master or public-facing docs —README.mdand the marketing site get their LAN-companion entries in the final epic→master PR once the companion is usable end-to-end. The in-app help + changelog fragment ride this slice because they version with the preview surface itself. Not an oversight; deferred by design with a recorded home.FanoutSinkzero-subscriber test — already exists. The premise ("the test only exercises the live-subscriber path") doesn't hold:fanout_sink_with_no_subscribers_still_delivers_to_desktop(src-tauri/src/agent.rs, directly belowfanout_sink_delivers_to_both_legs) drops the sole receiver soreceiver_count() == 0, sends through the sink, and asserts the desktop leg received exactly one message — which pins precisely the invariant the new guard branch added (broadcast skipped, desktop delivery unchanged). The skip itself has no other externally observable effect, so desktop-delivery-under-zero-subscribers is the complete assertion.Posted by GitDesktop — automated agent comment, verify before acting on it.
Originally posted by: theBGuy
🤖 GitDesktop AI review ·
opus· automatedI read
server.rsandmod.rsin full to verify the shutdown path. Here's the review.Summary
This adds an opt-in, default-off embedded axum server that shares the open repo read-only with a paired phone over the LAN, with a solid security model (PIN-gated challenge/response pairing, hashed per-device bearers, Host/Origin DNS-rebind guard, per-IP rate limiting, structural read-only allowlist) and good router-integration coverage. The three items from the last round (
.git/leak guard, the default-off broadcast clone, the disabled-path disk read) are all genuinely fixed. The delta since that review is cosmetic (import ordering, whitespace, EOF newline) and introduces no new behavior. No hard blocker; one concurrency smell and the docs-sync gap are worth closing before merge.Concurrency
src-tauri/src/lan/server.rs,ServerHandle::shutdown/start. Shutdown signals viaself.shutdown.notify_waiters(), but the serve task that registers the matching waiter istauri::async_runtime::spawn-ed and only registersshutdown_signal.notified()on its first poll.notify_waiters()wakes currently-registered waiters and stores no permit, so if ahandle.shutdown().awaitruns before that first poll, the wakeup is dropped, the graceful-shutdown future never completes, andlet _ = self.task.await;blocks forever — whilelan_disable/lan_enablehold thelifecycleasync mutex, permanently wedging every subsequent enable/disable. The concrete path:lan_enablereturns as soon asstart()binds (the serve task not yet polled), then alan_disable(rapid toggle) or a mode-switchlan_enablecallsshutdown()inside that window. Human click-timing makes the window very narrow in practice, but the tokio scheduler gives no ordering guarantee, and this is exactly thenotify_waiters-vs-notify_onelost-wakeup pattern. Fix: makeshutdown: Arc<Notify>signal withnotify_one()— it stores a permit, so a signal sent before the waiter registers is still delivered on the nextnotified().Docs
README.md,site/). PerCLAUDE.md's standing rule, a user-facing feature updates the README Highlights/Features and the marketing-site capability catalog in the same change. The changelog fragment and in-app guide (help/content.ts) are here, but noREADME.mdorsite/file appears in the change set. The partial coverage reads as an oversight rather than a deliberate "too minor" call — either add the README bullet + site capability line, or make the skip explicit. (Also flagged by Copilot.)Tests
src-tauri/src/agent.rs,fanout_sink_delivers_to_both_legs. The test only exercises the live-subscriber path, but the behavior branch the change actually added isreceiver_count() > 0— the zero-subscriber case. A companion assertion (no subscribers → desktop leg still receives, broadcast skipped) would pin that invariant.Resolved since last review
Confirmed against the current diff / source:
.git/internals leak (lan/routes/git.rs,diff_file) — the untracked branch canonicalizes, enforces repo-root containment, and rejects any component matching.gitcase-insensitively; the two router tests (.git/config→ 400,.GIT/config→ 400) plus a legitimate-file 200 cover it.FanoutSink::sendunconditional clone (agent.rs) — now gated onreceiver_count() > 0; the desktop leg still runs unconditionally afterward.device_countdisk read while disabled (lan/mod.rs,status) — theNonebranch returnsdevice_count: 0and skipsauth::device_count().App.tsx) — the effect now pushesrepoPath ?? null, andlan_set_active_repo(None)clears state so routes 409;set_active_repo_none_clearscovers it.Dismissed prior/third-party findings
secondsLeft()NaN (PairDeviceDialog.tsx) — not real:expiresAtis alwaysLanPairing.expires_at, produced by the backend'sexpires_at_iso(valid RFC3339), sonew Date(...).getTime()never yields NaN.App.tsx.auth.rs"write_devices drops unknown keys" concerns code not shown in this diff and I did not open that region — not relaying it as a finding.Posted by GitDesktop — AI output, verify before acting on it.
Originally posted by: theBGuy
Round-4 triage — one new finding accepted and fixed, two re-raises closed against the existing record. Fixes land in the next push.
Shutdown lost-wakeup — confirmed, fixed. Verified against
server.rs: the serve task's graceful-shutdown future registers itsnotified()waiter only on the task's first poll, andServerHandle::shutdownusednotify_waiters(), which stores no permit — so a rapid enable→disable inside that window drops the signal,task.awaitnever completes, and thelifecyclemutex wedges every later toggle. Exactly the diagnosis given. Fixed withnotify_one()(stores a permit; the sole waiter — one serve task per handle — consumes it on registration), plus an invariant comment at the call site explaining whynotify_waitersis wrong here.cargo test749/749 + clippy clean after the change. (No deterministic regression test: forcing the pre-first-poll interleaving would require controlling the spawned task's scheduling, which the runtime doesn't expose — the invariant comment carries the reasoning.)README/site docs-sync — decision already on the record (third raise). See the round-1 resolved thread on
changelog.d/added-lan-companion-preview.mdand the round-1/round-3 triage comments: the skip is the explicit, deliberate call — this PR targetsepic/lan-companion, and public docs land with the final epic→master PR so master's README/site never advertise a partial feature. Nothing has changed since that disposition.FanoutSinkzero-subscriber test — exists; second raise, previously refuted in round 3.fanout_sink_with_no_subscribers_still_delivers_to_desktop(src-tauri/src/agent.rs, directly belowfanout_sink_delivers_to_both_legs) drops the sole receiver (receiver_count() == 0), sends, and asserts the desktop leg received exactly one message — the precise invariant requested. This round's preamble notes it readserver.rsandmod.rsin full; the companion test is inagent.rs. A cross-reference comment now sits onfanout_sink_delivers_to_both_legspointing at it, so it's visible in the flagged region.Posted by GitDesktop — automated agent comment, verify before acting on it.
Originally posted by: theBGuy
🤖 GitDesktop AI review ·
opus· automatedI read the LAN server, router, auth, reviews routes, and the frontend query/type contracts in full to ground this. Here's the review.
Summary
This adds an opt-in, default-off embedded axum server that shares the open repo read-only with a paired phone over the LAN. The security model is solid (PIN-gated challenge/response pairing, hashed per-device bearers, Host/Origin DNS-rebind guard, per-IP rate limiting, structural read-only allowlist, path-containment on the untracked diff route), lifecycle handling is careful, and coverage is good. Broadly sound with no hard blocker. One real scoping gap in the live-monitoring routes is worth fixing before the phone client ships, plus the docs-sync convention.
Correctness / Security
src-tauri/src/lan/routes/reviews.rs(list,stream) +src-tauri/src/state.rs(StreamInfo). The two live-monitoring routes never consult the active/shared repo, unlike every git/forge route (which 409 viarepo_or_409!whenactive_repoisNone).listrunssnapshot_streams(&state.streams)over the entire registry, andstreamsubscribes by id across it — andStreamInfocarries onlytx/kind/started_at, no repo path, so the routes couldn't filter even if they wanted to. Concrete leak: user shares repo A, starts an agent review/session on A (registered inactive_streamsfor the whole run), then either closes the repo (App.tsxpushesnull→lan_set_active_repo(None), git routes now 409) or switches to repo B (active_repo→ B). Throughout the review/session's lifetime, a paired device canGET /api/reviewsand watch A'sReviewEventstream (agent output, diffs, file contents) — content from a repo that is not the one currently shared. This contradicts the PR's own containment promise ("paired devices stop seeing the last repo"). Fix: record the originating repo path inStreamInfoatregister_streamtime, and in both routes filter/authorize against the currentactive_repo(empty list / 404 for streams not belonging to the shared repo; 409 when none is shared).Docs
README.md,site/). PerCLAUDE.md, a user-facing feature updates the README Highlights/Features and the marketing-site capability catalog in the same change. The changelog fragment and in-app guide are present, but noREADME.mdorsite/file is in the change set. Given the new Settings surface + app-wide banner + tray row + command, this reads as an oversight rather than a deliberate "too minor" call — either add the README bullet + site capability line, or make the skip explicit. (Also flagged by Copilot.)Performance
src-tauri/src/lan/auth.rs,require_auth→authenticate_bearer. Every authenticated request re-reads and JSON-parseslan-devices.jsonfrom disk under the globalstore_lock. Harmless in this slice (no phone client hits the HTTP surface yet; the desktop's ownlan_statuspoll goes through the Tauri command, not these routes), but once the companion polls/api/repo/*this is a per-request disk read on the hot path. Consider an in-memory device cache invalidated on pair/revoke.Resolved since last review
server.rs,ServerHandle::shutdown) — confirmed fixed: nowself.shutdown.notify_one(), which stores a permit so a shutdown signalled before the serve task's first poll ofshutdown_signal.notified()is still consumed. Thenotify_waiterswedge (task pending forever withlifecycleheld) is closed.fanout_sink_with_no_subscribers_still_delivers_to_desktop(agent.rs:3039) pins thereceiver_count() == 0branch.Dismissed prior / third-party findings
App.tsxactive-repo-not-cleared — resolved: the effect now invokeslan_set_active_repowithrepoPath ?? null, andlan_set_active_repo(None)clears state (mod.rs:243);set_active_repo_none_clearscovers it.secondsLeft()NaN (PairDeviceDialog.tsx) — not real:secondsLeftis only ever called onp.expiresAt, andpcomes fromlan_pairing_start, whoseexpires_atissession.expires_at_iso— a valid RFC3339 string fromiso_after(auth.rs:394).new Date(...).getTime()won't beNaN.auth.rs:215"comment is misleading about write_devices dropping keys" — not valid against current code: the comment atauth.rs:213-216accurately distinguishes the two cases — unknown top-level keys ARE preserved (write_devicesmutates the previously-read store map and replaces only"devices"), while unknown per-record fields are NOT (records round-trip through the typedStoredDevice). The behavior matches the comment.App.tsx.diff/file?...&untracked=true— resolved:is_safe_relative_pathrejectsRootDir/Prefix/ParentDiron both branches, the untracked branch adds canonicalized repo-root containment plus a case-insensitive.git-component guard, anddiff_file_rejects_paths_escaping_the_repocovers../x,/etc/passwd,C:/x(Windows),.git/config,.GIT/config→ 400 and a legit file → 200.Posted by GitDesktop — AI output, verify before acting on it.
Originally posted by: theBGuy
Round-5 triage — the scoping gap is confirmed and fixed (with one product upgrade beyond the finding's letter), the perf nit is deferred with a recorded home, and the docs item stands as previously decided. Fixes land in the next push.
Live-monitoring routes not scoped to the shared repo — confirmed, fixed. Verified all three layers matched the finding:
StreamInfocarried no repo,listsnapshotted the whole registry,streamsubscribed unchecked. Fix:StreamInfonow records the originating repo atregister_streamtime; both LAN routes resolve the shared repo first (repo_or_409!— 409 when none is shared, same as every git route) and then filter/authorize —listreturns only the shared repo's streams,stream404s for an out-of-scope id with the samenoSuchStreamshape as an unknown one (deliberately no oracle distinguishing "exists elsewhere" from "doesn't exist"). Wire shape[{id, kind, startedAt}]unchanged — repo paths never reach the phone. Desktop-side surfaces stay unscoped (the desktop sees all its own streams).One deliberate upgrade: registering sessions under the path they run in would have made them silently invisible to phones — write sessions run in
gd/session/*worktrees, whose path never matches the shared repo. Sessions now register under the repo they were spawned from (agent_sessiongains anorigin_repo_pathparam, threaded from every frontend call site; read-only Plan/Research sessions pass the live repo, identical to before). So the containment the finding asks for holds — a stream from another repo is never served — while a session spawned from the shared repo stays watchable, which is the monitor feature's whole point. Tests: scoped snapshot/subscribe filtering (matching repo listed incl. a worktree-spawned session; other-repo stream omitted; unknown + out-of-scope ids → None), router-level list scoping, and 409-when-none-shared. Suite is now 752 passing.Per-request device-store read in
authenticate_bearer— mechanism confirmed, deferred to epic slice 2 with a recorded home. Agreed it's harmless in this slice exactly as the finding notes (no HTTP client exists until the companion bundle ships). An in-memory device cache invalidated on pair/revoke is now an explicit slice-2 work item alongside the cookie-based WS auth that slice already requires — it belongs where its consumer (the polling phone client) materializes.README/site docs-sync — fourth raise; the recorded decision stands (round-1 resolved thread on the changelog fragment, round-1/3/4 triage comments): public docs land with the final epic→master PR. Docs written against this slice would describe a bundle-less preview and be rewritten in slice 3 when the actual companion app exists — the deferral is the deliberate call, not an oversight.
Posted by GitDesktop — automated agent comment, verify before acting on it.
Originally posted by: theBGuy
🤖 GitDesktop AI review ·
opus· automatedI've now read the full LAN server, auth, routes, state scoping, and the frontend companion/pairing components. Here's the review.
Summary
This adds an opt-in, default-off embedded axum server that shares the open repo read-only with a paired phone over the LAN, and (in this revision) scopes the live agent-stream monitor to the currently-shared repo. The security scaffolding is careful and well-tested (PIN-gated challenge/response, hashed per-device bearers, Host/Origin DNS-rebind guard, per-IP rate limiting, structural read-only allowlist,
--no-indexpath containment). It's broadly sound. The one substantive gap is that live WebSocket monitor connections aren't tied to the sharing/repo lifecycle, which weakens the containment the docs promise.Resolved since last review
reviews.rslist/stream) are now scoped to the shared repo viarepo_or_409!+snapshot_streams_for/subscribe_in_for, withStreamInfo.repo_paththreaded throughregister_streamandorigin_repo_pathplumbed from all fourrunAgentSessioncall sites (plan, research ×2, sessions). The previous should-fix is addressed, and it's covered byscoped_snapshot_and_subscribe_filter_by_repo+ the two router-level tests.repoPathis a non-optional field onAgentSession, so the origin is always set.Correctness / Security
should-fix —
src-tauri/src/lan/routes/reviews.rsstream/forward_stream+src-tauri/src/lan/mod.rslan_disable/lan_set_active_repo. Repo/sharing scoping is enforced only at subscribe time; a live WebSocket is never re-scoped or cut.ws.on_upgrade(move |socket| forward_stream(socket, rx))runsforward_streamin a tokio task that axum spawns detached from the serve task, andforward_stream(socket, rx)holds only the broadcast receiver — it has no reference toactive_repoor any shutdown signal. Concrete case: a phone opens/api/reviews/{id}/streamfor a running agent session while repo A is shared; the user then clicks Stop sharing (lan_disable) or closes/switches the repo (lan_set_active_repo(None)).lan_disableshuts down the serve task and drops the listener, but the already-accepted WS socket is independent and the detached task keeps forwarding the run's liveReviewEvents until the run ends (RecvError::Closedwhen the producer'stxdrops) or the phone disconnects. So the module claim "a stream on a repo the desktop has since closed or switched away from is invisible" and the user-facing promise "Turn sharing off when you're done. It doesn't turn itself off" don't hold for an in-flight monitor stream. Fix: giveforward_streama cancel signal (abroadcast/NotifyinRouterState, or a live-connection registry) andselect!on it in the loop, firing it fromlan_disableand whenever the active repo changes.should-fix —
src-tauri/src/lan/auth.rspair_submit. The "single active session → at most one device" invariant is breakable by concurrent submissions. Theexpectedproof is computed inside thestate.pairinglock, but the session is cleared (*guard = None) only aftermint_device/persist_device, in a separate lock acquisition. Two concurrentPOST /api/pairrequests carrying the correct proof both acquire the lock in turn, both still see the live session (neither has cleared it), both passconstant_time_eq, and both mint + persist a bearer — a single PIN entry yields multiple tokens. Exploitability is low (requires the PIN + concurrent timing on the LAN), but it contradicts the documented single-session model and means one visible "pair" can leave extra tokens. Fix:guard.take()the session (or null itschallenge) inside the same locked block once the proof verifies, before releasing, so a racing request hitspairing_inactive.Docs / project conventions
CLAUDE.md, a user-facing feature must updateREADME.md(Highlights/Features) and the marketing sitecapabilitieslist (site/src/pages/index.astro, non-AI → both views) in the same change. This PR updates the in-app help guide (content.ts) and adds a changelog fragment but touches neither README nor the site. Given the size of the added help section, this reads as user-facing enough to warrant at least the README bullet + site capability line — or make the deferral a deliberate, stated call (the convention only sanctions skipping those for very minor features).Readability / UX
src/features/settings/CompanionSection.tsx,useLanDevices({ enabled }). The device list is gated onenabled, so while sharing is off the list is empty and a previously-paired device can't be seen or revoked — but the help guide states you can "Revoke any of them at any time," and a persisted device becomes valid again the moment sharing is re-enabled. Consider allowing the list (and revoke) while off, or softening the guide wording.Everything else I checked holds up: the
FanoutSinkdesktop leg is byte-for-byte unchanged with the zero-subscriber clone guard,StreamGuardclears on every exit path, thediff_filecontainment (lexical + canonicalizedstarts_with+ case-insensitive.git-component reject) is correct and well-tested, the host/origin guard is a sound DNS-rebind defense, and the lifecycle mutex correctly serializes enable/disable.Posted by GitDesktop — AI output, verify before acting on it.
Ticket changed by: theBGuy
Originally posted by: theBGuy
Round-6 closure — this PR was (accidentally) squash-merged into
epic/lan-companionmid-round, so the round-6 findings' fixes land in the follow-up PR #72 (fix/lan-round6-hardening→epic/lan-companion), which contains exactly the round-6 delta and carries the full dispositions on its own record:monitor_cutbroadcast severing in-flight sockets on disable, rebind, actual repo change, and (an addition beyond the finding — same hole class, upgrade-time-only auth) device revoke.pair_submitdouble-mint race → accepted, fixed in [#72]: verify + consume (guard.take()) are now atomic under the pairing lock, mint/persist outside; duplicate submissions getpairingInactive.Review continues on [#72].
Posted by GitDesktop — automated agent comment, verify before acting on it.
Related
Tickets:
#72