You signed in with another tab or window. Reload to refresh your session.You signed out in another tab or window. Reload to refresh your session.You switched accounts on another tab or window. Reload to refresh your session.Dismiss alert
Google Play now rejects our uploads with: "App must target Android 16 (API level 36) or higher." Our current standard is targetSdk 35 (compileSdk 35, minSdk 24, AGP 8.7.3). We have to raise the target again, and we want to do it the same way we did 28 -> 35: probe on hardware first, understand what the new SELinux domain changes, ship with a rollback story, and regression-test the exact bug classes that already bit us.
This issue is written to be self-contained: it retells the whole 28 -> 35 story so someone with zero prior context can execute the 36 bump safely.
Part 1. How we raised 28 -> 35 without losing the Linux engine
The Android app boots a proot-distro Ubuntu rootfs in app storage and runs node plus the official claude/codex CLIs inside it:
flowchart TD
UI[WebView UI] -->|localhost HTTP 200| SM[ServerManager<br/>guest env + lifecycle]
SM --> GE{{GuestExec seam}}
GE --> DPE[DirectProotExec<br/>proot flags / binds / fake proc]
DPE --> PROOT[proot from nativeLibDir<br/>Termux process_vm build]
PROOT -->|maps guest ELF via its own loader mmap| NODE[node]
NODE --> CLAUDE[claude CLI]
NODE --> CODEX[codex CLI]
Loading
targetSdk 28 was originally pinned because Android 10+ applies W^X to the untrusted_app SELinux domain: an app targeting 29+ cannot execve() ELF from its own data dir. Our guest binaries live exactly there (/data/data/com.iqlabs.agentnet/files/rootfs/...), so raising the target looked like it would kill the engine.
We built a ground-truth on-device probe instead of guessing. Verdict: we never direct-execve guest binaries. proot maps them with its own loader (mmap), and that path survives the targetSdk 35 W^X policy. Verified on the worst-case device (Solana Seeker: Android 16 plus arm64 pointer tagging). No linker-exec routing was needed. This is the same reason Play-Store Termux can run proot-distro at target 29+.
Constraints that made 35 work and must survive 36 untouched:
Keep the Termux process_vm=yes proot build and its LD_LIBRARY_PATH libs. Stock proot reads guest memory with PTRACE_PEEKDATA, which the kernel rejects with EIO on arm64 top-byte-tagged addresses (Seeker), and the server silently never boots. Fixed in PR android: run sandbox on pointer-tagged devices (Solana Seeker) + mobile fixes #57; do not swap the proot build.
Never enable MTE (android:memtagMode). Never set PROOT_NO_SECCOMP.
Keep applicationId and signing identical so updates stay in-place and never wipe the user rootfs.
Part 2. What broke AFTER the bump (the silent deaths), and the lesson
Raising the target moves the app into a newer untrusted_app policy. The engine booted fine, and then tools started dying silently on side syscalls:
stale #114 git shim shadowed the fixed native git forever on old installs
shim was removed from fresh builds only
delete the shim on every launch (idempotent)
The one-line lesson: the exec wall was never the real danger. The real danger is a newer SELinux domain quietly changing side syscalls (link, /proc reads), plus layers that fake success and turn honest failures into silent data loss. A booting server and HTTP 200 prove almost nothing.
Two delivery channels exist for pushing fixes to already-installed devices:
Install marker bump: forces a one-time full rootfs re-extract. Heavy; only when files inside the bundled tar changed.
Every-launch idempotent writes in Installer.kt (gitconfig, .sysdata, wrappers, stale-file deletion). Cheap and instant; prefer this.
Part 3. The 36 plan
flowchart LR
A[Bump toolchain<br/>AGP 8.9.1+ and compileSdk 36] --> B[targetSdk 36 build]
B --> C{On-device probe:<br/>server boots?<br/>claude and codex answer?}
C -->|denial found| D[Classify with the taxonomy below<br/>fix via Installer or DirectProotExec]
C -->|clean| E[Silent-death regression suite]
D --> E
E -->|any silent loss| D
E -->|all pass| F[QA matrix like issue 111<br/>Seeker + Galaxy + Pixel]
F --> G[Ship single targetSdk 36<br/>same appId and signing]
compileSdk = 36, targetSdk = 36 in app/build.gradle.kts; update the "targetSdk 35 is our standard" rationale comments in the same commit (house rule: update, never delete)
Step 2: re-ask the exec question on hardware
Fix scripts/probe-modern-exec.sh: it still hardcodes the removed legacy/modern flavor tasks; point it at the single variant
Run on Seeker (Android 16, pointer tagging) and at least one Galaxy: server HTTP 200, claude answers, codex answers
Capture adb logcat -d | grep -iE "avc: denied|AgentNet" and classify any denial with the taxonomy below
pnpm install of a pinned small package: installed file count matches a known-good run
bun add express: node_modules populated, require('express') works
node -e "console.log(os.loadavg(), os.cpus().length)": real values, not zeros
/proc sweep: for f in loadavg stat uptime version sys/kernel/cap_last_cap sys/fs/inotify/max_user_watches; do cat /proc/$f >/dev/null 2>&1 && echo OK $f || echo DENIED $f; done. Any NEW denial: add a row to FAKE_PROC in Installer.kt; that list plus the conditional bind in DirectProotExec is the entire fix
hardlink honesty check: touch a; ln a b must fail visibly (EACCES), never fake success
Step 4: behavior changes specific to targetSdk 36
Large screens ignore android:screenOrientation="portrait" at target 36: check the WebView UI in landscape and resized windows (foldable or tablet emulator profile)
Predictive back is on by default at 36: verify back navigation (no Kotlin onBackPressed override exists today, so expected low risk)
Edge-to-edge opt-out is fully removed at 36: verify insets (we already render under the 35 enforcement, expected no-op)
16 KB page size: verify the jniLibs (libproot.so and friends) have 16 KB aligned LOAD segments (APK Analyzer or llvm-objdump -p). This is a parallel Play upload gate, not a targetSdk behavior, but the same submission blocks on it
Step 5: ship
Same applicationId and signing; confirm an in-place update preserves the rootfs (no data wipe prompt)
Sideload APK and Play AAB both build the single 36 variant; keep the public app-debug.apk asset name so the download link survives
Reproduce in the real SELinux domain.adb run-as runs in runas_app and does NOT reproduce untrusted_app bugs: a 56-clone run-as harness passed 100% while the in-app path failed 100%. Run probes as a true child of the app (paste commands into the in-app agent chat); use run-as only to stage files and read results back.
Read the tool's source first. We burned a full round on an O_TMPFILE theory that one look at git's object-file.c refuted.
One variable per probe. Save each result to a file in the guest, pull it with run-as, then change one thing.
Distrust any layer that reports success on behalf of the kernel (--link2symlink, File.canRead()). Verify with a real operation.
The full #115 probe kit (probe4-probe10 shell scripts, LD_PRELOAD C shims, staging instructions including zig cross-compilation for the aarch64 glibc guest) was removed from the tree after resolution but is fully recoverable from git: git show 2e87263:debug/issue-115/README.md.
Rollback story
targetSdk is a manifest attribute, not data. If 36 breaks a vendor we did not cover, shipping a 35 build again is an ordinary in-place update (same appId and signing); the rootfs and user data are untouched either way. What we can NOT easily roll back is an install marker bump (it re-extracts the rootfs), so prefer every-launch idempotent fixes for anything discovered after release.
Why this issue
Google Play now rejects our uploads with: "App must target Android 16 (API level 36) or higher." Our current standard is
targetSdk 35(compileSdk 35,minSdk 24, AGP 8.7.3). We have to raise the target again, and we want to do it the same way we did 28 -> 35: probe on hardware first, understand what the new SELinux domain changes, ship with a rollback story, and regression-test the exact bug classes that already bit us.This issue is written to be self-contained: it retells the whole 28 -> 35 story so someone with zero prior context can execute the 36 bump safely.
Part 1. How we raised 28 -> 35 without losing the Linux engine
The Android app boots a proot-distro Ubuntu rootfs in app storage and runs node plus the official claude/codex CLIs inside it:
flowchart TD UI[WebView UI] -->|localhost HTTP 200| SM[ServerManager<br/>guest env + lifecycle] SM --> GE{{GuestExec seam}} GE --> DPE[DirectProotExec<br/>proot flags / binds / fake proc] DPE --> PROOT[proot from nativeLibDir<br/>Termux process_vm build] PROOT -->|maps guest ELF via its own loader mmap| NODE[node] NODE --> CLAUDE[claude CLI] NODE --> CODEX[codex CLI]targetSdk 28was originally pinned because Android 10+ applies W^X to theuntrusted_appSELinux domain: an app targeting 29+ cannotexecve()ELF from its own data dir. Our guest binaries live exactly there (/data/data/com.iqlabs.agentnet/files/rootfs/...), so raising the target looked like it would kill the engine.What actually happened (PRs #109, #110):
GuestExecseam, validated on hardware, then collapsed to a single targetSdk 35 config (Adopt targetSdk 35 as standard + Play release AAB pipeline #110).claudedied withspawn ENOENT(Add legacy/modern targetSdk flavors, extract guest-launch seam, fix claude hardlink extraction #109: TarExtractor guest-absolute fix, install marker bump v2 -> v3 for a one-time rootfs re-extract).Constraints that made 35 work and must survive 36 untouched:
process_vm=yesproot build and itsLD_LIBRARY_PATHlibs. Stock proot reads guest memory withPTRACE_PEEKDATA, which the kernel rejects withEIOon arm64 top-byte-tagged addresses (Seeker), and the server silently never boots. Fixed in PR android: run sandbox on pointer-tagged devices (Solana Seeker) + mobile fixes #57; do not swap the proot build.android:memtagMode). Never setPROOT_NO_SECCOMP.applicationIdand signing identical so updates stay in-place and never wipe the user rootfs.Part 2. What broke AFTER the bump (the silent deaths), and the lesson
Raising the target moves the app into a newer
untrusted_apppolicy. The engine booted fine, and then tools started dying silently on side syscalls:git clonealways fails: "remote did not send all necessary objects"--link2symlinkFAKED link() success (returns 0), defeating git's and pnpm's own rename/copy fallbacks--link2symlinkso link fails honestly and fallbacks fire; guest/etc/gitconfigcore.createObject=renameas belt and suspendersos.loadavg()/os.cpus()broken, inotify watchers dead/proc/loadavg,/proc/version, inotify sysctls, etc..sysdata/) bind-mounted only where the real file is unreadable, tested by actually opening it (File.canRead()lies under SELinux)bun installproduces 0 files, reports nothing--backend=copyfileThe one-line lesson: the exec wall was never the real danger. The real danger is a newer SELinux domain quietly changing side syscalls (link, /proc reads), plus layers that fake success and turn honest failures into silent data loss. A booting server and HTTP 200 prove almost nothing.
Two delivery channels exist for pushing fixes to already-installed devices:
Installer.kt(gitconfig,.sysdata, wrappers, stale-file deletion). Cheap and instant; prefer this.Part 3. The 36 plan
flowchart LR A[Bump toolchain<br/>AGP 8.9.1+ and compileSdk 36] --> B[targetSdk 36 build] B --> C{On-device probe:<br/>server boots?<br/>claude and codex answer?} C -->|denial found| D[Classify with the taxonomy below<br/>fix via Installer or DirectProotExec] C -->|clean| E[Silent-death regression suite] D --> E E -->|any silent loss| D E -->|all pass| F[QA matrix like issue 111<br/>Seeker + Galaxy + Pixel] F --> G[Ship single targetSdk 36<br/>same appId and signing]Step 1: toolchain
compileSdk = 36,targetSdk = 36inapp/build.gradle.kts; update the "targetSdk 35 is our standard" rationale comments in the same commit (house rule: update, never delete)Step 2: re-ask the exec question on hardware
scripts/probe-modern-exec.sh: it still hardcodes the removed legacy/modern flavor tasks; point it at the single variantadb logcat -d | grep -iE "avc: denied|AgentNet"and classify any denial with the taxonomy belowStep 3: silent-death regression suite (what 28 -> 35 taught us)
Run these inside the guest via the in-app agent chat (NOT
adb run-as; see the playbook for why):git clone https://github.com/expressjs/express && git -C express fsck: clean, loose object count matchespnpm installof a pinned small package: installed file count matches a known-good runbun add express: node_modules populated,require('express')worksnode -e "console.log(os.loadavg(), os.cpus().length)": real values, not zerosfor f in loadavg stat uptime version sys/kernel/cap_last_cap sys/fs/inotify/max_user_watches; do cat /proc/$f >/dev/null 2>&1 && echo OK $f || echo DENIED $f; done. Any NEW denial: add a row toFAKE_PROCinInstaller.kt; that list plus the conditional bind inDirectProotExecis the entire fixtouch a; ln a bmust fail visibly (EACCES), never fake successStep 4: behavior changes specific to targetSdk 36
android:screenOrientation="portrait"at target 36: check the WebView UI in landscape and resized windows (foldable or tablet emulator profile)onBackPressedoverride exists today, so expected low risk)libproot.soand friends) have 16 KB aligned LOAD segments (APK Analyzer orllvm-objdump -p). This is a parallel Play upload gate, not a targetSdk behavior, but the same submission blocks on itStep 5: ship
applicationIdand signing; confirm an in-place update preserves the rootfs (no data wipe prompt)app-debug.apkasset name so the download link survivesDebugging playbook (when something fails)
Denial taxonomy, read from
adb logcat -d | grep -iE "avc|AgentNet":execute_no_transon our binaryexecmodorexecuteon mmapEFAULT/ PEEKDATALD_LIBRARY_PATHare intactENOENTon a guest binaryAgentNetserver log at allMethod rules, learned the hard way in #115:
adb run-asruns inrunas_appand does NOT reproduceuntrusted_appbugs: a 56-clone run-as harness passed 100% while the in-app path failed 100%. Run probes as a true child of the app (paste commands into the in-app agent chat); use run-as only to stage files and read results back.object-file.crefuted.linkreturned 0, target did not exist).--link2symlink,File.canRead()). Verify with a real operation.The full #115 probe kit (probe4-probe10 shell scripts, LD_PRELOAD C shims, staging instructions including zig cross-compilation for the aarch64 glibc guest) was removed from the tree after resolution but is fully recoverable from git:
git show 2e87263:debug/issue-115/README.md.Rollback story
targetSdkis a manifest attribute, not data. If 36 breaks a vendor we did not cover, shipping a 35 build again is an ordinary in-place update (same appId and signing); the rootfs and user data are untouched either way. What we can NOT easily roll back is an install marker bump (it re-extracts the rootfs), so prefer every-launch idempotent fixes for anything discovered after release.