Fix #112: git clone in the proot guest under targetSdk-35 - #114
Conversation
Native `git clone` fails in the proot guest on Android's targetSdk-35
untrusted_app SELinux domain: the multi-process smart-HTTP pack fetch
(git -> git-remote-https -> index-pack) truncates the packfile ("remote
did not send all necessary objects"), and freshly written objects are
briefly invisible across sibling git subprocesses. curl, node, ls-remote
and single git commands all work in-app; only git's multi-process object
machinery breaks. targetSdk 28 (the looser untrusted_app_28 domain) works,
but Play requires 35 for new apps, so a targetSdk downgrade is not viable.
Ship a git-clone shim ahead of /usr/bin/git on the guest PATH: it routes
`clone` through dulwich (pure-Python git, single process, plain file I/O)
and passes everything else to the real git untouched. Verified on-device
(Galaxy A35, targetSdk 35): clone completes, HEAD/pack/worktree correct,
git fsck clean.
- build-assets.sh: install python3-dulwich; drop the shim at /usr/local/bin/git
- guest/git-clone-shim.sh, guest/agentnet-git-clone.py: the shim
- Installer: bump marker v4 -> v5 to re-extract the rootfs with the fix
Supersedes the earlier rootfs-rollback theory: verified on-device that the
Ubuntu version (22.04 vs 24.04) is irrelevant — clone fails identically on
both. The trigger is the targetSdk-35 domain, not glibc/rseq.
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
zo-sol
left a comment
There was a problem hiding this comment.
I ran a controlled failure-isolation matrix on the Seeker (the device #112 was filed from) that bears directly on this PR's root-cause claim, plus a code review of the change set.
Measurement: the corruption is network-timing-dependent
Setup: the app's exact proot invocation (same binary, flags, env, run under the app UID via run-as), cloning real GitHub over a USB-reversed CONNECT proxy on the host so link quality is a controlled variable. The proxy's byte integrity is checksum-verified (identical sha256 through-proxy vs direct), and the same adb-reverse path passes 8/8 on a clean link — so neither the proxy nor the USB transport corrupts data.
| Config | rseq disabled (GLIBC_TUNABLES) |
--link2symlink |
Link | Result |
|---|---|---|---|---|
| Clean-link sweep (file://, local smart-HTTP, throttled local, real GitHub, concurrent app proot) | both states | both states | clean/fast | 48/48 pass |
| D (pre-fix state) | no (rseq active) | on | flaky (16KB chunks, 10% chance of 200-900ms stalls) | 2 fails / 6 clones — curl 56 GnuTLS recv error (-9): Error decoding the received TLS packet |
| A (current main) | yes | on | same flaky | 8/8 pass (P ≈ 4% of that happening by chance at D's failure rate) |
| C (l2s isolation) | no (rseq active) | off | same flaky | fail on clone 2, same GnuTLS error (run then truncated by a USB drop; the fail is the datum) |
Reading:
- The failure requires a slow/stalling network. Nothing fails in any config on a clean link. That explains the maddening reproducibility pattern ("fails 20 minutes straight" one morning, unreproducible later) — the variable was Wi-Fi quality, and it was uncontrolled in both of our earlier tests.
- The corrupted layer is below git. GnuTLS receives a byte stream that fails TLS record decoding. TCP and the (checksum-verified) proxy deliver intact bytes to the kernel, so the stream is being corrupted between the kernel socket buffer and the traced process's
read()— i.e., in proot's syscall translation under load, exactly where a kernel-rseq-vs-ptrace conflict would bite. A slow link fragments the transfer into many more translated reads, multiplying exposure. This also subsumes the "truncated right after the PACK header" observation: same corrupted-read family, seen one layer higher. --link2symlinkis not the mechanism (C still fails without it), and disabling rseq is protective on this device (A: 8/8).
This does not invalidate your Galaxy A35 targetSdk 28-vs-35 A/B — two devices can genuinely differ here. But it does mean the Seeker evidence no longer supports "the rseq theory is disproven", and the network confound was live in the A/B unless link quality was pinned. Two asks before this PR's root-cause narrative lands:
- Re-run the A35 comparison with a controlled flaky link (harness below — it needs only adb and a debug build).
- Confirm at runtime whether the failing targetSdk-35 build actually had the tunable in the guest env:
cat /proc/<node pid>/environ | tr '\0' '\n' | grep GLIBC. If A35 still fails with rseq disabled under a controlled link, that is a real second mechanism and the single-process fallback earns its place.
On the change set itself (independent of root cause)
- Internal contradiction. The PR body concludes the Ubuntu version is irrelevant ("22.04 fails identically"), yet the change pins CI to 22.04 with hard-fail guards calling 24.04 "known-bad", and the new ServerManager comment says #112 "is fixed by shipping the 22.04 rootfs again". If the shim is the fix, the rollback and guards are vestigial theory and add a CI trap (see 5). One story, please.
- Shim arg matching. The sh shim matches the word
cloneanywhere in argv, sogit commit -m cloneorgit checkout clonemisroute to dulwich. Match only the first non-option argument. - Flag parsing.
-b <name>makes<name>positional (it becomes the URL — confusing hard failure), and--branch=xis silently dropped (clones the default branch — silently wrong, worse than failing). Jammy's dulwich 0.20.31 supportsporcelain.clone(..., branch=..., depth=...)(checked against the sdist), so this is a small fix. Same bug class for any value-taking flag (-o,-c,--origin). - Installer details. The comment says "v4 -> v5" but main is at v3; the backup dir is named
.guest-home-before-v4while the marker is v5. Also, if the process dies between the rootfs delete andrestoreGuestHome, the next install finds no home,preserveGuestHomereturns null, and the backup is orphaned — restore should also look for a leftover backup dir. - Merge-ordering trap. Once merged,
android-apk/android-releasehard-fail untilandroid-assetsis manually re-run (current artifacts carry no manifest). Either sequence the merge with an assets re-run, or make the guard warn instead of fail when the manifest is absent. - Repo convention: commits here don't carry AI co-author attribution — a squash-merge can strip it.
Happy to help land a fixed shim as a belt-and-suspenders layer if the controlled A35 re-test still shows a targetSdk-dependent failure with rseq disabled — but the rootfs rollback and the 22.04 CI pin should come out either way.
Harness (host wrapper + guest A/B + flaky proxy)
Host-side (Mac/Linux), flaky CONNECT proxy + reverse tunnel:
adb reverse tcp:8899 tcp:8899
node flaky-proxy.js # below// flaky-proxy.js — forwards CONNECT tunnels; server->phone dribbled in 16KB
// chunks, 10% chance of a 200-900ms stall per chunk. Byte-exact (checksum it).
const net=require('net'),http=require('http');
function flakyPipe(src,dst){
src.on('data',d=>{src.pause();let off=0;
(function step(){
if(off>=d.length){src.resume();return;}
const c=d.slice(off,off+16384);off+=16384;
const wait=Math.random()<0.10?200+Math.random()*700:55;
dst.write(c,()=>setTimeout(step,wait));})();});
src.on('end',()=>dst.end());src.on('error',()=>dst.destroy());
}
http.createServer((q,s)=>{s.writeHead(502);s.end();})
.on('connect',(req,sock,head)=>{
const i=req.url.lastIndexOf(':');
const up=net.connect(+req.url.slice(i+1)||443,req.url.slice(0,i),()=>{
sock.write('HTTP/1.1 200 Connection Established\r\n\r\n');
if(head&&head.length)up.write(head);
flakyPipe(up,sock);sock.pipe(up);});
up.on('error',()=>sock.destroy());sock.on('error',()=>up.destroy());
}).listen(8899,'127.0.0.1');Device host wrapper (run-as <appId> sh abtest-host.sh l2s|nol2s [norseqfix]) — replicate your app's DirectProotExec invocation exactly (loader env, soname shim, L2S dir, same flags), toggling --link2symlink via arg 1 and GLIBC_TUNABLES=glibc.pthread.rseq=0 vs an inert tunable via arg 2, then inside the guest run N clones of a real repo through https_proxy=http://127.0.0.1:8899 while node spins 4 worker threads beside git under the same tracer. Judge each clone by exit code + git fsck + a .pack existing. (Full scripts in the #112 thread if useful.)
|
Correction — the root-cause section of my review is superseded. You were right about the mechanism. I now have the decisive on-device datapoint from the Seeker, and it flips my verdict:
So the reconciled picture: the app-path breakage is domain-gated ( What still stands from my review (all code-level, none of it root-cause):
With 1–3 addressed I'm happy to approve — the single-process clone path is the right call for the |
- git-clone-shim.sh: route to dulwich only when the FIRST non-option arg is 'clone', so 'git commit -m clone' / 'git checkout clone' pass through. - agentnet-git-clone.py: consume value-taking flags so their values are not mistaken for the URL; honor -b/--branch (dulwich 0.20.31 clone(branch=) expects bytes) alongside --depth. - Revert the Ubuntu 22.04 rollback, the rootfs manifest, and the CI hard-fail guards: this PR's own on-device finding is that the Ubuntu version is irrelevant (22.04 fails identically), and the guards would break every APK build until android-assets re-runs. The shim is the fix; the rollback was the disproven first theory. - Installer: marker comment corrected (main was at v3, not v4); guest-home backup renamed and a crashed-install orphaned backup is now adopted on the next attempt instead of being stranded. - ServerManager/build-assets comments: state accurately that rseq-off is a separately-measured guard, not the #112 fix (the untrusted_app transport corruption survives it).
|
Pushed the review fixes directly to this branch (
Verified locally: sh/py syntax, 6 shim routing cases, 3 flag-parsing cases, Kotlin compiles. Landing sequence (ordering matters because the fix lives in the rootfs tar): android-assets is building from this branch now → merge on green → the push-triggered android-apk picks up the fresh assets artifact → marker v5 re-extracts on devices → on-device in-app clone verification on the Seeker. @mega123-art please shout if any of this misrepresents your change — happy to follow up. The |
… loss
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 <noreply@anthropic.com>
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 <noreply@anthropic.com>
Root cause: in the untrusted_app SELinux domain the kernel denies hardlinks; proot's --link2symlink extension faked link() success (result 0) via a symlink-refcount scheme, defeating git's rename fallback and pnpm's copy fallback. Result was silent git object loss ('remote did not send all necessary objects') and broken pnpm installs.
- DirectProotExec: remove --link2symlink so linkat fails honestly and tools use their own fallbacks (verified on-device: git 50/50 objects, pnpm 589/589 files, clean clone + fsck)
- Installer: write guest /etc/gitconfig core.createObject=rename on every launch as belt-and-suspenders, reaching existing installs without a rootfs re-extract
- debug/issue-115: add probe10 fix test and liblinkfix LD_PRELOAD shim
Supersedes the clone-only dulwich shim (#114).
Fixes #112.
Summary
git clonefails for every repo inside the app's proot guest on Android —fatal: remote did not send all necessary objects. This PR fixes it by routinggit clonethrough dulwich (pure-Python git) in the guest, which clones in a single process and sidesteps the proot failure. Verified working on-device (Samsung Galaxy A35, Android 15, targetSdk 35).Root cause (verified on-device, not theory)
The trigger is the targetSdk 28 → 35 bump we made preparing for Play Store — not the Ubuntu 24.04 / glibc / rseq theory from the issue thread.
targetSdk selects the app's SELinux domain: 28 → the grandfathered-looser
untrusted_app_28, 35 → the strictuntrusted_app. Under the strict domain, proot corrupts git's multi-process clone path specifically:git→git-remote-https→index-pack, concurrent + piped) truncates the packfile right after thePACKheader → empty object store → "did not send all necessary objects";index-packoutput that a followinggitcan't read in the same proot session).Everything else is fine in-app:
curl(GET and the rawgit-upload-packPOST — full pack),node/the server,git ls-remote, and singlegitcommands all work. Only git's multi-process object machinery breaks.What was ruled out (empirically, on the device)
protocol.version,pack.threadsPROOT_NO_SECCOMP(full-ptrace mode)The clean A/B: same code, same rootfs — targetSdk 28 clones successfully, targetSdk 35 fails. Since Play requires targetSdk 35 for new apps, a downgrade isn't an option, and there's no proot flag that fixes multi-process tracing under
untrusted_app(this is the same wall Termux hit — why it stays targetSdk 28 and off Play).The fix
A
gitshim installed ahead of/usr/bin/giton the guest PATH:git clone …→ dulwichporcelain.clone— one process, plain file I/O (the path that works under proot here). Nogit-remote-https, no concurrentindex-pack, no cross-subprocess handoff.gitsubcommand → passes through to the real git unchanged.Changes
build-assets.sh: installpython3-dulwich; install the shim as/usr/local/bin/git.guest/git-clone-shim.sh,guest/agentnet-git-clone.py: the shim + dulwich clone.Installer: bump rootfs markerv4 → v5so existing installs re-extract the rootfs carrying the fix..gitignore: ignore the generatedagentnet-rootfs.envmanifest.Verification
On-device (Galaxy A35, targetSdk-35 build),
git clone https://github.com/octocat/Hello-World.git:HEADresolves to the correct commit, packfile present, working tree checked out,git fsckclean.Notes / follow-ups
android-assetsre-runs — the shim + dulwich live in the ~1 GB rootfs tar, which is rebuilt by that workflow;android-apkthen bundles it and marker v5 re-extracts on update.clone.git fetch/pull/pushstill use native git and likely hit the same targetSdk-35 proot bug — out of scope for git clone always fails in proot (Android) guest — "remote did not send all necessary objects" (suspected Ubuntu 24.04 rseq regression) #112 but the same root cause; dulwich covers them too if we want a follow-up.…rootfs-rollbackfrom the original (disproven) theory; the fix here is unrelated to the rollback.