feat(minimald): networking clean up and DNS plumbing - #717
Conversation
The forwarder control verbs (POST /services/forwarder/expose and the
:7654/:7655 proxy-publish) sent HTTP/1.0 with `Connection: close` and
read the reply to EOF. Over the KVM libkrun `add_vsock_port2(listen =
false)` shuttle, gvproxy's response is dropped when it closes the socket,
so the guest reads 0 bytes ("malformed gvproxy status line") even though
the forward was applied. apply_ingress then rolls back a *successful*
own-ip ingress attach, so the session's shell never starts (gap G-N8);
own-ip without ingress is unaffected.
Send HTTP/1.1 keep-alive (no `Connection: close`) and frame the reply by
Content-Length instead of read-to-EOF, so the server never closes first
and the response drains to the guest, which closes from its own side.
This also repairs the in-VM :7654/:7655 proxy-publish (same leg). The
native unix path (DM2) is unaffected.
Verified live on DM3 (KVM): own-ip + --ingress attach starts the shell
and publishes the lease; TC3/TC4/TC7/TC8 = 200/200/(401+200)/200, with
no startup proxy-publish warnings. Adds a regression test that hangs if
the framing reverts to read-to-EOF.
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
… DNS Two networking findings from a cross-platform (DM1/DM2) test run: - #2: switch-side ingress was unenforced. `--ingress` only gated the host-loopback publish, so a peer session or the daemon tap could reach any port on an own-IP PTask over the shared gvproxy switch (gvproxy has no per-client ACL API). Add a stateless TCP-SYN gate to the inbound relay leg (`relay_switch_to_tap`): a bare SYN (SYN set, ACK clear) to a port not in the target's ingress policy is dropped; established/return traffic, egress, and declared ports pass. UDP and non-IPv4 pass through (documented limitation). Wires the previously-dead `PolicyWarnLimiter` as the R2.7 drop-log site. - #3 / UC6: a session could not resolve a peer's `*.min.internal` name, and the own-IP lease rotates per attach. Register `<session>.<host-id>` to the current lease in gvproxy's runtime DNS (POST /services/dns/add) on every own-IP attach. gvproxy's resolver is the switch gateway every sandbox already queries, so no new resolver or resolv.conf change is needed; gvproxy's newest-wins merge picks up a rotated lease with no remove verb. Peers can now reach declared ports on another session by hostname (UC6), subject to that target's ingress (#2). Verified end-to-end on DM2 (the name registers to the live lease) plus unit tests for the frame parser and the DNS request shape. Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Gate the SSH port-forward and WireGuard-mesh surfaces behind new, off-by-default features so they compile out for now: UC7, UC2b option A, and the ssh-forward fallback are deferred (the daemon does not yet consume mesh enrolment, and ssh-forward's target resolves in the VM/host context rather than the sandbox). - New `remote-access` feature on `minimal` gates the `mesh` and `ssh-forward` (alias `forward`) subcommands and their plumbing. - New `ssh-forward` feature on `minimald` gates the `direct-tcpip` handler; with it off, a `#[cfg(not)]` stub rejects every forward so the daemon fails closed rather than relying on russh's default handler. - Drop `networking-wg` from the built features (justfile) and remove the WireGuard mesh test steps from CI and release. The mTLS reverse proxy (`networking-proxy`) is unaffected and stays enabled. Both crates compile, clippy, and test clean with and without the new features. Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
The `test-plan.md` / `test-plan.sh` pair under `03-spec-networking/` described an earlier DM1/DM2 CLI walk-through and is no longer maintained; nothing in the build, CI, or code references it. The networking spec, architecture, and diagram docs remain. Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
🚥 Pre-merge checks | ✅ 5✅ Passed checks (5 passed)
✨ Finishing Touches📝 Generate docstrings
Comment |
Extends the switch-side ingress gate (finding #2) to UDP, which the initial TCP-SYN gate left as a documented gap. UDP has no connection-establishment signal, so a stateless port check cannot tell a solicited reply (a DNS/QUIC response to an ephemeral port) from an unsolicited datagram. Add a per-PTask `UdpConntrack`: the egress relay leg records each outbound datagram's reverse flow `(remote_ip, remote_port, local_port)`, and the ingress leg allows a matching reply while dropping UDP to an undeclared port that matches no flow. Declared UDP ingress ports (the internal port of a UDP port-mapping) are always allowed. One flow table is shared between the two relay tasks; entries expire after 120s with an opportunistic sweep bounding the table. Refactors the frame parse into `parse_ipv4_l4`, reused by the TCP and UDP predicates. This closes UC6 for UDP round-trips (a peer reaching a declared UDP port, and its reply, both work) and completes spec Req 6's "TCP and UDP subject to target ingress." Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
…and-dns # Conflicts: # .github/workflows/ci.yml # crates/minimal/src/lib.rs
7449a87 to
21cd8f7
Compare
Un-mothball the UC1 (no-net), UC4 (static ingress), and UC6 (PTask<->PTask over the gvproxy switch) proofs in crates/minimald/tests/netns.rs, which lost their CI coverage when the standalone ci-netns.yml was retired (#697). They are Linux-native (DM2) integration tests that need only unprivileged userns + sudo and a userspace gvproxy switch (no KVM), so they run as a `netns-integration` job in the existing ci-linux-native lane rather than a separate workflow / required check. - Add the `netns-integration` job to ci-linux-native.yml (enable userns, fetch the pinned gvproxy, run `cargo test -p minimald --test netns -- --include-ignored`); wire it into the lane's success aggregator and add the gvproxy pin to the changes filter. - Un-mothball netns.rs: update the header and #[ignore] reasons. The WireGuard mesh proof (mesh_uc7, networking-wg) stays out — that feature is disabled. The three proofs pass locally on this branch, confirming the new switch-side ingress gate does not regress UC1/UC4/UC6. Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
21cd8f7 to
8064a0b
Compare
Drop three unreferenced networking items found by a workspace-wide dead-code sweep. All are behaviour-neutral: an identical before/after run of the native-daemon and KVM-VM networking test plans produced the same results (in-sandbox isolation, host egress, own-IP egress, managed DNS proxy, static ingress, and the mTLS reverse proxy all unchanged), and the network-namespace enforcement proofs stayed green. - guest.rs: a no-op `let _ = &cidr;` — `cidr` is already consumed by the "egress up" tracing call a few lines below, so the borrow-and-drop did nothing. - session_host.rs / session.rs: the `SandboxLauncher::session` field (and its unjustified `#[allow(dead_code)]`) was constructed but never read and has no Drop side effect; remove it with the now-unused import, the struct initializer, and rename the launcher parameter to `_session`. - net/mod.rs: the `GATEWAY_MAC` re-export had no consumer anywhere in the workspace (the switch crate uses its own constant internally). clippy --workspace --all-targets -D warnings clean; minimald unit tests pass; ssh-forward / networking-proxy / remote-access gated builds pass. Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01JTLRizuWQ3GuZmBBv27Nt7
There was a problem hiding this comment.
Actionable comments posted: 1
🧹 Nitpick comments (4)
crates/minimald/src/net/switch.rs (1)
626-653: 🚀 Performance & Scalability | 🔵 Trivial | ⚡ Quick winUdpConntrack sweep can degrade to O(n) per packet under sustained high flow counts.
record_egressonly sweeps whenflows.len() > UDP_FLOW_SWEEP_AT; if live (unexpired) flows persist above that threshold,retainre-scans the entire map on every subsequent egress datagram rather than amortizing, since none of the still-fresh entries get removed.Consider rate-limiting the sweep itself (e.g., only sweep once per N inserts or once per fixed interval) so a session with many concurrent UDP flows doesn't pay a full scan per packet indefinitely.
♻️ Sketch: amortize the sweep with a counter/interval instead of a length check alone
struct UdpConntrack { flows: Mutex<HashMap<(Ipv4Addr, u16, u16), Instant>>, + last_sweep: Mutex<Instant>, }Gate the
retaincall onnow.duration_since(*last_sweep) > SOME_MIN_INTERVALin addition to (or instead of) the length check, so it can't fire on every insert once the table is persistently large.🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the rest with a brief reason, keep changes minimal, and validate. In `@crates/minimald/src/net/switch.rs` around lines 626 - 653, Rate-limit the cleanup in UdpConntrack::record_egress so a persistently large flow table does not trigger flows.retain on every insert. Track the last sweep time or an equivalent insert interval in UdpConntrack, and only sweep when the table exceeds UDP_FLOW_SWEEP_AT and the configured minimum interval has elapsed; preserve existing TTL-based removal and flow recording behavior.crates/minimald/src/net/policy.rs (1)
340-355: 🎯 Functional Correctness | 🔵 Trivial | ⚡ Quick winBody-size cap overflow is silently truncated, unlike the header-size cap.
Hitting
MAX_CONTROL_RESPONSEwhile reading headers returns an explicitErr, but hitting it while reading the body justbreaks the loop and returns whatever was buffered so far — with no signal that the response is incomplete. A non-2xx status in that case would report a truncated error body without indicating truncation, and a 2xx would be treated as fully successful even though the body was cut short.🛡️ Suggested fix: surface the truncation as an error
while remaining > 0 { if response.len() as u64 >= MAX_CONTROL_RESPONSE { - break; + return Err(io::Error::other( + "gvproxy control response exceeded the size cap before its body completed", + )); } let n = stream.read(&mut buf).await?; if n == 0 { break; } response.extend_from_slice(&buf[..n]); remaining = remaining.saturating_sub(n); }🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the rest with a brief reason, keep changes minimal, and validate. In `@crates/minimald/src/net/policy.rs` around lines 340 - 355, Update the body-reading loop in the response buffering flow to return an explicit error when MAX_CONTROL_RESPONSE is reached before the Content-Length body is fully buffered, instead of breaking and returning truncated data. Preserve normal EOF handling and successful completion when the declared body length has been read.crates/minimald/src/net/gvproxy_network.rs (2)
256-271: 🚀 Performance & Scalability | 🔵 Trivial | ⚡ Quick winBest-effort DNS registration still blocks the attach path synchronously.
Even though DNS registration failures are swallowed (
tracing::warn!only), the.awaitonregister_dns_namestill runs inline beforeOwnIpGuardis returned, so a sluggish/unresponsive gvproxy control channel adds real latency (bounded by the 5sGVPROXY_CONTROL_TIMEOUT) to every own-IP session launch, purely for a "best-effort" side effect. Consider spawning this off as a detached background task so a hiccup here can't delay attach completion at all.♻️ Suggested non-blocking registration
- if let Err(e) = crate::net::policy::register_dns_name( - &control, - crate::net::dns::DEFAULT_HOST_ID, - session_name, - lease_ip, - ) - .await - { - tracing::warn!(error = %e, session = session_name, "registering *.min.internal name on gvproxy"); - } + let control_for_dns = control.clone(); // requires ControlChannel: Clone + let session_name_owned = session_name.to_string(); + tokio::spawn(async move { + if let Err(e) = crate::net::policy::register_dns_name( + &control_for_dns, + crate::net::dns::DEFAULT_HOST_ID, + &session_name_owned, + lease_ip, + ) + .await + { + tracing::warn!(error = %e, session = %session_name_owned, "registering *.min.internal name on gvproxy"); + } + });Please verify
ControlChannelderives/implementsClone(or restructure to avoid needing it) before applying.🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the rest with a brief reason, keep changes minimal, and validate. In `@crates/minimald/src/net/gvproxy_network.rs` around lines 256 - 271, Make the DNS registration in the own-IP attach flow non-blocking by spawning it as a detached background task instead of awaiting register_dns_name inline before returning OwnIpGuard. Verify ControlChannel supports Clone and clone or otherwise safely transfer the control handle into the task; retain the existing best-effort error warning within the task.
256-271: 🗄️ Data Integrity & Integration | 🔵 TrivialStale DNS records never expire — no invalidation on teardown.
Register this PTask's
<name>.<host-id>.min.internal→ its current lease so peer sessions can resolve it (finding#3/ UC6). Done for every own-IP PTask, even with no ingress: resolvable names are how peers find each other, and the ingress gate independently governs reachability. Best-effort — a DNS hiccup must not fail an otherwise-working attach.OwnIpGuard::teardownonly removes ingress forwards, never the DNS record (there's no remove verb per thedns_add_bodydoc comment), so a name keeps resolving to a now-freed lease until/unless something re-registers the same name. If that lease is recycled to a different session before the old name is ever re-registered, the stale record briefly points a peer at the wrong tenant (mitigated, but not eliminated, by the target's own ingress gate). Worth confirming this residual staleness window is an accepted tradeoff, and whether gvproxy's DNS table growth (records accumulate indefinitely across the daemon's lifetime with no reaping) is bounded in practice.🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the rest with a brief reason, keep changes minimal, and validate. In `@crates/minimald/src/net/gvproxy_network.rs` around lines 256 - 271, Ensure the DNS record registered by register_dns_name is invalidated when the corresponding OwnIpGuard is torn down, rather than leaving stale lease mappings behind. Add or use the appropriate gvproxy DNS removal/expiry mechanism alongside OwnIpGuard::teardown, preserving best-effort error handling, and verify records cannot accumulate indefinitely when sessions end.
🤖 Prompt for all review comments with AI agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.
Inline comments:
In `@crates/minimald/src/net/policy.rs`:
- Around line 176-233: Validate session_name before dns_add_body constructs the
DNS record label, ensuring it is a valid single DNS label and rejecting or
sanitizing dots and other invalid characters. Apply this through
register_dns_name or dns_add_body, while preserving the intended lowercased
"{session_name}.{host_id}" hostname for valid input.
---
Nitpick comments:
In `@crates/minimald/src/net/gvproxy_network.rs`:
- Around line 256-271: Make the DNS registration in the own-IP attach flow
non-blocking by spawning it as a detached background task instead of awaiting
register_dns_name inline before returning OwnIpGuard. Verify ControlChannel
supports Clone and clone or otherwise safely transfer the control handle into
the task; retain the existing best-effort error warning within the task.
- Around line 256-271: Ensure the DNS record registered by register_dns_name is
invalidated when the corresponding OwnIpGuard is torn down, rather than leaving
stale lease mappings behind. Add or use the appropriate gvproxy DNS
removal/expiry mechanism alongside OwnIpGuard::teardown, preserving best-effort
error handling, and verify records cannot accumulate indefinitely when sessions
end.
In `@crates/minimald/src/net/policy.rs`:
- Around line 340-355: Update the body-reading loop in the response buffering
flow to return an explicit error when MAX_CONTROL_RESPONSE is reached before the
Content-Length body is fully buffered, instead of breaking and returning
truncated data. Preserve normal EOF handling and successful completion when the
declared body length has been read.
In `@crates/minimald/src/net/switch.rs`:
- Around line 626-653: Rate-limit the cleanup in UdpConntrack::record_egress so
a persistently large flow table does not trigger flows.retain on every insert.
Track the last sweep time or an equivalent insert interval in UdpConntrack, and
only sweep when the table exceeds UDP_FLOW_SWEEP_AT and the configured minimum
interval has elapsed; preserve existing TTL-based removal and flow recording
behavior.
🪄 Autofix (Beta)
Fix all unresolved CodeRabbit comments on this PR:
- Push a commit to this branch (recommended)
- Create a new PR with the fixes
ℹ️ Review info
⚙️ Run configuration
Configuration used: Organization UI
Review profile: CHILL
Plan: Pro
Run ID: a11d1daf-1587-4f6a-b478-7daa66b2ed77
📒 Files selected for processing (18)
.github/workflows/ci-linux-native.yml.github/workflows/ci.yml.github/workflows/release.ymlcrates/minimal/Cargo.tomlcrates/minimal/src/lib.rscrates/minimald/Cargo.tomlcrates/minimald/src/connection.rscrates/minimald/src/guest.rscrates/minimald/src/net/gvproxy_network.rscrates/minimald/src/net/mod.rscrates/minimald/src/net/policy.rscrates/minimald/src/net/switch.rscrates/minimald/src/session.rscrates/minimald/src/session_host.rscrates/minimald/tests/netns.rsdocs/specs/03-spec-networking/test-plan.mddocs/specs/03-spec-networking/test-plan.shjustfile
💤 Files with no reviewable changes (4)
- docs/specs/03-spec-networking/test-plan.md
- .github/workflows/release.yml
- docs/specs/03-spec-networking/test-plan.sh
- .github/workflows/ci.yml
| /// A gvproxy DNS zone-add request body for `POST /services/dns/add` | ||
| /// (gvproxy's `types.Zone`): registers `records` under the DNS zone `name`. | ||
| /// | ||
| /// gvproxy merges on re-add with newest-first precedence and resolves to the | ||
| /// first matching record, so re-registering a name with a new IP makes the | ||
| /// newest win — how a rotated lease is picked up with no remove verb. | ||
| #[derive(Debug, Serialize)] | ||
| struct DnsZone { | ||
| name: String, | ||
| records: Vec<DnsRecord>, | ||
| } | ||
|
|
||
| /// A single `name → ip` answer within a [`DnsZone`]. | ||
| #[derive(Debug, Serialize)] | ||
| struct DnsRecord { | ||
| name: String, | ||
| ip: String, | ||
| } | ||
|
|
||
| /// Registers `<session_name>.<host_id>` in gvproxy's `min.internal.` DNS zone, | ||
| /// pointing at the PTask's current switch lease (finding #3 / UC6). | ||
| /// | ||
| /// gvproxy's resolver is the switch gateway (`100.64.0.1`) that every own-IP | ||
| /// sandbox's `resolv.conf` already targets, so this makes a PTask's | ||
| /// `*.min.internal` hostname resolvable *from a peer session* — with no new | ||
| /// resolver process and no `resolv.conf` change. The zone `Name` carries the | ||
| /// trailing dot gvproxy matches DNS queries against; the record label is | ||
| /// lowercased (gvproxy matches labels case-sensitively). | ||
| /// | ||
| /// # Errors | ||
| /// | ||
| /// Returns the I/O error from the gvproxy control request (non-2xx or transport). | ||
| pub async fn register_dns_name( | ||
| control: &ControlChannel, | ||
| host_id: &str, | ||
| session_name: &str, | ||
| lease_ip: Ipv4Addr, | ||
| ) -> io::Result<()> { | ||
| post_json( | ||
| control, | ||
| "/services/dns/add", | ||
| &dns_add_body(host_id, session_name, lease_ip), | ||
| ) | ||
| .await | ||
| } | ||
|
|
||
| /// Builds the `/services/dns/add` zone body for a PTask. Split out so the exact | ||
| /// wire shape (trailing-dot zone, lowercased label, dotted-quad IP) is unit-testable | ||
| /// without a live gvproxy. | ||
| fn dns_add_body(host_id: &str, session_name: &str, lease_ip: Ipv4Addr) -> DnsZone { | ||
| DnsZone { | ||
| name: format!("{}.", crate::net::dns::HOSTNAME_SUFFIX), | ||
| records: vec![DnsRecord { | ||
| name: format!("{session_name}.{host_id}").to_ascii_lowercase(), | ||
| ip: lease_ip.to_string(), | ||
| }], | ||
| } | ||
| } |
There was a problem hiding this comment.
🎯 Functional Correctness | 🟡 Minor | ⚡ Quick win
🧩 Analysis chain
🏁 Script executed:
#!/bin/bash
# Check whether session names are validated/constrained anywhere before reaching DNS registration.
rg -nP -C3 '\bfn\s+(validate_policy|validate_name|session_name)\b' crates/sessions crates/minimaldRepository: gominimal/minimal
Length of output: 857
🏁 Script executed:
#!/bin/bash
set -euo pipefail
# Map the relevant file and search for session name creation/validation and DNS registration call sites.
echo '--- crates/minimald/src/net/policy.rs outline ---'
ast-grep outline crates/minimald/src/net/policy.rs --view expanded || true
echo '--- session_name / register_dns_name occurrences ---'
rg -n -C 3 '\bsession_name\b|register_dns_name\(' crates/minimald crates/sessions
echo '--- potential session name type/constructor definitions ---'
rg -n -C 3 '\bstruct\s+Session\b|\bstruct\s+.*Session.*\b|\bnew_session\b|\bcreate_session\b|\bsession\s*name\b' crates/sessions crates/minimaldRepository: gominimal/minimal
Length of output: 43451
🏁 Script executed:
#!/bin/bash
set -euo pipefail
echo '--- SessionConfig and any name validation ---'
rg -n -C 4 '\bSessionConfig\b|\bname\s*:\s*String\b|\bname\s*:\s*&str\b|\bvalidate_.*name\b|regex|^[[:space:]]*name[[:space:]]*[:=]' crates/minimald crates/sessions crates/minimald_rpc
echo '--- create_session boundary and config fields ---'
rg -n -C 4 '\bcreate_session\(' crates/minimald/src/sessions.rs crates/minimald/src/rpc.rs crates/minimald/src/session_host.rs crates/minimald/src/net/dns.rs crates/minimald/src/net/gvproxy_network.rs
echo '--- session name tests / invalid name cases ---'
rg -n -C 4 'invalid.*name|name.*invalid|session name|dot|hostname|label' crates/minimald crates/sessionsRepository: gominimal/minimal
Length of output: 50374
🏁 Script executed:
#!/bin/bash
set -euo pipefail
echo '--- locate minimald_rpc SessionConfig definition ---'
fd -a '.*' . | rg '/(minimald_rpc|rpc\.rs|lib\.rs|mod\.rs|types\.rs)$' | head -n 200
echo '--- SessionConfig occurrences with nearby context ---'
rg -n -C 3 'pub struct SessionConfig|SessionConfig \{' crates . --glob '!target/**' --glob '!dist/**' --glob '!build/**'
echo '--- name validation around create_session/build_record ---'
sed -n '60,110p' crates/minimald/src/sessions.rs
echo '---'
sed -n '960,1025p' crates/minimald/src/sessions.rs
echo '---'
sed -n '1,140p' crates/minimald/src/net/dns.rsRepository: gominimal/minimal
Length of output: 24126
🏁 Script executed:
#!/bin/bash
set -euo pipefail
echo '--- CLI/session name definition ---'
rg -n -C 4 '\bname\b.*(String|Option<String>|Arg|value_parser|validator)|SessionConfig \{|args\.name|struct .*Args|clap' crates/minimal/src/lib.rs crates/minimald-rpc/src/lib.rs crates/minimald/src/sessions.rs
echo '--- minimald-rpc SessionConfig docs ---'
sed -n '188,220p' crates/minimald-rpc/src/lib.rs
echo '--- minimal CLI config wiring ---'
sed -n '790,825p' crates/minimal/src/lib.rsRepository: gominimal/minimal
Length of output: 19514
Validate session_name before building the gvproxy DNS label. minimald_rpc::SessionConfig.name is free-form, and dns_add_body() concatenates it directly into "{session_name}.{host_id}" (crates/minimald/src/net/policy.rs:225-229). A name containing . or other invalid label characters will register a different hostname than intended. Reject or sanitize non-label input here, or enforce a stricter upstream guarantee.
🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.
In `@crates/minimald/src/net/policy.rs` around lines 176 - 233, Validate
session_name before dns_add_body constructs the DNS record label, ensuring it is
a valid single DNS label and rejecting or sanitizing dots and other invalid
characters. Apply this through register_dns_name or dns_add_body, while
preserving the intended lowercased "{session_name}.{host_id}" hostname for valid
input.
The final network YOLO
Clean up networking state so that it is in defined state and ready for handover. The missing DNS plumbing for own-ip host resolutions is now in feature-complete state. Mesh and ssh-forwarding has been feature flagged out which removes their command line invocations.
Per-sandbox egress policy is still not completed and requires spec work.
Verification
Native, KVM and HVF deployments veriifed
*.min.internalname, routed via the egress proxy, returns 200.🤖 Generated with Claude Code
Summary by CodeRabbit
New Features
Bug Fixes
Tests