Configuration Reference¶
Every instance directory has a beetroot.yaml that fully describes it. This file is the source of truth. Edit it, run beetroot apply <name>, restart — the change takes effect.
The schema is validated by Pydantic on every load. Fields you omit use the defaults shown below.
Top-level structure¶
api_version: 8
lifecycle: durable # durable | ephemeral (optional; default durable)
android: ...
display: ...
resources: ...
frida: ... # optional / opt-in
modules: [...]
magisk: ...
ports: ...
binder: auto # auto | host | vm
api_version¶
Schema version this beetroot.yaml targets.
| Field | Type | Default | Description |
|---|---|---|---|
api_version |
int | 8 |
Schema version. Must match the value supported by this Beetroot release. |
Versioning policy¶
Each Beetroot release supports exactly one api_version. The current
release supports api_version: 8. Loading a YAML that pins a different
value raises one of two errors:
- Unknown / future version (
0,99, …): raises aValidationErrorwith a pointer toCHANGELOG.mdfor the migration steps. - Non-additive migration required (e.g.
api_version: 3with astealth:key renamed tomagisk:in v4,display.gpu_moderenamed todisplay.renderingin v5, a vendor-namedandroid.gappsin v7, or aports:mapping with a non-well-known key in v8): raises a clear migration error naming the changed field and pointing atCHANGELOG.md.
Auto-bump (legacy versions): api_version: 1 (v0.2) through 7 are
recognised legacy values and auto-bumped on load with a one-line warning —
unless the YAML still uses a key that a non-additive bump renamed
(stealth: for 3→4, display.gpu_mode for 4→5, a vendor-named
android.gapps for 6→7), in which case the migration error fires instead.
The additive bumps (the lifecycle field for 5→6, the lossless ports
mapping → list translation for 7→8) are silent. Persistence happens on the
next beetroot apply.
Omitting the field is equivalent to writing the currently supported value
— existing instance YAMLs without api_version keep working. Pinning the
field explicitly is recommended once you're committing an instance YAML to
source control, so that a future Beetroot release with a breaking schema
change fails loud instead of silently reinterpreting your config.
All example YAMLs declare api_version: 8
explicitly as the first field. When the schema breaks, the constant
SUPPORTED_API_VERSION in src/beetroot/config.py is bumped and a
migration entry is added to CHANGELOG.md.
lifecycle¶
Whether this instance's /data is meant to survive or is throwaway.
| Field | Type | Default | Description |
|---|---|---|---|
lifecycle |
durable | ephemeral |
durable |
Persistence intent — a label + guardrails, not a runtime persistence switch. |
durable(default) — a long-lived "research phone" whose/datamust survive (the product's namesake guarantee). Preserves today's behaviour exactly.beetroot destroyescalates its confirmation copy for these.ephemeral— a throwaway instance (CI/E2E, comparative fleets, reset between runs). Combined withvm.boot_cache: trueit opts into the warm-resume/datarevert quietly (the runtime advisory is suppressed — a reset each boot is exactly whatephemeralasked for).
Label, not a switch
lifecycle records intent and tunes guardrails; it never changes when
/data is wiped. beetroot down never wipes /data for either
value — only destroy / reset (and a vm.boot_cache warm resume) do.
Set it at create time with beetroot create --lifecycle ephemeral|durable,
or add the key to beetroot.yaml and beetroot apply.
android¶
Android version + image-tag derivation. Beetroot computes the redroid base image tag from these fields via config.base_image_tag() — you don't write the long tag yourself.
GApps is split across two axes (issue #107): an intent that says what you get, and an optional vendor escape hatch that says which distribution bakes it — for the rare app that detects or prefers a specific GApps build. Most configs only set gapps.
| Field | Type | Default | Description |
|---|---|---|---|
version |
int | 14 |
Android version. Valid: 11, 12, 13, 14. |
gapps |
enum | minimal |
GApps intent: none (no Play Services), minimal (a slim Play Services), or full (the full suite). |
gapps_vendor |
enum | (unset) | Optional vendor override: litegapps, opengapps, or mindthegapps. Unset lets the intent pick the vendor (minimal → LiteGApps, full → OpenGApps). When set alongside minimal/full, the vendor wins and the intent is overridden (a one-line note is printed). Cannot be combined with gapps: none. |
android:
version: 14
gapps: minimal # what you get; vendor is picked for you
# Pin a specific distribution for app compatibility:
android:
version: 14
gapps: full
gapps_vendor: mindthegapps
Legacy gapps: lite / gapps: mindthegapps removed
Before api_version 7, gapps fused intent and vendor into one enum (none/lite/full/mindthegapps). none and full are unchanged; the vendor-named values were split out. Replace gapps: lite with gapps: minimal and gapps: mindthegapps with gapps: full + gapps_vendor: mindthegapps (the base image is identical). Loading a YAML with the old value raises a ValidationError pointing at CHANGELOG.md.
Legacy base_image field removed
The old android.base_image: redroid/redroid:14.0.0_... field was replaced by android.version in the current schema. Loading a YAML with the legacy field raises a ValidationError pointing at this migration — see CHANGELOG.md.
display¶
Virtual display configuration for the Android framebuffer.
| Field | Type | Default | Description |
|---|---|---|---|
width |
int | 540 |
Framebuffer width in pixels. Must be > 0. |
height |
int | 960 |
Framebuffer height in pixels. Must be > 0. |
fps |
int | 3 |
Maximum framebuffer FPS. Must be > 0. 3 is enough for research; raise only if you need smooth UI. |
rendering |
enum | auto |
How redroid renders — the speed-vs-portability axis. gpu renders via the host GPU (fast, needs a GPU-capable host); software uses SwiftShader (always works, slower); auto (default) probes the host for a DRM render node (/dev/dri/renderD*) and picks gpu when present, else software. A typo (e.g. rendering: gpuu) fails at load. |
Low FPS saves resources
The default 3 FPS is intentional. redroid's GPU passthrough still costs CPU even at low FPS. For headless research (no UI interaction needed), you can reduce to 1.
Legacy gpu_mode field renamed
display.gpu_mode (redroid's host/guest vocabulary) was renamed to display.rendering (intent: gpu/software/auto) in api_version: 5. Loading a YAML with the old field raises a ValidationError with the mapping (gpu_mode: host → rendering: gpu, gpu_mode: guest → rendering: software) — see CHANGELOG.md. The default also changed from the aggressive host (which assumed a host GPU) to auto, so a headless box renders in software instead of misbehaving.
resources¶
Docker resource limits for the container.
| Field | Type | Default | Description |
|---|---|---|---|
mem |
string | 3g |
Memory limit. Docker size format: 512m, 1g, 3g, etc. Validated at load time — typos like "3gb" fail immediately. |
cpus |
float | 2.0 |
CPU limit in fractional cores. |
shared_mem |
string | 256m |
Shared memory size (/dev/shm, Docker shm_size). Docker size format. |
mem_reservation |
string | none | Soft memory floor. Docker size format. Docker scheduler reserves this for the container but allows it to use more up to mem. |
memswap_limit |
string | none | Combined memory + swap cap. Docker size format, plus Docker's documented sentinel -1 for unlimited swap. Unset → Docker's normal swap allowance applies; set it equal to mem to disable swap entirely and prevent swap storms. |
pids_limit |
int | 4096 |
Maximum number of PIDs the container can spawn. |
Legacy shm field removed
The old resources.shm field was renamed to resources.shared_mem for clarity. Loading a YAML with the legacy field raises a ValidationError pointing at this migration — see CHANGELOG.md.
Docker size format
All string memory fields (mem, shared_mem, mem_reservation, memswap_limit) must use Docker's size format: a number optionally followed by a single suffix — b, k, m, g, or t (case-insensitive). Examples: 3g, 512m, 1.5G. Values like 3gb (two-letter suffix) fail at load time with a clear error rather than being silently misinterpreted at docker compose up. memswap_limit additionally accepts Docker's documented -1 sentinel (unlimited swap); the other size fields reject -1 as a malformed size.
resources.mem vs vm.memory_mib
resources.mem is the Docker container memory cap, authoritative for binder: auto / host (redroid). For binder: vm the guest RAM is vm.memory_mib (the QEMU -m). Both knobs are deliberately kept — collapsing them into one field is deferred. Decision recorded in #104.
frida¶
Frida server configuration. Opt-in starting in v0.3 — omit the block entirely (or set it explicitly to null / ~) to disable Frida. Declare the block to opt in.
| Field | Type | Default | Description |
|---|---|---|---|
version |
string | "auto" |
Which frida-server release to stage. One of: auto (default) — match your host's installed frida-tools version so the staged server and the client you attach with agree on major+minor, falling back to latest when frida-tools isn't installed; latest — the current upstream release, resolved at download time; or a pinned major.minor.patch tag (e.g. "16.4.10") for reproducibility. auto / latest are resolved to a concrete tag at staging time; a malformed pinned tag fails at config-load. |
sha256 |
string | null | null |
Optional expected hex digest of the decompressed frida-server binary. Validated at config-load time as a 64-character hex digest (case-insensitive) so a truncated or fat-fingered value fails fast instead of after a full download; when set, it's also verified against the downloaded binary at download time, and a mismatch raises an error rather than staging a tampered binary. Requires a pinned version — a digest can't match the moving target auto / latest resolve to, so that combination is rejected at load. |
# Opt in, tracking your host frida-tools (recommended):
frida:
version: auto
# Or pin a specific server (reproducible; required if you set sha256):
frida:
version: "16.4.10"
# Default (omit the block entirely, or:):
# frida: ~
When opted in, the binary is downloaded from github.com/frida/frida/releases, decompressed (.xz), and cached at ~/.cache/beetroot/frida/ (respects $XDG_CACHE_HOME). The CLI then copies it into the instance directory at frida-server, which is bind-mounted into the container at /data/local/tmp/frida-server. When opted out, that same path is a 0-byte non-executable placeholder and entrypoint.sh skips the launch.
Client / server version skew
Frida requires the client and server to agree on major + minor. version: auto keeps them in lock-step automatically. If you pin a version (or use latest) that diverges from your host frida-tools, beetroot apply prints a one-line warning, because the frida client would otherwise fail to attach.
modules¶
A list of Magisk module zips to flash on the next boot. Each entry is an object with exactly one of url or path, and an optional sha256.
modules:
- url: https://example.com/Module.zip
sha256: abc123... # optional but recommended
- path: ./local-modules/MyHook.zip
# sha256: optional even for local files
Module entry fields¶
| Field | Type | Required | Description |
|---|---|---|---|
url |
string | Exclusive with path |
HTTP or HTTPS URL to a .zip. Downloaded and cached by the CLI. |
path |
string | Exclusive with url |
Path to a local .zip. Relative paths resolve against (and are contained to) the instance directory; one that escapes it is rejected. Absolute paths are read as-is. |
sha256 |
string | No | Expected SHA-256 hex digest. If provided, the CLI verifies the downloaded/local file before staging. |
Exactly one of url or path
Setting both or neither raises a Pydantic validation error at load time.
ports¶
A list of guest→host port mappings (since api_version: 8, issue #108). Each entry is a {service, guest, host} mapping; the list defaults to the three well-known services with auto-allocated host ports.
| Field | Type | Default | Description |
|---|---|---|---|
service |
string | null | null |
Optional label. adb / frida / frida_control are the well-known names the stride allocator and the adb_address / frida_address accessors key off; any other string is a free-form label for an arbitrary mapping. |
guest |
int | (required) | The container-side (guest) port this mapping exposes (1..65535). |
host |
int | null | null (auto-allocate) |
The host-side port. null auto-allocates — a stride base for a well-known service, or a dedicated extra-pool slot (40000 + index*10 + slot) for an arbitrary one. An integer pins the host port. |
# Default (omit the block): the three well-known services, stride-allocated.
ports:
- {service: adb, guest: 5555}
- {service: frida, guest: 27042}
- {service: frida_control, guest: 27043}
# Pin ADB to a stable host port; forward an arbitrary in-guest service.
ports:
- {service: adb, guest: 5555, host: 9000} # explicit host port
- {service: frida, guest: 27042} # host unset → stride default
- {service: frida_control, guest: 27043}
- {guest: 8080, host: 9090} # arbitrary, explicit host
- {service: metrics, guest: 9100} # arbitrary, auto-allocated host
If you omit the block entirely (the default), the three well-known services are stride-allocated. The list is validated: duplicate service names, duplicate guest ports, and duplicate explicit host ports are all rejected at load time. A well-known service (adb / frida / frida_control) must use its canonical guest port (5555 / 27042 / 27043 — fixed by the redroid image); a mistyped guest there is rejected, since the published host port is derived from the service name, not the guest, and a wrong guest would forward to a dead port. A frida: block additionally requires both a service: frida and a service: frida_control mapping. If a resolved host port collides with another instance, beetroot create and beetroot apply exit with a clear error before staging:
The variable-length port list is written to a per-instance compose.override.yaml (regenerated on every apply, like .env) which the CLI layers on top of the bundled compose template — a flat .env can't expand into a YAML list.
Migrating from the old ports mapping (api_version 7)
The old fixed mapping (ports: {adb: 9000, frida: ..., frida_control: ...}) was replaced by the list form. A YAML still carrying the old mapping with only well-known keys is migrated losslessly on load (one-line note) and auto-bumps to 8; a mapping with any other key raises a ValidationError naming the list shape. Rewrite the block as a list and set api_version: 8.
Arbitrary mappings under binder: vm
The binder: vm backend forwards only adb to the guest; arbitrary mappings beyond the well-known services are ignored (beetroot apply warns, mirroring the gapps/frida vm-inert advisories).
Why pin a port?
The most common reason is to keep a stable, memorable port across destroy/recreate cycles, or to coordinate with external tools (a CI pipeline, a fixed firewall rule, an IDE's run config) that already point at a specific host port. The stride allocator's index can shift if instances at lower indices are destroyed and recreated.
magisk¶
Magisk configuration, including the boot-time denylist. Entries listed here are enrolled in the Magisk SQLite database at boot time, before any app launches.
Each entry is encoded as package[/process] — a package, optionally followed by a slash and a process that belongs to it. With no slash, the process is the package itself (root is hidden from the package's main process). With a slash, the package goes into the denylist's package_name column and the process into its process column. This matters because Magisk keys the denylist on (package_name, process): Play Integrity's DroidGuard runs as the com.google.android.gms.unstable process of the com.google.android.gms package — it is not an installed package of its own, so it must be enrolled as com.google.android.gms/com.google.android.gms.unstable. Enrolling the bare com.google.android.gms.unstable as if it were a package matches no installed app, and vanilla (non-Shamiko) Magisk then never hides root there.
| Field | Type | Default | Description |
|---|---|---|---|
denylist |
list[string] | ["com.google.android.gms", "com.google.android.gms/com.google.android.gms.unstable"] |
package[/process] entries to enrol in Magisk's denylist. Both halves must match the Android package-id grammar ([a-zA-Z0-9._]+), separated by at most one / — validated at load time as SQL-injection prophylaxis. The default hides root in the GMS main process and its .unstable DroidGuard process, both under the real com.google.android.gms package. |
magisk:
denylist:
- com.google.android.gms
- com.google.android.gms/com.google.android.gms.unstable # DroidGuard process of GMS
- com.google.android.gms.persistent
- com.android.vending
Denylist vs. Shamiko
The Magisk denylist alone makes root inaccessible to listed processes. With Shamiko installed, the denylist is upgraded to a full allowlist-based hide — listed processes can't detect Magisk at all.
Migrating from stealth: (api_version 3)
The stealth: key was renamed to magisk: in api_version 4. If your beetroot.yaml still contains stealth:, Beetroot will fail at load with:
The 'stealth:' key was removed in api_version 4.
Move 'stealth.denylist' to 'magisk.denylist' and set
'api_version' to 8. See CHANGELOG.md for the migration.
Rename the key and bump api_version to the current value (8) to fix it.
binder¶
Selects how redroid obtains the kernel binder driver it needs to boot. redroid is a container, not an emulator — it runs Android's userspace against the host kernel — so binder must come from somewhere.
| Field | Type | Default | Description |
|---|---|---|---|
binder |
string | auto |
One of auto, host, vm. See the table below. |
| Value | Behaviour |
|---|---|
auto |
Use the host kernel's binder. If the host can't provide it, beetroot up prints a one-line advisory (once) and starts anyway — the container comes up but Android may not boot. This is the historical behaviour and the default. |
host |
Strict: beetroot up refuses to start (exit 1) unless the host binder is ready. Prefer this in CI, where a container that silently never boots Android is worse than a fast, clear failure. |
vm |
Opt into running redroid inside an emulated QEMU micro-VM that ships its own binder-enabled kernel — the path for hosts with no host binder at all (hardened CI, nomodule cloud sandboxes). |
binder: vm boots an emulated micro-VM
Selecting vm dispatches beetroot up to a QEMU micro-VM that ships its own binder-enabled kernel. Build the guest artifacts once with beetroot build --vm-kernel, point vm.kernel / vm.rootfs at them (or set BEETROOT_VM_KERNEL / BEETROOT_VM_ROOTFS), and run beetroot apply then beetroot up. On an x86_64 host with /dev/kvm this is near-native; without it the backend falls back to TCG (~5-20x slower — a slow first boot is expected, not a hang). The whole guest stack is x86_64, so KVM only accelerates on an x86_64 host — on a non-x86_64 host (e.g. arm64) KVM cannot virtualize the x86_64 guest and beetroot modes / doctor report the KVM path as unsupported; the guest still boots there under TCG cross-arch emulation (even slower). The slow path is never engaged automatically; binder: vm is always an explicit opt-in. See Binderless hosts (QEMU/TCG).
Frida is not yet supported under binder: vm
The micro-VM guest is network-isolated, so the vm backend is scoped to ADB forwarding (beetroot shell) only. beetroot frida-addr <vm-instance> raises a friendly "not yet supported on the 'vm' backend" error (exit 2) instead of emitting an address, beetroot doctor omits the frida.handshake row, and ls / status report the Frida address as unsupported. Any frida: block in a binder: vm config is ignored (no frida-server is staged). For Frida, use binder: auto / host (redroid) or beetroot adopt an external rooted device. Tracked as a follow-up to #44.
binder: vm boots plain redroid — GApps, Magisk, and Houdini are inert
Unlike binder: auto / host (which boot the layered image beetroot build produces: redroid + Magisk + optional GApps + Houdini), the binder: vm guest boots an unmodified upstream redroid image. So android.gapps and magisk.denylist have no effect under binder: vm — there are no Play Services and no Magisk in the guest. beetroot apply prints a one-line advisory naming any such setting it had to ignore (e.g. a gapps: full you'd expect to give you the Play Store) — once, at config/apply time, not on every boot. Set android.gapps: none (as examples/vm.yaml does) to make the config match what the VM actually runs. If you need rooted Magisk and GApps, use a binder: auto / host instance or beetroot adopt an external rooted device. The field→backend applicability matrix is expressed structurally in code (config.inert_fields), so the advisory is built from a single source of truth. Tracked as #96 and #104.
vm¶
QEMU micro-VM tunables. Consulted only when binder: vm; ignored otherwise. All fields are optional — an empty vm: block (or none) is valid, and the kernel/rootfs paths then fall back to the BEETROOT_VM_KERNEL / BEETROOT_VM_ROOTFS environment variables.
| Field | Type | Default | Description |
|---|---|---|---|
vm.kernel |
string | null | null |
Host path to the guest bzImage. null defers to BEETROOT_VM_KERNEL. |
vm.rootfs |
string | null | null |
Host path to the guest ext4 root image. null defers to BEETROOT_VM_ROOTFS. |
vm.accel |
string | auto |
QEMU accelerator: auto (probe /dev/kvm, prefer KVM, else TCG), kvm (force; errors if /dev/kvm is absent — or, on a non-x86_64 host, with a cross-arch error, since KVM can't accelerate the x86_64 guest there), or tcg (force software emulation). On a non-x86_64 host auto resolves straight to TCG regardless of /dev/kvm. |
vm.smp |
int | auto |
auto |
Guest vCPUs (-smp). auto pins -smp to the host's physical core count (HyperThread siblings collapsed, capped by CPU affinity so a cgroup-limited CI runner is respected) — the vm-rnd-log §B.5 measured optimum, since more vCPUs than physical cores oversubscribe the emulator. An explicit integer (>= 1) pins it. |
vm.memory_mib |
int | 8192 |
Guest RAM in MiB (-m). Must be >= 256. Authoritative for binder: vm; resources.mem is the Docker cap used by binder: auto / host. Both knobs kept by decision in #104. |
vm.boot_cache |
bool | false |
Warm-start boot cache. When true, the first up cold-boots through a qcow2 overlay and checkpoints the running machine state with QEMU savevm; every later up resumes that checkpoint (-loadvm) instead of cold-booting — ~10 s vs ~3-4 min under TCG. Resume reverts the guest to the checkpoint each time (a fast known-good boot, not persistence) — so /data writes made after the first boot (installed apps, logins, flashed-module state) are discarded on every warm up, and Beetroot prints a runtime warning on each resume so that reset is never silent. Set vm.boot_cache: false if you need /data to persist across restarts. The checkpoint lives at <instance>/vm-overlay.qcow2 (~2 GiB) and auto-invalidates when the kernel/rootfs changes (a digest is recorded beside it); delete it by hand to force a reset otherwise. Requires qemu-img. See Warm-start boot cache. |
After launching QEMU, beetroot up polls adb connect against the guest until the forwarded ADB endpoint accepts a connection — the guest restarts adbd to enable TCP a few seconds after sys.boot_completed=1, so a single immediate connect would race that late bind. The poll deadline is the BEETROOT_VM_ADB_CONNECT_TIMEOUT environment variable (seconds, default 60); raise it for slow TCG first boots. If the guest never exposes ADB within the deadline, up fails with an actionable error (try beetroot logs <name> to watch the boot, or pin vm.accel: kvm) rather than a traceback.
binder: vm
vm:
kernel: ~/.cache/beetroot/vm/bzImage
rootfs: ~/.cache/beetroot/vm/rootdisk.img
accel: auto
smp: auto
memory_mib: 8192
boot_cache: false
Warm-start boot cache (vm.boot_cache)¶
Booting redroid in the micro-VM under TCG is CPU-bound — emulating ART / Zygote / system_server to sys.boot_completed costs ~3-4 min on a 4-core host (Android 14). Entropy and disk-cache tweaks do not move it (see vm-rnd-log Stage E). The one lever that does is to not boot at all on repeat starts:
binder: vm
vm:
kernel: ~/.cache/beetroot/vm/bzImage
rootfs: ~/.cache/beetroot/vm/rootdisk.img
boot_cache: true # checkpoint once, resume in ~10 s thereafter
beetroot apply alpha
beetroot up alpha # FIRST up: cold-boot (~3-4 min under TCG), then checkpoint
beetroot down alpha
beetroot up alpha # LATER up: resume the checkpoint — ~10 s to a live device
Measured on a binderless, KVM-less host (Android 14, pure TCG): cold first boot to first host ADB ~222 s; warm resume ~10 s — a ~22x speedup. How it works:
- The first
upboots through a qcow2 overlay over the (untouched) raw rootfs, with an HMP monitor socket. Once ADB is reachable, Beetroot issuessavevmto checkpoint the running machine state (RAM + device + disk) inside the overlay. - Every later
updetects the checkpoint and launches QEMU with-loadvm, resuming the already-booted guest in seconds.
Caveats:
- Resume is ephemeral. Each warm
upreverts the guest to the checkpoint moment — it is a fast known-good boot, not a way to persist changes across restarts. (Persist research state withbeetroot snapshotinstead.) - The checkpoint auto-invalidates when the kernel or rootfs changes. Beetroot records a digest of the
vm.kernel+vm.rootfsthe overlay was built from (<instance>/vm-overlay.cache-key); if either changes (e.g. you re-runbeetroot build --vm-kernel), the nextupdiscards the stale checkpoint and cold-boots once to re-cache — no manual cleanup needed. You can still delete<instance>/vm-overlay.qcow2by hand to force a fresh cold boot. - Requires the
qemu-imgbinary (Debian/Ubuntu:qemu-utils); override withBEETROOT_QEMU_IMG_BIN.
Complete example¶
api_version: 8
android:
version: 14
display:
width: 1080
height: 1920
fps: 10
rendering: gpu
resources:
mem: 4g
cpus: 3.0
shared_mem: 512m
frida:
version: "16.5.0"
modules:
- url: https://github.com/LSPosed/LSPosed.github.io/releases/download/shamiko-426/Shamiko-v0.7.4-426-release.zip
sha256: <your-hash-here>
- path: ./local-modules/CustomHook.zip
magisk:
denylist:
- com.google.android.gms
- com.google.android.gms/com.google.android.gms.unstable # DroidGuard process of GMS
- com.google.android.gms.persistent
- com.android.vending
- com.target.app
ports:
- {service: adb, guest: 5555, host: 9000}
- {service: frida, guest: 27042, host: 9001}
- {service: frida_control, guest: 27043, host: 9002}
- {service: app-debug, guest: 8080, host: 9090}