Field notes
Eleven things Android actually does to a Linux engine — every one hit for real on a stock, unrooted Android 16 phone while bringing the engine up. Each cost a debugging round; recorded so they cost nobody a second one.
1 · Static Go binaries can't resolve DNS
Android has no /etc/resolv.conf. A CGO-free Go binary uses the pure-Go
resolver, which — finding no config — falls back to localhost:53 and dies
with connection refused. Bionic-linked binaries get DNS from Android's
resolver daemon; static binaries get nothing.
Fix: when the file is absent, install a custom
net.DefaultResolver that dials public DNS directly, rotating servers per
attempt (a UDP “dial” succeeds even to an unreachable server, so in-dial fallback is
an illusion). CELLAR_DNS overrides.
2 · …or verify TLS certificates
Same story for CA roots: nothing at /etc/ssl. Termux ships a bundle at
$PREFIX/etc/tls/cert.pem; Android keeps the system store in
/apex/com.android.conscrypt/cacerts (14+) or
/system/etc/security/cacerts.
Fix: probe those locations and set
SSL_CERT_FILE/SSL_CERT_DIR before the first handshake —
Go reads them exactly once.
3 · Hard links are forbidden in app data
SELinux denies link() in app-private storage, and distro rootfs
tarballs are full of hardlinks (Debian's /usr/bin/perl5.40.1 →
perl). Plain tar -x fails with
Cannot hard link … Permission denied.
Fix: first, run the extraction under
proot --link2symlink -0 (proot-distro's battle-tested recipe). Since
engine v0.2, extraction is built into the binary itself — hardlink entries become
plain copies — so neither tar nor proot is involved in extraction at
all, which an APK requires anyway (there's no tar inside an app sandbox).
4 · suid file modes break your own tooling
Alpine ships /bin/bbsuid as mode 4711 — setuid,
executable, not readable. You own the extracted file but can't read it:
export breaks, and read-only directories break recursive delete. suid grants nothing
under proot anyway.
Fix: normalize owner bits after extraction
(u+rw files, u+rwx dirs), and again before deletion for
partially extracted trees.
5 · proot's seccomp fast path breaks npm
npm install inside a machine dies with npm's infamous
Exit handler never called! and an otherwise-empty debug log — while
node itself runs fine. The culprit: proot's seccomp acceleration interacting with
node/npm's process management.
Fix: default PROOT_NO_SECCOMP=1 for guests —
correctness over speed (CELLAR_SECCOMP=1 re-enables the fast path for
benchmarking). With it set, Claude Code installs and runs.
6 · npm's rename dance fails under proot
Reinstalling a global npm package renames the existing dir aside; under proot the
rename dies with ENOTEMPTY. First install works, second breaks.
Fix: catalog stacks are idempotent — detect and skip instead of reinstalling over a live tree.
7 · Android has no /dev/shm, and binding host
/dev hides the guest's
Binding the host /dev into a machine is necessary — and it masks
whatever /dev/shm the rootfs shipped, because Android simply has no
/dev/shm. POSIX shared memory then fails, which quietly takes down
Python's multiprocessing, PostgreSQL's dynamic shared memory, and
anything Chromium-shaped.
Fix: bind a per-machine host directory over /dev/shm.
Verified on-device: before, mp.Queue() raised; after, it round-trips.
8 · A hard stop leaks guest daemons — and
--kill-on-exit can't help
proot's --kill-on-exit reaps guests when proot exits normally.
When a stop escalates to SIGKILL (an init that ignores
SIGTERM), proot dies without ever running it, and any guest that called
setsid() — sshd, a nohup'd agent — survives in its own
process group: invisible to the CLI, still burning Android's 32-child
phantom-process budget.
Finding those orphans is not obvious. proot fakes chroot via ptrace, so
/proc/<pid>/root still shows the host root, and a guest's
cmdline is its own argv with no rootfs path in it. Parentage is the only
reliable signal — walk /proc for PPid and kill
proot's descendants deepest-first before killing proot itself. setsid()
changes the session, not the parent, so the tree walk still finds them.
Measured A/B on the escalation path: without the descendant kill, one orphan survives; with it, zero.
9 · The hardlink ban reaches inside the machine too
Extraction isn't the only thing that hardlinks. Modern package managers link from a
local cache to save space — uv does by default — so installing Aider
inside a machine dies with Operation not permitted, the same SELinux
rule as note 3, one layer deeper. The second attempt then fails differently:
proot's --link2symlink leaves symlink chains behind and reads through the
half-built cache hit ELOOP.
Fix: tell the tool to copy (UV_LINK_MODE=copy) and clear
any cache a failed hardlink run poisoned — both inside the catalog stack, so a user
never meets either error.
10 · Installers assume a TTY that machine commands don't have
Vendor install scripts often end in an interactive configurator. Goose's did, and it
died on /dev/tty: No such device or address. Most have an opt-out; the
trap is where the variable goes — VAR=false curl … | bash sets it
for curl, not for the shell that runs the installer. It belongs on the
right: curl … | VAR=false bash.
11 · os.Exit skips your deferred cleanup
Not Android's fault, but found here: an error path that exits the process means
deferred cleanup never runs — leaving invisible half-created machines
on disk.
Fix: explicit cleanup on error paths, a SIGINT/SIGTERM handler
during create, and — because SIGKILL can't be caught — interrupted machines surface
as broken in cellar ls, removable with
rm --force.
Proof it all works
$ cellar create dev --distro debian # 90 MB, sha256-verified, ~1 min $ cellar apply dev claude-code $ cellar exec dev -- claude --version 2.1.220 (Claude Code)
An AI agent harness, running inside a rootless Linux machine, on a phone. The debugging chain above is why the quickstart is six commands instead of a wiki.