forgo.cloud
Sign in
Repo workspace

forkjoin-ai/gnosis

R1 in-app mesh node — reboot-survival without root

distributed-inference/docs/R1_INAPP_NODE.md
forkjoin-ai/gnosis

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/su is -rwsr-x--- root shell — only root and the shell group can exec it. The app runs as u0_a121 (not in shell), so it can't even invoke su. The adb shell can (it's uid 2000 / shell); the app can't.
  • SELinux is Enforcing; the untrusted_app domain may neither exec su nor 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:

  1. Ship the node in the APK. fat-station is a static aarch64 binary, so it ships verbatim as libfatstation.so in jniLibs/arm64-v8a/. (Naming it lib*.so is what gets it packaged + extracted.) Requires android:extractNativeLibs="true" (or gradle packaging.jniLibs.useLegacyPackaging = true) so it lands on disk as an executable file (-rwxr-xr-x) rather than staying compressed in the APK.
  2. 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 plugin load()) execs …/nativeLibraryDir/libfatstation.so as the app uid.
  3. Config from /data/local/tmp. untrusted_app can read world-readable files in /data/local/tmp (the dir is 0771, traversable; the knot is 0666). The launcher reads /data/local/tmp/mesh-node.conf for 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).
  4. Wakelock + WifiLock without root. Instead of the root /sys/power/wake_lock, the app holds a PARTIAL_WAKE_LOCK (normal WAKE_LOCK permission) so the CPU keeps running after release, plus a WifiLock(WIFI_MODE_FULL_HIGH_PERF) so the radio stays hot. This is the screen-off-but-active design: the display is allowed to SLEEP (short screen_off_timeout, no stayon) 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.sh also sets wifi_sleep_policy=never as belt-and-suspenders.
  5. DHCP-safe. The launcher passes no --advertise-host; fat-station auto-detects the wlan0 IP 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-off

mesh-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 peers

The 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, not run-as) execs libfatstation.so from nativeLibraryDir — 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-node role=both /chain → argmax 12095 @16.82.
  • The launcher reads mesh-node.conf and brings up the configured node (role=entry layers=0..12); /chain through 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:

  1. Flash the Android 16 image with the mtkclient "unknot" toolkit in packages/aeon-r1-unknot (the R1 is a MediaTek MT6765). Only hardware step.
  2. Stage the knot (once; world-readable, persists across reboot): adb -s <serial> push qwen05-dense.knot /data/local/tmp/qwen05-dense.knot (or set KNOT_URL and let the node download it from R2 on first boot).
  3. 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 (carries libfatstation.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-election mesh-node.conf, and launches. The node auto-elects its stage and joins.
  4. 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.apk

Fast 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 rustBuildX86Debug

Tradeoffs 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 units equip.sh ALSO holds the root /sys/power/wake_lock as 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 NodeStatus carries battery_pct=255 and temp_dc=0 on these nodes because untrusted_app (SELinux) can't read /sys/class/power_supply or /sys/class/thermal. The jam-relevant signals (free RAM, load, in-flight) ARE live, so jammed detection works regardless.
  • Reproducible packaging (TODO): copying libfatstation.so into jniLibs and the extractNativeLibs="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.sh write mesh-node.conf and install the APK so the in-app node is a one-command provision.
  • Remove the dead su boot hook in aeon-shell lib.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.