diff --git a/.github/workflows/android-assets.yml b/.github/workflows/android-assets.yml index 673e8fe4..9d8db012 100644 --- a/.github/workflows/android-assets.yml +++ b/.github/workflows/android-assets.yml @@ -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. @@ -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 diff --git a/surfaces/android/app/src/main/java/com/iqlabs/agentnet/DirectProotExec.kt b/surfaces/android/app/src/main/java/com/iqlabs/agentnet/DirectProotExec.kt index 584f7db2..fdf0526e 100644 --- a/surfaces/android/app/src/main/java/com/iqlabs/agentnet/DirectProotExec.kt +++ b/surfaces/android/app/src/main/java/com/iqlabs/agentnet/DirectProotExec.kt @@ -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, guestCommand: String): Process { // prootCommand wraps the launch in `sh -c 'cd && exec proot …'`, so the // host cwd is already pinned to a readable dir before proot runs (see the comment @@ -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, guestCommand: String): List { + 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", @@ -107,8 +124,14 @@ 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). @@ -116,6 +139,7 @@ class DirectProotExec(private val layout: Paths.Layout) : GuestExec { "-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", @@ -128,6 +152,31 @@ class DirectProotExec(private val layout: Paths.Layout) : GuestExec { return listOf("/system/bin/sh", "-c", inner) } + // #117 basement hardening: build `-b :/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 { + val dir = sysdataDir(layout.rootfs) + val binds = ArrayList() + 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("'", "'\\''") + "'" diff --git a/surfaces/android/app/src/main/java/com/iqlabs/agentnet/Installer.kt b/surfaces/android/app/src/main/java/com/iqlabs/agentnet/Installer.kt index 86683b15..187e526e 100644 --- a/surfaces/android/app/src/main/java/com/iqlabs/agentnet/Installer.kt +++ b/surfaces/android/app/src/main/java/com/iqlabs/agentnet/Installer.kt @@ -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> = 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. @@ -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 @@ -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)) { @@ -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 } @@ -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) { diff --git a/surfaces/android/guest/agentnet-git-clone.py b/surfaces/android/guest/agentnet-git-clone.py deleted file mode 100755 index 2bddad41..00000000 --- a/surfaces/android/guest/agentnet-git-clone.py +++ /dev/null @@ -1,70 +0,0 @@ -#!/usr/bin/env python3 -# issue #112 — single-process `git clone` for the proot guest (see git-clone-shim.sh). -# dulwich runs the entire clone in one Python process via plain file I/O, avoiding git's -# multi-process pack fetch and cross-subprocess file handoff, both of which proot corrupts -# under Android's targetSdk-35 untrusted_app domain. -import sys -from dulwich import porcelain - - -# Flags whose VALUE arrives as the next argv element. They must be consumed even when -# ignored, or the value would be mistaken for the URL (`git clone -b dev ` would -# try to clone "dev"). --depth and -b/--branch are honored; the rest are known -# value-taking clone flags that dulwich has no equivalent for — consumed and dropped. -VALUE_FLAGS = { - "--depth", "-b", "--branch", "-o", "--origin", "-c", "--config", - "--reference", "--reference-if-able", "--separate-git-dir", "--template", - "-u", "--upload-pack", "--shallow-since", "--shallow-exclude", "-j", "--jobs", - "--filter", "--bundle-uri", "--server-option", -} - - -def main(): - args = [a for a in sys.argv[1:] if a != "clone"] - depth = None - branch = None - positional = [] - it = iter(args) - for a in it: - if a in VALUE_FLAGS: - try: - v = next(it) - except StopIteration: - sys.stderr.write("git clone: flag %s requires a value\n" % a) - sys.exit(2) - if a == "--depth": - depth = int(v) - elif a in ("-b", "--branch"): - branch = v - elif a.startswith("--depth="): - depth = int(a.split("=", 1)[1]) - elif a.startswith("--branch="): - branch = a.split("=", 1)[1] - elif a.startswith("-"): - continue # valueless flags: -q/--progress/--single-branch/etc. - else: - positional.append(a) - if not positional: - sys.stderr.write("git clone: missing repository URL\n") - sys.exit(2) - url = positional[0] - if len(positional) > 1: - dst = positional[1] - else: - tail = url.rstrip("/").split("/")[-1] - dst = tail[:-4] if tail.endswith(".git") else tail - sys.stderr.write("Cloning into '%s'...\n" % dst) - try: - porcelain.clone( - url, dst, depth=depth, - # dulwich 0.20.31 builds refs as bytes (refs/remotes// + branch) - branch=branch.encode("utf-8") if branch is not None else None, - errstream=sys.stderr.buffer, - ) - except Exception as e: # noqa: BLE001 — surface any dulwich failure like git would - sys.stderr.write("fatal: clone failed: %s\n" % e) - sys.exit(1) - - -if __name__ == "__main__": - main() diff --git a/surfaces/android/guest/git-clone-shim.sh b/surfaces/android/guest/git-clone-shim.sh deleted file mode 100755 index 94cc34ce..00000000 --- a/surfaces/android/guest/git-clone-shim.sh +++ /dev/null @@ -1,25 +0,0 @@ -#!/bin/sh -# issue #112 — `git clone` shim for the proot guest. -# -# Under Android's targetSdk-35 `untrusted_app` SELinux domain, proot corrupts git's native -# clone path: the multi-process smart-HTTP pack fetch (git → git-remote-https → index-pack, -# concurrent + piped) truncates the packfile ("remote did not send all necessary objects"), -# and freshly-written objects are briefly invisible across sibling git subprocesses. curl, -# node and single git commands are fine; only git's multi-process object machinery breaks. -# (targetSdk 28 = the looser `untrusted_app_28` domain works, but Play requires 35.) -# -# dulwich (pure-Python git) performs the whole clone in ONE process using plain file I/O — -# the path that works here. Route `clone` through it; everything else uses the real git. -# -# Only the FIRST non-option argument decides the subcommand, so `git commit -m clone` or -# `git checkout clone` pass through untouched. Global option forms that take a value -# (`git -C clone`, `git -c k=v clone`) also pass through to real git: the value is -# indistinguishable from a subcommand here, and passthrough is the pre-existing behavior. -for a in "$@"; do - case "$a" in - -*) continue ;; - clone) exec python3 /usr/local/bin/agentnet-git-clone.py "$@" ;; - *) break ;; - esac -done -exec /usr/bin/git "$@" diff --git a/surfaces/android/proot/README.md b/surfaces/android/proot/README.md new file mode 100644 index 00000000..d19417c1 --- /dev/null +++ b/surfaces/android/proot/README.md @@ -0,0 +1,33 @@ +# AgentNet PRoot build + +AgentNet builds PRoot from source instead of shipping the unmodified Termux +package. The source inputs are pinned so the Android binary and its matching +loaders can be reproduced: + +- `termux/termux-packages` commit + `105685dac8697e3b6c2ceb57be24d624afbfa2a3` +- Termux PRoot recipe version `5.1.107.84` +- upstream `termux/proot` tag `v5.1.107.84` +- Termux package-builder image digest + `sha256:fa23eb4238ef8eda877cd991a06152ce76e9f274d1cae0d42f28fee3e5cd6016` +- [`patches/0001-copy-on-link.patch`](patches/0001-copy-on-link.patch) + +The `android-assets` workflow copies the patch into Termux's `packages/proot` +recipe and invokes the official package builder with `-f -I proot`. This keeps +the Termux build's Android/bionic target, `process_vm` accelerator detection, +`libtalloc` and `libandroid-shmem` linkage, and unbundled loader output. + +The patch adds the opt-in `--copy-on-link` extension. PRoot still attempts the +real `link(2)` or `linkat(2)` first. If and only if the Android kernel returns +`EACCES`, the extension copies a regular source file to a newly created +destination (`O_EXCL`) and reports success after all bytes and mode bits are +written. Failed copies are removed and the original `EACCES` is preserved. +Other errors, special files, symlink sources, and `AT_EMPTY_PATH` keep their +normal failure behavior. + +This is intentionally snapshot semantics, not inode-sharing semantics. It is +enabled only for AgentNet's `untrusted_app` guest, where SELinux rejects every +hardlink and package/tool callers need a data-preserving fallback. + +PRoot and this patch are distributed under GPL-2.0-or-later, matching the +upstream source headers and Termux package metadata. diff --git a/surfaces/android/proot/patches/0001-copy-on-link.patch b/surfaces/android/proot/patches/0001-copy-on-link.patch new file mode 100644 index 00000000..b86419ab --- /dev/null +++ b/surfaces/android/proot/patches/0001-copy-on-link.patch @@ -0,0 +1,223 @@ +diff --git a/src/GNUmakefile b/src/GNUmakefile +index 0adb88b..87cd66f 100644 +--- a/src/GNUmakefile ++++ b/src/GNUmakefile +@@ -88,6 +88,7 @@ OBJECTS += \ + extension/sysvipc/sysvipc_sem.o \ + extension/sysvipc/sysvipc_shm.o \ + extension/link2symlink/link2symlink.o \ ++ extension/copy_on_link/copy_on_link.o \ + extension/fix_symlink_size/fix_symlink_size.o + + define define_from_arch.h +diff --git a/src/cli/proot.c b/src/cli/proot.c +index 29365e8..cf473af 100644 +--- a/src/cli/proot.c ++++ b/src/cli/proot.c +@@ -293,6 +293,17 @@ static int handle_option_link2symlink(Tracee *tracee, const Cli *cli UNUSED, con + return 0; + } + ++static int handle_option_copy_on_link(Tracee *tracee, const Cli *cli UNUSED, const char *value UNUSED) ++{ ++ int status; ++ ++ status = initialize_extension(tracee, copy_on_link_callback, NULL); ++ if (status < 0) ++ note(tracee, WARNING, INTERNAL, "copy-on-link not initialized"); ++ ++ return 0; ++} ++ + static int handle_option_ashmem_memfd(Tracee *tracee, const Cli *cli UNUSED, const char *value UNUSED) + { + int status; +diff --git a/src/cli/proot.h b/src/cli/proot.h +index 3f61931..c974375 100644 +--- a/src/cli/proot.h ++++ b/src/cli/proot.h +@@ -61,6 +61,7 @@ static int handle_option_i(Tracee *tracee, const Cli *cli, const char *value); + static int handle_option_R(Tracee *tracee, const Cli *cli, const char *value); + static int handle_option_S(Tracee *tracee, const Cli *cli, const char *value); + static int handle_option_link2symlink(Tracee *tracee, const Cli *cli, const char *value); ++static int handle_option_copy_on_link(Tracee *tracee, const Cli *cli, const char *value); + static int handle_option_ashmem_memfd(Tracee *tracee, const Cli *cli, const char *value); + static int handle_option_sysvipc(Tracee *tracee, const Cli *cli, const char *value); + static int handle_option_kill_on_exit(Tracee *tracee, const Cli *cli, const char *value); +@@ -241,6 +242,15 @@ Copyright (C) 2015 STMicroelectronics, licensed under GPL v2 or later.", + .description = "Replace hard links with symlinks, pretending they are really hardlinks", + .detail = "\tEmulates hard links with symbolic links when SELinux policies\n\ + \tdo not allow hard links.", ++ }, ++ { .class = "Extension options", ++ .arguments = { ++ { .name = "--copy-on-link", .separator = '\0', .value = NULL }, ++ { .name = NULL, .separator = '\0', .value = NULL } }, ++ .handler = handle_option_copy_on_link, ++ .description = "Copy regular files when hard links are denied", ++ .detail = "\tWhen link(2) or linkat(2) fails with EACCES, create an independent\n\ ++\tbyte-for-byte copy at the destination. Other failures are unchanged.", + }, + { .class = "Extension options", + .arguments = { +diff --git a/src/extension/extension.h b/src/extension/extension.h +index 24409b9..66a9d91 100644 +--- a/src/extension/extension.h ++++ b/src/extension/extension.h +@@ -204,6 +204,7 @@ extern int fake_id0_callback(Extension *extension, ExtensionEvent event, intptr_ + extern int hidden_files_callback(Extension *extension, ExtensionEvent event, intptr_t d1, intptr_t d2); + extern int port_switch_callback(Extension *extension, ExtensionEvent event, intptr_t d1, intptr_t d2); + extern int link2symlink_callback(Extension *extension, ExtensionEvent event, intptr_t d1, intptr_t d2); ++extern int copy_on_link_callback(Extension *extension, ExtensionEvent event, intptr_t d1, intptr_t d2); + extern int fix_symlink_size_callback(Extension *extension, ExtensionEvent event, intptr_t d1, intptr_t d2); + extern int ashmem_memfd_callback(Extension *extension, ExtensionEvent event, intptr_t d1, intptr_t d2); + extern int mountinfo_callback(Extension *extension, ExtensionEvent event, intptr_t d1, intptr_t d2); +diff --git a/src/extension/copy_on_link/copy_on_link.c b/src/extension/copy_on_link/copy_on_link.c +new file mode 100644 +index 0000000..482465d +--- /dev/null ++++ b/src/extension/copy_on_link/copy_on_link.c +@@ -0,0 +1,143 @@ ++#include ++#include ++#include ++#include ++#include ++ ++#include "attribute.h" ++#include "extension/extension.h" ++#include "tracee/mem.h" ++#include "tracee/reg.h" ++#include "syscall/sysnum.h" ++ ++static int copy_regular_file(const char *source, const char *target) ++{ ++ char buffer[64 * 1024]; ++ struct stat statbuf; ++ ssize_t count; ++ int source_fd; ++ int target_fd; ++ int status = 0; ++ ++ source_fd = open(source, O_RDONLY | O_CLOEXEC | O_NOFOLLOW); ++ if (source_fd < 0) ++ return -errno; ++ ++ if (fstat(source_fd, &statbuf) < 0) { ++ status = -errno; ++ goto close_source; ++ } ++ if (!S_ISREG(statbuf.st_mode)) { ++ status = -EACCES; ++ goto close_source; ++ } ++ ++ target_fd = open(target, O_WRONLY | O_CREAT | O_EXCL | O_CLOEXEC, ++ statbuf.st_mode & 07777); ++ if (target_fd < 0) { ++ status = -errno; ++ goto close_source; ++ } ++ ++ while ((count = read(source_fd, buffer, sizeof(buffer))) != 0) { ++ ssize_t written = 0; ++ ++ if (count < 0) { ++ if (errno == EINTR) ++ continue; ++ status = -errno; ++ goto fail_target; ++ } ++ while (written < count) { ++ ssize_t result = write(target_fd, buffer + written, count - written); ++ if (result < 0 && errno == EINTR) ++ continue; ++ if (result <= 0) { ++ status = result < 0 ? -errno : -EIO; ++ goto fail_target; ++ } ++ written += result; ++ } ++ } ++ ++ if (fchmod(target_fd, statbuf.st_mode & 07777) < 0) { ++ status = -errno; ++ goto fail_target; ++ } ++ if (close(target_fd) < 0) { ++ status = -errno; ++ unlink(target); ++ goto close_source; ++ } ++ close(source_fd); ++ return 0; ++ ++fail_target: ++ close(target_fd); ++ unlink(target); ++close_source: ++ close(source_fd); ++ return status; ++} ++ ++static int copy_failed_link(Tracee *tracee) ++{ ++ char source[PATH_MAX]; ++ char target[PATH_MAX]; ++ Reg source_arg; ++ Reg target_arg; ++ ssize_t size; ++ Sysnum sysnum = get_sysnum(tracee, ORIGINAL); ++ ++ if ((int) peek_reg(tracee, CURRENT, SYSARG_RESULT) != -EACCES) ++ return 0; ++ ++ if (sysnum == PR_link) { ++ source_arg = SYSARG_1; ++ target_arg = SYSARG_2; ++ } else if (sysnum == PR_linkat) { ++ /* AT_EMPTY_PATH links an fd, not a pathname; preserve EACCES. */ ++ if ((peek_reg(tracee, ORIGINAL, SYSARG_5) & AT_EMPTY_PATH) != 0) ++ return 0; ++ source_arg = SYSARG_2; ++ target_arg = SYSARG_4; ++ } else { ++ return 0; ++ } ++ ++ /* MODIFIED arguments point at PRoot's canonicalized host paths. */ ++ size = read_string(tracee, source, ++ peek_reg(tracee, MODIFIED, source_arg), sizeof(source)); ++ if (size < 0 || size >= (ssize_t) sizeof(source)) ++ return 0; ++ size = read_string(tracee, target, ++ peek_reg(tracee, MODIFIED, target_arg), sizeof(target)); ++ if (size < 0 || size >= (ssize_t) sizeof(target)) ++ return 0; ++ ++ if (copy_regular_file(source, target) == 0) ++ poke_reg(tracee, SYSARG_RESULT, 0); ++ ++ /* A failed fallback leaves the kernel's original EACCES intact. */ ++ return 0; ++} ++ ++int copy_on_link_callback(Extension *extension, ExtensionEvent event, ++ intptr_t data1 UNUSED, intptr_t data2 UNUSED) ++{ ++ switch (event) { ++ case INITIALIZATION: { ++ static FilteredSysnum filtered_sysnums[] = { ++ { PR_link, FILTER_SYSEXIT }, ++ { PR_linkat, FILTER_SYSEXIT }, ++ FILTERED_SYSNUM_END, ++ }; ++ extension->filtered_sysnums = filtered_sysnums; ++ return 0; ++ } ++ case SYSCALL_EXIT_END: ++ return copy_failed_link(TRACEE(extension)); ++ default: ++ return 0; ++ } ++} diff --git a/surfaces/android/scripts/build-assets.sh b/surfaces/android/scripts/build-assets.sh index 858abc35..f3688b08 100755 --- a/surfaces/android/scripts/build-assets.sh +++ b/surfaces/android/scripts/build-assets.sh @@ -38,7 +38,7 @@ echo "==> ABI=$ABI proot=$PROOT_ARCH" echo "==> assets -> $ASSETS" echo "==> work -> $WORK" -# 1) proot binary + loader + its shared libs. We use the TERMUX proot build, NOT +# 1) proot binary + loader + its shared libs. We use our patched TERMUX proot build, NOT # green-green-avk. Why: this proot is compiled with the process_vm accelerator # (process_vm_readv/writev) for guest-memory access. process_vm_readv strips arm64 # top-byte pointer tags, so it avoids the `ptrace(PEEKDATA): I/O error` on tagged @@ -76,6 +76,18 @@ deb_extract() { # $1 = deb file, $2 = dest dir esac rm -rf "$tmp" } +# Accept the source-built package path from android-assets.yml as well as the +# repository URLs used for the two runtime libraries. +fetch_deb() { # $1 = URL or local path, $2 = destination + case "$1" in + http://*|https://*) curl -fsSL "$1" -o "$2" ;; + file://*) cp "${1#file://}" "$2" ;; + *) cp "$1" "$2" ;; + esac +} +# Release CI always sets PROOT_DEB to the package built from the pinned Termux +# source recipe plus surfaces/android/proot/patches/0001-copy-on-link.patch. +# The URL fallback keeps this script independently runnable for development. PROOT_DEB="${PROOT_DEB:-$(termux_latest_deb p/proot)}" TALLOC_DEB="${TALLOC_DEB:-$(termux_latest_deb libt/libtalloc)}" SHMEM_DEB="${SHMEM_DEB:-$(termux_latest_deb liba/libandroid-shmem)}" @@ -85,9 +97,9 @@ echo " talloc: $TALLOC_DEB" echo " shmem: $SHMEM_DEB" PROOT_STAGE="$WORK/proot" rm -rf "$PROOT_STAGE"; mkdir -p "$PROOT_STAGE" -curl -fsSL "$PROOT_DEB" -o "$WORK/proot.deb" && deb_extract "$WORK/proot.deb" "$PROOT_STAGE/proot" -curl -fsSL "$TALLOC_DEB" -o "$WORK/talloc.deb" && deb_extract "$WORK/talloc.deb" "$PROOT_STAGE/talloc" -curl -fsSL "$SHMEM_DEB" -o "$WORK/shmem.deb" && deb_extract "$WORK/shmem.deb" "$PROOT_STAGE/shmem" +fetch_deb "$PROOT_DEB" "$WORK/proot.deb" && deb_extract "$WORK/proot.deb" "$PROOT_STAGE/proot" +fetch_deb "$TALLOC_DEB" "$WORK/talloc.deb" && deb_extract "$WORK/talloc.deb" "$PROOT_STAGE/talloc" +fetch_deb "$SHMEM_DEB" "$WORK/shmem.deb" && deb_extract "$WORK/shmem.deb" "$PROOT_STAGE/shmem" # Termux installs under data/data/com.termux/files/usr — pull the bits we need out of there. TUSR="data/data/com.termux/files/usr" # Ship proot + loader + libs under jniLibs/ as lib*.so (NOT as loose ELF in @@ -137,9 +149,11 @@ if [ "$(id -u)" != "0" ]; then fi export DEBIAN_FRONTEND=noninteractive apt-get update -# python3-dulwich backs the git-clone shim (issue #112): native git clone is corrupted -# under proot on Android's targetSdk-35 untrusted_app domain; dulwich clones in one process. -apt-get install -y curl ca-certificates git ripgrep xz-utils python3 python3-dulwich +# python3 kept for general guest use. python3-dulwich dropped with the #112 git-clone shim: +# the real root cause (proot's --link2symlink faking link() success) is fixed at the proot +# launch layer (#115/#116, DirectProotExec), so native git clone works and dulwich is no longer +# needed. +apt-get install -y curl ca-certificates git ripgrep xz-utils python3 # node (NodeSource LTS) curl -fsSL https://deb.nodesource.com/setup_lts.x | bash - apt-get install -y nodejs @@ -169,15 +183,12 @@ apt-get clean && rm -rf /var/lib/apt/lists/* # ship the agent environment guidance into the guest cp "$ANDROID_DIR/guest/AGENTS.md" /root/AGENTS.md -# issue #112: install the git-clone shim ahead of /usr/bin/git on PATH. It routes `git clone` -# through dulwich (single-process, works under targetSdk-35 proot) and passes everything else -# to the real git. /usr/local/bin is first in the guest PATH (see ServerManager.buildGuestEnv). -cp "$ANDROID_DIR/guest/agentnet-git-clone.py" /usr/local/bin/agentnet-git-clone.py -cp "$ANDROID_DIR/guest/git-clone-shim.sh" /usr/local/bin/git -chmod +x /usr/local/bin/agentnet-git-clone.py /usr/local/bin/git +# issue #112 git-clone shim REMOVED (#115/#116): its root cause — proot's --link2symlink +# faking link() success under untrusted_app — is fixed at the proot launch layer, so native +# `git clone` works. No shim shadowing /usr/bin/git anymore; real git is used for every command. -# Keep rseq disabled for login shells. Not the fix for #112 (that's the clone shim above — -# the app-domain transport corruption survives rseq-off), but rseq-under-ptrace is a real, +# Keep rseq disabled for login shells. Not the fix for #112 (that was the link2symlink false +# success, fixed at the proot launch layer — #115), but rseq-under-ptrace is a real, # separately-measured corruption vector, so this stays as a low-cost guard. The app covers # node + children via guest env; this profile.d covers adb/manual proot entry too. cat > /etc/profile.d/00-agentnet-rseq.sh <<'RSEQ'