diff --git a/routes/cookbook_helpers.py b/routes/cookbook_helpers.py index bba10e6e8..163f02cd2 100644 --- a/routes/cookbook_helpers.py +++ b/routes/cookbook_helpers.py @@ -558,6 +558,18 @@ def _bash_squote(v: str) -> str: return v.replace("'", "'\\''") +# Shown by generated runner scripts when the ollama binary is missing on the +# target host. Must stay free of backticks/$( ) and be emitted single-quoted: +# an earlier version wrapped the install one-liner in backticks inside a +# double-quoted echo, which bash executed as command substitution and ran the +# system-wide installer (including on remote SSH hosts) instead of printing +# the hint. +OLLAMA_MISSING_HINT = ( + "ERROR: Ollama not found on this server. Install it from " + "https://ollama.com/download or run: curl -fsSL https://ollama.com/install.sh | sh" +) + + # Allow-list of binaries permitted as the leading token of `req.cmd` for /api/model/serve. # Anything else is rejected before the cmd is interpolated into a tmux/PowerShell wrapper. _SERVE_CMD_ALLOWLIST = { diff --git a/routes/cookbook_routes.py b/routes/cookbook_routes.py index fbd00c890..7a35de977 100644 --- a/routes/cookbook_routes.py +++ b/routes/cookbook_routes.py @@ -49,7 +49,7 @@ logger = logging.getLogger(__name__) from routes.cookbook_helpers import ( _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, + _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, _append_serve_exit_code_lines, _append_llama_cpp_linux_accel_build_lines, _cached_model_scan_script, load_stored_hf_token, @@ -1861,7 +1861,10 @@ def setup_cookbook_routes() -> APIRouter: runner_lines.append(' exec 3<&-; exec 3>&-') runner_lines.append('done') runner_lines.append('if ! command -v ollama &>/dev/null; then') - runner_lines.append(' echo "ERROR: Ollama not found on this server. Install it from https://ollama.com/download or `curl -fsSL https://ollama.com/install.sh | sh`."') + # Single-quoted on purpose: backticks inside a double-quoted + # echo are command substitution, and this line used to run the + # curl|sh installer on the target host instead of printing it. + runner_lines.append(f" echo '{_bash_squote(OLLAMA_MISSING_HINT)}'") runner_lines.append(' echo') runner_lines.append(' echo "=== Process exited with code 127 ==="') runner_lines.append(' exec bash -i') diff --git a/tests/test_ollama_runner_hint.py b/tests/test_ollama_runner_hint.py new file mode 100644 index 000000000..97a5d69e8 --- /dev/null +++ b/tests/test_ollama_runner_hint.py @@ -0,0 +1,57 @@ +"""The generated Ollama runner must print the install hint, not execute it. + +The runner script emitted by /api/model/serve contained: + + echo "ERROR: Ollama not found ... or `curl -fsSL .../install.sh | sh`." + +Backticks inside double quotes are bash command substitution, so on any host +without ollama the script downloaded and ran the system-wide installer +(including remote SSH serve targets) instead of printing the hint. The hint +now lives in OLLAMA_MISSING_HINT, contains no substitution tokens, and is +emitted single-quoted. +""" +import os +import shutil +import subprocess + +import pytest + +from routes.cookbook_helpers import OLLAMA_MISSING_HINT, _bash_squote + +ROOT = os.path.dirname(os.path.dirname(os.path.abspath(__file__))) + + +def test_hint_has_no_shell_expansion_tokens(): + assert "`" not in OLLAMA_MISSING_HINT + assert "$(" not in OLLAMA_MISSING_HINT + + +def test_hint_still_tells_the_user_how_to_install(): + assert "https://ollama.com/download" in OLLAMA_MISSING_HINT + assert "install.sh" in OLLAMA_MISSING_HINT + + +def test_no_runner_echo_line_uses_backticks_in_double_quotes(): + # Source-level guard: generated-script echo lines must never carry + # backticks inside a double-quoted bash string again. + src = open(os.path.join(ROOT, "routes", "cookbook_routes.py"), encoding="utf-8").read() + offenders = [ + line.strip() + for line in src.splitlines() + if "append(" in line and 'echo "' in line and "`" in line.split('echo "', 1)[1] + ] + assert offenders == [] + + +def test_single_quoted_echo_prints_hint_literally(): + bash = shutil.which("bash") + if not bash: + pytest.skip("bash not available") + out = subprocess.run( + [bash, "-c", f"echo '{_bash_squote(OLLAMA_MISSING_HINT)}'"], + capture_output=True, + text=True, + timeout=30, + ) + assert out.returncode == 0 + assert out.stdout.strip() == OLLAMA_MISSING_HINT