Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
33 changes: 33 additions & 0 deletions .github/workflows/android-assets.yml
Original file line number Diff line number Diff line change
Expand Up @@ -45,6 +45,38 @@ jobs:
- run: pnpm --filter agentnet-localhost build
- run: pnpm --filter agentnet-webview build # the React SPA the server serves

# Build PRoot from the exact Termux recipe we ship, with AgentNet's GPLv2
# copy-on-link patch applied. The official builder supplies the Android NDK,
# bionic sysroot, process_vm feature detection, libtalloc, libandroid-shmem,
# and the matching unbundled loaders.
- uses: actions/checkout@v4
with:
repository: termux/termux-packages
ref: 105685dac8697e3b6c2ceb57be24d624afbfa2a3
path: .termux-packages
- name: Build patched PRoot from source
shell: bash
# run-docker.sh reads ./scripts/profile-relaxed.apparmor relative to CWD, so it must
# run from the termux-packages root (not the workspace root, or it fails "No such file").
working-directory: .termux-packages
run: |
case "${{ github.event.inputs.abi }}" in
arm64) TERMUX_ARCH=aarch64 ;;
x86_64) TERMUX_ARCH=x86_64 ;;
esac
cp ../surfaces/android/proot/patches/0001-copy-on-link.patch \
packages/proot/0001-copy-on-link.patch
# The repo is bind-mounted at /home/builder/termux-packages inside the builder, but
# `docker exec` lands in /home/builder — so cd into the mount before build-package.sh.
CI=true CONTAINER_NAME=agentnet-proot-builder \
TERMUX_BUILDER_IMAGE_NAME=ghcr.io/termux/package-builder@sha256:fa23eb4238ef8eda877cd991a06152ce76e9f274d1cae0d42f28fee3e5cd6016 \
./scripts/run-docker.sh \
bash -c 'cd /home/builder/termux-packages && ./build-package.sh -a '"$TERMUX_ARCH"' -f -I proot'
PROOT_PACKAGE=$(ls output/proot_*_"$TERMUX_ARCH".deb | tail -1)
test -s "$PROOT_PACKAGE"
# Asset step mounts the workspace at /work, so the .deb is under /work/.termux-packages.
echo "PROOT_DEB=/work/.termux-packages/$PROOT_PACKAGE" >> "$GITHUB_ENV"

# Run the asset build inside an arm64 Ubuntu container (root + target arch). The
# container has the chroot/apt the rootfs step needs; ALLOW_CROSS lets the script
# proceed since uname inside the arm64 container already reports aarch64.
Expand All @@ -53,6 +85,7 @@ jobs:
docker run --rm --platform linux/arm64 \
-v "${{ github.workspace }}:/work" -w /work \
-e ABI=${{ github.event.inputs.abi }} -e ALLOW_CROSS=1 \
-e PROOT_DEB="${PROOT_DEB}" \
ubuntu:24.04 \
bash -c '
set -e
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -19,6 +19,17 @@ class DirectProotExec(private val layout: Paths.Layout) : GuestExec {
private const val TAG = "AgentNet/Server"
}

// android-apk can temporarily reuse an older android-assets artifact while a new heavy
// source build is pending. Never pass an unknown option to that old binary (which would
// prevent the entire guest from booting). The patched binary embeds its CLI option string.
private val copyOnLinkSupported: Boolean by lazy {
runCatching {
File(layout.proot).readBytes()
.toString(Charsets.ISO_8859_1)
.contains("--copy-on-link")
}.getOrDefault(false)
}

override fun launch(guestEnv: List<String>, guestCommand: String): Process {
// prootCommand wraps the launch in `sh -c 'cd <filesDir> && exec proot …'`, so the
// host cwd is already pinned to a readable dir before proot runs (see the comment
Expand Down Expand Up @@ -94,6 +105,12 @@ class DirectProotExec(private val layout: Paths.Layout) : GuestExec {
// before exec, makes proot start from a readable cwd. The guest env + args are passed
// verbatim to that shell as a single argv.
private fun prootCommand(guestEnv: List<String>, guestCommand: String): List<String> {
val copyOnLinkArgs = if (copyOnLinkSupported) {
arrayOf("--copy-on-link")
} else {
Log.w(TAG, "bundled PRoot predates --copy-on-link; rebuild android-assets")
emptyArray()
}
val guestArgv = listOf(
layout.proot,
"--kill-on-exit",
Expand All @@ -107,15 +124,22 @@ class DirectProotExec(private val layout: Paths.Layout) : GuestExec {
// honestly with EACCES and both tools fall back correctly (verified on-device: git
// 50/50 objects survive, pnpm 589/589 files, real clone clean). One flag removed fixes
// every link()-using tool with a fallback — no proot rebuild, no per-tool config.
// (The core.createObject=rename gitconfig from #116 is now belt-and-suspenders: it just
// skips the doomed link attempt for git specifically.)
//
// #117 closes the remaining no-fallback gap (notably dpkg, which has NO copy fallback):
// our published GPLv2 PRoot patch adds --copy-on-link. A native hardlink is attempted
// first; only EACCES retries as an O_EXCL regular-file byte copy. Honest data
// preservation, unlike l2s's dangling-symlink false success. Other link errors and
// unsupported file types are unchanged. The core.createObject=rename gitconfig from #116
// stays as belt-and-suspenders.
*copyOnLinkArgs,
// NOTE: kept to flags the Termux proot build supports. --sysvipc is dropped
// (node doesn't need SysV IPC). -L and --kernel-release are likewise omitted as
// non-essential (add back only if a specific build is confirmed to accept them).
"-r", layout.rootfs,
"-0", // present as uid 0 inside the guest (fake root)
"-b", "/dev",
"-b", "/proc",
*fakeProcBinds().toTypedArray(), // #117: shadow the /proc files Android denies us
"-b", "/sys",
"-b", "${layout.rootfs}/tmp:/dev/shm", // Android has no /dev/shm; bind a guest tmp dir
"-w", "/root",
Expand All @@ -128,6 +152,31 @@ class DirectProotExec(private val layout: Paths.Layout) : GuestExec {
return listOf("/system/bin/sh", "-c", inner)
}

// #117 basement hardening: build `-b <fake>:/proc/X` binds for exactly the /proc files
// Android denies the guest under untrusted_app. We test readability from THIS (the app)
// process — the guest inherits our SELinux domain, so what we can't read, it can't either.
// Bind only the denied ones (never shadow a /proc that actually works) and only if the
// fake file exists (Installer.writeFakeSysdata lays them down; missing => skip, no bad bind).
// Same conditional-bind pattern as proot-distro's fake_proc_bindings(). Fixes the whole
// "tool reads a blocked /proc file" class (node os.loadavg/os.cpus, inotify watchers,
// capsh, id-mapping) in one place — not one tool at a time.
private fun fakeProcBinds(): List<String> {
val dir = sysdataDir(layout.rootfs)
val binds = ArrayList<String>()
for ((realPath, name, _) in FAKE_PROC) {
val fake = File(dir, name)
if (fake.exists() && !realReadable(realPath)) {
binds.add("-b"); binds.add("${fake.absolutePath}:$realPath")
}
}
return binds
}

// True iff this process can actually read `path`. File.canRead()/access() can disagree
// with SELinux, so we open + read one byte and trust the exception (Permission denied).
private fun realReadable(path: String): Boolean =
runCatching { java.io.FileInputStream(path).use { it.read() }; true }.getOrDefault(false)

// POSIX single-quote escaping so paths/args with spaces or metacharacters survive the
// host shell unmodified ( ' -> '\'' ).
private fun shQuote(s: String): String = "'" + s.replace("'", "'\\''") + "'"
Expand Down
113 changes: 108 additions & 5 deletions surfaces/android/app/src/main/java/com/iqlabs/agentnet/Installer.kt
Original file line number Diff line number Diff line change
Expand Up @@ -5,6 +5,66 @@ import android.system.Os
import android.util.Log
import java.io.File

// #117 basement hardening: fake /proc content. Under untrusted_app, Android DENIES the app
// (and therefore the proot guest that inherits its SELinux domain) read access to a set of
// /proc files — proven on-device (SM-A356E): `cat /proc/loadavg` -> "Permission denied",
// same for /proc/version and /proc/sys/fs/inotify/max_user_watches. This breaks tools that
// read them: node os.loadavg()/os.cpus() (loadavg, stat), vite/chokidar & other inotify
// watchers (max_user_watches), capsh/apt (cap_last_cap), id-mapping (overflowuid/gid).
// proot-distro solves this by bind-mounting fake files ONLY where the real one is unreadable.
// We mirror that: Installer lays these down, DirectProotExec binds the unreadable ones.
// One list = the single source of truth for both. (realProcPath, fakeFileName, content)
// Note: /proc/vmstat is in proot-distro's set but has no consumer in our node/agent
// stack — skipped; add a row here if a tool ever needs it.
internal val FAKE_PROC: List<Triple<String, String, String>> = listOf(
Triple("/proc/loadavg", "loadavg", "0.12 0.07 0.02 2/165 765\n"),
Triple(
"/proc/stat", "stat",
// cpu + per-cpu lines: node os.cpus() parses cpu0..cpuN for CPU times. 8 cores.
"cpu 1957 0 2877 93280 262 342 254 87 0 0\n" +
"cpu0 31 0 226 12027 82 10 4 9 0 0\n" +
"cpu1 45 0 664 11144 21 263 233 12 0 0\n" +
"cpu2 494 0 537 11283 27 10 3 8 0 0\n" +
"cpu3 359 0 234 11723 24 26 5 7 0 0\n" +
"cpu4 295 0 268 11772 10 12 2 12 0 0\n" +
"cpu5 270 0 251 11833 15 3 1 10 0 0\n" +
"cpu6 430 0 520 11386 30 8 1 12 0 0\n" +
"cpu7 30 0 172 12108 50 8 1 13 0 0\n" +
"ctxt 140223\nbtime 1680020856\nprocesses 772\n" +
"procs_running 2\nprocs_blocked 0\n",
),
Triple("/proc/uptime", "uptime", "124.08 932.80\n"),
Triple("/proc/version", "version", "Linux version 5.15.0-android13 (proot@agentnet) #1 SMP PREEMPT\n"),
Triple("/proc/sys/kernel/cap_last_cap", "sysctl_cap_last_cap", "40\n"),
Triple("/proc/sys/fs/inotify/max_user_watches", "sysctl_inotify_max_user_watches", "4096\n"),
Triple("/proc/sys/kernel/overflowuid", "sysctl_overflowuid", "65534\n"),
Triple("/proc/sys/kernel/overflowgid", "sysctl_overflowgid", "65534\n"),
)

// Directory (in the rootfs) holding the fake /proc files DirectProotExec binds from.
internal fun sysdataDir(rootfs: String): File = File(rootfs, ".sysdata")

// #117: /usr/local/bin/bun wrapper — makes `bun add/install` work under proot/untrusted_app.
// See Installer.writeBunWrapper for the why. Kept minimal; forwards everything else verbatim.
private val BUN_WRAPPER = """
#!/bin/sh
# AgentNet #117: bun under proot/untrusted_app needs two nudges (both proven on-device):
# (1) it won't create node_modules itself ("ENOENT: could not open node_modules") -> pre-create.
# This is unrelated to hardlinks, so --copy-on-link does NOT cover it — always needed.
# (2) its default hardlink backend hits the kernel hardlink denial -> force --backend=copyfile.
# copyfile is belt-and-suspenders alongside PRoot's --copy-on-link (like git's core.createObject=
# rename in #116): it works whether or not the shipped PRoot is the patched build, so bun never
# regresses while a source-built binary is pending. Keeping it costs nothing (same result, bun
# just copies in userspace instead of PRoot copying on the EACCES).
case "${'$'}1" in
add|install|i|update|remove|rm|link|unlink|ci)
mkdir -p node_modules 2>/dev/null
case " ${'$'}* " in *" --backend"*) : ;; *) set -- "${'$'}@" --backend=copyfile ;; esac
;;
esac
exec /usr/bin/bun "${'$'}@"
""".trimStart()

// First-run setup: lay down the Ubuntu rootfs and our server bundle into app storage.
// Idempotent — a marker file means "already installed", so this is a no-op on every
// launch after the first.
Expand All @@ -24,11 +84,11 @@ import java.io.File
class Installer(private val ctx: Context) {
companion object {
private const val TAG = "AgentNet/Installer"
// Bumped v3 -> v5 (v4 skipped) to force a one-time rootfs re-extraction on existing
// installs: issue #112's fix ships IN the rootfs (python3-dulwich + the git-clone shim
// at /usr/local/bin/git — native git clone is corrupted by proot under targetSdk-35's
// untrusted_app domain), so a server-bundle-only update is not enough. Marker bumps
// are how heavy rootfs fixes reach devices: the MARKER only re-extracts on a fresh
// Bumped v3 -> v5 (v4 skipped) to force a one-time rootfs re-extraction when issue
// #112's dulwich clone shim shipped IN the rootfs. That shim has since been removed
// (#115 fixed the root cause at the proot launch layer; removeLegacyCloneShim cleans
// it off existing installs every launch — no marker bump needed). Marker bumps remain
// how heavy rootfs changes reach devices: the MARKER only re-extracts on a fresh
// marker, and the re-extract is from the bundled tar (no network download).
private const val MARKER = ".installed-v5"
// Server bundle is small and changes every app build; its marker holds the app's
Expand Down Expand Up @@ -87,6 +147,9 @@ class Installer(private val ctx: Context) {
// /etc/gitconfig and git would stay broken. This write is idempotent + tiny, so do it
// on every launch — far cheaper than a MARKER bump + full rootfs re-extract.
writeGuestGitConfig(p)
writeFakeSysdata(p) // #117: idempotent + tiny; every-launch so existing installs get it
writeBunWrapper(p)
removeLegacyCloneShim(p)
// Heavy artifacts (proot + rootfs) are in place. But the server bundle changes
// every build — refresh it if this APK shipped a different one.
if (serverUpToDate(serverCrc)) {
Expand Down Expand Up @@ -169,6 +232,9 @@ class Installer(private val ctx: Context) {
writeFresh(File(p.rootfs, "etc/resolv.conf"), "nameserver 8.8.8.8\nnameserver 8.8.4.4\n")
writeFresh(File(p.rootfs, "etc/hosts"), "127.0.0.1 localhost\n::1 localhost\n")
writeGuestGitConfig(p)
writeFakeSysdata(p)
writeBunWrapper(p)
removeLegacyCloneShim(p)
val tmp = File(p.rootfs, "tmp").apply { mkdirs() }
runCatching { android.system.Os.chmod(tmp.absolutePath, 0b001_111_111_111) } // 1777
}
Expand All @@ -187,6 +253,43 @@ class Installer(private val ctx: Context) {
.onFailure { Log.w(TAG, "could not write guest /etc/gitconfig (#115 fix)", it) }
}

// #117: /usr/local/bin/bun wrapper (see BUN_WRAPPER for the two nudges + why). Pre-creates
// node_modules (bun exits "ENOENT: could not open the node_modules directory" otherwise —
// unrelated to hardlinks, so --copy-on-link doesn't cover it) and forces --backend=copyfile as
// belt-and-suspenders alongside PRoot's --copy-on-link, so bun works even if an older (pre-
// patch) android-assets artifact is reused. Every launch => reaches existing installs; skipped
// if the guest has no /usr/bin/bun.
private fun writeBunWrapper(p: Paths.Layout) {
runCatching {
if (!File(p.rootfs, "usr/bin/bun").exists()) return
val wrapper = File(p.rootfs, "usr/local/bin/bun")
writeFresh(wrapper, BUN_WRAPPER)
Os.chmod(wrapper.absolutePath, 0b000_111_101_101) // 0755
}.onFailure { Log.w(TAG, "could not write guest bun wrapper (#117)", it) }
}

// The #112 dulwich clone shim is removed from fresh rootfs builds, but existing installs
// (and fresh extracts of a pre-#115 bundled tar) still have it baked in at
// /usr/local/bin/git, shadowing the real git that now works. Delete it every launch —
// same reach-existing-installs pattern as the writes above; a MARKER bump (full rootfs
// re-extract) would be overkill for two files. python3-dulwich stays (harmless).
private fun removeLegacyCloneShim(p: Paths.Layout) {
runCatching {
File(p.rootfs, "usr/local/bin/git").delete()
File(p.rootfs, "usr/local/bin/agentnet-git-clone.py").delete()
}.onFailure { Log.w(TAG, "could not remove legacy #112 clone shim", it) }
}

// #117: write the fake /proc files (see FAKE_PROC). DirectProotExec binds them over the
// real, denied /proc entries at launch. Idempotent — overwrite each launch so content
// fixes reach existing installs without a rootfs re-extract.
private fun writeFakeSysdata(p: Paths.Layout) {
runCatching {
val dir = sysdataDir(p.rootfs).apply { mkdirs() }
for ((_, name, content) in FAKE_PROC) File(dir, name).writeText(content)
}.onFailure { Log.w(TAG, "could not write fake /proc sysdata (#117 hardening)", it) }
}

// Write `text` to `file`, first removing any existing symlink/file at that path so
// we never follow a dangling symlink (e.g. Ubuntu's /etc/resolv.conf link).
private fun writeFresh(file: File, text: String) {
Expand Down
70 changes: 0 additions & 70 deletions surfaces/android/guest/agentnet-git-clone.py

This file was deleted.

Loading