unsloth/.github/scripts/assert-llama-loads.sh
Daniel Han ce3f5c90ed
Studio: set DYLD_LIBRARY_PATH for llama-server on macOS, and classify macOS startup failures (#8574)
* Studio: give llama-server a dyld search path on macOS, and stop losing macOS startup diagnostics

_llama_server_env_for_binary branched win32 vs "everything else", and that
else branch is Linux: WSL/ROCm probing, pip nvidia wheel globs, CUDA toolkit
paths, and LD_LIBRARY_PATH. dyld ignores LD_LIBRARY_PATH, so llama-server was
launched on macOS with no library search path at all, while the installer's own
staged-binary validation does set DYLD_LIBRARY_PATH and therefore passed on a
path the real launch never took. sd_cpp_engine and stt_ggml_sidecar already map
the variable per platform; llama_cpp.py was the outlier. Add the darwin branch
plus a shared _loader_path_var(), and use it at the two other sites that
equated "not Windows" with LD_LIBRARY_PATH (the Vulkan probe and the CPU
fallback replay).

Classification was Linux-only too. dyld shares no wording with glibc, so
"Library not loaded", "Reason: tried: ... (no such file)", an invalid code
signature, an incompatible architecture and "built for macOS X which is newer
than running OS" all fell through to "check that the GGUF file is valid and you
have enough memory" - advice about a file and a resource that were never the
problem. Classify them, reusing the existing provenance-aware missing/unloadable
library messages, and keep them ahead of the returncode heuristics: a dyld
diagnostic is a fact, signal 9 is a guess. macOS also SIGKILLs a binary whose
code signature is invalid before it can print anything, so signal 9 there now
offers both readings instead of memory alone.

Finally, make the unknown-failure fallback self-diagnosing. Studio already
writes every byte of llama-server's stdout and stderr to a per-attempt log, and
already keeps the last 50 lines in memory, but the 400 response threw both away
precisely when nothing recognised the output. It now carries a bounded,
control-character-stripped output tail and the log path. With neither, the old
message is returned byte for byte.

Reported in #8566, where a Mac could load no GGUF at all and the error named the
two things that were fine.

* [pre-commit.ci] auto fixes from pre-commit.com hooks

for more information, see https://pre-commit.ci

* Pin the platform in the SIGKILL test

macOS SIGKILLs a binary with an invalid code signature the same way the OOM
killer does, so the -9 message names both readings there. The existing test
asserted the .wslconfig wording against whatever host it ran on, which made it
fail on the macOS runners. Pin it to the non-Darwin platform and assert the
two messages stay distinct.

* Update the capability-probe env test for the macOS loader path

The test simulated darwin and asserted the binary's dir landed on
LD_LIBRARY_PATH, which is the bug: probe_server_capabilities goes through
_llama_server_env_for_binary, so on macOS it ran llama-server --help with a
variable dyld ignores. Assert DYLD_LIBRARY_PATH instead, and that an inherited
LD_LIBRARY_PATH is left as the user set it.

* Address review: bound and scrub the diagnostics, judge the right dyld candidate

Six fixes from the review of the first two commits.

A stray "Reason:" anywhere in the output used to be read as dyld's, because
the capture was DOTALL and matched the first occurrence anywhere, running to
the end of the output. llama.cpp prints its own "Reason:" lines, and Studio
appends its own health-timeout marker, so the classifier could quote either
back as dyld's diagnosis, or pick a verdict from a line belonging to a
different failure. The reason is now bounded to its own line plus the indented
continuations and read only after the "Library not loaded:" it explains.

dyld4 lists every path it searched with a per-path verdict, so a mixed list is
normal: our own dylib missing, and a leftover Intel Homebrew copy under
/usr/local reporting an incompatible architecture or a policy-blocked
signature. Scanning the flattened list let the scariest entry decide, which
told users their install had been tampered with when a file was merely absent.
Parse the "tried:" list and judge the candidate in the runtime's own lib dir.

A stray "Reason:" also disabled the symbol-mismatch branch entirely, since the
lib/reason block returned instead of falling through.

"@rpath/libfoo.dylib" was passed to the shared message helpers as if it were a
path, producing "missing from that exact location" for a search directive that
is not a location. Strip the directive down to the soname.

The macOS SIGKILL message hardcoded `unsloth studio update`, which cannot touch
a pinned LLAMA_SERVER_PATH, and promised the log would say which cause it was
when a signature kill leaves the log empty. Route it through _runtime_remedy
and let it carry whatever diagnostics exist.

The output tail is llama-server's own stdout, and llama-server inherits nearly
all of Studio's environment, so a wrapper or diagnostic build echoing its env
would have put an API key in the load error. Redact credential-shaped env
values, plus bare hf_ and Bearer tokens, and slice the tail before filtering it
character by character so a runaway unterminated line is not walked in full.

Also fixes the same missing dyld search path in the RAG embedding server, which
Apple Silicon reaches through its use_gpu branch. It is the same bug as the
chat server's, in a second launcher.

* [pre-commit.ci] auto fixes from pre-commit.com hooks

for more information, see https://pre-commit.ci

* Assert Studio's llama-server launch environment in the macOS CI

The Mac job already installs a real llama.cpp and launches llama-server to
prove it loads on that host, but it launches it from the shell's own
environment, which says nothing about the one Studio builds for its child. That
environment was Linux-shaped on macOS (LD_LIBRARY_PATH, which dyld ignores), and
the installer's own staged-binary validation does set DYLD_LIBRARY_PATH, so
nothing at install time could see the defect.

Assert it here, where a real runtime is installed. A unit test with a
monkeypatched sys.platform can only prove the branch is taken; this proves the
launch environment points dyld at the dylibs that are actually on disk. Runs
under the interpreter the `unsloth` shim points at, so the backend imports
resolve without installing anything extra, and skips cleanly if no shim is on
PATH.

Verified on macos-14: fails on the pre-fix revision of llama_cpp.py, passes on
the current one.

* Cover the CPU, wrapper and probe paths the first macOS fix missed

Five follow-ups from the second review round, all consequences of the first
fix not reaching far enough.

The RAG embedding server only got the dyld search path on its GPU branch,
because it was bolted onto the CUDA helper. A CPU start (EMBED_DEVICE=cpu, or
the retry after a failed GPU start) loads the same sibling dylibs, so the path
now applies whichever device it lands on.

That server also passed Path(binary).parent as the library directory, while the
chat backend resolves the entrypoint with _llama_lib_dir. The managed install
puts an entrypoint in front of the real server, so on a normal install the
wrapper's own directory was named instead of the one holding the dylibs.

Its capability probe ran `llama-server --help` with no loader environment at
all, ahead of any launch. A bundle that needs the search path dies in the
loader there, and the error text reads as help output with no --embedding in
it, so the user was told their build lacks embedding support instead of that it
failed to load. The probe now runs under the same environment as the launch.

On macOS the launch resolves a shell entrypoint before spawning it. SIP purges
DYLD_* while starting the protected /bin/sh, so the loader path did not survive
a wrapper's exec of the real binary. The wrapper does nothing but exec the
target, so this is the same launch without the shell hop.

Secret redaction in the diagnostics tail now uses the same predicate that
decides what to strip from a managed server's own environment, plus URL
userinfo and common token shapes. The previous name-marker list missed
DATABASE_URL, REDIS_URL and GITHUB_PAT, which is exactly the class of variable
whose name says nothing about its contents.

* Read the symbol's provenance, and stop the CI check from skipping itself

Two follow-ups from the third review round.

A "Symbol not found" with no missing file was always reported as llama.cpp
libraries from different builds. That is right when dyld expected the symbol in
one of our dylibs, and wrong when it expected it in a system framework: there
the build wants a newer macOS than this one, and reinstalling the same build
cannot make the OS export the symbol. Read the "Expected in:" path and pick the
remedy from it. The MTLResidency special case is the same failure, now covered
by the general rule as well.

The macOS launch-environment assertion looked the interpreter up with
`command -v unsloth`. The clean-machine lane scrubs PATH to system directories
and puts the shim under its own UNSLOTH_STUDIO_HOME, so the lookup came back
empty exactly there and the check skipped, while the job still reported success.
It resolves from STUDIO_HOME first now, and a missing interpreter fails rather
than skips: an install that produced a llama-server but no reachable
interpreter is a broken install, and a skip is indistinguishable from a pass.

* [pre-commit.ci] auto fixes from pre-commit.com hooks

for more information, see https://pre-commit.ci

* Cover the tauri venv, the embedding entrypoint, and the launch api-key

Three follow-ups from the fourth review round.

The interpreter lookup added last round misses the tauri delivery, which nests
its venv one level deeper at $STUDIO_HOME/studio/unsloth_studio. Since the same
commit made a missing interpreter fail rather than skip, that turned a good
tauri install into a red job. Both layouts are candidates now.

The embedding server built its loader environment from the resolved directory
but still probed and launched the entrypoint itself, so on macOS SIP would take
DYLD_* away again on the way through the wrapper. It resolves the executable
once, before the capability probe, so both the probe and the spawn run the real
binary.

The startup diagnostics could echo our own --api-key. It is minted per launch
with secrets.token_urlsafe(32), so it is in no environment variable to look up
and matches no token shape; the log path already treated it as sensitive but
the API error did not. It is now redacted by its flag, and the live value is
handed to the scrubber so it goes wherever it appears.

* [pre-commit.ci] auto fixes from pre-commit.com hooks

for more information, see https://pre-commit.ci

* Scope the macOS resolution and the redaction more tightly

Five follow-ups from the fifth review round, all narrowing something the
previous rounds made too broad.

Resolving a shell entrypoint past its exec line is right for the one the
installer generates and wrong for a user's own: a custom LLAMA_SERVER_PATH
wrapper may export backend variables before it execs, and jumping to the target
would drop that silently. Resolution is now gated on the binary being ours,
through a single _exec_path_for_launch used by the chat launch, the capability
probe and the embedding backend.

That probe needed it too. Only load_model resolved, so the startup, status and
preflight routes still probed the wrapper, lost DYLD_* to SIP, and read back
inconclusive capabilities, which clamps parallel slots and disables DSpark and
DFlash sizing on a server that supports both.

Stripping only the leading directive left "@loader_path/../Frameworks/libomp.dylib"
as "../Frameworks/libomp.dylib", still path-like enough to earn the same
"missing from that exact location" advice one level in. Placeholder install
names reduce to the library name now.

The classifier is pure text and runs on every platform, so keying a macOS
diagnosis on the words "Symbol not found" claimed a Linux wrapper's plugin
error as a mixed macOS runtime, and suppressed the output tail that would have
shown what really happened. It now requires dyld's own framing.

Redaction skipped any value under eight characters, which is the right rule for
a port number and the wrong one for DATABASE_PASSWORD=hunter2. Short values
from secret-named variables are redacted where they appear beside their name,
rather than globally.

* Tighten the comments this change adds

Comment-only pass over the new code: same intent, 27 fewer lines. The
explanations of why macOS needed each of these were written while working the
problem out and read like it.

* Compare the binary revision in the path space the launch recorded

_binary_revision keys on the path string, and the macOS launch now resolves a
managed entrypoint to its target, so the changed-since-launch check compared a
resolved path against an unresolved discovery path. On a managed install, where
the entrypoint is a symlink or wrapper, those never match, so an unchanged
llama.cpp read as a fresh update and every Apply reloaded the model.

Resolve on the comparison side too. The regression test fails without the fix,
and its sibling pins that a genuine update is still detected.

* Treat a --with-llama-cpp-dir tree as the user's own

_is_unsloth_managed_binary asked only whether the binary sits under the managed
root, which a --with-llama-cpp-dir install does: setup.sh makes the canonical
llama.cpp directory a symlink to the user's checkout. The update flow already
detects that case and refuses to write through the link, so the same install
was being called managed here and unmanaged there.

Two consequences, both now fixed by consulting the same predicate the update
flow uses: the macOS launch resolved past the user's own entrypoint, dropping
whatever setup it does, and a failure told them to run an updater that declines
to touch their tree.

* Pin the inherited value instead of asserting the key is absent

The macOS embedding-env test asserted LD_LIBRARY_PATH was not in the child env
at all. That env is a copy of os.environ, so the key is there whenever the
ambient environment has it, whatever this branch does: the test passed alone
and failed in a full run. Set a sentinel and assert it survives untouched,
matching how the chat-backend test states the same invariant.

* Scope the non-macOS behaviour changes out, and bound the dyld parsing

Five independent audits of this branch, all asked the same question: is
anything outside macOS different afterwards. Everything they found that
was, is fixed here, each one reproduced against origin/main first.

Cross-platform behaviour restored:

- The macOS loader classifier ran on any output that merely contained
  'Library not loaded:' or 'Symbol not found'. llama.cpp echoes GGUF
  metadata to stderr while loading, so a model whose general.name holds
  either string got library advice, and on two paths that outranked a
  correct answer: status 127 and signal 15. Every branch now requires
  dyld's own line-anchored framing.
- _is_unsloth_managed_binary learning about --with-llama-cpp-dir also
  moved the gate on the Vulkan CPU fallback, which reads and copies the
  tree and needs no updater. Split into a second predicate so those
  installs keep the fallback on every platform.
- The embedding capability probe was given a rebuilt environment, and
  its GPU library dir was resolved through the entrypoint, on all three
  platforms. Both are macOS-only now; Linux and Windows are byte for
  byte as they were.

Correctness:

- An exact LLAMA_SERVER_PATH pin now outranks inferred ownership. A
  wrapper pinned inside a managed tree read as ours and was resolved
  past, dropping whatever it exported before its exec line.
- A quoted 'Expected in:' path failed the /System/ test, so a too-new
  build was blamed on a mismatched install.
- Older dyld says 'mach-o, but wrong architecture'; 'Bad CPU type in
  executable' never reaches dyld at all. Both were falling through.
- The signal 9 wording no longer reads as an exhaustive pair of causes.

Bounds and idempotency:

- The tried-candidate scan went quadratic on adversarial output: 100KB
  of "'a' (" took 6.3s against 0.0s on main. The reason is windowed
  before matching, capped after it, and candidates are counted.
- _with_startup_diagnostics recognises its own output, so a second call
  cannot double the tail. One call site exists, so this cannot fire yet.
- One shared loader-path prepend, deduplicating on the normalised
  spelling, and the embedding one returns a new dict instead of editing
  the caller's.
- Bearer matching covers the full token68 alphabet and Basic.

Verified by differential probes against origin/main: 1080 child-env
scenarios over platform x GPU vendor x install layout x inherited
environment differ on darwin only, and 1392 classifier scenarios differ
only where dyld is genuinely the cause or the generic fallback gains its
new output tail.

* [pre-commit.ci] auto fixes from pre-commit.com hooks

for more information, see https://pre-commit.ci

* Make three loader tests independent of the host they run on

They simulate a POSIX platform by monkeypatching sys.platform, but
os.path and pathlib stay the host's, so on the Windows runner
Path('/opt/llama/bin/llama-server').resolve() came back drive-anchored
and splitting an LD_LIBRARY_PATH on ':' cut 'C:\...' after the drive
letter. Product behaviour is not involved: the darwin branch never runs
on Windows, and the Linux branch's own separator is correct there.

Paths now come from tmp_path, and the ordering assertion uses startswith
rather than splitting on a character that appears inside Windows paths.

* Stub the install-tree gate where the CPU replay tests stub provenance

_cpu_isolated_binary now asks _is_llama_install_tree rather than
_is_unsloth_managed_binary, and three fixtures stub only the latter, so
the gate fell through to the real _llama_install_root on a tmp_path with
no install marker and every staged-runtime test got None.

Product behaviour is not involved. Checked directly against origin/main
on four trees: a marked install, a --with-llama-cpp-dir link, an
unmarked directory and a binary pinned outside any install. The new gate
returns what main's gate returned in all four, including restoring True
for the link, which is the case this PR was fixing.

* Use the Sequence import instead of hiding it in a string annotation

verify_import_hoist flagged Sequence as added-but-unused: the three
annotations that need it are quoted, so nothing references the name at
module scope. Both Sequence and Optional are unconditional imports here,
so the quotes bought nothing. Unquoting them makes the import real.

scripts/verify_import_hoist.py --before origin/main --after now reports
PASS rather than OVERALL: FAIL.

* Follow a wrapper chain, and make re-classification a fixed point

Two findings from a second round of ten independent audits, both
reproduced against origin/main first.

_resolve_llama_binary followed one hop. A wrapper whose target is
another wrapper resolved to the intermediate script, which on macOS is
the very defect this fix exists to avoid (SIP drops DYLD_* through the
shell) and also handed _llama_lib_dir the wrapper's directory instead of
the one holding the dylibs. It now follows the chain, bounded, and stops
on a repeat so a pair pointing at each other cannot spin.

_with_startup_diagnostics guarded its "message" argument, but the
composition that actually grows is feeding a classified result back in
as the child "output": 222 -> 413 -> 604 characters over three passes,
and a specific dyld diagnosis downgraded to the generic fallback. The
detector is now shared and applied to both sides. A classified message
carries no marker of its own, so one wrap still happens before it
settles; tagging the return type to close that too is a bigger change to
a widely used error path than an unreachable property justifies, and the
test says so.

No caller feeds either composition today. These keep stated properties
true rather than fixing live bugs.

* Skip only the entrypoint the installer wrote, and redact quoted secrets

Two P2 items from this round, both reproduced against head first.

_llama_install_root treats the directory named by UNSLOTH_LLAMA_CPP_PATH as
the active install with no marker file needed, so provenance alone answered
"ours" for a wrapper at the root of a user's own checkout, and launching its
target dropped whatever it exported. The LLAMA_SERVER_PATH pin added earlier
covers only the case where they name the file. install_llama_prebuilt writes a
fixed three-line wrapper, so that exact shape, a symlink, or a real executable
is now the condition for resolving past an entrypoint; anything else is theirs
and is launched as written. _resolve_llama_binary keeps resolving any wrapper,
because _llama_lib_dir needs the directory holding the dylibs and that is the
target's directory whoever wrote the script.

The name-adjacent rule for environment values under eight characters matched
only a bare NAME=value, so DB_PASSWORD='hunter2' and the JSON form went into
the startup-output tail intact. It now allows quotes around the name and the
value, requiring the same quote on both sides so an unrelated neighbouring
quote cannot be swallowed.

Verified before and after on a markerless custom directory and on five env-dump
spellings; 680 tests pass.

* [pre-commit.ci] auto fixes from pre-commit.com hooks

for more information, see https://pre-commit.ci

* Widen the tail window by the longest secret before redacting

_scrub_secret_values matches a whole value, so a credential longer than the
8000-character prefilter lost its head to the slice that runs before it, and
the surviving suffix matched neither the literal nor any token shape. A PEM key
or a service-account blob dumped by a wrapper therefore reached the API error.
Reproduced with a 12010-character MY_PRIVATE_KEY: the marker at its end came
through before, and does not now.

The window is widened by the longest value the scrubber could be asked to
match, capped at 256KB so a pathological environment value cannot turn a
bounded slice into a scan of everything the child printed. The final 2000
character bound on the emitted tail is unchanged.

Worth recording: a secret SHORTER than the window cannot straddle its boundary,
since a value whose end is inside a window wider than itself has its start
inside too. Only values longer than the window were ever at risk, and a test
pins that reasoning.

* [pre-commit.ci] auto fixes from pre-commit.com hooks

for more information, see https://pre-commit.ci

* Decide on the wrapper's shape alone, not on how it was reached

The exact LLAMA_SERVER_PATH pin added two rounds ago short-circuited
resolution outright, to protect a custom wrapper's setup. That was too broad.
Pointing LLAMA_SERVER_PATH at the installer's own entrypoint is a supported
configuration, and refusing to resolve it just because it was named put the
launch back through /bin/sh, where SIP drops DYLD_* before the real binary
runs, reproducing #8566 for that setup and for the capability and embedding
probes with it.

_is_installer_entrypoint already answers the real question, so the pin check
is gone. An installer-template wrapper does nothing but exec its target, so
resolving past one is never a loss however it was reached; anything else is
somebody's own script and is launched as written however it was reached.

Reproduced inside a marked install tree before and after: a pinned template
wrapper now resolves to build/bin/llama-server, a pinned custom wrapper is
still launched as written. 684 tests pass, and the 1080-scenario child
environment differential still differs from main on darwin only.

* [pre-commit.ci] auto fixes from pre-commit.com hooks

for more information, see https://pre-commit.ci

* Never trust the child's output for being our own framing

_classify_llama_start_failure returned the child's stdout unchanged when
that stdout carried the framing _with_startup_diagnostics writes. I added
that early return two rounds ago to keep re-classification a fixed point.
It was a bad trade: the guard reads untrusted text, so a wrapper or a
diagnostic build whose first line happens to be "llama-server output:" or
"Full log: " had its whole stdout returned as the API error, past the
redaction and past the 2000-character cap.

Measured on the branch before this commit, with an environment dump behind
the heading: 50067 characters returned verbatim with the credential intact
and no log path, against 2136 characters, scrubbed, for the same bytes
without the heading.

Removed the early return. The only guard on our own framing is the one in
_with_startup_diagnostics, which reads the message this module built
itself, and that one is safe.

The property this drops was for a caller that does not exist. What
replaces it is the property that actually matters and is now pinned by a
test: repeated passes stay bounded. Each pass wraps the previous message
in a fresh tail, roughly 136 characters, and the tail cap stops it at
2136 no matter how many times it runs.

Tests: the fixed-point test is replaced by a bounded-growth test plus a
parametrised regression test covering all four ways the heading can
appear. 141 tests in the classification suite, 688 across the changed
areas.

* Do not let the child's output rewrite the diagnosis

Attaching a tail of llama-server's own stdout to the failure message had a
consequence I missed. The inference route scans the whole error string for
unsupported-model phrases:

    _NOT_SUPPORTED_HINTS = ("No config file found", "not yet supported",
                            "is not supported", "does not support")

Those now match inside the quoted child output. So a llama-server that
prints a line like "ggml_vulkan: device Intel(R) UHD does not support
16-bit storage" gets its failure rewritten to "This model is not supported
yet. Try a different model." The model was fine; the message sends the
user to replace it.

llama.cpp prints lines of that shape for ordinary reasons, so this was
reachable on Linux and Windows as much as on macOS. Reproduced on the
branch before this commit with three different phrasings.

_diagnosis_text() cuts the message at the diagnostics block, and both the
unsupported-model matcher and the NVFP4 matcher now decide on the part
this backend wrote. The evidence still reaches the user, and is still
quoted in full when a rewrite does happen; it just no longer votes on the
diagnosis. A message with no diagnostics block is returned unchanged, so
every other error source is untouched.

Tests: 11 new, covering four real llama.cpp phrasings, the same phrase in
our own text (still rewritten), our text carrying a tail (still rewritten,
evidence preserved), the NVFP4 matcher, and four shapes that must pass
through unchanged. 710 tests pass across the changed areas.

Whole backend suite against origin/main: 244 failures on each side, the
same 244, no regressions.

* [pre-commit.ci] auto fixes from pre-commit.com hooks

for more information, see https://pre-commit.ci

* Redact by name as well as by value, and bound the library name

Two findings from a fresh adversarial pass over the diagnostics tail, both
reproduced on the branch before this commit.

Redaction missed an encoded secret. _scrub_secret_values matched the
credential's literal text, so it only worked when the child printed the
value verbatim. A wrapper dumping its environment as JSON prints

    DB_PASSWORD = pa"ss\word-12345

as

    {"DB_PASSWORD": "pa\"ss\\word-12345"}

which is a different string. The escaped form went into the API error
fully reconstructible. URL-encoding it did the same.

_redact_secret_assignments replaces whatever sits beside a secret-looking
NAME, in the three shapes an environment dump takes: shell-ish, JSON and
bare. Positional, so the encoding does not matter, and it also covers a
value we never set and therefore could not have matched on. The old
value pass stays, because it catches a credential under a name that does
not look like one. The remaining gap is an unrecognised name AND an
escaped value.

Checked against over-redaction, which would defeat the point of the tail:
PATH, port, n_ctx, model_path, timings and llama_model_loader lines are
all untouched. Both regex arms are unambiguous single-character
alternations, so the pass is linear; 200000-character pathological inputs
run in 7 to 52 ms.

The library name was unbounded. A dyld install name is a path and macOS
PATH_MAX is 1024, but nothing enforced that on untrusted output: a
200093-character input produced a 100444-character HTTP error. Capped at
2048 with an ellipsis, which cannot truncate anything the loader could
have produced. A real name is unchanged.

Tests: 22 new across encoded secrets, the never-set value, six
over-redaction controls, four linearity checks and the name cap. 722 pass
across the changed areas. The 1392-scenario classifier differential
against origin/main is unchanged: 540 non-dyld differences, all still
strictly additive, 0 violations.

* [pre-commit.ci] auto fixes from pre-commit.com hooks

for more information, see https://pre-commit.ci

* Redact every classified branch, fix the quoted arm, stop at custom wrappers

Three findings, all reproduced on the branch before this commit. Two of
the three are defects in code I added earlier in this PR.

Redaction only covered the tail. _scrub_secret_values ran inside
_with_startup_diagnostics, so it protected the fallback and nothing else.
Every other branch also quotes untrusted text: a dyld message names the
library it could not load, and that name comes from the child. A
credential appearing there went out in the API error while the same
credential in the tail was starred out. Verified with a token as the
library name: leaked before, redacted now.

The fix is one boundary rather than one guard per interpolation, which is
the arrangement that let this through in the first place. The classifier
body moves to _classify_start_failure_text and the public function scrubs
whatever comes back. Scrubbing twice is a no-op, since a redacted value no
longer matches.

The quoted arm ended on either quote character. So a JSON value holding an
apostrophe, or a shell value holding a quote, failed to match, fell
through to the bare arm, and that arm stops at whitespace:

    {"DB_PASSWORD": "prefix' supersecret"}

left ` supersecret"` standing in the API error. The arm now closes on the
delimiter it opened with. Still linear, still an unambiguous alternation.

Launch resolution stepped over somebody else's wrapper. Approving the
outer entrypoint says nothing about what it points at, so an
installer-shaped symlink whose target was a hand-written wrapper had that
wrapper skipped on macOS, along with any exports it set, even though
running the symlink directly would have executed it. _resolve_llama_binary
grows a template_only flag: launching stops at the first link that is not
the installer's own template, while the library-directory lookup keeps
following the whole chain, because that is where the dylibs sit whoever
wrote the links. Pinned by a test on each of the three shapes.

Tests: 9 new. 731 pass across the changed areas, 1482 across every
llama.cpp and secret-handling suite. Both differentials against
origin/main are unchanged: 1392 classifier scenarios with 540 non-dyld
differences, all still strictly additive and 0 violations, and 1080 env
scenarios differing on darwin only.

* [pre-commit.ci] auto fixes from pre-commit.com hooks

for more information, see https://pre-commit.ci

* Close three more holes in the name-anchored redaction

All three reproduced on the previous head. Each is reachable by a wrapper
or crash handler that prints its own configuration rather than the
environment we can match values against, which is the case the name pass
exists to cover and where it was weakest.

An unterminated quoted value leaked its tail. A truncated line ends the
output mid-value, so

    DB_PASSWORD="prefix supersecret

has no closing delimiter, cannot match the terminated arm, and fell to the
bare arm, which stops at whitespace and left ` supersecret` behind. A
third arm now runs an opened-but-unclosed quote to the end of the line,
ordered after the terminated arm so a properly closed value still prefers
that one. The opening delimiter is kept and no closing one is invented,
since that would misreport what the child printed. Only the value's line
is consumed; a following line survives.

A config-style key was never tested. The name accepted shell identifiers
only, so `db-password` and `api-key` never reached is_secret_env_name at
all. The name now accepts hyphens and dots, and the separator is
normalised to an underscore before the predicate is asked, because the
predicate's markers are underscored (API_KEY, PRIVATE_KEY). The predicate
stays the one place that decides what counts as a secret.

Checked hard against over-redaction, since a wider name is the obvious way
to make this worse: model-path, ggml.backend, n-gpu-layers, cache.type-k,
llama_model_loader and timing lines are all untouched.

Overlapping literals masked each other. With TOKEN_A=abcdefgh and
TOKEN_B="abcdefgh VERYSECRET", replacing the short value first rewrote the
long one's only occurrence to "*** VERYSECRET", after which the long value
no longer matched itself and its tail went out. The minted secrets and the
environment's own values are now collected into one set and replaced
longest first, so a credential cannot partially mask another before it is
scrubbed. Order-independent, verified both ways round.

Tests: 20 new. 185 in the classification suite, 1715 across every
llama.cpp and secret-handling suite. Both differentials against
origin/main unchanged: 540 non-dyld classifier differences, all strictly
additive, 0 violations; 1080 env scenarios differing on darwin only.
Linearity re-checked on the widened pattern: 200000-character inputs in 15
to 61 ms.

* [pre-commit.ci] auto fixes from pre-commit.com hooks

for more information, see https://pre-commit.ci

---------

Co-authored-by: pre-commit-ci[bot] <66853113+pre-commit-ci[bot]@users.noreply.github.com>
2026-08-13 06:32:35 -07:00

109 lines
5.1 KiB
Bash
Executable file

#!/usr/bin/env bash
# SPDX-License-Identifier: AGPL-3.0-only
# Copyright 2026-present the Unsloth AI Inc. team. All rights reserved.
#
# Assert Unsloth installed a llama.cpp that loads and runs on THIS macOS. Tests
# the contract that matters (binaries load and their minimum-OS is <= this host)
# instead of the old "did install.sh fall back to a source build?" grep, since a
# source build with a correct deployment target is a valid outcome.
set -uo pipefail
UNSLOTH_HOME="${STUDIO_HOME:-$HOME/.unsloth}"
LLAMA_DIR="${LLAMA_CPP_DIR:-$UNSLOTH_HOME/llama.cpp}"
BIN_DIR="$LLAMA_DIR/build/bin"
fail() {
echo "::error::$*"
if [ -f logs/install.log ]; then
echo "---- install.log (llama.cpp lines) ----"
grep -E "llama-prebuilt|llama\.cpp|macos prebuilt|falling back" logs/install.log | tail -80 || true
fi
exit 1
}
SERVER="$(find "$LLAMA_DIR" -type f -name 'llama-server' 2>/dev/null | head -1)"
QUANT="$(find "$LLAMA_DIR" -type f -name 'llama-quantize' 2>/dev/null | head -1)"
[ -n "$SERVER" ] || fail "llama-server not found under $LLAMA_DIR after install"
[ -n "$QUANT" ] || fail "llama-quantize not found under $LLAMA_DIR after install"
HOST_VER="$(sw_vers -productVersion 2>/dev/null || echo '0')"
HOST_MAJOR="${HOST_VER%%.*}"
# Static minimum-OS check on every Mach-O we ship. vtool ships with the Xcode
# command line tools, which GitHub macOS runners always have; if it is somehow
# missing we skip the static check and rely on the runtime launch below.
if command -v vtool >/dev/null 2>&1; then
while IFS= read -r macho; do
[ -n "$macho" ] || continue
minos="$(vtool -show-build "$macho" 2>/dev/null | awk '/minos/{print $2; exit}')"
[ -n "$minos" ] || continue
min_major="${minos%%.*}"
if [ "$min_major" -gt "$HOST_MAJOR" ] 2>/dev/null; then
fail "$(basename "$macho") is built for macOS $minos but this runner is macOS $HOST_VER (prebuilt is newer than the host)"
fi
done < <(find "$BIN_DIR" -type f \( -name '*.dylib' -o -name 'llama-server' -o -name 'llama-quantize' \) 2>/dev/null)
fi
# Runtime launch: --version forces dyld to load every linked dylib (including
# libggml-metal.dylib). A missing Metal symbol or too-new binary fails here.
if ! "$SERVER" --version >/tmp/llama-server-version.txt 2>&1; then
echo "---- llama-server --version output ----"
cat /tmp/llama-server-version.txt || true
fail "llama-server failed to launch on macOS $HOST_VER (dyld load / symbol error)"
fi
# The launch above uses this shell's environment, not the one Studio builds for
# its child. That one was Linux-shaped on macOS (LD_LIBRARY_PATH, which dyld
# ignores) while the installer's own validation set DYLD_LIBRARY_PATH, so the
# defect could not show up at install time (#8566). A unit test with a
# monkeypatched sys.platform cannot prove the real thing; this can.
# Resolve the interpreter from STUDIO_HOME first, not from PATH. The
# clean-machine lane scrubs PATH down to system directories and puts the shim
# under its own UNSLOTH_STUDIO_HOME, so `command -v unsloth` is empty there and
# a PATH-only lookup would skip this assertion in the one lane whose whole
# point is a clean install, while CI still reported success. The tauri delivery
# nests its venv one level deeper (clean-machine-install-ci.yml checks
# $HOME_DIR/studio/unsloth_studio), so both layouts are candidates.
STUDIO_PY=""
for candidate in \
"$UNSLOTH_HOME/unsloth_studio/bin/python" \
"$UNSLOTH_HOME/studio/unsloth_studio/bin/python" \
"$UNSLOTH_HOME/.venv/bin/python" \
"$HOME/.unsloth/unsloth_studio/bin/python"; do
[ -x "$candidate" ] && { STUDIO_PY="$candidate"; break; }
done
if [ -z "$STUDIO_PY" ]; then
for shim in "$UNSLOTH_HOME/bin/unsloth" "$(command -v unsloth || true)"; do
[ -n "$shim" ] && [ -x "$shim" ] || continue
candidate="$(head -1 "$shim" | sed 's/^#!//' | awk '{print $1}')"
[ -n "$candidate" ] && [ -x "$candidate" ] && { STUDIO_PY="$candidate"; break; }
done
fi
# Fail rather than skip: an install that produced a llama-server but no
# reachable interpreter is itself a broken install, and a skip here is
# indistinguishable from a pass.
[ -n "$STUDIO_PY" ] || fail "no Unsloth interpreter found under $UNSLOTH_HOME or on PATH; cannot check the launch environment"
if [ -n "$STUDIO_PY" ]; then
if ! PYTHONPATH=studio/backend "$STUDIO_PY" - "$SERVER" <<'PY'
import os, sys
from core.inference.llama_cpp import LlamaCppBackend, _llama_lib_dir
binary = sys.argv[1]
lib_dir = str(_llama_lib_dir(binary))
env = LlamaCppBackend._llama_server_env_for_binary(binary)
got = env.get("DYLD_LIBRARY_PATH", "")
print(f"DYLD_LIBRARY_PATH: {got or '<unset>'}")
if not got:
sys.exit("Studio would launch llama-server with no DYLD_LIBRARY_PATH; dyld ignores LD_LIBRARY_PATH")
if got.split(os.pathsep)[0] != lib_dir:
sys.exit(f"expected {lib_dir} first on DYLD_LIBRARY_PATH, got {got}")
print("child launch environment is correct for dyld")
PY
then
fail "Studio's llama-server launch environment is wrong for macOS (see above)"
fi
fi
echo "llama.cpp load validation passed on macOS $HOST_VER"
echo " server: $SERVER"
sed -n '1,4p' /tmp/llama-server-version.txt 2>/dev/null || true