Report a denied UNSLOTH_LOCAL_LLAMA_CPP_DIR instead of aborting on errexit (#8143)
Some checks are pending
Local Agent Guides CI / connection (openclaw) (push) Waiting to run
Local Agent Guides CI / connection (pi) (push) Waiting to run
Core / Core (HF=default + TRL=default) (push) Waiting to run
Core / Core (HF=4.57.6 + TRL<1) (push) Waiting to run
Core / Core (HF=latest + TRL=latest) (push) Waiting to run
Core / llama.cpp build + smoke (push) Waiting to run
Cross-platform parity / parity (ubuntu-latest) (push) Waiting to run
Cross-platform parity / parity (windows-latest) (push) Waiting to run
Lint CI / Source lint (Python + shell + YAML + JSON + safety nets) (push) Waiting to run
Local Agent Guides CI / connection (claude) (push) Waiting to run
Local Agent Guides CI / connection (codex) (push) Waiting to run
Local Agent Guides CI / connection (hermes) (push) Waiting to run
Local Agent Guides CI / connection (opencode) (push) Waiting to run
Local Agent Guides CI / file-edit (claude) (push) Waiting to run
Local Agent Guides CI / file-edit (codex) (push) Waiting to run
Local Agent Guides CI / file-edit (hermes) (push) Waiting to run
Local Agent Guides CI / file-edit (openclaw) (push) Waiting to run
Local Agent Guides CI / file-edit (opencode) (push) Waiting to run
Local Agent Guides CI / file-edit (pi) (push) Waiting to run
Local Agent Guides CI / resume (claude) (push) Waiting to run
Local Agent Guides CI / resume (codex) (push) Waiting to run
Local Agent Guides CI / resume (opencode) (push) Waiting to run
Local Agent Guides CI / resume (pi) (push) Waiting to run
Local Agent Guides CI / prompt-cache (gemma-3-270m) (push) Waiting to run
MLX CI on Mac M1 / dispatch (push) Waiting to run
Scorecard supply-chain security / Scorecard analysis (push) Waiting to run
Windows Unsloth Update CI / Unsloth Updating Tests (push) Waiting to run
Wheel CI / Wheel build + content sanity + import smoke (push) Waiting to run
Security audit / advisory audit (pip + npm + cargo) (push) Waiting to run
Security audit / pip scan-packages :: extras (push) Waiting to run
Security audit / pip scan-packages :: studio (push) Waiting to run
Security audit / pip scan-packages :: hf-stack (push) Waiting to run
Security audit / npm scan-packages (Unsloth frontend tarballs) (push) Waiting to run
Security audit / workflow-trigger lint (pull_request_target / cache-poisoning) (push) Waiting to run
Security audit / pytest tests/security (push) Waiting to run
Security audit / npm provenance + new install-script diff (push) Waiting to run
Unsloth API CI / Unsloth API & Auth Tests (push) Waiting to run
Backend CI / (Python 3.10) (push) Waiting to run
Backend CI / (Python 3.11) (push) Waiting to run
Backend CI / (Python 3.12) (push) Waiting to run
Backend CI / (Python 3.13) (push) Waiting to run
Backend CI / Repo tests (CPU) (push) Waiting to run
Unsloth export capability / capability (ubuntu-latest) (push) Waiting to run
Unsloth export capability / capability (windows-latest) (push) Waiting to run
Frontend CI / Frontend build + bundle sanity (push) Waiting to run
Unsloth GGUF CI / OpenAI, Anthropic API tests (push) Waiting to run
Unsloth GGUF CI / Tool calling Tests (push) Waiting to run
Unsloth GGUF CI / JSON, images (push) Waiting to run
Unsloth load-orchestrator CI / test (push) Waiting to run
Mac Studio GGUF CI / GGUF inference smoke (API, tools, vision) (push) Waiting to run
Mac Studio Install Matrix CI / Install + load (macos-15) (push) Waiting to run
Mac Studio Install Matrix CI / Install + load (macos-26) (push) Waiting to run
Mac Studio Install Matrix CI / Install + load (macos-15-intel) (push) Waiting to run
Mac Studio Install Matrix CI / Install + load (macos-26-intel) (push) Waiting to run
Mac Studio UI + API + Update CI / Chat UI, API and Update Tests (push) Waiting to run
Unsloth Tauri CI / Tauri Linux debug build (no codesign) (push) Waiting to run
Unsloth Tauri CI / Rust unit tests (windows) (push) Waiting to run
Unsloth UI CI / Chat UI Tests (push) Waiting to run
Unsloth Update CI / Unsloth Updating Tests (push) Waiting to run
Windows Unsloth API CI / Unsloth API & Auth Tests (push) Waiting to run
Windows Unsloth GGUF CI / GGUF inference smoke (API, tools, vision) (push) Waiting to run
Windows Unsloth GGUF CI / Unsloth install + inference without Visual Studio (push) Waiting to run
Windows Unsloth GGUF CI / GPU prebuilt resolves without Visual Studio (push) Waiting to run
Windows Unsloth GGUF CI / setup.ps1 unit tests (VS 2026 / CMake guard) (push) Waiting to run
Windows Unsloth GGUF CI / real-VS detection (VS 2022) (push) Waiting to run
Windows Unsloth GGUF CI / real-VS detection (VS 2026) (push) Waiting to run
Windows Unsloth GGUF CI / VC++ runtime detect + install round-trip (windows-2025-vs2026) (push) Waiting to run
Windows Unsloth GGUF CI / VC++ runtime detect + install round-trip (windows-latest) (push) Waiting to run
Windows Unsloth UI CI / Chat UI Tests (push) Waiting to run

* Report a denied UNSLOTH_LOCAL_LLAMA_CPP_DIR instead of aborting on errexit

* Report a denied ancestor and stop on a denied install parent

Two gaps in the first pass, both reproduced.

A build under an unsearchable ancestor cannot be stat'd, so `[ ! -d ]` read it as
missing and setup said "does not exist", sending the user to correct a path that
was already right. `_report_denied_ancestor` walks up to the deepest ancestor it
can stat and reports that one, so the message names the directory that actually
needs the permission change. A genuinely absent path still reports as missing.

Keeping the textual path when the install parent could not be canonicalized only
moved the abort: `rm -rf` fails, `[ -e ]` is false because the parent cannot be
stat'd, and the following `ln -sfn` then aborts under errexit with no
[TAURI:ERROR]. Nothing can be written under a parent we cannot search, so report
there instead of continuing.

* Follow symlinks and terminate dirname options when locating a denied ancestor

* Strip trailing slashes before probing for a symlinked denied ancestor

* Tighten the denied-ancestor comments in setup.sh and its test

---------

Co-authored-by: danielhanchen <moonshotaisubstack@gmail.com>
This commit is contained in:
Daniel Han 2026-08-08 01:18:24 -07:00 committed by GitHub
parent c84e3e2323
commit 9f37302e2e
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
2 changed files with 184 additions and 12 deletions

View file

@ -978,20 +978,18 @@ _studio_owned_adoptable() {
[ -f "$1/UNSLOTH_WHISPER_PREBUILT_INFO.json" ] && return 0
return 1
}
# Search (+x), not read (+r), is what the marker probes need: inside a directory
# we cannot search every probe reports absent, so our own install reads as someone else's.
# Marker probes need search (+x), not read (+r): in an unsearchable dir every probe reports absent, so our install looks foreign.
_studio_dir_unsearchable() {
[ -d "$1" ] || return 1
( cd "$1" ) 2>/dev/null && return 1
( cd -- "$1" ) 2>/dev/null && return 1
return 0
}
# Also needs +r, for callers that list or replace the tree: mode 111 is searchable
# but still fails install_llama_prebuilt.py.
# Also needs +r for callers that list or replace the tree: mode 111 is searchable but still fails install_llama_prebuilt.py.
_studio_dir_unreadable() {
[ -d "$1" ] || return 1
_studio_dir_unsearchable "$1" && return 0
ls -A "$1" >/dev/null 2>&1 && return 1
ls -A -- "$1" >/dev/null 2>&1 && return 1
return 0
}
@ -1018,6 +1016,41 @@ _path_access_denied() {
setup_fail 1 "Permission denied reading the existing $_pad_label at $_pad_dir. Delete or rename that folder (Unsloth reinstalls it) or restore access, then re-run setup. Reinstalling the app does not reset it."
}
# POSIX follows a final symlink when the path ends in /, so "link/" is never -L. Strip it, but never past the root.
_studio_rstrip_slash() {
_srs_path="$1"
while [ "$_srs_path" != "/" ] && [ "${_srs_path%/}" != "$_srs_path" ]; do
_srs_path="${_srs_path%/}"
done
printf '%s' "$_srs_path"
}
# An unsearchable ancestor makes a real path read as missing. Walk up to the deepest
# ancestor we can stat and report it as the blocker; stay quiet if the path is just absent.
_report_denied_ancestor() {
_rda_probe="$(_studio_rstrip_slash "$1")"
_rda_hops=0
while [ ! -e "$_rda_probe" ] && [ "$_rda_probe" != "/" ] && [ "$_rda_probe" != "." ]; do
# An unfollowable symlink is the deepest name we have, so walk its target:
# the denied ancestor lives there. The hop cap breaks symlink cycles.
if [ -L "$_rda_probe" ] && [ "$_rda_hops" -lt 40 ]; then
_rda_hops=$((_rda_hops + 1))
_rda_target="$(readlink -- "$_rda_probe")" || break
case "$_rda_target" in
/*) _rda_probe="$_rda_target" ;;
*) _rda_probe="$(dirname -- "$_rda_probe")/$_rda_target" ;;
esac
_rda_probe="$(_studio_rstrip_slash "$_rda_probe")"
continue
fi
# -- keeps a leading-dash path an operand, not a dirname option.
_rda_probe="$(dirname -- "$_rda_probe")"
done
if _studio_dir_unsearchable "$_rda_probe"; then
_path_access_denied "$_rda_probe" "$2" owner-unverified
fi
}
_assert_studio_owned_or_absent() {
_aso_dir="$1"
_aso_label="$2"
@ -1948,10 +1981,17 @@ _has_local_llama_server() {
_LOCAL_LLAMA_CPP_LINKED=false
if [ -n "${UNSLOTH_LOCAL_LLAMA_CPP_DIR:-}" ]; then
if [ ! -d "$UNSLOTH_LOCAL_LLAMA_CPP_DIR" ]; then
# A build under an unsearchable ancestor cannot be stat'd, so report permissions
# rather than sending the user to fix a path that is already correct.
_report_denied_ancestor "$UNSLOTH_LOCAL_LLAMA_CPP_DIR" "UNSLOTH_LOCAL_LLAMA_CPP_DIR"
step "llama.cpp" "UNSLOTH_LOCAL_LLAMA_CPP_DIR does not exist: $UNSLOTH_LOCAL_LLAMA_CPP_DIR" "$C_ERR"
setup_fail 1 "UNSLOTH_LOCAL_LLAMA_CPP_DIR does not exist: $UNSLOTH_LOCAL_LLAMA_CPP_DIR"
fi
_RESOLVED_LOCAL="$(CDPATH= cd -P -- "$UNSLOTH_LOCAL_LLAMA_CPP_DIR" && pwd -P)"
# In an if condition so a denied dir reports instead of tripping errexit.
if ! _RESOLVED_LOCAL="$(CDPATH= cd -P -- "$UNSLOTH_LOCAL_LLAMA_CPP_DIR" 2>/dev/null && pwd -P)"; then
# owner-unverified: this is the user's own tree, never advise deleting it.
_path_access_denied "$UNSLOTH_LOCAL_LLAMA_CPP_DIR" "UNSLOTH_LOCAL_LLAMA_CPP_DIR" owner-unverified
fi
# Canonicalize the install path the same way before comparing: _RESOLVED_LOCAL
# is fully resolved, but LLAMA_CPP_DIR is textual ($UNSLOTH_HOME/llama.cpp). If
# $HOME (or UNSLOTH_HOME) contains a symlink, the two never match even when the
@ -1961,7 +2001,13 @@ if [ -n "${UNSLOTH_LOCAL_LLAMA_CPP_DIR:-}" ]; then
_CANON_LLAMA_CPP_DIR="$LLAMA_CPP_DIR"
_LLAMA_CPP_PARENT="$(dirname "$LLAMA_CPP_DIR")"
if [ -d "$_LLAMA_CPP_PARENT" ]; then
_CANON_LLAMA_CPP_DIR="$(CDPATH= cd -P -- "$_LLAMA_CPP_PARENT" && pwd -P)/$(basename "$LLAMA_CPP_DIR")"
# Nothing can be written under a parent we cannot search, so report here
# rather than let the link below abort raw a few lines later.
if _canon_parent="$(CDPATH= cd -P -- "$_LLAMA_CPP_PARENT" 2>/dev/null && pwd -P)"; then
_CANON_LLAMA_CPP_DIR="$_canon_parent/$(basename "$LLAMA_CPP_DIR")"
else
_path_access_denied "$_LLAMA_CPP_PARENT" "Unsloth install directory" owner-unverified
fi
fi
if [ "$_RESOLVED_LOCAL" = "$_CANON_LLAMA_CPP_DIR" ]; then
# Points at the canonical install location itself: never delete-then-link

View file

@ -26,10 +26,9 @@ echo "=== setup.sh: the guards exist and probe the right permission ==="
assert_contains "defines the unsearchable-directory probe" \
"$SETUP_SH" "_studio_dir_unsearchable() {"
# cd needs +x, exactly what the marker probes need; ls needs +r, which is neither
# sufficient nor necessary. Pin the cd form.
# cd needs +x, what the marker probes need; ls needs +r, neither sufficient nor necessary. Pin the cd form.
assert_contains "the probe tests search (cd), not read (ls)" \
"$SETUP_SH" '( cd "$1" ) 2>/dev/null && return 1'
"$SETUP_SH" '( cd -- "$1" ) 2>/dev/null && return 1'
assert_contains "defines the denial reporter" \
"$SETUP_SH" "_path_access_denied() {"
assert_contains "the ownership guard checks readability before blaming ownership" \
@ -111,6 +110,25 @@ else
ok "no unguarded rm -rf of the install dir remains"
fi
# A bare $(cd ...) assignment aborts under errexit before setup_fail can report, leaving
# an exit code with no [TAURI:ERROR]. Both must sit in an if condition, which errexit exempts.
if grep -qE '^\s*_RESOLVED_LOCAL="\$\(CDPATH= cd' "$SETUP_SH"; then
bad "a denied UNSLOTH_LOCAL_LLAMA_CPP_DIR reports instead of tripping errexit"
else
ok "a denied UNSLOTH_LOCAL_LLAMA_CPP_DIR reports instead of tripping errexit"
fi
if grep -qE '^\s*_CANON_LLAMA_CPP_DIR="\$\(CDPATH= cd' "$SETUP_SH"; then
bad "an unsearchable install parent is reported instead of aborting"
else
ok "an unsearchable install parent is reported instead of aborting"
fi
# Carrying on past a denied parent only moves the abort to the ln a few lines down.
assert_contains "a denied install parent stops rather than continuing" \
"$SETUP_SH" '_path_access_denied "$_LLAMA_CPP_PARENT" "Unsloth install directory" owner-unverified'
# An unsearchable ancestor makes a real path unstattable, so [ ! -d ] would call it missing.
assert_contains "a denied ancestor is reported before the missing-path guard" \
"$SETUP_SH" '_report_denied_ancestor "$UNSLOTH_LOCAL_LLAMA_CPP_DIR" "UNSLOTH_LOCAL_LLAMA_CPP_DIR"'
echo ""
echo "=== behaviour against a genuinely unsearchable tree ==="
@ -123,7 +141,8 @@ import sys, pathlib
src = pathlib.Path(sys.argv[1]).read_text()
out = []
for name in ("_studio_owned_adoptable", "_studio_dir_unsearchable",
"_studio_dir_unreadable",
"_studio_dir_unreadable", "_studio_rstrip_slash",
"_report_denied_ancestor",
"_path_access_denied", "_assert_studio_owned_or_absent"):
i = src.index(name + "() {")
out.append(src[i:src.index("\n}\n", i) + 3])
@ -225,6 +244,113 @@ else
fi
chmod 755 "$NOLIST"
# A real build under an unsearchable ancestor must report permissions, not "missing".
ANC="$WORK/anc"; mkdir -p "$ANC/denied/llama.cpp"
chmod 000 "$ANC/denied"
if [ -d "$ANC/denied/llama.cpp" ]; then
echo " SKIP: this host cannot make an ancestor unsearchable (running as root?)"
else
ok "the ancestor is really unsearchable (negative control)"
out=$(bash -c '. "$1"
C_ERR= C_WARN= C_DIM= C_OK= C_RST=
step() { printf "STEP|%s|%s\n" "$1" "$2"; }; substep() { :; }
setup_fail() { printf "FAIL|%s\n" "$2"; exit "$1"; }
_report_denied_ancestor "$2" "UNSLOTH_LOCAL_LLAMA_CPP_DIR"
echo "NOT_REPORTED"' _ "$WORK/helpers.sh" "$ANC/denied/llama.cpp" 2>&1)
case "$out" in
*"cannot be read: permission denied"*) ok "a build under a denied ancestor reports permissions" ;;
*) bad "a build under a denied ancestor reports permissions (got: $out)" ;;
esac
case "$out" in
*"$ANC/denied"*) ok "the message names the denied ancestor, not the leaf" ;;
*) bad "the message names the denied ancestor, not the leaf (got: $out)" ;;
esac
fi
chmod 755 "$ANC/denied"
# A genuinely missing path must still be reported as missing, not as denied.
out=$(bash -c '. "$1"
C_ERR= C_WARN= C_DIM= C_OK= C_RST=
step() { :; }; substep() { :; }
setup_fail() { printf "FAIL|%s\n" "$2"; exit "$1"; }
_report_denied_ancestor "$2" "UNSLOTH_LOCAL_LLAMA_CPP_DIR"
echo "NOT_REPORTED"' _ "$WORK/helpers.sh" "$WORK/definitely-absent" 2>&1)
case "$out" in
*NOT_REPORTED*) ok "a genuinely missing path is not reported as denied" ;;
*) bad "a genuinely missing path is not reported as denied (got: $out)" ;;
esac
# Run the reporter over $2 from directory $1, with errexit on like the real script.
rda() {
( cd "$1" && bash -c 'set -e
. "$1"
C_ERR= C_WARN= C_DIM= C_OK= C_RST=
step() { printf "STEP|%s|%s\n" "$1" "$2"; }; substep() { :; }
setup_fail() { printf "FAIL|%s\n" "$2"; exit "$1"; }
_report_denied_ancestor "$2" "UNSLOTH_LOCAL_LLAMA_CPP_DIR"
echo "NOT_REPORTED"' _ "$WORK/helpers.sh" "$2" 2>&1 )
}
# A symlink under a denied ancestor is unstattable all the way down, so a lexical walk alone would call the build missing.
SYM="$WORK/sym"; mkdir -p "$SYM/shared/denied/build/llama.cpp" "$SYM/tmp"
ln -s "$SYM/shared/denied/build" "$SYM/tmp/local"
chmod 000 "$SYM/shared/denied"
if [ -d "$SYM/tmp/local/llama.cpp" ]; then
echo " SKIP: this host cannot make an ancestor unsearchable (running as root?)"
else
ok "the symlink target is really unreachable (negative control)"
out=$(rda "$WORK" "$SYM/tmp/local/llama.cpp")
case "$out" in
*"$SYM/shared/denied"*) ok "a symlinked build names the denied target ancestor" ;;
*) bad "a symlinked build names the denied target ancestor (got: $out)" ;;
esac
fi
chmod 755 "$SYM/shared/denied"
# A trailing slash follows the link, so "link/" is not -L: it must still report.
chmod 000 "$SYM/shared/denied"
if [ -d "$SYM/tmp/local/llama.cpp" ]; then
echo " SKIP: this host cannot make an ancestor unsearchable (running as root?)"
else
case "$(rda "$WORK" "$SYM/tmp/local/")" in
*"$SYM/shared/denied"*) ok "a trailing slash still finds the denied target" ;;
*) bad "a trailing slash still finds the denied target" ;;
esac
fi
chmod 755 "$SYM/shared/denied"
# Stripping must stop at the root instead of emptying the path.
case "$(bash -c '. "$1"; _studio_rstrip_slash "/"; printf "|"; _studio_rstrip_slash "//"' _ "$WORK/helpers.sh")" in
"/|/") ok "stripping trailing slashes never consumes the root" ;;
*) bad "stripping trailing slashes never consumes the root" ;;
esac
# A dangling symlink is genuinely missing, so it must not be reported as denied.
ln -s "$SYM/gone" "$SYM/tmp/dangle"
case "$(rda "$WORK" "$SYM/tmp/dangle/llama.cpp")" in
*NOT_REPORTED*) ok "a dangling symlink is not reported as denied" ;;
*) bad "a dangling symlink is not reported as denied" ;;
esac
# A symlink cycle must terminate on the hop cap instead of looping forever.
ln -s "$SYM/tmp/a" "$SYM/tmp/b"; ln -s "$SYM/tmp/b" "$SYM/tmp/a"
case "$(rda "$WORK" "$SYM/tmp/a/llama.cpp")" in
*NOT_REPORTED*) ok "a symlink cycle terminates without reporting" ;;
*) bad "a symlink cycle terminates without reporting" ;;
esac
# A leading-dash path must reach the reporter, not be eaten as a dirname option and abort on errexit.
DASH="$WORK/dash"; mkdir -p "$DASH"
( cd "$DASH" && mkdir -p -- "-denied/llama.cpp" && chmod 000 -- "-denied" )
if [ -d "$DASH/-denied/llama.cpp" ]; then
echo " SKIP: this host cannot make an ancestor unsearchable (running as root?)"
else
case "$(rda "$DASH" "-denied/llama.cpp")" in
*"cannot be read: permission denied"*) ok "a leading-dash path reports instead of aborting" ;;
*) bad "a leading-dash path reports instead of aborting" ;;
esac
fi
chmod 755 -- "$DASH/-denied"
echo ""
echo "=== Results ==="
echo " PASS: $PASS"