R1 in-app mesh node — reboot-survival without root
How a Rabbit R1 runs a resilient distributed-inference node inside its launcher app, surviving reboots autonomously, with no root on a stock Enforcing GSI. This is the keystone of catch-and-release (docs/R1_RESILIENT_MESH.md component 5b): plug in, equip, unplug and walk away — the device re-joins the mesh on every boot by itself.
Status: PROVEN on hardware (2026-05-24, R1 919109A4H16000691813). Rebooted the live
entry node; it rejoined the mesh autonomously in ~12 s (/chain → 12095), zero manual
intervention.
The problem: an app can't use su
Earlier 5b attempts had the Aeon Shell app run the node via su 0 sh -c "…boot.sh". That
cannot work on this GSI, for two independent reasons:
/system/xbin/suis-rwsr-x--- root shell— only root and the shell group can exec it. The app runs asu0_a121(not inshell), so it can't even invokesu. The adb shell can (it's uid 2000 /shell); the app can't.- SELinux is Enforcing; the
untrusted_appdomain may neither execsunor exec a binary out of/data/local/tmp.
So any design where the app shells out to a root helper or to /data/local/tmp/fat-station
is dead on-device. (The old spawn_mesh_boot_hook su path in aeon-shell is superseded.)
The mechanism: exec the node from the app's own lib dir
Android does let an app exec a binary from its nativeLibraryDir (the extracted
lib/<abi>/ of its own APK) under untrusted_app. So:
- Ship the node in the APK.
fat-stationis a static aarch64 binary, so it ships verbatim aslibfatstation.soinjniLibs/arm64-v8a/. (Naming itlib*.sois what gets it packaged + extracted.) Requiresandroid:extractNativeLibs="true"(or gradlepackaging.jniLibs.useLegacyPackaging = true) so it lands on disk as an executable file (-rwxr-xr-x) rather than staying compressed in the APK. - The launcher starts it on boot. Aeon Shell is the device's default home, so it
launches on every boot.
R1HardwarePlugin.startMeshNode()(called from the guarded pluginload()) execs…/nativeLibraryDir/libfatstation.soas the app uid. - Config from
/data/local/tmp.untrusted_appcan read world-readable files in/data/local/tmp(the dir is0771, traversable; the knot is0666). The launcher reads/data/local/tmp/mesh-node.conffor the node's role/layers/peers/etc. and reads the knot in place — no knot delivery needed (the app's external files dir is locked by scoped storage anyway). - Wakelock + WifiLock without root. Instead of the root
/sys/power/wake_lock, the app holds aPARTIAL_WAKE_LOCK(normalWAKE_LOCKpermission) so the CPU keeps running after release, plus aWifiLock(WIFI_MODE_FULL_HIGH_PERF)so the radio stays hot. This is the screen-off-but-active design: the display is allowed to SLEEP (shortscreen_off_timeout, nostayon) to save power and — more importantly on the MT6765 — heat (a lit panel is watts + thermal throttle for zero compute); the node keeps serving because the CPU (wakelock) and WiFi (WifiLock + no-doze) stay up with the screen dark.equip.shalso setswifi_sleep_policy=neveras belt-and-suspenders. - DHCP-safe. The launcher passes no
--advertise-host; fat-station auto-detects thewlan0IP at runtime (detect_local_ip), so the node is correct after a reboot even if the lease changed.
boot ─▶ Android launches default home (Aeon Shell)
─▶ plugin load() ─▶ startMeshNode()
├─ read /data/local/tmp/mesh-node.conf
├─ acquire PARTIAL_WAKE_LOCK
└─ exec <nativeLibraryDir>/libfatstation.so --knot … --role … --layers …
--port … --gossip-port … --node-id … [--peers …] [--admit]
─▶ node binds 0.0.0.0:<port>, joins gossip, serves /chain ── rejoined, hands-offmesh-node.conf format
KEY=VALUE, one per line, # comments. Written to /data/local/tmp/mesh-node.conf.
Preferred: gossip self-election (no hardcoded role/layers — replica coverage settles +
evolves from the live mesh):
STAGES=0..12,12..24 # the model's stage plan; the node elects its under-replicated stage
SEED=http://10.0.0.199:8090 # a live node to read the gossip view from
TARGET_REPLICAS=2 # replicas/stage to settle toward (default 2)
KNOT=/data/local/tmp/qwen05-dense.knot # used if present (USB-pushed)
KNOT_URL=https://<r2-bucket>/qwen05-dense.knot # else downloaded from R2 on first boot
NODE_ID=auto # auto -> r1-<bitwise hash of ANDROID_ID> (privacy; re-flash-stable)
PORT=8090
GOSSIP_PORT=9090
PEERS=10.0.0.199:9090 # seed gossip endpoints (omit for the first node)
# ADMIT=1 # optional: qspec-gate next-stage peersThe node passes --auto-stage --stages --seed to fat-station, which queries the seed at
startup and elects the most under-replicated stage (RAM-aware) before loading the model.
PROVEN on R1: a fresh node self-elected 12..24 role=exit from gossip, giving the exit
stage 2 replicas.
Knot acquisition: the launcher uses the USB-pushed KNOT if present, else a cached
prior download, else downloads KNOT_URL from R2 into the app's files dir (first-boot
autonomy — too many knots to push them all).
Explicit pin (debug / fixed topology): set ROLE + LAYERS instead of STAGES; the
launcher passes --role/--layers and skips election.
Defaults: KNOT=/data/local/tmp/qwen05-dense.knot, PORT=8090, GOSSIP_PORT=9090,
NODE_ID=auto (-> r1-<hash> from the device ANDROID_ID; the launcher passes --hwid),
TARGET_REPLICAS=2.
Device prereq: WebView provider
Some R1 BSP builds ship com.android.webview but leave the WebView provider unset, which
crashes the Tauri launcher at WebView init (MissingWebViewPackageException: No WebView installed). equip.sh --apk runs
cmd webviewupdate set-webview-implementation com.android.webview to select it (along with
the rest of the post-flash OS prep).
What was proven
On R1 919109A4H16000691813:
- The real app (
untrusted_app, notrun-as) execslibfatstation.sofromnativeLibraryDir— captured fat-station's own usage output in logcat. - It reads the knot from
/data/local/tmp, loads the model, binds the LAN port, and serves correct inference: single-noderole=both/chain→ argmax 12095 @16.82. - The launcher reads
mesh-node.confand brings up the configured node (role=entry layers=0..12);/chainthrough it to the exit replica → 12095. - Reboot: rebooted the entry node, touched nothing — it rejoined in ~12 s post-boot,
/chain→ 12095.
Cold tap-to-answer on one R1 running the whole model in-app is ~16 s (app launch ~7.6 s + node spawn + knot load ~2–3 s + first inference ~3.4 s). The pipeline split (≈0.77 s warm) and the teleport cache (≈17 ms on a repeated prefix) bring repeated/distributed inference far below that.
Catch-and-release runbook (in-app node)
Per device, over USB (one-time prep), then unplug and release:
- Flash the Android 16 image with the mtkclient "unknot" toolkit in
packages/aeon-r1-unknot(the R1 is a MediaTek MT6765). Only hardware step. - Stage the knot (once; world-readable, persists across reboot):
adb -s <serial> push qwen05-dense.knot /data/local/tmp/qwen05-dense.knot(or setKNOT_URLand let the node download it from R2 on first boot). - Equip (one command) —
scripts/r1/equip.sh -s <serial> -n r1-N --stages 0..12,12..24 --seed http://<live-node>:8090 --apk <apk>. This is self-contained: installs the APK (carrieslibfatstation.so), does the OS prep (sets Aeon Shell as the home launcher — required, it's what runs the node on boot — WebView provider, lockscreen off, provisioned, audio/camera), writes the gossip self-electionmesh-node.conf, and launches. The node auto-elects its stage and joins. - Release: unplug. The node keeps serving (wakelock) and re-joins on every boot.
Inspect any node: curl http://<ip>:8090/mesh/hive (the 2002 brain digest) or
/mesh/peers.
Building the APK
Toolchain (provision once): Android SDK + NDK 26.x + platforms;android-34 +
build-tools;34.0.0 via sdkmanager; JDK 17; cargo install tauri-cli (v2).
cd open-source/aeon-shell
# package the node binary into the app (until the build hook does this automatically):
cp ../gnosis/distributed-inference/target/aarch64-unknown-linux-musl/release/fat-station \
src-tauri/gen/android/app/src/main/jniLibs/arm64-v8a/libfatstation.so
export ANDROID_HOME=~/Library/Android/sdk JAVA_HOME=$(/usr/libexec/java_home -v 17 2>/dev/null || echo /opt/homebrew/opt/openjdk@17/libexec/openjdk.jdk/Contents/Home)
export ANDROID_NDK_HOME=$ANDROID_HOME/ndk/26.1.10909125
# dist/ is prebuilt; pnpm is broken repo-wide, so skip the frontend step:
cargo tauri android build --debug --target aarch64 -c '{"build":{"beforeBuildCommand":""}}'
# -> src-tauri/gen/android/app/build/outputs/apk/universal/debug/app-universal-debug.apkFast repackage (only the .so or Kotlin changed — skips the Rust app rebuild): the
gradle rustBuild* tasks fail outside the cargo tauri wrapper (it tries a CLI WebSocket
and gets connection-refused). Since both .so libs already sit in app/src/main/jniLibs,
copy the new libfatstation.so there and assemble with the rust tasks excluded:
cd src-tauri/gen/android
./gradlew :app:assembleUniversalDebug \
-x rustBuildArm64Debug -x rustBuildArmDebug -x rustBuildUniversalDebug \
-x rustBuildX86_64Debug -x rustBuildX86DebugTradeoffs and remaining polish
- No root means no kernel wakelock; the app
PARTIAL_WAKE_LOCK(CPU) +WifiLock(FULL_HIGH_PERF)(radio) +deviceidle disable(no doze) keep the node serving with the screen asleep. On the Android-13 GSI unitsequip.shALSO holds the root/sys/power/wake_lockas a second layer; on the Android-16 BSP units root for that path fails (WARN: wakelock not held) so they rely on the app locks alone — reboot-survival is proven on both. Deep-doze on a long-released battery device is the residual risk. - Battery / thermal telemetry reads "unknown" in-app. The gossiped
NodeStatuscarriesbattery_pct=255andtemp_dc=0on these nodes becauseuntrusted_app(SELinux) can't read/sys/class/power_supplyor/sys/class/thermal. The jam-relevant signals (free RAM, load, in-flight) ARE live, sojammeddetection works regardless. - Reproducible packaging (TODO): copying
libfatstation.sointojniLibsand theextractNativeLibs="true"flag currently live in generated files — wire a build hook + persist the flag (tauri.conf / manifest template) so a clean build bundles the node. - equip integration (TODO): have
scripts/r1/equip.shwritemesh-node.confand install the APK so the in-app node is a one-command provision. - Remove the dead
suboot hook in aeon-shelllib.rs(superseded by this). - Redundant-spawn: the launcher starts a node on every app launch; if one is already bound to the port the new one fails to bind (harmless). A "already-running" check is a nicety.
Why this matters
The mesh node is no longer an external root-managed binary babysat over adb — it's part of the device's own OS surface. A released R1 is a self-contained, reboot-surviving, quality-gated inference node. Throw N of them in a room; they power-cycle, they brick, they come back on their own.