TermFold

How TermFold runs Debian and AI coding agents on Android without root

Notes from building it, by Bibarud

Android does not want you to run a Linux system inside an app. Five restrictions got in my way, and each has a specific and slightly odd fix. These are the notes I wish I had had at the start.

TermFold unpacks a real Debian 13 root filesystem and runs it with PRoot, so apt, git, Node, Python and the coding agents work on a tablet. PRoot does the heavy lifting, but PRoot alone does not give you a working system.

1. You can only execute files the OS installed

Since Android 10 an app cannot execve anything under its own data directory, whatever the permission bits say. There is exactly one exception: the native library directory, which the system fills at install time from jniLibs, and only with files named lib*.so.

So PRoot, its loader, libtalloc and libandroid-shmem all ship as lib*.so and run under those names. There is no friendlier filename to copy them to, because nowhere friendlier is executable. Renaming libtalloc.so.2 to libtalloc.so breaks PRoot's DT_NEEDED entry, so the build rewrites that string in place. It only gets shorter, so no ELF offset moves. useLegacyPackaging also has to be on. Otherwise the libraries stay compressed inside the APK and execve fails even though dlopen would have worked.

2. The target SDK, and a thing I got wrong

At API 29 and above an app runs in a stricter SELinux domain, where executing files from app data is denied outright. Termux pins targetSdk = 28 for exactly this reason, and I did the same. I also told people, including in an earlier version of this page, that the target had to stay at 28 or nothing would run.

That was wrong, or at least much too strong. I later built a copy of the app with targetSdk = 34 and ran it next to the real one on an Android 16 tablet. The whole Debian setup completed, and a binary I compiled in the app, a copied binary, a freshly built shared library and an npm package with a native executable all ran. PRoot never asks the kernel to execute files in app storage: its own loader, which Android allows to run, maps the guest program into memory instead.

It is one device and one Android version, so I am not claiming it is solved. The full write-up has the method, what I do not know yet, and the plan.

3. Raw fork() is answered with ENOSYS

Android's seccomp filter makes the plain fork syscall fail for app processes. Apps are expected to use clone. That does not stop PRoot, because glibc programs use clone, so the whole Debian guest runs fine. What breaks is anything that calls fork directly, and BusyBox's tar does. The sign in PRoot's verbose log is a fork that returns a child pid, followed by a SIGSYS that overwrites the successful result with -ENOSYS.

The fix was to stop shelling out. TermFold unpacks the root filesystem with its own tar reader written in Kotlin. That also means the filesystem is in place before any bundled executable has to prove it runs, which makes first-start failures much easier to tell apart.

4. Hard links are forbidden, and dpkg needs them

Inside an app's directory, link(2) returns EPERM. dpkg, npm, git, pip and tar all assume they can make hard links. The usual workaround, PRoot's --link2symlink, fakes a hard link with a hidden data file kept beside the first name. It works until a program replaces that folder, which npm does on every install: the data disappears while other names still point at it. That is how a 240 MB Claude Code binary was lost during development.

TermFold now preloads a small library through /etc/ld.so.preload, the way a distribution would. When the kernel refuses a link(), the library turns it into an independent copy of the file. Every tool above accepts that, and only st_nlink stays at 1. Only glibc reads that preload file, so static and musl programs are untouched.

Debian added one more wrinkle. Packages such as openssh-client run groupadd in their post-install script. Shadow locks /etc/group with a hard link and then insists the link count is exactly two, which no copy can satisfy. The setup swaps in small replacements for groupadd and useradd, using dpkg-divert so that package updates leave them alone.

5. Android hides /proc/stat

Apps cannot read /proc/stat, /proc/uptime, /proc/version and a few others. Most programs shrug. Some need them. LibreOffice refuses to start without /proc/version. The new native Codex CLI works out a process's start time from the boot time in /proc/stat, so it can record its background server, and without that it stops with "failed to read start time for pid-managed app server".

PRoot can bind a file over any path, so TermFold writes plausible stand-ins for the hidden files and binds them in. The one subtle part is the boot time. A process's start time is that boot time plus a per-process offset, and a program that saved it earlier compares it with what it reads later. If the fake boot time drifted by a single second between sessions, the program would decide its own process had gone. So the value is kept steady across sessions.

And a cheaper browser for agents

TermFold also gives agents a real browser to test web work. My first version returned every control on the page on every call, which costs a lot of tokens on a long page. The current one does what other agent browsers do: it lists only what is on screen, has a --changed mode that prints just what differs from the last snapshot, a --find to locate one control anywhere, and it saves the full list to a file the agent can grep. On a long Wikipedia article that took a snapshot from about 29,000 characters to about 1,200. Screenshots became small JPEGs that can be skipped when the page has not changed.

The source is under Apache-2.0 on GitHub, and the full engineering notes go into more detail. You can download the app or go back to the front page.