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.

SELinux denies link() in app-private storage, and distro rootfs tarballs are full of hardlinks (Debian's /usr/bin/perl5.40.1perl). 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.

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.