diff --git a/routes/cookbook_helpers.py b/routes/cookbook_helpers.py index e724b2dc1..73157ff8e 100644 --- a/routes/cookbook_helpers.py +++ b/routes/cookbook_helpers.py @@ -1204,6 +1204,41 @@ def _safe_env_prefix(ep: str | None) -> str | None: return f'[ -f "{path}" ] && source "{path}" || true' +def _local_windows_bash_env_prefix(ep: str | None) -> str | None: + """Convert a frontend PowerShell venv prefix for the local Git Bash runner.""" + if not ep: + return ep + + prefix = ep.strip() + if not prefix.startswith("&"): + return ep + + raw_path = prefix[1:].lstrip() + if not raw_path: + return ep + if raw_path.startswith("'"): + if len(raw_path) < 2 or not raw_path.endswith("'"): + return ep + quoted_path = raw_path[1:-1] + if "'" in quoted_path.replace("''", ""): + return ep + path = quoted_path.replace("''", "'") + else: + path = raw_path.rstrip() + if "'" in path or '"' in path: + return ep + if any(c in path for c in "\r\n;&|`$<>"): + return ep + if not path.replace("\\", "/").casefold().endswith("/scripts/activate.ps1"): + return ep + + bash_path = _git_bash_path(path) + if "\\" in bash_path: + return ep + bash_path = bash_path[: -len("Activate.ps1")] + "activate" + return "source " + shlex.quote(bash_path) + + def _ssh_ps(host, script_path, port=None): """Build SSH command to run a PowerShell script on a Windows remote.""" pf = f"-p {port} " if port and port != "22" else "" diff --git a/routes/cookbook_routes.py b/routes/cookbook_routes.py index d51fb9a09..d3d0e36dd 100644 --- a/routes/cookbook_routes.py +++ b/routes/cookbook_routes.py @@ -50,7 +50,7 @@ _SESSION_ID_RE, _validate_repo_id, _validate_serve_model_id, _validate_include, _validate_token, _validate_local_dir, _validate_gpus, _shell_path, _ps_squote, _bash_squote, _validate_serve_cmd, _parse_serve_phase, OLLAMA_MISSING_HINT, - _safe_env_prefix, _local_tooling_path_export, _append_serve_preflight_exit_lines, + _safe_env_prefix, _local_windows_bash_env_prefix, _local_tooling_path_export, _append_serve_preflight_exit_lines, _append_serve_exit_code_lines, _append_llama_cpp_linux_accel_build_lines, _cached_model_scan_script, load_stored_hf_token, _append_vllm_linux_preflight_lines, _ollama_bind_from_cmd, _pip_install_fallback_chain, @@ -1336,7 +1336,7 @@ async def model_download(request: Request, req: ModelDownloadRequest): # Local: run hf download in the background (tmux on POSIX, a detached # process + logfile on Windows where tmux doesn't exist). if req.env_prefix: - lines.append(_safe_env_prefix(req.env_prefix)) + lines.append(_safe_env_prefix(_local_windows_bash_env_prefix(req.env_prefix) if local_windows else req.env_prefix)) else: lines.append("deactivate 2>/dev/null; hash -r") # Show whether the HF token reached this run (masked) — tells a gated @@ -2166,7 +2166,7 @@ async def model_serve(request: Request, req: ServeRequest): if req.gpus: runner_lines.append(f"export CUDA_VISIBLE_DEVICES='{req.gpus}'") if req.env_prefix: - runner_lines.append(_safe_env_prefix(req.env_prefix)) + runner_lines.append(_safe_env_prefix(_local_windows_bash_env_prefix(req.env_prefix) if local_windows else req.env_prefix)) else: runner_lines.append("deactivate 2>/dev/null; hash -r") _append_venv_nvidia_library_path_lines(runner_lines, cmd=req.cmd) diff --git a/static/js/cookbookDownload.js b/static/js/cookbookDownload.js index 330d7d9aa..5e3ac9562 100644 --- a/static/js/cookbookDownload.js +++ b/static/js/cookbookDownload.js @@ -15,6 +15,7 @@ let _getPlatform; let _serverByVal; let _isWindows; let _buildEnvPrefix; +let _psQuote; let _buildServeCmd; let _detectBackend; let _detectToolParser; @@ -538,7 +539,7 @@ export async function _runModelDownload(panel, model, backend, hostOverride) { if (srv.downloadDir) payload.local_dir = srv.downloadDir; if (isWin) { if (env === 'venv' && envPath) { - payload.env_prefix = '& ' + (envPath.endsWith('\\Scripts\\Activate.ps1') ? envPath : envPath + '\\Scripts\\Activate.ps1'); + payload.env_prefix = '& ' + _psQuote(envPath.endsWith('\\Scripts\\Activate.ps1') ? envPath : envPath + '\\Scripts\\Activate.ps1'); } else if (env === 'conda' && envPath) { payload.env_prefix = 'conda activate ' + envPath; } @@ -652,6 +653,7 @@ export function initDownload(shared) { _serverByVal = shared._serverByVal; _isWindows = shared._isWindows; _buildEnvPrefix = shared._buildEnvPrefix; + _psQuote = shared._psQuote; _buildServeCmd = shared._buildServeCmd; _detectBackend = shared._detectBackend; _detectToolParser = shared._detectToolParser; diff --git a/static/js/cookbookRunning.js b/static/js/cookbookRunning.js index 5057d40d5..2dc9089b0 100644 --- a/static/js/cookbookRunning.js +++ b/static/js/cookbookRunning.js @@ -338,6 +338,7 @@ let _sshPrefix; let _getPlatform; let _isWindows; let _buildEnvPrefix; +let _psQuote; let _loadPresets; let _savePresets; let _copyText; @@ -1971,7 +1972,7 @@ export async function _launchServeTask(shortName, repo, cmd, fields, hostOverrid let envPrefix = ''; if (_isWindows()) { if (_envState.env === 'venv' && _envState.envPath) { - envPrefix = '& ' + (_envState.envPath.endsWith('\\Scripts\\Activate.ps1') ? _envState.envPath : _envState.envPath + '\\Scripts\\Activate.ps1'); + envPrefix = '& ' + _psQuote(_envState.envPath.endsWith('\\Scripts\\Activate.ps1') ? _envState.envPath : _envState.envPath + '\\Scripts\\Activate.ps1'); } else if (_envState.env === 'conda' && _envState.envPath) { envPrefix = 'conda activate ' + _envState.envPath; } @@ -4402,6 +4403,7 @@ export function initRunning(shared) { _getPlatform = shared._getPlatform; _isWindows = shared._isWindows; _buildEnvPrefix = shared._buildEnvPrefix; + _psQuote = shared._psQuote; _loadPresets = shared._loadPresets; _savePresets = shared._savePresets; _copyText = shared._copyText; diff --git a/tests/test_cookbook_helpers.py b/tests/test_cookbook_helpers.py index 37620c88a..fdac772cd 100644 --- a/tests/test_cookbook_helpers.py +++ b/tests/test_cookbook_helpers.py @@ -16,6 +16,7 @@ _llama_cpp_rebuild_cmd, _append_vllm_linux_preflight_lines, _local_tooling_path_export, + _local_windows_bash_env_prefix, _pip_install_attempt, _pip_install_fallback_chain, _ollama_bind_from_cmd, @@ -107,6 +108,70 @@ def test_safe_env_prefix_accepts_powershell_activation_path(): ) +@pytest.mark.parametrize( + ("prefix", "expected"), + [ + ("& 'C:\\Users\\me\\venv\\Scripts\\Activate.ps1'", "source /c/Users/me/venv/Scripts/activate"), + (r"& C:\Users\me\venv\Scripts\Activate.ps1", "source /c/Users/me/venv/Scripts/activate"), + ( + r"& C:\Users\me\My Envs\venv\Scripts\Activate.ps1", + "source '/c/Users/me/My Envs/venv/Scripts/activate'", + ), + ( + "& 'C:\\Users\\me\\My Envs\\venv\\Scripts\\Activate.ps1'", + "source '/c/Users/me/My Envs/venv/Scripts/activate'", + ), + (r"& D:/Envs/venv/Scripts/Activate.ps1", "source /d/Envs/venv/Scripts/activate"), + ], +) +def test_local_windows_bash_env_prefix_converts_powershell_venv_activation(prefix, expected): + converted = _local_windows_bash_env_prefix(prefix) + + assert converted == expected + assert _safe_env_prefix(converted).startswith('[ -f "') + + +@pytest.mark.parametrize( + "prefix", + [ + None, + "", + "source /home/me/venv/bin/activate", + "conda activate qwen35", + 'eval "$(conda shell.bash hook)" && conda activate qwen35', + r"& \\server\share\venv\Scripts\Activate.ps1", + ], +) +def test_local_windows_bash_env_prefix_leaves_other_prefixes_unchanged(prefix): + assert _local_windows_bash_env_prefix(prefix) == prefix + + +def test_local_windows_bash_env_prefix_handles_long_whitespace_input(): + prefix = "\t" * 100_000 + + assert _local_windows_bash_env_prefix(prefix) == prefix + + +@pytest.mark.parametrize( + "relative_path", + ["static/js/cookbookRunning.js", "static/js/cookbookDownload.js"], +) +def test_primary_windows_venv_emitters_quote_activation_path(relative_path): + source = (Path(__file__).resolve().parents[1] / relative_path).read_text(encoding="utf-8") + + assert "'& ' + _psQuote(" in source + assert "_psQuote = shared._psQuote;" in source + + +def test_windows_venv_conversion_stays_scoped_to_local_git_bash_runners(): + source = (Path(__file__).resolve().parents[1] / "routes/cookbook_routes.py").read_text(encoding="utf-8") + guarded_conversion = ( + "_local_windows_bash_env_prefix(req.env_prefix) if local_windows else req.env_prefix" + ) + + assert source.count(guarded_conversion) == 2 + + def test_validate_local_dir_accepts_external_drive_paths_with_spaces(): path = "/Volumes/T7 2TB/AI Models/llamacpp"