Fix unsloth start on Windows: agent install, PATH resolution, and local model selection (#7257)

* unsloth start: fix Windows agent install/launch and local model selection

- claude: pin availableModels to the served model in the session --settings
  overlay so a user's ~/.claude/settings.json allowlist no longer substitutes
  the org default for the local Unsloth model. The allowlist covers --model,
  ANTHROPIC_MODEL and the model setting, and an empty [] is ignored, so the pin
  lists the model explicitly.
- installs: run the Windows installer under -ExecutionPolicy Bypass
  (process-scoped, nothing persistent) so npm's npm.ps1 and irm|iex scripts run
  under the default Restricted policy; on failure, hint at Set-ExecutionPolicy
  -Scope CurrentUser -ExecutionPolicy RemoteSigned for a hand-run retry.
- PATH: resolve agents installed to ~/.local/bin (claude) and %APPDATA%\npm (npm
  agents) in-process, so a fresh install launches without opening a new shell and
  an already-installed agent is not re-prompted for install.
- load message: "Loading <model> - please wait" while a model loads.

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

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

* unsloth start: resolve agent version against the launch PATH

The claude/codex/opencode version probes ran shutil.which while building the
command, before _launch augments PATH with the known install dirs. An agent
present only in ~/.local/bin or %APPDATA%\npm was therefore missed, assumed to
be a current build, and launched with flags an older build rejects (claude
aborts on the unknown flags). Route the three probes through a new
_which_with_install_dirs() so each resolves the same binary _launch will,
restoring PATH afterward so only _launch persists the augmentation.

Add regression tests for the three probes (POSIX and the Windows npm dir) and
make the Windows-branch tests run on POSIX hosts (pinning Path to the native
flavour so a simulated os.name does not make pathlib build WindowsPath).

* unsloth start: keep os.defpath when augmenting an unset PATH

_augment_path_with_install_dirs collapsed an unset PATH to just the install
dirs, dropping the os.defpath fallback (/bin:/usr/bin) that shutil.which and
exec*p* use when PATH is absent. A system-installed agent then looked missing
and the launched child lost its normal PATH. Seed os.defpath when PATH is
unset; an explicitly empty PATH is left as-is (search nothing), matching
shutil.which. Add regression tests for the augment helper and the version-probe
wrapper.

---------

Co-authored-by: pre-commit-ci[bot] <66853113+pre-commit-ci[bot]@users.noreply.github.com>
This commit is contained in:
Daniel Han 2026-07-20 06:05:28 -07:00 committed by GitHub
commit c7b17c455b
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
2 changed files with 356 additions and 37 deletions

View file

@ -192,7 +192,7 @@ _OPENCODE_NATIVE_AUTO_MIN_VERSION = (1, 17, 12)
def _opencode_supports_native_auto() -> bool:
executable = shutil.which("opencode")
executable = _which_with_install_dirs("opencode")
if executable is None:
# No local binary: a --no-launch recipe may run elsewhere, and _run installs the
# current release on launch -- either way assume native --auto is available.
@ -826,7 +826,7 @@ def _resolve_model(
)
if requested and match is None:
typer.echo(
f"Ensuring {requested} is loaded with the requested settings"
f"Loading {requested} - please wait"
if load_has_overrides
else f"Loading {requested} on the Unsloth server (this can take a while)…"
)
@ -902,17 +902,21 @@ def _require_gguf_for_codex(base: str, key: str, model_id: str) -> None:
_DYNAMIC_SECTIONS_FLAG = "--exclude-dynamic-system-prompt-sections"
# Session overlay applied via `claude --settings`; suppresses the attribution header
# for THIS run only (no ~/.claude write) so llama.cpp KV-cache reuse is preserved. It
# reinforces the CLAUDE_CODE_ATTRIBUTION_HEADER env var on builds that read the setting
# only from settings.json.
_CLAUDE_SETTINGS_OVERLAY = '{"env":{"CLAUDE_CODE_ATTRIBUTION_HEADER":"0"}}'
def _claude_settings_overlay(model_id: str) -> str:
# Session-only `claude --settings` overlay (command-line tier, no ~/.claude write):
# suppress the attribution header, and pin availableModels to the served model so a
# user allowlist can't reject it. The pin must be non-empty; [] is ignored.
return json.dumps(
{"env": {"CLAUDE_CODE_ATTRIBUTION_HEADER": "0"}, "availableModels": [model_id]}
)
def _claude_version() -> Optional[tuple]:
# None = no local `claude` (a --no-launch printout for another machine; assume a
# current build). An unparseable version is treated as too old for the new flags.
executable = shutil.which("claude")
executable = _which_with_install_dirs("claude")
if executable is None:
return None
try:
@ -929,16 +933,14 @@ def _claude_version() -> Optional[tuple]:
return (0,)
def _claude_flags() -> list:
# Both knobs preserve llama.cpp KV-cache reuse: --exclude-dynamic-system-prompt-sections
# moves per-session context out of the system prompt, and --settings suppresses the
# attribution header for this session only (no persistent ~/.claude write; the env var
# sets it too). Claude Code < 2.1.98 aborts on unknown flags, so gate on the version;
# no local binary means a printout for another machine, so assume a current build.
def _claude_flags(model_id: str) -> list:
# KV-cache-preserving flags: move per-session context out of the system prompt and pass
# the session overlay. claude < 2.1.98 rejects unknown flags; no local binary means a
# printout for another machine, so assume a current build.
version = _claude_version()
if version is not None and version < (2, 1, 98):
return []
return [_DYNAMIC_SECTIONS_FLAG, "--settings", _CLAUDE_SETTINGS_OVERLAY]
return [_DYNAMIC_SECTIONS_FLAG, "--settings", _claude_settings_overlay(model_id)]
def _merge_codex_config(existing: str, base: str) -> str:
@ -971,7 +973,7 @@ _CODEX_MODEL_CATALOG_MIN_VERSION = (0, 110, 0)
def _codex_supports_model_catalog() -> bool:
executable = shutil.which("codex")
executable = _which_with_install_dirs("codex")
if executable is None:
# A --no-launch recipe may be copied to another machine; assume a current Codex.
return True
@ -1202,6 +1204,53 @@ def _refresh_windows_path() -> None:
os.environ["PATH"] = os.pathsep.join(entries)
def _augment_path_with_install_dirs() -> None:
# Append known install dirs to PATH so a freshly installed agent resolves without a new
# shell: some installers write the binary but not PATH (claude drops ~/.local/bin and
# only prints a note; npm -g shims land in %APPDATA%\npm). Appended, so precedence holds.
try:
home = Path.home()
except (RuntimeError, OSError):
return
candidates = [home / ".local" / "bin"]
if os.name == "nt":
appdata = os.environ.get("APPDATA")
if appdata:
candidates.append(Path(appdata) / "npm")
current = os.environ.get("PATH")
if current is None:
# PATH unset: shutil.which() and exec*p* fall back to os.defpath (e.g. /bin:/usr/bin), so
# keep that default instead of collapsing to just the install dirs (which would hide a
# system-installed agent and strip the launched child's normal PATH). An explicitly empty
# PATH is left as-is: like shutil.which, it means "search nothing", not os.defpath.
current = os.defpath
seen = {os.path.normcase(entry) for entry in current.split(os.pathsep) if entry}
additions = [
str(directory)
for directory in candidates
if directory.is_dir() and os.path.normcase(str(directory)) not in seen
]
if additions:
os.environ["PATH"] = os.pathsep.join([current, *additions] if current else additions)
def _which_with_install_dirs(name: str) -> Optional[str]:
# shutil.which(name), but searching the known agent install dirs too, so a version probe
# resolves the same binary _launch() will (it augments PATH before it runs). Without this an
# agent present only in ~/.local/bin / %APPDATA%\npm is missed, wrongly assumed current, and
# launched with flags an older build rejects. PATH is restored afterward: only _launch()
# should persist the augmentation for the child process.
original = os.environ.get("PATH")
_augment_path_with_install_dirs()
try:
return shutil.which(name)
finally:
if original is None:
os.environ.pop("PATH", None)
else:
os.environ["PATH"] = original
def _install_source(install_hint: str) -> Optional[str]:
"""The first http(s) URL an install hint fetches, or None (e.g. an npm install)."""
match = re.search(r"https?://[^\s'\")]+", install_hint)
@ -1254,17 +1303,35 @@ def _install_agent(name: str, install_hint: str) -> Optional[str]:
typer.secho(warning, fg = "yellow", err = True)
if not typer.confirm(f"Install `{name}` now with `{install_hint}`?", default = False):
return None
# Run each hint through the shell it is written for: PowerShell (irm | iex, or npm)
# on Windows, /bin/sh (curl | bash, or npm) everywhere else.
# Run each hint through its shell: PowerShell on Windows, /bin/sh elsewhere.
# -ExecutionPolicy Bypass is process-scoped (nothing persistent) so npm's npm.ps1 and
# irm | iex run under the Windows default Restricted policy instead of failing with a
# PSSecurityException.
if os.name == "nt":
install_command = ["powershell", "-NoProfile", "-Command", install_hint]
install_command = [
"powershell",
"-NoProfile",
"-ExecutionPolicy",
"Bypass",
"-Command",
install_hint,
]
else:
install_command = ["/bin/sh", "-c", install_hint]
if subprocess.run(install_command).returncode != 0:
_fail(f"Install command failed. Run it yourself, then re-run: {install_hint}")
# The installer just wrote PATH to the registry (Windows); pull it into this
# process so the freshly installed agent resolves without a shell restart.
message = f"Install command failed. Run it yourself, then re-run: {install_hint}"
if os.name == "nt":
# A hand-run retry can still hit the policy; point at the one-time per-user fix.
message += (
"\nIf it fails because running scripts is disabled (PSSecurityException), "
"allow local scripts for your user, then retry:\n"
" Set-ExecutionPolicy -Scope CurrentUser -ExecutionPolicy RemoteSigned"
)
_fail(message)
# Resolve the freshly installed agent without a shell restart: pull registry PATH
# (Windows) plus well-known install dirs the installer may not have added to PATH.
_refresh_windows_path()
_augment_path_with_install_dirs()
executable = shutil.which(name)
if executable is None:
_fail(
@ -1290,6 +1357,9 @@ def _launch(
install_hint: str,
unset_env: tuple = (),
) -> NoReturn:
# Resolve well-known install dirs (e.g. ~/.local/bin) first, so an already-installed
# agent not yet on PATH is found instead of prompting a needless reinstall.
_augment_path_with_install_dirs()
executable = shutil.which(command[0]) or _install_agent(command[0], install_hint)
if executable is None:
_fail(f"`{command[0]}` not found on PATH. Install it with: {install_hint}")
@ -1780,7 +1850,7 @@ def claude(
"claude",
"--model",
model_id,
*_claude_flags(),
*_claude_flags(model_id),
*_yolo_command_flags("claude", yolo),
*ctx.args,
]