feat(tunnel): pipelined full-tunnel polls, ordered writes, and STUN blocking
Merged trusted PR #1115 by @yyoyoian-pixel after local verification and a small maintainer fix on the PR branch.
---
Answered via LLM, Supervised @therealaleph
Closes#251. In Android Full mode, Telegram worked but Google search and most other websites failed silently. `apps_script` mode on the same setup was unaffected.
**Root cause**: the udpgw magic destination (`198.18.0.1:7300`) was inside `198.18.0.0/15` — the exact range tun2proxy's `--dns virtual` allocator uses to synthesise fake IPs for hostname lookups. Whenever virtual DNS assigned `198.18.0.1` to a real hostname, that hostname's traffic was intercepted by tun2proxy *itself* as a udpgw connection and dropped. Telegram was immune because it uses hardcoded numeric IPs; `apps_script` mode was immune because it never sets `--udpgw-server`.
**Fix**: move `UDPGW_MAGIC_IP` to `192.0.2.1` (RFC 5737 TEST-NET-1) — outside any virtual-DNS allocation pool. Coordinated change across the tunnel-node constant and the Android `--udpgw-server` flag.
## Back-compat
v1.9.25 tunnel-nodes still recognise the legacy `198.18.0.1:7300` for one deprecation cycle (removal in v1.10.0).
| Android | Tunnel-node | Full-mode UDP |
|---|---|---|
| v1.9.25 | v1.9.25 | ✅ fully fixed |
| ≤v1.9.24 | v1.9.25 | ⚠️ handshake works (legacy IP still recognised), but the old client still asks tun2proxy for `198.18.0.1`, so the #251 virtual-DNS collision is still live on-device |
| v1.9.25 | ≤v1.9.24 | ❌ breaks silently (old node rejects `192.0.2.1`) |
The fix lives on the client side (which magic IP it asks tun2proxy to reserve). The back-compat is on the tunnel-node side (accepting both during the deprecation window).
## Verified locally
- `cargo test --lib --release`: 231/231 ✅
- `cargo build --release --features ui --bin mhrv-rs-ui`: clean ✅
- `(cd tunnel-node && cargo test --release)`: 38/38 ✅ (+2 new tests for the IP change)
## Version bump
Cargo.toml already bumped to 1.9.25 in this PR; `docs/changelog/v1.9.25.md` pre-baked. Will combine with any other PRs landing into v1.9.25 before tagging.
Reviewed via Anthropic Claude.
Co-Authored-By: dazzling-no-more <noreply@github.com>
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
Android (#700 from @ilok67):
- Reordered MhrvVpnService.teardown() to call Native.stopProxy() FIRST. The previous order (tun2proxy.stop → tun.close → join → stopProxy) crashed SIGSEGV ~2s after Disconnect: tun2proxy's worker thread was blocked in native code on a SOCKS5 socket read; after the 2s+4s timeouts expired with the worker still alive, Native.stopProxy freed the runtime including that socket, and the worker hit use-after-free in the next read. The old comment claimed "runtime shutdown will knock the rest of the world over" — wrong, Native.stopProxy can't forcibly terminate a separate native thread, it just frees memory the other thread is still using. New order closes the socket first, the worker's blocking read returns with EOF, the worker exits cleanly through its error path, and the join is then near-instant.
tunnel-node (PR #695 from @dazzling-no-more, merged):
- Cleanup now tracks eof'd sids from drain_now's return value, not the raw atomic — was silently dropping the tail on >16 MiB buffers when EOF arrived between polls.
- Phase-1 `data` op no longer holds the sessions map across upstream write/flush — was head-of-line-blocking every other batch op.
- Mixed TCP+UDP batch wait switched from tokio::join! to tokio::select! — was paying the UDP LONGPOLL_DEADLINE (15 s) on TCP-ready bursts.
- Watcher tasks now wrapped in AbortOnDrop newtype — was leaking Arc<Inner> permits when select!'s loser arm dropped its future.
- 2 new regression tests, 35/35 pass.
Example configs:
- config.exit-node.example.json: added aistudio.google.com + ai.google.dev to default hosts (#701 — AI Studio sanctions Iran IPs).
- config.fronting-groups.example.json: PR #696 from @Shjpr9 added Reddit/Fastly/Pinterest/CNN/BuzzFeed family domains on the Fastly 151.101.x.x edge.
Tests: 179 lib + 35 tunnel-node green.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
Uses tun2proxy_run_with_cli_args (the C API) via dlsym instead of
modifying the JNI run() signature. The upstream tun2proxy maintainer
recommended this path — the CLI API accepts --udpgw-server natively.
- Cargo.toml: enable udpgw feature, remove [patch.crates-io]
- MhrvVpnService.kt: build CLI args with --udpgw-server in full mode
- Native.kt + android_jni.rs: dlsym wrapper for the C API
- Tun2proxy.kt: reverted to upstream signature
No fork, no patch, no submodule.
Co-authored-by: yyoyoian-pixel <279225925+yyoyoian-pixel@users.noreply.github.com>
Co-authored-by: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
* feat: native udpgw protocol alongside existing UDP associate
Why udpgw is needed even with UDP associate:
UDP associate (udp_open/udp_data) creates one tunnel session per UDP
destination and polls each independently. On high-latency or shaky
networks this compounds — N simultaneous UDP flows need N separate
polling loops, each paying its own batch round-trip overhead. Google
Meet calls, which fire dozens of concurrent STUN + RTP flows, stall
or fail entirely because the per-destination polling can't keep up.
udpgw multiplexes ALL UDP over one persistent TCP-like session using
conn_id framing. One batch op carries frames for many destinations.
Persistent sockets per (conn_id, dest) with continuous reader tasks
keep source ports stable — critical for protocols like Telegram VoIP
and STUN that expect replies on the same port.
Both paths coexist — they serve different traffic:
- UDP associate (SOCKS5): apps that negotiate SOCKS5 UDP relay
- udpgw (198.18.0.1:7300): TUN-captured UDP (DNS, QUIC, Meet, etc.)
tun2proxy vendored as git submodule at v0.7.20 with one transparent
commit adding udpgw_server to the Android JNI run() function.
Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
* fix: block QUIC (UDP 443) and DNS (UDP 53) from udpgw
QUIC through udpgw is slower than TCP/HTTP2 through the batch pipeline
— blocking it forces browsers to fall back to TCP, improving YouTube
and general browsing speed.
DNS is better handled by tun2proxy's virtual DNS / SOCKS5 UDP associate
path which is more reliable for single request-response exchanges.
VoIP (Telegram, Meet) still flows through udpgw normally.
Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
* fix: replace submodule with [patch.crates-io] for tun2proxy udpgw
Use the idiomatic Rust [patch.crates-io] mechanism instead of a git
submodule. Points to yyoyoian-pixel/tun2proxy fork with the udpgw
JNI parameter patch (upstream PR: tun2proxy/tun2proxy#247).
Will be removed once upstream ships the change in tun2proxy >= 0.8.
Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
* chore: pin tun2proxy patch SHA in Cargo.lock
Locks tun2proxy at dfc24ed1 so the patch resolution is recorded and
any branch rewrite is visible in the lockfile diff.
Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
* fix: use AbortHandle for ConnSocket readers to prevent FD leaks
JoinHandle::drop detaches the task without aborting it. When
udpgw_server_task is cancelled (session close), the post-loop
cleanup never runs and per-(conn_id, dest) reader tasks become
zombies holding Arc<UdpSocket> file descriptors.
AbortHandle::drop aborts the task automatically, so cleanup is
correct by construction regardless of how the parent task exits.
Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
---------
Co-authored-by: yyoyoian-pixel <279225925+yyoyoian-pixel@users.noreply.github.com>
Co-authored-by: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
buildNotif() hardcoded `proxyPort + 1` for the SOCKS5 line, ignoring
cfg.socks5Port entirely. With the default Android config
(listenPort=8080, socks5Port=1081) the foreground notification read
"Routing via SOCKS5 127.0.0.1:8081" but the real listener was on 1081 —
so users configuring per-app SOCKS5 (Telegram, etc.) against the
notification value silently failed.
Use the same `cfg.socks5Port ?: (cfg.listenPort + 1)` elvis fallback the
real listener uses, and surface both ports in the notification:
HTTP 127.0.0.1:8080 · SOCKS5 127.0.0.1:1081
Reported by vpnineh and l3est (with netstat screenshots showing the
exact mismatch).
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
When ONLY mode contains only this app or stale packages, no allowed application is successfully added. Android then treats the VPN as applying to every app, which can route more traffic than requested and can also include our own proxy traffic in the TUN path. Count successful addAllowedApplication calls and fall back to the existing ALL-mode self-exclusion behavior when none are usable.
Adds a daily-budget visualization for users worried about hitting the
Apps Script free-tier quota (20,000 UrlFetchApp calls/day).
Usage today card (desktop + Android):
- today_calls / today_bytes / today_key / today_reset_secs atomics on
DomainFronter, hooked into the bytes_relayed fetch_add path so we only
count successful relays (matching what Google actually billed)
- Daily rollover at 00:00 UTC, std-only date math (Hinnant's
civil_from_days) — no chrono/time dep pull
- StatsSnapshot extended with the four new fields + to_json() for the
Android JNI bridge
- Desktop UI renders the card right under the existing Traffic stats
with a hyperlink to https://script.google.com/home/usage for the
authoritative Google-side number
- Android UI renders the same card via Compose, polling
Native.statsJson(handle) once a second only while the proxy is up,
with an Intent(ACTION_VIEW, …) opening the dashboard URL
JNI / state plumbing:
- New Java_…_statsJson reads the Arc<DomainFronter> kept in slot_map
- VpnState.proxyHandle StateFlow so HomeScreen knows which handle to
poll without poking into the service's internal state
- MhrvVpnService publishes the handle on start, zeroes on teardown
Persian localization:
- HowToUseBody (5-step guide + Cloudflare Turnstile note) was
hardcoded English even when locale=FA. Ported to a string resource
with a full FA translation in values-fa/strings.xml. Persian users
no longer drop to English at the bottom of the screen.
Also lands the deferred Android ConfigStore.kt wiring for
passthrough_hosts (commit fe9328e shipped the Rust + desktop side).
82 tests pass (added: unix_to_ymd_utc_handles_known_epochs,
seconds_until_utc_midnight_is_bounded). Built and visually verified on
both desktop and Android emulator (mhrv_test AVD).
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
Real bug I introduced in #94: Full mode was skipping the credential check that apps_script mode enforces, but Full mode does talk to CodeFull.gs on Apps Script and needs the same auth_key + deployment ID. Users flipping to Full mode with empty fields would silently fail.
Two sites fixed:
- MhrvVpnService.kt — changed `mode == APPS_SCRIPT` gate to `mode != GOOGLE_ONLY`
- HomeScreen.kt — removed the `cfg.mode == Mode.FULL` bypass in the Start button's enabled-state
Also includes a UX improvement for the Deployment IDs editor (per-row field with add/remove buttons instead of raw newline-separated text), which makes multi-deployment setups easier to manage on Android.
Rust-side 75 tests still green, Kotlin compiles clean. Android-only diff so no Rust CI impact.
Adds a new `mode: full` that tunnels ALL traffic end-to-end through Apps Script → a remote tunnel node. Browser does TLS directly with the destination. No MITM, no CA installation needed on the client device.
Ships as part of the 3-PR series: #93 (tunnel-node service + CodeFull.gs, merged) + this (Rust-side Mode::Full + batch tunnel client) + #95 (Android UI dropdown, now rolled into this PR post-rebase).
### Architecture
- Client → mhrv-rs → script.google.com (Apps Script fetch) → tunnel-node on user's VPS → real destination
- Apps Script is the transport to reach the VPS; works even when the ISP blocks direct VPS IPs
- Batch multiplexer collects data from all active sessions and ships one Apps Script request per tick
### Safety properties of this merge
- AppsScript + GoogleOnly dispatch paths are **unchanged**; Full mode is an additive branch at the top of `dispatch_tunnel`.
- `tunnel_client.rs` is a new isolated module (387 LOC).
- `tunnel_request()` is a new method on `DomainFronter`, no change to `relay()` / `relay_parallel_range()`.
- Config: additive `Mode::Full` variant + validation tests (2 new); existing validation rules untouched.
- Local build: clean compile. `cargo test --quiet`: 75 passed (73 → 75 with 2 new config tests).
### Closes
Unblocks the feature requested in #61, #69, #100, #105, #110, #111, #113, #116.
### Testing
vahidlazio has iterated on prior review feedback. End-to-end testing with a real tunnel-node deployment will follow post-merge from @Feiabyte (volunteered in #61). Post-merge CI will exercise compile + full test matrix across all targets; any regression caught there gets a fast-follow fix.
Three user-facing fixes:
- Android Start crash in google_only mode (#73): every early-return
path in startEverything now satisfies Android 8+'s foreground-service
contract by calling startForeground before stopSelf. Previously if
you opened the app, selected google_only mode, and tapped Connect
without filling deployment ID + auth key (which google_only doesn't
need anyway), the service crashed with
ForegroundServiceDidNotStartInTimeException. Also gated the
deployment-ID requirement on mode == APPS_SCRIPT.
- google_ip auto-overwrite on Start (#71): some carriers serve poisoned
DNS for www.google.com that resolves but refuses TLS, clobbering
working IPs users had manually set. DNS lookup now only fires when
the field is blank — manual configs are preserved across Connect.
Explicit "Auto-detect" button still refreshes on demand.
- chromewebstore.google.com added to DEFAULT_GOOGLE_SNI_POOL and
DEFAULT_SNI_POOL (#75). Same family as the rest of the pool —
wildcard cert, GFE-hosted.
Major feature release across Android + desktop. Six items the user
asked for, verified end-to-end on the emulator.
Android
-------
* Unified Connect/Disconnect button. Single large button swaps
between green "Connect" (when the service is down) and red
"Disconnect" (when it's up). Tracks the real service state via a
new process-wide `VpnState` singleton flipped from the service's
startEverything() / teardown() — not optimistic, the button only
reports what the service actually did.
* Connection mode dropdown (issue #37). Two options: VPN (TUN) —
routes every app — and Proxy only — user configures per-app via
Wi-Fi proxy to 127.0.0.1:8080 (HTTP) / :1081 (SOCKS5). PROXY_ONLY
skips VpnService.prepare() entirely (no OS VPN grant prompt) and
the service just keeps the foreground listeners up. Default is
VPN_TUN so existing behaviour is preserved for users who upgrade
without looking at the dropdown.
* App splitting. In VPN_TUN mode you can pick All / Only selected /
All except selected, with a picker dialog that lists installed
user-visible apps (LazyColumn with search, "show system apps"
toggle, multi-select checkboxes). ONLY calls
`Builder.addAllowedApplication()` for each chosen package;
EXCEPT calls `addDisallowedApplication()` additive to the
mandatory self-exclude. Requires QUERY_ALL_PACKAGES — added to
the manifest along with a `<queries>` launcher-intent filter so
the picker rows can render app labels, not just package strings.
* Persian/English UI toggle with RTL. Top-bar TextButton cycles
AUTO → FA → EN → AUTO. Persian strings live in
`res/values-fa/strings.xml`; English in `res/values/strings.xml`.
`AppCompatDelegate.setApplicationLocales()` is used as the
persistence layer (plus `AppLocalesMetadataHolderService` meta
and `locales_config.xml` for the per-app-language OS entry on
API 33+). MainActivity overrides `attachBaseContext` to wrap the
context with the right locale at the earliest possible moment —
otherwise a saved preference wouldn't apply until the SECOND
process after toggling. RTL swaps automatically because Persian
is script="Arab" in Android's locale database.
* Collapsible How-to-use card. The big instruction block that used
to dominate the bottom of the screen now lives inside a
CollapsibleSection that starts expanded for a fresh install
(empty deployment URLs / auth_key) and collapsed otherwise.
* Update check auto-fires on first composition, silent-on-up-to-date,
snackbar-only-if-available. Still surfaces via the version badge
tap for manual checks.
* MhrvVpnService teardown guard was kept from v1.0.2 —
`AtomicBoolean` makes the second caller a no-op, which is the
SIGSEGV fix for "tap Stop, app closes" from before. Stress-tested
under rapid Connect/Disconnect cycles.
Desktop
-------
* Fix: Advanced section silently resetting on every Save. `ConfigWire`
was missing `fetch_ips_from_api` / `max_ips_to_scan` /
`scan_batch_size` / `google_ip_validation` — every persist dropped
them, every reload fell back to the serde defaults, user saw their
Advanced toggles reset. Added the fields to the wire struct (issue
surfaced by the user as "Advanced resets after reopening the app").
* Windows renderer fallback (issue #28). `eframe` is now built with
BOTH `glow` (OpenGL 2+) and `wgpu` (DX12/Vulkan/Metal); runtime
defaults to glow for compat but honours `MHRV_RENDERER=wgpu` for
boxes that crash with "egui_glow requires opengl 2.0+" — old
Windows hardware, RDP sessions, VMs without GPU acceleration.
`run.bat` auto-retries the UI with `MHRV_RENDERER=wgpu` if the
first launch exits non-zero, so users don't need to know about
the flag.
CI
--
* Added OpenWRT mipsel-softfloat build target (issue #26). MT7621
routers specifically need soft-float because the CPU has no FPU;
a hard-float binary segfaults on first fp op. Built via
`messense/rust-musl-cross:mipsel-musl-softfloat` docker image +
nightly Rust with `-Z build-std` (mipsel is Rust tier 3 since
1.72, no pre-built std). Marked `continue-on-error: true` — the
tier-3 target occasionally regresses and we'd rather ship the
rest of the release than block on MT7621 support.
Signature / versioning
----------------------
* versionCode 110, versionName 1.1.0; Cargo bumped to 1.1.0.
* Release APK signed with the committed `release.jks` (same as
v1.0.2), so v1.0.2 → v1.1.0 upgrades install in-place without
the uninstall-first dance.
🤖 Generated with [Claude Code](https://claude.com/claude-code)
Co-authored-by: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
Three fixes + one behaviour change from v1.0.1 reports.
APK signature is now stable (release.jks committed)
----------------------------------------------------
v1.0.0 and v1.0.1 signed release APKs with Gradle's
auto-generated debug keystore, which is randomly generated per
machine and per CI runner. Result: every upgrade failed with
INSTALL_FAILED_UPDATE_INCOMPATIBLE and users had to uninstall
first. Unfixable without a stable key.
android/app/release.jks now holds that key, committed to the
repo with the password in plaintext in build.gradle.kts. This
is fine for a FOSS sideload project without a Play Store
identity — the trust model is "trust the source tree you
pulled from," not "trust the key we hold." Anyone forking and
shipping a rebranded build should generate their own key.
One-time cost: v1.0.1 → v1.0.2 STILL requires uninstall,
because we're switching signature keys. Every upgrade from
v1.0.2 onward is clean.
Stop no longer (sometimes) closes the app
-----------------------------------------
teardown() is reachable from three paths on two threads:
1. ACTION_STOP onStartCommand branch (mhrv-teardown worker)
2. onDestroy after stopSelf (main thread)
3. VpnService revocation out-of-band (main thread)
Running the full native cleanup sequence twice races the two
threads through Tun2proxy.stop() → fd.close() →
Native.stopProxy(handle) on state that's already been
nullified — SIGSEGV source, user-visible as "tap Stop, app
disappears."
New AtomicBoolean `tornDown` gates entry: first caller wins,
every subsequent caller logs "teardown: already done" and
returns. onDestroy also wraps the call in try/catch — crashing
out of onDestroy takes the whole process with it, which is
exactly the bug we're trying to fix. Smoke-tested on emulator:
teardown now logs
teardown: begin caller=mhrv-teardown
... clean sequence ...
teardown: done
onDestroy entered
teardown: already done, skipping (caller=main)
onDestroy done
with PID unchanged throughout.
CA install now routes to the Settings search
--------------------------------------------
Old flow: `Settings.ACTION_SECURITY_SETTINGS` deep-link, then
walk "Encryption & credentials → Install a certificate →
CA certificate". That path varies wildly between OEMs (Samsung
buries it under "Biometrics and security → Other security
settings"; Xiaomi under "Passwords & Security → Privacy"; Pixel
splits it between "More security settings" and "Privacy
controls" depending on Android version). Users got lost.
New flow: open the top-level Settings app
(`Settings.ACTION_SETTINGS`) and instruct the user to use the
Settings search bar to find "CA certificate". Search is
consistent across OEMs and Android versions; the menu paths
are not. Dialog, snackbar, and `docs/android.md` copy all
updated to match.
Version bump: 1.0.1 → 1.0.2 (versionCode 101 → 102).
releases/mhrv-rs-android-universal-v1.0.1.apk replaced with
the v1.0.2 build.
🤖 Generated with [Claude Code](https://claude.com/claude-code)
Co-authored-by: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
The app is a Kotlin/Compose front-end that reuses the mhrv-rs crate
via JNI. It speaks VpnService to get a TUN fd, hands that to tun2proxy,
and funnels every app's traffic through the in-process SOCKS5 listener —
no per-app proxy setup on the device.
Two fixes in `src/proxy_server.rs` apply to desktop builds too:
* SNI peek via `LazyConfigAcceptor`. When a browser uses DoH (Chrome's
default), tun2proxy hands us a raw IP in the SOCKS5 CONNECT. Minting
a MITM cert for the IP produced `ERR_CERT_COMMON_NAME_INVALID` on
Cloudflare-fronted sites. We now read the ClientHello's SNI first
and use that both as the cert subject and as the upstream host for
the Apps Script relay (fetching `https://<IP>/...` with an IP in the
Host header gets rejected by CF anyway).
* Short-circuit CORS preflight at the MITM boundary. `UrlFetchApp.fetch()`
rejects `OPTIONS` with a Swedish "Ett attribut med ogiltigt värde
har angetts: method" error, which silently broke every fetch()/XHR
preflight and was the root cause of "JS doesn't load" on Discord,
Yahoo, and similar. Since we already terminate the TLS the browser
talks to, answering the preflight with a permissive 204 is safe —
the real request still goes through the relay.
Android-side capabilities (feature-parity with `mhrv-rs-ui` where it
fits on a phone):
* multi-deployment ID editor
* SNI rotation pool + per-SNI "Test" + "Test all" (JNI into scan_sni)
* live logs panel (JNI ring buffer drained on a 500 ms poll)
* Advanced section: verify_ssl, parallel_relay, log_level, upstream_socks5
* CA install flow that matches modern Android's reality: saves
`Downloads/mhrv-ca.crt` via MediaStore, deep-links Security settings,
then verifies post-hoc by fingerprint lookup in AndroidCAStore (the
KeyChain intent dead-ends with a Close-only dialog on Android 11+)
* Start/Stop debounced to dodge an emulator EGL renderer crash on
rapid taps
Theme matches the desktop palette exactly — always-dark, accent
`#4678B4`, card fill `#1C1E22`, 4dp button / 6dp card radii.
No dynamic color, no light scheme: the desktop is always dark and
we follow.
Build wiring:
* `Cargo.toml`: `cdylib` crate-type added; `jni` + `tun2proxy`
scoped to `cfg(target_os = "android")` so desktop builds pay
nothing.
* `src/data_dir.rs`: `set_data_dir()` override so the Android app's
private filesDir replaces the `directories` crate's desktop default.
* `src/android_jni.rs`: JNI entry points for start/stop/exportCa plus
a ring buffer draining to `Native.drainLogs()` and `testSni()` that
wraps `scan_sni::probe_one`.
* Gradle task chain runs `cargo ndk` before each assemble; post-step
normalizes tun2proxy's hash-suffixed cdylib to a stable filename
so `System.loadLibrary("tun2proxy")` works.
Verified end-to-end on an API 34 emulator: ipleak, yahoo, discord,
cloudflare.com all render; TLS is MITM-ed under our user-installed
CA; service survives rapid Stop/Start cycles.
🤖 Generated with [Claude Code](https://claude.com/claude-code)
Co-authored-by: Claude Opus 4.7 (1M context) <noreply@anthropic.com>