Boot Flow¶
The configuration chain (beetroot.yaml → helper sh)¶
Researcher-facing configuration lives in beetroot.yaml and flows
through pydantic models. The container-side helpers (docker/*.sh)
can't read YAML — Android's toybox sh has no YAML parser. Instead,
every container-side path is read from a BEETROOT_* env var with a
safe default, and the CLI plumbs the resolved values from the YAML
into the container's environment via the bundled compose template's
environment: block:
beetroot.yaml
↓ pydantic models (config.py — InstanceConfig, Magisk, Frida, ...)
↓ config.render_env()
instance .env file (per-instance, in the instance directory)
↓ docker compose --env-file <.env> + the bundled compose template's
`environment:` block (one ${KEY} per BEETROOT_* var)
container env vars (visible to PID 1 = Android `init`)
↓ Android init propagates env to services it spawns
helper shell scripts (entrypoint.sh / magisk-path.sh / magisk-config.sh / magisk-env.sh /
flash-modules.sh / activate-zygisk.sh / launch-frida.sh)
reads `${BEETROOT_FRIDA_BIN:-/data/local/tmp/frida-server}` etc.
Everything is YAML-config-driven at the user-facing boundary; the
env-var layer exists strictly because toybox sh can't read YAML.
v0.3 had two breakages in this chain (the bundled compose template
hardcoded its mount targets, and Stealth.denylist was never plumbed
past pydantic); v0.4's T2 fixed both, so the chain is now
end-to-end. Adding a new helper-script env var means three coordinated
edits: declare the field on the matching pydantic model, emit the
KEY=value line from render_env, and add ${KEY} substitution to
the bundled compose template (the helper's ${KEY:-default} fallback
keeps the script runnable under a bare docker run without a
Beetroot-rendered .env).
There is no Docker ENTRYPOINT¶
The Beetroot image has no ENTRYPOINT and no CMD. The container starts with redroid's default boot process, where /init runs as PID 1 — exactly as it does on a real Android device.
This is intentional. redroid's /init is Android's init process, and it needs to be PID 1 to drive the Android boot sequence (mounting virtual filesystems, starting services, launching Zygote, etc.). Replacing it with a Docker entrypoint script would break the Android environment.
How entrypoint.sh gets invoked¶
Since there's no Docker entrypoint, Beetroot uses Android's own init system to run its configuration script.
At build time, docker/stealth.rc is copied to /system/etc/init/stealth.rc inside the image. Android's init parses all .rc files in /system/etc/init/ at startup. stealth.rc registers a one-shot service that fires when sys.boot_completed=1 — the property Android sets when the boot sequence is done.
The service runs as:
The u:r:magisk:s0 SELinux context gives the script the same permissions as Magisk itself, which is what it needs to call magisk --sqlite and magisk --install-module.
Boot sequence¶
sequenceDiagram
participant D as Docker
participant I as Android init (PID 1)
participant E as entrypoint.sh
participant M as Magisk (Zygisk)
D->>I: container start → /init runs as PID 1
I->>I: parse stealth.rc (from /system/etc/init/)
I->>I: start Android services, mount filesystems
I->>M: start Zygote → Zygisk initialises
M->>I: set sys.boot_completed=1
I->>E: trigger exec_background u:r:magisk:s0 root root -- /system/bin/sh /entrypoint.sh
E->>E: prepend /sbin (the magisk binary's dir) to PATH
E->>E: wait for /data/adb/magisk.db to exist
E->>M: magisk --sqlite: enable Zygisk + denylist (record prior zygisk value)
E->>M: magisk --sqlite: add denylist entries
E->>E: populate /data/adb/magisk (MAGISKBIN) from /system/etc/init/magisk + magisk.apk
E->>M: magisk --install-module for each zip in /data/adb/modules_update
E->>I: setprop ctl.restart zygote (only if Zygisk was newly enabled this boot)
I->>M: zygote restarts → Zygisk injects + modules load
E->>E: launch /data/local/tmp/frida-server &
E->>E: wait (keeps frida-server as child)
entrypoint.sh step by step¶
Each numbered step below lives in a dedicated helper (see Boot Scripts for per-helper contracts). The entrypoint itself is a few lines of glue that sources the helpers in order.
-
Make
magiskresolvable. (magisk-path.sh.)entrypoint.shis launched by Android init (stealth.rc'sexec_background), which inherits init's default service PATH —/system/bin:/system/xbin:/vendor/bin:/product/bin:…— and that does not include the directory Magisk installs itsmagiskbinary into (/sbin/magiskon the redroid Magisk image). Every later helper calls baremagisk, so this runs first and prepends the first directory (fromBEETROOT_MAGISK_DIRS, default/sbin:/debug_ramdisk) that actually holds an executablemagisk. Without it, the daemon wait below would fail every probe and time out, aborting the whole boot before anything is configured. It's a no-op whenmagiskalready resolves. -
Wait for the Magisk daemon. (
magisk-config.sh.) Pollsmagisk --sqlite "SELECT 1"in a bounded loop (default 120 one-second attempts, ~2 minutes — conservative because a first boot of redroid+Magisk can legitimately take a while). The DB at/data/adb/magisk.dbis created by Magisk during its own initialization, which happens during the Zygote start. Without this wait, the SQL writes below would silently no-op. If Magisk never answers (broken or missing), the helper exits 1 and the boot configuration aborts loudly — the error surfaces indocker compose logsinstead of the container hanging half-configured forever. -
Configure Magisk via SQL. (
magisk-config.sh.) Callsmagisk --sqliteto enable Zygisk and the denylist, then inserts each package frommagisk.denylistas a denylist entry. It also records the priorzygiskvalue before the write so step 6 knows whether this boot is the one that flips Zygisk on. The denylist write takes effect the next time Zygisk reads the DB. -
Populate the Magisk binary directory. (
magisk-env.sh.) The redroid-script image leaves/data/adb/magisk(MAGISKBIN) empty — on a real phone the Magisk app finishes the install the first time it's opened, copying the binaries and extracting the per-install scripts (util_functions.sh,module_installer.sh, …) out ofmagisk.apk. Headless redroid never runs that, somagisk --install-modulewould abort with "Incomplete Magisk install". This helper replicates the app's environment-fix headlessly: it copies the Magisk binaries from/system/etc/init/magiskandbusybox unzips the asset scripts out ofmagisk.apkinto MAGISKBIN. It runs before the flash step for that reason, and is idempotent (it skips whenutil_functions.shis already present). -
Flash modules. (
flash-modules.sh.) Iterates every*.zipin/data/adb/modules_update(the bind-mounted<instance-dir>/modules/directory — v0.4 T4 moved the target from the Beetroot-invented/flash_dirto Magisk's well-known staging dir) and callsmagisk --install-module <zip>. Modules that are already installed are reinstalled safely (Magisk handles idempotency). A module that fails to install is logged with a[!]warning and skipped — boot continues. -
Activate Zygisk. (
activate-zygisk.sh.) Zygisk only injects zygote at zygote start, but step 3 enables it afterboot_completed— when the first zygote has already started without it. So on the first boot of a fresh instance (where step 2 recorded a0/missing prior value) this helper restarts zygote once viasetprop ctl.restart zygote, making Zygisk — and any Zygisk module just flashed (e.g. LSPosed) — active without the user having tobeetroot restart. On later bootszygiskis already1, so magiskd injects the first zygote and no restart fires. Opt out withBEETROOT_ZYGOTE_RESTART=0. -
Launch Frida (if opted in). (
launch-frida.sh.) If/data/local/tmp/frida-serveris executable, starts it in the background with&. When the instance'sbeetroot.yamlomits thefrida:block (v0.3+ default), this path is a 0-byte non-executable placeholder and the launch is skipped — no Frida process inside the container. -
wait. (Back inentrypoint.sh.) The script blocks onwaitso the shell process stays alive. If Frida was launched, this also keeps it attached to the Docker container's process tree and meansdocker compose logsstreams Frida's stderr alongside the entrypoint output.
Shell environment¶
entrypoint.sh runs with /system/bin/sh — Android's toybox-derived shell. This is not bash or dash. It supports basic POSIX sh features but not bashisms like [[ ]], arrays, or <(process substitution). The script is written for toybox compatibility — do not introduce bash-specific syntax if you modify it.
Helper scripts¶
entrypoint.sh is slimmed-down glue that sources six helpers in order — magisk-path.sh, magisk-config.sh, magisk-env.sh, flash-modules.sh, activate-zygisk.sh, launch-frida.sh. Each helper reads its container-side paths from a BEETROOT_* env var with a safe default, so v0.4's stealth-posture path randomization can swap paths per-build without touching helper code.
For the per-helper contracts (env vars, idempotency, exit semantics) and the modify-helpers checklist, see Boot Scripts.