Two bugs in the Alpine runtime (Alpine 3.23 / apk-tools 3)
Tested against AlpineRuntime.kt at commit 54583f2. Both issues are one-line fixes.
Bug 1 — every apk add silently fails to update the package database
Symptom
Every package install ends with:
Executing busybox-1.37.0-r30.trigger
ERROR: System state may be inconsistent: failed to write database: Permission denied
The files are unpacked and the tools run, so the UI reports success — but
lib/apk/db/installed is never updated.
Root cause
Confirmed by reading apk-tools 3.0.8 (the version in Alpine 3.23), not by guesswork:
src/io.c:1160-1168 — __apk_ostream_to_file() opens the destination with
openat(atfd, path, O_RDWR | O_TMPFILE | O_CLOEXEC, mode) whenever
/proc/self/fd is present.
src/io.c:1088 — on close it materialises that anonymous inode with
linkat(AT_FDCWD, "/proc/self/fd/N", fos->atfd, tmpname, AT_SYMLINK_FOLLOW).
- On Android app-private storage that
linkat() is denied (EACCES), so
apk_ostream_cancel() propagates the error.
src/database.c:2335 — apk_db_write_layers() fails, and
src/database.c:2342 prints the message above.
This is new in apk-tools 3. apk-tools 2.14.x has no O_TMPFILE code path at all,
which is why the Alpine 3.21 runtime (scripts/prepare-ios-runtime.sh) never hit it.
Aether ships Alpine 3.23.4 (AlpineRuntime.kt:840), so it is affected.
Impact — not cosmetic
apk_db_write_layers() (src/database.c:2247) is what writes installed,
triggers and scripts.tar:
ld->installed = apk_ostream_to_file(ld->fd, "installed", 0644);
ld->triggers = apk_ostream_to_file(ld->fd, "triggers", 0644);
When it fails, apk loses its record of what is installed: apk info is incomplete,
subsequent installs redo work, and package triggers are never registered.
apk's own wording — "System state may be inconsistent" — is accurate.
The failure is currently masked in installPackageProfileLocked()
(AlpineRuntime.kt:449):
return if (result.optBoolean("ok") || verifyPackageProfile(profileId)) {
Because the binaries do run, verifyPackageProfile passes and the profile is
reported Ready, so the broken database never surfaces as a failure.
Fix
Pass proot's --link2symlink. Termux's proot carries a handler written for exactly
this call shape — handle_linkat_from_proc_fd() in
src/extension/link2symlink/link2symlink.c:1014:
/** Handler for linkat(..., "/proc/X/fd/Y", ..., AT_SYMLINK_FOLLOW) */
It detects that the source is a " (deleted)" O_TMPFILE inode and, instead of
creating a symlink, copies the contents into a real file via
open(target_path, O_WRONLY|O_CREAT|O_EXCL, ...). The result is an ordinary
regular file, so there is no risk of leaving a dangling symlink where the apk
database should be.
The bundled binary already supports the flag — verified with
strings app/src/main/assets/runtimes/alpine/arm64-v8a/proot.bin | grep -x -- --link2symlink.
Apply in both places that build a proot command line,
buildAlpineProcess() (AlpineRuntime.kt:718) and
buildAlpineInteractiveCommand() (AlpineRuntime.kt:753):
val prootCommand = listOf(
AlpineHostLinker,
prootFile.absolutePath,
+ // apk (apk-tools 3) commits its database with O_TMPFILE + linkat(), which
+ // Android denies in app-private storage. This turns that into a real file.
+ "--link2symlink",
"-0",
"-r",
rootfsDir.absolutePath,
Bug 2 — the agent cannot pip install anything (PEP 668)
Symptom
error: externally-managed-environment
× This environment is externally managed
Any pip install <pkg> inside the Alpine runtime fails, including as root.
Root cause
Alpine ships a PEP 668 marker at /usr/lib/python3.12/EXTERNALLY-MANAGED:
[externally-managed]
Error=
The system-wide python installation should be maintained using the system
package manager (apk) only.
The python profile (AlpineRuntime.kt:1294) installs
python3 py3-pip py3-virtualenv, so pip is present but refuses to install
anything into the system interpreter. This is a deliberate distro guard, not a
permission problem — ls -l on the site-packages directory shows root-writable
directories, which makes it easy to misdiagnose.
Fix
The Alpine rootfs here is a private, disposable sandbox rather than a system that
needs protecting, so the guard has no value and only blocks the agent. Set the
override in buildAlpineProcessEnvironment() (AlpineRuntime.kt:775):
private fun buildAlpineProcessEnvironment(): Map<String, String> =
buildMap {
put("HOME", homeDirectory)
put("PATH", "/usr/local/sbin:/usr/local/bin:/usr/sbin:/usr/bin:/sbin:/bin")
+ // Alpine ships a PEP 668 EXTERNALLY-MANAGED marker, so `pip install` fails even
+ // as root. This rootfs is a private sandbox, not a system that needs the guard.
+ put("PIP_BREAK_SYSTEM_PACKAGES", "1")
+ put("UV_BREAK_SYSTEM_PACKAGES", "1")
put("AETHER_RUNTIME", "alpine")
Verified in a real Alpine 3.23 rootfs under proot:
--- without the variable ---
error: externally-managed-environment
--- with PIP_BREAK_SYSTEM_PACKAGES=1 ---
Would install certifi-2026.7.22 charset-normalizer-3.5.1 idna-3.20 requests-2.34.2 urllib3-2.8.0
UV_BREAK_SYSTEM_PACKAGES is the documented uv equivalent
(uv pip install --help → --break-system-packages ... [env: UV_BREAK_SYSTEM_PACKAGES=]).
A per-project virtualenv remains the better habit for real projects, but it should
be the user's choice, not a hard wall in front of every pip install.
Verification status
| Claim |
How it was checked |
| apk uses O_TMPFILE + linkat |
apk-tools 3.0.8 source, io.c:1168 / io.c:1088 |
| Failure loses the installed database |
database.c:2247 writes installed/triggers through that path |
| apk-tools 2.x is unaffected |
no O_TMPFILE in the 2.14.9 tree |
--link2symlink handles this case correctly |
link2symlink.c:1014, copies rather than symlinks |
| bundled proot accepts the flag |
strings on proot.bin |
| PEP 668 fix works |
run in an Alpine 3.23 rootfs under proot (output above) |
| Bug 1 fix on a real device |
not yet verified on-device — needs an install run on Android |
Two bugs in the Alpine runtime (Alpine 3.23 / apk-tools 3)
Tested against
AlpineRuntime.ktat commit54583f2. Both issues are one-line fixes.Bug 1 — every
apk addsilently fails to update the package databaseSymptom
Every package install ends with:
The files are unpacked and the tools run, so the UI reports success — but
lib/apk/db/installedis never updated.Root cause
Confirmed by reading
apk-tools 3.0.8(the version in Alpine 3.23), not by guesswork:src/io.c:1160-1168—__apk_ostream_to_file()opens the destination withopenat(atfd, path, O_RDWR | O_TMPFILE | O_CLOEXEC, mode)whenever/proc/self/fdis present.src/io.c:1088— on close it materialises that anonymous inode withlinkat(AT_FDCWD, "/proc/self/fd/N", fos->atfd, tmpname, AT_SYMLINK_FOLLOW).linkat()is denied (EACCES), soapk_ostream_cancel()propagates the error.src/database.c:2335—apk_db_write_layers()fails, andsrc/database.c:2342prints the message above.This is new in apk-tools 3. apk-tools 2.14.x has no
O_TMPFILEcode path at all,which is why the Alpine 3.21 runtime (
scripts/prepare-ios-runtime.sh) never hit it.Aether ships Alpine 3.23.4 (
AlpineRuntime.kt:840), so it is affected.Impact — not cosmetic
apk_db_write_layers()(src/database.c:2247) is what writesinstalled,triggersandscripts.tar:When it fails, apk loses its record of what is installed:
apk infois incomplete,subsequent installs redo work, and package triggers are never registered.
apk's own wording — "System state may be inconsistent" — is accurate.
The failure is currently masked in
installPackageProfileLocked()(
AlpineRuntime.kt:449):Because the binaries do run,
verifyPackageProfilepasses and the profile isreported
Ready, so the broken database never surfaces as a failure.Fix
Pass proot's
--link2symlink. Termux's proot carries a handler written for exactlythis call shape —
handle_linkat_from_proc_fd()insrc/extension/link2symlink/link2symlink.c:1014:/** Handler for linkat(..., "/proc/X/fd/Y", ..., AT_SYMLINK_FOLLOW) */It detects that the source is a
" (deleted)"O_TMPFILE inode and, instead ofcreating a symlink, copies the contents into a real file via
open(target_path, O_WRONLY|O_CREAT|O_EXCL, ...). The result is an ordinaryregular file, so there is no risk of leaving a dangling symlink where the apk
database should be.
The bundled binary already supports the flag — verified with
strings app/src/main/assets/runtimes/alpine/arm64-v8a/proot.bin | grep -x -- --link2symlink.Apply in both places that build a proot command line,
buildAlpineProcess()(AlpineRuntime.kt:718) andbuildAlpineInteractiveCommand()(AlpineRuntime.kt:753):val prootCommand = listOf( AlpineHostLinker, prootFile.absolutePath, + // apk (apk-tools 3) commits its database with O_TMPFILE + linkat(), which + // Android denies in app-private storage. This turns that into a real file. + "--link2symlink", "-0", "-r", rootfsDir.absolutePath,Bug 2 — the agent cannot
pip installanything (PEP 668)Symptom
Any
pip install <pkg>inside the Alpine runtime fails, including as root.Root cause
Alpine ships a PEP 668 marker at
/usr/lib/python3.12/EXTERNALLY-MANAGED:The
pythonprofile (AlpineRuntime.kt:1294) installspython3 py3-pip py3-virtualenv, so pip is present but refuses to installanything into the system interpreter. This is a deliberate distro guard, not a
permission problem —
ls -lon the site-packages directory shows root-writabledirectories, which makes it easy to misdiagnose.
Fix
The Alpine rootfs here is a private, disposable sandbox rather than a system that
needs protecting, so the guard has no value and only blocks the agent. Set the
override in
buildAlpineProcessEnvironment()(AlpineRuntime.kt:775):private fun buildAlpineProcessEnvironment(): Map<String, String> = buildMap { put("HOME", homeDirectory) put("PATH", "/usr/local/sbin:/usr/local/bin:/usr/sbin:/usr/bin:/sbin:/bin") + // Alpine ships a PEP 668 EXTERNALLY-MANAGED marker, so `pip install` fails even + // as root. This rootfs is a private sandbox, not a system that needs the guard. + put("PIP_BREAK_SYSTEM_PACKAGES", "1") + put("UV_BREAK_SYSTEM_PACKAGES", "1") put("AETHER_RUNTIME", "alpine")Verified in a real Alpine 3.23 rootfs under proot:
UV_BREAK_SYSTEM_PACKAGESis the documented uv equivalent(
uv pip install --help→--break-system-packages ... [env: UV_BREAK_SYSTEM_PACKAGES=]).A per-project virtualenv remains the better habit for real projects, but it should
be the user's choice, not a hard wall in front of every
pip install.Verification status
io.c:1168/io.c:1088database.c:2247writesinstalled/triggersthrough that pathO_TMPFILEin the 2.14.9 tree--link2symlinkhandles this case correctlylink2symlink.c:1014, copies rather than symlinksstringsonproot.bin