mirror of
https://github.com/unslothai/unsloth.git
synced 2026-08-24 16:23:51 +00:00
* Windows: start the backend from a usable folder on login autostart "Run Unsloth at login" registers the desktop through an HKCU Run value, which cannot carry a working directory, so Windows starts the app in C:\Windows\system32. Every `unsloth` CLI child inherited that folder, and the CLI refuses to run there, so a reboot produced a tray icon and no server (issue #8510). Desktop side: pick the working directory explicitly for every CLI child (backend, both preflight probes, the install check, auth provisioning, update, installer) instead of passing on whatever the launcher gave us. The inherited folder is kept whenever it is usable, so ./models and other cwd-relative defaults resolve exactly where they used to; only a Windows system folder is replaced, with ~/.unsloth. A home that cannot be reached at all now reports working_directory_unavailable rather than looking like a broken install, which stops the pointless automatic repair and gets its own message in the UI. CLI side: move the System32 guard into unsloth_cli/_system_dir_guard.py and run it before the command modules import, since commands.studio resolves STUDIO_HOME at import time. The commands the desktop itself issues take no path from the user, so they move to a safe folder and carry on; everything else keeps the hard error, since relocating a command would silently rebase the relative paths its caller typed. This half fixes anyone whose installed desktop build predates the change, without waiting for a desktop release. * [pre-commit.ci] auto fixes from pre-commit.com hooks for more information, see https://pre-commit.ci * Only trust a Windows directory that holds System32, and tell an absent profile from an absent install Two review findings on the working-directory work. WINDIR is an ordinary variable, so pointing it at the user's own profile made windows_roots() treat that profile as a Windows installation: an ordinary project folder underneath it then looked like a system folder, the fallback rejected the home for being "inside the Windows directory", and the backend could not start anywhere on that machine. Candidates are now checked rather than trusted, and a directory only counts if it actually contains System32. With nothing on the machine looking like Windows, the check falls back to SystemRoot or the default, never to the settable value. Same fix on both sides, since the CLI guard reads the same variables. The managed install lives under the user's profile, so a profile that is not mounted yet makes find_unsloth_binary() return None and preflight reported NotInstalled before the working-directory check could run. That is the exact case the check was added for, and it was sending those users to reinstall. Check whether the home is reachable before turning a failed lookup into "not installed", and report it as its own state with no binary path and no repair offered. * [pre-commit.ci] auto fixes from pre-commit.com hooks for more information, see https://pre-commit.ci * Tighten the comments added by this PR * Keep the preflight message choice testable The branch that tells an unreachable profile from an outdated install sat inline in use-tauri-backend.ts, which pulls in React and the Tauri APIs and so cannot be imported from a test. Move the choice into its own module and drive it directly, so a new backend reason cannot silently land in the stale-install bucket again. * Pin relative path overrides before relocating out of a system folder Studio resolves UNSLOTH_STUDIO_HOME and the cache overrides with Path.resolve(), which anchors a relative value to the working directory. So `UNSLOTH_STUDIO_HOME=.\custom unsloth studio update` from System32 moved first and then resolved the override against the new directory, silently targeting a folder the caller never named. That is the one thing this guard is supposed not to do to caller-supplied paths. Absolutise the relative overrides against the original directory before the move, so they keep meaning what they meant. A ~ value is left alone, since expanduser does not consult the working directory, and an environment that cannot be pinned is one we refuse to move underneath. Covers the Studio home pair plus the cache and llama.cpp overrides, which resolve the same way: UNSLOTH_LLAMA_CPP_PATH, UNSLOTH_COMPILE_LOCATION, HF_HOME, HF_HUB_CACHE, HUGGINGFACE_HUB_CACHE, HF_XET_CACHE. * [pre-commit.ci] auto fixes from pre-commit.com hooks for more information, see https://pre-commit.ci * Pin every relative path override and withhold repair on an unreachable profile - pin_relative_overrides() missed the Studio documents, projects and sandbox roots and the sd.cpp/whisper.cpp/llama.cpp engine paths, so a relative value would have been retargeted by the move, and the remaining single-path cache overrides are pinned for the same reason - an owned or ownerless-spawned backend that is stale no longer offers auto repair when the managed profile is unreachable: the repair runs through the same profile and stops a backend that still answers * Pin relative overrides on the desktop side too, and align the home checks - the desktop moved a CLI child out of a system folder without rewriting the relative path overrides it inherited, so the same install placed state in a different folder depending on whether the desktop or the CLI guard did the move; both layers now anchor those values to the directory being left, with a test that fails if the two lists drift apart - pin the diffusion cache dirs, OLLAMA_MODELS, DG_VISUAL_BIN and UNSLOTH_DG_SHIM, which are resolved against the working directory as well - home_dir_available() accepted a home the working directory resolver then rejected, so a SYSTEM account was offered an install that cannot start; both now go through usable_home_dir() * Report an unreachable profile as the reason a stale backend cannot repair Withholding auto repair was not enough: the result still carried the backend's own reason, so the frontend advised running the update, which needs the same profile the probe could not reach. Both stale paths now report working_directory_unavailable, and the backend's reason goes to the log. * Resolve drive-relative overrides through the OS before moving A value such as HF_HOME=D:cache names the current directory on drive D, so joining it to the folder being left hands it straight back and the move retargets it. Both layers now ask the OS to resolve it first, GetFullPathNameW through ntpath.abspath and std::path::absolute, which is what tracks each drive's own directory. The CLI guard refuses to move at all if that resolution fails, rather than moving and silently changing where the value points. * [pre-commit.ci] auto fixes from pre-commit.com hooks for more information, see https://pre-commit.ci * Pin the stable-diffusion binary overrides and the rest of the cache family SD_CLI_PATH and SD_SERVER_PATH are the highest-priority binary locations for the sd.cpp engine, so a relative value pointed somewhere else after the move. Added alongside the llama.cpp and whisper.cpp equivalents, together with the HF and XDG names that belong to the same families as the ones already pinned. * Pin the GPU SDK roots, and do not relocate into a missing or shared profile - CUDA_PATH, HIP_PATH, HIP_PATH_57 and ROCM_PATH are joined with bin/ for DLL discovery, so a relative value pointed elsewhere after the move - a profile that has not mounted yet still has a writable parent, so makedirs built an empty second profile that would shadow the real one when it arrives; the guard now requires the home to exist, as the Rust resolver does - the public profile is refused whichever variable named it: allow_public only kept PUBLIC out of the candidate list, so a USERPROFILE or ~ that resolves there still put one account's state in a folder shared by every account * [pre-commit.ci] auto fixes from pre-commit.com hooks for more information, see https://pre-commit.ci * Tighten the comments added since the last pass * Cover running the working-directory fix more than once Both halves run repeatedly over state an earlier run touched: the desktop resolves the directory on every spawn, the guard runs in every CLI process, and a child's environment reaches its own grandchildren. Two tests pin the fixpoint, one for a command configured twice and one for a value that has already been anchored. * Pin STUDIO_LOCAL_REPO, and blame a lost profile for the probe that failed - "studio update" is one of the commands that relocates, and it resolves a relative STUDIO_LOCAL_REPO against the working directory, so the move retargeted the checkout the user meant to install from - a profile can drop between the working directory check and the probes that follow, which reported cli_unusable or desktop_capability_probe_failed and offered a repair needing that same profile; both now ask again and report the profile when it is what went missing * Narrow the desktop marker, and anchor the values a move could still retarget Five independent reviews of the branch agreed on the same three gaps: - the marker is inherited by the backend and everything below it, so treating it as authorisation for any "studio" subcommand let a marked descendant relocate "studio run --model .\local.gguf", rebasing a path the caller chose. It now authorises only invocations that carry no path, which is what the desktop actually runs - a value like "\cache" is rooted to the drive of the current directory, not to a drive, so a profile on another drive moved it. Root-relative values now go through the OS with the drive-relative ones, and the extended prefix is matched case-insensitively, since the object manager accepts \\?\unc\ too - four more single-path overrides are pinned, and two path lists are anchored entry by entry, so one relative entry cannot change what a whole search or allowlist means The desktop also relocated a child out of any folder under the Windows tree, while the CLI only ever refused System32 and SysWOW64. It now uses the same definition, so a child running from somewhere like C:\Windows\Temp keeps the directory it had. * [pre-commit.ci] auto fixes from pre-commit.com hooks for more information, see https://pre-commit.ci * Offer the roaming-profile cause only where roaming profiles exist A platform-isolation audit found this PR's working-directory-unavailable message asserting a Windows cause on every platform: Unsloth cannot reach your user folder, so it has nowhere to run from. This usually means a network or roaming profile is not available yet. The Rust half of this PR is deliberately not #[cfg]-gated; isolation comes from windows_roots() returning empty off Windows, which does hold for every env-var rewrite. But home_dir_available() is called ungated from preflight/managed.rs, so ManagedProbe::Unavailable{working_directory_unavailable} is reachable on Linux and macOS, where the same symptom means an unmounted home or a permissions problem. A roaming profile is a Windows concept and naming it there sends the reader looking for something that is not present. The symptom and the remedy are unchanged everywhere. Only the CAUSE moves behind a platform check, so it is offered where it applies instead of asserted everywhere. Test covers both directions: Windows still gets the sentence, Linux and macOS do not, and both keep the symptom and "Reconnect and try again". studio/frontend/tests/backend-preflight-message.test.ts 4 passed * Do not let the interpreter decide which folder a value names The Windows cross-platform CI caught this: ntpath.isabs answered True for a leading separator until Python 3.13 and False after it, so on 3.12 a root-relative "\cache" was treated as fully qualified and left to move with the working directory, which is the retargeting the pinning exists to prevent. Both halves now spell the test out, a drive plus a separator or a UNC share, so the same value names the same folder on every interpreter and on the Linux runner that tests the Rust half. * Run the update smoke tests when the CLI guard changes The guard runs on every CLI invocation, "unsloth studio update" included, but neither update workflow listed it, so the two suites that install Unsloth and then update it twice never ran for this change. * Do not rewrite an override the working directory never resolved Three of the pinned names are read as something other than a path by the code that consumes them, so anchoring one changed its meaning instead of preserving it. MLX_HOSTFILE holds either a filename or the host list itself, huggingface_hub expands %VAR% in HF_HOME and its neighbours after the guard has run, and the pre-quant allowlist ignores a bare on/off token precisely so that there is no allow-all mode: anchoring the "1" would have turned it into a real allowlisted directory. All three are now left alone, in the CLI guard and in the desktop twin. Also in this pass: - studio --frontend=.\dist carries a path inside the option token, which the marker gate missed because it only looked for a bare argument after the subcommand. A marked child running it is refused rather than moved. - \Windows\System32\config\systemprofile names SYSTEM's profile without a drive, so it compared equal to no drive-qualified Windows root and was accepted as a home. The drive-less spelling of each Windows root is compared too. - The child is only told where to run when that differs from where the parent already is. Reopening an inherited directory by name can fail if an ancestor turned unreadable after launch, where inheriting the open handle would have worked, so the no-move case stays exactly as it was. - An override the OS declines to resolve now refuses the whole move on the desktop side rather than being dropped, which is what the CLI guard already did: moving with that value still relative would retarget it. - The tests that read the ambient environment take the crate-wide env lock; XDG_DATA_HOME is one of the pinned names now, and the test that swaps it was documented as the only reader. * Scope the non-path exemptions, and anchor the import roots too Three follow-ups on the pinning: The exemptions for inline JSON, %VAR% / $VAR and bare on/off tokens now name the variables whose reader proves them, rather than applying to every pinned name. A directory really called [llama] or %data% is legal on Windows and UNSLOTH_LLAMA_CPP_PATH is read as exactly that, so the blanket form left it unpinned and the move retargeted it. PYTHONPATH joins the anchored search lists, and the guard anchors the relative entries this interpreter is already carrying in sys.path: those are resolved on every import, not at startup, so a move would let whatever sits in ~/.unsloth shadow a managed import. sys.path is best effort, unlike the environment: an import root is not worth refusing the move over, and refusing is how the login start broke to begin with. PATH is deliberately left out, being mostly other people's absolute entries. Preflight asks for the whole managed context, not just the directory. An override the OS declines to resolve fails the same spawn, and a probe that returned false for it was read as a broken CLI, which started an automatic repair that needed the same context and failed the same way. * [pre-commit.ci] auto fixes from pre-commit.com hooks for more information, see https://pre-commit.ci * Expand a cache override before deciding, and read the real invocation Three more from the review: huggingface_hub expands %VAR% in HF_HOME and its neighbours, but Studio's own hf_cache_settings._canonical() does not, so leaving such a value as written sent the two readers to different folders once the process moved. The guard now expands those names before deciding, writes the expanded form back when expanding is what made it name a folder, and anchors it when it does not. A name the machine does not set stays as written, which is what expandvars does too. The desktop got the same, with a small %VAR% expander since std has none. The updater removes PYTHONPATH on Windows, and pinning an inherited relative one put it straight back. The removal now happens after the managed context is applied: -I only covers the first interpreter, and the update's PowerShell and setup descendants start further Python processes that do not clear it. The Typer callback classified sys.argv even when the app was called as a library, so a host whose own argv looked like a desktop command could move the process out from under the caller's relative paths. It reads the invocation Click is running instead, and where Click keeps the tail to itself the invocation is refused rather than relocated. The console script is unaffected: it is classified at import, from the real argv. * [pre-commit.ci] auto fixes from pre-commit.com hooks for more information, see https://pre-commit.ci * Take the whole invocation, and the spellings that follow the process Four more from the review: os.path.expandvars takes %NAME%, $NAME and ${NAME} on a Windows path; the desktop expander only took the first, so a value written $LOCALAPPDATA\hf was anchored under System32 by the desktop while the CLI guard would have kept it. All three forms now, with a test that walks each one. The Typer group records the tokens it was handed. Click keeps the tail on the child context, so the callback saw only the subcommand name and refused a library `studio --api-only` that it should have relocated. Reading the recorded list classifies the invocation in full, whether it arrives through app(args = [...]) or a runner. HF_TOKEN_PATH is pinned like the caches beside it: huggingface_hub reads the credential file from there, and a relative value would follow the child and lose access to gated repos while everything still looked healthy. PYTHONPATH has two spellings that follow the process rather than the caller: an empty component means the working directory itself, and `~` is never expanded there, so Python reads ~\plugins as an ordinary relative folder. Both are anchored to the directory being left, in the environment, in sys.path and in the desktop twin. * [pre-commit.ci] auto fixes from pre-commit.com hooks for more information, see https://pre-commit.ci * Only anchor a sys.path entry that is a directory, and name the setting Two findings from a fresh review round. sys.path holds other people's strings as well as folders. setuptools registers a relative sentinel for an editable namespace install and its own path hook accepts that sentinel by exact equality, and a relative .zip keeps the spelling its already imported packages hold in their loaders; rewriting either breaks the import the pinning was meant to protect. Both were reproduced: an editable namespace stopped importing after the rewrite, and a package loaded from a relative archive lost its submodules. Only an entry that is a directory right now is anchored, plus the empty entry, which is the working directory by definition. A value that cannot be pinned now says so. Windows caps an environment variable at 32767 characters, so a long enough list can stop fitting once every entry names its folder in full, and a drive with no current directory of its own cannot be resolved either. Both were reported as "check that the user profile for this account exists and is writable", which sends the reader looking in the wrong place. * [pre-commit.ci] auto fixes from pre-commit.com hooks for more information, see https://pre-commit.ci * Expand exactly as ntpath does, and say which context failed The desktop expander read $NAME as far as the first non-word character, where ntpath counts a hyphen as part of the name. With CACHE and CACHE-ROOT both set, HF_HOME=$CACHE-ROOT\hf resolved to C:\right\hf in the CLI and C:\wrong-ROOT\hf in the desktop: one install, two folders. It now mirrors ntpath's pattern outright, including the single-quoted run that is copied through unexpanded, %% and $$ standing for one character, and anything unterminated staying as written. The guard's own tests call os.path.expandvars against the environment they describe rather than a stand-in that only knew %NAME%. Preflight tells its two context failures apart. A probe that could not be configured returns that instead of false, so a context which recovers between the failed apply and the check afterwards can no longer make an untested CLI look broken and start a repair. And an override the OS cannot resolve is reported as path_setting_unresolvable rather than as an unreachable user folder: the profile is fine, the value is not, and the frontend now says so instead of advising a reconnect. * Never panic on the spawn path, and leave a host's state as it was Five more from the review. The backend spawn unwrapped the managed context, so a drive that went between the preflight check and the spawn took the desktop down with it. It reports through the same diagnostics path as every other start failure now. UNSLOTH_STUDIO_HOME and STUDIO_HOME are removed for every managed child, because Tauri uses the legacy root whatever the environment says. Trying to resolve them could only invent a failure for a value the child never sees, so they are skipped when pinning and removed by the context helper itself rather than only at the call sites. A child was moved with its relative overrides untouched when the original directory could not be read at all. That silently retargets each of them at the new directory, so it is refused unless there is nothing relative left to preserve. The Typer callback no longer relocates. It runs after the command modules are imported, and commands.studio resolves STUDIO_HOME at import time, so a host that reached that point cannot be moved without leaving the cached root behind. The console script is unaffected: it is checked before any command module loads. The environment and sys.path are put back if the move does not happen. Inside a host process both belong to the caller, and a chdir that fails after pinning left them rewritten as though it had succeeded. * Write a ~ value out, and pin the uv cache Two more from the review round. A `~` value was left alone on the grounds that expanduser does not consult the working directory. That is true of expanduser and false of the readers: llama_cpp.py hands UNSLOTH_LLAMA_CPP_PATH and LLAMA_SERVER_PATH straight to Path(), as the whisper and stable diffusion overrides do with theirs, so `~\llama.cpp` was an ordinary relative path for them and followed the child to the new directory. It is written out now, on both sides, which is what the caller meant and what the readers that do call expanduser would have computed for themselves. That also covers the PYTHONPATH case more honestly than anchoring it did: `~` names the profile rather than a folder called "~" beside the old directory. UV_CACHE_DIR joins the pinned names. uv reads it as written, Studio treats a non-blank value as authoritative, and `unsloth studio update` runs uv through setup.ps1, so a relative one moved the install cache. The guard's tests and the simulations now call the real expanduser against the environment each case describes; the stand-in returned the profile for every input, which is what hid this. * [pre-commit.ci] auto fixes from pre-commit.com hooks for more information, see https://pre-commit.ci * Judge a list entry by entry, and keep the checkout out of the children Three more from the review. The lost-directory check read a whole path list as one value, so PYTHONPATH=C:\vendor;plugins looked qualified because of the drive at the front while `plugins` still depended on the directory that was gone. Each entry is judged on its own now, with the empty PYTHONPATH component counting as the directory itself. MLX_IBV_DEVICES is pinned beside MLX_HOSTFILE and exempted from anchoring the same way. `_json_rank_count_from_env` reads the two identically: either the device list inline as JSON, or a filename. STUDIO_LOCAL_REPO is read by the update and installer path alone (install_python_stack.py), so a stale drive-relative value was failing preflight, backend startup, capability probes and auth provisioning over a setting none of them look at. It stays pinned for the update child and is dropped for the rest. * Anchor an import root that is really there, and read /var/cache as absolute Two more from the review. The lost-directory check judged every value by Windows rules, and it is the one part of the pinning that runs off Windows: a desktop on Linux or macOS whose launch directory was deleted read XDG_CACHE_HOME=/var/cache as relative and failed every managed spawn with path_setting_unresolvable over a value that depends on no directory at all. The native spelling counts there too now. sys.path entries are anchored when they name something on disk rather than only a directory. Skipping every non-directory kept setuptools' editable sentinel safe, which is what it was for, but it also left a relative importable archive behind: os.environ is not sys.path, so after the move the next import from that archive looked for it beside the new working directory. A folder or an archive is anchored; a string that names nothing on disk is still left exactly as written. * [pre-commit.ci] auto fixes from pre-commit.com hooks for more information, see https://pre-commit.ci * Read a POSIX path list with POSIX rules, separator included The lost-directory fallback is the one part of the pinning that also runs off Windows, and absoluteness there was already fixed. The separator was not: the list was still split on ';', so "/opt/vendor:plugins" stayed a single entry, started with '/', and passed as absolute. The relative entry behind it was never seen, and the child got it resolved against the wrong directory. Deciding both from a parameter rather than cfg! is what makes this testable. The existing entry-by-entry test feeds a Windows list and runs on every platform, so a compile-time cfg would either break it off Windows or leave the POSIX path untested. Now each test names the rules it means, and the POSIX one also pins that a Windows-shaped value stays Windows-judged. Supersedes the narrower POSIX test added alongside the absoluteness fix: same fixture, and the new one additionally covers the separator and the Windows-stays-Windows direction. * Normalize overrides before refusing a lost-directory move When the launch directory is gone, the pin scan judged each override exactly as written, without the expansion and non-path exemptions the moving path applies. HF_HOME=%LOCALAPPDATA%\hf, an inline JSON MLX_HOSTFILE and UNSLOTH_ALLOW_LOCAL_PREQUANT_PATH=1 were all read as relative, so preflight and every managed spawn failed with path_setting_unresolvable over values that never depended on a directory. The same normalization now runs before the check. * Shorten the comments added by this branch Same intent, fewer lines: the reasons that are not obvious from the code stay, the restatements of it go. No code or behaviour change. * Pin the model paths llama-server reads for itself llama-server takes LLAMA_ARG_MODEL, LLAMA_ARG_MMPROJ and the two draft-model spellings straight from the environment and resolves a relative one against its own working directory, and Studio reads them back when it sizes a launch (llama_cpp.py). A managed child moved out of System32 with one of those still relative would look for the model or the projector beneath ~/.unsloth. Both mirrored lists now carry them. The URL and HF-repo spellings stay out: they name no local file. * Settle an expansion before anchoring it, and restore the real sys.path Two defects found by a fresh round of idempotency review. A value whose expansion needs a second pass, LOCALAPPDATA holding %USERPROFILE% and HF_HOME holding %LOCALAPPDATA%, was anchored while still half expanded, so the reader that expands saw a folder name with a second drive in the middle of it. Expansion now runs to a fixpoint, and a value that never settles, HF_HOME holding itself, is left exactly as written rather than anchored or grown. Both layers, with a test each. The console script passes no list, so the guard pinned the real sys.path with no snapshot to put back: a chdir that then failed left the process carrying import roots it never agreed to, while the environment was restored. The snapshot is now taken from the list actually being pinned. The existing rollback test passed its own list and so missed the one path production takes; the harness can now leave it out. * Never let a lost directory fail a spawn, and let the installer build its profile Two success-to-failure changes found by the same review round. A process whose working directory has been deleted or unmounted can still spawn children from the handle it holds. Pinning cannot anchor anything to a directory it cannot name, and the answer was to refuse, which took the capability probe, the backend start, the auth provision and the update down over a setting the command may never read. The pins still report what a move would lose; that report now decides whether the child moves rather than whether it runs, so it stays where it is, exactly as it did before this file learned about working directories. The installer shared the managed resolver, which requires the home to exist so that a child never builds an empty folder shadowing a roaming profile that has not mounted yet. install.ps1 and install.sh detect a SYSTEM profile themselves, and before they shared the resolver a home that did not exist was simply created along with ~/.unsloth, so they get that policy back. * [pre-commit.ci] auto fixes from pre-commit.com hooks for more information, see https://pre-commit.ci * Pin the pinning decision as a table * Expand a value the way its reader does, and never leave the process elsewhere Four findings from a fresh round of review, all in code this branch added. Expansion now runs exactly one pass, which is what every reader runs, and the result is written back only when expanding it again would change nothing. The fixpoint loop that was here consumed an escaped %%NAME%% on the first pass and then expanded what the escape was protecting, and a nested reference came out half expanded. Both are now left exactly as written, alongside the self-referencing case they were added for. Python never expands ~ in PYTHONPATH, so neither does the pinning: `~\plugins` is anchored as the relative folder the interpreter actually reads rather than turned into a profile folder it was never reading, which also stops an inert entry from becoming an importable one. Click reads `-f.\dist` as a value exactly as it reads `--frontend=.\dist`, so an invocation carrying one is refused rather than rebased under the new folder. A chdir can succeed and still land somewhere the guard refuses, through a junction or a profile inside the Windows tree. The process now goes back where it started, so the values written for the move are put back with it instead of being restored under a directory nobody chose. * Resolve a tilde the way ntpath does, and pin two more paths More from the same review round, all cross-layer. The two layers expanded a tilde and a variable in opposite orders, so a value that names a folder through both reached different folders. Rust now does the tilde first and the variables second, as the CLI guard does. Rust also resolved ~someone-else as a sibling of this profile unconditionally. ntpath declines to guess unless the profile is named after the current user, because C:\Users\me.DOMAIN is not me's sibling, and now so does this. AMDGPU_ASIC_ID_TABLE_PATH and VLLM_CACHE_ROOT are read straight from the environment as file paths, by import_fixes.py and by Studio when the caller set one themselves, so both lists carry them. The update no longer pins PYTHONPATH on Windows, where build_update_command drops it anyway: pinning it could only refuse an update over a value the child never receives. * Never refuse an update over a setting it drops, and name the one that blocks Two review items, both about what a failure costs. STUDIO_LOCAL_REPO is now pinned best effort in both layers. A bare `unsloth studio update` drops it before anything reads it, and `update --local` is refused rather than relocated, so a stale drive-relative value could only refuse the one update form the System32 fallback exists for. It is still written out whenever it can be, so a reader that appears later still finds the folder the caller meant; every other setting still stops the move, because something does read those. A context failure now carries the setting that caused it, as `path_setting_unresolvable:HF_HOME`, and the window names it. "One of Unsloth's folder settings" is not something anyone can act on, and every pin failure already names the setting it could not preserve. The name only, never the value, since this reaches the screen. * Refuse the move when one pass leaves the folder up to the caller Leaving an unsettled expansion as written and moving anyway takes the value with the process: the reader resolves what one pass gives it against whatever directory it is standing in, so a nested %NESTED%, an escaped %%NAME%% or a self-reference quietly followed the child to ~/.unsloth. One pass is still what the reader does, so it is still what decides. If that result names a folder on its own, C:\\cache\\%UNSET%\\assets, the value means the same thing from anywhere and is left alone. If it does not, the setting cannot be preserved across a move and the move is refused, naming it. Both layers, a test each. * Read a path list the way the host does, and report what will not fit Four more from review, all in the moving path. The list loop split and rejoined on ';' and judged absoluteness by Windows rules, while the lost-directory branch had already learned the native ones. A POSIX PYTHONPATH of plugins:/opt/vendor was therefore one entry, and both import roots left with it. Both loops now read the host's separator and the host's idea of absolute. A pinned list that no longer fits in a Windows variable is now reported as the setting that did not fit, rather than discovered by CreateProcess, where the window would have offered a repair that hits the same wall. The tilde now follows USERPROFILE, which is what ntpath.expanduser answers and what the CLI guard uses. dirs::home_dir() reads the known folder, which a portable or overridden environment moves, and the two layers have to name the same folder. GGML_BACKEND_PATH joins both lists: llama_cpp.py preserves it into the llama.cpp child, which resolves it against wherever it is standing. * Expand a POSIX value the POSIX way, and check a scalar against the limit too The lost-directory branch is the one part of the pinning that runs off Windows, and it still read %HOME%/hf as an expandable reference. posixpath.expandvars leaves that literal and expands $HOME instead, so a value that depends on where the process is standing was read as one that does not, and the child moved out from under it. Expansion now follows the host: $NAME and ${NAME} off Windows, %NAME% and the rest on it. The oversized check covered the joined list but not a scalar, which crosses the same limit once it names its folder in full. Same check, same message, both layers. --------- Co-authored-by: pre-commit-ci[bot] <66853113+pre-commit-ci[bot]@users.noreply.github.com> Co-authored-by: danielhanchen <unslothshared@gmail.com>
848 lines
32 KiB
Python
848 lines
32 KiB
Python
# SPDX-License-Identifier: AGPL-3.0-only
|
|
# Copyright 2026-present the Unsloth AI Inc. team. All rights reserved. See /studio/LICENSE.AGPL-3.0
|
|
|
|
"""Windows system-folder guard for the `unsloth` console script.
|
|
|
|
C:\\Windows\\System32 is unwritable for a normal user, and cwd-relative paths
|
|
(`./models`, `unsloth_compiled_cache`) would resolve inside the Windows tree.
|
|
|
|
Two ways in. "Run as administrator" opens a terminal there, a mistake that still
|
|
stops with an actionable error. And "Run Unsloth at login" starts the desktop
|
|
from an HKCU Run value, which carries no working directory, so it and every CLI
|
|
child inherit System32 (issue #8510). That one is not the user's mistake: the
|
|
desktop's own commands take no paths from the user, so they move to ~/.unsloth
|
|
rather than leaving a tray icon and no server.
|
|
|
|
Imports stay at `os`: this runs before the command modules, which resolve
|
|
STUDIO_HOME against the working directory.
|
|
"""
|
|
|
|
import os as _os
|
|
|
|
# Set by Unsloth Desktop on every CLI child it owns (process.rs). Forging it
|
|
# grants nothing: anyone who can set a child's environment can set its working
|
|
# directory, and the move lands inside the caller's own account.
|
|
DESKTOP_MANAGED_ENV = "UNSLOTH_DESKTOP_MANAGED"
|
|
|
|
# The directory process.rs pins, so an older desktop lands in the same place.
|
|
WORK_DIR_NAME = ".unsloth"
|
|
|
|
|
|
def windows_root(
|
|
environ,
|
|
pathmod = _os.path,
|
|
isdir = None,
|
|
):
|
|
"""Where Windows is installed, for messages."""
|
|
return windows_roots(environ, pathmod, isdir)[0]
|
|
|
|
|
|
def windows_roots(
|
|
environ,
|
|
pathmod = _os.path,
|
|
isdir = None,
|
|
):
|
|
"""Every real Windows directory.
|
|
|
|
Candidates are checked, not trusted: a WINDIR aimed at the user's profile
|
|
would make ordinary folders look like system ones, and one aimed elsewhere
|
|
would disarm the guard. So a directory counts only if it holds System32.
|
|
"""
|
|
if isdir is None:
|
|
isdir = pathmod.isdir
|
|
system_root = environ.get("SystemRoot")
|
|
candidates = [system_root, environ.get("WINDIR"), r"C:\Windows"]
|
|
|
|
roots = []
|
|
for value in candidates:
|
|
if value and value not in roots and isdir(pathmod.join(value, "System32")):
|
|
roots.append(value)
|
|
if roots:
|
|
return roots
|
|
# No Windows installation found: keep the guard alive on SystemRoot or the
|
|
# default, never on a user-settable value.
|
|
return [system_root or r"C:\Windows"]
|
|
|
|
|
|
def _strip_extended_prefix(path):
|
|
r"""Drop the \\?\ (and \\?\UNC\) form so it compares like an ordinary path.
|
|
|
|
Matched case-insensitively: the object manager accepts \\?\unc\server\share,
|
|
and reading that as relative would reject a profile Windows itself resolves.
|
|
"""
|
|
lowered = path.lower()
|
|
if lowered.startswith("\\\\?\\unc\\"):
|
|
return "\\\\" + path[8:]
|
|
if lowered.startswith("\\\\?\\"):
|
|
return path[4:]
|
|
return path
|
|
|
|
|
|
def _normalize(path, pathmod):
|
|
return pathmod.normcase(pathmod.normpath(_strip_extended_prefix(path)))
|
|
|
|
|
|
def system_dirs(windir, pathmod = _os.path):
|
|
"""The Windows folders Unsloth refuses to run from."""
|
|
# SysWOW64 too: a 32-bit elevated shell opens there, same unwritable folder.
|
|
return [_normalize(pathmod.join(windir, name), pathmod) for name in ("System32", "SysWOW64")]
|
|
|
|
|
|
def is_system_dir(
|
|
cwd,
|
|
windir,
|
|
pathmod = _os.path,
|
|
sep = _os.sep,
|
|
):
|
|
"""True for a system folder itself or anything under it.
|
|
|
|
`windir` may be a single directory or several candidates. The separator keeps
|
|
the match on a path boundary, so C:\\Windows2\\System32x is an ordinary folder.
|
|
"""
|
|
if not cwd:
|
|
return False
|
|
roots = [windir] if isinstance(windir, str) else list(windir)
|
|
normalized = _normalize(cwd, pathmod)
|
|
return any(
|
|
normalized == directory or normalized.startswith(directory + sep)
|
|
for root in roots
|
|
for directory in system_dirs(root, pathmod)
|
|
)
|
|
|
|
|
|
def _is_rooted(path, pathmod):
|
|
"""Absolute, or at least rooted at a drive.
|
|
|
|
"." and "C:sub" name no directory on their own, so they are no escape
|
|
(pin_relative_overrides resolves the drive-relative form separately). A
|
|
leading separator is not absolute, but can never resolve back into System32.
|
|
"""
|
|
stripped = _strip_extended_prefix(path)
|
|
return pathmod.isabs(stripped) or stripped.startswith(("\\", "/"))
|
|
|
|
|
|
def _is_fully_qualified(path, pathmod):
|
|
r"""Whether the value names one directory whatever the process does next.
|
|
|
|
Narrower than _is_rooted: "\cache" is rooted only to the drive of the current
|
|
directory, so a profile on another drive silently moves it too. Spelled out
|
|
rather than deferred to isabs(), which answered True for a leading separator
|
|
until Python 3.13 and False after: the folder a value names cannot depend on
|
|
the interpreter running the guard.
|
|
"""
|
|
stripped = _strip_extended_prefix(path)
|
|
if stripped.startswith(("\\\\", "//")):
|
|
# A UNC share names its own root.
|
|
return True
|
|
drive, rest = pathmod.splitdrive(stripped)
|
|
return bool(drive) and rest.startswith(("\\", "/"))
|
|
|
|
|
|
def _outside_windows(candidate, windirs, pathmod, sep):
|
|
if not candidate or not _is_rooted(candidate, pathmod):
|
|
return False
|
|
norm = _normalize(candidate, pathmod)
|
|
for windir in windirs:
|
|
windir_norm = _normalize(windir, pathmod)
|
|
# A root-relative candidate carries no drive, so it equals no
|
|
# drive-qualified root: "\Windows\System32\config\systemprofile" is
|
|
# SYSTEM's profile on whichever drive, so compare that spelling too.
|
|
for form in (windir_norm, pathmod.splitdrive(windir_norm)[1]):
|
|
if not form:
|
|
continue
|
|
if norm == form or norm.startswith(form + sep):
|
|
return False
|
|
return True
|
|
|
|
|
|
def safe_user_dir(
|
|
environ,
|
|
windir,
|
|
pathmod = _os.path,
|
|
sep = _os.sep,
|
|
expanduser = None,
|
|
allow_public = False,
|
|
):
|
|
"""First home outside the Windows tree, or None.
|
|
|
|
SYSTEM's USERPROFILE is C:\\Windows\\System32\\config\\systemprofile, so a naive
|
|
pick lands back in the rejected folder. %PUBLIC% is only ever a suggestion a
|
|
human can type: moving there would put one account's caches and outputs in a
|
|
folder every other account can read and write.
|
|
"""
|
|
if expanduser is None:
|
|
expanduser = pathmod.expanduser
|
|
windirs = [windir] if isinstance(windir, str) else list(windir)
|
|
public = (environ.get("PUBLIC") or "").strip()
|
|
candidates = [environ.get("USERPROFILE")]
|
|
if allow_public:
|
|
candidates.append(public)
|
|
candidates.append(expanduser("~"))
|
|
for candidate in candidates:
|
|
if not _outside_windows(candidate, windirs, pathmod, sep):
|
|
continue
|
|
# USERPROFILE and ~ can name the public profile themselves, so the check
|
|
# is on the folder, not on which variable it came from.
|
|
if (
|
|
not allow_public
|
|
and public
|
|
and _normalize(candidate, pathmod) == _normalize(public, pathmod)
|
|
):
|
|
continue
|
|
return candidate
|
|
return None
|
|
|
|
|
|
# Commands the desktop runs that take no path from a user: Studio resolves its
|
|
# venv, llama.cpp, auth, pid files and logs from the Studio home. `update` is here
|
|
# so an older desktop can still upgrade from the tray. Everything else keeps the
|
|
# hard error. Matched whole, not on the first word, since `studio update --local
|
|
# <path>` resolves that path against the working directory.
|
|
_STUDIO_COMMANDS = (
|
|
("provision-desktop-auth",),
|
|
("desktop-capabilities",),
|
|
("desktop-capabilities", "--json"),
|
|
("update",),
|
|
)
|
|
_HELP_FLAGS = ("-h", "--help", "--version", "-V")
|
|
_API_ONLY_FLAGS = ("--api-only", "-H", "--host", "-p", "--port")
|
|
|
|
|
|
def _is_desktop_backend_launch(rest):
|
|
"""`studio --api-only -H 127.0.0.1 -p 8888` and nothing else: matching
|
|
--api-only anywhere would also match `studio run --model ./m.gguf --api-only`,
|
|
a user command with user paths.
|
|
"""
|
|
if "--api-only" not in rest:
|
|
return False
|
|
expects_value = False
|
|
for arg in rest:
|
|
if expects_value:
|
|
expects_value = False
|
|
continue
|
|
if arg not in _API_ONLY_FLAGS:
|
|
return False
|
|
expects_value = arg != "--api-only"
|
|
return True
|
|
|
|
|
|
# Subcommands that take a path from the caller, by argument or by environment.
|
|
# `run` takes --model and a raw llama-server tail; `update --local` installs
|
|
# from a checkout the caller names.
|
|
_PATH_TAKING_STUDIO_COMMANDS = ("run", "update")
|
|
|
|
|
|
def _carries_a_value(arg):
|
|
"""Whether this token can hold a value, attached or not."""
|
|
if not arg.startswith("-"):
|
|
return True
|
|
if arg.startswith("--"):
|
|
return "=" in arg
|
|
# A short option carries its value in the same token from the second
|
|
# character on: `-f.\dist` is Click's spelling of `--frontend .\dist`.
|
|
return len(arg) > 2
|
|
|
|
|
|
def _takes_a_path(rest):
|
|
"""Whether this `studio` invocation can carry a caller's path. Blunt on
|
|
purpose: the bare forms the desktop runs carry none, so anything else might.
|
|
"""
|
|
if not rest:
|
|
return False
|
|
if rest[0] in _PATH_TAKING_STUDIO_COMMANDS:
|
|
return tuple(rest) not in _STUDIO_COMMANDS
|
|
# rest[0] is the subcommand name, skipped unless it is a flag (then there is
|
|
# no subcommand). An attached value hides inside its own token, so a leading
|
|
# dash does not clear it: Click reads both `--frontend=.\dist` and the short
|
|
# `-f.\dist` as a value, and either carries a path a relocation would rebase.
|
|
tail = rest if rest[0].startswith("-") else rest[1:]
|
|
return any(_carries_a_value(arg) for arg in tail)
|
|
|
|
|
|
def is_relocatable_invocation(argv, environ):
|
|
"""True when this invocation is desktop-managed or provably cwd-independent.
|
|
|
|
The argv arm matters on its own: it fixes users whose desktop build predates
|
|
the Rust-side fix and so sets no marker.
|
|
"""
|
|
args = [arg for arg in argv if arg]
|
|
if not args:
|
|
return False
|
|
# Click handles top-level -h/--help/--version eagerly, before the callback
|
|
# this runs from, so only `studio --help` actually arrives here.
|
|
if all(arg in _HELP_FLAGS for arg in args):
|
|
return True
|
|
if args[0] != "studio":
|
|
# The marker is inherited by everything the backend spawns, so it
|
|
# authorises the desktop's studio commands and nothing else: rebasing
|
|
# `train --dataset .\data.json` under a stray one would be worse than the
|
|
# refusal it replaced.
|
|
return False
|
|
rest = args[1:]
|
|
if environ.get(DESKTOP_MANAGED_ENV) == "1" and not _takes_a_path(rest):
|
|
# The marker covers a desktop build whose command shape this CLI does not
|
|
# know yet, but must not widen the set to path-carrying commands: `studio
|
|
# run --model .\local.gguf` from a marked shell would be rebased.
|
|
return True
|
|
if rest and all(arg in _HELP_FLAGS for arg in rest):
|
|
return True
|
|
if _is_desktop_backend_launch(rest):
|
|
return True
|
|
return tuple(rest) in _STUDIO_COMMANDS
|
|
|
|
|
|
# Path overrides the caller may have written relative to the folder being left.
|
|
# Studio resolves them with Path.resolve(), which anchors a relative value to the
|
|
# working directory, so moving first would silently retarget them.
|
|
_RELATIVE_PATH_ENV = (
|
|
# Studio roots: storage_roots.py.
|
|
"UNSLOTH_STUDIO_HOME",
|
|
"STUDIO_HOME",
|
|
"UNSLOTH_STUDIO_DOCUMENTS_HOME",
|
|
"UNSLOTH_STUDIO_PROJECTS_HOME",
|
|
"UNSLOTH_STUDIO_SANDBOX_HOME",
|
|
# `studio update` reads it, and that command relocates.
|
|
"STUDIO_LOCAL_REPO",
|
|
# Engine and tool locations the user may point somewhere of their own.
|
|
"UNSLOTH_LLAMA_CPP_PATH",
|
|
"UNSLOTH_LLAMA_CPP_SCRIPTS_DIR",
|
|
"UNSLOTH_SD_CPP_PATH",
|
|
"UNSLOTH_WHISPER_CPP_PATH",
|
|
"LLAMA_SERVER_PATH",
|
|
"WHISPER_SERVER_PATH",
|
|
"SD_CLI_PATH",
|
|
"SD_SERVER_PATH",
|
|
# Model files llama-server reads straight from the environment, and Studio
|
|
# reads back when it sizes a launch (llama_cpp.py). The URL and HF-repo
|
|
# spellings are deliberately absent: they name no local file.
|
|
"LLAMA_ARG_MODEL",
|
|
"LLAMA_ARG_MMPROJ",
|
|
"LLAMA_ARG_MODEL_DRAFT",
|
|
"LLAMA_ARG_SPEC_DRAFT_MODEL",
|
|
# Read straight from the environment as a file path: the ASIC table by
|
|
# unsloth/import_fixes.py, the vLLM cache root by Studio when the caller set
|
|
# it themselves (storage_roots.py only fills a blank one).
|
|
"AMDGPU_ASIC_ID_TABLE_PATH",
|
|
"VLLM_CACHE_ROOT",
|
|
# A custom ggml backend, preserved into the llama.cpp child (llama_cpp.py).
|
|
"GGML_BACKEND_PATH",
|
|
# GPU SDK roots, joined with bin/ for DLL discovery.
|
|
"CUDA_PATH",
|
|
"HIP_PATH",
|
|
"HIP_PATH_57",
|
|
"ROCM_PATH",
|
|
"MLX_HOSTFILE",
|
|
# Read exactly like MLX_HOSTFILE: either inline JSON or a filename.
|
|
"MLX_IBV_DEVICES",
|
|
"OLLAMA_MODELS",
|
|
"DG_VISUAL_BIN",
|
|
"UNSLOTH_DG_SHIM",
|
|
# Caches.
|
|
"UNSLOTH_COMPILE_LOCATION",
|
|
"TORCHINDUCTOR_CACHE_DIR",
|
|
"UNSLOTH_DIFFUSION_COMPILE_CACHE_DIR",
|
|
"UNSLOTH_DIFFUSION_COND_CACHE_DIR",
|
|
"HF_HOME",
|
|
"HF_HUB_CACHE",
|
|
"HUGGINGFACE_HUB_CACHE",
|
|
"HF_XET_CACHE",
|
|
"HF_DATASETS_CACHE",
|
|
"HF_ASSETS_CACHE",
|
|
# The credential file: a relative value would follow the child and lose
|
|
# access to gated repos.
|
|
"HF_TOKEN_PATH",
|
|
# Read as written, and authoritative when non-blank (storage_roots.py), so
|
|
# `unsloth studio update` would install from a different cache after a move.
|
|
"UV_CACHE_DIR",
|
|
"TRANSFORMERS_CACHE",
|
|
"SENTENCE_TRANSFORMERS_HOME",
|
|
"XDG_CACHE_HOME",
|
|
"XDG_CONFIG_HOME",
|
|
"XDG_DATA_HOME",
|
|
"UNSLOTH_STUDIO_CHILD_RECORD",
|
|
"UNSLOTH_LLAMA_INSTALLER",
|
|
"CUDA_HOME",
|
|
"CUDA_ROOT",
|
|
)
|
|
|
|
# Pinned when it can be, but never at the cost of the move. `studio update
|
|
# --local` is the only command that reads STUDIO_LOCAL_REPO and it takes a path,
|
|
# so it keeps the hard error; the bare update that does relocate drops the value
|
|
# before anything reads it (commands/studio.py). Refusing a System32 update over
|
|
# a stale setting nothing was going to look at would defeat the fallback this
|
|
# guard exists for, so an unresolvable one is left behind instead.
|
|
_BEST_EFFORT_ENV = frozenset(("STUDIO_LOCAL_REPO",))
|
|
|
|
# The most a Windows environment variable holds, terminator included.
|
|
_WINDOWS_ENV_VALUE_LIMIT = 32767
|
|
|
|
# The separator is Windows', not the host's: this guard only ever runs on
|
|
# Windows, and os.pathsep would split "D:\\shared" apart anywhere else.
|
|
_PATH_LIST_SEPARATOR = ";"
|
|
|
|
# Values holding several separated directories, anchored entry by entry. A
|
|
# relative PYTHONPATH entry is resolved at import time, so a move would let
|
|
# whatever sits in the new directory shadow a managed import. PATH is left out on
|
|
# purpose: refusing the whole move over one unresolvable entry in a list that long
|
|
# would cost more than it protects.
|
|
_PATH_LIST_ENV = (
|
|
"UNSLOTH_ALLOW_LOCAL_PREQUANT_PATH",
|
|
"CUDA_RUNTIME_DLL_DIR",
|
|
"PYTHONPATH",
|
|
)
|
|
|
|
|
|
def pin_relative_overrides(
|
|
environ,
|
|
cwd,
|
|
pathmod = _os.path,
|
|
abspath = None,
|
|
expandvars = None,
|
|
expanduser = None,
|
|
):
|
|
"""Rewrite relative path overrides so they keep naming the same folder.
|
|
|
|
Returns the names pinned. A `~` value is written out, since only some readers
|
|
expand it themselves.
|
|
"""
|
|
pinned = []
|
|
for name in _RELATIVE_PATH_ENV:
|
|
value = (environ.get(name) or "").strip()
|
|
try:
|
|
anchored = _anchor(name, value, cwd, pathmod, abspath, expandvars, expanduser)
|
|
except Exception:
|
|
if name not in _BEST_EFFORT_ENV:
|
|
raise
|
|
continue
|
|
if anchored is not None:
|
|
if len(anchored) >= _WINDOWS_ENV_VALUE_LIMIT:
|
|
# Anchoring a value that was already near the limit can cross it,
|
|
# and a variable Windows will not accept is a failure to report
|
|
# here rather than one to discover in the next process.
|
|
raise ValueError(
|
|
f"{name} does not fit in an environment variable once it "
|
|
"names its folder in full"
|
|
)
|
|
environ[name] = anchored
|
|
pinned.append(name)
|
|
for name in _PATH_LIST_ENV:
|
|
raw = environ.get(name) or ""
|
|
if not raw.strip():
|
|
continue
|
|
# Each entry is anchored on its own: one relative entry changes what the
|
|
# whole list means.
|
|
entries = raw.split(_PATH_LIST_SEPARATOR)
|
|
anchored_entries = [
|
|
_anchor_list_entry(name, e, cwd, pathmod, abspath, expandvars, expanduser)
|
|
for e in entries
|
|
]
|
|
if anchored_entries != entries:
|
|
joined = _PATH_LIST_SEPARATOR.join(anchored_entries)
|
|
if len(joined) >= _WINDOWS_ENV_VALUE_LIMIT:
|
|
# A value Windows will not accept is a failure to report here,
|
|
# not one to discover when the next process is started.
|
|
raise ValueError(
|
|
f"{name} does not fit in an environment variable once each "
|
|
"entry names its folder in full"
|
|
)
|
|
environ[name] = joined
|
|
pinned.append(name)
|
|
return pinned
|
|
|
|
|
|
# Values a consumer deliberately does not read as a plain path: anchoring one
|
|
# changes what it means rather than moving a folder. Each exemption names the
|
|
# variables whose reader proves it, since the syntax is only special there and a
|
|
# directory really called "[llama]" or "%data%" is legal on Windows.
|
|
|
|
# MLX_HOSTFILE holds either a filename or the host list itself, as JSON
|
|
# (`unsloth_cli/_inference.py`, `_json_rank_count_from_env`).
|
|
_INLINE_JSON_ENV = frozenset(("MLX_HOSTFILE", "MLX_IBV_DEVICES"))
|
|
|
|
# Names whose readers disagree about %VAR% and $VAR: huggingface_hub calls
|
|
# expandvars on HF_HOME (and on the XDG_CACHE_HOME it defaults from), HF_HUB_CACHE
|
|
# and HF_ASSETS_CACHE, and Studio calls it on SENTENCE_TRANSFORMERS_HOME, but
|
|
# Studio's own hf_cache_settings._canonical() does not, so it would read
|
|
# %LOCALAPPDATA%\hf as a relative folder. Expanding here before deciding settles
|
|
# it: both readers then see one absolute path. Scoped to these names because a
|
|
# directory really called "%data%" is legal, and every other name is read as one.
|
|
_EXPANDED_ENV = frozenset(
|
|
(
|
|
"HF_HOME",
|
|
"HF_HUB_CACHE",
|
|
"HUGGINGFACE_HUB_CACHE",
|
|
"HF_ASSETS_CACHE",
|
|
"HF_TOKEN_PATH",
|
|
"XDG_CACHE_HOME",
|
|
"SENTENCE_TRANSFORMERS_HOME",
|
|
)
|
|
)
|
|
|
|
# The pre-quant allowlist skips a bare on/off token precisely so there is no
|
|
# "allow all" mode (`diffusion_prequant.py`); anchoring one would turn it into a
|
|
# real allowlisted directory.
|
|
_TOGGLE_ENV = frozenset(("UNSLOTH_ALLOW_LOCAL_PREQUANT_PATH",))
|
|
_TOGGLE_TOKENS = frozenset(("1", "true", "yes", "on", "0", "false", "no", "off"))
|
|
|
|
|
|
def _names_a_path(name, value):
|
|
"""Whether the working directory is what resolves this variable's value."""
|
|
if name in _INLINE_JSON_ENV and value.startswith(("[", "{")):
|
|
return False
|
|
if name in _TOGGLE_ENV and value.lower() in _TOGGLE_TOKENS:
|
|
return False
|
|
return True
|
|
|
|
|
|
def pin_relative_sys_path(
|
|
cwd,
|
|
pathmod = _os.path,
|
|
syspath = None,
|
|
abspath = None,
|
|
exists = None,
|
|
expanduser = None,
|
|
):
|
|
"""Anchor the relative import roots this interpreter already carries.
|
|
|
|
sys.path holds PYTHONPATH entries as written, including the two spellings that
|
|
follow the process rather than the caller: an empty entry means the working
|
|
directory, and a leading `~` is never expanded there.
|
|
|
|
Only an entry naming something on disk is touched, folder or archive. The rest
|
|
are other people's strings rather than paths, such as the relative sentinel
|
|
setuptools registers for an editable install and accepts back by exact
|
|
equality; rewriting one breaks the import it was meant to protect, and leaving
|
|
a real archive breaks the next import from it.
|
|
"""
|
|
if syspath is None:
|
|
import sys as _sys
|
|
syspath = _sys.path
|
|
if exists is None:
|
|
exists = _os.path.exists
|
|
pinned = []
|
|
for index, entry in enumerate(syspath):
|
|
if not isinstance(entry, str):
|
|
continue
|
|
try:
|
|
# The empty entry is the working directory by definition; anything
|
|
# else has to name something that is really there.
|
|
if entry.strip() and not exists(entry):
|
|
continue
|
|
anchored = _anchor_list_entry(
|
|
"PYTHONPATH", entry, cwd, pathmod, abspath, None, expanduser
|
|
)
|
|
except Exception:
|
|
# Best effort, unlike the environment: an import root this process
|
|
# already holds is not worth refusing the move over.
|
|
continue
|
|
if anchored != entry:
|
|
syspath[index] = anchored
|
|
pinned.append(anchored)
|
|
return pinned
|
|
|
|
|
|
def _anchor_list_entry(
|
|
name,
|
|
entry,
|
|
cwd,
|
|
pathmod,
|
|
abspath,
|
|
expandvars,
|
|
expanduser = None,
|
|
):
|
|
r"""One entry of a path list, anchored, or left as written.
|
|
|
|
PYTHONPATH has two spellings that follow the process rather than the caller:
|
|
an empty component is the working directory itself, and `~` is never expanded
|
|
there, so Python reads `~\plugins` as an ordinary relative folder and so does
|
|
this.
|
|
"""
|
|
entry = entry.strip()
|
|
if name == "PYTHONPATH":
|
|
if not entry:
|
|
return cwd
|
|
expanduser = lambda value: value
|
|
return _anchor(name, entry, cwd, pathmod, abspath, expandvars, expanduser) or entry
|
|
|
|
|
|
def _expand_settled(value, expandvars):
|
|
"""The value expanded exactly once, or None if one pass does not settle it.
|
|
|
|
One pass is what every reader does, so one pass is what the guard does. The
|
|
result is only usable if expanding it again would change nothing, because the
|
|
reader expands whatever gets written back: a value that still holds a
|
|
reference (a nested %LOCALAPPDATA% that itself holds %USERPROFILE%, an escaped
|
|
%%NAME%%, a self-reference) would be expanded a second time by the reader and
|
|
read as a folder with another drive in the middle of it. Those are left
|
|
exactly as written instead.
|
|
"""
|
|
expanded = expandvars(value)
|
|
return expanded if expandvars(expanded) == expanded else None
|
|
|
|
|
|
def _anchor(
|
|
name,
|
|
value,
|
|
cwd,
|
|
pathmod,
|
|
abspath = None,
|
|
expandvars = None,
|
|
expanduser = None,
|
|
):
|
|
"""The value rewritten to name the same folder from anywhere, or None.
|
|
|
|
None means no rewriting is needed: empty, or already fully qualified.
|
|
"""
|
|
original = value = (value or "").strip()
|
|
if value.startswith("~"):
|
|
# Written out rather than skipped: only some readers call expanduser
|
|
# first, and llama_cpp.py hands UNSLOTH_LLAMA_CPP_PATH straight to Path(),
|
|
# so a move would leave it naming a folder called "~" under the new
|
|
# directory. It is what the caller meant either way.
|
|
value = (expanduser or pathmod.expanduser)(value)
|
|
if name in _EXPANDED_ENV and value:
|
|
# Written out, so the reader that expands and the one that does not land
|
|
# in the same folder. An unset variable is left exactly as written.
|
|
expandvars = expandvars or _os.path.expandvars
|
|
settled = _expand_settled(value, expandvars)
|
|
if settled is None:
|
|
# One pass does not settle it, so writing the result back would have
|
|
# the reader expand it a second time. What the reader does see is one
|
|
# pass: if that names a folder on its own the value is safe to leave
|
|
# alone, and if it does not, the folder it names depends on where the
|
|
# process is standing and the move has to be refused instead.
|
|
once = expandvars(value)
|
|
if _is_fully_qualified(once, pathmod):
|
|
return None
|
|
raise ValueError(f"{name} does not expand to one folder")
|
|
value = settled
|
|
if not value:
|
|
return None
|
|
if _is_fully_qualified(value, pathmod):
|
|
# Already names one folder, but still worth writing back if expanding is
|
|
# what made it name one: the reader that does not expand cannot see that.
|
|
return value if value != original else None
|
|
if not _names_a_path(name, value):
|
|
return None
|
|
if pathmod.splitdrive(value)[0] or value.startswith(("\\", "/")):
|
|
# "D:cache" is drive D's own current directory and "\cache" the root of
|
|
# the current drive, neither of which join() knows, so ask the OS. A
|
|
# failure reaches the caller, which then declines to move at all.
|
|
return (abspath or pathmod.abspath)(value)
|
|
return pathmod.join(cwd, value)
|
|
|
|
|
|
def relocation_target(
|
|
environ,
|
|
windir,
|
|
pathmod = _os.path,
|
|
sep = _os.sep,
|
|
expanduser = None,
|
|
makedirs = _os.makedirs,
|
|
home_isdir = None,
|
|
):
|
|
"""Where a desktop-managed command should run instead, or None."""
|
|
home = safe_user_dir(environ, windir, pathmod, sep, expanduser)
|
|
if not home:
|
|
return None
|
|
if home_isdir is None:
|
|
home_isdir = pathmod.isdir
|
|
# A profile that has not mounted yet still has a writable parent, so makedirs
|
|
# would build an empty second one that shadows the real one when it arrives.
|
|
if not home_isdir(home):
|
|
return None
|
|
work_dir = pathmod.join(home, WORK_DIR_NAME)
|
|
try:
|
|
makedirs(work_dir, exist_ok = True)
|
|
except OSError:
|
|
# An unwritable home is a broken profile and Studio must write there
|
|
# anyway, so stop now rather than failing later.
|
|
return None
|
|
return work_dir
|
|
|
|
|
|
def blocked_message(
|
|
cwd,
|
|
argv,
|
|
environ,
|
|
windir,
|
|
pathmod = _os.path,
|
|
sep = _os.sep,
|
|
expanduser = None,
|
|
):
|
|
"""The error shown to someone who ran Unsloth from a system folder by hand."""
|
|
# allow_public here only: a person can sensibly `cd C:\Users\Public`, but
|
|
# relocating there would share one account's state with every other account.
|
|
home = safe_user_dir(environ, windir, pathmod, sep, expanduser, allow_public = True)
|
|
if home:
|
|
# Quote it, or C:\Users\Jane Doe reaches Set-Location as two arguments.
|
|
# PowerShell single quotes are verbatim ('' escapes an apostrophe); cmd
|
|
# needs double quotes once extensions are off.
|
|
home_ps = "'" + home.replace("'", "''") + "'"
|
|
home_cmd = '"' + home + '"'
|
|
cd_lines = (
|
|
f" cd {home_ps} (PowerShell)\n" f" cd /d {home_cmd} (cmd.exe)\n"
|
|
)
|
|
else:
|
|
cd_lines = f" (any folder outside {windir if isinstance(windir, str) else windir[0]})\n"
|
|
rendered_argv = " ".join((f'"{arg}"' if " " in arg else arg) for arg in argv)
|
|
retry = ("unsloth " + rendered_argv).rstrip()
|
|
return (
|
|
f"Unsloth cannot run from {cwd}\n"
|
|
"\n"
|
|
"That is a Windows system folder. Windows blocks writes here, and any\n"
|
|
"relative path you pass would resolve inside the Windows folder.\n"
|
|
"Opening a terminal with 'Run as administrator' starts you in a folder like\n"
|
|
"this one, which is how most people end up here.\n"
|
|
"\n"
|
|
"Change to a normal folder and run the command again:\n"
|
|
f"{cd_lines}"
|
|
f" {retry}"
|
|
)
|
|
|
|
|
|
def check_working_directory(
|
|
argv,
|
|
environ,
|
|
platform,
|
|
getcwd = _os.getcwd,
|
|
chdir = _os.chdir,
|
|
pathmod = _os.path,
|
|
sep = _os.sep,
|
|
expanduser = None,
|
|
makedirs = _os.makedirs,
|
|
isdir = None,
|
|
abspath = None,
|
|
home_isdir = None,
|
|
exists = None,
|
|
syspath = None,
|
|
expandvars = None,
|
|
relocate = True,
|
|
):
|
|
"""Decide what to do about the current working directory.
|
|
|
|
Returns (message, colour, fatal). `fatal` is the caller's cue to exit 1;
|
|
a message with fatal False is a warning printed after a successful move.
|
|
"""
|
|
if platform != "win32":
|
|
return None, None, False
|
|
|
|
windirs = windows_roots(environ, pathmod, isdir)
|
|
windir = windirs[0]
|
|
try:
|
|
cwd = getcwd()
|
|
except OSError:
|
|
# The launch directory is gone: say so rather than name a folder they
|
|
# were never in.
|
|
return (
|
|
(
|
|
"Unsloth cannot determine its current folder. It may have been deleted,\n"
|
|
"or it may be on a drive that is no longer available.\n"
|
|
"Change to a folder that exists and run the command again."
|
|
),
|
|
"red",
|
|
True,
|
|
)
|
|
|
|
if not is_system_dir(cwd, windirs, pathmod, sep):
|
|
return None, None, False
|
|
|
|
if not relocate or not is_relocatable_invocation(argv, environ):
|
|
# `relocate = False` is the imported-as-a-library case: the command
|
|
# modules are already loaded, so a move now is too late for the roots they
|
|
# resolved at import time.
|
|
return blocked_message(cwd, argv, environ, windirs, pathmod, sep, expanduser), "red", True
|
|
|
|
target = relocation_target(environ, windirs, pathmod, sep, expanduser, makedirs, home_isdir)
|
|
unpinnable = None
|
|
# The caller's own process state when the guard runs inside a host, so nothing
|
|
# stays rewritten unless the move actually happens.
|
|
environ_before = dict(environ)
|
|
if syspath is None:
|
|
# Resolved here rather than inside the pinning, or the console script,
|
|
# which passes nothing, would rewrite the real sys.path with no snapshot
|
|
# to put back when the move then fails.
|
|
import sys as _sys
|
|
syspath = _sys.path
|
|
syspath_before = list(syspath)
|
|
if target is not None:
|
|
try:
|
|
# Before moving, or a relative override would end up naming a folder
|
|
# under the new directory instead of theirs.
|
|
pin_relative_overrides(environ, cwd, pathmod, abspath, expandvars, expanduser)
|
|
# This interpreter read PYTHONPATH before the guard ran and keeps the
|
|
# entries as written, resolving a relative one on every import, so it
|
|
# needs anchoring here as well as in the environment.
|
|
pin_relative_sys_path(cwd, pathmod, syspath, abspath, exists, expanduser)
|
|
except Exception as error:
|
|
# An environment we cannot pin is one we must not move underneath.
|
|
# Both reasons are real: a drive with no current directory, and a list
|
|
# that no longer fits in a Windows variable once fully qualified.
|
|
unpinnable = error
|
|
target = None
|
|
moved = False
|
|
if target is not None:
|
|
try:
|
|
chdir(target)
|
|
except OSError:
|
|
target = None
|
|
else:
|
|
moved = True
|
|
# Confirm where it landed rather than trusting chdir not to raise.
|
|
try:
|
|
if is_system_dir(getcwd(), windirs, pathmod, sep):
|
|
target = None
|
|
except OSError:
|
|
target = None
|
|
if target is None:
|
|
# It landed somewhere the CLI still refuses, so go back: the
|
|
# values written for the move only mean the same folder from the
|
|
# directory they were written in.
|
|
try:
|
|
chdir(cwd)
|
|
except OSError:
|
|
pass
|
|
else:
|
|
moved = False
|
|
if target is None and not moved:
|
|
# Nothing moved, so nothing stays rewritten: put back what pinning wrote.
|
|
if environ_before != environ:
|
|
environ.clear()
|
|
environ.update(environ_before)
|
|
if syspath_before != syspath:
|
|
syspath[:] = syspath_before
|
|
if unpinnable is not None:
|
|
# Named separately from the profile case below: blaming the user folder
|
|
# for an unpinnable override sends them looking in the wrong place.
|
|
return (
|
|
(
|
|
f"Unsloth cannot run from {cwd}, and could not move out of it\n"
|
|
"without changing where one of its path settings points\n"
|
|
f"({type(unpinnable).__name__}: {unpinnable}).\n"
|
|
"Set that value to a full path, or start Unsloth from a normal folder."
|
|
),
|
|
"red",
|
|
True,
|
|
)
|
|
if target is None:
|
|
# Fail closed: nowhere usable outside the Windows tree. This text lands in
|
|
# the desktop's logs, so it describes that case, not a shell.
|
|
return (
|
|
(
|
|
f"Unsloth cannot run from {cwd}, and no folder outside {windir} was\n"
|
|
"available to run from instead. Check that the user profile for this\n"
|
|
"account exists and is writable."
|
|
),
|
|
"red",
|
|
True,
|
|
)
|
|
|
|
return (
|
|
(
|
|
f"Unsloth was started from {cwd}, which is a Windows system folder,\n"
|
|
f"so it switched to {target} instead.\n"
|
|
"This happens when Unsloth Desktop is started by 'Run Unsloth at login'."
|
|
),
|
|
"yellow",
|
|
False,
|
|
)
|