karnel-termux 4.18.1 → 4.18.3

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/README.md CHANGED
@@ -8,7 +8,7 @@
8
8
 
9
9
  <p align="center">
10
10
  <a href="https://github.com/karnel-termuxOFC/karnel-termux">
11
- <img src="https://img.shields.io/badge/version-4.18.1-0078D4?style=for-the-badge" alt="Version">
11
+ <img src="https://img.shields.io/badge/version-4.18.3-0078D4?style=for-the-badge" alt="Version">
12
12
  </a>
13
13
  <a href="https://www.npmjs.com/package/karnel-termux">
14
14
  <img src="https://img.shields.io/npm/v/karnel-termux?style=for-the-badge&logo=npm&color=cb3837" alt="npm">
@@ -78,7 +78,7 @@ Why it's great:
78
78
  ### Via checksummed GitHub release (recommended)
79
79
 
80
80
  ```bash
81
- version=4.18.1
81
+ version=4.18.3
82
82
  tmpdir=$(mktemp -d) && trap 'rm -rf "$tmpdir"' EXIT
83
83
  base="https://github.com/karnel-termuxOFC/karnel-termux/releases/download/v$version"
84
84
  curl -fsSL "$base/karnel-termux-install.sh" -o "$tmpdir/karnel-termux-install.sh"
package/docs/CHANGELOG.md CHANGED
@@ -6,6 +6,21 @@ layout: base
6
6
 
7
7
  # Documentation Changelog
8
8
 
9
+ ## 4.18.3
10
+
11
+ - **Every command that installs something now adapts what it put on PATH.** The dispatcher brackets `install`, `update`, `upgrade`, `reinstall`, `restore`, `plugin`, `supabase`, `deploy`, `voice`, `robin`, `pg`, `open` and `init` with a snapshot of the writable parts of PATH (`path|size|mtime` per entry) and afterwards adapts exactly the entries that appeared or changed. That closes the gap the registries could never close: an installer that never calls the compatibility layer, one that names a command its registry does not mention, and a file restored from an archive with its original timestamp all end up wrapped anyway. Sweeping the prefix instead was measured on this device at 51 seconds of `readelf` over 800 entries (and 875 seconds with `readelf -l`); diffing a snapshot takes 60 ms and touches nothing the command did not write. The exit code of the command is taken before adaptation and returned unchanged.
12
+ - **The AI reinstall fallback adapts what it installed.** `_run_ai_tool_action` called `$install_fn` and returned without passing through its adapt call, so those binaries stayed unwrapped.
13
+ - **An install can no longer report success for a command that does not start.** A device audit of all 169 catalogued tools found 21 installed and 147 absent — and exactly one of them dead: `$PREFIX/bin/freebuff` had been left as npm's shim, which exits 1 on Termux with "Unsupported platform: android arm64", while `karnel install ai --freebuff` still reported "already installed". Both paths of `install_freebuff` now prove the entry runs before returning, the same way `_fix_freebuff_runtime` already proves the runtime does. The audit is green afterwards: freebuff `0.2.22`, bun `1.4.2`, supabase `2.20.8`, zero broken.
14
+ - **The compatibility suite is part of the test run.** `tests/android-compat.sh` had never been listed in `tests/run.sh`, so its cases only ever ran by hand; it now runs in `npm test` and in CI, and every other file under `tests/` was checked against the list while adding it.
15
+ - **Tests.** Five new cases in `tests/android-compat.sh` (a binary created during a command, a binary rewritten in place, only the entries that changed, an exit code that cannot change, and the dispatcher wiring itself) plus two in `tests/ai-installer-hardening.sh` (a dead freebuff entry fails, a live one is accepted), taking the suites to 28 and 11 cases. Every branch was proven to fail first: dropping the adapt call fails 1 case, dropping the snapshot fails 1, making `compat_adapt_since` a no-op fails 2, removing the AI fallback's adapt fails 1, removing the already-installed verification fails 1, and turning that verification into a no-op fails both new freebuff cases.
16
+
17
+ ## 4.18.2
18
+
19
+ - **Wrappers no longer hand a preload to glibc.** `login(1)` exports `LD_PRELOAD=$PREFIX/lib/libtermux-exec-ld-preload.so`, a bionic object; glibc's `ld.so` cannot load it, so every wrapped tool died before `main()` with `.../glibc/lib/libc.so: invalid ELF header`. Tier 3's own preload was fatal in the other direction: exporting the glibc `libtermux-exec.so` shim left every bionic child of the tool failing with `library "libc.so.6" not found`. Every wrapper now starts with `unset LD_PRELOAD`, and tier 3 exports `PATH` only.
20
+ - **Wrappers written by older Karnels are refreshed.** `compat_wrapper_is_current()` checks the invariants the current code emits; when one is missing the wrapper is rewritten in place before `compat_escalate` probes it, so a stale launcher can no longer keep a tool pinned to a tier it no longer reaches.
21
+ - **An orphaned parked copy no longer blocks adaptation.** Freebuff's own entry re-extracts its runtime in place, dropping a raw ELF over the wrapper and leaving `.karnel-real` behind; `compat_wrap` refused to touch that file, so the tool could never be adapted again — which is how `karnel install ai --freebuff` ended in "does not start at any tier" and then "Freebuff runtime still does not run after repair". Identical bytes are dropped (a 130 MB runtime is not copied to prove a point) and a different copy is parked as `.karnel-real.stale`.
22
+ - **Tests.** Four new cases pin the behaviour — no wrapper hands a preload to the loader on tier 2 or tier 3, a launcher from an older karnel is refreshed in place, an orphaned parked copy does not block adaptation — in a suite of 23 cases running in 8 seconds. Every branch was proven to fail first: removing the `unset` fails 2 cases, restoring the tier 3 preload fails 5, dropping the refresh fails 1, restoring the refusal fails 1, and always discarding the orphan fails 1.
23
+
9
24
  ## 4.18.1
10
25
 
11
26
  - **The ladder is reachable from every installer, not just Freebuff:** `compat_adapt_installed()` is the single entry point behind `_tool_compat_adapt` (utils/tools.sh), `_ai_tool_compat_adapt` (tools/ai/all.sh) and the post-install loop in `cli/commands/install.sh`, so it already covered all 16 categories — but it only ever called `compat_wrap()`, which stops at the loader tier. Tiers 3 (glibc userland) and 4 (proot FHS root) were reachable solely from Freebuff's `compat_adapt()` call, which is why no other tool could ever climb above tier 2. It now escalates after wrapping, and re-probes wrappers already on disk so a tool that stops starting at its current tier climbs on the next install or update instead of staying broken behind a launcher nobody re-examines.
@@ -1 +1 @@
1
- a046270feccbad261fd440a040fb960250c8e699
1
+ 4f1840c76ffba58dde286b506142fa1f9e5ee1d8
@@ -2,6 +2,7 @@
2
2
 
3
3
  import "@/utils/log"
4
4
  import "@/utils/colors"
5
+ import "@/utils/compat"
5
6
 
6
7
  : "${KARNEL_VERSION:=unknown}"
7
8
 
@@ -67,8 +68,23 @@ _karnel_dispatch() {
67
68
 
68
69
  if [[ -f "$command_file" ]]; then
69
70
  import "@/cli/commands/$cmd"
71
+ # Every command that can put a binary on PATH is bracketed by a snapshot
72
+ # of PATH, and whatever changed inside it is adapted afterwards. That is
73
+ # what makes the layer cover *all* tools: an installer that never calls
74
+ # compat itself, one that names a command its registry does not mention,
75
+ # and one that restores a file from an archive all end up here anyway.
76
+ local compat_snap=""
77
+ case "$cmd" in
78
+ install | update | upgrade | reinstall | restore | plugin | supabase | deploy | voice | robin | pg | open | init)
79
+ compat_snap="$(compat_path_snapshot 2>/dev/null)" || compat_snap=""
80
+ ;;
81
+ esac
70
82
  "${cmd}_main" "$@"
71
- return $?
83
+ local cmd_rc=$?
84
+ if [[ -n "$compat_snap" ]]; then
85
+ compat_adapt_since "$compat_snap" || true
86
+ fi
87
+ return "$cmd_rc"
72
88
  fi
73
89
 
74
90
  import "@/tools/plugins/install"
@@ -230,6 +230,13 @@ _run_ai_tool_action() {
230
230
  return "$uninstall_rc"
231
231
  fi
232
232
  "$install_fn"
233
+ local install_rc=$?
234
+ # The fallback path never passes through _run_ai_tool_action's adapt call,
235
+ # so the binaries this install just wrote would stay unwrapped.
236
+ if ((install_rc == 0)); then
237
+ _ai_tool_compat_adapt "$id"
238
+ fi
239
+ return "$install_rc"
233
240
  }
234
241
 
235
242
  _all_ai_tools_action() {
@@ -23,6 +23,7 @@ install_freebuff() {
23
23
  log_info "Freebuff is already installed"
24
24
  _fix_freebuff_runtime || true
25
25
  _install_freebuff_entry || true
26
+ _verify_freebuff_entry || return 1
26
27
  return 2
27
28
  fi
28
29
 
@@ -42,6 +43,7 @@ install_freebuff() {
42
43
  _fix_freebuff_shebang || return 1
43
44
  _fix_freebuff_runtime || return 1
44
45
  _install_freebuff_entry || return 1
46
+ _verify_freebuff_entry || return 1
45
47
  log_success "Freebuff installed"
46
48
  }
47
49
 
@@ -96,6 +98,25 @@ _freebuff_binary_runs() {
96
98
  ((rc == 0))
97
99
  }
98
100
 
101
+ # npm recreates its shim at $PREFIX/bin/freebuff on every global operation, and
102
+ # that shim cannot start on Android - so an install can look successful while
103
+ # the command itself is dead. Every path therefore proves the entry runs before
104
+ # reporting success, the same way _fix_freebuff_runtime proves the runtime does.
105
+ _verify_freebuff_entry() {
106
+ local entry rc=0
107
+ entry="$(command -v freebuff 2>/dev/null)"
108
+ if [[ -z "$entry" || ! -e "$entry" ]]; then
109
+ log_error "Freebuff is not on PATH after install"
110
+ return 1
111
+ fi
112
+ { timeout 30 "$entry" --version >/dev/null 2>&1; rc=$?; } 2>/dev/null
113
+ if ((rc != 0)); then
114
+ log_error "Freebuff does not run after install (exit $rc)"
115
+ return 1
116
+ fi
117
+ return 0
118
+ }
119
+
99
120
  _freebuff_target_key() {
100
121
  case "$(uname -m)" in
101
122
  aarch64 | arm64) printf 'linux-arm64\n' ;;
@@ -206,6 +206,19 @@ compat_wrapper_tier() {
206
206
  esac
207
207
  }
208
208
 
209
+ # A wrapper written by an older karnel is still a wrapper, but it may hand
210
+ # LD_PRELOAD straight to the glibc loader and die in a login shell. Returns 0
211
+ # only when the launcher carries every invariant the current code emits.
212
+ compat_wrapper_is_current() {
213
+ local file="$1"
214
+ _compat_is_wrapper "$file" || return 1
215
+ grep -qF 'unset LD_PRELOAD' -- "$file" 2>/dev/null || return 1
216
+ if grep -qF 'export LD_PRELOAD' -- "$file" 2>/dev/null; then
217
+ return 1
218
+ fi
219
+ return 0
220
+ }
221
+
209
222
  # Builds (once) a synthetic filesystem root out of the glibc sysroot that is
210
223
  # already on the device. proot then maps /lib, /usr and /etc onto it, which is
211
224
  # everything a binary that hardcodes those paths needs -- for a few kilobytes
@@ -273,24 +286,23 @@ _compat_write_wrapper() {
273
286
  printf '%s\n' "#!$bash_bin"
274
287
  printf '%s\n' "$COMPAT_WRAPPER_MARKER"
275
288
  printf '%s\n' "$COMPAT_WRAPPER_TIER_MARKER $tier"
289
+ # login(1) exports LD_PRELOAD=$PREFIX/lib/libtermux-exec-ld-preload.so,
290
+ # a bionic object. glibc's ld.so cannot load it and dies with
291
+ # "libc.so: invalid ELF header" on every start. The glibc termux-exec
292
+ # shim is equally unusable: every bionic child this tool spawns then
293
+ # fails with "library libc.so.6 not found". So no preload survives.
294
+ printf '%s\n' "unset LD_PRELOAD"
276
295
  case "$tier" in
277
296
  "$COMPAT_TIER_USERLAND")
278
- # The $root/$PATH/$LD_PRELOAD below must reach the wrapper verbatim.
297
+ # The $root/$PATH below must reach the wrapper verbatim.
279
298
  # shellcheck disable=SC2016
280
299
  printf '%s\n' "# Tier 3: the glibc loader plus the glibc userland, so the child"
281
- printf '%s\n' "# processes this tool spawns find the same coreutils and exec shim."
300
+ printf '%s\n' "# processes this tool spawns find the same coreutils."
282
301
  # Everything below is emitted literally and expanded by the wrapper at
283
302
  # runtime, where "root" is the assignment written on the previous line.
284
303
  printf 'root=%q\n' "$(compat_glibc_root)"
285
304
  # shellcheck disable=SC2016 # expands in the wrapper
286
305
  printf 'export PATH="$root/bin:$PATH"\n'
287
- # The glibc termux-exec shim is optional; a preload pointing nowhere
288
- # makes ld.so complain on every start of the tool.
289
- # shellcheck disable=SC2016 # expands in the wrapper
290
- printf 'if [ -f "$root/lib/libtermux-exec.so" ]; then\n'
291
- # shellcheck disable=SC2016 # expands in the wrapper
292
- printf ' export LD_PRELOAD="$root/lib/libtermux-exec.so${LD_PRELOAD:+:$LD_PRELOAD}"\n'
293
- printf 'fi\n'
294
306
  printf 'exec %q --library-path %q %q "$@"\n' "$loader" "$libpath" "$real"
295
307
  ;;
296
308
  "$COMPAT_TIER_PROOT")
@@ -328,22 +340,43 @@ compat_wrap() {
328
340
  [[ -f "$real" ]] || return 1
329
341
  if _compat_is_wrapper "$real"; then
330
342
  current="$(compat_wrapper_tier "$real")"
331
- [[ "$current" == "$tier" ]] && return 0
332
- # Re-tier an existing wrapper: the original is already parked next to it,
333
- # so only the launcher changes.
343
+ if [[ "$current" == "$tier" ]] && compat_wrapper_is_current "$real"; then
344
+ return 0
345
+ fi
346
+ # Re-tier an existing wrapper, or refresh one written by an older karnel:
347
+ # the original is already parked next to it, so only the launcher changes.
334
348
  stored="$real.karnel-real"
335
349
  [[ -e "$stored" ]] || return 1
336
350
  loader="$(compat_glibc_loader)" || loader="/lib/ld-linux-aarch64.so.1"
337
351
  _compat_write_wrapper "$real" "$stored" "$loader" "$tier" || return 1
338
- log_info "compat: $real retiered to level $tier"
352
+ if [[ "$current" == "$tier" ]]; then
353
+ log_info "compat: $real wrapper refreshed"
354
+ else
355
+ log_info "compat: $real retiered to level $tier"
356
+ fi
339
357
  return 0
340
358
  fi
341
359
  compat_is_glibc_elf "$real" || return 1
342
360
  loader="$(compat_glibc_loader)" || return 1
343
361
  stored="$real.karnel-real"
344
362
  if [[ -e "$stored" ]]; then
345
- log_error "compat: refusing to overwrite existing $stored"
346
- return 1
363
+ # The file on disk is a real ELF, not our wrapper, while a parked copy is
364
+ # already sitting beside it: something outside karnel replaced the
365
+ # launcher (freebuff's node entry re-extracts its runtime in place) and
366
+ # orphaned the copy. The file on disk is authoritative now, so the parked
367
+ # copy is stale. A refusal here would leave the tool permanently
368
+ # unrunnable, so the copy is dropped when it is the same bytes and kept
369
+ # as .karnel-real.stale otherwise - a 130 MB runtime must not be copied
370
+ # just to prove a point.
371
+ if cmp -s -- "$stored" "$real" 2>/dev/null; then
372
+ rm -f -- "$stored" || return 1
373
+ log_warn "compat: dropped a stale parked copy for $real"
374
+ elif ! mv -f -- "$stored" "$stored.stale" 2>/dev/null; then
375
+ log_error "compat: could not move the stale parked copy for $real"
376
+ return 1
377
+ else
378
+ log_warn "compat: replaced a stale parked copy for $real"
379
+ fi
347
380
  fi
348
381
  if ! mv -- "$real" "$stored"; then
349
382
  log_error "compat: could not move $real aside"
@@ -392,6 +425,11 @@ compat_escalate() {
392
425
  real="$(readlink -f -- "$target" 2>/dev/null || printf '%s' "$target")"
393
426
  _compat_is_wrapper "$real" || return 0
394
427
 
428
+ # Refresh a launcher written by an older karnel before probing it: the probe
429
+ # has to exercise the wrapper we are about to ship, not the stale one.
430
+ compat_wrapper_is_current "$real" ||
431
+ compat_wrap "$real" "$(compat_wrapper_tier "$real")" || true
432
+
395
433
  compat_probe "$real" && return 0
396
434
 
397
435
  if compat_wrap "$real" "$COMPAT_TIER_USERLAND" && compat_probe "$real"; then
@@ -496,13 +534,11 @@ compat_fix_shebangs() {
496
534
  printf '%s\n' "$fixed"
497
535
  }
498
536
 
499
- # Adapts whatever a tool has just put on PATH. Wraps a glibc ELF so the
500
- # kernel can start it; leaves native binaries, scripts and text files alone.
501
- # Always returns 0: adaptation is an optimisation, never a reason to fail an
502
- # install or an update.
503
- compat_adapt_installed() {
504
- local tool="$1" path real
505
- path="$(command -v -- "$tool" 2>/dev/null)" || return 0
537
+ # Adapts one resolved path. Wraps a glibc ELF so the kernel can start it;
538
+ # leaves native binaries, scripts and text files alone. Always returns 0:
539
+ # adaptation is an optimisation, never a reason to fail an install.
540
+ _compat_adapt_path() {
541
+ local path="$1" real
506
542
  [[ -n "$path" ]] || return 0
507
543
  real="$(readlink -f -- "$path" 2>/dev/null || printf '%s' "$path")"
508
544
  [[ -f "$real" && -x "$real" ]] || return 0
@@ -522,6 +558,57 @@ compat_adapt_installed() {
522
558
  return 0
523
559
  }
524
560
 
561
+ # Adapts whatever a tool has just put on PATH.
562
+ compat_adapt_installed() {
563
+ local tool="$1" path
564
+ path="$(command -v -- "$tool" 2>/dev/null)" || return 0
565
+ [[ -n "$path" ]] || return 0
566
+ _compat_adapt_path "$path"
567
+ }
568
+
569
+ # Records every direct entry of the writable parts of PATH as
570
+ # "path|size|mtime", printed to a temporary file. Taken before a command runs
571
+ # so the layer can afterwards adapt exactly what that command touched.
572
+ #
573
+ # Sweeping the whole prefix instead would need readelf on every ELF, which
574
+ # measures 60+ seconds over a Termux bin directory of 800 entries; diffing a
575
+ # snapshot adapts only the handful of files that actually changed. It also
576
+ # means no installer has to declare its binary names: a tool that installs a
577
+ # command its registry does not mention is still covered, and a file restored
578
+ # from an archive with its original mtime is caught by being a new path.
579
+ compat_path_snapshot() {
580
+ local dir tmp
581
+ tmp="$(mktemp "${TMPDIR:-${KARNEL_CACHE:-/tmp}}/karnel-compat-snap.XXXXXX" 2>/dev/null)" || return 1
582
+ local IFS=':'
583
+ for dir in $PATH; do
584
+ [[ -n "$dir" && -d "$dir" && -w "$dir" ]] || continue
585
+ find "$dir" -maxdepth 1 \( -type f -o -type l \) -printf '%p|%s|%T@\n' 2>/dev/null || true
586
+ done | sort >"$tmp" || true
587
+ printf '%s\n' "$tmp"
588
+ }
589
+
590
+ # Adapts every PATH entry that appeared or changed since a snapshot, then
591
+ # removes both files. Returns 0 unconditionally.
592
+ compat_adapt_since() {
593
+ local snap="$1" tmp dir path
594
+ [[ -f "$snap" ]] || return 0
595
+ tmp="$(mktemp "${TMPDIR:-${KARNEL_CACHE:-/tmp}}/karnel-compat-now.XXXXXX" 2>/dev/null)" || {
596
+ rm -f -- "$snap"
597
+ return 0
598
+ }
599
+ local IFS=':'
600
+ for dir in $PATH; do
601
+ [[ -n "$dir" && -d "$dir" && -w "$dir" ]] || continue
602
+ find "$dir" -maxdepth 1 \( -type f -o -type l \) -printf '%p|%s|%T@\n' 2>/dev/null || true
603
+ done | sort >"$tmp" || true
604
+ while IFS= read -r path; do
605
+ [[ -n "$path" ]] || continue
606
+ _compat_adapt_path "$path" || true
607
+ done < <(comm -13 -- "$snap" "$tmp" 2>/dev/null | cut -d'|' -f1)
608
+ rm -f -- "$snap" "$tmp"
609
+ return 0
610
+ }
611
+
525
612
  # Human-readable one-liner for doctor output.
526
613
  compat_describe() {
527
614
  local file="$1" kind interp real
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "karnel-termux",
3
- "version": "4.18.1",
3
+ "version": "4.18.3",
4
4
  "description": "Modular Dev Environment for Termux — install languages, databases, AI agents, editors, and more with one command",
5
5
  "bin": {
6
6
  "karnel": "karnel/bin/karnel"