From f76a7fdb4ec1e00fb0acae61963f0bb7cc3c0c60 Mon Sep 17 00:00:00 2001 From: danielhanchen Date: Tue, 11 Aug 2026 12:13:48 +0000 Subject: [PATCH 1/6] Desktop: stop the loopback client following redirects `loopback_http::client` is the client that posts `.desktop_secret` to /api/auth/desktop-login, and it was built without a redirect policy. reqwest follows up to 10 redirects by default, and its cross-host protection strips headers rather than bodies, so a responder answering 307 (which preserves the method and the body) would carry the secret to whatever the Location header names, after the loopback URL had already been checked. Its sibling `streaming_client` already refuses redirects for exactly this reason: "Redirects are refused so a loopback URL cannot be bounced off-host after the check." Give `client` the same policy. No behaviour change for any real backend, which never redirects these routes. --- studio/src-tauri/src/loopback_http.rs | 41 +++++++++++++++++++++++++++ 1 file changed, 41 insertions(+) diff --git a/studio/src-tauri/src/loopback_http.rs b/studio/src-tauri/src/loopback_http.rs index 2dec4a633d1..16536849057 100644 --- a/studio/src-tauri/src/loopback_http.rs +++ b/studio/src-tauri/src/loopback_http.rs @@ -1,9 +1,15 @@ use std::time::Duration; +/// Redirects are refused for the same reason `streaming_client` refuses them, and it matters +/// more here: this is the client that posts `.desktop_secret` to `/api/auth/desktop-login`. +/// reqwest follows up to 10 redirects by default, and its cross-host protection strips headers, +/// not bodies, so a responder answering 307 would carry the secret off-host after the loopback +/// URL had already been checked. pub(crate) fn client(timeout: Duration) -> Result { reqwest::Client::builder() .no_proxy() .timeout(timeout) + .redirect(reqwest::redirect::Policy::none()) .build() } @@ -77,4 +83,39 @@ mod tests { assert!(response.status().is_success()); server.join().unwrap(); } + + /// The desktop secret rides this client to /api/auth/desktop-login. A 307 + /// preserves the method and body, so a followed redirect would hand the + /// secret to whatever the Location header names. Refuse instead: the caller + /// sees the 307 itself and treats it as a failed login. + #[test] + fn a_redirect_is_returned_not_followed() { + let listener = TcpListener::bind("127.0.0.1:0").unwrap(); + let port = listener.local_addr().unwrap().port(); + let server = std::thread::spawn(move || { + let (mut stream, _) = listener.accept().unwrap(); + let mut discard = [0_u8; 2048]; + let _ = stream.read(&mut discard); + let _ = stream.write_all( + b"HTTP/1.1 307 Temporary Redirect\r\nLocation: http://evil.test/collect\r\n\ + Content-Length: 0\r\nConnection: close\r\n\r\n", + ); + }); + + let runtime = tokio::runtime::Runtime::new().unwrap(); + let response = runtime.block_on(async { + super::client(Duration::from_secs(2)) + .unwrap() + .post(format!("http://127.0.0.1:{port}/api/auth/desktop-login")) + .json(&serde_json::json!({ "secret": "desktop-not-a-real-secret" })) + .send() + .await + .unwrap() + }); + + assert_eq!(response.status().as_u16(), 307); + // Still the port we dialled: nothing was re-sent anywhere else. + assert_eq!(response.url().port(), Some(port)); + server.join().unwrap(); + } } From 24389585f0848d10a3d7bd3656290f93b9bf1b64 Mon Sep 17 00:00:00 2001 From: Wasim Yousef Said Date: Tue, 11 Aug 2026 20:26:24 +0200 Subject: [PATCH 2/6] Suppress macOS uv developer tools dialog --- .github/scripts/clean-machine-assert.sh | 114 ++++++- .github/scripts/clean-machine-env.sh | 34 ++- .../clean-machine-install-name-tool.sh | 94 ++++++ .../workflows/clean-machine-install-ci.yml | 72 ++++- .../desktop-app-clean-machine-ci.yml | 40 ++- install.sh | 79 ++++- ..._clean_machine_install_name_tool_assert.sh | 143 +++++++++ tests/sh/test_install_python_guard.sh | 6 +- tests/sh/test_mac_intel_compat.sh | 21 +- .../test_macos_uv_install_name_tool_guard.sh | 287 ++++++++++++++++++ tests/sh/test_macos_venv_python_preference.sh | 8 +- 11 files changed, 849 insertions(+), 49 deletions(-) create mode 100755 .github/scripts/clean-machine-install-name-tool.sh create mode 100755 tests/sh/test_clean_machine_install_name_tool_assert.sh create mode 100755 tests/sh/test_macos_uv_install_name_tool_guard.sh diff --git a/.github/scripts/clean-machine-assert.sh b/.github/scripts/clean-machine-assert.sh index 40d495634d0..fe88798ac7c 100755 --- a/.github/scripts/clean-machine-assert.sh +++ b/.github/scripts/clean-machine-assert.sh @@ -7,7 +7,10 @@ # absent The toolchain really was absent for the whole run. Catches a leg that # "passed" because masking silently failed, or because the installer # quietly installed Xcode CLT behind our back. -# notools The trace recorded no compiler/git/brew invocation (trace mode). +# notools The trace recorded no compiler/git/brew invocation (trace mode), +# except uv's exact optional libpython self-ID operation. +# nodylibtool No install_name_tool invocation escaped the CLT-absent guard. +# dylibpatch A CLT-present control observed only exact libpython self-ID patches. # nobuild Wheels-only: no "Building wheel" from pip, no "Building ==" # from uv. Needs UNSLOTH_VERBOSE=1, or run_install_cmd # (install.sh:193-243) discards uv's output on success. @@ -15,7 +18,7 @@ # Mach-O MAIN EXECUTABLE is signed. Closes the Rosetta 2 gap, the one # divergence masking cannot reproduce. # -# Usage: bash .github/scripts/clean-machine-assert.sh absent notools nobuild macho +# Usage: bash .github/scripts/clean-machine-assert.sh absent nodylibtool notools dylibpatch nobuild macho set -uo pipefail LOG="${INSTALL_LOG:-logs/install.log}" @@ -25,6 +28,50 @@ rc=0 fail() { echo "::error::$*"; rc=1; } ok() { echo "[assert] OK $*"; } +_decode_trace_arg() { # encoded, destination variable + _encoded=$1 + case "$_encoded" in h*) _hex=${_encoded#h} ;; *) return 1 ;; esac + case "$_hex" in *[!0123456789abcdef]* ) return 1 ;; esac + [ $(( ${#_hex} % 2 )) -eq 0 ] || return 1 + _decoded="" + while [ -n "$_hex" ]; do + _rest=${_hex#??} + _pair=${_hex%"$_rest"} + _hex=$_rest + printf -v _byte '%b' "\\x$_pair" + _decoded+=$_byte + done + printf -v "$2" '%s' "$_decoded" +} + +_is_uv_libpython_self_id_patch() { # argc, operation, source, destination, extra + [ "$1" = "3" ] && [ "$2" = "-id" ] && [ -n "$3" ] && [ "$3" = "$4" ] \ + && [ -z "$5" ] || return 1 + _patch_name=${3##*/} + case "$_patch_name" in libpython*.dylib) ;; *) return 1 ;; esac + _patch_dir=${3%/*} + + if [ -n "${UV_PYTHON_INSTALL_DIR:-}" ]; then + _patch_root=${UV_PYTHON_INSTALL_DIR%/} + else + # Default uv data locations end in uv/python; this fallback keeps the assertion + # useful outside CI, where UV_PYTHON_INSTALL_DIR is normally unset. + case "$3" in */uv/python/*) ;; *) return 1 ;; esac + _patch_root=${3%%/uv/python/*}/uv/python + fi + + # Resolve both directories physically before comparing them. A lexical shell glob + # would accept "$root/x/../../outside/..." (and symlink escapes) because * spans '/'. + _patch_root=$(CDPATH= cd "$_patch_root" 2>/dev/null && pwd -P) || return 1 + _patch_dir=$(CDPATH= cd "$_patch_dir" 2>/dev/null && pwd -P) || return 1 + case "$_patch_dir" in "$_patch_root"/*/lib) ;; *) return 1 ;; esac + _patch_install=${_patch_dir#"$_patch_root"/} + _patch_install=${_patch_install%/lib} + [ -n "$_patch_install" ] || return 1 + case "$_patch_install" in */*) return 1 ;; esac + return 0 +} + for check in "$@"; do case "$check" in @@ -63,6 +110,52 @@ for check in "$@"; do fi ;; + nodylibtool) + if [ -z "$TRACE" ] || [ ! -f "$TRACE" ]; then + fail "nodylibtool requested but no trace file (\$UNSLOTH_TOOL_TRACE=$TRACE)" + else + _dylib_hits=0 + while IFS=$'\t' read -r tool _rest; do + [ "$tool" = "install_name_tool" ] && _dylib_hits=$((_dylib_hits + 1)) + done < "$TRACE" + if [ "$_dylib_hits" -ne 0 ]; then + fail "install_name_tool escaped the CLT-absent uv guard ($_dylib_hits invocation(s))" + grep '^install_name_tool[[:space:]]' "$TRACE" | head -20 || true + else + ok "install_name_tool was never reached on the CLT-absent path" + fi + fi + ;; + + dylibpatch) + if [ -z "$TRACE" ] || [ ! -f "$TRACE" ]; then + fail "dylibpatch requested but no trace file (\$UNSLOTH_TOOL_TRACE=$TRACE)" + else + _dylib_hits=0 + _dylib_bad=0 + while IFS=$'\t' read -r tool argc operation_encoded source_encoded destination_encoded extra; do + [ "$tool" = "install_name_tool" ] || continue + _dylib_hits=$((_dylib_hits + 1)) + operation=""; source=""; destination="" + if ! _decode_trace_arg "$operation_encoded" operation \ + || ! _decode_trace_arg "$source_encoded" source \ + || ! _decode_trace_arg "$destination_encoded" destination \ + || ! _is_uv_libpython_self_id_patch "$argc" "$operation" "$source" "$destination" "$extra"; then + _dylib_bad=$((_dylib_bad + 1)) + echo "::error::invalid install_name_tool trace record: $tool argc=$argc" + fi + done < "$TRACE" + if [ "$_dylib_hits" -eq 0 ]; then + fail "CLT-present control recorded no install_name_tool patch; managed Python may have been reused" + elif [ "$_dylib_bad" -ne 0 ]; then + fail "$_dylib_bad of $_dylib_hits install_name_tool invocation(s) were not exact libpython self-ID patches" + else + ok "all $_dylib_hits install_name_tool invocation(s) were exact libpython self-ID patches" + fi + fi + ;; + + notools) if [ -z "$TRACE" ] || [ ! -f "$TRACE" ]; then fail "notools requested but no trace file (\$UNSLOTH_TOOL_TRACE=$TRACE)" @@ -71,13 +164,26 @@ for check in "$@"; do # leg allow-lists it via UNSLOTH_ALLOW_TOOLS. allow="${UNSLOTH_ALLOW_TOOLS:-}" hits="" - while IFS=$'\t' read -r tool rest; do + while IFS=$'\t' read -r tool argc_or_rest arg1 arg2 arg3 extra; do [ -n "$tool" ] || continue + # This optional uv operation is the only permitted developer-tool use. Keep it + # structural rather than name-only: arbitrary install_name_tool calls still fail. + if [ "$tool" = "install_name_tool" ]; then + operation=""; source=""; destination="" + if _decode_trace_arg "$arg1" operation \ + && _decode_trace_arg "$arg2" source \ + && _decode_trace_arg "$arg3" destination \ + && _is_uv_libpython_self_id_patch "$argc_or_rest" "$operation" "$source" "$destination" "$extra"; then + continue + fi + hits="$hits $tool" + continue + fi case " $allow " in *" $tool "*) continue ;; esac # `xcode-select -p` only ASKS whether a toolchain is selected and the fix is # carrying on without one, so it is not USE. `--install` stays a hit. if [ "$tool" = "xcode-select" ]; then - case "$rest" in + case "$argc_or_rest" in -p|--print-path|-v|--version|"") continue ;; esac fi diff --git a/.github/scripts/clean-machine-env.sh b/.github/scripts/clean-machine-env.sh index 82bd329ddfc..2f2387a9f07 100755 --- a/.github/scripts/clean-machine-env.sh +++ b/.github/scripts/clean-machine-env.sh @@ -8,8 +8,11 @@ # # mask Make the toolchain genuinely ABSENT: scrub PATH to OS defaults and (with # --remove) move the real toolchain aside so `command -v git` correctly -# FAILS. Deliberately no "poison shims": a failing shim is still FOUND by -# `command -v`, which reports the tool as present, the opposite of clean. +# FAILS. Deliberately no general "poison shims": a failing shim is still FOUND +# by `command -v`, which reports the tool as present, the opposite of clean. +# macOS has one observation-only exception: install_name_tool gets a logging +# sentinel because the installer must shadow Apple's dialog-producing shim and +# never uses this command to decide whether a dependency is installed. # trace Leave the toolchain working behind wrappers that log the call then exec # the real binary, answering whether the installer ever REACHES for a # compiler/git without changing behaviour. @@ -23,6 +26,9 @@ # source ./clean-machine.env set -uo pipefail +SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)" +INSTALL_NAME_TOOL_HELPER="$SCRIPT_DIR/clean-machine-install-name-tool.sh" + MODE="${1:-}" REMOVE=0 [ "${2:-}" = "--remove" ] && REMOVE=1 @@ -44,8 +50,9 @@ mkdir -p "$BIN" printf '#!/usr/bin/env bash\n# Undo clean-machine-env.sh --remove. Safe to run twice.\nset -uo pipefail\n' > "$RESTORE" chmod +x "$RESTORE" -# The toolchain we care about: a consumer install must need none of it. -TOOLS="xcode-select xcrun clang clang++ cc c++ gcc g++ git cmake make brew ninja cargo rustc" +# The toolchain we care about: a consumer install must need none of it. uv's optional +# install_name_tool self-ID patch is observed separately and narrowly allow-listed. +TOOLS="xcode-select xcrun clang clang++ cc c++ gcc g++ git cmake make brew ninja cargo rustc install_name_tool" note() { echo "[clean-machine] $*"; } @@ -85,6 +92,14 @@ scrub_path() { # ── mask ────────────────────────────────────────────────────────────────────── if [ "$MODE" = "mask" ]; then NEWPATH="$(scrub_path)" + if [ "$OS" = "Darwin" ]; then + # Do not execute /usr/bin/install_name_tool as a self-test on a CLT-free Mac: that + # is the GUI prompt this lane exists to prevent. This sentinel is ahead of /usr/bin, + # logs argv with explicit argc and hex-encoded argument boundaries, and fails without + # touching a dylib. install.sh's still-more-local uv guard must win over it. + bash "$INSTALL_NAME_TOOL_HELPER" write sentinel "$BIN/install_name_tool" + NEWPATH="$BIN:$NEWPATH" + fi { echo "export PATH='$NEWPATH'" # UNSET, not a fake path: `xcode-select -p` honours DEVELOPER_DIR and prints it @@ -94,6 +109,8 @@ if [ "$MODE" = "mask" ]; then echo "unset SDKROOT CC CXX CFLAGS CXXFLAGS LDFLAGS CMAKE_GENERATOR CMAKE_PREFIX_PATH || true" echo "export HOMEBREW_NO_AUTO_UPDATE=1" echo "export UNSLOTH_CLEAN_MACHINE=1" + + echo "export UNSLOTH_TOOL_TRACE='$TRACE'" } >> "$ENV_FILE" if [ "$REMOVE" = "1" ] && [ "$OS" = "Darwin" ]; then @@ -210,12 +227,17 @@ if [ "$MODE" = "trace" ]; then real="$(command -v "$tool" 2>/dev/null || true)" [ -n "$real" ] || continue # Logs then execs the REAL binary, so behaviour is unchanged and the trace answers - # "did the installer reach for this?" honestly. - cat > "$BIN/$tool" < "$BIN/$tool" <> "$TRACE" exec "$real" "\$@" WRAP + fi chmod +x "$BIN/$tool" done { diff --git a/.github/scripts/clean-machine-install-name-tool.sh b/.github/scripts/clean-machine-install-name-tool.sh new file mode 100755 index 00000000000..06a4e24d06f --- /dev/null +++ b/.github/scripts/clean-machine-install-name-tool.sh @@ -0,0 +1,94 @@ +#!/usr/bin/env bash +# SPDX-License-Identifier: AGPL-3.0-only +# Copyright 2026-present the Unsloth AI Inc. team. All rights reserved. +# Shared install_name_tool trace wrapper and CLT-absent sentinel contract. +set -euo pipefail + +usage() { + echo "usage: $0 write {sentinel|passthrough} TARGET [REAL_TOOL]" >&2 + echo " $0 verify-sentinel TRACE MARKER" >&2 + + echo " $0 decode TRACE_ARG" >&2 + exit 2 +} + + +decode_trace_arg() { + encoded=$1 + case "$encoded" in h*) hex=${encoded#h} ;; *) return 1 ;; esac + case "$hex" in *[!0123456789abcdef]*) return 1 ;; esac + [ $(( ${#hex} % 2 )) -eq 0 ] || return 1 + decoded="" + while [ -n "$hex" ]; do + rest=${hex#??} + pair=${hex%"$rest"} + hex=$rest + printf -v byte '%b' "\\x$pair" + decoded+=$byte + done + printf '%s' "$decoded" +} + +case "${1:-}" in + write) + kind="${2:-}" + target="${3:-}" + [ -n "$target" ] || usage + case "$kind" in sentinel|passthrough) ;; *) usage ;; esac + + cat > "$target" <<'WRAPPER' +#!/bin/sh +: "${UNSLOTH_TOOL_TRACE:?UNSLOTH_TOOL_TRACE is required}" +encode_trace_arg() { + printf '%s' "$1" | od -An -v -tx1 | tr -d ' \n' +} +printf 'install_name_tool\t%s' "$#" >> "$UNSLOTH_TOOL_TRACE" +for arg in "$@"; do printf '\th%s' "$(encode_trace_arg "$arg")" >> "$UNSLOTH_TOOL_TRACE"; done +printf '\n' >> "$UNSLOTH_TOOL_TRACE" +WRAPPER + if [ "$kind" = "sentinel" ]; then + printf '%s\n' 'exit 97' >> "$target" + else + real_tool="${4:-}" + [ -n "$real_tool" ] || usage + case "$real_tool" in *'"'*|*$'\n'*) echo "unsupported tool path: $real_tool" >&2; exit 2 ;; esac + printf 'exec "%s" "$@"\n' "$real_tool" >> "$target" + fi + chmod +x "$target" + ;; + + decode) + [ "$#" -eq 2 ] || usage + decode_trace_arg "$2" + ;; + + + verify-sentinel) + trace="${2:-}" + marker="${3:-}" + [ -n "$trace" ] && [ -n "$marker" ] || usage + [ -n "${UNSLOTH_TOOL_TRACE:-}" ] && [ "$UNSLOTH_TOOL_TRACE" = "$trace" ] || { + echo "::error::install_name_tool sentinel trace environment is not active" >&2 + exit 1 + } + + set +e + install_name_tool "--${marker}-sentinel-self-test" >/dev/null 2>&1 + sentinel_rc=$? + set -e + [ "$sentinel_rc" -eq 97 ] || { + echo "::error::install_name_tool sentinel returned $sentinel_rc, expected 97" >&2 + exit 1 + } + marker_hex=$(printf '%s' "--${marker}-sentinel-self-test" | od -An -v -tx1 | tr -d ' \n') + expected=$(printf 'install_name_tool\t1\th%s' "$marker_hex") + grep -Fqx "$expected" "$trace" || { + echo "::error::install_name_tool sentinel did not preserve/record its self-test argv" >&2 + cat "$trace" >&2 || true + exit 1 + } + : > "$trace" + ;; + + *) usage ;; +esac diff --git a/.github/workflows/clean-machine-install-ci.yml b/.github/workflows/clean-machine-install-ci.yml index 00a282bce17..0291cc3a961 100644 --- a/.github/workflows/clean-machine-install-ci.yml +++ b/.github/workflows/clean-machine-install-ci.yml @@ -29,9 +29,9 @@ # Legs left on `overlay: false`: # mac */mask/pipe the `curl | sh` shape a user runs, kept end-to-end on the # released package so a broken PyPI release still shows up. -# mac macos-15/trace `notools` asserts the installer never reaches for git, and the -# editable build calls `git rev-parse` / `git archive` itself via -# setuptools-scm's file finder, answering the leg's own question. +# mac macos-15/trace `notools` rejects toolchain use except uv's exact optional +# libpython self-ID patch; `dylibpatch` requires that control call. +# No overlay: setuptools-scm's editable build would call git itself. # linux ubuntu2404-nonroot-notransport dies at the elevation gate before a venv exists. # wsl only install.sh is copied in; no source tree inside WSL. @@ -125,6 +125,12 @@ jobs: runs-on: ${{ matrix.os }} timeout-minutes: 40 continue-on-error: ${{ matrix.experimental }} + + env: + # uv's managed Python is not stored in the ordinary download cache on macOS. + # Isolate both so every row exercises the first-download dylib patch path. + UV_CACHE_DIR: ${{ github.workspace }}/.clean-machine/uv-cache + UV_PYTHON_INSTALL_DIR: ${{ github.workspace }}/.clean-machine/uv-python # Explicit legs, not a full cross-product: the interesting dimensions are (does # the toolchain exist) x (how the script is delivered), not every pairing. strategy: @@ -140,9 +146,9 @@ jobs: - {os: macos-15, mode: mask, delivery: file, flags: '', experimental: false, overlay: true} # What the desktop app runs: no tty, stdin closed, TAURI markers on. - {os: macos-15, mode: mask, delivery: tauri, flags: '', experimental: false, overlay: true} - # Toolchain present but logged: does the installer ever reach for it? No - # overlay: the editable build calls git itself (setuptools-scm), planting the - # very evidence `notools` looks for. + # Toolchain present but logged: uv may make exactly one kind of developer-tool + # call, its managed-libpython self-ID patch. No overlay: the editable build calls + # git itself (setuptools-scm), planting evidence unrelated to the consumer path. - {os: macos-15, mode: trace, delivery: file, flags: '', experimental: false, overlay: false} # --no-torch is the one macOS path that can still want a compiler # (sentencepiece has no guaranteed cp313 arm64 wheel), so probe it apart from @@ -179,6 +185,9 @@ jobs: - name: Simulate a clean machine (${{ matrix.mode }}) run: | mkdir -p logs + + rm -rf "$UV_CACHE_DIR" "$UV_PYTHON_INSTALL_DIR" + mkdir -p "$UV_CACHE_DIR" "$UV_PYTHON_INSTALL_DIR" if [ "${{ matrix.mode }}" = "mask" ]; then bash .github/scripts/clean-machine-env.sh mask --remove else @@ -189,8 +198,14 @@ jobs: if: matrix.mode == 'mask' run: | set -a; . ./clean-machine.env; set +a + [ -n "$UNSLOTH_TOOL_TRACE" ] || { echo "::error::mask mode set no UNSLOTH_TOOL_TRACE"; exit 1; } + # Prove the observation-only sentinel is on PATH without ever reaching Apple's + # /usr/bin shim, then clear the self-test so the post-install zero-hit assertion + # describes only the installer. + bash .github/scripts/clean-machine-install-name-tool.sh \ + verify-sentinel "$UNSLOTH_TOOL_TRACE" clean-machine UNSLOTH_CLEAN_ALLOW_WORKING='${{ matrix.allow_working }}' \ - bash .github/scripts/clean-machine-assert.sh absent + bash .github/scripts/clean-machine-assert.sh absent nodylibtool - name: Verify the trace actually records if: matrix.mode == 'trace' @@ -305,11 +320,28 @@ jobs: checks="nobuild" # `absent` ran only BEFORE the install, so an installer that quietly selected the # CLT or installed a compiler left the leg green. Re-run it after. - [ "${{ matrix.mode }}" = "mask" ] && checks="$checks absent" - [ "${{ matrix.mode }}" = "trace" ] && checks="$checks notools" + [ "${{ matrix.mode }}" = "mask" ] && checks="$checks absent nodylibtool" + [ "${{ matrix.mode }}" = "trace" ] && checks="$checks notools dylibpatch" UNSLOTH_CLEAN_ALLOW_WORKING='${{ matrix.allow_working }}' \ bash .github/scripts/clean-machine-assert.sh $checks + if [ "${{ matrix.mode }}" = "trace" ]; then + # Validate the on-disk result, not only uv's argv. Field 4 is the first + # hex-encoded dylib operand in the boundary-safe trace format. + dylib_encoded=$(awk -F '\t' '$1 == "install_name_tool" { print $4; exit }' "$UNSLOTH_TOOL_TRACE") + dylib=$(bash .github/scripts/clean-machine-install-name-tool.sh decode "$dylib_encoded") + [ -n "$dylib" ] && [ -f "$dylib" ] || { + echo "::error::traced managed-Python dylib is missing: ${dylib:-no trace path}" + exit 1 + } + dylib_id=$(otool -D "$dylib" | sed -n '2{s/^[[:space:]]*//;p;}') + [ "$dylib_id" = "$dylib" ] || { + echo "::error::managed libpython ID was not patched to itself (expected $dylib, got ${dylib_id:-empty})" + exit 1 + } + echo "managed libpython ID: $dylib_id" + fi + - name: Assert llama.cpp loads, and every downloaded Mach-O is native and signed if: steps.install.outcome == 'success' run: | @@ -322,12 +354,34 @@ jobs: else HOME_DIR="$UNSLOTH_STUDIO_HOME" fi + + if [ "${{ matrix.flags }}" != "--no-torch" ]; then + if [ "${{ matrix.delivery }}" = "tauri" ]; then + PY="$HOME_DIR/studio/unsloth_studio/bin/python" + else + PY="$HOME_DIR/unsloth_studio/bin/python" + fi + [ -x "$PY" ] || { echo "::error::installer left no managed Python at $PY"; exit 1; } + # These representative native wheels load without the developer-only dylib patch. + "$PY" -c "import numpy, safetensors, tokenizers, torch; print('native wheels loaded', numpy.__version__, torch.__version__)" + fi STUDIO_HOME="$HOME_DIR" bash .github/scripts/assert-llama-loads.sh # Rosetta 2 is on this runner and not on a fresh Mac, so llama-server launching # above does not prove it would for a user. Assert the arch of every payload # (llama.cpp, whisper.cpp, the Node prebuilt, uv) instead. MACHO_ROOT="$HOME_DIR" bash .github/scripts/clean-machine-assert.sh macho + - name: Assert the uv guard left no temporary files + if: always() && steps.install.outcome == 'success' + run: | + leftovers=$(find "${TMPDIR:-/tmp}" -maxdepth 1 -type d \ + -name 'unsloth-uv-install-name-tool.*' -print 2>/dev/null || true) + [ -z "$leftovers" ] || { + echo "::error::installer left temporary uv guard directories: $leftovers" + exit 1 + } + + - name: Restore the runner if: always() # `|| true` swallowed everything, a genuinely broken restore included. The file diff --git a/.github/workflows/desktop-app-clean-machine-ci.yml b/.github/workflows/desktop-app-clean-machine-ci.yml index f89971ec3eb..a2d95c9b9ce 100644 --- a/.github/workflows/desktop-app-clean-machine-ci.yml +++ b/.github/workflows/desktop-app-clean-machine-ci.yml @@ -86,6 +86,12 @@ jobs: runs-on: ${{ matrix.os }} timeout-minutes: 45 continue-on-error: ${{ matrix.experimental }} + + env: + # Force the bundled installer through uv's first managed-Python download rather + # than reusing runner state outside the ordinary cache. + UV_CACHE_DIR: ${{ github.workspace }}/.clean-machine/uv-cache + UV_PYTHON_INSTALL_DIR: ${{ github.workspace }}/.clean-machine/uv-python strategy: fail-fast: false matrix: @@ -98,6 +104,12 @@ jobs: with: persist-credentials: false + + - name: Prepare fresh uv state + run: | + rm -rf "$UV_CACHE_DIR" "$UV_PYTHON_INSTALL_DIR" + mkdir -p "$UV_CACHE_DIR" "$UV_PYTHON_INSTALL_DIR" + - name: Download the shipped .dmg env: GH_TOKEN: ${{ secrets.GITHUB_TOKEN }} @@ -138,7 +150,10 @@ jobs: run: | bash .github/scripts/clean-machine-env.sh mask --remove set -a; . ./clean-machine.env; set +a - bash .github/scripts/clean-machine-assert.sh absent + [ -n "$UNSLOTH_TOOL_TRACE" ] || { echo "::error::mask mode set no UNSLOTH_TOOL_TRACE"; exit 1; } + bash .github/scripts/clean-machine-install-name-tool.sh \ + verify-sentinel "$UNSLOTH_TOOL_TRACE" desktop + bash .github/scripts/clean-machine-assert.sh absent nodylibtool - name: Mount and install run: | @@ -206,9 +221,22 @@ jobs: PY="$HOME/.unsloth/studio/unsloth_studio/bin/python" [ -x "$PY" ] || { echo "::error::bundled installer left no venv at $PY"; exit 1; } "$PY" -V - # install.rs passes only --tauri, so torch is part of first launch: without this - # the venv check passes a bundle whose only failure is the torch install. - "$PY" -c "import torch; print('torch', torch.__version__)" + # First launch installs the native runtime stack. All representative wheels + # must load without uv's developer-only libpython ID patch. + "$PY" -c "import numpy, safetensors, tokenizers, torch; print('native wheels loaded', numpy.__version__, torch.__version__)" + + if [ -f ./clean-machine.env ]; then + # A successful install alone is insufficient: uv treats install_name_tool + # failure as a warning, so a broken guard would still exit zero after opening + # Apple's GUI prompt. The sentinel proves the bundled guard won instead. + bash .github/scripts/clean-machine-assert.sh absent nodylibtool + fi + leftovers=$(find "${TMPDIR:-/tmp}" -maxdepth 1 -type d \ + -name 'unsloth-uv-install-name-tool.*' -print 2>/dev/null || true) + [ -z "$leftovers" ] || { + echo "::error::bundled installer left temporary uv guard directories: $leftovers" + exit 1 + } - name: Launch and prove it stays up run: | @@ -291,7 +319,9 @@ jobs: uses: actions/upload-artifact@043fb46d1a93c77aae656e7c1c64a875d1fc6a0a # v7.0.1 with: name: desktop-macos-${{ matrix.os }} - path: logs/ + path: | + logs/ + .clean-machine/tool-invocations.log retention-days: 7 if-no-files-found: warn diff --git a/install.sh b/install.sh index bda7e7cdb69..d5125289be0 100755 --- a/install.sh +++ b/install.sh @@ -717,6 +717,7 @@ _commit_studio_venv_replacement() { _cleanup_install_temporaries() { [ -n "${_UV_OVERRIDE_TMPDIR:-}" ] && rm -rf "$_UV_OVERRIDE_TMPDIR" 2>/dev/null || true + [ -n "${_UV_INSTALL_NAME_TOOL_SHIM_DIR:-}" ] && rm -rf "$_UV_INSTALL_NAME_TOOL_SHIM_DIR" 2>/dev/null || true [ -n "${_UNSLOTH_TORCH_OVERRIDES:-}" ] && rm -f "$_UNSLOTH_TORCH_OVERRIDES" 2>/dev/null || true } @@ -740,8 +741,10 @@ _on_install_signal() { exit "$_signal_status" } # Empty so an inherited value never reaches the trap's rm; only temp paths this -# script creates below (spaced-path dir, torch-trio overrides) are removed. +# script creates below (spaced-path dir, uv install_name_tool guard, torch-trio +# overrides) are removed. _UV_OVERRIDE_TMPDIR="" +_UV_INSTALL_NAME_TOOL_SHIM_DIR="" _UNSLOTH_TORCH_OVERRIDES="" trap _on_install_exit EXIT trap '_on_install_signal 129' HUP @@ -2507,6 +2510,72 @@ if [ "$SKIP_TORCH" = true ] && [ "$MAC_INTEL" = true ] && [ -z "$_USER_PYTHON" ] fi fi +# uv unconditionally invokes install_name_tool after downloading managed CPython on +# macOS. On a consumer Mac without developer tools, Apple's /usr/bin shim opens the +# Command Line Tools installer even though uv treats patch failure as a warning. There +# is no supported uv opt-out yet (https://github.com/astral-sh/uv/issues/14893). +# +# Do not execute install_name_tool or xcrun to probe it: either probe can launch the +# same dialog. A selected standalone CLT and full Xcode have stable on-disk locations. +_macos_has_selected_install_name_tool() { + _uvv_developer_dir=$(xcode-select -p 2>/dev/null) || return 1 + [ -n "$_uvv_developer_dir" ] && [ -d "$_uvv_developer_dir" ] || return 1 + + # DEVELOPER_DIR may select a custom path or symlink, so do not require Apple's + # standard directory names. Reject only candidates that are the base-system + # /usr/bin dialog shim itself; comparing file identity does not execute the tool. + for _uvv_tool in \ + "$_uvv_developer_dir/usr/bin/install_name_tool" \ + "$_uvv_developer_dir/Toolchains/XcodeDefault.xctoolchain/usr/bin/install_name_tool"; do + [ -x "$_uvv_tool" ] || continue + if [ -e /usr/bin/install_name_tool ] \ + && [ "$_uvv_tool" -ef /usr/bin/install_name_tool ] 2>/dev/null; then + continue + fi + return 0 + done + return 1 +} + +# Run one uv venv command with its argv unchanged. If no real selected macOS tool is +# present, put a non-success shim ahead of /usr/bin only for uv's process. Returning +# nonzero is intentional: uv must retain its warning path rather than being told that +# an unpatched dylib was successfully modified. +_run_uv_venv() { # label, uv-venv args... + _uvv_label="$1" + shift + if [ "$OS" != "macos" ] || _macos_has_selected_install_name_tool; then + run_install_cmd "$_uvv_label" uv venv "$@" + return $? + fi + + _UV_INSTALL_NAME_TOOL_SHIM_DIR=$(mktemp -d \ + "${TMPDIR:-/tmp}/unsloth-uv-install-name-tool.XXXXXX") || { + echo "ERROR: could not create the temporary macOS uv guard." >&2 + tauri_stream_log stderr "ERROR_OUTPUT" "$_uvv_label failed (temporary guard)" + return 1 + } + if ! printf '%s\n' '#!/bin/sh' 'exit 1' \ + > "$_UV_INSTALL_NAME_TOOL_SHIM_DIR/install_name_tool" \ + || ! chmod +x "$_UV_INSTALL_NAME_TOOL_SHIM_DIR/install_name_tool"; then + echo "ERROR: could not prepare the temporary macOS uv guard." >&2 + rm -rf "$_UV_INSTALL_NAME_TOOL_SHIM_DIR" 2>/dev/null || true + _UV_INSTALL_NAME_TOOL_SHIM_DIR="" + tauri_stream_log stderr "ERROR_OUTPUT" "$_uvv_label failed (temporary guard)" + return 1 + fi + + if run_install_cmd "$_uvv_label" env \ + PATH="$_UV_INSTALL_NAME_TOOL_SHIM_DIR:$PATH" uv venv "$@"; then + _uvv_status=0 + else + _uvv_status=$? + fi + rm -rf "$_UV_INSTALL_NAME_TOOL_SHIM_DIR" 2>/dev/null || true + _UV_INSTALL_NAME_TOOL_SHIM_DIR="" + return "$_uvv_status" +} + # Apple Silicon venv. The arch-explicit arm64 CPython stops uv reusing a cached # x86_64 (Rosetta) build: torch ships no macOS x86_64 wheels since 2.2.2, so an # x86_64 venv cannot resolve torch. The arm64 guard below backstops older venvs. @@ -2521,10 +2590,10 @@ fi # UV_PYTHON_DOWNLOADS=never is left with nothing to resolve. Retry unflagged for # them: the dialog is worth removing, a failed install is not. _uv_venv_arm64() { # label - run_install_cmd "$1" uv venv "$VENV_DIR" \ + _run_uv_venv "$1" "$VENV_DIR" \ --python-preference only-managed \ --python "cpython-${PYTHON_VERSION}-macos-aarch64-none" \ - || run_install_cmd "$1 (system Python)" uv venv "$VENV_DIR" \ + || _run_uv_venv "$1 (system Python)" "$VENV_DIR" \ --python "cpython-${PYTHON_VERSION}-macos-aarch64-none" } @@ -2534,7 +2603,7 @@ if [ ! -x "$VENV_DIR/bin/python" ]; then if [ "$OS" = "macos" ] && [ "$_ARCH" = "arm64" ] && [ -z "$_USER_PYTHON" ]; then _uv_venv_arm64 "create venv" else - run_install_cmd "create venv" uv venv "$VENV_DIR" \ + _run_uv_venv "create venv" "$VENV_DIR" \ --python "$(_python_request "$PYTHON_VERSION")" fi fi @@ -2620,7 +2689,7 @@ if [ -z "$_USER_PYTHON" ] && [ -x "$VENV_DIR/bin/python" ]; then echo " WARNING: Python $_PY_VER cannot import torch." echo " Recreating venv..." _discard_venv_for_recreate "$VENV_DIR" - run_install_cmd "recreate venv" uv venv "$VENV_DIR" \ + _run_uv_venv "recreate venv" "$VENV_DIR" \ --python "$(_python_request "$PYTHON_VERSION")" if [ -x "$VENV_DIR/bin/python" ]; then : > "$VENV_DIR/.unsloth-studio-owned" 2>/dev/null || true diff --git a/tests/sh/test_clean_machine_install_name_tool_assert.sh b/tests/sh/test_clean_machine_install_name_tool_assert.sh new file mode 100755 index 00000000000..4c2224caa2f --- /dev/null +++ b/tests/sh/test_clean_machine_install_name_tool_assert.sh @@ -0,0 +1,143 @@ +#!/bin/bash +# SPDX-License-Identifier: AGPL-3.0-only +# Copyright 2026-present the Unsloth AI Inc. team. All rights reserved. See /studio/LICENSE.AGPL-3.0 +# Keep the clean-machine allow-list narrow: uv may self-ID one managed libpython on a +# CLT-present control leg, while CLT-absent legs must not reach install_name_tool at all. +set -e + +SCRIPT_DIR="$(cd "$(dirname "$0")" && pwd)" +ASSERT_SH="$SCRIPT_DIR/../../.github/scripts/clean-machine-assert.sh" + +INSTALL_NAME_TOOL_HELPER="$SCRIPT_DIR/../../.github/scripts/clean-machine-install-name-tool.sh" +ROOT=$(mktemp -d) +trap 'rm -rf "$ROOT"' EXIT +PASS=0 +FAIL=0 + +expect_rc() { + _label="$1"; _expected="$2"; _check="$3"; _trace_content="$4" + printf '%b' "$_trace_content" > "$ROOT/trace.log" + set +e + UV_PYTHON_INSTALL_DIR="$UV_ROOT" UNSLOTH_TOOL_TRACE="$ROOT/trace.log" \ + INSTALL_LOG="$ROOT/install.log" bash "$ASSERT_SH" "$_check" \ + > "$ROOT/out.log" 2>&1 + _actual=$? + set -e + if [ "$_actual" -eq "$_expected" ]; then + echo " PASS: $_label" + PASS=$((PASS + 1)) + else + echo " FAIL: $_label (expected rc $_expected, got $_actual)" + cat "$ROOT/out.log" + FAIL=$((FAIL + 1)) + fi +} + +: > "$ROOT/install.log" +UV_ROOT="$ROOT/uv python" +_dylib="$UV_ROOT/cpython-3.12-macos-aarch64-none/lib/libpython3.12.dylib" +mkdir -p "$(dirname "$_dylib")" "$UV_ROOT/x" "$ROOT/outside/lib" +: > "$_dylib" +_traversal="$UV_ROOT/x/../../outside/lib/libpython3.12.dylib" +: > "$ROOT/outside/lib/libpython3.12.dylib" +trace_arg() { + printf 'h' + printf '%s' "$1" | od -An -v -tx1 | tr -d ' \n' +} +trace_record() { + _argc="$1"; shift + printf 'install_name_tool\\t%s' "$_argc" + for _arg in "$@"; do printf '\\t%s' "$(trace_arg "$_arg")"; done + printf '\\n' +} +_valid=$(trace_record 3 -id "$_dylib" "$_dylib") + +echo "=== shared wrapper and sentinel helper ===" +mkdir -p "$ROOT/bin" +bash "$INSTALL_NAME_TOOL_HELPER" write sentinel "$ROOT/bin/install_name_tool" +: > "$ROOT/trace.log" +if PATH="$ROOT/bin:$PATH" UNSLOTH_TOOL_TRACE="$ROOT/trace.log" \ + bash "$INSTALL_NAME_TOOL_HELPER" verify-sentinel "$ROOT/trace.log" focused; then + echo " PASS: shared helper verifies and clears the CLT-absent sentinel" + PASS=$((PASS + 1)) +else + echo " FAIL: shared helper could not verify its CLT-absent sentinel" + FAIL=$((FAIL + 1)) +fi +if [ ! -s "$ROOT/trace.log" ]; then + echo " PASS: sentinel self-test trace is cleared before installer assertions" + PASS=$((PASS + 1)) +else + echo " FAIL: sentinel self-test trace was not cleared" + FAIL=$((FAIL + 1)) +fi + +cat > "$ROOT/real-install-name-tool" <<'REAL_TOOL' +#!/bin/sh +printf '%s\n' "$*" >> "$REAL_TOOL_LOG" +REAL_TOOL +chmod +x "$ROOT/real-install-name-tool" +bash "$INSTALL_NAME_TOOL_HELPER" write passthrough \ + "$ROOT/bin/install_name_tool" "$ROOT/real-install-name-tool" +: > "$ROOT/trace.log" +: > "$ROOT/real-tool.log" +UNSLOTH_TOOL_TRACE="$ROOT/trace.log" REAL_TOOL_LOG="$ROOT/real-tool.log" \ + "$ROOT/bin/install_name_tool" -id "$_dylib" "$_dylib" +_expected_trace=$(printf '%b' "$_valid") + +_decoded_dylib=$(bash "$INSTALL_NAME_TOOL_HELPER" decode "$(trace_arg "$_dylib")") +if [ "$_decoded_dylib" = "$_dylib" ]; then + echo " PASS: shared helper decodes workflow trace operands" + PASS=$((PASS + 1)) +else + echo " FAIL: shared helper changed a decoded workflow trace operand" + FAIL=$((FAIL + 1)) +fi +if [ "$(cat "$ROOT/trace.log")" = "$_expected_trace" ] \ + && [ "$(cat "$ROOT/real-tool.log")" = "-id $_dylib $_dylib" ]; then + echo " PASS: shared passthrough encodes boundaries and executes the real tool" + PASS=$((PASS + 1)) +else + echo " FAIL: shared passthrough trace or real-tool argv changed" + FAIL=$((FAIL + 1)) +fi + +echo "=== CLT-absent assertion ===" +expect_rc "empty trace proves no Apple shim escape" 0 nodylibtool "" +expect_rc "any install_name_tool hit fails the absent leg" 1 nodylibtool "$_valid" + +echo "=== CLT-present exact allow-list ===" +expect_rc "exact libpython self-ID patch is accepted, including spaces" 0 dylibpatch "$_valid" +expect_rc "control must actually observe a patch" 1 dylibpatch "" +expect_rc "wrong argc is rejected" 1 dylibpatch "$(trace_record 2 -id "$_dylib" "$_dylib")" +expect_rc "wrong operation is rejected" 1 dylibpatch "$(trace_record 3 -change "$_dylib" "$_dylib")" +expect_rc "different source and destination are rejected" 1 dylibpatch \ + "$(trace_record 3 -id "$_dylib" /tmp/libpython3.12.dylib)" +expect_rc "non-libpython dylib is rejected" 1 dylibpatch \ + "$(trace_record 3 -id /tmp/lib/libtorch.dylib /tmp/lib/libtorch.dylib)" +expect_rc "absolute libpython outside uv's managed-Python root is rejected" 1 dylibpatch \ + "$(trace_record 3 -id /tmp/lib/libpython3.12.dylib /tmp/lib/libpython3.12.dylib)" +expect_rc "path traversal outside uv's managed-Python root is rejected" 1 dylibpatch \ + "$(trace_record 3 -id "$_traversal" "$_traversal")" +expect_rc "relative libpython path is rejected" 1 dylibpatch \ + "$(trace_record 3 -id lib/libpython3.12.dylib lib/libpython3.12.dylib)" +expect_rc "empty extra argument remains visible and is rejected" 1 dylibpatch \ + "$(trace_record 4 -id "$_dylib" "$_dylib" "")" +expect_rc "tab inside an operand cannot forge argument boundaries" 1 dylibpatch \ + "$(trace_record 3 -id "$_dylib" "$_dylib"$'\tignored')" +expect_rc "legacy unescaped records are rejected" 1 dylibpatch \ + "install_name_tool\t3\t-id\t$_dylib\t$_dylib\n" +expect_rc "mixed valid and invalid records still fail" 1 dylibpatch \ + "${_valid}$(trace_record 3 -delete_rpath "$_dylib" "$_dylib")" + +echo "=== general no-toolchain assertion ===" +expect_rc "notools permits the exact optional uv patch" 0 notools "$_valid" +expect_rc "notools rejects arbitrary install_name_tool use" 1 notools \ + "$(trace_record 3 -add_rpath /tmp/a /tmp/a)" +expect_rc "notools still rejects git" 1 notools "git\t--version\n" +expect_rc "notools still permits the xcode-select availability probe" 0 notools \ + "xcode-select\t-p\n" + +echo "" +echo "Passed: $PASS, Failed: $FAIL" +[ "$FAIL" -eq 0 ] diff --git a/tests/sh/test_install_python_guard.sh b/tests/sh/test_install_python_guard.sh index 95eb50ef623..1cd9ab890b4 100755 --- a/tests/sh/test_install_python_guard.sh +++ b/tests/sh/test_install_python_guard.sh @@ -169,10 +169,8 @@ EOF PYTHON_VERSION="3.13" # shellcheck disable=SC1090 . "$_HELPERS" - run_install_cmd() { + _run_uv_venv() { shift # label - shift # uv - shift # venv shift # target dir shift # --python echo "REQUEST=$1" >&2 @@ -225,7 +223,7 @@ if [ "$3" = true ]; then _VENV_ROLLBACK_ACTIVE=true fi _stub_rc="$4" -run_install_cmd() { return "$_stub_rc"; } +_run_uv_venv() { return "$_stub_rc"; } set -e # What _on_install_exit does for a non-zero status. trap '[ "$?" -eq 0 ] || _restore_studio_venv_replacement' EXIT diff --git a/tests/sh/test_mac_intel_compat.sh b/tests/sh/test_mac_intel_compat.sh index 809998f02d4..697040de70b 100644 --- a/tests/sh/test_mac_intel_compat.sh +++ b/tests/sh/test_mac_intel_compat.sh @@ -596,7 +596,7 @@ if [ ! -s "$_GUARD_FILE" ]; then echo " FAIL: could not extract Apple Silicon venv guard from install.sh" FAIL=$((FAIL + 1)) else - # Runner: stub uv (via run_install_cmd) + a fake venv python, source the + # Runner: stub the guarded uv venv runner + a fake venv python, source the # guard, then print " | ". # The stub maps a uv arm64 selector to the interpreter uv would produce: # cpython-3.12-* -> arm64 3.12.7, cpython-3.13-* -> arm64 $REBUILD_313_VERSION. @@ -615,18 +615,15 @@ make_python() { # dir machine version chmod +x "$1/bin/python" } RECREATE_LOG=$(mktemp); : > "$RECREATE_LOG" -run_install_cmd() { +_run_uv_venv() { shift # drop the human label - if [ "$1" = "uv" ] && [ "$2" = "venv" ]; then - dir="$3"; sel=""; shift 3 - while [ $# -gt 0 ]; do [ "$1" = "--python" ] && { sel="$2"; shift; }; shift; done - echo "$sel" >> "$RECREATE_LOG" - case "$sel" in - *3.12-macos-aarch64*) make_python "$dir" arm64 "3.12.7" ;; - *3.13-macos-aarch64*) make_python "$dir" arm64 "${REBUILD_313_VERSION:-3.13.3}" ;; - *) make_python "$dir" arm64 "$sel" ;; - esac - fi + dir="$1"; sel=""; shift + while [ $# -gt 0 ]; do [ "$1" = "--python" ] && { sel="$2"; shift; }; shift; done + echo "$sel" >> "$RECREATE_LOG" + case "$sel" in + cpython-3.12-*) make_python "$dir" arm64 3.12.7 ;; + cpython-3.13-*) make_python "$dir" arm64 "${REBUILD_313_VERSION:-3.13.3}" ;; + esac } [ "$INIT_ARCH" != none ] && make_python "$VENV_DIR" "$INIT_ARCH" "$INIT_VER" PYTHON_VERSION="3.13" diff --git a/tests/sh/test_macos_uv_install_name_tool_guard.sh b/tests/sh/test_macos_uv_install_name_tool_guard.sh new file mode 100755 index 00000000000..357c6ac15fc --- /dev/null +++ b/tests/sh/test_macos_uv_install_name_tool_guard.sh @@ -0,0 +1,287 @@ +#!/bin/bash +# SPDX-License-Identifier: AGPL-3.0-only +# Copyright 2026-present the Unsloth AI Inc. team. All rights reserved. See /studio/LICENSE.AGPL-3.0 +# Regression coverage for uv's unconditional macOS install_name_tool patch. A consumer +# Mac without CLT must never execute Apple's developer-tool shim, while a selected CLT +# or full Xcode installation must retain uv's real libpython patch. +set -e + +SCRIPT_DIR="$(cd "$(dirname "$0")" && pwd)" +INSTALL_SH="$SCRIPT_DIR/../../install.sh" +PASS=0 +FAIL=0 + +assert_eq() { + _label="$1"; _expected="$2"; _actual="$3" + if [ "$_actual" = "$_expected" ]; then + echo " PASS: $_label" + PASS=$((PASS + 1)) + else + echo " FAIL: $_label (expected '$_expected', got '$_actual')" + FAIL=$((FAIL + 1)) + fi +} + +assert_empty() { + _label="$1"; _path="$2" + if [ ! -s "$_path" ]; then + echo " PASS: $_label" + PASS=$((PASS + 1)) + else + echo " FAIL: $_label (unexpected contents: $(cat "$_path"))" + FAIL=$((FAIL + 1)) + fi +} + +_EXTRACTED_HELPERS=$(mktemp) +for _f in _macos_has_selected_install_name_tool _run_uv_venv; do + sed -n "/^$_f()/,/^}/p" "$INSTALL_SH" >> "$_EXTRACTED_HELPERS" +done +if ! grep -q '^_macos_has_selected_install_name_tool()' "$_EXTRACTED_HELPERS" \ + || ! grep -q '^_run_uv_venv()' "$_EXTRACTED_HELPERS"; then + echo " FAIL: guarded uv venv helpers not found in install.sh" + rm -f "$_EXTRACTED_HELPERS" + exit 1 +fi + +_ROOT=$(mktemp -d) +trap 'rm -rf "$_ROOT"; rm -f "$_EXTRACTED_HELPERS" "${_TRAP_HELPERS:-}"' EXIT +_BIN="$_ROOT/bin" +mkdir -p "$_BIN" + +cat > "$_BIN/xcode-select" <<'XCODE_EOF' +#!/bin/sh +printf '%s\n' "$*" >> "$CASE_DIR/xcode-select.log" +if [ -n "${DEVELOPER_DIR:-}" ]; then + printf '%s\n' "$DEVELOPER_DIR" + exit 0 +fi +[ "${XCODE_SELECT_RC:-0}" -eq 0 ] || exit "$XCODE_SELECT_RC" +printf '%s\n' "$XCODE_DEV" +XCODE_EOF + +cat > "$_BIN/install_name_tool" <<'TOOL_EOF' +#!/bin/sh +printf '%s\n' "$*" >> "$CASE_DIR/apple-tool.log" +exit 0 +TOOL_EOF + +cat > "$_BIN/uv" <<'UV_EOF' +#!/bin/sh +printf '%s\n' "$*" >> "$CASE_DIR/uv.log" + +printf 'argc=%s\n' "$#" >> "$CASE_DIR/uv-argv.log" +for arg in "$@"; do printf '<%s>\n' "$arg" >> "$CASE_DIR/uv-argv.log"; done +if [ "${FAKE_UV_PATCH:-1}" = 1 ]; then + dylib="$CASE_DIR/libpython3.12.dylib" + install_name_tool -id "$dylib" "$dylib" || : +fi +exit "${FAKE_UV_RC:-0}" +UV_EOF +chmod +x "$_BIN/xcode-select" "$_BIN/install_name_tool" "$_BIN/uv" + +# Each named layout owns its expected uv status and real-tool count; callers pass only +# the shell and scenario rather than a positional clump of correlated expectations. +run_case() { + _shell="$1"; _layout="$2" + _uv_rc=0; _expected_rc=0; _expected_tool=0; _developer_override=""; _dev="" + case "$_layout" in + absent) _select_rc=1 ;; + stale) _select_rc=0 ;; + partial) + _select_rc=0 + _dev="$_ROOT/${_shell##*/}-$_layout/developer" + mkdir -p "$_dev" + ;; + custom-selection) + _select_rc=0; _expected_tool=1 + _dev="$_ROOT/${_shell##*/}-$_layout/custom-developer-alias" + _real_dev="$_ROOT/${_shell##*/}-$_layout/real-developer" + mkdir -p "$_real_dev/usr/bin" + : > "$_real_dev/usr/bin/install_name_tool" + chmod +x "$_real_dev/usr/bin/install_name_tool" + ln -s "$_real_dev" "$_dev" + _developer_override="$_dev" + ;; + clt) + _select_rc=0; _expected_tool=1 + _dev="$_ROOT/${_shell##*/}-$_layout/Library/Developer/CommandLineTools" + mkdir -p "$_dev/usr/bin" + : > "$_dev/usr/bin/install_name_tool" + chmod +x "$_dev/usr/bin/install_name_tool" + ;; + xcode) + _select_rc=0; _uv_rc=19; _expected_rc=19; _expected_tool=1 + _dev="$_ROOT/${_shell##*/}-$_layout/Applications/Xcode.app/Contents/Developer" + mkdir -p "$_dev/Toolchains/XcodeDefault.xctoolchain/usr/bin" + : > "$_dev/Toolchains/XcodeDefault.xctoolchain/usr/bin/install_name_tool" + chmod +x "$_dev/Toolchains/XcodeDefault.xctoolchain/usr/bin/install_name_tool" + ;; + partial-failure) + _select_rc=0; _uv_rc=23; _expected_rc=23 + _dev="$_ROOT/${_shell##*/}-$_layout/developer" + mkdir -p "$_dev" + ;; + esac + + _case="$_ROOT/${_shell##*/}-$_layout" + mkdir -p "$_case/tmp" + : > "$_case/apple-tool.log" + : > "$_case/xcode-select.log" + : > "$_case/uv.log" + + : > "$_case/uv-argv.log" + : "${_dev:=$_case/developer}" + + set +e + _out=$(CASE_DIR="$_case" TMPDIR="$_case/tmp" PATH="$_BIN:$PATH" \ + DEVELOPER_DIR="$_developer_override" XCODE_DEV="$_dev" XCODE_SELECT_RC="$_select_rc" \ + FAKE_UV_RC="$_uv_rc" "$_shell" -c ' + . "'"$_EXTRACTED_HELPERS"'" + OS=macos + _UV_INSTALL_NAME_TOOL_SHIM_DIR="" + run_install_cmd() { shift; "$@"; } + if _run_uv_venv "create venv" "$CASE_DIR/venv" --python cpython-3.12-macos-aarch64-none; then + rc=0 + else + rc=$? + fi + printf "rc=%s guard=%s\n" "$rc" "${_UV_INSTALL_NAME_TOOL_SHIM_DIR-unset}" + exit "$rc" + ' 2>&1) + _actual_rc=$? + set -e + + assert_eq "$_shell $_layout preserves uv status" "$_expected_rc" "$_actual_rc" + assert_eq "$_shell $_layout clears guard state" "rc=$_expected_rc guard=" "$_out" + assert_eq "$_shell $_layout preserves uv argv" \ + "venv $_case/venv --python cpython-3.12-macos-aarch64-none" "$(cat "$_case/uv.log")" + assert_eq "$_shell $_layout real tool call count" "$_expected_tool" \ + "$(wc -l < "$_case/apple-tool.log" | tr -d ' ')" + if [ "$_expected_tool" = 0 ]; then + assert_empty "$_shell $_layout never reaches Apple tool" "$_case/apple-tool.log" + else + _dylib="$_case/libpython3.12.dylib" + assert_eq "$_shell $_layout keeps uv self-ID patch" "-id $_dylib $_dylib" \ + "$(cat "$_case/apple-tool.log")" + fi + assert_eq "$_shell $_layout removes temporary shim" "0" \ + "$(find "$_case/tmp" -mindepth 1 -maxdepth 1 | wc -l | tr -d ' ')" +} + +for _sh in sh bash; do + echo "=== install_name_tool guard under $_sh ===" + run_case "$_sh" absent + run_case "$_sh" stale + run_case "$_sh" partial + run_case "$_sh" custom-selection + run_case "$_sh" partial-failure + run_case "$_sh" clt + run_case "$_sh" xcode +done + + +echo "=== exact generic/user and non-macOS forwarding ===" +for _sh in sh bash; do + _case="$_ROOT/${_sh}-forwarding" + mkdir -p "$_case/tmp" + : > "$_case/uv.log"; : > "$_case/uv-argv.log"; : > "$_case/xcode-select.log" + CASE_DIR="$_case" TMPDIR="$_case/tmp" PATH="$_BIN:$PATH" FAKE_UV_PATCH=0 \ + "$_sh" -c ' + . "'"$_EXTRACTED_HELPERS"'" + _UV_INSTALL_NAME_TOOL_SHIM_DIR="" + run_install_cmd() { shift; "$@"; } + OS=linux + _run_uv_venv "generic user Python" "$CASE_DIR/venv with spaces" \ + --python "$CASE_DIR/Python 3.12/bin/python" --offline + ' + _expected_argv=$(printf '%s\n' 'argc=5' \ + '' "<$_case/venv with spaces>" '<--python>' \ + "<$_case/Python 3.12/bin/python>" '<--offline>') + assert_eq "$_sh non-macOS preserves exact generic/user-Python argv boundaries" \ + "$_expected_argv" "$(cat "$_case/uv-argv.log")" + assert_empty "$_sh non-macOS skips the developer-tool probe" "$_case/xcode-select.log" +done + +echo "=== call-site and trap cleanup coverage ===" +_outside=$(awk ' + /^_run_uv_venv\(\)/ { in_helper=1 } + in_helper && /^}/ { in_helper=0; next } + !in_helper && /run_install_cmd .*uv venv/ { print } +' "$INSTALL_SH") +assert_empty "all real uv venv commands are routed through the guard" <(printf '%s' "$_outside") + +_cleanup=$(sed -n '/^_cleanup_install_temporaries()/,/^}/p' "$INSTALL_SH") +case "$_cleanup" in + *'_UV_INSTALL_NAME_TOOL_SHIM_DIR'*) + echo " PASS: EXIT/signal cleanup owns the shim directory" + PASS=$((PASS + 1)) + ;; + *) + echo " FAIL: _cleanup_install_temporaries does not remove the shim directory" + FAIL=$((FAIL + 1)) + ;; +esac + +if grep -q '^_UV_INSTALL_NAME_TOOL_SHIM_DIR=""' "$INSTALL_SH"; then + echo " PASS: inherited shim cleanup path is cleared before traps are installed" + PASS=$((PASS + 1)) +else + echo " FAIL: shim cleanup state is not initialized safely" + FAIL=$((FAIL + 1)) +fi + +_TRAP_HELPERS=$(mktemp) +for _f in _cleanup_install_temporaries _on_install_signal; do + sed -n "/^$_f()/,/^}/p" "$INSTALL_SH" >> "$_TRAP_HELPERS" +done + +for _sh in sh bash; do + _signal_case="$_ROOT/${_sh}-signal" + mkdir -p "$_signal_case/tmp" + : > "$_signal_case/ready" + CASE_DIR="$_signal_case" TMPDIR="$_signal_case/tmp" PATH="$_BIN:$PATH" \ + XCODE_SELECT_RC=1 "$_sh" -c ' + . "'"$_EXTRACTED_HELPERS"'" + . "'"$_TRAP_HELPERS"'" + OS=macos + _UV_OVERRIDE_TMPDIR="" + _UV_INSTALL_NAME_TOOL_SHIM_DIR="" + _UNSLOTH_TORCH_OVERRIDES="" + _restore_studio_venv_replacement() { :; } + run_install_cmd() { + printf "%s\n" "$_UV_INSTALL_NAME_TOOL_SHIM_DIR" > "$CASE_DIR/ready" + while :; do :; done + } + trap "_on_install_signal 143" TERM + _run_uv_venv "create venv" "$CASE_DIR/venv" --python cpython-3.12-macos-aarch64-none + ' >/dev/null 2>&1 & + _signal_pid=$! + for _ in $(seq 1 100); do [ -s "$_signal_case/ready" ] && break; sleep 0.01; done + _signal_shim=$(cat "$_signal_case/ready") + if [ -z "$_signal_shim" ]; then + echo " FAIL: $_sh signal case never entered guarded uv command" + FAIL=$((FAIL + 1)) + kill -KILL "$_signal_pid" 2>/dev/null || true + wait "$_signal_pid" 2>/dev/null || true + continue + fi + kill -TERM "$_signal_pid" + set +e + wait "$_signal_pid" + _signal_rc=$? + set -e + assert_eq "$_sh TERM preserves signal status" "143" "$_signal_rc" + if [ ! -e "$_signal_shim" ]; then + echo " PASS: $_sh TERM removes the active shim directory" + PASS=$((PASS + 1)) + else + echo " FAIL: $_sh TERM left shim directory $_signal_shim" + FAIL=$((FAIL + 1)) + fi +done + + +echo "" +echo "Passed: $PASS, Failed: $FAIL" +[ "$FAIL" -eq 0 ] diff --git a/tests/sh/test_macos_venv_python_preference.sh b/tests/sh/test_macos_venv_python_preference.sh index d9adfe7741c..b8df62fd963 100755 --- a/tests/sh/test_macos_venv_python_preference.sh +++ b/tests/sh/test_macos_venv_python_preference.sh @@ -43,7 +43,7 @@ _run() { "$1" -c ' . "'"$_FN"'" VENV_DIR=/tmp/venv; PYTHON_VERSION=3.12 - run_install_cmd() { + _run_uv_venv() { shift case " $* " in *" only-managed "*) echo "managed"; return '"$2"' ;; @@ -68,7 +68,7 @@ for _sh in sh bash; do _out=$("$_sh" -c ' . "'"$_FN"'" VENV_DIR=/tmp/venv; PYTHON_VERSION=3.12 - run_install_cmd() { return 2; } + _run_uv_venv() { return 2; } _uv_venv_arm64 "create venv" && echo "rc=0" || echo "rc=$?" ' 2>&1) assert_eq "both attempts fail, non-zero propagates" "rc=2" "$_out" @@ -108,7 +108,7 @@ _STREAM=$(mktemp) printf 'C_ERR=""; TAURI_MODE=true; UNSLOTH_VERBOSE=false\n' printf 'step() { :; }\ntauri_log() { :; }\n' for _f in _is_verbose tauri_stream_log tauri_clear_install_error _redact_install_output \ - run_install_cmd _uv_venv_arm64; do + run_install_cmd _macos_has_selected_install_name_tool _run_uv_venv _uv_venv_arm64; do sed -n "/^$_f()/,/^}/p" "$INSTALL_SH" done } > "$_STREAM" @@ -123,7 +123,7 @@ chmod +x "$_UVDIR/uv" _emit() { # UV_FAIL_MANAGED _sd=$(mktemp -d) - PATH="$_UVDIR:$PATH" VENV_DIR="$_sd/venv" PYTHON_VERSION=3.12 UV_FAIL_MANAGED="$1" \ + PATH="$_UVDIR:$PATH" OS=linux VENV_DIR="$_sd/venv" PYTHON_VERSION=3.12 UV_FAIL_MANAGED="$1" \ sh -c ". '$_STREAM'; _uv_venv_arm64 'create venv'; echo RC=\$?" 2>&1 rm -rf "$_sd" } From 486e86cfd4fff90f4c1b862c7c12d68e3722ed4e Mon Sep 17 00:00:00 2001 From: Wasim Yousef Said Date: Tue, 11 Aug 2026 20:39:16 +0200 Subject: [PATCH 3/6] Update workspace guard for uv wrapper --- tests/test_studio_install_workspace_guard.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/tests/test_studio_install_workspace_guard.py b/tests/test_studio_install_workspace_guard.py index fad3ab616a0..0553d31f651 100644 --- a/tests/test_studio_install_workspace_guard.py +++ b/tests/test_studio_install_workspace_guard.py @@ -326,7 +326,7 @@ def test_env_mode_blocks_when_bin_unsloth_is_broken_symlink(tmp_path): def test_install_sh_writes_venv_marker_after_uv_venv(): """install.sh must write .unsloth-studio-owned into $VENV_DIR right after `uv venv` succeeds.""" src = INSTALL_SH.read_text(encoding = "utf-8") - create_idx = src.index('run_install_cmd "create venv" uv venv "$VENV_DIR"') + create_idx = src.index('_run_uv_venv "create venv" "$VENV_DIR"') tail = src[create_idx : create_idx + 600] assert ( ".unsloth-studio-owned" in tail From 8407e950a3031c6eefbc0e28be0ab9dccd81c6fb Mon Sep 17 00:00:00 2001 From: danielhanchen Date: Tue, 11 Aug 2026 18:26:46 +0000 Subject: [PATCH 4/6] Stop the installer raising the macOS command line developer tools dialog On a Mac without the Command Line Tools, /usr/bin/git, lipo, install_name_tool and friends are libxcselect shims. Executing one resolves no developer dir and posts to com.apple.dt.CommandLineTools.installondemand, which draws the 'requires the command line developer tools' dialog naming the tool. Resolving the path does not; only execution does. Two call sites execute a shim on the consumer path: _has_working_git ran 'git --version' to decide whether git works, so on a clean Mac the probe raised the dialog it exists to detect. It now answers from the resolved path when that path is exactly /usr/bin/git and no toolchain is selected. Deliberately narrow: a Homebrew, MacPorts or Xcode.app git earlier on PATH is a real binary and is still probed by executing it, so a Mac with a working git but no CLT selected behaves exactly as before. An earlier version of this gated on 'no CLT implies no working git' and broke that case, which the existing test caught. xcode-select -p only asks which toolchain is selected and never prompts. The venv arch probe called lipo first and fell back to file -L. lipo is a shim; 2>/dev/null hides its stderr but not a GUI dialog. file is base system and always answers, so the order is swapped. Both spellings feed the same case below, against 'Mach-O 64-bit executable arm64' or 'universal binary ... [x86_64] [arm64]' rather than lipo's 'arm64' / 'x86_64 arm64', so the branch taken is unchanged. clean-machine-assert.sh already made this same swap for its own use. The cctools binaries were missing from the clean machine CI tool list, so none of this was visible: trace mode generated no wrapper and the absent list never checked them. install_name_tool, lipo, otool, objdump, vtool, strip and nm are added, which is what makes these fixes regression testable. test_macos_clt_gate.sh gains two cases pinning the contract: with a shim git and no toolchain selected the probe answers no WITHOUT executing it, proven by a stub that records execution into a marker file, and with a real git elsewhere on PATH the stub IS executed. The first assertion passed vacuously when written (wrong temp path meant the marker could never be created) and was fixed by making its pair fail first. 18 to 23 passing. --- .github/scripts/clean-machine-env.sh | 9 ++++++--- install.sh | 30 ++++++++++++++++++++++++++-- tests/sh/test_macos_clt_gate.sh | 30 ++++++++++++++++++++++++++++ 3 files changed, 64 insertions(+), 5 deletions(-) diff --git a/.github/scripts/clean-machine-env.sh b/.github/scripts/clean-machine-env.sh index 2f2387a9f07..fca7b84e4bb 100755 --- a/.github/scripts/clean-machine-env.sh +++ b/.github/scripts/clean-machine-env.sh @@ -50,9 +50,12 @@ mkdir -p "$BIN" printf '#!/usr/bin/env bash\n# Undo clean-machine-env.sh --remove. Safe to run twice.\nset -uo pipefail\n' > "$RESTORE" chmod +x "$RESTORE" -# The toolchain we care about: a consumer install must need none of it. uv's optional -# install_name_tool self-ID patch is observed separately and narrowly allow-listed. -TOOLS="xcode-select xcrun clang clang++ cc c++ gcc g++ git cmake make brew ninja cargo rustc install_name_tool" +# The toolchain we care about: a consumer install must need none of it. +# cctools binaries are included because their /usr/bin shims can trigger the same +# developer-tools dialog. uv's exact optional install_name_tool self-ID patch is +# observed separately and narrowly allow-listed by clean-machine-assert.sh. +TOOLS="xcode-select xcrun clang clang++ cc c++ gcc g++ git cmake make brew ninja cargo rustc +install_name_tool lipo otool objdump vtool strip nm" note() { echo "[clean-machine] $*"; } diff --git a/install.sh b/install.sh index d5125289be0..6a908ceecdb 100755 --- a/install.sh +++ b/install.sh @@ -2107,6 +2107,24 @@ tauri_log "STEP" "Checking system dependencies" # a GUI dialog, so `command -v git` is not enough -- only running it tells the truth. _has_working_git() { command -v git >/dev/null 2>&1 || return 1 + # Executing the probe is the problem on macOS: /usr/bin/git is a Command Line Tools + # shim, so `git --version` against it raises the "install the command line developer + # tools" GUI dialog -- the probe firing the dialog it exists to detect. Answer from the + # resolved path instead when it is that exact shim and no toolchain is selected. + # + # Narrow on purpose. Only /usr/bin/git is a shim: a Homebrew, MacPorts or Xcode.app git + # earlier on PATH is a real binary and is still probed by executing it, so a Mac with a + # working git but no CLT selected keeps working exactly as before. xcode-select -p only + # asks which toolchain is selected and never prompts. + # $OS is the platform detected above, not a fresh `uname` call: this can run with a + # scrubbed PATH where uname is not resolvable, and a failed probe there would silently + # fall through to executing the shim. _CLT_GIT_SHIM is the shim path, overridable so + # the branch is testable without a /usr/bin write. + if [ "${OS:-}" = "macos" ] && + [ "$(command -v git)" = "${_CLT_GIT_SHIM:-/usr/bin/git}" ] && + ! xcode-select -p >/dev/null 2>&1; then + return 1 + fi git --version >/dev/null 2>&1 } @@ -2645,8 +2663,16 @@ if [ -z "$_USER_PYTHON" ] && [ "$OS" = "macos" ] && [ "$_ARCH" = "arm64" ]; then # uv symlinks bin/python to the base interpreter, so dereference with # file -L (lipo already follows the link). Trailing || true keeps the # installer alive under set -e when neither tool is present. - _archs=$(lipo -archs "$VENV_DIR/bin/python" 2>/dev/null \ - || file -L "$VENV_DIR/bin/python" 2>/dev/null || true) + # + # file -L FIRST, lipo only as the fallback: lipo is a Command Line Tools shim, so + # on a Mac without CLT merely running it raises the "install the command line + # developer tools" dialog -- 2>/dev/null hides its stderr but not a GUI dialog. + # file is base-system and always answers. Both spellings feed the same case below + # ("Mach-O 64-bit executable arm64", or "universal binary ... [x86_64] [arm64]", + # against lipo's "arm64" / "x86_64 arm64"), so the branch taken is unchanged. + # clean-machine-assert.sh:152 already made exactly this swap for its own use. + _archs=$(file -L "$VENV_DIR/bin/python" 2>/dev/null \ + || lipo -archs "$VENV_DIR/bin/python" 2>/dev/null || true) case "$_archs" in *arm64*) _VENV_ARCH=arm64 ;; *x86_64*) _VENV_ARCH=x86_64 ;; diff --git a/tests/sh/test_macos_clt_gate.sh b/tests/sh/test_macos_clt_gate.sh index 2779df191e4..88741a2bdee 100755 --- a/tests/sh/test_macos_clt_gate.sh +++ b/tests/sh/test_macos_clt_gate.sh @@ -158,6 +158,36 @@ rm -f "$_BIN"/git _r="$(PATH="$_BIN" "$_SH" -c ". '$_FN_FILE'; _has_working_git && echo yes || echo no")" assert_eq "absent git -> no" "no" "$_r" +# On macOS the probe must ANSWER FROM THE PATH, never execute /usr/bin/git: running the +# CLT shim is what raises the "install the command line developer tools" GUI dialog. The +# git stub here records execution, so an empty marker file is the proof it stayed unrun. +echo "=== macOS: the CLT git shim is never executed ===" +rm -f "$_BIN"/* +_RAN="$(mktemp -u)" +rm -f "$_RAN" +_mk git "echo ran >> '$_RAN'; exit 1" +_mk xcode-select 'exit 1' # no toolchain selected == a clean Mac +_r="$(PATH="$_BIN" OS=macos _CLT_GIT_SHIM="$_BIN/git" "$_SH" -c \ + ". '$_FN_FILE'; _has_working_git && echo yes || echo no")" +assert_eq "clean Mac + shim git -> no" "no" "$_r" +if [ -f "$_RAN" ]; then + assert_eq "shim git was NOT executed (no GUI dialog)" "not-executed" "executed" +else + assert_eq "shim git was NOT executed (no GUI dialog)" "not-executed" "not-executed" +fi + +# A real git elsewhere on PATH (Homebrew) must still be probed by executing it, so a Mac +# with a working git but no CLT selected keeps working exactly as before. +rm -f "$_RAN" +_r="$(PATH="$_BIN" OS=macos _CLT_GIT_SHIM=/usr/bin/git "$_SH" -c \ + ". '$_FN_FILE'; _has_working_git && echo yes || echo no")" +assert_eq "clean Mac + non-shim working git -> probed" "no" "$_r" +if [ -f "$_RAN" ]; then + assert_eq "non-shim git WAS executed" "executed" "executed" +else + assert_eq "non-shim git WAS executed" "executed" "not-executed" +fi + rm -rf "$_BIN" "$_FN_FILE" "$_HARNESS" echo "" From c2705e70162d4b77ab5e4e996557481528d37478 Mon Sep 17 00:00:00 2001 From: Wasim Yousef Said Date: Tue, 11 Aug 2026 20:54:09 +0200 Subject: [PATCH 5/6] Preserve working git on Intel macOS --- install.sh | 8 +++++--- tests/sh/test_macos_clt_gate.sh | 15 +++++++++++++++ 2 files changed, 20 insertions(+), 3 deletions(-) diff --git a/install.sh b/install.sh index 6a908ceecdb..ba77c5bc67b 100755 --- a/install.sh +++ b/install.sh @@ -2113,14 +2113,16 @@ _has_working_git() { # resolved path instead when it is that exact shim and no toolchain is selected. # # Narrow on purpose. Only /usr/bin/git is a shim: a Homebrew, MacPorts or Xcode.app git - # earlier on PATH is a real binary and is still probed by executing it, so a Mac with a - # working git but no CLT selected keeps working exactly as before. xcode-select -p only - # asks which toolchain is selected and never prompts. + # earlier on PATH is a real binary and is still probed by executing it. Intel macOS can + # also ship a working /usr/bin/git after CLT masking, so MAC_INTEL must probe that path; + # only Apple Silicon treats it as the known dialog shim without execution. xcode-select + # -p only asks which toolchain is selected and never prompts. # $OS is the platform detected above, not a fresh `uname` call: this can run with a # scrubbed PATH where uname is not resolvable, and a failed probe there would silently # fall through to executing the shim. _CLT_GIT_SHIM is the shim path, overridable so # the branch is testable without a /usr/bin write. if [ "${OS:-}" = "macos" ] && + [ "${MAC_INTEL:-false}" != true ] && [ "$(command -v git)" = "${_CLT_GIT_SHIM:-/usr/bin/git}" ] && ! xcode-select -p >/dev/null 2>&1; then return 1 diff --git a/tests/sh/test_macos_clt_gate.sh b/tests/sh/test_macos_clt_gate.sh index 88741a2bdee..1cc32e9bd39 100755 --- a/tests/sh/test_macos_clt_gate.sh +++ b/tests/sh/test_macos_clt_gate.sh @@ -176,9 +176,24 @@ else assert_eq "shim git was NOT executed (no GUI dialog)" "not-executed" "not-executed" fi +# Intel hosted runners can have a real working /usr/bin/git even with no selected CLT. +# MAC_INTEL is established from hardware detection before this helper runs, so probe +# the same path on real Intel while still refusing it on Apple Silicon. +rm -f "$_RAN" +_mk git "echo ran >> '$_RAN'; exit 0" +_r="$(PATH="$_BIN" OS=macos MAC_INTEL=true _CLT_GIT_SHIM="$_BIN/git" "$_SH" -c \ + ". '$_FN_FILE'; _has_working_git && echo yes || echo no")" +assert_eq "Intel Mac + working system git -> yes" "yes" "$_r" +if [ -f "$_RAN" ]; then + assert_eq "Intel system git WAS executed" "executed" "executed" +else + assert_eq "Intel system git WAS executed" "executed" "not-executed" +fi + # A real git elsewhere on PATH (Homebrew) must still be probed by executing it, so a Mac # with a working git but no CLT selected keeps working exactly as before. rm -f "$_RAN" +_mk git "echo ran >> '$_RAN'; exit 1" _r="$(PATH="$_BIN" OS=macos _CLT_GIT_SHIM=/usr/bin/git "$_SH" -c \ ". '$_FN_FILE'; _has_working_git && echo yes || echo no")" assert_eq "clean Mac + non-shim working git -> probed" "no" "$_r" From e913d13dccc431a5de50ea51c1187db7b66fca09 Mon Sep 17 00:00:00 2001 From: danielhanchen Date: Tue, 11 Aug 2026 19:14:11 +0000 Subject: [PATCH 6/6] Keep the git shim guard on under Rosetta --- install.sh | 11 ++++++++++- tests/sh/test_macos_clt_gate.sh | 20 ++++++++++++++++++++ 2 files changed, 30 insertions(+), 1 deletion(-) diff --git a/install.sh b/install.sh index ba77c5bc67b..087eb05d7d4 100755 --- a/install.sh +++ b/install.sh @@ -1837,10 +1837,16 @@ fi # ── Architecture detection & Python version ── _ARCH=$(uname -m) MAC_INTEL=false +# Rosetta is a property of the shell, not of the machine, so it is tracked apart from +# MAC_INTEL: torch and the Python version rightly follow the x86_64 shell, but anything +# that reasons about the HARDWARE (the /usr/bin CLT shims in _has_working_git) must see +# an Apple Silicon Mac here, not an Intel one. +_MAC_ROSETTA=false if [ "$OS" = "macos" ] && [ "$_ARCH" = "x86_64" ]; then # Guard against Apple Silicon running under Rosetta (reports x86_64). # sysctl hw.optional.arm64 returns "1" on Apple Silicon even in Rosetta. if [ "$(sysctl -in hw.optional.arm64 2>/dev/null || echo 0)" = "1" ]; then + _MAC_ROSETTA=true echo "" echo " WARNING: Apple Silicon detected, but this shell is running under Rosetta (x86_64)." echo " Re-run install.sh from a native arm64 terminal for full PyTorch support." @@ -2117,12 +2123,15 @@ _has_working_git() { # also ship a working /usr/bin/git after CLT masking, so MAC_INTEL must probe that path; # only Apple Silicon treats it as the known dialog shim without execution. xcode-select # -p only asks which toolchain is selected and never prompts. + # The hardware decides, not the shell: MAC_INTEL is also true for an x86_64 shell under + # Rosetta, where /usr/bin/git is still the arm64 machine's dialog shim, so _MAC_ROSETTA + # puts that host back on the non-executing branch. # $OS is the platform detected above, not a fresh `uname` call: this can run with a # scrubbed PATH where uname is not resolvable, and a failed probe there would silently # fall through to executing the shim. _CLT_GIT_SHIM is the shim path, overridable so # the branch is testable without a /usr/bin write. if [ "${OS:-}" = "macos" ] && - [ "${MAC_INTEL:-false}" != true ] && + { [ "${MAC_INTEL:-false}" != true ] || [ "${_MAC_ROSETTA:-false}" = true ]; } && [ "$(command -v git)" = "${_CLT_GIT_SHIM:-/usr/bin/git}" ] && ! xcode-select -p >/dev/null 2>&1; then return 1 diff --git a/tests/sh/test_macos_clt_gate.sh b/tests/sh/test_macos_clt_gate.sh index 1cc32e9bd39..5cbf17f8e43 100755 --- a/tests/sh/test_macos_clt_gate.sh +++ b/tests/sh/test_macos_clt_gate.sh @@ -190,6 +190,26 @@ else assert_eq "Intel system git WAS executed" "executed" "not-executed" fi +# An x86_64 shell under Rosetta also sets MAC_INTEL, but the machine is Apple Silicon and +# /usr/bin/git is still its dialog shim, so the Intel exception above must not apply: the +# hardware answer (_MAC_ROSETTA) wins over the shell's reported architecture. +rm -f "$_RAN" +_mk git "echo ran >> '$_RAN'; exit 1" +_r="$(PATH="$_BIN" OS=macos MAC_INTEL=true _MAC_ROSETTA=true _CLT_GIT_SHIM="$_BIN/git" "$_SH" -c \ + ". '$_FN_FILE'; _has_working_git && echo yes || echo no")" +assert_eq "Rosetta on Apple Silicon + shim git -> no" "no" "$_r" +if [ -f "$_RAN" ]; then + assert_eq "Rosetta shim git was NOT executed (no GUI dialog)" "not-executed" "executed" +else + assert_eq "Rosetta shim git was NOT executed (no GUI dialog)" "not-executed" "not-executed" +fi + +# The architecture block is what sets _MAC_ROSETTA, so pin it here too: a rename or a +# dropped assignment would leave the guard above reading an unset variable and silently +# fall back to executing the shim. +assert_contains "install.sh sets _MAC_ROSETTA when sysctl reports arm64 hardware" \ + "$(sed -n '/^MAC_INTEL=false$/,/^fi$/p' "$INSTALL_SH")" "_MAC_ROSETTA=true" + # A real git elsewhere on PATH (Homebrew) must still be probed by executing it, so a Mac # with a working git but no CLT selected keeps working exactly as before. rm -f "$_RAN"