From f7d05ae8c0a1bddce3a6c8504ee30310146de15c Mon Sep 17 00:00:00 2001 From: oobabooga <112222186+oobabooga@users.noreply.github.com> Date: Mon, 27 Jul 2026 15:34:05 -0300 Subject: [PATCH 01/12] unsloth start: harden coding-agent installation --- pyproject.toml | 1 + .../backend/requirements/no-torch-runtime.txt | 15 +- unsloth_cli/commands/start.py | 289 +++++++++----- unsloth_cli/tests/test_start.py | 358 +++++++++++++++++- 4 files changed, 553 insertions(+), 110 deletions(-) diff --git a/pyproject.toml b/pyproject.toml index 0f57ecf4df..1cd04b32b3 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -26,6 +26,7 @@ classifiers = [ ] dependencies = [ "typer>=0.12.0", + "click>=8.0", "rich", "pydantic", "pyyaml", diff --git a/studio/backend/requirements/no-torch-runtime.txt b/studio/backend/requirements/no-torch-runtime.txt index 847e89823b..885e83b11c 100644 --- a/studio/backend/requirements/no-torch-runtime.txt +++ b/studio/backend/requirements/no-torch-runtime.txt @@ -2,23 +2,22 @@ # Installed with --no-deps to prevent transitive torch resolution # from packages like accelerate, peft, trl, sentence-transformers. # -# Includes unsloth's own direct deps (typer, pydantic, pyyaml, -# nest-asyncio) since unsloth is also installed with --no-deps +# Includes unsloth's own direct deps (typer, click, rich, pydantic, +# pyyaml, nest-asyncio) since unsloth is also installed with --no-deps # (current PyPI metadata still declares torch as a hard dep). # unsloth direct deps (from pyproject.toml [project].dependencies) typer>=0.12.0 -# typer's full runtime dep tree. Required explicitly because this +click>=8.0 +rich>=13.0 +# typer's remaining runtime dep tree. Required explicitly because this # file is installed with --no-deps. On Linux/Mac CI runners these # are often cached transitively; on a fresh windows-latest venv they -# are not, and `unsloth studio setup` crashes with -# `ModuleNotFoundError: No module named 'click'`, then 'annotated_doc', -# then 'rich', etc. as each is hit. Pin the full chain so the +# are not, and `unsloth studio setup` crashes on the first missing +# module. Pin the full chain so the # no-torch path works cleanly on every fresh venv. -click>=8.0 shellingham>=1.5 annotated-doc>=0.0.3 -rich>=13.0 markdown-it-py>=3.0 mdurl>=0.1 pygments>=2.0 diff --git a/unsloth_cli/commands/start.py b/unsloth_cli/commands/start.py index ed1ee7bd5a..75c2c20d06 100644 --- a/unsloth_cli/commands/start.py +++ b/unsloth_cli/commands/start.py @@ -426,6 +426,18 @@ def _hermes_install_hint() -> str: return _HERMES_WINDOWS_INSTALL_HINT if os.name == "nt" else _HERMES_POSIX_INSTALL_HINT +def _npm_install_hint(package: str, *, ignore_scripts: bool = False) -> str: + parts = ["npm", "install", "-g"] + if os.name != "nt": + parts.extend(("--prefix", str(Path.home() / ".local"))) + if ignore_scripts: + parts.append("--ignore-scripts") + parts.append(package) + if os.name == "nt": + return " ".join(_powershell_quote(part) for part in parts) + return shlex.join(parts) + + def _hermes_resume_oneshot_args(args: list[str]) -> list[str]: """Route resumed one-shot prompts through Hermes' session-aware chat command.""" has_resume = any( @@ -1187,10 +1199,14 @@ def _require_studio( ) +def _studio_auth_root() -> Path: + from unsloth_cli.commands.studio import STUDIO_HOME + + return STUDIO_HOME / "auth" + + def _key_cache_path() -> Path: - ensure_studio_backend_path() - from utils.paths import auth_root - return auth_root() / "agent_api_key.json" + return _studio_auth_root() / "agent_api_key.json" def _read_cache(cache: Path) -> dict: @@ -1979,20 +1995,6 @@ def write_codex_parent_overlay(overlay: Path) -> Path: return overlay -@contextlib.contextmanager -def _codex_parent_overlay(session_home: Path, *, launch: bool, persist: bool): - if launch and not persist: - temp_root = _agents_config_root() / ".tmp" - temp_root.mkdir(parents = True, exist_ok = True, mode = 0o700) - overlay = Path(tempfile.mkdtemp(prefix = "codex-parent-", dir = temp_root)) - try: - yield write_codex_parent_overlay(overlay) - finally: - shutil.rmtree(overlay, ignore_errors = True) - else: - yield write_codex_parent_overlay(session_home / "parent") - - def _agent_config_path(path: Path, command: list) -> str: """Translate a generated config path when a Windows agent runs through WSL.""" return _wsl_windows_path(path) if _wsl_windows_executable(command) else str(path) @@ -2382,10 +2384,35 @@ def _refresh_windows_path() -> None: os.environ["PATH"] = os.pathsep.join(entries) +def _managed_node_tools() -> Optional[tuple[Path, Path, bool]]: + try: + ensure_studio_backend_path() + from utils.node_runtime import managed_node_binary, resolve_node_executable + + node = Path(managed_node_binary()) + except (ImportError, OSError, TypeError, ValueError): + return None + npm = node.with_name("npm.cmd" if os.name == "nt" else "npm") + try: + usable = node.is_file() and npm.is_file() + if os.name != "nt": + usable = usable and os.access(node, os.X_OK) and os.access(npm, os.X_OK) + except OSError: + return None + if not usable: + return None + try: + resolved = resolve_node_executable() + preferred = bool(resolved) and Path(resolved).resolve() == node.resolve() + except (OSError, RuntimeError, TypeError, ValueError): + preferred = False + return node, npm, preferred + + 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. + # Add known install dirs to PATH so a freshly installed agent resolves without a new + # shell. User agent dirs are appended so existing tools keep precedence. A managed Node + # selected over the system runtime is prepended so Node-backed shims use that same runtime. try: home = Path.home() except (RuntimeError, OSError): @@ -2395,6 +2422,9 @@ def _augment_path_with_install_dirs() -> None: appdata = os.environ.get("APPDATA") if appdata: candidates.append(Path(appdata) / "npm") + managed_node = _managed_node_tools() + if managed_node is not None and not managed_node[2]: + candidates.append(managed_node[0].parent) 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 @@ -2402,14 +2432,25 @@ def _augment_path_with_install_dirs() -> None: # 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 + preferred_node = ( + str(managed_node[0].parent) if managed_node is not None and managed_node[2] else None + ) + if preferred_node: + preferred_key = os.path.normcase(preferred_node) + current = os.pathsep.join( + entry + for entry in current.split(os.pathsep) + if not entry or os.path.normcase(entry) != preferred_key + ) 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) + if preferred_node or additions: + parts = [part for part in (preferred_node, current, *additions) if part] + os.environ["PATH"] = os.pathsep.join(parts) def _which_with_install_dirs(name: str) -> Optional[str]: @@ -2445,6 +2486,60 @@ def _pinned_raw_github_commit(source: str) -> Optional[str]: return match.group(1).lower() if match else None +def _npm_executable() -> Optional[str]: + executable = shutil.which("npm") + windows_npm_in_wsl = bool(executable and _wsl_windows_executable([executable])) + managed_node = _managed_node_tools() + if executable and not windows_npm_in_wsl and not (managed_node and managed_node[2]): + return executable + + return str(managed_node[1]) if managed_node is not None else None + + +def _install_command(install_hint: str) -> tuple[list[str], Optional[dict]]: + if not re.match(r"^\s*npm(?:\s|$)", install_hint): + if os.name == "nt": + return ( + [ + "powershell", + "-NoProfile", + "-ExecutionPolicy", + "Bypass", + "-Command", + install_hint, + ], + None, + ) + return ["/bin/sh", "-c", install_hint], None + + npm = _npm_executable() + if npm is None: + _fail( + "npm is required to install this agent, but no native system npm or usable " + "Unsloth-managed Node installation was found. Install Node.js with npm, " + "then re-run." + ) + args = shlex.split(install_hint) + env = dict(os.environ) + npm_dir = str(Path(npm).parent) + current_path = env.get("PATH", "") + env["PATH"] = os.pathsep.join([npm_dir, current_path]) if current_path else npm_dir + if os.name == "nt": + command = "& " + " ".join(_powershell_quote(arg) for arg in [npm, *args[1:]]) + return ( + [ + "powershell", + "-NoProfile", + "-ExecutionPolicy", + "Bypass", + "-Command", + command, + ], + env, + ) + return [npm, *args[1:]], env + + def _install_agent(name: str, install_hint: str) -> Optional[str]: # Missing agent under --launch: offer to run its documented install command, then # re-resolve it on PATH. Consent-based (we never auto-run a remote install script @@ -2481,22 +2576,15 @@ 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 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", - "-ExecutionPolicy", - "Bypass", - "-Command", - install_hint, - ] - else: - install_command = ["/bin/sh", "-c", install_hint] - if subprocess.run(install_command).returncode != 0: + install_command, install_env = _install_command(install_hint) + try: + result = subprocess.run(install_command, env = install_env) + except OSError as exc: + _fail( + f"Could not run the install command: {exc}. " + f"Run it yourself, then re-run: {install_hint}" + ) + if result.returncode != 0: 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. @@ -2519,6 +2607,19 @@ def _install_agent(name: str, install_hint: str) -> Optional[str]: return executable +def _resolve_or_install_agent(name: str, install_hint: str, resolver) -> str: + executable = resolver(name) or _install_agent(name, install_hint) + if executable is None: + _fail(f"`{name}` not found on PATH. Install it with: {install_hint}") + return executable + + +def _require_agent_for_launch(name: str, install_hint: str, launch: bool) -> Optional[str]: + if not launch: + return None + return _resolve_or_install_agent(name, install_hint, _which_with_install_dirs) + + def _wsl_shim_env(command: list, env: dict, unset_env: tuple) -> tuple[dict, tuple]: wsl_env_bridge = _wsl_bridge_names(env, unset_env) if _wsl_windows_executable(command) else () if not wsl_env_bridge: @@ -2538,9 +2639,7 @@ def _launch( # 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}") + executable = _resolve_or_install_agent(command[0], install_hint, shutil.which) env, wsl_env_bridge = _wsl_shim_env(command, env, unset_env) child_env = dict(os.environ) if wsl_env_bridge: @@ -2652,9 +2751,18 @@ def _run( def _agents_config_root() -> Path: - ensure_studio_backend_path() - from utils.paths import auth_root - return auth_root() / "agents" + return _studio_auth_root() / "agents" + + +@contextlib.contextmanager +def _temporary_agent_config(prefix: str): + temp_root = _agents_config_root() / ".tmp" + temp_root.mkdir(parents = True, exist_ok = True, mode = 0o700) + path = Path(tempfile.mkdtemp(prefix = prefix, dir = temp_root)) + try: + yield path + finally: + shutil.rmtree(path, ignore_errors = True) @contextlib.contextmanager @@ -2672,11 +2780,8 @@ def _session_config( resumed next time. Either way the user's real ~/. config is left untouched. """ if launch and not persist: - path = Path(tempfile.mkdtemp(prefix = f"unsloth-{agent}-")) - try: + with _temporary_agent_config(f"unsloth-{agent}-") as path: yield path - finally: - shutil.rmtree(path, ignore_errors = True) else: # Never wipe this dir: a previously printed recipe may still be running # an agent whose sessions/state live here, and every config writer @@ -3062,6 +3167,12 @@ def claude( """Point Claude Code at the running Unsloth server and start it.""" # Route a leading `org/name` positional to --model; forward the rest to the agent. model, ctx.args[:] = _consume_positional_model(model, ctx.args) + install_hint = ( + "irm https://claude.ai/install.ps1 | iex" + if os.name == "nt" + else "curl -fsSL https://claude.ai/install.sh | bash" + ) + _require_agent_for_launch("claude", install_hint, launch) base, key, entry = _connect( api_key, model, @@ -3081,11 +3192,6 @@ def claude( ), ) model_id = entry["id"] - install_hint = ( - "irm https://claude.ai/install.ps1 | iex" - if os.name == "nt" - else "curl -fsSL https://claude.ai/install.sh | bash" - ) if as_subagent: subagent_id = _subagent_model_id(base, key, entry, model, gguf_variant) subagent_model = {**entry, "id": subagent_id} @@ -3180,6 +3286,8 @@ def codex( """Point OpenAI Codex at the running Unsloth server and start it.""" # Route a leading `org/name` positional to --model; forward the rest to the agent. model, ctx.args[:] = _consume_positional_model(model, ctx.args) + install_hint = _npm_install_hint("@openai/codex") + _require_agent_for_launch("codex", install_hint, launch) base, key, entry = _connect( api_key, model, @@ -3217,25 +3325,25 @@ def codex( home, yolo = yolo, ) - with _codex_parent_overlay(home, launch = launch, persist = persist) as parent_home: - command = [ - "codex", - *_codex_subagent_flags(bridge_config), - *_yolo_command_flags("codex", yolo), - *ctx.args, - ] - typer.echo( - "Unsloth is available as a local agent. " - "Ask Codex to spawn an Unsloth or local agent." - ) - _run( - base, - subagent_model, - {"CODEX_HOME": str(parent_home)}, - command, - launch = launch, - install_hint = "npm install -g @openai/codex", - ) + parent_home = write_codex_parent_overlay(home / "parent") + command = [ + "codex", + *_codex_subagent_flags(bridge_config), + *_yolo_command_flags("codex", yolo), + *ctx.args, + ] + typer.echo( + "Unsloth is available as a local agent. " + "Ask Codex to spawn an Unsloth or local agent." + ) + _run( + base, + subagent_model, + {"CODEX_HOME": str(parent_home)}, + command, + launch = launch, + install_hint = install_hint, + ) return command = [ "codex", @@ -3248,7 +3356,7 @@ def codex( with _session_config("codex", launch, persist = persist) as home: write_codex_config(base, entry, home) env = {_CODEX_ENV_KEY: key, "CODEX_HOME": str(home)} - _run(base, entry, env, command, launch = launch, install_hint = "npm install -g @openai/codex") + _run(base, entry, env, command, launch = launch, install_hint = install_hint) @start_app.command("openclaw", cls = _PassthroughCommand, context_settings = _PASSTHROUGH) @@ -3279,6 +3387,12 @@ def openclaw( # Route a leading `org/name` positional to --model; forward the rest to the agent. model, ctx.args[:] = _consume_positional_model(model, ctx.args) _reject_as_subagent("openclaw", ctx.args) + install_hint = ( + "iwr -useb https://openclaw.ai/install.ps1 | iex" + if os.name == "nt" + else "curl -fsSL https://openclaw.ai/install.sh | bash" + ) + _require_agent_for_launch("openclaw", install_hint, launch) base, key, entry = _connect( api_key, model, @@ -3308,11 +3422,6 @@ def openclaw( if not openclaw_args: openclaw_args = ["tui", "--local"] command = ["openclaw", *openclaw_args] - install_hint = ( - "iwr -useb https://openclaw.ai/install.ps1 | iex" - if os.name == "nt" - else "curl -fsSL https://openclaw.ai/install.sh | bash" - ) with _session_config("openclaw", launch, persist = persist) as cfg: config_path = cfg / "openclaw.json" workspace_path = None @@ -3360,6 +3469,8 @@ def opencode( """Point OpenCode at the running Unsloth server and start it.""" # Route a leading `org/name` positional to --model; forward the rest to the agent. model, ctx.args[:] = _consume_positional_model(model, ctx.args) + install_hint = _npm_install_hint("opencode-ai") + _require_agent_for_launch("opencode", install_hint, launch) base, key, entry = _connect( api_key, model, @@ -3397,11 +3508,6 @@ def opencode( as_subagent = True, ) env = {"OPENCODE_CONFIG": str(config_path)} - if launch and _which_with_install_dirs("opencode") is None: - # Provider-filter inspection needs the binary; offer the install now so - # a global/project allowlist is honored on this first launch instead of - # being read only after _launch installs OpenCode. - _install_agent("opencode", "npm install -g opencode-ai") inline_config = _opencode_subagent_inline_config(config_path, session_permission) # A project opencode.json outranks the session file and could field-merge its # own agent.unsloth over ours. Pin ours in the inline overlay so it wins. @@ -3419,7 +3525,7 @@ def opencode( env, command, launch = launch, - install_hint = "npm install -g opencode-ai", + install_hint = install_hint, ) return opencode_model = f"{_OPENCODE_PROVIDER}/{entry['id']}" @@ -3490,7 +3596,7 @@ def opencode( "OPENCODE_CONFIG": str(config_path), "OPENCODE_CONFIG_CONTENT": json.dumps(inline_config), } - _run(base, entry, env, command, launch = launch, install_hint = "npm install -g opencode-ai") + _run(base, entry, env, command, launch = launch, install_hint = install_hint) @start_app.command("hermes", cls = _PassthroughCommand, context_settings = _PASSTHROUGH) @@ -3523,6 +3629,8 @@ def hermes( _reject_as_subagent("hermes", ctx.args) native_args = [*_yolo_command_flags("hermes", yolo), *ctx.args] command = ["hermes", *_hermes_resume_oneshot_args(native_args)] + install_hint = _hermes_install_hint() + _require_agent_for_launch("hermes", install_hint, launch) base, key, entry = _connect( api_key, model, @@ -3541,7 +3649,6 @@ def hermes( presence_penalty = presence_penalty, ), ) - install_hint = _hermes_install_hint() with _session_config("hermes", launch, persist = persist) as home: # HERMES_HOME relocates hermes' whole home dir (config.yaml, sessions, state) # like CODEX_HOME, so the user's ~/.hermes is left untouched for the session. @@ -3578,6 +3685,13 @@ def pi( """Point Pi (coding agent) at the running Unsloth server and start it.""" # Route a leading `org/name` positional to --model; forward the rest to the agent. model, ctx.args[:] = _consume_positional_model(model, ctx.args) + install_hint = _npm_install_hint( + "@earendil-works/pi-coding-agent", + ignore_scripts = True, + ) + if as_subagent and not _PI_SUBAGENT_EXTENSION.is_file(): + _fail(f"Missing Pi subagent extension: {_PI_SUBAGENT_EXTENSION}") + _require_agent_for_launch("pi", install_hint, launch) base, key, entry = _connect( api_key, model, @@ -3596,10 +3710,7 @@ def pi( presence_penalty = presence_penalty, ), ) - install_hint = "npm install -g --ignore-scripts @earendil-works/pi-coding-agent" if as_subagent: - if not _PI_SUBAGENT_EXTENSION.is_file(): - _fail(f"Missing Pi subagent extension: {_PI_SUBAGENT_EXTENSION}") subagent_id = _subagent_model_id(base, key, entry, model, gguf_variant) subagent_model = {**entry, "id": subagent_id} extension = _agent_config_path(_PI_SUBAGENT_EXTENSION, ["pi"]) diff --git a/unsloth_cli/tests/test_start.py b/unsloth_cli/tests/test_start.py index 608baa6e4c..64006ca983 100644 --- a/unsloth_cli/tests/test_start.py +++ b/unsloth_cli/tests/test_start.py @@ -12,7 +12,7 @@ import shlex import sys import urllib.error from pathlib import Path -from types import SimpleNamespace +from types import ModuleType, SimpleNamespace _REPO_ROOT = Path(__file__).resolve().parents[2] if str(_REPO_ROOT) not in sys.path: @@ -136,6 +136,7 @@ def test_install_agent_prompts_then_installs(monkeypatch): monkeypatch.setattr(start.os, "name", "posix") monkeypatch.setattr(start.sys, "stdin", SimpleNamespace(isatty = lambda: True)) monkeypatch.setattr(start.typer, "confirm", lambda *a, **k: True) + monkeypatch.setattr(start, "_npm_executable", lambda: "/usr/local/bin/npm") ran = [] monkeypatch.setattr( start.subprocess, @@ -147,7 +148,7 @@ def test_install_agent_prompts_then_installs(monkeypatch): monkeypatch.setattr(start.shutil, "which", lambda _: "/usr/local/bin/codex") executable = start._install_agent("codex", "npm install -g @openai/codex") assert executable == "/usr/local/bin/codex" - assert ran == [["/bin/sh", "-c", "npm install -g @openai/codex"]] + assert ran == [["/usr/local/bin/npm", "install", "-g", "@openai/codex"]] def test_install_agent_uses_powershell_on_windows(monkeypatch): @@ -177,9 +178,10 @@ def test_install_agent_windows_failure_hints_execution_policy(monkeypatch, capsy # A failed install on Windows points the user at the per-user execution-policy fix: # our subprocess bypasses the policy, but their own shell may still block npm.ps1 # (PSSecurityException) when they run the install by hand. - monkeypatch.setattr(start.os, "name", "nt") + _simulate_windows(monkeypatch) monkeypatch.setattr(start.sys, "stdin", SimpleNamespace(isatty = lambda: True)) monkeypatch.setattr(start.typer, "confirm", lambda *a, **k: True) + monkeypatch.setattr(start, "_npm_executable", lambda: r"C:\Users\me\AppData\Roaming\npm\npm.cmd") monkeypatch.setattr( start.subprocess, "run", @@ -195,6 +197,23 @@ def test_install_agent_windows_failure_hints_execution_policy(monkeypatch, capsy assert "Set-ExecutionPolicy -Scope CurrentUser -ExecutionPolicy RemoteSigned" in err +def test_install_command_uses_resolved_npm_cmd_on_windows(monkeypatch): + _simulate_windows(monkeypatch) + monkeypatch.setattr(start, "_npm_executable", lambda: r"C:\Managed Node\npm.cmd") + + command, env = start._install_command(start._npm_install_hint("@openai/codex")) + + assert command == [ + "powershell", + "-NoProfile", + "-ExecutionPolicy", + "Bypass", + "-Command", + "& 'C:\\Managed Node\\npm.cmd' install -g '@openai/codex'", + ] + assert env is not None + + def test_install_agent_posix_failure_omits_execution_policy_hint(monkeypatch, capsys): # The execution-policy hint is Windows-only; a POSIX install failure must not mention it. monkeypatch.setattr(start.os, "name", "posix") @@ -214,6 +233,176 @@ def test_install_agent_posix_failure_omits_execution_policy_hint(monkeypatch, ca assert "Set-ExecutionPolicy" not in err +def test_npm_install_hint_uses_user_prefix_on_posix(monkeypatch, tmp_path): + monkeypatch.setattr(start.os, "name", "posix") + monkeypatch.setattr(start.Path, "home", lambda: tmp_path) + + hint = start._npm_install_hint("@openai/codex") + + assert shlex.split(hint) == [ + "npm", + "install", + "-g", + "--prefix", + str(tmp_path / ".local"), + "@openai/codex", + ] + + +def test_npm_executable_uses_studio_managed_node(monkeypatch, tmp_path): + start.ensure_studio_backend_path() + from utils import node_runtime + + managed_bin = tmp_path / "node" / "bin" + managed_bin.mkdir(parents = True) + node = managed_bin / "node" + npm = managed_bin / "npm" + node.touch() + npm.touch() + node.chmod(0o755) + npm.chmod(0o755) + monkeypatch.setattr(start.shutil, "which", lambda _: None) + monkeypatch.setattr(node_runtime, "managed_node_binary", lambda: node) + monkeypatch.setattr(node_runtime, "resolve_node_executable", lambda: str(node)) + + assert start._npm_executable() == str(npm) + + +def test_npm_executable_uses_managed_npm_when_system_node_has_none(monkeypatch, tmp_path): + start.ensure_studio_backend_path() + from utils import node_runtime + + managed_bin = tmp_path / "node" / "bin" + managed_bin.mkdir(parents = True) + node = managed_bin / "node" + npm = managed_bin / "npm" + node.touch() + npm.touch() + node.chmod(0o755) + npm.chmod(0o755) + monkeypatch.setattr( + start.shutil, + "which", + lambda name: "/usr/bin/node" if name == "node" else None, + ) + monkeypatch.setattr(node_runtime, "managed_node_binary", lambda: node) + monkeypatch.setattr(node_runtime, "resolve_node_executable", lambda: str(node)) + + assert start._npm_executable() == str(npm) + + +def test_npm_executable_prefers_managed_npm_over_windows_npm_in_wsl(monkeypatch, tmp_path): + start.ensure_studio_backend_path() + from utils import node_runtime + + managed_bin = tmp_path / "node" / "bin" + managed_bin.mkdir(parents = True) + node = managed_bin / "node" + npm = managed_bin / "npm" + node.touch(mode = 0o755) + npm.touch(mode = 0o755) + windows_npm = "/mnt/c/Program Files/nodejs/npm" + monkeypatch.setenv("WSL_DISTRO_NAME", "Ubuntu") + monkeypatch.setattr( + start.shutil, + "which", + lambda name: windows_npm if name in ("npm", windows_npm) else None, + ) + monkeypatch.setattr(node_runtime, "managed_node_binary", lambda: node) + monkeypatch.setattr(node_runtime, "resolve_node_executable", lambda: str(node)) + + assert start._npm_executable() == str(npm) + + +def test_augment_path_includes_studio_managed_node(monkeypatch, tmp_path): + start.ensure_studio_backend_path() + from utils import node_runtime + + managed_bin = tmp_path / "node" / "bin" + managed_bin.mkdir(parents = True) + node = managed_bin / "node" + npm = managed_bin / "npm" + node.touch(mode = 0o755) + npm.touch(mode = 0o755) + monkeypatch.setattr(start.Path, "home", lambda: tmp_path / "home") + monkeypatch.setattr(node_runtime, "managed_node_binary", lambda: node) + monkeypatch.setattr(node_runtime, "resolve_node_executable", lambda: str(node)) + monkeypatch.setenv("PATH", os.pathsep.join([os.defpath, str(managed_bin)])) + + start._augment_path_with_install_dirs() + + path = os.environ["PATH"].split(os.pathsep) + assert path[0] == str(managed_bin) + assert path.count(str(managed_bin)) == 1 + + +def test_install_agent_missing_npm_names_node_requirement(monkeypatch, capsys): + monkeypatch.setattr(start.sys, "stdin", SimpleNamespace(isatty = lambda: True)) + monkeypatch.setattr(start.typer, "confirm", lambda *a, **k: True) + monkeypatch.setattr(start, "_npm_executable", lambda: None) + monkeypatch.setattr( + start.subprocess, + "run", + lambda *a, **k: pytest.fail("should not run an installer without npm"), + ) + + with pytest.raises(start.typer.Exit): + start._install_agent("codex", "npm install -g @openai/codex") + + err = capsys.readouterr().err + assert "npm is required" in err + assert "Unsloth-managed Node" in err + assert "Install Node.js with npm" in err + + +def test_install_agent_reports_os_error_without_traceback(monkeypatch, capsys): + monkeypatch.setattr(start.sys, "stdin", SimpleNamespace(isatty = lambda: True)) + monkeypatch.setattr(start.typer, "confirm", lambda *a, **k: True) + monkeypatch.setattr(start, "_npm_executable", lambda: "/broken/npm") + monkeypatch.setattr( + start.subprocess, + "run", + lambda *args, **kwargs: (_ for _ in ()).throw(PermissionError("permission denied")), + ) + + with pytest.raises(start.typer.Exit): + start._install_agent("codex", "npm install -g @openai/codex") + + err = capsys.readouterr().err + assert "Could not run the install command: permission denied" in err + assert "Run it yourself, then re-run" in err + + +def test_install_agent_runs_managed_npm_with_its_node_on_path(monkeypatch, tmp_path): + monkeypatch.setattr(start.os, "name", "posix") + monkeypatch.setattr(start.Path, "home", lambda: tmp_path) + monkeypatch.setattr(start.sys, "stdin", SimpleNamespace(isatty = lambda: True)) + monkeypatch.setattr(start.typer, "confirm", lambda *a, **k: True) + npm = tmp_path / "managed-node" / "bin" / "npm" + monkeypatch.setattr(start, "_npm_executable", lambda: str(npm)) + monkeypatch.setattr(start.shutil, "which", lambda _: "/usr/local/bin/codex") + captured = {} + + def run(command, **kwargs): + captured["command"] = command + captured["env"] = kwargs["env"] + return SimpleNamespace(returncode = 0) + + monkeypatch.setattr(start.subprocess, "run", run) + hint = start._npm_install_hint("@openai/codex") + + assert start._install_agent("codex", hint) == "/usr/local/bin/codex" + assert captured["command"] == [ + str(npm), + "install", + "-g", + "--prefix", + str(tmp_path / ".local"), + "@openai/codex", + ] + assert captured["env"]["PATH"].split(os.pathsep)[0] == str(npm.parent) + + def test_install_agent_warns_remote_installer_is_unverified_third_party(monkeypatch, capsys): # Before the confirm, a remote installer must name the URL it fetches so the # user consents to a specific source rather than blindly accepting. @@ -530,6 +719,25 @@ def _parse_toml(text: str) -> dict: return tomllib.loads(text) +def test_project_declares_direct_click_dependency(): + project = _parse_toml((_REPO_ROOT / "pyproject.toml").read_text(encoding = "utf-8")) + assert "click>=8.0" in project["project"]["dependencies"] + + +def test_agent_paths_use_cli_studio_home_without_backend_imports(monkeypatch, tmp_path): + studio = ModuleType("unsloth_cli.commands.studio") + studio.STUDIO_HOME = tmp_path + monkeypatch.setitem(sys.modules, studio.__name__, studio) + monkeypatch.setattr( + start, + "ensure_studio_backend_path", + lambda: pytest.fail("agent paths should not import backend runtime packages"), + ) + + assert start._key_cache_path() == tmp_path / "auth" / "agent_api_key.json" + assert start._agents_config_root() == tmp_path / "auth" / "agents" + + def test_merge_codex_config_fresh(): merged = start._merge_codex_config("", BASE) parsed = _parse_toml(merged) @@ -796,20 +1004,34 @@ def test_write_codex_parent_overlay_uses_windows_home_for_windows_codex(tmp_path assert (overlay / "auth.json").read_text() == '{"auth": "windows"}\n' -def test_codex_parent_overlay_launch_uses_private_temp_root_and_cleans_up(tmp_path, monkeypatch): +def test_codex_parent_overlay_can_use_session_home(tmp_path, monkeypatch): source = tmp_path / "user-codex" source.mkdir() (source / "auth.json").write_text("{}\n") monkeypatch.setenv("CODEX_HOME", str(source)) + session_home = tmp_path / "session" + + overlay = start.write_codex_parent_overlay(session_home / "parent") + + assert overlay == session_home / "parent" + assert start._CODEX_SUBAGENT_ROUTING_INSTRUCTIONS in (overlay / "AGENTS.md").read_text() + assert overlay.exists() + + +def test_ephemeral_codex_parent_overlay_is_cleaned_with_session(tmp_path, monkeypatch): + source = tmp_path / "user-codex" + source.mkdir() + monkeypatch.setenv("CODEX_HOME", str(source)) agents_root = tmp_path / "agents" monkeypatch.setattr(start, "_agents_config_root", lambda: agents_root) - with start._codex_parent_overlay(tmp_path / "session", launch = True, persist = False) as overlay: - assert overlay.parent == agents_root / ".tmp" - assert start._CODEX_SUBAGENT_ROUTING_INSTRUCTIONS in (overlay / "AGENTS.md").read_text() + with start._session_config("codex-subagent", launch = True) as session_home: + overlay = start.write_codex_parent_overlay(session_home / "parent") assert overlay.exists() + assert session_home.exists() assert not overlay.exists() + assert not session_home.exists() @pytest.mark.skipif(os.name == "nt", reason = "WSL scenario") @@ -894,6 +1116,113 @@ def test_unsupported_agents_reject_as_subagent(agent, flag): assert f"--as-subagent is not supported for {agent}." in result.output +@pytest.mark.parametrize("agent", ["claude", "codex", "openclaw", "opencode", "hermes", "pi"]) +def test_launch_preflights_agent_before_connect(agent, monkeypatch): + events = [] + + def require(name, hint, launch): + assert name == agent + assert hint + assert launch is True + events.append("agent") + + def connect(*args, **kwargs): + events.append("connect") + raise RuntimeError("stop after ordering check") + + monkeypatch.setattr(start, "_require_agent_for_launch", require) + monkeypatch.setattr(start, "_connect", connect) + + result = CliRunner().invoke(start.start_app, [agent]) + + assert result.exit_code == 1 + assert events == ["agent", "connect"] + + +def test_declined_opencode_subagent_install_stops_before_connect(monkeypatch): + installs = [] + monkeypatch.setattr(start, "_which_with_install_dirs", lambda _: None) + monkeypatch.setattr( + start, + "_install_agent", + lambda name, hint: installs.append((name, hint)), + ) + monkeypatch.setattr( + start, + "_connect", + lambda *a, **k: pytest.fail("declined install must stop before model connection"), + ) + + result = CliRunner().invoke(start.start_app, ["opencode", "--as-subagent"]) + + assert result.exit_code == 1 + assert len(installs) == 1 + assert installs[0][0] == "opencode" + + +@pytest.mark.parametrize("agent", ["claude", "codex", "openclaw", "opencode", "hermes", "pi"]) +def test_noninteractive_missing_agent_stops_before_connect(agent, monkeypatch): + monkeypatch.setattr(start, "_which_with_install_dirs", lambda _: None) + monkeypatch.setattr( + start.subprocess, + "run", + lambda *args, **kwargs: pytest.fail("non-interactive launch must not install"), + ) + monkeypatch.setattr( + start, + "_connect", + lambda *args, **kwargs: pytest.fail("missing agent must stop before connection"), + ) + + result = CliRunner().invoke(start.start_app, [agent]) + + assert result.exit_code == 1 + assert f"`{agent}` not found on PATH" in result.output + + +@pytest.mark.parametrize("agent", ["claude", "codex", "openclaw", "opencode", "hermes", "pi"]) +def test_no_launch_skips_agent_resolution(agent, monkeypatch): + monkeypatch.setattr( + start, + "_which_with_install_dirs", + lambda _: pytest.fail("--no-launch must not resolve an agent"), + ) + monkeypatch.setattr( + start, + "_install_agent", + lambda *args: pytest.fail("--no-launch must not install an agent"), + ) + + def stop_at_connect(*args, **kwargs): + raise RuntimeError + + monkeypatch.setattr(start, "_connect", stop_at_connect) + + result = CliRunner().invoke(start.start_app, [agent, "--no-launch"]) + + assert result.exit_code == 1 + assert isinstance(result.exception, RuntimeError) + + +def test_missing_pi_subagent_extension_fails_before_install_or_connect(monkeypatch, tmp_path): + monkeypatch.setattr(start, "_PI_SUBAGENT_EXTENSION", tmp_path / "missing.ts") + monkeypatch.setattr( + start, + "_require_agent_for_launch", + lambda *args: pytest.fail("local prerequisites must be checked before installation"), + ) + monkeypatch.setattr( + start, + "_connect", + lambda *args, **kwargs: pytest.fail("local prerequisites must be checked before connection"), + ) + + result = CliRunner().invoke(start.start_app, ["pi", "--as-subagent"]) + + assert result.exit_code == 1 + assert "Missing Pi subagent extension" in result.output + + @pytest.fixture() def fake_studio(tmp_path, monkeypatch): calls = [] @@ -933,6 +1262,7 @@ def fake_studio(tmp_path, monkeypatch): monkeypatch.setattr(start, "_key_cache_path", lambda: tmp_path / "agent_api_key.json") # --no-launch session configs land under tmp instead of the real Unsloth dir. monkeypatch.setattr(start, "_agents_config_root", lambda: tmp_path / "agents") + monkeypatch.setattr(start, "_require_agent_for_launch", lambda *args: None) # No `claude` on PATH, so _claude_flags never probes the real binary. monkeypatch.setattr(start.shutil, "which", lambda _: None) monkeypatch.delenv("UNSLOTH_API_KEY", raising = False) @@ -3644,12 +3974,12 @@ def test_opencode_subagent_installs_binary_before_filter_inspection(fake_studio, lambda name: "/usr/local/bin/opencode" if installed.get("done") else None, ) - def install(name, hint): + def require(name, hint, launch): + assert launch is True installed["done"] = True installed["name"] = name - return "/usr/local/bin/opencode" - monkeypatch.setattr(start, "_install_agent", install) + monkeypatch.setattr(start, "_require_agent_for_launch", require) inspected = {} def inline(path, permission): @@ -4698,11 +5028,13 @@ def test_session_config_persist_uses_stable_dir_and_survives(monkeypatch, tmp_pa assert (home / "marker").read_text() == "kept" -def test_session_config_default_launch_is_ephemeral(): - # Default launch (no --persist) still uses a throwaway temp dir wiped on exit. +def test_session_config_default_launch_is_ephemeral(monkeypatch, tmp_path): + agents_root = tmp_path / "agents" + monkeypatch.setattr(start, "_agents_config_root", lambda: agents_root) with start._session_config("codex", launch = True) as home: assert home.exists() assert "unsloth-codex-" in home.name + assert home.parent == agents_root / ".tmp" assert not home.exists() @@ -4751,7 +5083,7 @@ def test_default_launch_home_is_ephemeral(agent, fake_studio, tmp_path, monkeypa captured = _capture_launch(monkeypatch, [agent]) home = captured["env"][_RESUME_ENV_VAR[agent]] assert f"unsloth-{agent}-" in home - assert str(tmp_path / "agents") not in home + assert Path(home).parent == tmp_path / "agents" / ".tmp" def test_resume_opencode_config_in_stable_dir(fake_studio, tmp_path, monkeypatch): From 733fb9ba5415260671347752336a7b8b06a1e83b Mon Sep 17 00:00:00 2001 From: oobabooga <112222186+oobabooga@users.noreply.github.com> Date: Mon, 27 Jul 2026 17:12:57 -0300 Subject: [PATCH 02/12] Never let the managed-Node probe break an agent launch --- unsloth_cli/commands/start.py | 5 ++++- 1 file changed, 4 insertions(+), 1 deletion(-) diff --git a/unsloth_cli/commands/start.py b/unsloth_cli/commands/start.py index 75c2c20d06..7a26cdfa12 100644 --- a/unsloth_cli/commands/start.py +++ b/unsloth_cli/commands/start.py @@ -2385,12 +2385,15 @@ def _refresh_windows_path() -> None: def _managed_node_tools() -> Optional[tuple[Path, Path, bool]]: + # Best-effort probe on the launch path: reaching the backend for the managed Node must + # never break a launch, so any failure (missing package, unreadable home, degraded env) + # just means "no managed Node" and falls back to the system runtime. try: ensure_studio_backend_path() from utils.node_runtime import managed_node_binary, resolve_node_executable node = Path(managed_node_binary()) - except (ImportError, OSError, TypeError, ValueError): + except Exception: return None npm = node.with_name("npm.cmd" if os.name == "nt" else "npm") try: From d8793b66cfe57162353619bbcaecd7f13d362d89 Mon Sep 17 00:00:00 2001 From: oobabooga <112222186+oobabooga@users.noreply.github.com> Date: Mon, 27 Jul 2026 17:33:03 -0300 Subject: [PATCH 03/12] unsloth start: narrow managed Node probe fallback --- unsloth_cli/commands/start.py | 2 +- unsloth_cli/tests/test_start.py | 9 +++++++++ 2 files changed, 10 insertions(+), 1 deletion(-) diff --git a/unsloth_cli/commands/start.py b/unsloth_cli/commands/start.py index 7a26cdfa12..919b4bf9d5 100644 --- a/unsloth_cli/commands/start.py +++ b/unsloth_cli/commands/start.py @@ -2393,7 +2393,7 @@ def _managed_node_tools() -> Optional[tuple[Path, Path, bool]]: from utils.node_runtime import managed_node_binary, resolve_node_executable node = Path(managed_node_binary()) - except Exception: + except (ImportError, OSError, RuntimeError, TypeError, ValueError): return None npm = node.with_name("npm.cmd" if os.name == "nt" else "npm") try: diff --git a/unsloth_cli/tests/test_start.py b/unsloth_cli/tests/test_start.py index 64006ca983..30c43dbfd2 100644 --- a/unsloth_cli/tests/test_start.py +++ b/unsloth_cli/tests/test_start.py @@ -268,6 +268,15 @@ def test_npm_executable_uses_studio_managed_node(monkeypatch, tmp_path): assert start._npm_executable() == str(npm) +def test_managed_node_probe_tolerates_unsupported_path_flavour(monkeypatch): + def unsupported_backend_path(): + raise RuntimeError("unsupported path flavour") + + monkeypatch.setattr(start, "ensure_studio_backend_path", unsupported_backend_path) + + assert start._managed_node_tools() is None + + def test_npm_executable_uses_managed_npm_when_system_node_has_none(monkeypatch, tmp_path): start.ensure_studio_backend_path() from utils import node_runtime From 98fe6d32ac9946d0c70fe164fcf521c4adefa7aa Mon Sep 17 00:00:00 2001 From: "pre-commit-ci[bot]" <66853113+pre-commit-ci[bot]@users.noreply.github.com> Date: Mon, 27 Jul 2026 21:06:12 +0000 Subject: [PATCH 04/12] [pre-commit.ci] auto fixes from pre-commit.com hooks for more information, see https://pre-commit.ci --- unsloth_cli/commands/start.py | 2 -- unsloth_cli/tests/test_start.py | 8 ++++++-- 2 files changed, 6 insertions(+), 4 deletions(-) diff --git a/unsloth_cli/commands/start.py b/unsloth_cli/commands/start.py index 919b4bf9d5..b99ad91dd0 100644 --- a/unsloth_cli/commands/start.py +++ b/unsloth_cli/commands/start.py @@ -1201,7 +1201,6 @@ def _require_studio( def _studio_auth_root() -> Path: from unsloth_cli.commands.studio import STUDIO_HOME - return STUDIO_HOME / "auth" @@ -2391,7 +2390,6 @@ def _managed_node_tools() -> Optional[tuple[Path, Path, bool]]: try: ensure_studio_backend_path() from utils.node_runtime import managed_node_binary, resolve_node_executable - node = Path(managed_node_binary()) except (ImportError, OSError, RuntimeError, TypeError, ValueError): return None diff --git a/unsloth_cli/tests/test_start.py b/unsloth_cli/tests/test_start.py index 30c43dbfd2..c904a997c3 100644 --- a/unsloth_cli/tests/test_start.py +++ b/unsloth_cli/tests/test_start.py @@ -181,7 +181,9 @@ def test_install_agent_windows_failure_hints_execution_policy(monkeypatch, capsy _simulate_windows(monkeypatch) monkeypatch.setattr(start.sys, "stdin", SimpleNamespace(isatty = lambda: True)) monkeypatch.setattr(start.typer, "confirm", lambda *a, **k: True) - monkeypatch.setattr(start, "_npm_executable", lambda: r"C:\Users\me\AppData\Roaming\npm\npm.cmd") + monkeypatch.setattr( + start, "_npm_executable", lambda: r"C:\Users\me\AppData\Roaming\npm\npm.cmd" + ) monkeypatch.setattr( start.subprocess, "run", @@ -1223,7 +1225,9 @@ def test_missing_pi_subagent_extension_fails_before_install_or_connect(monkeypat monkeypatch.setattr( start, "_connect", - lambda *args, **kwargs: pytest.fail("local prerequisites must be checked before connection"), + lambda *args, **kwargs: pytest.fail( + "local prerequisites must be checked before connection" + ), ) result = CliRunner().invoke(start.start_app, ["pi", "--as-subagent"]) From fb43e3faf27d808f29c718813cd327f7924f81b7 Mon Sep 17 00:00:00 2001 From: danielhanchen Date: Wed, 29 Jul 2026 08:06:49 +0000 Subject: [PATCH 05/12] Keep npm lookup compatible with single-arg which stubs and skip POSIX-only tests on Windows Cross-platform runs surfaced two problems in the npm work. `_npm_executable` called `shutil.which("npm", path = ...)` on the common path, which broke callers and tests that stub `shutil.which` with a single positional argument. It now keeps the plain `shutil.which("npm")` lookup and only walks PATH entry by entry after that first hit is rejected as a WSL Windows shim, which is the only case that needs the wider search. Seven tests added in this branch assume POSIX: the managed node layout uses a bare `npm` rather than `npm.cmd`, WSL shim rejection is a no-op when os.name is "nt", and the npm hint is PowerShell quoted there. Marked them skipif os.name == "nt", matching the convention already used in this file. unsloth_cli/tests/test_start.py: 381 passed. --- unsloth_cli/commands/start.py | 15 +++++++++------ unsloth_cli/tests/test_start.py | 15 ++++++++++++++- 2 files changed, 23 insertions(+), 7 deletions(-) diff --git a/unsloth_cli/commands/start.py b/unsloth_cli/commands/start.py index fcd4c7f691..8618eea213 100644 --- a/unsloth_cli/commands/start.py +++ b/unsloth_cli/commands/start.py @@ -2521,13 +2521,16 @@ def _pinned_raw_github_commit(source: str) -> Optional[str]: def _npm_executable() -> Optional[str]: managed_node = _managed_node_tools() if not (managed_node and managed_node[2]): - # WSL inherits the Windows PATH, so a shim often shadows a usable native npm. - # Skip shims and keep looking instead of stopping at shutil.which's first hit. - for directory in os.get_exec_path(): - executable = shutil.which("npm", path = directory) - if executable is None or _wsl_windows_executable([executable]): - continue + executable = shutil.which("npm") + if executable and not _wsl_windows_executable([executable]): return executable + if executable: + # WSL inherits the Windows PATH, so the shim just rejected may be shadowing a + # usable native npm further along it. Keep looking rather than giving up here. + for directory in os.get_exec_path(): + candidate = shutil.which("npm", path = directory) + if candidate and not _wsl_windows_executable([candidate]): + return candidate return str(managed_node[1]) if managed_node is not None else None diff --git a/unsloth_cli/tests/test_start.py b/unsloth_cli/tests/test_start.py index aec041b482..d092f30de7 100644 --- a/unsloth_cli/tests/test_start.py +++ b/unsloth_cli/tests/test_start.py @@ -252,6 +252,7 @@ def test_npm_install_hint_uses_user_prefix_on_posix(monkeypatch, tmp_path): ] +@pytest.mark.skipif(os.name == "nt", reason = "managed node layout is POSIX") def test_npm_executable_uses_studio_managed_node(monkeypatch, tmp_path): start.ensure_studio_backend_path() from utils import node_runtime @@ -271,6 +272,7 @@ def test_npm_executable_uses_studio_managed_node(monkeypatch, tmp_path): assert start._npm_executable() == str(npm) +@pytest.mark.skipif(os.name == "nt", reason = "WSL scenario") def test_npm_executable_skips_wsl_shim_and_finds_native_npm(monkeypatch): # WSL inherits the Windows PATH, so a shim can precede a usable native npm. native = "/usr/bin/npm" @@ -279,16 +281,23 @@ def test_npm_executable_skips_wsl_shim_and_finds_native_npm(monkeypatch): monkeypatch.setenv("PATH", "/mnt/c/Program Files/nodejs:/usr/bin") monkeypatch.setattr(start, "_managed_node_tools", lambda: None) + found = {"/mnt/c/Program Files/nodejs": shim, "/usr/bin": native} + def fake_which(name, path = None): if os.path.isabs(name): return name - return {"/mnt/c/Program Files/nodejs": shim, "/usr/bin": native}.get(path) + # No path given means search all of PATH, so the shim wins as it does in WSL. + for entry in (path or os.environ["PATH"]).split(os.pathsep): + if entry in found: + return found[entry] + return None monkeypatch.setattr(start.shutil, "which", fake_which) assert start._npm_executable() == native +@pytest.mark.skipif(os.name == "nt", reason = "POSIX hint form") def test_npm_install_hint_without_resolvable_home(monkeypatch): # A bare container UID has no home; the hint must still build so an already # installed agent can launch. @@ -309,6 +318,7 @@ def test_managed_node_probe_tolerates_unsupported_path_flavour(monkeypatch): assert start._managed_node_tools() is None +@pytest.mark.skipif(os.name == "nt", reason = "managed node layout is POSIX") def test_npm_executable_uses_managed_npm_when_system_node_has_none(monkeypatch, tmp_path): start.ensure_studio_backend_path() from utils import node_runtime @@ -332,6 +342,7 @@ def test_npm_executable_uses_managed_npm_when_system_node_has_none(monkeypatch, assert start._npm_executable() == str(npm) +@pytest.mark.skipif(os.name == "nt", reason = "WSL scenario") def test_npm_executable_prefers_managed_npm_over_windows_npm_in_wsl(monkeypatch, tmp_path): start.ensure_studio_backend_path() from utils import node_runtime @@ -355,6 +366,7 @@ def test_npm_executable_prefers_managed_npm_over_windows_npm_in_wsl(monkeypatch, assert start._npm_executable() == str(npm) +@pytest.mark.skipif(os.name == "nt", reason = "managed node layout is POSIX") def test_augment_path_includes_studio_managed_node(monkeypatch, tmp_path): start.ensure_studio_backend_path() from utils import node_runtime @@ -414,6 +426,7 @@ def test_install_agent_reports_os_error_without_traceback(monkeypatch, capsys): assert "Run it yourself, then re-run" in err +@pytest.mark.skipif(os.name == "nt", reason = "POSIX install command") def test_install_agent_runs_managed_npm_with_its_node_on_path(monkeypatch, tmp_path): monkeypatch.setattr(start.os, "name", "posix") monkeypatch.setattr(start.Path, "home", lambda: tmp_path) From a5c4c07fafa02c56e19792f1fe3297f5e39e368e Mon Sep 17 00:00:00 2001 From: danielhanchen Date: Wed, 29 Jul 2026 08:11:52 +0000 Subject: [PATCH 06/12] Resolve the npm directory with dirname so a patched os.name cannot pick the wrong Path flavour _install_command used Path(npm).parent. pathlib chooses PosixPath or WindowsPath from os.name at call time, so a caller that overrides os.name (the install tests set it to posix) builds the flavour the host cannot instantiate, and the call raised NotImplementedError on Windows. os.path.dirname gives the same answer without consulting os.name. An empty result now leaves PATH alone rather than prepending the current directory. --- unsloth_cli/commands/start.py | 8 ++++++-- 1 file changed, 6 insertions(+), 2 deletions(-) diff --git a/unsloth_cli/commands/start.py b/unsloth_cli/commands/start.py index 8618eea213..31db1a9210 100644 --- a/unsloth_cli/commands/start.py +++ b/unsloth_cli/commands/start.py @@ -2560,9 +2560,13 @@ def _install_command(install_hint: str) -> tuple[list[str], Optional[dict]]: ) args = shlex.split(install_hint) env = dict(os.environ) - npm_dir = str(Path(npm).parent) + # dirname, not Path().parent: Path picks its flavour from os.name, so a caller that + # overrides it (the tests do) builds the wrong one. Empty means npm is a bare name, + # and prepending "" would put the cwd on PATH. + npm_dir = os.path.dirname(npm) current_path = env.get("PATH", "") - env["PATH"] = os.pathsep.join([npm_dir, current_path]) if current_path else npm_dir + if npm_dir: + env["PATH"] = os.pathsep.join([npm_dir, current_path]) if current_path else npm_dir if os.name == "nt": command = "& " + " ".join(_powershell_quote(arg) for arg in [npm, *args[1:]]) return ( From eec7c67d1e20497bfa6a6b8458a646aac26d35bd Mon Sep 17 00:00:00 2001 From: danielhanchen Date: Wed, 29 Jul 2026 08:24:51 +0000 Subject: [PATCH 07/12] Reclaim ephemeral agent homes left behind by abnormal exits Moving ephemeral homes out of the system temp directory and under Studio's auth tree removed the only thing that ever cleaned them up: the OS. When the wrapper is killed by SIGKILL, the console closes or the machine crashes, the context manager's finally never runs, and nothing prunes /auth/agents/.tmp, so interrupted sessions accumulate there indefinitely. Only the Windows codex path had reclamation. `_temporary_agent_config` now goes through the same locked session helper that path already used, so every agent gets the scavenge on launch, the advisory lock that keeps a live session from being swept, and the heartbeat that anchors the stale window to wrapper death rather than session start. `_reclaim_stale_ephemeral_sessions` and `_short_ephemeral_session` take the prefix to glob and create, which is the only part that was codex specific. An age-only sweep was not enough on its own: without the live marker a session still running after the stale window would be deleted underneath itself. unsloth_cli/tests/test_start.py: 382 passed. --- unsloth_cli/commands/start.py | 24 ++++++++++++------------ unsloth_cli/tests/test_start.py | 24 ++++++++++++++++++++++++ 2 files changed, 36 insertions(+), 12 deletions(-) diff --git a/unsloth_cli/commands/start.py b/unsloth_cli/commands/start.py index 31db1a9210..7b34bb9dd8 100644 --- a/unsloth_cli/commands/start.py +++ b/unsloth_cli/commands/start.py @@ -2799,13 +2799,13 @@ def _agents_config_root() -> Path: @contextlib.contextmanager def _temporary_agent_config(prefix: str): + # These homes live under Studio's auth tree, which nothing else prunes, so reuse the + # locked session helper: a wrapper killed before its finally runs leaves a home that + # the next launch reclaims, and the lock keeps a live session from being swept. temp_root = _agents_config_root() / ".tmp" temp_root.mkdir(parents = True, exist_ok = True, mode = 0o700) - path = Path(tempfile.mkdtemp(prefix = prefix, dir = temp_root)) - try: + with _short_ephemeral_session(temp_root, prefix) as path: yield path - finally: - shutil.rmtree(path, ignore_errors = True) def _ephemeral_session_parent(agent: str) -> Optional[Path]: @@ -2874,9 +2874,9 @@ def _locked_file(path: Path, blocking: bool = True): handle.close() -def _reclaim_stale_ephemeral_sessions(parent: Path) -> None: - """Remove abandoned short Codex homes while preserving locked live sessions.""" - for path in parent.glob("u-codex-*"): +def _reclaim_stale_ephemeral_sessions(parent: Path, prefix: str) -> None: + """Remove abandoned session homes while preserving locked live sessions.""" + for path in parent.glob(f"{prefix}*"): if not path.is_dir(): continue active_lock = path / ".active.lock" @@ -2907,8 +2907,8 @@ def _refresh_ephemeral_session_marker(path: Path, stop: threading.Event) -> None @contextlib.contextmanager -def _short_ephemeral_session(parent: Path): - """Create a short Codex home whose lock makes crash cleanup concurrency-safe.""" +def _short_ephemeral_session(parent: Path, prefix: str = "u-codex-"): + """Create a session home whose lock makes crash cleanup concurrency-safe.""" path = None active_lock = contextlib.ExitStack() heartbeat_stop = None @@ -2917,8 +2917,8 @@ def _short_ephemeral_session(parent: Path): with _locked_file(parent / ".cleanup.lock") as cleanup_lock: if not cleanup_lock: # The blocking acquisition should always succeed. raise RuntimeError(f"Could not lock ephemeral session root: {parent}") - _reclaim_stale_ephemeral_sessions(parent) - path = Path(tempfile.mkdtemp(prefix = "u-codex-", dir = parent)) + _reclaim_stale_ephemeral_sessions(parent, prefix) + path = Path(tempfile.mkdtemp(prefix = prefix, dir = parent)) locked = active_lock.enter_context(_locked_file(path / ".active.lock")) if not locked: raise RuntimeError(f"Could not lock ephemeral session home: {path}") @@ -2926,7 +2926,7 @@ def _short_ephemeral_session(parent: Path): heartbeat = threading.Thread( target = _refresh_ephemeral_session_marker, args = (path / ".active.lock", heartbeat_stop), - name = "unsloth-codex-home-heartbeat", + name = "unsloth-agent-home-heartbeat", daemon = True, ) heartbeat.start() diff --git a/unsloth_cli/tests/test_start.py b/unsloth_cli/tests/test_start.py index d092f30de7..4e33b15064 100644 --- a/unsloth_cli/tests/test_start.py +++ b/unsloth_cli/tests/test_start.py @@ -5199,6 +5199,30 @@ def test_session_config_reclaims_old_short_homes_but_keeps_recent_and_live(monke assert not first.exists() +def test_session_config_reclaims_abandoned_homes_for_non_codex_agents(monkeypatch, tmp_path): + # These homes sit under Studio's auth tree, which nothing else prunes, so a wrapper + # killed before its finally runs must be reclaimed by the next launch. + agents_root = tmp_path / "agents" + temp_root = agents_root / ".tmp" + temp_root.mkdir(parents = True) + monkeypatch.setattr(start, "_agents_config_root", lambda: agents_root) + abandoned = temp_root / "unsloth-claude-abandoned" + abandoned.mkdir() + (abandoned / ".active.lock").write_bytes(b"\0") + (abandoned / "state.json").write_text("left behind") + old = time.time() - start._CODEX_EPHEMERAL_STALE_SECONDS - 1 + os.utime(abandoned / ".active.lock", (old, old)) + recent = temp_root / "unsloth-claude-still-running" + recent.mkdir() + (recent / ".active.lock").write_bytes(b"\0") + + with start._session_config("claude", launch = True) as home: + assert not abandoned.exists() + assert recent.exists() + assert home.parent == temp_root + assert not home.exists() + + def test_session_config_serializes_normal_short_home_deletion(monkeypatch, tmp_path): short_parent = tmp_path / "u" short_parent.mkdir() From c3d09c0fa12ad582b1012bbfe31dda002159f077 Mon Sep 17 00:00:00 2001 From: danielhanchen Date: Wed, 29 Jul 2026 08:36:24 +0000 Subject: [PATCH 08/12] Route Codex subagent homes through the short Windows parent `_ephemeral_session_parent` matched the agent name exactly, so only `codex` reached the short `~/.unsloth/.tmp/u-codex-*` root. The subagent path is created as `codex-subagent`, and it also nests CODEX_HOME one level deeper under `/parent`, so it needed the short root more than a plain launch, not less. On Windows with an 8 character mkdtemp suffix the resulting CODEX_HOME was 86 characters against 248 for the git limit, where current main was 69 and a plain codex launch is 45. Codex checks out its curated plugins below CODEX_HOME, which is what exceeded the limit in the first place. Both names now take the short root and the same `u-codex-` prefix, so one scavenger pass covers both, and the subagent home lands at 52. `_session_config` now derives its prefix from `_ephemeral_session_prefix` for both branches rather than rebuilding it inline. unsloth_cli/tests/test_start.py: 385 passed. --- unsloth_cli/commands/start.py | 15 +++++++++++---- unsloth_cli/tests/test_start.py | 20 ++++++++++++++++++++ 2 files changed, 31 insertions(+), 4 deletions(-) diff --git a/unsloth_cli/commands/start.py b/unsloth_cli/commands/start.py index 7b34bb9dd8..73465ee4ed 100644 --- a/unsloth_cli/commands/start.py +++ b/unsloth_cli/commands/start.py @@ -2808,9 +2808,13 @@ def _temporary_agent_config(prefix: str): yield path +# codex-subagent nests CODEX_HOME under /parent, so it needs the short root too. +_CODEX_SHORT_HOME_AGENTS = ("codex", "codex-subagent") + + def _ephemeral_session_parent(agent: str) -> Optional[Path]: """Return a non-system-temp parent when an agent needs one.""" - if os.name != "nt" or agent != "codex": + if os.name != "nt" or agent not in _CODEX_SHORT_HOME_AGENTS: return None # Codex creates a deeply nested curated-plugin checkout below CODEX_HOME. # A normal %TEMP%\unsloth-codex-* home can exceed legacy Windows path @@ -2824,7 +2828,9 @@ def _ephemeral_session_parent(agent: str) -> Optional[Path]: def _ephemeral_session_prefix(agent: str, parent: Optional[Path]) -> str: """Return the platform-specific prefix for an ephemeral agent home.""" - return "u-codex-" if agent == "codex" and parent is not None else f"unsloth-{agent}-" + if agent in _CODEX_SHORT_HOME_AGENTS and parent is not None: + return "u-codex-" + return f"unsloth-{agent}-" @contextlib.contextmanager @@ -2967,11 +2973,12 @@ def _session_config( # Windows codex keeps #7519's short, locked home (MAX_PATH + stale reclaim); # every other agent uses the Studio-private root. parent = _ephemeral_session_parent(agent) + prefix = _ephemeral_session_prefix(agent, parent) if parent is not None: - with _short_ephemeral_session(parent) as path: + with _short_ephemeral_session(parent, prefix) as path: yield path else: - with _temporary_agent_config(f"unsloth-{agent}-") as path: + with _temporary_agent_config(prefix) as path: yield path else: # Never wipe this dir: a previously printed recipe may still be running diff --git a/unsloth_cli/tests/test_start.py b/unsloth_cli/tests/test_start.py index 4e33b15064..1674838482 100644 --- a/unsloth_cli/tests/test_start.py +++ b/unsloth_cli/tests/test_start.py @@ -5199,6 +5199,26 @@ def test_session_config_reclaims_old_short_homes_but_keeps_recent_and_live(monke assert not first.exists() +@pytest.mark.parametrize("agent", ["codex", "codex-subagent"]) +def test_windows_codex_homes_use_the_short_parent(monkeypatch, tmp_path, agent): + # codex-subagent nests CODEX_HOME under /parent, so the long Studio auth + # path would eat even more of the legacy MAX_PATH budget than a plain launch. + monkeypatch.setattr(start.os, "name", "nt") + monkeypatch.setattr(start.Path, "home", staticmethod(lambda: tmp_path)) + + assert start._ephemeral_session_parent(agent) == tmp_path / ".unsloth" / ".tmp" + parent = start._ephemeral_session_parent(agent) + assert start._ephemeral_session_prefix(agent, parent) == "u-codex-" + + +def test_non_codex_agents_keep_the_studio_private_root(monkeypatch, tmp_path): + monkeypatch.setattr(start.os, "name", "nt") + monkeypatch.setattr(start.Path, "home", staticmethod(lambda: tmp_path)) + + assert start._ephemeral_session_parent("claude") is None + assert start._ephemeral_session_prefix("claude", None) == "unsloth-claude-" + + def test_session_config_reclaims_abandoned_homes_for_non_codex_agents(monkeypatch, tmp_path): # These homes sit under Studio's auth tree, which nothing else prunes, so a wrapper # killed before its finally runs must be reclaimed by the next launch. From d999f22f040528404e9598be213b3f8b5670cc53 Mon Sep 17 00:00:00 2001 From: danielhanchen Date: Wed, 29 Jul 2026 08:51:19 +0000 Subject: [PATCH 09/12] Fall back to the system temp dir when the Studio auth tree is unwritable Routing every ephemeral home under /auth/agents/.tmp made a writable local auth tree a hard requirement for any non-persistent launch. Attaching to a remote or already-running Studio with an explicit key does not need one, and the key cache already tolerates that: _remember_key wraps its write in except OSError. Such launches used tempfile.mkdtemp() before this branch and now died with PermissionError before the agent started. _temporary_agent_config now falls back to the system temp dir when the root cannot be created, which is where these homes lived previously. Reclamation is lost on that path, but the OS prunes it, so nothing accumulates. unsloth_cli/tests/test_start.py: 386 passed. --- unsloth_cli/commands/start.py | 14 +++++++++++++- unsloth_cli/tests/test_start.py | 13 +++++++++++++ 2 files changed, 26 insertions(+), 1 deletion(-) diff --git a/unsloth_cli/commands/start.py b/unsloth_cli/commands/start.py index 73465ee4ed..0ca489dc31 100644 --- a/unsloth_cli/commands/start.py +++ b/unsloth_cli/commands/start.py @@ -2803,7 +2803,19 @@ def _temporary_agent_config(prefix: str): # locked session helper: a wrapper killed before its finally runs leaves a home that # the next launch reclaims, and the lock keeps a live session from being swept. temp_root = _agents_config_root() / ".tmp" - temp_root.mkdir(parents = True, exist_ok = True, mode = 0o700) + try: + temp_root.mkdir(parents = True, exist_ok = True, mode = 0o700) + except OSError: + # Attaching to a remote or already-running Studio does not need a local auth tree, + # so it may be absent or read-only; the key cache degrades the same way. Fall back + # to the system temp dir, which is where these homes lived before. No reclamation + # there, but the OS prunes it. + path = Path(tempfile.mkdtemp(prefix = prefix)) + try: + yield path + finally: + shutil.rmtree(path, ignore_errors = True) + return with _short_ephemeral_session(temp_root, prefix) as path: yield path diff --git a/unsloth_cli/tests/test_start.py b/unsloth_cli/tests/test_start.py index 1674838482..69a2bc983a 100644 --- a/unsloth_cli/tests/test_start.py +++ b/unsloth_cli/tests/test_start.py @@ -5219,6 +5219,19 @@ def test_non_codex_agents_keep_the_studio_private_root(monkeypatch, tmp_path): assert start._ephemeral_session_prefix("claude", None) == "unsloth-claude-" +def test_session_config_falls_back_when_studio_auth_root_is_unwritable(monkeypatch, tmp_path): + # Attaching to a remote or already-running Studio does not need a local auth tree, so + # an absent or read-only one must not stop the launch. + readonly = tmp_path / "readonly" + readonly.mkdir(mode = 0o500) + monkeypatch.setattr(start, "_agents_config_root", lambda: readonly / "agents") + + with start._session_config("claude", launch = True) as home: + assert home.exists() + assert readonly not in home.parents + assert not home.exists() + + def test_session_config_reclaims_abandoned_homes_for_non_codex_agents(monkeypatch, tmp_path): # These homes sit under Studio's auth tree, which nothing else prunes, so a wrapper # killed before its finally runs must be reclaimed by the next launch. From cab05dac322387bcd4f8f4d2cb7cbefe9f2e4268 Mon Sep 17 00:00:00 2001 From: danielhanchen Date: Wed, 29 Jul 2026 09:04:50 +0000 Subject: [PATCH 10/12] Give agent probes the augmented PATH and cover an existing unwritable temp root Two gaps in the previous two commits. _which_with_install_dirs restores PATH before returning, so a shim it resolved through Studio's managed Node could not find that node when the probe actually ran it. That broke `opencode debug config` on the --as-subagent path and made the claude and codex version probes report an unsupported build, which changes the flags the agent is launched with. Probes now build their environment with _probe_env, which augments PATH for the child without leaving it set in this process. The unwritable-root fallback only covered mkdir. When the temp root already exists but cannot be written, mkdir(exist_ok = True) succeeds and the failure moves to .cleanup.lock inside _short_ephemeral_session. The whole setup is now inside the guard, so either failure falls back to the system temp dir. unsloth_cli/tests/test_start.py: 388 passed. --- unsloth_cli/commands/start.py | 49 +++++++++++++++++++++------------ unsloth_cli/tests/test_start.py | 36 ++++++++++++++++++++++++ 2 files changed, 68 insertions(+), 17 deletions(-) diff --git a/unsloth_cli/commands/start.py b/unsloth_cli/commands/start.py index 0ca489dc31..101f169dd1 100644 --- a/unsloth_cli/commands/start.py +++ b/unsloth_cli/commands/start.py @@ -385,6 +385,7 @@ def _opencode_supports_native_auto() -> bool: text = True, timeout = 10, stderr = subprocess.DEVNULL, + env = _probe_env(), ) except Exception: return False @@ -1677,7 +1678,7 @@ def _claude_version() -> Optional[tuple]: return None try: result = subprocess.run( - [executable, "--version"], capture_output = True, text = True, timeout = 10 + [executable, "--version"], capture_output = True, text = True, timeout = 10, env = _probe_env() ) # Pull the X.Y.Z out of the output rather than assuming it is the first token. # claude prints it first today ("2.1.98 (Claude Code)"), but a format change @@ -1754,7 +1755,8 @@ def _codex_supports_model_catalog() -> bool: return True try: output = subprocess.check_output( - [executable, "--version"], text = True, timeout = 10, stderr = subprocess.DEVNULL + [executable, "--version"], text = True, timeout = 10, stderr = subprocess.DEVNULL, + env = _probe_env(), ) except Exception: return False @@ -2074,8 +2076,7 @@ def _opencode_subagent_inline_config(path: Path, permission: dict) -> dict: err = True, ) else: - env = os.environ.copy() - env["OPENCODE_CONFIG"] = _agent_config_path(path, ["opencode"]) + env = _probe_env(OPENCODE_CONFIG = _agent_config_path(path, ["opencode"])) try: resolved = subprocess.run( [executable, "debug", "config"], @@ -2485,6 +2486,23 @@ def _augment_path_with_install_dirs() -> None: os.environ["PATH"] = os.pathsep.join(parts) +def _probe_env(**extra: str) -> dict: + """Environment for probes that RUN a resolved shim. + + _which_with_install_dirs restores PATH before returning, so a shim backed by Studio's + managed Node would not find that node when executed. + """ + original = os.environ.get("PATH") + _augment_path_with_install_dirs() + env = os.environ.copy() + if original is None: + os.environ.pop("PATH", None) + else: + os.environ["PATH"] = original + env.update(extra) + return env + + 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 @@ -2803,20 +2821,17 @@ def _temporary_agent_config(prefix: str): # locked session helper: a wrapper killed before its finally runs leaves a home that # the next launch reclaims, and the lock keeps a live session from being swept. temp_root = _agents_config_root() / ".tmp" - try: - temp_root.mkdir(parents = True, exist_ok = True, mode = 0o700) - except OSError: - # Attaching to a remote or already-running Studio does not need a local auth tree, - # so it may be absent or read-only; the key cache degrades the same way. Fall back - # to the system temp dir, which is where these homes lived before. No reclamation - # there, but the OS prunes it. - path = Path(tempfile.mkdtemp(prefix = prefix)) + with contextlib.ExitStack() as stack: try: - yield path - finally: - shutil.rmtree(path, ignore_errors = True) - return - with _short_ephemeral_session(temp_root, prefix) as path: + temp_root.mkdir(parents = True, exist_ok = True, mode = 0o700) + path = stack.enter_context(_short_ephemeral_session(temp_root, prefix)) + except OSError: + # Attaching to a remote or already-running Studio does not need a local auth + # tree, so it may be absent, read-only, or owned by someone else; the key cache + # degrades the same way. Fall back to the system temp dir, where these homes + # lived before. No reclamation there, but the OS prunes it. + path = Path(tempfile.mkdtemp(prefix = prefix)) + stack.callback(shutil.rmtree, path, ignore_errors = True) yield path diff --git a/unsloth_cli/tests/test_start.py b/unsloth_cli/tests/test_start.py index 69a2bc983a..f9106b4ab3 100644 --- a/unsloth_cli/tests/test_start.py +++ b/unsloth_cli/tests/test_start.py @@ -5219,6 +5219,42 @@ def test_non_codex_agents_keep_the_studio_private_root(monkeypatch, tmp_path): assert start._ephemeral_session_prefix("claude", None) == "unsloth-claude-" +def test_session_config_falls_back_when_existing_temp_root_is_unwritable(monkeypatch, tmp_path): + # mkdir(exist_ok = True) succeeds on a root that already exists but cannot be written, + # so the lock file is the first thing to fail. + agents = tmp_path / "agents" + temp_root = agents / ".tmp" + temp_root.mkdir(parents = True) + os.chmod(temp_root, 0o500) + monkeypatch.setattr(start, "_agents_config_root", lambda: agents) + + try: + with start._session_config("claude", launch = True) as home: + assert home.exists() + assert temp_root not in home.parents + finally: + os.chmod(temp_root, 0o700) + assert not home.exists() + + +def test_probe_env_carries_install_dirs_and_restores_path(monkeypatch, tmp_path): + # A shim resolved via Studio's managed Node needs that node on PATH when it runs. + managed_bin = tmp_path / "node" / "bin" + managed_bin.mkdir(parents = True) + monkeypatch.setattr( + start, + "_managed_node_tools", + lambda: (managed_bin / "node", managed_bin / "npm", True), + ) + before = os.environ.get("PATH") + + env = start._probe_env(OPENCODE_CONFIG = "/tmp/cfg.json") + + assert str(managed_bin) in env["PATH"] + assert env["OPENCODE_CONFIG"] == "/tmp/cfg.json" + assert os.environ.get("PATH") == before + + def test_session_config_falls_back_when_studio_auth_root_is_unwritable(monkeypatch, tmp_path): # Attaching to a remote or already-running Studio does not need a local auth tree, so # an absent or read-only one must not stop the launch. From 17ffc9b3b34a45c84654c473864f2241ca412e85 Mon Sep 17 00:00:00 2001 From: "pre-commit-ci[bot]" <66853113+pre-commit-ci[bot]@users.noreply.github.com> Date: Wed, 29 Jul 2026 09:05:46 +0000 Subject: [PATCH 11/12] [pre-commit.ci] auto fixes from pre-commit.com hooks for more information, see https://pre-commit.ci --- unsloth_cli/commands/start.py | 5 ++++- 1 file changed, 4 insertions(+), 1 deletion(-) diff --git a/unsloth_cli/commands/start.py b/unsloth_cli/commands/start.py index 101f169dd1..de42c4d27d 100644 --- a/unsloth_cli/commands/start.py +++ b/unsloth_cli/commands/start.py @@ -1755,7 +1755,10 @@ def _codex_supports_model_catalog() -> bool: return True try: output = subprocess.check_output( - [executable, "--version"], text = True, timeout = 10, stderr = subprocess.DEVNULL, + [executable, "--version"], + text = True, + timeout = 10, + stderr = subprocess.DEVNULL, env = _probe_env(), ) except Exception: From 34f1e9b089f82d5fa72143fca33477748e5c0a2a Mon Sep 17 00:00:00 2001 From: danielhanchen Date: Wed, 29 Jul 2026 09:25:37 +0000 Subject: [PATCH 12/12] Keep the managed Node on PATH when the home directory is unavailable _augment_path_with_install_dirs returned as soon as Path.home() raised, and the managed Node lookup sits after that return, so a container running under a bare UID never got the managed Node on PATH. An npm agent shim installed there still resolved, then failed because `env node` could not find a node. The same environment is the one the install hint was already taught to handle. Only the user install dirs need a home, so a missing one now leaves them out rather than skipping the rest. When there is nothing to add, PATH is still left untouched, as before. unsloth_cli/tests/test_start.py: 390 passed. --- unsloth_cli/commands/start.py | 6 ++++-- unsloth_cli/tests/test_start.py | 36 +++++++++++++++++++++++++++++++++ 2 files changed, 40 insertions(+), 2 deletions(-) diff --git a/unsloth_cli/commands/start.py b/unsloth_cli/commands/start.py index de42c4d27d..1220503dc9 100644 --- a/unsloth_cli/commands/start.py +++ b/unsloth_cli/commands/start.py @@ -2449,11 +2449,13 @@ def _augment_path_with_install_dirs() -> None: # Add known install dirs to PATH so a freshly installed agent resolves without a new # shell. User agent dirs are appended so existing tools keep precedence. A managed Node # selected over the system runtime is prepended so Node-backed shims use that same runtime. + # Only the user install dirs need a home; a missing one must not also drop the managed + # Node, or a shim resolved here fails on `env node` under a bare container UID. try: home = Path.home() except (RuntimeError, OSError): - return - candidates = [home / ".local" / "bin"] + home = None + candidates = [home / ".local" / "bin"] if home is not None else [] if os.name == "nt": appdata = os.environ.get("APPDATA") if appdata: diff --git a/unsloth_cli/tests/test_start.py b/unsloth_cli/tests/test_start.py index f9106b4ab3..63a79e853c 100644 --- a/unsloth_cli/tests/test_start.py +++ b/unsloth_cli/tests/test_start.py @@ -5237,6 +5237,42 @@ def test_session_config_falls_back_when_existing_temp_root_is_unwritable(monkeyp assert not home.exists() +def test_augment_path_keeps_managed_node_without_a_home(monkeypatch, tmp_path): + # A bare container UID has no home, but the managed Node is still the runtime that + # resolves a Node-backed shim, so it must stay on PATH. + managed_bin = tmp_path / "node" / "bin" + managed_bin.mkdir(parents = True) + monkeypatch.setattr( + start, + "_managed_node_tools", + lambda: (managed_bin / "node", managed_bin / "npm", True), + ) + monkeypatch.setattr( + start.Path, + "home", + staticmethod(lambda: (_ for _ in ()).throw(RuntimeError("no home directory"))), + ) + monkeypatch.setenv("PATH", "/usr/bin") + + start._augment_path_with_install_dirs() + + assert os.environ["PATH"].split(os.pathsep)[0] == str(managed_bin) + + +def test_augment_path_leaves_path_alone_when_nothing_to_add(monkeypatch): + monkeypatch.setattr(start, "_managed_node_tools", lambda: None) + monkeypatch.setattr( + start.Path, + "home", + staticmethod(lambda: (_ for _ in ()).throw(RuntimeError("no home directory"))), + ) + monkeypatch.setenv("PATH", "/usr/bin") + + start._augment_path_with_install_dirs() + + assert os.environ["PATH"] == "/usr/bin" + + def test_probe_env_carries_install_dirs_and_restores_path(monkeypatch, tmp_path): # A shim resolved via Studio's managed Node needs that node on PATH when it runs. managed_bin = tmp_path / "node" / "bin"