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.api—Instance,Manager, and theDeviceBackendProtocol are re-exported from the top-level package sofrom beetroot import InstanceJust 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.pycomposes them, doesn't replace them. The CLI's Typer verbs delegate toInstance/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(inbeetroot.backends.adb) — sibling toInstancefor rooted-Android-device backends driven over the hostadbCLI. Import asfrom beetroot.backends.adb import AdbDevice. Satisfies the expandedDeviceBackendProtocol; registers itself askind="adb"in the backend registry at module import time.- Expanded
DeviceBackendProtocol — now hasname: str(read-only property),kind: str(the backend discriminator, e.g."redroid"/"adb"),shell() -> int,frida_cli(args) -> int, and afrom_meta(name, backend_config)classmethod used by the backend-registry dispatcher. The Protocol stays@runtime_checkablesoisinstance(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-Instancebackend). The CLI catches it and renders a friendlyerror: ...line + exit code2(distinct from "instance not found" → exit1).Manager.resolve(name) -> DeviceBackend— dispatches to the concrete backend class via the backend registry. The return type is the Protocol, so callers narrow withisinstance(b, Instance)for Redroid-specific operations. This is the polymorphic entry point most v0.4+ programmatic code wants.register_backend(kind, cls)(inbeetroot.backends) — register an in-process third-party backend. Third-party packages typically prefer the[project.entry-points."beetroot.backends"]mechanism instead (loaded lazily on firstManager.resolvecall), 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 openregistry.BackendConfigBasehierarchy (the type of thebackendfield onregistry.InstanceMeta). It is no longer aField(discriminator=...)discriminated union: backend configs are now resolved through an open registration-based scheme keyed on thekinddiscriminator, so third-party backends register their ownBackendConfigBasesubclass rather than being members of a closed union. In-tree concrete subclasses:registry.RedroidBackendConfig(absolute_path, stealth_paths),registry.AdbBackendConfig(serial), andregistry.VmBackendConfig(absolute_path). Third-party backends define their ownBackendConfigBasesubclass with a uniquekind: Literal[...]discriminator and callregistry.register_backend_config(cls)— see the Adding a backend guide for the in-process / entry-point registration split. Unknown kinds are preserved verbatim asUnresolvedBackendConfigso a row never gets silently wiped.CheckResult— frozen pydantic model withstatus: Literal["pass", "fail", "skip"]and optionalreason: str | None. Returned fromInstance.health()/AdbDevice.health()keyed by check name.Instance.health() -> dict[str, CheckResult]— redroid-backed health surface thatbeetroot doctorconsumes. NOT part of theDeviceBackendProtocol — callers narrow withisinstance(b, Instance)(or callAdbDevice.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 minuscompose.status(no container to inspect). Delegates to the free functionapi.adb_device_health(device), which is preserved as a back-compat shim for pre-T7 programmatic callers.registry.set_stealth_paths(name, blob)— write adict[str, str]into the named instance'sRedroidBackendConfig.stealth_pathsslot (T4 plumbing for a future release's stealth-path work). Locked + atomic-replaced via the same_writepattern the rest ofregistry.pyuses. Rejects unknown names and adb-kind rows.DevicePreflightError(RuntimeError)(issue #38) — raised byAdbDevice.auto_install_modules()when a whole-device problem (offline / unauthorized, no usable root, or nomagiskbinary) would otherwise surface as N identical failed rows, and again mid-batch if an adb-level failure plus anadb devicesre-probe confirms the device went away. Carries the per-module rows completed before the abort in itsresultsattribute. 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'sAdbDeviceBackendwill 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 |
required |
sha256s
|
Sequence[str | None] | None
|
Optional per-source expected hex digests, parallel
to |
None
|
Returns:
| Name | Type | Description |
|---|---|---|
One |
list[ModuleInstallResult]
|
class: |
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 |
reason |
str | None
|
Optional one-line explanation. Surfaced on the doctor
output line for non- |
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. |
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 |
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
|
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: |
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 containingbeetroot.yamland 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 |
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 |
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 |
None
|
cfg
|
InstanceConfig | None
|
Override the default minimal config. Defaults to a
fresh :class: |
None
|
lifecycle
|
Literal['ephemeral', 'durable'] | None
|
Persistence intent to stamp into the generated minimal
|
None
|
Returns:
| Type | Description |
|---|---|
Instance
|
The newly created and registered :class: |
Raises:
| Type | Description |
|---|---|
ValueError
|
If |
FileExistsError
|
If |
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 |
False
|
Raises:
| Type | Description |
|---|---|
ValueError
|
If called with |
ComposeError
|
If |
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 |
required |
Returns:
| Type | Description |
|---|---|
int
|
The exit code of the |
Raises:
| Type | Description |
|---|---|
FridaNotInstalledError
|
If the |
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: |
required |
Returns:
| Type | Description |
|---|---|
Self
|
The hydrated :class: |
Raises:
| Type | Description |
|---|---|
InstanceNotFoundError
|
If |
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: |
Raises:
| Type | Description |
|---|---|
InstanceRootNotFoundError
|
If no |
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: |
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. |
None
|
Raises:
| Type | Description |
|---|---|
ValueError
|
If |
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
|
and the on-disk |
Raises:
| Type | Description |
|---|---|
InstanceNotFoundError
|
If |
logs(*, follow=False)
¶
Tail the container logs for this instance.
Parameters:
| Name | Type | Description | Default |
|---|---|---|---|
follow
|
bool
|
If True, stream logs continuously ( |
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 |
required |
name
|
str | None
|
Registry name. Defaults to the directory's basename. |
None
|
Returns:
| Type | Description |
|---|---|
Instance
|
The newly registered :class: |
Raises:
| Type | Description |
|---|---|
FileNotFoundError
|
If |
ValueError
|
If the chosen name is already registered, if
|
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 |
False
|
Raises:
| Type | Description |
|---|---|
ValueError
|
If called with |
ComposeError
|
If stopping the container fails (the
|
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 |
None
|
Returns:
| Type | Description |
|---|---|
int
|
The exit code of the |
int
|
does not raise on non-zero exits — research scripts may |
int
|
care about |
Raises:
| Type | Description |
|---|---|
AdbNotInstalledError
|
If the |
snapshot(dest)
¶
Pack this instance'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). |
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: |
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 | 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: |
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: |
Raises:
| Type | Description |
|---|---|
InstanceNotFoundError
|
If |
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 |
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 |
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: |
required |
Returns:
| Type | Description |
|---|---|
dict[str, CheckResult]
|
Ordered dict of check name → :class: |
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 |
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
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 "
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 — |
gapps_vendor |
GappsVendor | None
|
Optional vendor override ( |
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 |
Frida
¶
Bases: BaseModel
Frida-server version selection for an instance.
Attributes:
| Name | Type | Description |
|---|---|---|
version |
str
|
Which frida-server release to stage. One of:
|
sha256 |
str | None
|
Optional expected hex digest of the decompressed
frida-server binary. |
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: |
lifecycle |
Literal['ephemeral', 'durable']
|
Whether this instance's |
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; |
modules |
list[Module]
|
Magisk modules to flash at boot. |
magisk |
Magisk
|
Magisk denylist / root-hiding settings. |
ports |
list[PortMapping]
|
List of guest→host :class: |
binder |
Literal['auto', 'host', 'vm']
|
How redroid obtains the kernel |
vm |
Vm
|
QEMU micro-VM tunables (kernel/rootfs paths, accelerator,
vCPUs, memory). Consulted only when |
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 |
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 |
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. |
guest |
int
|
The container-side (guest) port this mapping exposes (1..65535). Required. |
host |
int | None
|
The host-side port. |
Resources
¶
Bases: BaseModel
Docker resource caps for the container.
Attributes:
| Name | Type | Description |
|---|---|---|
mem |
str
|
Hard memory limit (e.g. |
cpus |
float
|
CPU cap as a float. |
shared_mem |
str
|
Shared-memory size (Docker |
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 |
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 |
rootfs |
str | None
|
Host path to the guest ext4 root image. |
accel |
Literal['auto', 'kvm', 'tcg']
|
QEMU accelerator. |
smp |
int | Literal['auto']
|
Number of guest vCPUs ( |
memory_mib |
int
|
Guest RAM in MiB ( |
boot_cache |
bool
|
Opt into the warm-start boot cache (default |
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
|
|
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 |
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
|
|
bool
|
symbolic |
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 ( |
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: |
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 |
None
|
Returns:
| Type | Description |
|---|---|
str
|
The rendered |
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: none → None (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: |
GappsVendor | None
|
intent is |
resolve_rendering(rendering)
¶
Map a display.rendering intent to redroid's gpu_mode string.
gpu → host (render via the host GPU), software → guest
(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 |
required |
Returns:
| Type | Description |
|---|---|
str
|
The redroid |
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
|
|
Raises:
| Type | Description |
|---|---|
ValueError
|
If |
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. |
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 ( |
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: |
required |
Returns:
| Type | Description |
|---|---|
dict[str, int]
|
A dict with keys |
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 |
resolve_ports(index, ports, *, quiet=False)
¶
Resolve every PortMapping to a concrete host port for index.
Resolution rules, in order of precedence:
- an explicit
hostalways wins; - a well-known service (
adb/frida/frida_control) withhostunset gets its stride base (base + index*STRIDE); - any other entry with
hostunset gets an extra-pool slot (EXTRA_POOL_BASE + index*STRIDE + slot, whereslotis 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 |
required |
quiet
|
bool
|
When |
False
|
Returns:
| Type | Description |
|---|---|
list[ResolvedPort]
|
A list of :class: |
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 |
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: |
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 |
serial |
str
|
The adb serial / endpoint identifier (e.g.
|
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. |
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: |
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 |
absolute_path |
str
|
Absolute path to the instance directory (the
directory containing |
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 |
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 |
instances |
dict[str, InstanceMeta]
|
Mapping of instance name → :class: |
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: |
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 |
absolute_path |
str
|
Absolute path to the instance directory (the
directory containing |
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 |
None
|
backend
|
BackendConfigBase | None
|
Pre-built backend config (any
:class: |
None
|
Returns:
| Type | Description |
|---|---|
int
|
The port index that was allocated. |
Raises:
| Type | Description |
|---|---|
ValueError
|
If |
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 |
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
|
|
tuple[int, str, str] | None
|
found — |
tuple[int, str, str] | None
|
colliding entry ( |
tuple[int, str, str] | None
|
renders as |
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 |
Path
|
when the instance was registered. |
Raises:
| Type | Description |
|---|---|
RegistryError
|
If |
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 |
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 |
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 |
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 |
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 |
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¶
|
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
|
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 |
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 |
required |
args
|
Sequence[str]
|
Subcommand and flags to append after the base compose args. |
required |
**kwargs
|
object
|
Forwarded verbatim to |
{}
|
Returns:
| Type | Description |
|---|---|
CompletedProcess[str]
|
The completed process result. |
CompletedProcess[str]
|
|
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
|
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 |
ValueError
|
If |
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. |
latest_release_tag()
¶
Resolve the current frida release tag via the GitHub latest-release redirect.
Returns:
| Type | Description |
|---|---|
str
|
The concrete tag the |
Raises:
| Type | Description |
|---|---|
FridaFetchError
|
If the redirect can't be reached, or the resolved URL
doesn't end in a recognizable |
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. |
required |
arch
|
str | None
|
The frida-server architecture suffix; defaults to
|
None
|
Returns:
| Type | Description |
|---|---|
str
|
The full HTTPS URL to the |
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 |
required |
Returns:
| Type | Description |
|---|---|
str
|
The frida-server architecture suffix (e.g. |
resolve_version(version, *, host_version=None)
¶
Resolve a (possibly symbolic) frida.version to a concrete release tag.
- a pinned
major.minor.patchis returned unchanged (reproducible); autoresolves to the hostfrida-toolsversion when installed (so the staged server matches the client you'll attach with), elselatest;latestresolves to the current upstream release.
Parameters:
| Name | Type | Description | Default |
|---|---|---|---|
version
|
str
|
The configured version ( |
required |
host_version
|
str | None
|
An already-resolved host |
None
|
Returns:
| Type | Description |
|---|---|
str
|
A concrete |
Raises:
| Type | Description |
|---|---|
FridaFetchError
|
On a network failure resolving |
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
|
required |
version
|
str
|
Frida version selector — |
required |
expected_sha256
|
str | None
|
Optional hex digest forwarded to
:func: |
None
|
binder
|
str
|
The instance's |
'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
|
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 |
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 |
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
|
lifecycle |
Literal['ephemeral', 'durable']
|
The source instance's persistence intent
( |
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 |
required |
Returns:
| Type | Description |
|---|---|
Manifest
|
The parsed |
Manifest
|
|
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 |
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 |
False
|
Returns:
| Type | Description |
|---|---|
Path
|
The absolute path of the restored instance directory. |
Raises:
| Type | Description |
|---|---|
SnapshotError
|
If 128), if
|
ValueError
|
If |
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
|
required |
dest
|
Path
|
Destination path for the archive. |
required |
Returns:
| Type | Description |
|---|---|
Path
|
The final archive path (after the |
Raises:
| Type | Description |
|---|---|
SnapshotError
|
If |
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 ( |
required |
name
|
str
|
The offending instance name. |
required |
kind
|
str
|
The instance's registered backend kind (e.g. |
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 |
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. |
required |
cls
|
type[DeviceBackend]
|
The concrete backend class. |
required |
Raises:
| Type | Description |
|---|---|
BackendRegistrationError
|
If |
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).
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: |
|
_host_forward_port |
Host port number that |
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: |
required |
host_forward_port
|
int
|
Host port number for the Frida |
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 |
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 |
None
|
Raises:
| Type | Description |
|---|---|
AdbNotInstalledError
|
If the |
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 |
required |
sha256s
|
Sequence[str | None] | None
|
Optional per-source expected hex digests, parallel
to |
None
|
Returns:
| Name | Type | Description |
|---|---|---|
One |
list[ModuleInstallResult]
|
class: |
Raises:
| Type | Description |
|---|---|
AdbNotInstalledError
|
If the |
ValueError
|
If |
DevicePreflightError
|
If the device is offline, has no
usable root, or has no |
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 |
required |
Returns:
| Type | Description |
|---|---|
int
|
The exit code of the |
Raises:
| Type | Description |
|---|---|
FridaNotInstalledError
|
If the |
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: |
required |
Returns:
| Type | Description |
|---|---|
Self
|
The hydrated :class: |
Raises:
| Type | Description |
|---|---|
InstanceNotFoundError
|
If |
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: |
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:27042 sofrida -H localhost:`` reaches the device's Frida socket.
Parameters:
| Name | Type | Description | Default |
|---|---|---|---|
version
|
str | None
|
The frida release tag (e.g. |
None
|
Raises:
| Type | Description |
|---|---|
ValueError
|
If |
AdbNotInstalledError
|
If the |
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 |
None
|
Returns:
| Type | Description |
|---|---|
int
|
The exit code of the |
Raises:
| Type | Description |
|---|---|
AdbNotInstalledError
|
If the |
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 |
bool
|
|
bool
|
is absent from PATH — an uninstalled adb means no device is |
bool
|
reachable, so |
bool
|
instead of crashing on |
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:DefaultRunnershells out viasubprocess.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
|
check
|
bool
|
If |
True
|
env
|
dict[str, str] | None
|
Extra environment to overlay on the parent's. |
None
|
Raises:
| Type | Description |
|---|---|
BootstrapError
|
If |
PreflightProblem
¶
Bases: BaseModel
One missing host prerequisite for beetroot build --vm-kernel.
Attributes:
| Name | Type | Description |
|---|---|---|
requirement |
str
|
The missing tool / capability (e.g. |
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 |
required |
cwd
|
Path | None
|
Working directory; |
None
|
check
|
bool
|
If |
True
|
env
|
dict[str, str] | None
|
Full environment to pass; |
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 |
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:
git clone --depth 1 <redroid_script_url> <work_dir>. Ifwork_diralready contains a clone of the same URL the clone step is skipped so re-runningbeetroot buildafter 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.uv run --with requests --with tqdm python -W ignore redroid.py -a <version>.0.0 [gapps-flag] -i -mfrom insidework_dir.BASE_IMAGE=<tag> <docker_bin> compose buildfrombuild_context(see below).
Parameters:
| Name | Type | Description | Default |
|---|---|---|---|
gapps
|
GappsIntent
|
GApps intent to bake in ( |
'minimal'
|
gapps_vendor
|
GappsVendor | None
|
Optional vendor override ( |
None
|
android_version
|
int
|
Android major version. Must be one of |
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: |
None
|
build_context
|
Path | None
|
Directory passed to Docker as the build context and
as |
None
|
runner
|
SubprocessRunner | None
|
Inject a :class: |
None
|
Returns:
| Type | Description |
|---|---|
str
|
The full base-image tag that was built, e.g. |
str
|
|
Raises:
| Type | Description |
|---|---|
BootstrapError
|
If git clone, the patcher, or |
ValueError
|
If |
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 |
required |
android_version
|
int
|
Android major version to bake; selects the plain
upstream redroid image (overridden by the |
DEFAULT_ANDROID_VERSION
|
runner
|
RootfsRunner | None
|
Inject a :class: |
None
|
Returns:
| Type | Description |
|---|---|
Path
|
|
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:
- Obtain the guest kernel
bzImage. By default this fetches a prebuilt kernel matching the pinned version + the bundledkernel.configfingerprint 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 amake defconfigbase merged with the vendored fragment via the kernel tree'smerge_config.sh+make(the heavyweight compile stays a shell step the injected :class:SubprocessRunnerdispatches). Passfrom_source=Trueto skip the fetch and always compile. - 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.shas/init).from_source=Trueforces 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_preflightonly 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 |
None
|
build_context
|
Path | None
|
A source-checkout directory whose |
None
|
android_version
|
int
|
Android major version to bake into the guest rootfs
(issue #82); passed through to :func: |
DEFAULT_ANDROID_VERSION
|
runner
|
SubprocessRunner | None
|
Inject a :class: |
None
|
rootfs_build
|
_RootfsBuildFn
|
Inject the rootfs assembler for testing. Defaults to
:func: |
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: |
fetch_prebuilt
|
rootfs_fetch
|
_RootfsFetchFn
|
Inject the prebuilt rootfs fetcher for testing. Defaults
to :func: |
fetch_prebuilt
|
bake_preflight
|
_BakePreflightFn | None
|
Inject the bake-only host-prerequisite check for testing.
Defaults to :func: |
None
|
Returns:
| Name | Type | Description |
|---|---|---|
The |
VmArtifacts
|
class: |
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: |
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 |
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
|
|
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. |
required |
Returns:
| Type | Description |
|---|---|
Path
|
|
Path
|
func: |
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 |
None
|
Returns:
| Name | Type | Description |
|---|---|---|
One |
list[PreflightProblem]
|
class: |
list[PreflightProblem]
|
the host is ready), each carrying an actionable |
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 |
None
|
Returns:
| Name | Type | Description |
|---|---|---|
One |
list[PreflightProblem]
|
class: |
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: |
list[PreflightProblem]
|
host is ready), each carrying an actionable |
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 |
None
|
Returns:
| Type | Description |
|---|---|
Path
|
The absolute path to the instance root. |
Raises:
| Type | Description |
|---|---|
InstanceRootNotFoundError
|
If no |
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. |
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. |