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
24 changes: 24 additions & 0 deletions debug/issue-115/README.md
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
41 changes: 41 additions & 0 deletions debug/issue-115/probes/probe10.sh
Original file line number Diff line number Diff line change
@@ -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 <<EOF >/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 <<EOF >/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 ==="
35 changes: 35 additions & 0 deletions debug/issue-115/shims/liblinkfix.c
Original file line number Diff line number Diff line change
@@ -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 <tool>.
*/
#define _GNU_SOURCE
#include <errno.h>
#include <string.h>
#include <fcntl.h>
#include <unistd.h>
#include <sys/syscall.h>

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);
}
Original file line number Diff line number Diff line change
Expand Up @@ -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).
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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)) {
Expand Down Expand Up @@ -163,10 +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")
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) {
Expand Down