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 c702b90748..1220503dc9 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 @@ -439,6 +440,23 @@ 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": + # No resolvable home (container under a bare UID): fall back to npm's own prefix + # rather than failing a launch whose agent may already be installed. + try: + parts.extend(("--prefix", str(Path.home() / ".local"))) + except (RuntimeError, OSError): + pass + 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( @@ -1213,10 +1231,13 @@ 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: @@ -1657,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 @@ -1734,7 +1755,11 @@ 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 @@ -2005,20 +2030,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) @@ -2068,8 +2079,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"], @@ -2408,19 +2418,51 @@ def _refresh_windows_path() -> None: os.environ["PATH"] = os.pathsep.join(entries) +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, RuntimeError, 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. + # 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: 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 @@ -2428,14 +2470,42 @@ 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 _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]: @@ -2471,6 +2541,71 @@ def _pinned_raw_github_commit(source: str) -> Optional[str]: return match.group(1).lower() if match else None +def _npm_executable() -> Optional[str]: + managed_node = _managed_node_tools() + if not (managed_node and managed_node[2]): + 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 + + +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) + # 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", "") + 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 ( + [ + "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 @@ -2507,22 +2642,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. @@ -2545,6 +2673,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: @@ -2564,9 +2705,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: @@ -2678,14 +2817,36 @@ 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): + # 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" + with contextlib.ExitStack() as stack: + try: + 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 + + +# 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 @@ -2699,7 +2860,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 @@ -2749,9 +2912,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" @@ -2782,8 +2945,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 @@ -2792,8 +2955,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}") @@ -2801,7 +2964,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() @@ -2839,16 +3002,16 @@ def _session_config( resumed next time. Either way the user's real ~/. config is left untouched. """ if launch and not persist: + # 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: - path = Path(tempfile.mkdtemp(prefix = _ephemeral_session_prefix(agent, parent))) - try: + with _temporary_agent_config(prefix) 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 @@ -3235,6 +3398,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, @@ -3255,11 +3424,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} @@ -3355,6 +3519,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, @@ -3393,25 +3559,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", @@ -3424,7 +3590,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) @@ -3456,6 +3622,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, @@ -3486,11 +3658,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 @@ -3539,6 +3706,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, @@ -3577,11 +3746,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. @@ -3599,7 +3763,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']}" @@ -3670,7 +3834,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) @@ -3704,6 +3868,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, @@ -3723,7 +3889,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. @@ -3761,6 +3926,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, @@ -3780,10 +3952,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 dd8a1daf2d..63a79e853c 100644 --- a/unsloth_cli/tests/test_start.py +++ b/unsloth_cli/tests/test_start.py @@ -13,7 +13,7 @@ import sys import time 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: @@ -137,6 +137,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, @@ -148,7 +149,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): @@ -178,9 +179,12 @@ 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", @@ -196,6 +200,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") @@ -215,6 +236,227 @@ 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", + ] + + +@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 + + 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) + + +@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" + shim = "/mnt/c/Program Files/nodejs/npm" + monkeypatch.setenv("WSL_DISTRO_NAME", "Ubuntu") + 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 + # 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. + def no_home(): + raise RuntimeError("no home directory") + + monkeypatch.setattr(start.Path, "home", staticmethod(no_home)) + + assert start._npm_install_hint("@openai/codex") == "npm install -g @openai/codex" + + +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 + + +@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 + + 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) + + +@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 + + 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) + + +@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 + + 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 + + +@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) + 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. @@ -531,6 +773,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) @@ -797,20 +1058,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") @@ -895,6 +1170,115 @@ 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 = [] @@ -934,6 +1318,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) @@ -3678,12 +4063,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): @@ -4732,12 +5117,15 @@ 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() parent = start._ephemeral_session_parent("codex") assert home.name.startswith(start._ephemeral_session_prefix("codex", parent)) + if parent is None: + assert home.parent == agents_root / ".tmp" assert not home.exists() @@ -4811,6 +5199,135 @@ 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_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_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" + 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. + 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. + 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() @@ -4875,7 +5392,8 @@ def test_default_launch_home_is_ephemeral(agent, fake_studio, tmp_path, monkeypa home = captured["env"][_RESUME_ENV_VAR[agent]] parent = start._ephemeral_session_parent(agent) assert start._ephemeral_session_prefix(agent, parent) in home - assert str(tmp_path / "agents") not in home + if parent is None: + assert Path(home).parent == tmp_path / "agents" / ".tmp" def test_resume_opencode_config_in_stable_dir(fake_studio, tmp_path, monkeypatch):