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
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down Expand Up @@ -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")
Expand All @@ -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
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
70 changes: 70 additions & 0 deletions surfaces/android/guest/agentnet-git-clone.py
Original file line number Diff line number Diff line change
@@ -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 <url>` 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/<origin>/ + 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()
25 changes: 25 additions & 0 deletions surfaces/android/guest/git-clone-shim.sh
Original file line number Diff line number Diff line change
@@ -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 <dir> 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 "$@"
20 changes: 14 additions & 6 deletions surfaces/android/scripts/build-assets.sh
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down Expand Up @@ -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
Expand Down
Loading