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 991b908b..68dd6229 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 @@ -24,13 +24,13 @@ import java.io.File class Installer(private val ctx: Context) { companion object { private const val TAG = "AgentNet/Installer" - // Bumped v2 -> v3 to force a one-time rootfs re-extraction on existing installs: - // the hardlink-extraction fix (guest-absolute symlinks) lives in how the rootfs is - // unpacked, so binaries like claude stay broken until the rootfs is laid down again. - // The MARKER only re-extracts on a fresh marker, so bumping it is the only way the - // fix reaches devices that update in place. Re-extract is from the bundled tar (no - // network download). - private const val MARKER = ".installed-v3" + // 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 + // 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 // versionCode so an APK update re-extracts ONLY the server bundle (the heavy // rootfs is left alone). Without this, the idempotent MARKER froze the server @@ -107,8 +107,11 @@ class Installer(private val ctx: Context) { // keep this dependency-free, we unpack with Android's own gzip/xz is not // available, so the rootfs ships as a plain tar (.tar) and we stream-untar it // in Kotlin before first proot use. + val preservedHome = preserveGuestHome(p) + File(p.rootfs).deleteRecursively() Paths.dir(p.rootfs) TarExtractor.extract(ctx.assets.open("rootfs-$abi.tar"), File(p.rootfs)) + restoreGuestHome(p, preservedHome) configureGuest(p) // DNS/hosts/tmp — Android doesn't provide these to the guest onProgress("Almost there") @@ -118,6 +121,37 @@ class Installer(private val ctx: Context) { Log.i(TAG, "install complete") } + private fun preserveGuestHome(p: Paths.Layout): File? { + val home = File(p.home) + val backup = File(ctx.filesDir, ".guest-home-backup") + // If a previous install attempt died between the rootfs delete and restore, the + // backup still holds the user's guest /root while home is gone — adopt it instead + // of returning null, or that data would be orphaned forever. + if (!home.exists()) return backup.takeIf { it.exists() } + backup.deleteRecursively() + if (home.renameTo(backup)) return backup + return runCatching { + home.copyRecursively(backup, overwrite = true) + backup + }.onFailure { + Log.w(TAG, "could not preserve guest /root before rootfs replacement", it) + }.getOrNull() + } + + private fun restoreGuestHome(p: Paths.Layout, preservedHome: File?) { + if (preservedHome == null || !preservedHome.exists()) return + File(p.home).deleteRecursively() + val home = File(p.home) + if (!preservedHome.renameTo(home)) { + runCatching { + preservedHome.copyRecursively(home, overwrite = true) + preservedHome.deleteRecursively() + }.onFailure { + Log.w(TAG, "could not restore preserved guest /root after rootfs replacement", it) + } + } + } + // Android exposes neither /etc/resolv.conf nor /etc/hosts to the proot guest, so // the agent CLIs' DNS lookups (to the model API) fail without these. proot-distro // writes the same files at install time. /tmp must exist + be world-writable since diff --git a/surfaces/android/app/src/main/java/com/iqlabs/agentnet/ServerManager.kt b/surfaces/android/app/src/main/java/com/iqlabs/agentnet/ServerManager.kt index 2fd1c05f..f5b5186d 100644 --- a/surfaces/android/app/src/main/java/com/iqlabs/agentnet/ServerManager.kt +++ b/surfaces/android/app/src/main/java/com/iqlabs/agentnet/ServerManager.kt @@ -44,15 +44,11 @@ class ServerManager(private val ctx: Context) { "TERM=xterm-256color", "LANG=C.UTF-8", "TMPDIR=/tmp", - // Ubuntu 24.04's glibc (2.39) registers rseq for every thread. proot does not - // translate the rseq syscall AT ALL (it is absent from termux/proot's syscall - // table), so kernel-side rseq areas coexist with proot's ptrace rewriting and - // corrupt state under a loaded tracer — measured on the Seeker as git clone - // failures ("remote did not send all necessary objects" / lost .git files) - // whenever git runs beside thread-heavy node under ONE proot (issue #112, - // A/B-verified on device). This tunable is glibc's official switch for rseq - // (no proot-distro precedent — they don't handle it as of 2026-07). Set here - // so node and every child it spawns (claude/codex/git) inherit it. + // Keep rseq disabled in the guest. NOT the fix for #112 — the app-domain + // (untrusted_app) transport corruption survives rseq-off; the fix is the + // git-clone shim in the rootfs. But rseq-under-ptrace is a real, separately + // measured corruption vector (flaky-link clones in the runas_app domain fail + // ~33% with rseq on, 0% with it off), so this stays as a cheap guard. "GLIBC_TUNABLES=glibc.pthread.rseq=0", "AGENTNET_PORT=${Paths.PORT}", // the React SPA ships alongside the server bundle (build-assets.sh packs it at diff --git a/surfaces/android/guest/agentnet-git-clone.py b/surfaces/android/guest/agentnet-git-clone.py new file mode 100755 index 00000000..2bddad41 --- /dev/null +++ b/surfaces/android/guest/agentnet-git-clone.py @@ -0,0 +1,70 @@ +#!/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 new file mode 100755 index 00000000..94cc34ce --- /dev/null +++ b/surfaces/android/guest/git-clone-shim.sh @@ -0,0 +1,25 @@ +#!/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/scripts/build-assets.sh b/surfaces/android/scripts/build-assets.sh index 65dc73d4..858abc35 100755 --- a/surfaces/android/scripts/build-assets.sh +++ b/surfaces/android/scripts/build-assets.sh @@ -137,7 +137,9 @@ if [ "$(id -u)" != "0" ]; then fi export DEBIAN_FRONTEND=noninteractive apt-get update -apt-get install -y curl ca-certificates git ripgrep xz-utils +# 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 # node (NodeSource LTS) curl -fsSL https://deb.nodesource.com/setup_lts.x | bash - apt-get install -y nodejs @@ -167,11 +169,17 @@ 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 -# 24.04's glibc registers rseq per-thread; proot passes the rseq syscall through -# untranslated, which corrupts traced processes under load (issue #112, A/B-verified on -# device). The app covers node + children via guest env (ServerManager.buildGuestEnv); -# this profile.d covers any LOGIN shell entered another way (adb + manual proot, the -# future in-app terminal) — same pattern as proot-distro's inject_termux_profile. +# 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 + +# 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, +# 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' export GLIBC_TUNABLES=glibc.pthread.rseq=0 RSEQ