Skip to content

API Reference

Auto-generated documentation for the beetroot Python package. Two audiences are served here:

  • Researchers driving Beetroot from Python. Start with the high-level OOP layer in beetroot.apiInstance, Manager, and the DeviceBackend Protocol are re-exported from the top-level package so from beetroot import Instance Just Works. The Protocol is the same one fleshed out in the Device backends design doc.
  • Contributors editing the CLI. The procedural modules (compose, config, ports, registry, frida_download, modules_download, paths, snapshot, builder) remain the load-bearing implementation — api.py composes them, doesn't replace them. The CLI's Typer verbs delegate to Instance / Manager; the procedural modules stay importable.

beetroot.api — the OOP surface

The recommended entry point for programmatic users. Each Instance binds a registry name to an on-disk root and a parsed InstanceConfig. Manager exposes the cross-instance operations (list_instances, all, get, resolve, list_orphans). DeviceBackend is the Protocol both Instance (Redroid-via-compose) and v0.4's AdbDevice satisfy. Manager.resolve(name) dispatches via the backend registry in beetroot.backends; verbs that don't generalise across backends (up, down, apply, snapshot) raise BackendCapabilityError when called on a backend that doesn't expose them.

Adding a new backend (e.g. a cloud-emulator service that talks via its own shell instead of adb) takes about 30 LOC + one entry-point line — see the Adding a backend guide for the step-by-step recipe (and the Device backends design doc for the rationale).

Surfaces introduced in v0.4

The v0.3 OOP surface (Instance, Manager, DeviceBackend, InstanceNotFoundError, FridaNotInstalledError, AdbNotInstalledError) is preserved bit-for-bit. v0.4 adds:

  • AdbDevice (in beetroot.backends.adb) — sibling to Instance for rooted-Android-device backends driven over the host adb CLI. Import as from beetroot.backends.adb import AdbDevice. Satisfies the expanded DeviceBackend Protocol; registers itself as kind="adb" in the backend registry at module import time.
  • Expanded DeviceBackend Protocol — now has name: str (read-only property), kind: str (the backend discriminator, e.g. "redroid" / "adb"), shell() -> int, frida_cli(args) -> int, and a from_meta(name, backend_config) classmethod used by the backend-registry dispatcher. The Protocol stays @runtime_checkable so isinstance(b, DeviceBackend) still works.
  • BackendCapabilityError(RuntimeError) — raised by lifecycle verbs (up, down, restart, apply, destroy, snapshot) when called on a backend that doesn't honour them (typically: any non-Instance backend). The CLI catches it and renders a friendly error: ... line + exit code 2 (distinct from "instance not found" → exit 1).
  • Manager.resolve(name) -> DeviceBackend — dispatches to the concrete backend class via the backend registry. The return type is the Protocol, so callers narrow with isinstance(b, Instance) for Redroid-specific operations. This is the polymorphic entry point most v0.4+ programmatic code wants.
  • register_backend(kind, cls) (in beetroot.backends) — register an in-process third-party backend. Third-party packages typically prefer the [project.entry-points."beetroot.backends"] mechanism instead (loaded lazily on first Manager.resolve call), but the in-process registration is what tests use and what the synthetic third-backend test exercises.
  • registry.BackendConfig — a back-compat alias for the open registry.BackendConfigBase hierarchy (the type of the backend field on registry.InstanceMeta). It is no longer a Field(discriminator=...) discriminated union: backend configs are now resolved through an open registration-based scheme keyed on the kind discriminator, so third-party backends register their own BackendConfigBase subclass rather than being members of a closed union. In-tree concrete subclasses: registry.RedroidBackendConfig(absolute_path, stealth_paths), registry.AdbBackendConfig(serial), and registry.VmBackendConfig(absolute_path). Third-party backends define their own BackendConfigBase subclass with a unique kind: Literal[...] discriminator and call registry.register_backend_config(cls) — see the Adding a backend guide for the in-process / entry-point registration split. Unknown kinds are preserved verbatim as UnresolvedBackendConfig so a row never gets silently wiped.
  • CheckResult — frozen pydantic model with status: Literal["pass", "fail", "skip"] and optional reason: str | None. Returned from Instance.health() / AdbDevice.health() keyed by check name.
  • Instance.health() -> dict[str, CheckResult] — redroid-backed health surface that beetroot doctor consumes. NOT part of the DeviceBackend Protocol — callers narrow with isinstance(b, Instance) (or call AdbDevice.health() after narrowing the other way). See the design rationale at the top of the Device backends design doc.
  • AdbDevice.health() -> dict[str, CheckResult] — adb-backed equivalent. Returns the same check-name vocabulary minus compose.status (no container to inspect). Delegates to the free function api.adb_device_health(device), which is preserved as a back-compat shim for pre-T7 programmatic callers.
  • registry.set_stealth_paths(name, blob) — write a dict[str, str] into the named instance's RedroidBackendConfig.stealth_paths slot (T4 plumbing for a future release's stealth-path work). Locked + atomic-replaced via the same _write pattern the rest of registry.py uses. Rejects unknown names and adb-kind rows.
  • DevicePreflightError(RuntimeError) (issue #38) — raised by AdbDevice.auto_install_modules() when a whole-device problem (offline / unauthorized, no usable root, or no magisk binary) would otherwise surface as N identical failed rows, and again mid-batch if an adb-level failure plus an adb devices re-probe confirms the device went away. Carries the per-module rows completed before the abort in its results attribute. Connectivity is always decided by that re-probe, never by matching probe/install error text (host paths and module-controlled stderr are untrusted) — host-side validation failures (bad zip, sha256 mismatch) stay per-module rows and never raise it.

beetroot.api

High-level OOP wrapper around Beetroot's procedural modules.

The procedural modules (:mod:beetroot.compose, :mod:beetroot.config, :mod:beetroot.frida_download, :mod:beetroot.modules_download, :mod:beetroot.paths, :mod:beetroot.ports, :mod:beetroot.registry, :mod:beetroot.snapshot, :mod:beetroot.builder) remain the load-bearing implementation. This module composes them behind a small object-oriented surface so researchers can drive Beetroot from Python with from beetroot import Instance without learning the cross-module function vocabulary.

Two end-user classes are exported alongside a Protocol that formalises the device backend abstraction (see docs/design/device-backends.md for the v0.4 roadmap that fleshes the Protocol out into multiple backends):

  • :class:Instance — a single research phone, identified by its on-disk directory and its registry name. Owns the per-instance lifecycle (up / down / apply / destroy) plus operations (shell, frida_cli, add_module, snapshot).
  • :class:Manager — aggregate operations over the global registry (list, get, resolve).
  • :class:DeviceBackend — the Protocol that v0.3's implicit Redroid-via-compose backend satisfies and that v0.4's AdbDeviceBackend will satisfy too.

The CLI verbs in :mod:beetroot.cli delegate to these classes: the Typer command bodies are 1-5 lines that construct an Instance or call a Manager static method. The verbs themselves stay as module-level Typer commands (not bound methods) because Typer captures the function reference at import time.

AdbNotInstalledError

Bases: RuntimeError

Raised when Instance.shell is called without the host adb CLI on PATH.

AutoModuleInstaller

Bases: Protocol

Capability sub-protocol: backends that can auto-install Magisk modules.

Opting in gives the backend the beetroot module --auto-install path: modules are installed without manual Magisk-app interaction (on the adb backend, via su -c magisk --install-module, which stages the zip into /data/adb/modules_update/<id>/). :class:beetroot.backends.adb.AdbDevice implements this capability; :class:Instance deliberately does not (redroid instances flash staged modules at boot — there is nothing to auto-install at runtime).

auto_install_modules(sources, *, sha256s=None)

Install Magisk modules via root, reporting per-module outcomes.

Parameters:

Name Type Description Default
sources Sequence[str]

Host paths to local .zip modules.

required
sha256s Sequence[str | None] | None

Optional per-source expected hex digests, parallel to sources. A configured digest is enforced fail-closed — a mismatching zip is never pushed.

None

Returns:

Name Type Description
One list[ModuleInstallResult]

class:ModuleInstallResult per source, in order.

BackendCapabilityError

Bases: RuntimeError

Raised when a :class:DeviceBackend can't honour a requested operation.

The CLI gate :func:beetroot.cli._require raises this when a verb targets a backend that doesn't implement the required capability sub-protocol. The CLI catches it and renders a friendly error: ... line with exit 2.

CheckResult

Bases: BaseModel

One row of a backend health report.

Returned from :meth:Instance.health and :func:adb_device_health keyed by a check name (e.g. "compose.status", "magisk.zygisk"). status is a closed three-valued literal so downstream tools (beetroot doctor, dashboards) can pattern-match without parsing free-form strings. reason is a one-line human-readable hint that's surfaced verbatim on the doctor output line for fail / skip rows (and elided for pass).

Attributes:

Name Type Description
status Literal['pass', 'fail', 'skip']

Either "pass", "fail", or "skip".

reason str | None

Optional one-line explanation. Surfaced on the doctor output line for non-pass rows (and pass rows that volunteer extra context).

DeviceBackend

Bases: Protocol

Base abstraction for a Magisk-rooted Android device that Beetroot can drive.

Every backend satisfies this Protocol: enough to identify the backend, attach Frida, look up the canonical addresses, check reachability, and dispatch the two universal user-facing operations (shell and frida_cli).

Capability sub-protocols (:class:Lifecycle, :class:ModuleInstaller, :class:HealthCheckable, :class:Snapshottable) are opt-in — a backend gains a capability by implementing the methods. The CLI gates on isinstance(backend, <CapabilityProtocol>) via :func:beetroot.cli._require rather than isinstance(b, Instance) so third-party backends are first-class citizens.

The kind property is typed as :class:str (not Literal[...]) so third-party backends can declare their own discriminator strings without forking this Protocol.

from_meta returns :class:~typing.Self so mypy can prove structural conformance for each concrete subclass.

adb_address property

Return the host:port (or adb serial) that adb connect targets.

frida_address property

Return the host:port Frida control endpoint.

Backends with no reachable Frida endpoint return the :data:FRIDA_ADDRESS_UNSUPPORTED sentinel instead of a misleading localhost:<port>.

is_available property

Return True iff the backend is reachable right now (no install/start required).

kind property

Return the backend kind discriminator ("redroid", "adb", …).

Mirrors the kind field of the matching :class:~beetroot.registry.BackendConfigBase subclass.

name property

Return the registry name for this backend.

frida_cli(args)

Invoke the host frida CLI against this backend; return the exit code.

from_meta(name, backend) classmethod

Construct a backend instance from a registry meta's backend config.

Used by :meth:Manager.resolve to dispatch via the backend registry. Typing backend as :class:~beetroot.registry.BackendConfigBase (not the old in-tree union) lets a third-party from_meta isinstance-narrow to its own config under mypy strict.

Parameters:

Name Type Description Default
name str

Registry name for the backend.

required
backend BackendConfigBase

The matching registry backend config row — the concrete subclass this backend kind owns.

required

Returns:

Type Description
Self

A constructed backend instance satisfying this Protocol.

install_frida(version=None)

Make a frida-server available on the device.

Parameters:

Name Type Description Default
version str | None

The frida release tag (e.g. 16.4.10). None means "use the backend's default version". Backends that have no meaningful default (e.g. :class:AdbDevice) raise :class:ValueError when version is None.

None

shell(args=None)

Open a shell into the device; return the subprocess exit code.

Parameters:

Name Type Description Default
args Sequence[str] | None

Optional extra argv tokens forwarded to the underlying shell invocation. Pass ["-c", "id"] to run a non-interactive command. None (the default) opens an interactive shell.

None

Returns:

Type Description
int

The exit code of the underlying shell subprocess.

DevicePreflightError

Bases: RuntimeError

Raised when an auto-install device probe fails before (or mid-) batch.

:meth:beetroot.backends.adb.AdbDevice.auto_install_modules probes the device before pushing anything (is the device reachable? does su work? is magisk on the root PATH?) and raises this with a single friendly diagnosis instead of emitting N identical failed rows. It is also raised mid-batch when an adb call fails and a serial-scoped adb devices re-probe confirms the device is no longer available — the remaining modules are skipped because they would all fail identically. (Connectivity is always determined by that re-probe, never by matching failure text, which can embed untrusted host paths or module-controlled stderr.)

The CLI catches it, reports any per-module rows completed before the abort, and renders error: <message> + exit 1 (the standard domain-error shape; capability gating stays exit 2).

Attributes:

Name Type Description
results

Per-module rows completed before the abort, in request order. Empty for pre-flight failures (nothing was attempted).

__init__(message, results=())

Bind the friendly diagnosis and any pre-abort per-module rows.

Parameters:

Name Type Description Default
message str

The user-facing diagnosis (rendered after error: by the CLI).

required
results Sequence[ModuleInstallResult]

Rows for modules processed before the abort.

()

FridaNotInstalledError

Bases: RuntimeError

Raised when Instance.frida_cli is called without the host frida CLI on PATH.

HealthCheckable

Bases: Protocol

Capability sub-protocol: backends that expose health-check diagnostics.

Backends implement this to gain the beetroot doctor verb. Both :class:Instance (redroid) and :class:AdbDevice implement this capability.

health()

Run the aggregated health checks for this backend.

Returns:

Type Description
dict[str, CheckResult]

Ordered dict of check name to :class:CheckResult.

Instance

A single Beetroot research phone, identified by its on-disk directory.

Instances are addressed by name in the global registry; the registry is the authoritative source for "does this name exist" and "where on disk does it live". The container's runtime state is queried live from docker compose ps — never cached on the instance.

Construct via the three classmethod constructors:

  • :meth:create — make a new instance directory and register it.
  • :meth:load — look up an existing registered instance by name.
  • :meth:from_path — walk up from a path containing beetroot.yaml and load the matching registry entry.

The __init__ constructor itself is a low-level "I have all the pieces, hand me the object" path and is rarely the right call site in research code.

adb_address property

localhost:<adb_port> — what adb connect should target.

config property

The parsed beetroot.yaml at the time this object was constructed.

frida_address property

localhost:<frida_port> — what frida -H should target.

A valid Frida-less ports: config (adb only, no frida service) has no frida key in :func:ports.well_known, so return the :data:FRIDA_ADDRESS_UNSUPPORTED sentinel rather than crashing the whole-fleet ls with a KeyError — mirroring the vm backend (#158).

index cached property

The instance's allocated port index (stride-of-10 base).

Memoized: ports / adb_address / frida_address all key off this, so caching collapses their repeated registry.get lookups into a single read per Instance — cutting the redundant instances.json reads a whole-fleet ls / status incurred (#230). The disappearance contract is preserved: :meth:_meta still raises :class:InstanceNotFoundError on the first access if the row is gone, and a raised exception is never cached.

is_available property

True iff the underlying container is running right now.

kind property

Backend discriminator — always "redroid" for :class:Instance.

Defined here so :class:Instance satisfies the :class:DeviceBackend Protocol's kind property.

name property

Registry name for this instance.

ports property

Resolved guest→host port mappings for this instance (full list).

Includes the well-known services AND any arbitrary mappings. Use :func:beetroot.ports.well_known to project to the {service: host} dict the address accessors key off.

root property

Absolute path to the instance directory.

status property

Live one-word container status (see :data:compose.ComposeStatus).

__init__(name, root, cfg)

Bind a name + on-disk root + parsed config into an Instance.

Most callers use :meth:create, :meth:load, or :meth:from_path instead of constructing the object directly.

Parameters:

Name Type Description Default
name str

Registry name for this instance.

required
root Path

Absolute path to the instance directory (the one containing beetroot.yaml).

required
cfg InstanceConfig

Parsed instance configuration.

required

add_module(source, *, sha256=None)

Append a Magisk module to beetroot.yaml and re-stage.

Stages the module zip FIRST (so a download failure or sha256 mismatch is caught before the YAML grows a half-broken entry), then on success mutates the in-memory config + writes the YAML. If staging raises, the YAML and in-memory model are left unchanged — the user can re-run the verb with a corrected URL without manually un-doing a half-applied add.

Parameters:

Name Type Description Default
source str

Either an http(s):// URL or an instance-relative path to a .zip module.

required
sha256 str | None

Optional expected hex digest for integrity checking.

None
Notes

The container will not pick up the new module until the next :meth:restart.

apply()

Re-load beetroot.yaml and re-stage all derived files.

Re-reads the on-disk config (so external edits are picked up), re-validates port-collision, then re-renders .env and re-stages Frida + modules. A subsequent :meth:restart is required for the container to pick up the new config.

If the reloaded config now sets binder: vm (a hand-edit after create registered the redroid kind), the registry row is flipped to the QEMU micro-VM backend kind so the next resolution dispatches to VmDeviceBackend (issue #44), and the set-but-inert binder: vm fields (GApps / Magisk / Frida / arbitrary ports) are surfaced ONCE here via :func:config.warn_inert_fields (issue #104). This is the only apply that sees the kind flip: the CLI apply verb dispatches on the registry kind via Manager.resolve, which still reads redroid at this point, so the VM backend's own apply-time advisory never runs in the canonical create-then-edit flow.

Raises:

Type Description
ValueError

If the re-resolved ports collide with another registered instance.

create(name, path=None, cfg=None, lifecycle=None) classmethod

Create a new instance directory and register it under name.

Allocates a fresh port index, writes a minimal beetroot.yaml, renders the .env, and stages Frida (or an empty placeholder) and modules. The instance is left in the "down" state — call :meth:up to start it.

Parameters:

Name Type Description Default
name str

Registry name to assign.

required
path Path | None

Directory to create. Defaults to ./<name>.

None
cfg InstanceConfig | None

Override the default minimal config. Defaults to a fresh :class:config.InstanceConfig.

None
lifecycle Literal['ephemeral', 'durable'] | None

Persistence intent to stamp into the generated minimal beetroot.yaml (ephemeral or durable). Only honoured on the default-config path (cfg is None); when None (the default) the key is omitted and the instance is durable by schema default. Pass it together with an explicit cfg and a ValueError is raised — set cfg.lifecycle instead.

None

Returns:

Type Description
Instance

The newly created and registered :class:Instance.

Raises:

Type Description
ValueError

If name is already in the registry, the resolved ports collide with another instance, target_root overlaps (nests inside or contains) another registered instance's directory, or lifecycle is passed alongside an explicit cfg.

FileExistsError

If path already contains a beetroot.yaml (use :meth:register to adopt it).

destroy(*, yes=False)

Stop the container and permanently delete the instance directory.

compose.down errors propagate to the caller — programmatic users typically want to see them — but the host-side teardown (directory removal + registry deregistration) still runs. The CLI wraps this with a friendlier "continuing" message; library users can do the same by catching :class:compose.ComposeError around the call (the host-side state will have already been cleaned up by the time the exception fires).

The library API does NOT prompt on stdin. Callers must pass yes=True to confirm the destructive operation. The CLI verb :func:beetroot.cli.destroy handles the interactive prompt via typer.confirm and then calls Instance.destroy(yes=True).

Parameters:

Name Type Description Default
yes bool

Must be True to proceed. Passing False (the default) raises :class:ValueError — the caller is responsible for any confirmation prompt before invoking this method.

False

Raises:

Type Description
ValueError

If called with yes=False (caller must confirm before destroying).

ComposeError

If docker compose down fails. The host-side state is removed regardless before the error surfaces.

down()

Stop the instance with docker compose down (data preserved).

frida_cli(args)

Invoke the host frida CLI against this instance.

Beetroot prepends -H localhost:<frida_port> and forwards the rest of args verbatim.

Parameters:

Name Type Description Default
args Sequence[str]

Tokens to pass after frida -H <addr> (e.g. ["-n", "com.app"]).

required

Returns:

Type Description
int

The exit code of the frida invocation.

Raises:

Type Description
FridaNotInstalledError

If the frida binary is not on PATH (install via the [frida] extra).

from_meta(name, backend) classmethod

Build an :class:Instance from a registry meta's backend config.

Used by the backend-registry dispatcher in :mod:beetroot.backends so :meth:Manager.resolve can construct any backend class uniformly given (name, BackendConfigBase). Typing backend as :class:~beetroot.registry.BackendConfigBase (the shared base) lets mypy verify the call site without requiring the in-tree union.

Parameters:

Name Type Description Default
name str

Registry name.

required
backend BackendConfigBase

The matching backend config. Must be a :class:~beetroot.registry.RedroidBackendConfig.

required

Returns:

Type Description
Self

The hydrated :class:Instance.

Raises:

Type Description
InstanceNotFoundError

If backend is not a :class:~beetroot.registry.RedroidBackendConfig.

from_path(path) classmethod

Walk up from path to the nearest beetroot.yaml and load it.

The discovered directory must be in the global registry; this constructor refuses to make up a name on its own.

Parameters:

Name Type Description Default
path Path

A path inside (or at the top of) an instance directory.

required

Returns:

Name Type Description
The Instance

class:Instance bound to the discovered directory.

Raises:

Type Description
InstanceRootNotFoundError

If no beetroot.yaml is found in path or any of its ancestors.

InstanceNotFoundError

If the discovered directory isn't registered under any name.

health()

Aggregate the redroid-backed health checks for this instance.

Returns a mapping check_name → CheckResult. The check names that overlap with the adb backend (frida.handshake, magisk.zygisk, magisk.denylist.<pkg>) use IDENTICAL keys to :func:adb_device_health so downstream tools can grep check rows uniformly across backend kinds.

Notes

health() is NOT part of the :class:DeviceBackend Protocol. It's a capability method that not every backend supports (third-party cloud backends may not have any equivalent), so per the v0.3 device-backend design doc it lives on the concrete class rather than the Protocol. Callers narrow via isinstance(b, Instance) (or the free-function :func:adb_device_health for ADB).

Returns:

Type Description
dict[str, CheckResult]

Ordered dict of check name → :class:CheckResult. Insertion

dict[str, CheckResult]

order matches the doctor verb's intended output order

dict[str, CheckResult]

(compose first, then connectivity, then Magisk).

install_frida(version=None)

Stage a frida-server binary of the requested version on the instance.

Implements the :class:DeviceBackend Protocol's install_frida. The binary is downloaded into the user-global Frida cache (idempotent) and copied into the instance's bind mount. A subsequent :meth:restart is required for the container's entrypoint.sh to launch the new binary.

Parameters:

Name Type Description Default
version str | None

The frida release tag (e.g. 16.4.10). None uses the version pinned in this instance's beetroot.yaml (cfg.frida.version). Raises :class:ValueError if version is None and no frida block is configured.

None

Raises:

Type Description
ValueError

If version is None and the instance has no frida: block in its config.

load(name) classmethod

Look up a registered instance by name and load its config.

Parameters:

Name Type Description Default
name str

Registry name.

required

Returns:

Name Type Description
The Instance

class:Instance bound to the registry's recorded path

Instance

and the on-disk beetroot.yaml.

Raises:

Type Description
InstanceNotFoundError

If name is not in the registry, or if it's registered under a non-redroid backend kind (which Instance does not represent — use the corresponding backend class from :mod:beetroot.backends).

logs(*, follow=False)

Tail the container logs for this instance.

Parameters:

Name Type Description Default
follow bool

If True, stream logs continuously (-f).

False

register(path, name=None) classmethod

Adopt an existing instance directory under the global registry.

Parameters:

Name Type Description Default
path Path

Directory containing a beetroot.yaml.

required
name str | None

Registry name. Defaults to the directory's basename.

None

Returns:

Type Description
Instance

The newly registered :class:Instance.

Raises:

Type Description
FileNotFoundError

If path has no beetroot.yaml.

ValueError

If the chosen name is already registered, if path overlaps (nests inside or contains) another registered instance's directory, or if the resolved ports collide with another instance.

reset(*, yes=False)

Drop the instance's /data while keeping the instance and its tooling.

Stops the container (compose.down, idempotent), then wipes and recreates the bind-mounted data/ directory. redroid regenerates a clean /data deterministically from the base image on the next :meth:up. frida-server and modules/ are staged outside /data (see the bundled compose template), so the staged tooling survives — this is the explicit, gated counterpart to the silent boot_cache /data revert (issue #123). Unlike :meth:destroy the registry row and port index are untouched, so the instance keeps its identity.

The container is left stopped; run :meth:up for a fresh /data. The library API does NOT prompt on stdin — callers pass yes=True (the CLI verb :func:beetroot.cli.reset prompts first).

Parameters:

Name Type Description Default
yes bool

Must be True to proceed. False (the default) raises :class:ValueError.

False

Raises:

Type Description
ValueError

If called with yes=False.

ComposeError

If stopping the container fails (the data/ directory is left untouched in that case).

restart()

Stop then start the instance in sequence.

Routes the start through :meth:up, so a restart inherits the same self-heal: a missing compose.override.yaml / .env is re-staged (after the port-collision precheck) before boot, instead of restarting with zero published ports or a hard --env-file failure (issues #166, #219).

Raises:

Type Description
ValueError

If the re-resolved ports collide with another registered instance.

shell(args=None)

Open an ADB shell into the instance.

Parameters:

Name Type Description Default
args Sequence[str] | None

Optional extra tokens appended after adb -s <target> shell. Pass ["-c", "id"] to run a non-interactive command. None (the default) opens an interactive shell.

None

Returns:

Type Description
int

The exit code of the adb shell invocation. Beetroot

int

does not raise on non-zero exits — research scripts may

int

care about adb exit codes for their own flow control.

Raises:

Type Description
AdbNotInstalledError

If the adb binary is not on PATH.

snapshot(dest)

Pack this instance's host-side state into a .tar.zst archive.

Parameters:

Name Type Description Default
dest Path

Destination archive path. .tar.zst is appended if the caller omits it.

required

Returns:

Type Description
Path

The final archive path (after extension fix-up).

Raises:

Type Description
SnapshotError

On packing failure (missing source, bad disk write).

up()

Start the instance with docker compose up -d.

Self-heals a missing compose.override.yaml or .env first: an instance created before v8 (or one whose override was deleted by hand) carries no ports overlay, and the bundled template publishes none of its own — so it would boot with ZERO published ports (a silent adb + frida outage). A hand-deleted .env is worse still: _base_cmd appends --env-file <root>/.env unconditionally, so its absence hard-fails compose up and makes ps_status misreport a running container as not-created (issue #219). When either file is absent, re-run the network-free, rollback-safe local stage (re-renders .env + the override) before starting.

The self-heal also re-runs the cross-instance port-collision precheck that :meth:apply enforces, so re-staging the override can never re-publish a port another registered instance already owns (issue #166).

Raises:

Type Description
ValueError

If the re-resolved ports collide with another registered instance.

InstanceNotFoundError

Bases: LookupError

Raised when an instance name is not in the registry.

Lifecycle

Bases: Protocol

Capability sub-protocol: backends that manage a container or process lifecycle.

Backends implement this to gain up / down / restart / apply / destroy CLI verbs. The :class:Instance (redroid) backend implements this; the :class:AdbDevice backend does not (adb-adopted devices are always-on and managed outside Beetroot).

apply()

Re-load config and re-stage derived files.

destroy(*, yes=False)

Permanently destroy this backend and its host-side state.

Parameters:

Name Type Description Default
yes bool

Must be True to proceed. Passing False raises ValueError — callers must confirm before destroying. The CLI handles the prompt via typer.confirm then passes yes=True.

False

down()

Stop the backend (data preserved).

restart()

Stop then start the backend.

up()

Start the backend.

LogReader

Bases: Protocol

Capability sub-protocol: backends that can surface their own logs.

The :class:Instance (redroid) backend tails docker compose logs; the :class:~beetroot.backends.vm.VmDeviceBackend reads the persisted QEMU serial console. Both satisfy this Protocol, so the logs verb gates on it (via :func:beetroot.cli._require) rather than on the concrete :class:Instance type.

logs(*, follow=False)

Tail this backend's logs.

Parameters:

Name Type Description Default
follow bool

If True, stream continuously instead of printing once.

False

Manager

Aggregate operations over the global instance registry.

Stateless — every method reads the registry live, so concurrent mutations from other processes are picked up on the next call.

all() staticmethod

Return every resolvable registered backend, sorted by name.

Walks all registered names via :meth:resolve, skipping names that fail resolution (opaque/unresolvable rows, orphaned redroid rows whose yaml is gone, etc.). Useful for operations that span all backend kinds.

Returns:

Type Description
list[DeviceBackend]

A list of :class:DeviceBackend instances, one per

list[DeviceBackend]

successfully resolved registry entry, sorted by name.

get(name) staticmethod

Look up a registered backend by name.

Returns None if missing or unresolvable.

Unlike :meth:Instance.load, this method returns any backend kind (redroid, adb, or third-party), not just redroid. Returns None if name is not registered or if the backend cannot be resolved (e.g. the package providing an unknown kind is not installed).

Parameters:

Name Type Description Default
name str

Registry name.

required

Returns:

Name Type Description
A DeviceBackend | None

class:DeviceBackend, or None if name isn't

DeviceBackend | None

registered or can't be resolved.

list_instances() staticmethod

Return every registered redroid instance, sorted by name.

Adb-kind rows are skipped because :class:Instance only represents redroid backends — use :meth:all to walk every backend uniformly via the Protocol. Orphan entries (redroid rows whose on-disk directory has been rm -rf'd, or whose beetroot.yaml is now unparsable) are silently skipped — without this, a single orphan would crash beetroot ls and prevent the user from cleaning up. Use :meth:list_orphans to surface them for cleanup.

Returns:

Type Description
list[Instance]

A list of :class:Instance objects, one per healthy

list[Instance]

registered redroid name.

list_orphans() staticmethod

Return names of redroid instances whose on-disk dir is missing OR unparsable.

An orphan is a redroid-kind registry row pointing at a path with no beetroot.yaml (typically because the user manually rm -rf'd the directory without running beetroot destroy) OR a beetroot.yaml that can't be parsed any more (e.g. a half-overwritten file, an api_version mismatch, or hand-edited junk). v0.3 returned only the first kind, so a corrupted YAML left the entry invisible to Manager.list_instances AND to Manager.list_orphans — the user had no surface to clean it up from. (T2 v0.3.1 deferred.)

Adb-kind rows are not directory-backed so they can never be orphans by this definition. Names are returned sorted; the cleanup verb is beetroot destroy <name> -y.

Returns:

Type Description
list[str]

Sorted list of orphan instance names. Empty if every

list[str]

registered redroid entry's directory is present and its

list[str]

YAML parses.

resolve(name) staticmethod

Look up a registered instance and return its concrete backend.

Dispatches via the backend registry (see :mod:beetroot.backends): the meta.backend.kind discriminator is mapped to the registered class, which is then constructed with (name, meta.backend).

Parameters:

Name Type Description Default
name str

Registry name.

required

Returns:

Type Description
DeviceBackend

A backend instance satisfying :class:DeviceBackend.

Raises:

Type Description
InstanceNotFoundError

If name is not in the registry, if its kind is not in the backend registry (install the package providing it), or if the backend row is opaque (unknown kind).

ModuleInstallResult

Bases: BaseModel

Per-module outcome of an auto-install run.

Returned (one row per requested module, in request order) from :meth:AutoModuleInstaller.auto_install_modules. A failed module never aborts the rest of the batch — callers inspect ok per row and decide the aggregate exit status themselves (the CLI exits non-zero if any row failed).

Attributes:

Name Type Description
source str

The host-side source path exactly as the caller passed it.

ok bool

True iff the module was pushed and installed successfully.

detail str

One-line human-readable outcome — the on-device install target for ok rows, the error message for failed rows.

ModuleInstaller

Bases: Protocol

Capability sub-protocol: backends that can install Magisk modules.

Backends implement this to gain the beetroot module verb. Both :class:Instance (redroid) and :class:AdbDevice implement this capability; third-party backends can opt in too.

add_module(source, *, sha256=None)

Install a Magisk module.

Parameters:

Name Type Description Default
source str

URL or path to the module zip.

required
sha256 str | None

Optional expected hex digest for integrity checking.

None

Resettable

Bases: Protocol

Capability sub-protocol: backends that can drop their /data in place.

Backends implement this to gain the beetroot reset verb — a destructive "fresh start" that wipes accumulated app//data state while keeping the instance's identity (registry row, port index) and its staged tooling (frida-server / modules/). Only :class:Instance (redroid) currently implements it; binder: vm keeps /data inside the guest (pending the split-data-disk work, issue #125) and adb-backed devices have no host-side /data to wipe, so both surface a capability error.

reset(*, yes=False)

Drop the backend's /data while keeping the instance.

Parameters:

Name Type Description Default
yes bool

Must be True to proceed (the destructive op is gated; the CLI prompts before passing yes=True).

False

Snapshottable

Bases: Protocol

Capability sub-protocol: backends that can be snapshotted to an archive.

Backends implement this to gain the beetroot snapshot verb. Only :class:Instance (redroid) currently implements this; adb-backed devices have no host-side directory to pack.

snapshot(dest)

Pack the backend's host-side state into a .tar.zst archive.

Parameters:

Name Type Description Default
dest Path

Destination archive path.

required

Returns:

Type Description
Path

The final archive path (after extension fix-up).

adb_device_health(device)

Health-check the adb-backed equivalent of :meth:Instance.health.

Originally landed as a free function (not a method on :class:AdbDevice) because T6 landed BEFORE T5's :class:AdbDevice existed. T7 added :meth:AdbDevice.health as a real method that delegates back to this function. The free function is preserved as the canonical implementation (so the body only lives in one place) AND as a back-compat shim for pre-T7 programmatic callers that imported :func:adb_device_health directly. New code on or after T7 should call backend.health() — :meth:AdbDevice.health is the spelling that satisfies the "backends own their own health surface" intuition.

The shared check NAMES (frida.handshake, magisk.zygisk, magisk.denylist.<pkg>) match :meth:Instance.health exactly so downstream tools grep uniformly. device only needs the Protocol surface (adb_address, frida_address) — no AdbDevice-specific methods — so this still works against minimal stub backends in tests that don't import :class:AdbDevice.

Parameters:

Name Type Description Default
device DeviceBackend

A :class:DeviceBackend whose kind == "adb".

required

Returns:

Type Description
dict[str, CheckResult]

Ordered dict of check name → :class:CheckResult. compose.status

dict[str, CheckResult]

is intentionally absent — there's no container for the adb backend.

instance_lock(instance_root, *, exclusive)

Acquire an advisory fcntl.flock on <instance_root>/.beetroot.lock.

Snapshot acquires a SHARED lock (LOCK_SH) — multiple snapshots can run in parallel, but a concurrent destroy must wait. Destroy acquires an EXCLUSIVE lock (LOCK_EX) — blocks every other snapshot/destroy on the same instance until the destructive operation completes. Without the lock, a destroy that races a long-running snapshot could rmtree the directory while the snapshot is reading from it, producing a torn archive. (T2 Agent 2 B-12.)

The lock file lives inside the instance root so it's naturally scoped per-instance — two instances don't share a lock. The lock file is created on first acquisition and never deleted; future operations re-attach to the same inode. Sibling processes that crash holding the lock release it automatically (the kernel drops the flock on fd close at process exit).

Parameters:

Name Type Description Default
instance_root Path

The instance directory.

required
exclusive bool

True for LOCK_EX; False for LOCK_SH.

required

Yields:

Type Description
Path

The lock-file path (for debugging — callers don't usually

Path

need it).

beetroot.cli

beetroot.cli

beetroot — multi-instance Magisk-Android research lab CLI.

The Typer commands here are thin shells over the OOP surface in :mod:beetroot.api. Each verb constructs an :class:api.Instance or calls an :class:api.Manager staticmethod; CLI-specific concerns (stdout formatting, error: ... lines, typer.Exit(1)) live in this module, while the lifecycle logic lives behind the OOP layer.

The verbs stay as module-level Typer commands (not bound methods) because @app.command() captures the function reference at import time — wrapping them in a class would break Typer's dispatch.

adopt(serial, name=None, verify=False)

Adopt a rooted Android device that's already reachable via adb.

Allocates a Beetroot port index for the device (so a follow-up beetroot install-frida and beetroot frida-addr pick the same Frida port a redroid instance with the same index would have got), then writes an adb-kind row to the registry. Unlike beetroot create, no on-disk instance directory is made; the device is managed by whatever installed it (real phone, third- party emulator, adb connect from a network device).

Pass --verify to require the serial to be reachable via adb devices before the registry row is written.

apply(name)

Re-render .env and re-stage files from the instance's beetroot.yaml.

build(gapps=_GappsIntent.minimal, gapps_vendor=None, vm_kernel=False, from_source=False, check=False, build_context=None, android_version=config.DEFAULT_ANDROID_VERSION)

Build the redroid base image, or (with --vm-kernel) the micro-VM artifacts.

create(name, path=None, from_data=None, lifecycle=None, preset=None)

Create a new instance directory and stage its files.

The new beetroot.yaml is the minimal valid config (api_version plus android.version); every other field falls back to schema defaults. To start from a richer baseline, copy a file from the repo's examples/ directory over the generated beetroot.yaml.

Pass --lifecycle ephemeral|durable to record persistence intent in the committed YAML (default durable preserves today's behaviour).

destroy(name, yes=False)

Stop and permanently delete an instance including its data directory.

doctor(name)

Run the aggregated health checks for an instance.

Output is machine-parseable: one ": [reason]" line per check. "pass" rows elide the reason; "fail" and "skip" rows include it.

Exits 0 if every check passes; otherwise the exit code is the count of "fail" results (capped at 255 — the POSIX exit-code ceiling). "skip" rows do not count toward the exit code.

down(names=None, all_=False)

Stop one or more instances, preserving data.

forget(name)

Deregister an instance from the registry without touching its host directory.

Removes the registry row and frees its port index. No host-directory teardown, no docker compose down, no data deletion — it is the inverse of beetroot adopt (and the safe cleanup path for adb-backed instances that beetroot destroy refuses to handle). Works for both redroid and adb instances.

frida_addr(name)

Print an instance's Frida address (localhost:<frida_port>) to stdout.

This resolves the toil the old beetroot frida passthrough existed for — looking up an instance's stride-allocated Frida port — without wrapping the frida CLI, so you keep frida's own shell completion, --help, and flag validation by invoking it directly (issue #109):

frida -H "$(beetroot frida-addr alpha)" -n com.target.app

The address composes into any frida workflow (and the Python API), and generalises to future transports (e.g. Frida Gadget, #3) that aren't a plain host:port.

install_frida(name, version)

Push and launch frida-server on an adb-adopted device.

Downloads the requested frida-server release, adb pushes it to the device, launches it as root, and forwards the host Frida port so frida -H "$(beetroot frida-addr <name>)" reaches it. This is the CLI path the adopt hint advertises for adb-adopted devices (issue #205).

logs(name, follow=False)

Tail container logs for an instance.

ls(json_out=False)

List every registered instance — redroid containers and adopted devices alike.

Walks all backend kinds via Manager.all(), so adb-adopted devices appear next to redroid instances. Redroid rows report live docker compose ps status and the instance directory; adb rows report live adb devices availability, the serial in the ADB column, and - for PATH (no on-disk directory). Orphan entries are skipped with a trailing stderr advisory, as before.

main()

Parse CLI arguments and dispatch to the appropriate command handler.

Wraps app() to convert domain exceptions raised from deep in the procedural call tree into the same friendly error: ... line + exit 1 shape the rest of the CLI uses. compose.ComposeError and builder.BootstrapError are caught here because the up/down/restart/logs/apply/build verbs let them propagate as plain tracebacks otherwise — v0.2 was uniformly error: ....

modes(json_out=False)

Survey the host and report which Beetroot run-modes it supports.

Host-level and instance-independent — answers "what can this machine run before I create anything or pick a binder mode?", unlike beetroot doctor <name> (which health-checks one existing instance).

Probes the host binder driver, KVM, and the QEMU / Docker / adb binaries, then reports each mode as supported / needs-setup / unsupported / unknown with a reason and remedy. See docs/how-it-works/binder-and-modes.md for what each mode needs.

Always exits 0 — it reports, it does not gate.

module(name, sources, sha256=None, auto_install=False)

Install a Magisk module — append + re-stage (redroid), push (adb), or root --auto-install.

register(path, name=None)

Adopt an existing instance directory under the global registry.

reset(name, yes=False)

Drop an instance's /data (app state) while keeping the instance and tooling.

restart(names=None, all_=False)

Stop then start one or more instances.

restore(archive, name=None, as_=None, path=None, force=False)

Unpack a snapshot archive into a new instance and register it.

setup_deprecated(args=None)

Print a migration hint for the v0.2 setup verb.

v0.2 had beetroot setup [variant]; v0.3 renamed it to beetroot build. This hidden alias catches the old form and surfaces a one-line migration message instead of bare Typer No such command output.

shell(ctx, name)

Open an interactive ADB shell, optionally running a one-shot command.

Extra arguments after the instance name are forwarded to the underlying adb shell. Use -c 'cmd' to run a non-interactive command:

beetroot shell alpha -c 'id'
beetroot shell alpha -c 'ls /data/local/tmp'

snapshot(name, output=None)

Pack an instance's host-side state into a .tar.zst archive.

status(name)

Print a single-instance JSON snapshot.

Output is JSON to stdout — pipe to jq for selective fields. The row shape is the same as ls --json for redroid; adb-kind entries get a smaller row with serial and the correct allocated frida_address instead of absolute_path.

Exit codes: 0 on success, 1 if name is not in the registry.

up(names=None, all_=False, build=False)

Start one or more instances.

beetroot.config

beetroot.config

beetroot.yaml schema, loading, and .env rendering.

Only android.version is required; every other field has a sensible default and can be omitted entirely from an instance YAML. Optional top-level sections: display, resources, frida, modules, magisk, ports.

Android

Bases: BaseModel

Android version and GApps selection.

GApps is split across two axes (issue #107): gapps is the intent (what the user gets — nothing / a minimal Play Services / the full suite), and gapps_vendor is an optional escape hatch for the compatibility case where an app prefers a specific distribution. Most users only set gapps; Beetroot picks a sensible vendor for the chosen intent.

Attributes:

Name Type Description
version int

Android major version (11, 12, 13, or 14).

gapps GappsIntent

GApps intent — none (no Play Services), minimal (a slim Play Services, the default), or full (the full suite). Resolved to a concrete vendor via :func:resolve_gapps_vendor.

gapps_vendor GappsVendor | None

Optional vendor override (litegapps / opengapps / mindthegapps). None (the default) lets the intent pick the vendor. Setting it pins a specific distribution for app compatibility; it must not be combined with gapps: none (naming a vendor while asking for no GApps is contradictory).

Display

Bases: BaseModel

Display settings for the virtual Android screen.

Attributes:

Name Type Description
width int

Horizontal resolution in pixels (must be > 0).

height int

Vertical resolution in pixels (must be > 0).

fps int

Frame rate limit (must be > 0).

rendering Literal['gpu', 'software', 'auto']

How redroid renders the framebuffer — the speed-vs-portability axis, expressed as intent rather than redroid's host/guest vocabulary. gpu renders via the host GPU (fast, assumes a GPU-capable host); software uses SwiftShader (always works, slower); auto (the default) probes for a host render node and picks gpu when present, else software — so a headless box doesn't silently assume a GPU. Mapped to redroid's gpu_mode via :func:resolve_rendering at .env render time.

Frida

Bases: BaseModel

Frida-server version selection for an instance.

Attributes:

Name Type Description
version str

Which frida-server release to stage. One of: auto (the default) — match the host's installed frida-tools version, falling back to latest when it 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 by :mod:beetroot.frida_download at staging time; a malformed pinned tag ("16.4", "16.4.10-rc1") raises a ValidationError at load time rather than 404-ing on the CDN.

sha256 str | None

Optional expected hex digest of the decompressed frida-server binary. frida_download.download verifies the digest against the cached binary when set and raises ValueError on mismatch (defends against a hostile mirror replacing the upstream release). Only meaningful with a pinned version — a digest can't match the moving target auto / latest resolve to, so that combination is rejected at load time. Lowercase or mixed-case hex are both accepted; comparison is case-insensitive.

InstanceConfig

Bases: BaseModel

The schema of an instance directory's beetroot.yaml.

Instance name is not part of the schema — it's the registry key that maps a name to this directory's absolute path.

Each Beetroot release supports exactly one api_version; mismatched values fail loud with a pointer to the migration story in CHANGELOG.md.

Attributes:

Name Type Description
api_version int

Schema version this YAML targets. Must equal :data:SUPPORTED_API_VERSION.

lifecycle Literal['ephemeral', 'durable']

Whether this instance's /data is meant to survive (durable, the default — a long-lived "research phone") or is throwaway (ephemeral — CI/E2E, comparative fleets, reset between runs). This is a label + guardrails, not a runtime persistence switch: beetroot down never wipes /data for either value; only destroy / reset (and a vm.boot_cache warm resume) drop it. The label drives intent-aware behaviour — destroy escalates its confirmation for a durable instance, and an ephemeral instance opts into vm.boot_cache's revert-on-resume quietly (the #123 advisory is suppressed, because a reset each boot is exactly what ephemeral asked for). durable preserves today's contract exactly.

android Android

Android version and GApps flavour.

display Display

Virtual screen geometry and frame rate.

resources Resources

Docker resource caps.

frida Frida | None

Frida-server version pin; None (the default) disables frida entirely. Declare an explicit frida: block to opt in.

modules list[Module]

Magisk modules to flash at boot.

magisk Magisk

Magisk denylist / root-hiding settings.

ports list[PortMapping]

List of guest→host :class:PortMapping entries. Defaults to the three well-known services (adb / frida / frida_control) with auto-allocated host ports. Entries whose host is unset fall back to the stride-of-10 allocator (for a well-known service) or a dedicated extra-pool band (for an arbitrary service) on the instance's index. The old mapping form (ports: {adb: ...}) is migrated to this list on load.

binder Literal['auto', 'host', 'vm']

How redroid obtains the kernel binder driver it needs to boot. "auto" (default) uses the host kernel's binder and warns (without aborting) when the host can't provide it — preserving the historical behaviour. "host" is the strict variant: beetroot up refuses to start unless the host binder is ready (useful in CI, where a container that silently never boots Android is worse than a fast failure). "vm" opts 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. The micro-VM engine now ships: selecting "vm" boots redroid inside the QEMU micro-VM (VmDeviceBackend); on a host with /dev/kvm it is near-native, and under TCG it is slow but functional. See docs/design/binderless-hosts-qemu-tcg.md. Never silently falls back to the slow emulated path — that choice is always explicit.

vm Vm

QEMU micro-VM tunables (kernel/rootfs paths, accelerator, vCPUs, memory). Consulted only when binder == "vm"; ignored otherwise. Defaults to an all-defaults :class:Vm block so a YAML can opt into binder: vm without a vm: section and still rely on the BEETROOT_VM_* env defaults.

Magisk

Bases: BaseModel

Magisk configuration, including the boot-time denylist.

Attributes:

Name Type Description
denylist list[str]

Entries added to Magisk's denylist at boot, each encoded as package[/process] — a package, optionally followed by a slash and a process that belongs to it (no slash means the process equals the package). Both halves must match the Android package-id grammar ([a-zA-Z0-9._]+) — see :data:_DENYLIST_PKG_RE. The grammar is enforced on BOTH halves at validation time so magisk-config.sh can split the entry and compose the package + process into a SQLite INSERT without escaping; any shape that wouldn't be a valid package name today is assumed to be either a typo or an injection attempt. The package/process form matters because Magisk's denylist keys on (package_name, process): DroidGuard runs as the com.google.android.gms.unstable process of the com.google.android.gms package — not an installed package of its own — so it must be enrolled under its real package or vanilla (non-Shamiko) Magisk never hides root there (issue #170). Defaults to the GMS main process plus the .unstable DroidGuard process, both under the real com.google.android.gms package (the v0.3 helper enrolled the GMS pair unconditionally; the config move keeps the intent identical while putting the user in control and fixing the process it targets).

Module

Bases: BaseModel

A single Magisk module entry from beetroot.yaml.

Exactly one of url or path must be set. sha256 is optional but recommended when fetching from a URL.

Attributes:

Name Type Description
url str | None

HTTPS URL to download the module zip from.

path str | None

Path to a local zip file. A relative path is resolved relative to the instance directory (the directory containing this beetroot.yaml) and must stay inside it — a relative path that escapes the instance dir is rejected at staging time (the path-traversal analogue of the file:// URL block). An absolute path is permitted and read as-is.

sha256 str | None

Expected hex digest for integrity verification.

model_post_init(_ctx)

Validate that exactly one of url or path is set.

Parameters:

Name Type Description Default
_ctx object

Pydantic post-init context (unused).

required

Raises:

Type Description
ValueError

If neither or both of url and path are set, or if url uses a non-http(s) scheme.

PortMapping

Bases: BaseModel

A single guest→host port mapping for an instance.

Generalises the pre-v8 fixed ports: {adb, frida, frida_control} block (issue #108) into an arbitrary, named guest→host mapping. The three well-known services (adb / frida / frida_control) are seeded by default and stride-allocated on the instance index when host is left unset; any other entry whose host is unset is auto-allocated from a dedicated extra-pool band (see :func:beetroot.ports.resolve_ports).

Attributes:

Name Type Description
service str | None

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. None is allowed for an unlabelled arbitrary mapping.

guest int

The container-side (guest) port this mapping exposes (1..65535). Required.

host int | None

The host-side port. None (the default) auto-allocates — a stride base for a well-known service, an extra-pool slot otherwise. An explicit value pins the host port (1..65535).

Resources

Bases: BaseModel

Docker resource caps for the container.

Attributes:

Name Type Description
mem str

Hard memory limit (e.g. 3g). Docker size format. This is the Docker container cap, authoritative for redroid (binder: auto/host); for binder: vm the guest RAM is :attr:Vm.memory_mib (the QEMU -m). Both knobs are intentionally kept (issue #104) — collapsing into one field is deferred.

cpus float

CPU cap as a float.

shared_mem str

Shared-memory size (Docker shm_size). Docker size format.

mem_reservation str | None

Optional soft memory floor. Docker size format.

memswap_limit str | None

Optional total memory + swap cap. Docker size format, plus the documented sentinel -1 (unlimited swap).

pids_limit int

Maximum number of PIDs the container can spawn.

Vm

Bases: BaseModel

QEMU micro-VM backend settings, consulted only when binder: vm.

The micro-VM boots a Beetroot-built guest kernel (kernel, a bzImage with binder + binderfs compiled in) on top of an ext4 rootfs (rootfs) that auto-starts redroid inside Docker — the proof-of-concept proven in docs/design/binderless-hosts-qemu-tcg.md. The host's own kernel binder driver is irrelevant in this mode: the guest ships its own.

Both kernel and rootfs are optional in the schema so an empty vm: block (or none at all) is valid; the launcher falls back to the BEETROOT_VM_KERNEL / BEETROOT_VM_ROOTFS environment defaults (see :mod:beetroot.settings) and only errors at up time when neither the config nor the env supplies a path.

Attributes:

Name Type Description
kernel str | None

Host path to the guest bzImage. None defers to the BEETROOT_VM_KERNEL setting.

rootfs str | None

Host path to the guest ext4 root image. None defers to the BEETROOT_VM_ROOTFS setting.

accel Literal['auto', 'kvm', 'tcg']

QEMU accelerator. "auto" (default) probes /dev/kvm and prefers KVM, falling back to TCG; "kvm" / "tcg" force the choice. Explicit "kvm" on a host without /dev/kvm is a hard error (no silent slow fallback).

smp int | Literal['auto']

Number of guest vCPUs (-smp). Either an explicit integer (must be >= 1) or "auto" (the default), which pins -smp to the host's physical core count at launch (HyperThread siblings collapsed, capped by CPU affinity so a cgroup-limited CI runner is respected). The vm-rnd-log §B.5 sweep showed the real redroid boot scales with vCPUs up to the host core count and regresses past it (oversubscription → cross-thread TCG sync overhead); counting physical cores avoids the regression a logical-CPU count would hit on a hyperthreaded host. Pin an explicit value to leave host cores free or to override.

memory_mib int

Guest RAM in MiB (-m). Must be >= 256. Default 8192. Authoritative for binder: vm; :attr:Resources.mem is the Docker cap used by binder: auto/host. Both knobs kept by decision in #104.

boot_cache bool

Opt into the warm-start boot cache (default False). When True, the first beetroot up cold-boots through a qcow2 overlay and checkpoints the running machine state with QEMU savevm; every subsequent up resumes that checkpoint (-loadvm) instead of cold-booting — ~10 s vs ~minutes under TCG (issue #49/#83). The checkpoint lives in the instance directory (vm-overlay.qcow2) and auto-invalidates when the kernel/rootfs changes — a digest of both is recorded beside it, so the next up after a build --vm-kernel cold-boots once to re-cache (issue #126); delete it by hand to reset otherwise. Resume reverts the guest to the checkpoint each time, so it is a fast known-good boot, not a persistence mechanism. Requires qemu-img.

base_image_tag(android)

Derive the redroid base-image tag from version + resolved GApps vendor.

Parameters:

Name Type Description Default
android Android

The Android section of an InstanceConfig.

required

Returns:

Type Description
str

The Docker image tag, e.g.

str

redroid/redroid:14.0.0_litegapps_houdini_magisk.

inert_fields(cfg)

Return human-readable descriptions of beetroot.yaml fields the backend ignores.

Expresses the field→backend applicability matrix structurally (issue

104): given a fully-loaded config, returns one entry per set-but-inert

field for the ACTIVE backend, each naming the field and why it no-ops. Empty list means every set field is honoured. The caller turns a non-empty list into a single apply-time advisory.

Only binder: vm has inert fields today: it boots an UNMODIFIED upstream redroid image (:func:vm_redroid_image) with no GApps / Magisk / Houdini / Frida layer, so the layered-image knobs (android.gapps, magisk.denylist, modules) and the whole frida: block are inert, only adb is forwarded so arbitrary ports: mappings are dropped (issue #44/#108), and the display geometry is dropped because the VM's guest-init.sh passes no redroid_width/height/fps props to the guest (issue #264 — only flagged when display differs from the default, so an all-defaults config stays quiet). binder: auto/host honour all of these → empty list.

Parameters:

Name Type Description Default
cfg InstanceConfig

The fully-loaded instance config to inspect.

required

Returns:

Type Description
list[str]

One human-readable string per set-but-inert field for cfg's

list[str]

active backend; empty when every set field is honoured.

is_pinned_frida_version(v)

Return True if v is a concrete major.minor.patch Frida tag.

The single source of truth for the pinned-tag shape, shared by the :class:Frida validator and :mod:beetroot.frida_download's resolver so the two never disagree on what counts as "already concrete".

Parameters:

Name Type Description Default
v str

The candidate version string.

required

Returns:

Type Description
bool

True for a concrete tag (e.g. 16.4.10); False for the

bool

symbolic auto / latest or any malformed value.

load_yaml(path)

Load and validate an InstanceConfig from a YAML file.

An empty file is treated as an all-defaults config.

Auto-bump (legacy versions): YAMLs that pinned one of the versions in :data:_AUTO_BUMPABLE_API_VERSIONS are upgraded to :data:SUPPORTED_API_VERSION on load with a one-line stderr warning — unless they also carry a key that a non-additive bump renamed (see below), in which case the migration error fires instead. The bump is persisted organically on the next beetroot apply.

Migration error (non-additive renames): stealth.denylist moved to magisk.denylist in api_version 4, display.gpu_mode became display.rendering in api_version 5, and android.gapps's vendor values (lite / mindthegapps) were split into an intent + android.gapps_vendor in api_version 7. A YAML that still contains a stealth: section, a display.gpu_mode key, or a vendor-named android.gapps raises a clear, actionable error naming the renamed field rather than silently mis-parsing.

Lossless migration (silent / note): the old mapping-form ports (ports: {adb: 9000}) was generalised to a list of {service, guest, host} mappings in api_version 8 (issue #108). A well-known mapping is translated into the seeded list with the host overrides applied (a one-line note) and the version auto-bumps; a mapping with a non-well-known key raises a migration error naming the new list shape.

Parameters:

Name Type Description Default
path Path

Absolute path to the YAML file.

required

Returns:

Type Description
InstanceConfig

A validated InstanceConfig populated from the file.

Raises:

Type Description
ValidationError

If the YAML is invalid, contains a renamed key (stealth:magisk: in v4, display.gpu_modedisplay.rendering in v5, a vendor-named android.gappsgapps intent + gapps_vendor in v7), or carries an unsupported api_version.

render_compose_ports_override(resolved)

Render the per-instance compose.override.yaml carrying the port list.

A flat .env can't expand into a variable-length compose ports: list, so since v8 (issue #108) the resolved host→guest mappings are written to a per-instance override file that the CLI layers on top of the bundled template with a second -f. The output targets the bundled template's phone service and is deterministic (entries in the resolved order) so re-staging an unchanged config produces an identical file.

Parameters:

Name Type Description Default
resolved Sequence[ResolvedPort]

The resolved port list produced by :func:beetroot.ports.resolve_ports.

required

Returns:

Type Description
str

The YAML override document as a newline-terminated string.

render_env(name, cfg, stealth_paths=None)

Render the .env file that compose reads via --env-file.

Every ${VAR} substitution in compose.yaml must have a corresponding line here. Ports are intentionally NOT emitted here: since v8 the port list is variable-length (issue #108) and a flat .env can't expand into multiple compose YAML list items, so ports live in the per-instance compose.override.yaml rendered by :func:render_compose_ports_override instead.

Parameters:

Name Type Description Default
name str

Instance name used as the compose project name.

required
cfg InstanceConfig

The instance configuration.

required
stealth_paths dict[str, str] | None

Optional per-instance override blob (T4) carrying the magisk_db / modules_dir / frida_bin keys. Each key present here overrides the corresponding BEETROOT_* default; absent keys fall back to the well-known v0.4 defaults (/data/adb/magisk.db / /data/adb/modules_update / /data/local/tmp/frida-server). None and {} both mean "use defaults" — the helper merges either form against _STEALTH_PATH_DEFAULTS so callers can pass the RedroidBackendConfig.stealth_paths blob verbatim. Unknown keys are silently ignored (so a v0.6-shaped blob carrying a future stealth_module_id key restores cleanly against a v0.4 render_env).

None

Returns:

Type Description
str

The rendered .env content as a newline-terminated string.

resolve_gapps_vendor(android)

Resolve the effective GApps vendor for an Android config.

An explicit gapps_vendor always wins. Otherwise the intent picks the vendor: noneNone (no GApps), minimal → LiteGApps, full → OpenGApps.

Parameters:

Name Type Description Default
android Android

The Android section of an InstanceConfig.

required

Returns:

Type Description
GappsVendor | None

The vendor name (a key of :data:_VENDOR_SLUG), or None when the

GappsVendor | None

intent is none.

resolve_rendering(rendering)

Map a display.rendering intent to redroid's gpu_mode string.

gpuhost (render via the host GPU), softwareguest (SwiftShader software rendering), and auto probes the host for a DRM render node — picking host when one exists, else guest — so a headless / GPU-less box renders in software instead of silently misbehaving.

Parameters:

Name Type Description Default
rendering str

The validated display.rendering value.

required

Returns:

Type Description
str

The redroid gpu_mode string (host or guest).

validate_android_version(v)

Validate an Android major version against the supported set.

Single source of truth for the supported-version check, reused by the :class:Android schema validator AND by callers outside the config model (e.g. beetroot build --vm-kernel --android-version N) that need to fail fast before kicking off expensive work.

Parameters:

Name Type Description Default
v int

The Android major version to validate.

required

Returns:

Type Description
int

v unchanged when it is one of the supported versions.

Raises:

Type Description
ValueError

If v is not one of the supported Android versions.

vm_redroid_image(version)

Derive the plain redroid image the micro-VM guest bakes for an Android version.

Unlike :func:base_image_tag (which names the Magisk + GApps + Houdini layered base image built by beetroot build), the binder: vm guest runs an unmodified upstream redroid image pulled straight from Docker Hub. Those tags carry a -latest suffix (e.g. 11.0.0-latest); the bare X.0.0 tag does not exist on Docker Hub — see docs/design/vm-rnd-log.md.

Parameters:

Name Type Description Default
version int

Android major version (11, 12, 13, or 14).

required

Returns:

Type Description
str

The plain redroid image reference, e.g. redroid/redroid:14.0.0-latest.

warn_inert_fields(cfg, name)

Emit the single apply-time advisory naming every set-but-inert field.

Builds the one-shot console.note from :func:inert_fields (issue

104) so the message text and the field→backend applicability matrix are

single-sourced here, in code. Called by every apply path that can change or first observe an instance's effective backend: the redroid Instance.apply (which flips a hand-edited binder: vm config to the VM backend kind — the canonical create-redroid → edit-to-vm → apply flow, where the registry still says redroid so Manager.resolve never reaches the VM backend) and :meth:beetroot.backends.vm.VmDeviceBackend._warn_on_inert_vm_config (re-apply of an already-VM instance). A no-op when nothing is inert (every redroid config, and a VM config that sets no layered-image knobs), so it is safe to call unconditionally at apply time. Non-fatal note.

Parameters:

Name Type Description Default
cfg InstanceConfig

The fully-loaded instance config to inspect.

required
name str

The instance name, woven into the advisory for context.

required

write_yaml(path, cfg)

Serialise an InstanceConfig to a YAML file, creating parent dirs.

Parameters:

Name Type Description Default
path Path

Destination path (parent directories are created if absent).

required
cfg InstanceConfig

The config to serialise.

required

beetroot.ports

beetroot.ports

Port allocation for instances.

Stride-of-10 scheme: instance index N maps to ADB port 5555 + N10 and Frida ports 27042/27043 + N10. Indices are stable across the lifetime of an instance and freed on destroy. Freed slots are reused — allocation always picks the lowest free index.

Per-instance overrides — including arbitrary guest→host mappings beyond the three well-known services — are supported via the ports: list in beetroot.yaml; see :func:resolve_ports and the PortMapping model in config.py.

PortCollisionError

Bases: ValueError

Raised when :func:resolve_ports produces duplicate host ports.

This happens when a partial ports: override pins one entry to the stride-of-10 / extra-pool default of a sibling entry that wasn't overridden. The pydantic schema only validates distinctness among the explicit host ports it receives, so this resolver-side check catches the collisions the model can't see without knowing the index.

It is also raised when an instance asks for more than STRIDE host-unset arbitrary entries: the extra-pool slot would spill into the next index's per-instance window. That bound is enforced eagerly while allocating (the monotonic slot sequence never duplicates a host, so the post-resolution self-collision Counter would not catch it).

ResolvedPort

Bases: NamedTuple

A fully-resolved guest→host port mapping for an instance.

Attributes:

Name Type Description
service str | None

The service label (adb / frida / frida_control for well-known services, a free-form label, or None).

guest int

The container-side port.

host int

The resolved host-side port.

lowest_free_index(used)

Return the smallest non-negative integer not in used. Reuses freed slots.

ports_for_index(index)

Compute the ADB and Frida host port numbers for a given instance index.

Parameters:

Name Type Description Default
index int

The instance's port index (non-negative integer, at most :data:_MAX_PORT_INDEX).

required

Returns:

Type Description
dict[str, int]

A dict with keys adb, frida, and frida_control mapping

dict[str, int]

to the host port numbers for this instance — the well-known stride

dict[str, int]

defaults only (arbitrary mappings are not represented here).

Raises:

Type Description
ValueError

If index is negative or above :data:_MAX_PORT_INDEX (the extra-pool base bounds the maximum index).

resolve_ports(index, ports, *, quiet=False)

Resolve every PortMapping to a concrete host port for index.

Resolution rules, in order of precedence:

  • an explicit host always wins;
  • a well-known service (adb / frida / frida_control) with host unset gets its stride base (base + index*STRIDE);
  • any other entry with host unset gets an extra-pool slot (EXTRA_POOL_BASE + index*STRIDE + slot, where slot is its 0-based position among the instance's auto-allocated arbitrary entries).

Parameters:

Name Type Description Default
index int

The instance's port index (non-negative integer).

required
ports Sequence[PortMapping]

The instance's PortMapping list (from its config).

required
quiet bool

When True, suppress the privileged-port advisory. Read-only cross-instance scans (which re-resolve every registered instance) pass this so a privileged-port instance's advisory isn't re-emitted once per scan and misattributed to an unrelated operation (#224).

False

Returns:

Type Description
list[ResolvedPort]

A list of :class:ResolvedPort, one per input mapping, in order.

Raises:

Type Description
PortCollisionError

If two resolved mappings land on the same host port (e.g. a partial override colliding with a stride default), or if more than STRIDE host-unset arbitrary entries are requested (the extra-pool slot would spill into the next index).

well_known(resolved)

Project the resolved list to a {service: host} dict of well-known services.

Only entries whose service is one of the well-known names (adb / frida / frida_control) are included — the accessor the adb-address / frida-address consumers key off.

Parameters:

Name Type Description Default
resolved Sequence[ResolvedPort]

The resolved port list from :func:resolve_ports.

required

Returns:

Type Description
dict[str, int]

A dict mapping each present well-known service name to its host port.

beetroot.registry

beetroot.registry

Cross-instance registry mapping instance name to metadata.

The registry is a single user-global JSON file (at $XDG_CONFIG_HOME/beetroot/instances.json, defaulting to ~/.config/beetroot/instances.json) that records every instance on the host regardless of where on disk its directory lives.

Container status is NOT cached here; query Docker live so we can't lie. Only assignment-time data lives in the registry: the open backend config, the allocated port index, and the created-at timestamp.

The on-disk schema is now (v3) defined by :class:RegistryFile: a strongly-typed pydantic model that round-trips via model_validate_json / model_dump_json. Backend configs use an open registration-based scheme: in-tree backends (redroid, adb) are pre-registered; third-party backends register their own :class:BackendConfigBase subclass via :func:register_backend_config. An unknown kind is preserved as an opaque :class:UnresolvedBackendConfig that round-trips byte-for-byte — it is never silently wiped, and neither is a known-kind row whose backend payload is rejected (it too is preserved opaquely so its port index stays reserved). Only a genuinely corrupt envelope (bad JSON / missing version) — or a row so broken it has no usable integer index — triggers .bak-and-empty; a row is never silently dropped (#252).

AdbBackendConfig

Bases: BackendConfigBase

Backend config for the real-device-over-ADB backend (shipped in v0.4, T5).

Attributes:

Name Type Description
kind Literal['adb']

Discriminator tag — always "adb".

serial str

The adb serial / endpoint identifier (e.g. "emulator-5554" or "192.168.1.10:5555"). Passed verbatim to adb -s <serial> invocations.

BackendConfigBase

Bases: BaseModel

Base class for all backend config models.

Every in-tree and third-party backend config must subclass this and pin a :class:~typing.Literal kind discriminator. The base carries extra="forbid" and frozen=False to match the existing in-tree config models.

Attributes:

Name Type Description
kind str

Backend kind discriminator string (e.g. "redroid", "adb"). Subclasses pin a Literal[...] value.

InstanceMeta

Bases: BaseModel

Per-instance metadata stored in the registry.

Replaces the v0.3 dict[str, Any] payload. Every consumer that used to subscript meta["absolute_path"] now reaches through meta.backend.absolute_path (for redroid) or meta.backend.serial (for adb).

Attributes:

Name Type Description
backend BackendConfigBase

Backend config (open-union :class:BackendConfigBase).

index int

Stride-of-10 port index allocated to this instance.

created_at datetime

ISO-8601 UTC timestamp when the entry was added.

RedroidBackendConfig

Bases: BackendConfigBase

Backend config for the v0.3-shaped Redroid-container backend.

Attributes:

Name Type Description
kind Literal['redroid']

Discriminator tag — always "redroid".

absolute_path str

Absolute path to the instance directory (the directory containing beetroot.yaml).

stealth_paths dict[str, str]

Reserved slot for the v0.4 stealth-posture plumbing. Empty in v0.4; a future release's stealth work populates it with the randomized container-path layout produced by Instance.create. Snapshot / restore round-trips the blob so a future snapshot lands cleanly on a v0.4 host.

RegistryError

Bases: RuntimeError

Raised on registry consistency errors (e.g. unknown name lookups).

RegistryFile

Bases: BaseModel

On-disk shape of instances.json.

The discriminator-bearing version field gates schema compatibility — :func:_read falls through to the legacy-migration path for any mismatch.

Attributes:

Name Type Description
version Literal[3]

Always 3 for this Beetroot release.

instances dict[str, InstanceMeta]

Mapping of instance name → :class:InstanceMeta.

RegistryRowError

Bases: RegistryError

Raised when a single registry row is corrupt beyond opaque salvage.

A row that passes the envelope checks (valid JSON, version == 3, a dict backend sub-object) can still fail row-level validation — for example a non-parseable created_at or a missing / non-integer index. Such a row cannot be preserved opaquely because there is no usable integer index to keep reserved, so :func:_read surfaces it loudly (via the envelope-corruption backup path) instead of silently dropping it and handing its port index to the next create (#252).

UnresolvedBackendConfig

Bases: BackendConfigBase

Opaque placeholder for an unknown backend kind.

When :func:_read encounters a kind that is not registered in :data:_BACKEND_CONFIG_REGISTRY, the raw dict is preserved here so the row survives a read/write cycle byte-for-byte. Callers that need to operate on the backend (e.g. :meth:Manager.resolve) will get an :class:~beetroot.api.InstanceNotFoundError with an "install the package providing kind X" message.

Attributes:

Name Type Description
kind str

The unknown kind discriminator (preserved verbatim).

_raw dict[str, object]

The full raw dict from the JSON file, including all fields that the registered model would validate.

__init__(kind, raw)

Wrap an unknown-kind row for opaque round-tripping.

Parameters:

Name Type Description Default
kind str

The unrecognised kind string.

required
raw dict[str, object]

The full backend sub-dict from the registry JSON, preserved verbatim for :func:_write to re-emit.

required

VmBackendConfig

Bases: BackendConfigBase

Backend config for the QEMU micro-VM backend (binder: vm).

A directory-backed backend, like :class:RedroidBackendConfig: the instance directory holds the beetroot.yaml (which carries the vm: tunables) plus the QEMU pidfile written at up time. The guest kernel + rootfs images are host paths referenced from the config / settings, not stored in the registry.

Attributes:

Name Type Description
kind Literal['vm']

Discriminator tag — always "vm".

absolute_path str

Absolute path to the instance directory (the directory containing beetroot.yaml).

add_allocating(name, absolute_path=None, *, backend=None)

Atomically allocate the lowest free port index AND register name.

Without this critical section, two parallel Instance.create calls could read used_indices() simultaneously, both get the same lowest-free index, and then both write to the registry — silently co-allocating the same port to two instances. The user only sees the failure at docker compose up time, when the second instance's bind fails.

The backend argument is keyword-only to prevent accidental positional misuse (the old dual-form add had a positional index footgun). Pass absolute_path for the legacy redroid-shorthand form; pass backend=<config> for any pre-built :class:BackendConfigBase arm.

Parameters:

Name Type Description Default
name str

Instance name to register.

required
absolute_path Path | None

Absolute path to the instance directory. Required when backend is None; ignored otherwise.

None
backend BackendConfigBase | None

Pre-built backend config (any :class:BackendConfigBase subclass). When omitted, the redroid shape is synthesised from absolute_path.

None

Returns:

Type Description
int

The port index that was allocated.

Raises:

Type Description
ValueError

If name is already in the registry or if neither absolute_path nor backend is supplied.

all_resolved_host_ports()

Return the set of allocated host ports for every registered instance.

For redroid / vm instances, loads their beetroot.yaml to resolve the full ports: list — including arbitrary (non-well-known) mappings, not just the three well-known services — so a cross-instance collision over any host port is caught (issue #108). For adb-kind (and other) instances, uses the stride-of-10 well-known defaults derived from the registered index (they have no beetroot.yaml to consult). Orphan directory-backed entries (registered names whose beetroot.yaml is gone) are silently skipped.

This is a read-only scan, so it resolves each instance's ports with quiet=True — the privileged-port advisory is suppressed here so it isn't re-emitted on every cross-instance scan and misattributed to an unrelated operation (#224); it still fires on the staging-instance resolve in create / apply.

Returns:

Type Description
dict[str, set[int]]

A mapping instance_name → {host_port, ...} covering every

dict[str, set[int]]

registered instance. Empty dict if the registry is empty or every

dict[str, set[int]]

directory-backed entry is an orphan.

assert_no_port_collision(name, new_ports)

Raise ValueError if new_ports collide with a sibling — under the lock.

The sibling read (beetroot.yaml resolve) and the collision decision run inside a single exclusive registry critical section, closing the TOCTOU window where two concurrent apply/create operations pinning the same explicit host: port could both pass the precheck and double-bind at up time (#183). Resolved sibling ports aren't persisted, so they're re-read from disk here rather than from the registry file.

Parameters:

Name Type Description Default
name str

The staging instance's name (excluded from the sibling scan).

required
new_ports list[ResolvedPort]

The staging instance's resolved host ports.

required

Raises:

Type Description
ValueError

On the first cross-instance host-port collision.

find_port_collision(new_ports, others)

Search others for any host port that collides with new_ports.

Parameters:

Name Type Description Default
new_ports list[ResolvedPort]

Resolved port list for the instance being staged.

required
others dict[str, set[int]]

Mapping of other-instance-name → set of allocated host ports. The caller is responsible for excluding the staging instance itself from this mapping.

required

Returns:

Type Description
tuple[int, str, str] | None

(host_port, conflicting_instance, service) on the first collision

tuple[int, str, str] | None

found — service is the new instance's service label for the

tuple[int, str, str] | None

colliding entry (str(service) so an unlabelled arbitrary mapping

tuple[int, str, str] | None

renders as "None"). Returns None if no collision exists.

get(name)

Return the :class:InstanceMeta for name, or None if not registered.

instance_path(name)

Return the absolute path to an instance's directory, from the registry.

Only redroid-kind instances have a meaningful on-disk root; adb-kind instances raise :class:RegistryError because they're not backed by a directory.

Parameters:

Name Type Description Default
name str

Instance name.

required

Returns:

Type Description
Path

The path recorded under the backend config's absolute_path

Path

when the instance was registered.

Raises:

Type Description
RegistryError

If name is not in the registry, or if the registered backend is not directory-backed.

list_instances()

Return all known instances as name → metadata. Empty if registry is missing.

reconcile_backend_kind(name, binder)

Sync a directory-backed row's kind to its config's binder mode.

binder: vm instances must be registered as :class:VmBackendConfig so :meth:beetroot.api.Manager.resolve dispatches to the QEMU micro-VM engine; every other mode is the redroid-over-compose backend. When a user hand-edits binder in beetroot.yaml after creation, the next beetroot apply calls this to flip the registry kind to match — preserving absolute_path and the allocated index.

Only the redroid ↔ vm pair is reconciled (both are directory-backed and share the absolute_path field); adb and third-party rows are left untouched.

Parameters:

Name Type Description Default
name str

Instance name to reconcile.

required
binder str

The instance config's binder value (auto / host / vm).

required

Returns:

Type Description
bool

True if the row's kind was changed, False if it already matched (or

bool

the row isn't a directory-backed redroid/vm kind).

register_backend_config(cls)

Register a third-party backend config class under its kind discriminator.

The class must be a :class:BackendConfigBase subclass with a kind field pinned to a Literal[...]. The kind is derived from cls.__fields__["kind"].default — the Literal's sole value.

This must be called before any :func:_read that could encounter the corresponding rows in the registry file. The best place is a package's __init__.py or entry-point loader.

Parameters:

Name Type Description Default
cls type[BackendConfigBase]

The pydantic model class to register.

required

Raises:

Type Description
ValueError

If the class has no pinned kind default, or if the kind is already registered to a different class.

remove(name)

Remove an instance from the registry under an exclusive file lock.

A no-op if name is not present.

Parameters:

Name Type Description Default
name str

Instance name to deregister.

required

set_stealth_paths(name, stealth_paths)

Replace the stealth-path blob on an existing redroid instance row.

Used by :func:snapshot.restore (T4) to replay a path-layout blob from the snapshot manifest into the freshly-allocated registry entry. Keeping the mutation on its own helper (rather than threading the blob through add_allocating) keeps the hot create-path's signature stable and avoids coupling snapshot/restore plumbing to AdbBackendConfig work.

.. note:: This helper and the stealth_paths slot are provisional and may change before v1.0 — stealth path-randomization work is deferred to a future release.

Parameters:

Name Type Description Default
name str

Instance name to update.

required
stealth_paths dict[str, str]

New stealth-path mapping. A copy is taken so the caller can mutate the dict afterwards without retroactively changing the registry row.

required

Raises:

Type Description
RegistryError

If name is not registered, or if the registered backend is not directory-backed (the stealth_paths slot lives on :class:RedroidBackendConfig only — adb-backed devices don't have container paths to randomize).

used_indices()

Return the set of port indices currently allocated to registered instances.

beetroot.compose

beetroot.compose

Subprocess wrappers around docker compose.

The compose template ships inside the wheel (see :func:paths.bundled_compose_file); the per-instance state lives under the instance directory (--project-directory <instance_dir>). We always invoke compose with -p <project> set to the instance name and --env-file pointing at the instance's generated .env. When the instance has a generated compose.override.yaml (the variable-length ports: list, issue #108) it is layered on with a second -f; the override is omitted when absent so down / ps / logs still work before the first apply. Compose is authoritative for container state — the registry only knows about allocation, not runtime status.

ComposeError

Bases: RuntimeError

Raised when a docker compose subcommand exits with a non-zero status.

build(name, instance_root)

Build the Docker image for an instance.

Parameters:

Name Type Description Default
name str

Instance name.

required
instance_root Path

The instance directory.

required

Raises:

Type Description
ComposeError

If compose exits with a non-zero status.

down(name, instance_root, *, volumes=False)

Stop an instance with compose down.

Parameters:

Name Type Description Default
name str

Instance name.

required
instance_root Path

The instance directory.

required
volumes bool

If True, also remove named volumes.

False

Raises:

Type Description
ComposeError

If compose exits with a non-zero status. The daemon's stderr tail is folded into the message (issue #276).

logs(name, instance_root, follow=False)

Tail container logs for an instance.

Parameters:

Name Type Description Default
name str

Instance name.

required
instance_root Path

The instance directory.

required
follow bool

If True, stream logs continuously (-f).

False

Raises:

Type Description
ComposeError

If compose exits with a non-zero status in non-follow mode; the daemon's stderr tail is folded into the message (issue

276). In follow mode a non-zero exit is tolerated, because

Ctrl-C-ing out of the stream is the expected way to stop it.

ps_status(name, instance_root)

Return a closed-enum container status for an instance.

Queries docker compose ps --format json live; never reads from cache. Distinguishes "docker daemon unreachable" from "not-created" so callers (beetroot doctor, ls --json) can give a precise diagnostic. The probe is bounded by :data:_PS_STATUS_TIMEOUT: a wedged-but-reachable daemon (or an unresponsive TCP DOCKER_HOST) degrades to "docker-unreachable" instead of hanging the verb.

Parameters:

Name Type Description Default
name str

Instance name.

required
instance_root Path

The instance directory.

required

Returns:

Type Description
ComposeStatus

One of the :data:ComposeStatus literals. "unknown" is

ComposeStatus

used when compose reports a state string we don't recognise

ComposeStatus

(e.g. a future Docker release introducing a new state); it

ComposeStatus

never silently maps to "running".

run(name, instance_root, args, **kwargs)

Run docker compose -p <name> ..., inheriting stdio by default.

Parameters:

Name Type Description Default
name str

Instance name used as the compose project name.

required
instance_root Path

The instance directory (cwd for the subprocess and the value of --project-directory).

required
args Sequence[str]

Subcommand and flags to append after the base compose args.

required
**kwargs object

Forwarded verbatim to subprocess.run. Typed as object so mypy under disallow_any_explicit accepts them; callers pass shapes that subprocess.run itself validates (capture_output, text, …).

{}

Returns:

Type Description
CompletedProcess[str]

The completed process result. [str] is correct under the

CompletedProcess[str]

text=True default we add; callers that opt into binary

CompletedProcess[str]

stdout would need to re-cast, but no caller does today.

up(name, instance_root)

Start an instance with compose up -d.

Parameters:

Name Type Description Default
name str

Instance name.

required
instance_root Path

The instance directory.

required

Raises:

Type Description
ComposeError

If compose exits with a non-zero status. The daemon's stderr tail is folded into the message (issue #276).

beetroot.frida_download

beetroot.frida_download

Download and stage frida-server binaries on the host.

Frida releases are fetched from github.com/frida/frida/releases/download/<version>/ frida-server-<version>-android-x86_64.xz. The decompressed binary is cached under $XDG_CACHE_HOME/beetroot/frida/ (default ~/.cache/beetroot/frida/) and copied per-instance on apply, shared across all instances on the host.

FridaFetchError

Bases: RuntimeError

Raised when frida-server cannot be downloaded or decompressed.

Mirrors :class:~beetroot.modules_download.ModuleFetchError so callers can catch a single, named domain exception rather than the raw :class:lzma.LZMAError or :class:urllib.error.URLError that surfaced before this class existed.

cached_binary(version, *, arch=None)

Return the cache path for a decompressed frida-server binary.

Parameters:

Name Type Description Default
version str

The frida release tag.

required
arch str | None

The frida-server architecture suffix; defaults to settings.frida_arch. Included in the filename so an aarch64 and an x86_64 build of the same version cache side by side (#189).

None

Returns:

Type Description
Path

Path under the user-global Frida cache where the binary lives.

download(version, *, expected_sha256=None)

Fetch and decompress frida-server into the host cache. Idempotent.

If the binary already exists in the cache with non-zero size, the download is skipped. If expected_sha256 is set, the cached (or freshly-downloaded) binary's digest is compared against it and a ValueError is raised on mismatch — guards against a hostile mirror substituting the upstream release.

The architecture suffix is read from the _active_arch context var (default settings.frida_arch), which :func:stage_for_instance sets to the backend-resolved arch around its call (#189). Keeping it out of the signature preserves download's public shape so existing test doubles stay assignment-compatible.

Parameters:

Name Type Description Default
version str

The frida release tag to download.

required
expected_sha256 str | None

Optional hex digest of the decompressed frida-server binary. Comparison is case-insensitive.

None

Returns:

Type Description
Path

Path to the cached (decompressed, executable) binary.

Raises:

Type Description
FridaFetchError

On HTTP errors, network timeouts, URL errors, or a corrupt/truncated .xz payload that cannot be decompressed.

ValueError

If expected_sha256 is set and doesn't match the binary's actual digest.

frida_cache_dir()

Return the user-global Frida binary cache directory.

host_frida_tools_version()

Return the host frida-tools version (from frida --version), or None.

None means there's no usable host client version to match against — the frida CLI isn't on PATH (the optional [frida] extra) or its output isn't a concrete major.minor.patch tag — so callers treat "no host version" uniformly.

Returns:

Type Description
str | None

The host client version (e.g. 16.4.10), or None.

latest_release_tag()

Resolve the current frida release tag via the GitHub latest-release redirect.

Returns:

Type Description
str

The concrete tag the latest release points at (e.g. 16.7.19).

Raises:

Type Description
FridaFetchError

If the redirect can't be reached, or the resolved URL doesn't end in a recognizable major.minor.patch tag.

release_url(version, *, arch=None)

Return the GitHub download URL for a frida-server release.

Parameters:

Name Type Description Default
version str

The frida release tag (e.g. 16.4.10).

required
arch str | None

The frida-server architecture suffix; defaults to settings.frida_arch when not supplied by a backend-aware caller.

None

Returns:

Type Description
str

The full HTTPS URL to the .xz compressed binary.

resolve_frida_arch(binder)

Resolve the frida-server architecture suffix for a given backend.

An explicit BEETROOT_FRIDA_ARCH always wins (the researcher pinned a cross-arch build on purpose). Otherwise the arch is backend-aware: a binder: vm instance always uses the x86_64 build (its guest is an x86_64 micro-VM), while a binder: host|auto instance runs Android directly against the host kernel, so the arch is detected from :func:platform.machine — an aarch64 host stages android-arm64 rather than an x86_64 ELF that never launches on ARM (#189). An unrecognized host machine falls back to the settings.frida_arch default so behaviour is never worse than before.

Parameters:

Name Type Description Default
binder str

The instance's binder mode (auto / host / vm).

required

Returns:

Type Description
str

The frida-server architecture suffix (e.g. android-arm64).

resolve_version(version, *, host_version=None)

Resolve a (possibly symbolic) frida.version to a concrete release tag.

  • a pinned major.minor.patch is returned unchanged (reproducible);
  • auto resolves to the host frida-tools version when installed (so the staged server matches the client you'll attach with), else latest;
  • latest resolves to the current upstream release.

Parameters:

Name Type Description Default
version str

The configured version (auto / latest / a pinned tag).

required
host_version str | None

An already-resolved host frida-tools version, if the caller has one (e.g. :func:stage_for_instance computes it once and reuses it for the skew check). None means "fetch it here" — used only on the auto path, so a pinned/latest resolve never spawns frida --version.

None

Returns:

Type Description
str

A concrete major.minor.patch tag.

Raises:

Type Description
FridaFetchError

On a network failure resolving auto / latest, or an unrecognized symbolic value.

sha256_of(path)

Return the lowercase hex SHA-256 digest of a file.

Parameters:

Name Type Description Default
path Path

Path to the file to hash.

required

Returns:

Type Description
str

Lowercase hex digest string.

stage_empty(instance_root)

Place a zero-byte non-executable placeholder for instances with no Frida.

The compose bind mount is unconditional, so the file must exist. entrypoint.sh checks for the executable bit and skips launching when it's not set.

Parameters:

Name Type Description Default
instance_root Path

The instance directory.

required

Returns:

Type Description
Path

Path to the placeholder file inside the instance directory.

stage_for_instance(instance_root, version, *, expected_sha256=None, binder='auto')

Copy the cached frida-server binary into the instance's directory.

The version is resolved first (auto / latest → a concrete tag, see :func:resolve_version), then a host-client/server skew warning is surfaced before the binary is staged.

Parameters:

Name Type Description Default
instance_root Path

The instance directory (the one containing beetroot.yaml). The binary is written to <instance_root>/frida-server.

required
version str

Frida version selector — auto / latest / a pinned tag.

required
expected_sha256 str | None

Optional hex digest forwarded to :func:download for integrity verification. Comparison is case-insensitive. Only valid with a pinned version (enforced by :class:beetroot.config.Frida).

None
binder str

The instance's binder mode, used to resolve the host-matching frida-server architecture (#189).

'auto'

Returns:

Type Description
Path

Path to the staged binary inside the instance directory.

beetroot.modules_download

beetroot.modules_download

Stage Magisk module zips into an instance's modules/ directory.

Each module entry in beetroot.yaml is either a URL (downloaded and cached) or a host path. Relative path: entries are resolved relative to the instance directory itself (the one containing beetroot.yaml) and are contained to it — a relative path that escapes the instance dir (e.g. ../../etc/shadow) is rejected, mirroring the file:// URL block. An absolute path: the user types explicitly is permitted (it bypasses the instance dir by design) and remains a supported feature. An optional sha256 field is verified when present to guard against corruption or supply-chain substitution.

ModuleFetchError

Bases: RuntimeError

Raised when a module zip cannot be downloaded from its URL.

ModuleResolveError

Bases: ValueError

Raised when a local path: module entry cannot be resolved safely.

Currently fires when a RELATIVE path: escapes the instance directory (the path-traversal analogue of the file:// URL block).

stage_for_instance(instance_root, cfg)

Materialise all module zips into <instance_root>/modules/. Idempotent.

Wipes stale zips before staging so that removing a module from beetroot.yaml actually un-stages it on the next apply.

Parameters:

Name Type Description Default
instance_root Path

The instance directory (the one containing beetroot.yaml). Relative path: entries in the config are resolved relative to this directory.

required
cfg InstanceConfig

The instance configuration containing the modules list.

required

Returns:

Type Description
list[Path]

List of paths to the staged zip files inside the instance directory.

verify_sha256(path, expected)

Verify a file's SHA-256 digest against an expected hex value.

Shared by the staging path (:func:stage_for_instance via _resolve) and by :meth:beetroot.backends.adb.AdbDevice.auto_install_modules so both enforce the same case-insensitive comparison and raise the same sha256 mismatch message shape. The file is left untouched — callers that cache downloads decide whether a mismatch should also evict the bad artifact.

Parameters:

Name Type Description Default
path Path

The file to hash.

required
expected str

The expected SHA-256 hex digest (case-insensitive).

required

Raises:

Type Description
ValueError

If the actual digest differs from expected.

beetroot.snapshot

beetroot.snapshot

Snapshot and restore Beetroot instances as zstandard-compressed tar archives.

A snapshot captures the host-side state of one instance — its beetroot.yaml, its persisted data/ directory, any staged modules/, and the optional frida-server placeholder — into a single .tar.zst archive. The container's overlay layer is NOT captured by design: redroid regenerates it deterministically from the base image plus the persisted /data bind mount, so re-running beetroot up after a restore produces an equivalent container.

The archive carries a .beetroot-snapshot.json manifest at its root. The manifest's path_layout field carries the source instance's RedroidBackendConfig.stealth_paths blob (T4) so a randomized layout round-trips through snapshot → restore into the new instance's registry entry. v0.4 itself defaults the slot to the empty dict, so v0.4 → v0.4 round-trips preserve {}; a future release's stealth work will populate the slot in Instance.create's generator and the same round-trip will preserve those randomized paths.

The .env file is deliberately excluded — it's regenerated from beetroot.yaml on the next beetroot apply.

Manifest

Bases: BaseModel

Per-snapshot metadata embedded as .beetroot-snapshot.json in the archive.

Frozen + extra="forbid" so an archive carrying an unknown future key surfaces a :class:ValidationError at restore time rather than silently dropping the field. v0.4 snapshots are redroid-only by design (kind: Literal["redroid"]); the field exists so a future cross-backend snapshot story doesn't need a second schema bump.

Attributes:

Name Type Description
schema_version Literal[1]

Manifest schema version. Currently 1.

name str

Source instance name at snapshot time.

source_index int

Source instance's allocated port index.

created_at str

ISO-8601 UTC timestamp of when the snapshot was taken.

beetroot_version str

Beetroot release that produced the snapshot.

kind Literal['redroid']

Backend kind discriminator. v0.4 snapshots are redroid-only.

path_layout dict[str, str]

Stealth-posture path mapping carried alongside the instance. Populated from the source's RedroidBackendConfig.stealth_paths at snapshot time (T4) and replayed into the destination's slot on restore. Default {} in v0.4; v0.6's Instance.create generator will populate the slot per-instance.

lifecycle Literal['ephemeral', 'durable']

The source instance's persistence intent (ephemeral / durable) at snapshot time (#124). Stamped so a restored archive carries the same intent; an archive produced before this field existed has no lifecycle key and restores as durable (the default), preserving today's contract.

SnapshotError

Bases: RuntimeError

Raised on snapshot/restore failures (missing source, bad archive, etc.).

read_manifest(archive)

Extract and parse the manifest from a snapshot archive.

Parameters:

Name Type Description Default
archive Path

Path to a .tar.zst snapshot archive.

required

Returns:

Type Description
Manifest

The parsed Manifest from the archive's

Manifest

.beetroot-snapshot.json member.

Raises:

Type Description
SnapshotError

If the archive has no manifest, the manifest can't be parsed, or its schema version is not supported.

restore(archive, *, dest_name, dest_path, force=False)

Unpack a snapshot archive into a new instance directory and register it.

A fresh port index is allocated via :func:ports.lowest_free_index — the source's index is NOT reused, so an instance can be restored alongside its source.

The manifest's path_layout is replayed into the new instance's :class:registry.RedroidBackendConfig.stealth_paths slot via :func:registry.set_stealth_paths. A v0.4 snapshot ships {}, so the assignment is a no-op for today's snapshots — but a v0.6 snapshot carrying randomized paths round-trips into a matching slot on the new instance, ready for render_env to consume on the next apply.

Parameters:

Name Type Description Default
archive Path

Path to a .tar.zst snapshot archive.

required
dest_name str

Registry name to assign to the restored instance.

required
dest_path Path

Directory to extract into (created if absent).

required
force bool

If True, an existing non-empty dest_path is wiped before extraction. Defaults to False (refuse and raise).

False

Returns:

Type Description
Path

The absolute path of the restored instance directory.

Raises:

Type Description
SnapshotError

If dest_name is already registered (with a redroid-specific message when the existing row is non-redroid,

128), if dest_path exists and is non-empty without

force, or if the archive is invalid.

ValueError

If dest_name does not match the instance-name grammar. The CLI default derives dest_name from the attacker-controlled manifest.name; validating here — the API boundary, so both the CLI default and programmatic callers are covered — rejects path separators, .., and absolute paths before any filesystem mutation or registry write, so a malicious archive cannot escape into an attacker-chosen directory.

snapshot(instance_root, dest)

Pack an instance directory into a zstandard-compressed tar archive.

The archive is rooted at the instance directory itself (entries are relative paths like ./beetroot.yaml, ./data/..., ./modules/...). The .env file is deliberately excluded — it's regenerated from beetroot.yaml on the next beetroot apply. The manifest is written as the archive's FIRST member (#265) so :func:read_manifest can early-exit after reading a few KiB rather than streaming/decompressing the whole archive to reach a trailing manifest; an older archive that carried the manifest last still restores because :func:read_manifest matches by member name, wherever it sits.

Holds a SHARED fcntl.flock on <instance_root>/.beetroot.lock for the duration of the archive write — multiple snapshots can run in parallel, but a concurrent :meth:Instance.destroy (which takes the exclusive lock) blocks until snapshotting finishes. Without this, a destroy race would rmtree the directory mid-read and produce a torn archive. (T2 Agent 2 B-12.)

Warning

Snapshotting a running container is unsupported. A live /data bind-mount commonly contains absolute symlinks created by Android init or the Magisk daemon. These fail the filter="data" extraction guard (tarfile raises AbsoluteLinkError) so the resulting archive cannot be restored on most hosts. :func:restore rolls back cleanly when extraction fails (B7a), but the snapshot itself will be unrestorable. Always run beetroot down <name> before snapshotting.

Parameters:

Name Type Description Default
instance_root Path

The source instance directory (the one containing beetroot.yaml). The instance must be registered.

required
dest Path

Destination path for the archive. .tar.zst is appended if the caller omits it. Parent directories are created.

required

Returns:

Type Description
Path

The final archive path (after the .tar.zst extension fix-up).

Raises:

Type Description
SnapshotError

If instance_root has no beetroot.yaml or isn't registered under any name.

unsupported_backend_message(verb, name, kind)

Build the "snapshot/restore is redroid-only" error message for a backend kind.

Single source of truth for the wording shared by :func:_find_registry_entry (the programmatic snapshot path) and the CLI snapshot / restore verbs, so a binder: vm (or adb) instance gets one consistent, actionable error instead of the misleading "not registered" message that :func:_find_registry_entry used to raise for any non-redroid row.

Parameters:

Name Type Description Default
verb str

The verb being attempted ("snapshot" or "restore").

required
name str

The offending instance name.

required
kind str

The instance's registered backend kind (e.g. "vm", "adb").

required

Returns:

Type Description
str

A one-line error string naming the verb, the instance, and the

str

unsupported backend, and pointing at issue #128.

beetroot.backends

beetroot.backends

Backend registry: maps a backend kind discriminator to a concrete class.

A "backend" is any class that satisfies the :class:beetroot.api.DeviceBackend Protocol AND exposes a from_meta(name: str, backend_config) -> Self classmethod (used by :meth:beetroot.api.Manager.resolve to construct the backend from a registry row's :class:beetroot.registry.BackendConfigBase).

In-tree backends register themselves programmatically at import time (see :func:_register_builtin_backends); third-party backends register via the [project.entry-points."beetroot.backends"] group in their pyproject.toml. Third parties also call :func:beetroot.registry.register_backend_config to add their :class:~beetroot.registry.BackendConfigBase subclass to the open registry union — that is what makes their rows survive read/write cycles.

T1 ships only the redroid backend; T5 adds adb.py (the :class:AdbDevice backend) and registers it as "adb".

BackendRegistrationError

Bases: ValueError

Raised on duplicate or invalid backend registration.

get_backend(kind)

Look up a backend class by kind, loading entry points on first call.

Parameters:

Name Type Description Default
kind str

The discriminator value.

required

Returns:

Type Description
type[DeviceBackend]

The registered backend class.

Raises:

Type Description
KeyError

If no backend is registered for kind (after the entry-point load attempt).

register_backend(kind, cls)

Register a backend class under a kind discriminator.

The class is expected to satisfy :class:beetroot.api.DeviceBackend AND expose a from_meta(name, backend_config) -> Self classmethod (used by :meth:beetroot.api.Manager.resolve). The from_meta requirement is checked via hasattr at registration time so a silently-broken third-party backend surfaces the error at registration time rather than at dispatch time.

Entry-point kind collisions (two installed packages both declaring kind="foo") raise :class:BackendRegistrationError loudly instead of silently discarding the second registration — a silent discard would leave the user with a non-obvious "wrong backend loaded" bug that only manifests at runtime dispatch.

Parameters:

Name Type Description Default
kind str

The discriminator value (e.g. "redroid", "adb", "cloud-xyz"). Must be unique across the process.

required
cls type[DeviceBackend]

The concrete backend class.

required

Raises:

Type Description
BackendRegistrationError

If kind is already registered or if cls lacks the required from_meta classmethod.

registered_kinds()

Return the sorted list of currently-registered backend kinds.

reset_for_testing()

Clear the backend registry and reset the entry-point-loaded flag.

Test-only seam. Allows a test to start from a clean registry state and re-register exactly the backends it needs, without relying on the autouse _snapshot_backend_registry fixture's timing. Do NOT call this in production code.

beetroot.backends.adb — the AdbDevice backend

T5's real-device backend. Drives a rooted Android device (real phone, third-party emulator, adb connect-ed network device) via the host adb CLI. Satisfies the DeviceBackend Protocol so every universal CLI verb (shell, frida, module, status) works uniformly against an adopted instance; lifecycle verbs (up, down, restart, apply, destroy, snapshot) raise BackendCapabilityError cleanly because there's no on-disk container to manage. Implements the AutoModuleInstaller capability: auto_install_modules() backs beetroot module --auto-install (push to a synthesized /data/local/tmp/beetroot-module-<N>.zip temp name + su -c magisk --install-module, sha256 enforced, per-module ModuleInstallResult rows; whole-device problems — offline, no usable root, no magisk binary — raise DevicePreflightError from a pre-flight probe instead of producing N identical failed rows).

from beetroot.backends.adb import AdbDevice

The class registers itself as kind="adb" at module import time so Manager.resolve("phone") returns an AdbDevice for any registry row with backend.kind == "adb".

beetroot.backends.adb

AdbDevice backend — real (or emulator) Android device driven over ADB.

The :class:beetroot.api.Instance class is the v0.3 Redroid-over-compose backend; AdbDevice is its sibling for any rooted Android device that the host can reach via adb (a real phone on USB, an emulator started outside Beetroot, a adb connect-ed network device — anything where adb devices lists the target with state "device").

The class satisfies :class:beetroot.api.DeviceBackend so every Protocol-driven CLI verb (shell, frida, module, env, status, doctor) Just Works against an adb-adopted instance. Lifecycle verbs that only make sense for a managed container (up, down, restart, apply, destroy, snapshot) raise :class:beetroot.api.BackendCapabilityError — the CLI catches it and renders a friendly error: ... line + exit 2.

Per-host port allocation reuses the stride-of-10 scheme from :mod:beetroot.ports so an adb-adopted instance never collides with the Frida control ports a redroid container would pick. The host side of adb forward tcp:<host_port> tcp:27042 is the resolved frida port for the instance's registry index — exactly the port the user would have got if they'd called beetroot create instead of beetroot adopt.

AdbDevice

Backend that drives a rooted Android device via the host adb CLI.

Attributes:

Name Type Description
_name

Registry name for this backend.

_config

The validated :class:registry.AdbBackendConfig row.

_host_forward_port

Host port number that adb forward tcp:<host_forward_port> tcp:27042 exposes for Frida.

adb_address property

Return the adb serial verbatim (the value of adb -s <serial>).

Adb-backed devices don't have a host:port form (the serial IS the address); the property name matches the Protocol so callers can stay backend-agnostic.

frida_address property

localhost:<host_forward_port> — what frida -H should target.

is_available property

True iff adb devices lists this serial in state "device".

Devices that show up as offline / unauthorized / no permissions count as unavailable — the user needs to re-plug, accept the RSA prompt, or fix udev rules first.

kind property

Backend discriminator — always "adb".

name property

Registry name for this backend.

__init__(name, config, host_forward_port)

Bind a name + adb config + reserved host port into an AdbDevice.

Most callers use :meth:from_meta (which derives the host port from the registry meta's allocated index) instead of this low-level constructor.

Parameters:

Name Type Description Default
name str

Registry name for this backend.

required
config AdbBackendConfig

The validated :class:registry.AdbBackendConfig row.

required
host_forward_port int

Host port number for the Frida adb forward mapping.

required

add_module(source, *, sha256=None)

Push a Magisk module zip to the device's Downloads dir.

This is the safe-default variant: the zip is pushed to /sdcard/Download/<basename> and the user is told to install it via the Magisk app's Modules tab. For root-driven installs without manual Magisk-app interaction, use :meth:auto_install_modules (the beetroot module --auto-install path), which also enforces sha256.

Parameters:

Name Type Description Default
source str

Path to a local .zip on the host filesystem. Remote URLs are deliberately not supported here — the user can curl the zip into ./modules/ first if they want the same UX as :class:beetroot.api.Instance.

required
sha256 str | None

Optional expected hex digest for integrity checking. Ignored on this safe-default path — verifying the host-side hash stays the user's responsibility before invoking beetroot module; :meth:auto_install_modules enforces it fail-closed.

None

Raises:

Type Description
AdbNotInstalledError

If the adb binary is not on PATH.

auto_install_modules(sources, *, sha256s=None)

Install Magisk modules via root, reporting per-module outcomes.

The issue-#7 auto-install variant of :meth:add_module. Each zip is pushed to a synthesized device-side temp name (/data/local/tmp/beetroot-module-<N>.zip, N = batch position — the untrusted local basename never reaches a device shell) and installed with su -c magisk --install-module <path> — Magisk's own supported non-interactive install primitive (the same one the redroid backend's flash-modules.sh uses), which stages the module into /data/adb/modules_update/<id>/ for the next reboot. The pushed temp zip is removed afterwards, even when the install step fails.

Before anything is pushed, a cheap pre-flight probe (issue #38) diagnoses whole-device problems that would otherwise surface as N identical opaque failed rows: su -c true checks for usable root and su -c 'command -v magisk' checks for the magisk binary, each quoted exactly like the install command itself. A failed probe raises :class:beetroot.api.DevicePreflightError with a single friendly diagnosis and nothing is pushed. Failures are never classified by sniffing probe/install output (host paths and module-controlled stderr are untrusted text): instead the device's connectivity is re-checked authoritatively via :func:serial_is_available (adb devices, serial-scoped), which routes offline / unauthorized / no-permissions devices to the connectivity diagnosis and everything else to the root / magisk one.

A failing module never aborts the batch: every source gets its own :class:beetroot.api.ModuleInstallResult row, in request order, and the caller decides the aggregate exit status. The one exception is a device that goes offline mid-batch: an adb-level failure triggers the same :func:serial_is_available re-probe, and if the device is genuinely gone the remaining modules would all fail identically, so the batch aborts with :class:beetroot.api.DevicePreflightError carrying the rows completed so far in its results attribute. Host-side validation failures (missing zip, sha256 mismatch) keep the per-module row contract unconditionally — they can never mean the device is offline.

Parameters:

Name Type Description Default
sources Sequence[str]

Host paths to local .zip modules.

required
sha256s Sequence[str | None] | None

Optional per-source expected hex digests, parallel to sources. Unlike the safe-default :meth:add_module, a configured digest is enforced fail-closed here — a mismatching zip is never pushed.

None

Returns:

Name Type Description
One list[ModuleInstallResult]

class:beetroot.api.ModuleInstallResult per source.

Raises:

Type Description
AdbNotInstalledError

If the adb binary is not on PATH.

ValueError

If sha256s is given with a length different from sources.

DevicePreflightError

If the device is offline, has no usable root, or has no magisk binary (pre-flight), or if it goes offline mid-batch.

frida_cli(args)

Invoke the host frida CLI against this device.

Beetroot prepends -H localhost:<host_forward_port> and forwards the rest of args verbatim — mirrors :meth:beetroot.api.Instance.frida_cli so the beetroot frida verb is uniform across backends.

Parameters:

Name Type Description Default
args Sequence[str]

Tokens to pass after frida -H <addr> (e.g. ["-n", "com.app"]).

required

Returns:

Type Description
int

The exit code of the frida invocation.

Raises:

Type Description
FridaNotInstalledError

If the frida binary is not on PATH (install via the [frida] extra).

from_meta(name, backend) classmethod

Build an :class:AdbDevice from a registry meta's backend config.

Used by :meth:beetroot.api.Manager.resolve to dispatch via the backend registry. The host forward port is derived from the registry meta's allocated index via the same stride-of-10 allocator used for redroid instances — so an adb-backed instance with index N shares the Frida port a redroid instance with index N would have got.

Typing backend as :class:~beetroot.registry.BackendConfigBase (the shared base) lets mypy verify the call site under strict mode without requiring the old in-tree union.

Parameters:

Name Type Description Default
name str

Registry name.

required
backend BackendConfigBase

The matching backend config. Must be a :class:~beetroot.registry.AdbBackendConfig.

required

Returns:

Type Description
Self

The hydrated :class:AdbDevice.

Raises:

Type Description
InstanceNotFoundError

If backend is not an :class:~beetroot.registry.AdbBackendConfig, or if the registry row is missing.

health()

Aggregate the adb-backed health checks for this device.

T7 wired this on as a real method (T6 shipped the body as a free function in :mod:beetroot.api because T6 landed before T5's :class:AdbDevice class existed). The free function :func:beetroot.api.adb_device_health is preserved as a thin shim that delegates here, so existing programmatic callers (and the doctor-verb dispatch path that predates this method) keep working unchanged.

The check NAMES (frida.handshake, magisk.zygisk, magisk.denylist.<pkg>) match :meth:beetroot.api.Instance.health exactly so downstream tools can grep uniformly across backend kinds. compose.status is intentionally absent — there's no container for an adb-backed device.

Returns:

Type Description
dict[str, CheckResult]

Ordered dict of check name → :class:CheckResult.

install_frida(version=None)

Download the requested frida-server, push it, launch it, expose it.

Steps: 1. frida_download.download(version) populates the per-user cache (idempotent — re-runs hit the cached binary). 2. adb push the cached binary to /data/local/tmp/frida-server. 3. adb shell chmod 755 so the binary is executable. 4. ``adb shell su -c '/data/local/tmp/frida-server </dev/null

/dev/null 2>&1 &'to background the daemon with its stdio detached off the captured adb pipe (so the launch call returns instead of blocking on the daemon's inherited fds). Requires the device to be rooted (Magisk / KernelSU / SuperSU all work). 5.adb forward tcp: tcp:27042sofrida -H localhost:`` reaches the device's Frida socket.

Parameters:

Name Type Description Default
version str | None

The frida release tag (e.g. 16.4.10). None is not supported for the adb backend — adb-adopted devices have no beetroot.yaml to fall back to for a default version.

None

Raises:

Type Description
ValueError

If version is None (no default for adb).

AdbNotInstalledError

If the adb binary is not on PATH.

shell(args=None)

Open an adb -s <serial> shell, optionally with extra args.

Unlike :class:beetroot.api.Instance.shell, no preliminary adb connect is needed — the user-supplied serial already identifies a connected device.

Parameters:

Name Type Description Default
args Sequence[str] | None

Optional extra tokens appended after adb -s <serial> shell. Pass ["-c", "id"] for a non-interactive command. None (the default) opens an interactive shell.

None

Returns:

Type Description
int

The exit code of the adb shell invocation.

Raises:

Type Description
AdbNotInstalledError

If the adb binary is not on PATH.

serial_is_available(serial)

Return True iff adb devices lists serial in state "device".

Shared between :attr:AdbDevice.is_available, the --verify flag on beetroot adopt, and the auto-install failure classifier (:meth:AdbDevice.auto_install_modules re-probes connectivity with this instead of sniffing untrusted error text) so every call site uses identical parsing logic. Serials in offline / unauthorized / no permissions state return False — the user needs to re-plug, accept the RSA prompt, or fix udev rules first.

Parameters:

Name Type Description Default
serial str

The adb serial / endpoint identifier to look up.

required

Returns:

Type Description
bool

True if adb devices exits 0 and the serial is listed as

bool

device; False otherwise. Also False when the adb binary

bool

is absent from PATH — an uninstalled adb means no device is

bool

reachable, so status / ls render a clean unavailable row

bool

instead of crashing on FileNotFoundError.

beetroot.builder

beetroot.builder

One-time base-image builder.

Rewrite of the legacy scripts/setup.sh as testable Python. Clones the external ayasa520/redroid-script patcher, runs it to bake Magisk + optional GApps + Houdini into a redroid base image, then layers Beetroot's own entrypoint.sh / stealth.rc on top via docker compose build.

Public surface:

  • :class:SubprocessRunner — protocol describing how subprocess calls are dispatched. The default :class:DefaultRunner shells out via subprocess.run(check=True); tests inject a recording fake.
  • :class:BootstrapError — raised when any step (clone, patch, build) fails.
  • :data:GAPPS_VENDOR_FLAGS — mapping from the resolved gapps vendor to the patcher CLI flags it needs.
  • :func:build_image — entry point that orchestrates the three steps and returns the resulting image tag.

BackgroundProcess

Bases: Protocol

A long-running child process (the throwaway staging dockerd) that can be stopped.

stop()

Terminate the process and release any associated resources.

BootstrapError

Bases: RuntimeError

Raised when a bootstrap step (git clone, patcher, build) fails.

DefaultRootfsRunner

Production :class:RootfsRunner backed by :mod:subprocess.

capture(cmd, *, cwd=None)

Run cmd and return its stdout as text, raising :class:BootstrapError on failure.

run(cmd, *, cwd=None, env=None)

Run cmd via :func:subprocess.run, raising :class:BootstrapError on failure.

spawn(cmd, *, env=None, log_path=None)

Start cmd detached, sending stdout+stderr to log_path (or discarding them).

try_run(cmd, *, cwd=None, env=None)

Run cmd quietly and return True iff it exited zero.

DefaultRunner

Production :class:SubprocessRunner that shells out via :mod:subprocess.

Translates :class:subprocess.CalledProcessError into :class:BootstrapError so callers only need to catch one exception type.

run(cmd, *, cwd=None, check=True, env=None)

Run cmd via :func:subprocess.run.

Parameters:

Name Type Description Default
cmd Sequence[str]

The argv to execute.

required
cwd Path | None

Working directory; None inherits the parent's.

None
check bool

If True, raise :class:BootstrapError on non-zero exit.

True
env dict[str, str] | None

Extra environment to overlay on the parent's. None inherits the parent's environment unmodified. A non-None dict is merged on top of os.environ rather than replacing it, so the child still sees PATH, HOME, DOCKER_CONFIG, etc. — without this merge a bare {"BASE_IMAGE": tag} would launch docker with no PATH and the build would fail with FileNotFoundError on a fresh shell.

None

Raises:

Type Description
BootstrapError

If check is True and the command exits non-zero.

PreflightProblem

Bases: BaseModel

One missing host prerequisite for beetroot build --vm-kernel.

Attributes:

Name Type Description
requirement str

The missing tool / capability (e.g. socat).

detail str

Why it failed the check (not found, daemon down, …).

fix str

The actionable remedy (the apt package or command to run).

RootfsRunner

Bases: Protocol

Executes the external tools the rootfs assembly cannot do in pure stdlib.

Richer than :class:SubprocessRunner: the rootfs build needs to capture stdout (ldd, busybox --list), probe a command's success without raising (the staging dockerd readiness loop), and spawn a background daemon. Tests inject a recording fake that materialises the files real tools would produce.

capture(cmd, *, cwd=None)

Run cmd and return its captured stdout, raising on failure.

run(cmd, *, cwd=None, env=None)

Run cmd to completion, raising :class:BootstrapError on failure.

spawn(cmd, *, env=None, log_path=None)

Start cmd in the background, streaming its output to log_path.

try_run(cmd, *, cwd=None, env=None)

Run cmd and return whether it exited zero (never raises).

SubprocessRunner

Bases: Protocol

Strategy object that executes external commands.

Implementations should raise :class:BootstrapError (or let the caller translate a subprocess.CalledProcessError to one) when check is True and the command exits non-zero. Keeping this as a Protocol means tests can inject a fake that records calls without monkey-patching the global :mod:subprocess module.

run(cmd, *, cwd=None, check=True, env=None)

Execute cmd, optionally in cwd with extra environment.

Parameters:

Name Type Description Default
cmd Sequence[str]

The argv to execute, including the binary as cmd[0].

required
cwd Path | None

Working directory; None inherits the parent's.

None
check bool

If True, raise on non-zero exit.

True
env dict[str, str] | None

Full environment to pass; None inherits the parent's.

None

VmArtifacts

Bases: BaseModel

Host paths to the guest kernel + rootfs produced by beetroot build --vm-kernel.

Attributes:

Name Type Description
kernel Path

Path to the built guest bzImage.

rootfs Path

Path to the built guest ext4 root image.

build_image(*, gapps='minimal', gapps_vendor=None, android_version=config.DEFAULT_ANDROID_VERSION, redroid_script_url=_DEFAULT_REDROID_URL, work_dir=None, build_context=None, runner=None)

Patch and build the redroid base image + Beetroot layer for gapps.

The three steps are:

  1. git clone --depth 1 <redroid_script_url> <work_dir>. If work_dir already contains a clone of the same URL the clone step is skipped so re-running beetroot build after a network interruption doesn't discard already-downloaded Magisk / GApps / Houdini artifacts. A pre-existing clone of a different URL is wiped and re-cloned so the caller never silently builds against the wrong source.
  2. uv run --with requests --with tqdm python -W ignore redroid.py -a <version>.0.0 [gapps-flag] -i -m from inside work_dir.
  3. BASE_IMAGE=<tag> <docker_bin> compose build from build_context (see below).

Parameters:

Name Type Description Default
gapps GappsIntent

GApps intent to bake in (none / minimal / full).

'minimal'
gapps_vendor GappsVendor | None

Optional vendor override (litegapps / opengapps / mindthegapps). None lets the intent pick the vendor. Must not be combined with gapps: none (rejected by :class:config.Android).

None
android_version int

Android major version. Must be one of 11, 12, 13, 14 — validated against :class:beetroot.config.Android.

DEFAULT_ANDROID_VERSION
redroid_script_url str

Override the patcher source (testing / forks).

_DEFAULT_REDROID_URL
work_dir Path | None

Override the clone directory (default is a subdir of the user cache; see :func:_default_work_dir).

None
build_context Path | None

Directory passed to Docker as the build context and as --project-directory. Must contain a docker/ sub-directory with Dockerfile and the boot-script helpers. When None the BEETROOT_BUILD_CONTEXT env var is consulted, falling back to paths.bundled_compose_file().parent.parent.parent.parent — the repo root for a source / editable install. For a uv tool install-based setup you MUST set one of these because docker/ is not bundled in the wheel.

None
runner SubprocessRunner | None

Inject a :class:SubprocessRunner for testing. Defaults to :class:DefaultRunner.

None

Returns:

Type Description
str

The full base-image tag that was built, e.g.

str

redroid/redroid:14.0.0_litegapps_houdini_magisk.

Raises:

Type Description
BootstrapError

If git clone, the patcher, or docker compose build fail.

ValueError

If android_version is not one of the supported redroid versions (delegated to :class:beetroot.config.Android).

build_rootfs(*, out_image, vm_dir, android_version=config.DEFAULT_ANDROID_VERSION, runner=None)

Assemble the micro-VM guest ext4 rootfs (pure-Python port of build-rootfs.sh).

Stages busybox + the Docker static bundle + static iptables-legacy + socat, bakes the redroid image into /var/lib/docker (so the guest boots fully offline), installs guest-init.sh as /init, and packs the tree into a raw ext4 image with mke2fs -d (no loop mount, no root needed). The Android version baked is recorded in a marker beside out_image (see :func:rootfs_version_marker) so up / apply can warn on a config/rootfs version skew (issue #82).

Parameters:

Name Type Description Default
out_image Path

Path the packed ext4 image is written to.

required
vm_dir Path

Directory holding guest-init.sh and (optionally) adbprobe.c.

required
android_version int

Android major version to bake; selects the plain upstream redroid image (overridden by the REDROID_IMAGE env var). Defaults to :data:config.DEFAULT_ANDROID_VERSION.

DEFAULT_ANDROID_VERSION
runner RootfsRunner | None

Inject a :class:RootfsRunner for testing. Defaults to :class:DefaultRootfsRunner.

None

Returns:

Type Description
Path

out_image (the path of the assembled rootfs image).

Raises:

Type Description
BootstrapError

If any external tool (curl, tar, dockerd, docker, mke2fs, …) fails.

build_vm_kernel(*, out_dir=None, build_context=None, android_version=config.DEFAULT_ANDROID_VERSION, runner=None, rootfs_build=build_rootfs, from_source=False, kernel_fetch=kernel_download.fetch_prebuilt, rootfs_fetch=rootfs_download.fetch_prebuilt, bake_preflight=None)

Build the micro-VM guest kernel + rootfs for the binder: vm backend.

Two steps:

  1. Obtain the guest kernel bzImage. By default this fetches a prebuilt kernel matching the pinned version + the bundled kernel.config fingerprint from the repo's GitHub release (~12 MiB, seconds) — so a fresh host skips the ~7-min compile. If no matching prebuilt exists (config edited, version bumped, release not yet published, or network blocked) it falls back to compiling from a make defconfig base merged with the vendored fragment via the kernel tree's merge_config.sh + make (the heavyweight compile stays a shell step the injected :class:SubprocessRunner dispatches). Pass from_source=True to skip the fetch and always compile.
  2. Obtain the ext4 rootfs. By default this fetches a prebuilt rootfs matching the Android version + a composite fingerprint over (Android version, Docker bundle version, guest-init.sh) from the repo's GitHub release (a zstd-compressed image over plain HTTPS) — so a fresh host skips the ~2 GiB redroid pull + local bake (and needs no Docker daemon). If no matching prebuilt exists (an input changed, release not yet published, or network blocked) it falls back to assembling the rootfs locally via :func:build_rootfs (busybox-static + Docker static bundle + guest-init.sh as /init). from_source=True forces a local bake of the rootfs too, as does setting any rootfs-bake override env var (REDROID_TAR / REDROID_IMAGE / IMAGE_SIZE_MB / DOCKER_URL) — those change the baked bytes without being part of the prebuilt fingerprint, so the prebuilt would silently ignore them. The heavyweight bake-only host prerequisites (busybox/socat/iptables/ldd/ mke2fs + a responsive Docker daemon) are enforced via :func:vm_bake_preflight only when a bake will actually run, so the default fetch-only path needs none of them.

Parameters:

Name Type Description Default
out_dir Path | None

Directory the bzImage and rootdisk.img are written to. Defaults to a vm subdir of the user cache.

None
build_context Path | None

A source-checkout directory whose docker/vm/ subtree holds the build assets. When None (the default), the BEETROOT_BUILD_CONTEXT env var is consulted, and failing that the assets bundled inside the wheel are used — so the build works from a plain uv tool install with no docker/ tree on disk.

None
android_version int

Android major version to bake into the guest rootfs (issue #82); passed through to :func:build_rootfs, which selects the matching plain upstream redroid image and records the version in a marker beside the image. Defaults to :data:config.DEFAULT_ANDROID_VERSION so a default-config instance and an unflagged build --vm-kernel agree.

DEFAULT_ANDROID_VERSION
runner SubprocessRunner | None

Inject a :class:SubprocessRunner (the kernel step) for testing. Defaults to :class:DefaultRunner.

None
rootfs_build _RootfsBuildFn

Inject the rootfs assembler for testing. Defaults to :func:build_rootfs.

build_rootfs
from_source bool

Skip both prebuilt fetches and always build both guest artifacts locally — compile the kernel from source and bake the rootfs locally.

False
kernel_fetch _KernelFetchFn

Inject the prebuilt kernel fetcher for testing. Defaults to :func:kernel_download.fetch_prebuilt.

fetch_prebuilt
rootfs_fetch _RootfsFetchFn

Inject the prebuilt rootfs fetcher for testing. Defaults to :func:rootfs_download.fetch_prebuilt.

fetch_prebuilt
bake_preflight _BakePreflightFn | None

Inject the bake-only host-prerequisite check for testing. Defaults to :func:vm_bake_preflight; called only when a local rootfs bake is about to run.

None

Returns:

Name Type Description
The VmArtifacts

class:VmArtifacts naming the built kernel + rootfs paths.

Raises:

Type Description
BootstrapError

If the kernel build or rootfs assembly fails, or if a local rootfs bake is required but the host is missing bake-only prerequisites (see :func:vm_bake_preflight).

read_rootfs_version(out_image)

Read the Android version recorded beside a baked rootfs, if a marker exists.

Backward-compatible by design: a rootfs built before issue #82 (or one whose marker the user deleted) has no marker, so this returns None and the caller stays silent rather than warning on a missing-marker case.

Parameters:

Name Type Description Default
out_image Path

The packed rootfs image path the marker sits beside.

required

Returns:

Type Description
int | None

The baked Android major version, or None when no marker exists or

int | None

its contents are not a plain integer.

rootfs_bake_override_set()

Return the first set rootfs-bake override env var, or None if none is set.

These knobs (REDROID_IMAGE / REDROID_TAR / IMAGE_SIZE_MB / DOCKER_URL) change the baked bytes without being part of the prebuilt fingerprint, so a prebuilt fetch would silently bypass them. The caller (:func:build_vm_kernel) treats any of them as a directive to skip the prebuilt rootfs fetch and bake locally instead, keeping the documented overrides honest (issue #79).

Returns:

Type Description
str | None

The name of the first override env var that is set (non-empty), or

str | None

None when none is — in which case the prebuilt fetch is safe.

rootfs_version_marker(out_image)

Return the path of the baked-version marker that sits beside out_image.

Parameters:

Name Type Description Default
out_image Path

The packed rootfs image path (e.g. rootdisk.img).

required

Returns:

Type Description
Path

<out_image>.android-version — the file

Path

func:build_rootfs writes (and the VM backend reads) to record the

Path

Android major version the rootfs was baked with.

vm_bake_preflight(*, redroid_tar=None)

Check the host prerequisites the local rootfs bake needs.

Assembling the guest rootfs locally stages a few static host binaries (busybox/socat/iptables-legacy), shells out to ldd/mke2fs, and bakes the redroid image via the host Docker daemon. Each used to abort the build one at a time with a raw [Errno 2] and no install hint (issue #78); this reports them all together, each with the apt package (or command) that fixes it. These are enforced only when a bake actually runs — a prebuilt rootfs miss or --from-source — so a fresh dockerless/busyboxless host that fetches the prebuilt rootfs is never gated on them (issue #79).

Parameters:

Name Type Description Default
redroid_tar Path | None

A pre-saved redroid image tarball (the REDROID_TAR env knob). When set, the Docker-daemon check is skipped — the bake loads from the tarball instead of pulling, so no running daemon is needed.

None

Returns:

Name Type Description
One list[PreflightProblem]

class:PreflightProblem per missing bake prerequisite (empty when

list[PreflightProblem]

the host is ready), each carrying an actionable fix.

vm_build_preflight(*, redroid_tar=None)

Report every host prerequisite for beetroot build --vm-kernel.

The union of :func:vm_fetch_preflight (the lightweight fetch-path tools) and :func:vm_bake_preflight (the heavyweight local-bake toolchain). This is what beetroot build --vm-kernel --check surfaces — a single pass over the full superset so a researcher provisioning a host sees everything either path might need at once, even though a default (fetch-only) build needs only the fetch subset.

Parameters:

Name Type Description Default
redroid_tar Path | None

A pre-saved redroid image tarball (the REDROID_TAR env knob). When set, the Docker-daemon check is skipped.

None

Returns:

Name Type Description
One list[PreflightProblem]

class:PreflightProblem per missing prerequisite across both paths.

vm_fetch_preflight()

Check the lightweight host prerequisites the prebuilt-fetch path needs.

The default beetroot build --vm-kernel fetches both guest artifacts as prebuilt release assets over plain HTTPS — it needs no Docker daemon and none of the static rootfs-bake binaries. The only host tools that path can still reach for are curl + tar: the kernel prebuilt fetch can miss (config edited, version bumped, release not published, network blocked) and degrade to a self-contained source compile that downloads + extracts the kernel tarball. These are cheap and almost always present, so they're checked up front; the heavyweight rootfs-bake toolchain is checked separately, only when a bake actually runs (see :func:vm_bake_preflight).

Returns:

Name Type Description
One list[PreflightProblem]

class:PreflightProblem per missing fetch-path tool (empty when the

list[PreflightProblem]

host is ready), each carrying an actionable fix.

beetroot.paths

beetroot.paths

Single source of truth for filesystem layout.

The path model is Docker-inspired: an "instance" is any directory on disk containing a beetroot.yaml file. There is no central instances/ directory — instances live wherever the user puts them. The CLI discovers the current instance the way git discovers a repo: it walks up from the cwd looking for the marker file (beetroot.yaml).

Global state (the cross-instance registry and download caches) lives under the user's platform-appropriate config / cache directories. We delegate to :mod:platformdirs so the same code does the right thing on Linux ($XDG_CONFIG_HOME / $XDG_CACHE_HOME), macOS (~/Library/...), and Windows (%APPDATA% / %LOCALAPPDATA%). The XDG env vars are honoured automatically by platformdirs on Linux.

InstanceRootNotFoundError

Bases: FileNotFoundError

Raised when no beetroot.yaml marker is found in cwd or its ancestors.

bundled_compose_file()

Return the path to the compose.yaml shipped inside the package.

The compose template is bundled under beetroot.templates so the CLI works identically whether installed editable (uv sync) or as a tool (uv tool install); there is no copy of compose.yaml at the project root anymore.

Uses :func:importlib.resources.as_file (T2 Agent 2 B-8) so a wheel install — where the resource lives inside a zip and has no real filesystem path — gets a materialised copy under the user's cache dir that docker compose -f can actually read. The extracted copy is cached per-process: subsequent calls return the same path. The cache key is the resource's bytes (digest), so a package upgrade that ships a new template invalidates the cache on first read of the new bytes. For an editable / source install where files() already returns a real path, as_file returns that path unchanged and no copy is made.

Returns:

Type Description
Path

Absolute path to a readable compose.yaml on disk.

bundled_vm_dir()

Return a directory holding the bundled micro-VM build assets.

The three text assets the binder: vm build needs — kernel.config (the kernel-config fragment merged via merge_config.sh), guest-init.sh (installed as the guest /init) and adbprobe.c (compiled into the guest's ADB self-test) — are shipped as package data under beetroot.templates.vm so beetroot build --vm-kernel works from a plain uv tool install wheel, where the repo's docker/ tree is absent.

Mirrors :func:bundled_compose_file: for an editable / source install the resources already live on a real filesystem path and that directory is returned unchanged; for a zip / wheel install the three assets are extracted into a per-user cache directory (so cc, merge_config.sh and shutil.copy can read them off disk) and cached for the process lifetime.

Returns:

Type Description
Path

Absolute path to a directory containing the three vm assets.

instance_compose_override(root)

Return <root>/compose.override.yaml — the per-instance compose overlay.

Carries the variable-length ports: list (issue #108) that a flat .env can't express; the CLI layers it on top of the bundled template with a second -f when it exists. Regenerated from beetroot.yaml on each beetroot apply, like .env.

instance_data(root)

Return <root>/data/ — bind-mounted to /data inside the container.

instance_env(root)

Return <root>/.env — the compose env file rendered by the CLI.

instance_frida(root)

Return <root>/frida-server — the staged Frida binary for this instance.

instance_modules(root)

Return <root>/modules/ — bind-mounted read-only to /data/adb/modules_update.

instance_root(start=None)

Find the current Beetroot instance root by walking up from start.

An instance root is the nearest ancestor directory containing a beetroot.yaml file. This is the same discovery model used by git (.git marker) and pip/uv (pyproject.toml marker).

Parameters:

Name Type Description Default
start Path | None

Directory to start the search from. Defaults to Path.cwd().

None

Returns:

Type Description
Path

The absolute path to the instance root.

Raises:

Type Description
InstanceRootNotFoundError

If no beetroot.yaml is found in the start directory or any of its ancestors. The error message tells the user to cd into an instance directory.

instance_yaml(root)

Return <root>/beetroot.yaml — the instance config file.

user_cache_dir(subdir)

Return a per-subsystem subdirectory under the user's Beetroot cache.

Lives under <platformdirs-cache>/beetroot/<subdir> — on Linux this is $XDG_CACHE_HOME/beetroot/<subdir>, defaulting to ~/.cache/beetroot/<subdir>. Used for the Frida binary cache and the Magisk module download cache, both shared across instances to avoid re-downloading the same blobs.

Parameters:

Name Type Description Default
subdir str

A subsystem name (e.g. "frida", "modules").

required

Returns:

Type Description
Path

Absolute path to the requested cache subdirectory.

user_config_dir()

Return the user-global Beetroot config directory.

On Linux this resolves to $XDG_CONFIG_HOME/beetroot (defaulting to ~/.config/beetroot); on macOS / Windows :mod:platformdirs picks the platform-appropriate location. Use :func:user_registry_file for the registry-file path specifically.

Returns:

Type Description
Path

Absolute path to the per-user Beetroot config dir.

user_registry_file()

Return the absolute path to the cross-instance registry file.

Lives at <user_config_dir>/instances.json. The directory is resolved by :mod:platformdirs so the same code does the right thing on Linux / macOS / Windows; on Linux $XDG_CONFIG_HOME is honoured automatically. Note this is a user-global registry — every instance on the host is listed here, regardless of where on disk its directory lives.

Returns:

Type Description
Path

Absolute path to the registry JSON file.