Imported from Xiddoc/Beetroot (
AGENTS.md). Install upstream withnpx skills add Xiddoc/Beetroot. Copyright stays with the author.
AGENTS.md
The contributor and agent playbook for Beetroot: how to drive the CLI, the uv-based development workflow (lint, type-check, tests, coverage, CI gates), and the gotchas to know before editing. Architecture and project orientation live in CLAUDE.md.
Commands
uv sync— install the CLI's Python deps (PyYAML + Pydantic) into a project-local.venv. See Development workflow for lint, type-check, and dev deps. Contributors only. End users should install withuv tool install git+https://github.com/Xiddoc/Beetroot.git, which exposesbeetrootonPATHwithout theuv runprefix. The host-sidefridaCLI used bybeetroot fridais optional and exposed via a[frida]extra (uv sync --extra fridain-tree, oruv tool install 'beetroot[frida]'for end users); plain installs omitfrida-toolsandbeetroot fridaerrors out with an install hint.uv run beetroot <verb>— invoke any CLI verb during development. Verbs:create,register,adopt,apply,up,down,restart,destroy,reset,forget,ls,logs,shell,status,doctor,modes,frida-addr,install-frida,module,build,snapshot,restore. Runbeetroot <verb> --helpfor flags. (With auv tool install-based setup, drop theuv runprefix entirely.)beetroot build [variant](oruv run beetroot build [variant]in-tree) — one-time bootstrap. Clonesayasa520/redroid-scriptinto the per-user cache dir (~/.cache/beetroot/redroid-script, respecting$XDG_CACHE_HOME), runs its patcher viauvto produce a local base image (e.g.redroid/redroid:14.0.0_litegapps_houdini_magisk), thendocker compose builds the research layer on top of it via theBASE_IMAGEbuild arg. The optional positional argument selects the GApps intent:none,minimal(default), orfull; an optional--gapps-vendor(litegapps/opengapps/mindthegapps) pins a specific distribution. Re-run once per intent/vendor whenever the base image needs to be regenerated.beetroot updoes not auto-rebuild — runbeetroot buildfirst if you want a fresh image. The implementation lives insrc/beetroot/builder.py.docker compose -p <name> -f <bundled-compose> -f <instance-dir>/compose.override.yaml --project-directory <instance-dir> --env-file <instance-dir>/.env <subcommand>— the raw escape hatch. The CLI just wraps this; if the CLI breaks, you can still drive instances directly. The second-fcarries the variable-lengthports:overlay (issue #108) and is present only after the firstapplyhas staged it (_base_cmdomits it when absent sodown/ps/logswork pre-apply). The bundled compose template lives atsrc/beetroot/templates/compose.yaml(resolve at runtime viapaths.bundled_compose_file()).
A typical flow: beetroot create alpha → beetroot up alpha → beetroot shell alpha.
Things to know when editing
docker/entrypoint.shruns inside Android. Android's userland is toybox-derived — no GNU coreutils, no bash. Stick to POSIX sh and toybox-compatible flags. Magisk's sqlite schema is load-bearing; do not refactor the DB writes.docker/stealth.rcis Android init syntax, not arbitrary text.exec_background u:r:magisk:s0is a SELinux context. If you don't know what that means, don't touch this file.- The base image tag is derived at runtime from
android.version,android.gapps, and the optionalandroid.gapps_vendorinbeetroot.yamlbyconfig.base_image_tag()(e.g.version: 14, gapps: minimal→redroid/redroid:14.0.0_litegapps_houdini_magisk; the intent resolves to a vendor viaconfig.resolve_gapps_vendor()). The tag is injected into the build via theBASE_IMAGEARG indocker/Dockerfileand the${BASE_IMAGE}substitution insrc/beetroot/templates/compose.yaml. The patcher that produces the base image isbeetroot build <intent> [--gapps-vendor <vendor>](wrappingayasa520/redroid-script); run it once per intent/vendor. Bumping Android version, gapps intent/vendor, or translation layer means re-running the patcher with the appropriate flags. - Adding a new Android version. The supported set is single-sourced at
config._VALID_ANDROID_VERSIONS(andconfig.DEFAULT_ANDROID_VERSIONfor the default). To support Android N:- Add N to
_VALID_ANDROID_VERSIONS(and bumpDEFAULT_ANDROID_VERSIONif it should become the new default). - Verify the upstream tags actually exist — this is the assumption nothing else checks. Both image-tag derivations are pure functions of
version:base_image_tag()buildsredroid/redroid:N.0.0[_gapps]_houdini_magisk(via theayasa520/redroid-scriptpatcher) andvm_redroid_image()builds the plainredroid/redroid:N.0.0-latest(pulled straight from Docker Hub for thebinder: vmguest). A version that upstream tags differently (no.0.0, no-latest, or a GApps/Houdini flavour that doesn't exist for N) passes validation and then 404s at pull time. Confirm the tags on Docker Hub before adding the version. - Update the human-readable enumerations ("11, 12, 13, or 14") in the
config.py/builder.pydocstrings,README.md,docs/reference/config.md, anddocs/guides/ci-reusable-workflow.md.tests/test_android_version_extensibility.pyfails CI on drift for all of these — it greps theconfig.py/builder.pysource enumerations against the constant, and presence-checks the canonical phrase in the three doc pages — and parametrizes both tag-derivation functions across every supported version, so a stale enumeration or a malformed tag is caught at the unit level.
- Add N to
- The bundled
compose.yamlis templated — every${VAR}must have a corresponding line inrender_env()insrc/beetroot/config.py. If you add a new substitution, update both. api_versiongates the schema.InstanceConfigcarries a top-levelapi_version: int(currently2, tracked bySUPPORTED_API_VERSIONinsrc/beetroot/config.py). The default lets old YAMLs that omit the field keep working; pinning a non-matching value raises aValidationErrorpointing atCHANGELOG.md. When a future change breaks the schema, bumpSUPPORTED_API_VERSIONand add a migration entry toCHANGELOG.md.mem_limitandcpusare top-level keys, not underdeploy:. Thedeploy:form is Swarm-only and silently ignored bydocker compose up. This also applies tomem_reservation,memswap_limit, andpids_limit— all top-level keys in the bundled compose template.- Environment-driven overrides are provided by
src/beetroot/settings.py(Settings(BaseSettings)). The followingBEETROOT_*environment variables are recognised:BEETROOT_DOCKER_BIN(default:docker),BEETROOT_FRIDA_ARCH(default:android-x86_64),BEETROOT_HTTP_TIMEOUT(default:30), andBEETROOT_VM_ADB_CONNECT_TIMEOUT(default:60; secondsVmDeviceBackend.up()pollsadb connectagainst the freshly-launched micro-VM guest before failing — the guest re-binds adbd's TCP port a few seconds after boot). These are read at import time; override them before launching the CLI. - Docs are part of every feature. Touching a CLI verb, install path, schema field, or any user-facing string means grepping
docs/andREADME.mdfor old spellings and updating every hit — not just the obvious page. v0.2 shipped multiple features (uv tool install, the[frida]extra) with README + one docs page updated while three other pages still showed the old guidance; see retros onfix/uv-tool-install-docsandfix/frida-extra-docsinCHANGELOG.md. Before commit, grep the surface that changed:- CLI verb rename:
grep -rn '<old-verb>' docs/ README.md - Install path (
alias,pip install,uv run,uv tool install):grep -rn 'alias beetroot\|pip install frida-tools\|uv run beetroot ' docs/ README.md, then prune contributor-aside callouts from the hits. - Schema rename:
grep -rn '<old-field>' docs/ examples/ README.md.
- CLI verb rename:
Development workflow
The project uses uv exclusively as the package manager. Never invoke pip directly — uv owns the virtual environment.
Setup
uv sync # install runtime deps only
uv sync --group dev # also install dev tools (ruff, mypy, types-PyYAML, pytest, pytest-cov)
Dev tools live in [dependency-groups].dev in pyproject.toml (PEP 735 dependency-groups, not [project.optional-dependencies]).
Adding or updating deps
uv add <package> # add a runtime dep
uv add --group dev <package> # add a dev-only dep
uv lock # regenerate uv.lock after manual edits
Lint
uv run ruff check src/beetroot/ # check for violations
uv run ruff check --fix src/beetroot/ # auto-fix fixable violations
uv run ruff format src/beetroot/ # auto-format
uv run ruff format --check src/beetroot/ # CI gate — src/beetroot/ must be formatter-clean
Ruff is configured in [tool.ruff] / [tool.ruff.lint] — target Python 3.13, line length 100, with a strict rule set covering 20+ families including D (pydocstyle, Google convention).
Comments vs. docstrings. Inline comments (#) should be rare and only explain why something is done — not what. Docstrings on public APIs are required and enforced by ruff's D rules with Google convention (convention = "google"). The docstring style is D213: the summary goes on the line after the opening """, not on the same line. Private functions (leading _) do not require docstrings. Tests are per-file-ignored from D — test function names should be self-describing.
Type checking
uv run mypy src/beetroot/
uv run mypy tests/
Mypy is configured in [tool.mypy] with strict = true and the pydantic.mypy plugin enabled. The plugin gives mypy full visibility into pydantic model __init__ signatures, model_validate return types, and ConfigDict options. Both src/beetroot/ and tests/ must type-check — do not add # type: ignore without an error code and a brief comment explaining why.
Tests
uv run pytest # full suite (coverage gate is wired into addopts)
uv run pytest --cov=beetroot --cov-report=term-missing # equivalent — explicit cov flags
The suite treats every warning as an error (filterwarnings = ["error"] in pyproject.toml — allowlist upstream deprecations individually, with a comment naming their origin), runs in random order (pytest-randomly; pass -p no:randomly to disable while bisecting an order-dependent failure), and fails any single test that runs past 30 seconds (pytest-timeout).
Tests live under tests/ and use pytest's built-in mocking (unittest.mock) — no real network calls, and almost no real docker calls. conftest.py provides two composable fixtures: isolated_registry (points $XDG_CONFIG_HOME and $XDG_CACHE_HOME at a per-test tmp dir) and isolated_instance (creates a minimal instance dir and chdirs into it). Most CLI/registry tests use the cli_root composite fixture, which layers isolated_registry with stubbed shutil.which + a no-op frida_download.download.
A small set of tests do shell out to docker for real, and they self-gate so a daemonless host skips them instead of failing (#59). tests/docker_daemon.py exposes a cached daemon_available() that probes docker info (not just shutil.which("docker") — the CLI can be present with no running daemon); test_container_boot.py (docker run) and the destroy-driven restore tests in test_instance_invariants.py / test_partial_failure_rollback.py are marked @pytest.mark.skipif(not daemon_available(), ...). Note test_config.py's docker compose config tests deliberately keep the bare shutil.which guard — compose config only renders YAML and never touches the daemon, so gating them on daemon liveness would needlessly skip tests that work fine daemonless.
What your environment can test (local vs GitHub CI). Run beetroot modes to see, at a glance, which device backends the current host can boot — it prints a table (MODE / STATUS / DETAIL) covering redroid binder: host|auto, binder: vm under KVM and under TCG, and the adb backend. Use it to know what's testable before you reach for a backend.
-
The pure-Python suite (
uv run pytest) is the universal gate — it mocks docker/network and runs anywhere. The only exceptions are the daemon-gated tests above: they run wherever a Docker daemon is live and skip (not fail) where it isn't. -
The Claude Code on the web sandbox (where these agent sessions run) is daemonless and binderless:
dockerCLI present butdockerdopt-in (so the daemon-gated tests skip), no hostbinderand no/dev/kvm(sobeetroot modesreports redroidhost/autoandvm,KVMasunsupported). You CAN boot a real Android VM in this sandbox — use thebinder: vmTCG path. It is the one device backend reachable here, and it does not need binder, KVM, or a running Docker daemon.beetroot modesreports it asneeds-setup(notunsupported) precisely because it is available once you run the two-step setup:apt-get install -y qemu-system-x86 # the TCG (software-emulated) QEMU uv run beetroot build --vm-kernel # fetches prebuilt bzImage (~12 MiB) if one matches the config, else compiles (~7 min); also fetches a prebuilt zstd rootfs (#79) if one matches, else bakes it locally. Pass --from-source to force a local build of both. uv run beetroot create <name> # then copy examples/vm.yaml over <name>/beetroot.yaml uv run beetroot apply <name> && uv run beetroot up <name>There is no
--binderflag — the backend is selected by thebinder: vmkey inbeetroot.yaml(copyexamples/vm.yamlover the generated config). Itsaccel: autoprefers KVM when/dev/kvmexists and automatically falls back to TCG here, so you don't have to pin TCG by hand. The validated recipe and measurements live indocs/design/vm-rnd-log.md. It is slow (TCG is ~5–20× software emulation, no hardware accel) and a slow first boot is expected (not a hang), but it works — don't report VM boot as impossible in this environment. The adb backend is the otherneeds-setuppath (installandroid-tools-adb), but it also needs an external rooted device tobeetroot adopt, so the TCG VM is the self-contained option. Verifiedbeetroot modesin-sandbox (2026-06):redroid (binder: host/auto)→unsupported,binder: vm, KVM accel→unsupported,binder: vm, TCG accel→needs-setup(usable after the setup above),adb backend→needs-setup. Expected skip count: a cleanuv run pytestin the sandbox reports 16 skipped — not 6: the 6 docker-daemon tests plus the 10test_shell_lint.pycases that self-skip becauseshellcheck/shfmtaren't onPATH(install both, or replicate their CI gate withuvx --from shellcheck-py shellcheck -S style -s sh docker/*.sh src/beetroot/templates/vm/*.sh). Bottom line: in the sandbox you can always run and trust the entire pure-Python suite (with those 16 environment-gated tests skipping, not failing); thehost/KVMbackends are genuinelyunsupportedhere, but the TCG VM backend is reachable — boot it via the two-step setup above when you need a live device. -
GitHub-hosted CI (
ubuntu-latest) has a live Docker daemon (so the daemon-gated tests run there and keep coverage at 100%) and can load thebinder_linuxmodule via.github/actions/provide-binder(ladder rank 2), so thee2e.ymlboot tiers exercise the real redroid host path — but those are label/schedule-gated, not a per-PR gate (seeCLAUDE.md→ "Binder runtime & CI"). Thetier-vm-qemutier boots thebinder: vmmicro-VM under TCG (hosted runners have no/dev/kvm).
Coverage
100% line + branch coverage on src/beetroot/ is mandatory. [tool.pytest.ini_options] invokes --cov=beetroot --cov-report=term-missing automatically and [tool.coverage.report].fail_under = 100 makes uv run pytest exit non-zero if the threshold isn't met. New code must come with new tests. CI runs the same gate; the pre-push hook (.pre-commit-config.yaml) catches it locally before the push hits the remote.
Behavior tests, not just line coverage. Line + branch coverage is necessary but not sufficient. When a user-facing behavior emerges from the composition of two or more pieces of code (config-model → resolver → .env render; the create verb → port allocator → registry write → compose start), ship at least one test that drives the full user input → final artifact path and asserts on the artifact. feat/configurable-ports hit 100% line + branch on ports.py and still shipped a silent port self-collision after a partial override, because no test asserted on the resolved .env dict given the model input; see fix/ports-resolver-self-collision in CHANGELOG.md for the fix and the corrective test pattern.
One-time setup of the pre-push hook:
uv sync --group dev
uv run pre-commit install --hook-type pre-push
After that, every git push runs the full test suite + coverage gate. Failures block the push.
Running verbs
uv run beetroot <verb> # contributor workflow (project-local .venv)
For a system-wide install of your working tree (so plain beetroot <verb> works without the uv run prefix), run uv tool install . from the repo root. Re-run it whenever you want the installed copy to catch up with your edits.
CI
GitHub Actions runs the full gate set on every push to master and on every pull request targeting master. The workflow is at .github/workflows/ci.yml. The core jobs are exactly the commands listed above under Lint, Type checking, and Tests (ruff check, ruff format --check src/beetroot/, mypy, pytest with the 100% coverage gate) — CI additionally passes --cov-report=xml -p no:cacheprovider to pytest (stateless runs) and uploads the coverage report (XML + terminal) as a workflow artifact. On top of those, CI enforces:
uv lock --check—uv.lockmust be in sync withpyproject.toml(runuv lockafter editing deps).- actionlint +
uvx zizmor==1.25.2 .github/workflows/— the workflows themselves are lint- and security-audited. All actions stay SHA-pinned and every checkout setspersist-credentials: false; zizmor fails the build otherwise. uvx codespell==2.4.2 src/ docs/ README.md CHANGELOG.md— spelling.uvx yamllint==1.38.0 -c .yamllint src/beetroot/templates/compose.yaml .github/workflows/ examples/— YAML style (policy lives in.yamllint).uvx deptry==0.25.1 .— dependency hygiene: undeclared imports and declared-but-unused deps (config in[tool.deptry]inpyproject.toml; add exceptions only for genuine false positives like the optionalfrida-toolsextra).shellcheck -S style -s sh docker/*.sh src/beetroot/templates/vm/*.sh— shell linting at the strictest severity (style), covering both the in-container boot helpers and the micro-VMguest-init.sh. POSIXshmode (toybox / busybox, not bash).shfmt -i 4 -d docker/*.sh src/beetroot/templates/vm/*.sh— shell formatting for the same set (CI downloads a checksum-verified pinned release binary; installshfmtlocally to replicate).- Packaging gate:
uv build,uvx twine==6.2.0 check dist/*, then the wheel is installed into a clean venv andbeetroot --helpmust run.
Every gate is version-pinned in the workflow (release binaries are checksum-verified); to replicate a gate locally, run the same command with the same pin.
