The PersonaPhone dev loop runs the entire multi-persona stack inside Cuttlefish on a Linux box. You edit, build, boot in Cuttlefish, test, repeat — all without touching a Pixel until you're ready for hardware-specific validation.
Cuttlefish is Google's reference virtual Android device. It:
- Uses crosvm as its VMM (same VMM we're patching for snapshot/restore)
- Supports nested KVM — Cuttlefish itself runs as a VM; PersonaPhone's base layer can spawn pVMs inside it
- Is the official AOSP CI target — it tracks upstream AOSP exactly
- Runs on standard x86_64 Linux hosts — no Pixel needed for dev
- Boots in seconds to a fully-functional Android with display (WebRTC streaming) + input + audio + sensors + fake-NFC + fake-RIL + fake-biometric
What this means for us: every line of code — pKVM patches, VirtualizationService extensions, crosvm snapshot/restore, virt HALs, arbitrator daemons, PersonaLauncher UI — is exercised end-to-end on Cuttlefish before any hardware contact. The same source produces the same behavior in Cuttlefish and on a real Pixel, with the documented exceptions in PLAN.md § 4.
┌───────────────────────┐
│ Edit code │
└──────────┬────────────┘
│
▼
┌───────────────────────┐
│ ./tools/build/inc.sh │ incremental build (~30s–5min)
└──────────┬────────────┘
│
▼
┌───────────────────────┐
│ cvd start │ boot PersonaPhone in Cuttlefish
│ --image=<artifact> │ (~15–30s to base layer ready)
└──────────┬────────────┘
│
▼
┌───────────────────────┐
│ ./tools/test/run.sh │ run the test(s) you care about
│ --suite cuttlefish │ (full suite: ~30 min; single test: ~30s–2min)
│ --test <name> │
└──────────┬────────────┘
│
┌─────┴─────┐
│ │
▼ ▼
green red
(commit (read logs, edit, loop)
or
deploy
to Pixel)
Total cycle for "small edit → test result": ~2-5 minutes for most changes. Kernel/pKVM changes are slower (~15 minutes) because of the full kernel build.
One-time setup. Skip if ./tools/build/setup-host.sh has been run.
- Linux x86_64 (Ubuntu 24.04 LTS or Debian 13 recommended)
- 16-core CPU minimum, 32+ recommended
- 64GB RAM minimum (Cuttlefish + base layer + 2 pVMs simultaneously)
- 1TB NVMe — 250GB for AOSP source, 200GB for build artifacts, rest for snapshots
- Hardware virtualization enabled in BIOS (VT-x + VT-d on Intel, AMD-V + IOMMU on AMD)
- Nested virtualization enabled in kernel:
echo "options kvm-intel nested=1" | sudo tee /etc/modprobe.d/kvm.conf echo "options kvm-amd nested=1" | sudo tee -a /etc/modprobe.d/kvm.conf sudo modprobe -r kvm_intel; sudo modprobe kvm_intel # or kvm_amd cat /sys/module/kvm_intel/parameters/nested # should print "Y"
Done by tools/build/setup-host.sh:
# AOSP build deps
sudo apt update
sudo apt install -y git-core gnupg flex bison build-essential zip curl \
zlib1g-dev libc6-dev-i386 libncurses5 lib32ncurses5-dev x11proto-core-dev \
libx11-dev lib32z1-dev libgl1-mesa-dev libxml2-utils xsltproc unzip fontconfig \
python-is-python3 openjdk-21-jdk
# repo tool
mkdir -p ~/bin
curl https://storage.googleapis.com/git-repo-downloads/repo > ~/bin/repo
chmod +x ~/bin/repo
# Cuttlefish host packages
sudo apt install -y cuttlefish-base cuttlefish-user
# Rust (for crosvm work)
curl --proto '=https' --tlsv1.2 -sSf https://sh.rustup.rs | sh
rustup install nightly
rustup component add rust-src --toolchain nightly
# Additional: avbroot, qemu, docker for builds
sudo apt install -y qemu-system-arm qemu-user-static docker.io
cargo install avbrootAfter setup:
./tools/build/check-host.shShould print all green checks. If anything fails, see docs/runbook.md § "Dev host setup issues."
./tools/build/sync-sources.shThis pulls:
| Source | Path | Size | What |
|---|---|---|---|
| AOSP main | aosp/ |
~150GB | Android Open Source Project |
| Android KVM kernel | kernel/android-kvm/ |
~3GB | pKVM-bearing kernel tree |
| crosvm upstream | crosvm/upstream/ |
~500MB | google/crosvm |
| crosvm Android fork | crosvm/android-fork/ |
~500MB | AOSP external/crosvm |
| GrapheneOS sources | aosp-graphene/ |
~150GB | Hardened AOSP for the GrapheneOS persona |
| Cuttlefish images | cf/ |
~5GB | Latest cuttlefish reference images |
| avbroot | third_party/avbroot/ |
~50MB | Boot signing tooling |
| References | third_party/refs/ |
~20GB | Pinned copies of nfcgate, wg-netns, etc. (read-only, for code-lifting) |
Total: ~330GB. Cached aggressively via git's own object reuse; second sync of GrapheneOS is fast because it shares objects with AOSP.
./tools/build/build-all.shBuilds (in order, parallel where possible):
- GKI kernel with our pKVM patches →
out/kernel/ - pvmfw with our AVB key in trust root →
out/pvmfw.img - crosvm with snapshot patches →
out/bin/crosvm - avf (VirtualizationService) with our AIDL extensions →
out/avf/ - Host AOSP (
personaphoneproduct) →out/target/product/personaphone/ - Guest images: one per persona makefile in
guest/personas/*.mk→out/guest/<persona>.img - Cuttlefish images of the same build →
out/cf/ - Sign everything with our AVB key →
out/signed/
Full clean build: ~3-4 hours on a 32-core machine. Incremental: typically 30 seconds to 5 minutes depending on what changed.
./tools/build/inc.shDetects what changed since last build (by mtime + git status) and rebuilds the minimum set. Common scenarios:
| You changed | Rebuilds | Time |
|---|---|---|
| PersonaLauncher Kotlin | Just the APK | ~30s |
| A virt HAL .cpp | Just the HAL + reassemble guest image | ~2min |
| personad C++ | Daemon binary + base layer system image | ~3min |
| Arbitrator daemon | Daemon binary + base layer system image | ~3min |
| crosvm Rust | crosvm binary + redeploy to image | ~1min (Rust incremental) |
| AVF AIDL extension | AVF service + base layer | ~5min |
| pKVM kernel patch | Full kernel rebuild + reflash | ~15min |
| AVB config | Full re-sign | ~5min |
./tools/build/cuttlefish-up.shInternally:
cvd start \
--num_instances=1 \
--kernel_path=out/kernel/Image.lz4 \
--initramfs_path=out/cf/initrd.img \
--system_image_dir=out/cf/ \
--gpu_mode=gfxstream \
--enable_host_nfc_proxy=true \ # talks to our host NFC arbitrator
--enable_host_ril_proxy=true \ # talks to our host RIL arbitrator
--num_persona_pvms=3 \ # spawn 3 nested pVMs for testing
--pvm_image_dir=out/guest/ \
--webrtc_public_ip=stunThis boots Cuttlefish, which boots the PersonaPhone base layer, which then spawns 3 nested pVM personas.
Cuttlefish streams the display + microphone + camera via WebRTC. Open in your browser:
https://localhost:1443
You see the PersonaPhone launcher. Click "switch persona" — the surface swaps to the next pVM. Just like on hardware.
adb devices
# 0.0.0.0:6520 device
adb shell
# you're in the base layer
adb -s 0.0.0.0:6521 shell
# you're in persona 0's pVM
adb -s 0.0.0.0:6522 shell
# persona 1, etc.cvd stop./tools/test/run.sh --suite cuttlefishThis:
- Spins up a fresh Cuttlefish instance
- Runs each test in
tests/cuttlefish/*.shin sequence - Captures: stdout/stderr per test, ADB logcat from base layer + each persona, screenshots at test boundaries, timing data
- Outputs a JUnit-format XML to
out/test-results/cuttlefish-<timestamp>.xml - Tears down Cuttlefish, leaves logs in
out/test-logs/
For a single test:
./tools/test/run.sh --suite cuttlefish --test snapshot-stressFor a flame graph of slow tests:
./tools/test/run.sh --suite cuttlefish --profileCuttlefish doesn't have real NFC / Shannon modem / Titan M2. We add fake backends so the arbitrator daemons can be tested end-to-end:
tools/test/fake-nfc/ is a Python service that mocks the PN553 NCI protocol over the same /dev/nq-nci device path. The NFC arbitrator daemon talks to it indistinguishably from real hardware. We can scriptedly inject "card detected" events to test foreground routing.
Cuttlefish already provides a fake RIL via cuttlefish-ril. We extend it via tools/test/fake-ril/ to support test-driven scenarios: inject incoming calls, SMS, signal strength changes. RIL arbitrator daemon doesn't know it's fake.
tools/test/fake-biometric/ exposes a vsock interface that the biometric arbitrator treats as a fingerprint sensor. Scriptable: "press finger now," "match success," "match fail," "sensor error."
Trusty TEE doesn't run in Cuttlefish by default. We deploy a software-only Trusty replica via tools/test/fake-trusty/ that implements the same Keymint AIDL but stores keys in a host file. For confidentiality testing we deploy this with a host-protected directory; for security testing we explicitly mark these tests as "Cuttlefish stub, full validation requires real Trusty on Pixel."
| Test | Why Cuttlefish can't |
|---|---|
nfc-tap-real-card.sh |
No physical NFC controller |
ril-real-call.sh |
No Shannon modem |
biometric-real-fingerprint.sh |
No fingerprint sensor + no Trusty hardware-bound seal |
tee-real-attestation.sh |
No Titan M2 |
gpu-mali-performance.sh |
Cuttlefish uses host GPU or swiftshader, not Mali |
pmu-power-consumption.sh |
No real PMIC |
boot-from-fastboot.sh |
Cuttlefish boot is different |
These are validated on a dedicated test Pixel attached to the CI runner.
adb shell setprop persist.dev.pkvm.trace 1
adb shell cat /sys/kernel/debug/pkvm/trace./tools/build/cuttlefish-up.sh --crosvm-log=trace
adb logcat | grep crosvmadb shell vm tool inspect --vm-id=persona-2 --include=memory,devices,vcputools/test/gdb-pvm.sh persona-2 opens gdb on the guest kernel via a vsock-bridged gdbserver running inside the pVM.
Same git SHA → bit-identical Cuttlefish images. CI verifies by building each commit twice and diffing:
./tools/build/verify-reproducibility.sh <git-sha>Cuttlefish is somewhat slower than real Pixel (no Tensor accelerators, virtualized I/O), but the ordering is preserved — if a test passes its timing budget on Cuttlefish, it'll pass on Pixel comfortably:
| Operation | Cuttlefish budget | Pixel target |
|---|---|---|
| Base layer cold boot | <30s | <12s |
| First persona pVM start | <15s | <8s |
| Hot persona switch | <5s | <2s |
| Cold persona switch (snapshot from disk) | <12s | <5s |
| NFC arbiter switch handoff | <500ms | <200ms |
| RIL call route after switch | <2s | <1s |
These are encoded as test assertions in tests/cuttlefish/*.sh.
When all tests/cuttlefish/ are green:
./tools/flash/flash-pixel.sh --device <adb-serial>Flashes the same artifacts (signed factory image) you've been testing. Then:
./tools/test/run.sh --suite e2e --device <adb-serial>The e2e suite mirrors the Cuttlefish suite plus the Pixel-only tests. Expected outcome: everything green that was green on Cuttlefish, plus the Pixel-only tests pass on first run.
If a test passes on Cuttlefish but fails on Pixel, that's a divergence bug — file in tests/divergence/ and treat as a blocker.