diff --git a/core/platform_compat.py b/core/platform_compat.py index e2339ad33..f2160d9f2 100644 --- a/core/platform_compat.py +++ b/core/platform_compat.py @@ -180,6 +180,21 @@ def _is_windows_bash_stub(path: str) -> bool: ) +def git_bash_path(path: str | Path) -> str: + """Convert a path to POSIX style suitable for Git Bash on Windows. + + Transforms drive letters (e.g., 'C:\\path') to POSIX '/c/path', + and uses forward slashes. + """ + p = Path(path) + p_str = p.as_posix() + if IS_WINDOWS and len(p_str) >= 2 and p_str[1] == ":": + drive = p_str[0].lower() + return f"/{drive}{p_str[2:]}" + return p_str + + + def find_bash() -> Optional[str]: """Locate a real ``bash`` interpreter, or None. diff --git a/routes/cookbook_helpers.py b/routes/cookbook_helpers.py index 8fbaa9e99..a20d78a90 100644 --- a/routes/cookbook_helpers.py +++ b/routes/cookbook_helpers.py @@ -206,12 +206,16 @@ def _pip_install_fallback_chain(package: str, *, python_cmd: str = "python3 -m p exit code is preserved (no ``| tail`` masking) and the last 5 lines of pip output appear in the Cookbook log on failure. """ + from core.platform_compat import IS_WINDOWS upgrade_flag = " -U" if upgrade else "" # Shell-quote the package spec: an extras spec like ``llama-cpp-python[server]`` # contains brackets that bash would treat as a glob, so it must be quoted # before being embedded in the install command. Plain names (e.g. # ``huggingface_hub``) are returned unchanged by ``shlex.quote``. pkg = shlex.quote(package) + if IS_WINDOWS and "llama-cpp-python" in package: + pkg += " --extra-index-url https://abetlen.github.io/llama-cpp-python/whl/cpu" + base = _pip_install_attempt(f"{python_cmd} install -q{upgrade_flag} {pkg}") user = _pip_install_attempt(f"{python_cmd} install --user --break-system-packages -q{upgrade_flag} {pkg}") # Derive the python executable for the venv detection check. @@ -525,6 +529,7 @@ def _validate_serve_cmd(v: str | None) -> str | None: # Backticks and raw newlines are never legitimate here. if any(c in v for c in ("`", "\n", "\r")): raise HTTPException(400, "Invalid characters in cmd") + # Known GGUF launcher prelude → validate the serve invocation(s) it guards. m = _GGUF_PRELUDE_RE.match(v) if m: @@ -533,9 +538,19 @@ def _validate_serve_cmd(v: str | None) -> str | None: for part in rest.split("||"): _check_serve_binary(part.strip()) return v + # Otherwise: a single invocation — no shell metacharacters allowed. + # Temporarily replace safe $(printf %s ...) expressions with a placeholder + # to avoid triggering the metacharacter/command-injection checks. + cleaned_v = v + printf_matches = list(re.finditer(r"\$\(\s*printf\s+%s\s+([^\n()]*?)\)", v)) + for match in printf_matches: + inner = match.group(1) + if not any(c in inner for c in (";", "&&", "||", "$(", "`")): + cleaned_v = cleaned_v.replace(match.group(0), "/placeholder/safe/path.gguf") + # (`$(` was the original intent; bare `$` is fine for shell-safe paths.) - if any(c in v for c in (";", "&&", "||", "$(")): + if any(c in cleaned_v for c in (";", "&&", "||", "$(")): raise HTTPException(400, "Invalid characters in cmd") _check_serve_binary(v) return v diff --git a/routes/cookbook_routes.py b/routes/cookbook_routes.py index af5ff1dba..2b86b6eda 100644 --- a/routes/cookbook_routes.py +++ b/routes/cookbook_routes.py @@ -22,6 +22,7 @@ from core.platform_compat import ( IS_WINDOWS, detached_popen_kwargs, find_bash, + git_bash_path, kill_process_tree, pid_alive, safe_chmod, @@ -175,6 +176,7 @@ def setup_cookbook_routes() -> APIRouter: safe_chmod(key_path.with_suffix(".pub"), 0o644) return {"ok": True, "public_key": _read_cookbook_public_key()} + def _needs_binary(cmd: str, binary: str) -> bool: return bool(re.search(rf"(^|[\s;&|()]){re.escape(binary)}($|[\s;&|()])", cmd or "")) @@ -235,8 +237,8 @@ def setup_cookbook_routes() -> APIRouter: # POSIX form + shell-quoting so drive paths / spaces survive. inner = TMUX_LOG_DIR / f"{session_id}_run.sh" inner.write_text("\n".join(bash_lines) + "\n", encoding="utf-8") - lp = shlex.quote(log_path.as_posix()) - ip = shlex.quote(inner.as_posix()) + lp = shlex.quote(git_bash_path(log_path)) + ip = shlex.quote(git_bash_path(inner)) script_path = TMUX_LOG_DIR / f"{session_id}.sh" script_path.write_text( f"bash {ip} > {lp} 2>&1\n", @@ -352,6 +354,8 @@ def setup_cookbook_routes() -> APIRouter: ps_lines = [] ps_lines.append('$sessionDir = "$env:TEMP\\odysseus-sessions"') ps_lines.append('New-Item -ItemType Directory -Force -Path $sessionDir | Out-Null') + ps_lines.append('$env:PYTHONIOENCODING = "utf-8"') + ps_lines.append('$env:PYTHONUTF8 = "1"') if req.hf_token: ps_lines.append(f"$env:HF_TOKEN = '{_ps_squote(req.hf_token)}'") if req.env_prefix: @@ -851,6 +855,16 @@ def setup_cookbook_routes() -> APIRouter: in_venv=sys.prefix != sys.base_prefix, ) is_pip_install = bool(req.cmd and "pip install" in req.cmd) + remote = req.remote_host + is_windows = req.platform == "windows" + local_windows = IS_WINDOWS and not remote + if is_windows or local_windows: + if req.cmd.startswith("python3 "): + req.cmd = "python " + req.cmd[len("python3 "):] + if is_pip_install and ("llama-cpp-python" in req.cmd or "llama_cpp" in req.cmd) and (is_windows or local_windows): + if "--extra-index-url" not in req.cmd: + req.cmd += " --extra-index-url https://abetlen.github.io/llama-cpp-python/whl/cpu" + if is_pip_install: # Keep big dependency wheel builds (vLLM, …) off the home filesystem's # pip cache so they don't fail mid-build with "No space left" (#1219) @@ -908,6 +922,8 @@ def setup_cookbook_routes() -> APIRouter: ps_lines = [] ps_lines.append('$sessionDir = "$env:TEMP\\odysseus-sessions"') ps_lines.append('New-Item -ItemType Directory -Force -Path $sessionDir | Out-Null') + ps_lines.append('$env:PYTHONIOENCODING = "utf-8"') + ps_lines.append('$env:PYTHONUTF8 = "1"') if req.hf_token: ps_lines.append(f"$env:HF_TOKEN = '{_ps_squote(req.hf_token)}'") if req.gpus: @@ -926,7 +942,7 @@ def setup_cookbook_routes() -> APIRouter: ps_lines.append('try { python -c "import llama_cpp" 2>$null } catch {}') ps_lines.append('if ($LASTEXITCODE -ne 0) {') ps_lines.append(' Write-Host "Installing llama-cpp-python..."') - ps_lines.append(' python -m pip install llama-cpp-python[server]') + ps_lines.append(' python -m pip install llama-cpp-python[server] --extra-index-url https://abetlen.github.io/llama-cpp-python/whl/cpu') ps_lines.append('}') elif "vllm" in req.cmd: ps_lines.append('Write-Host "ERROR: vLLM is not supported on Windows. Use Ollama or llama.cpp instead."') @@ -1001,45 +1017,57 @@ def setup_cookbook_routes() -> APIRouter: # ollama is found (otherwise macOS falls back to a slow source build). # /opt/homebrew = Apple Silicon, /usr/local = Intel; harmless on Linux. runner_lines.append('export PATH="$HOME/.local/bin:$HOME/bin:$HOME/llama.cpp/build/bin:/opt/homebrew/bin:/usr/local/bin:$PATH"') - runner_lines.append('if [ -d /data/data/com.termux ]; then') - runner_lines.append(' # Termux: no native build — use the Python bindings (CPU).') - runner_lines.append(' if ! python3 -c "import llama_cpp" 2>/dev/null; then') - runner_lines.append(' pkg install -y cmake 2>/dev/null') - runner_lines.append(' pip install numpy diskcache jinja2 2>/dev/null') - runner_lines.append(' CMAKE_ARGS="-DGGML_BLAS=OFF -DGGML_LLAMAFILE=OFF" pip install \'llama-cpp-python[server]\' --no-build-isolation --no-cache-dir 2>&1 || true') - runner_lines.append(' fi') - runner_lines.append('elif ! command -v llama-server &>/dev/null; then') - runner_lines.append(' echo "Native llama-server not found — building from source (one-time, may take a few minutes)..."') - runner_lines.append(' mkdir -p ~/bin') - runner_lines.append(' cd ~ && [ -d llama.cpp ] || git clone --depth 1 https://github.com/ggml-org/llama.cpp') - # Build with the right accelerator: Metal on macOS (llama.cpp - # enables it automatically, no flag), CUDA on Linux when present, - # else a plain CPU build. nproc is Linux-only — fall back to - # `sysctl hw.ncpu` on macOS. (Tip: `brew install llama.cpp` ships - # a prebuilt llama-server and skips this whole source build.) - runner_lines.append(' NPROC="$(nproc 2>/dev/null || sysctl -n hw.ncpu 2>/dev/null || echo 4)"') - runner_lines.append(' if [ "$(uname -s)" = "Darwin" ]; then') - runner_lines.append(' command -v cmake >/dev/null 2>&1 || echo "WARNING: cmake not found — install it with: brew install cmake (or: brew install llama.cpp for a prebuilt llama-server)."') - # Start from a clean cache: a prior failed configure (e.g. a CUDA - # attempt) poisons build/CMakeCache.txt, so a plain `cmake -B build` - # would reuse the bad settings and fail again. CMAKE_BUILD_TYPE is - # explicit so the binary is optimized (Metal auto-enables on macOS). - runner_lines.append(' cd ~/llama.cpp && rm -rf build && cmake -B build -DCMAKE_BUILD_TYPE=Release \\') - runner_lines.append(' && cmake --build build -j"$NPROC" --target llama-server \\') - runner_lines.append(' && ln -sf ~/llama.cpp/build/bin/llama-server ~/bin/llama-server') - runner_lines.append(' else') - _append_llama_cpp_linux_accel_build_lines(runner_lines) - runner_lines.append(' fi') - runner_lines.append(' # If the native build failed, fall back to the Python bindings.') - runner_lines.append(' if ! command -v llama-server &>/dev/null && ! python3 -c "import llama_cpp" 2>/dev/null; then') - runner_lines.append(' echo "llama-server build failed — installing Python bindings as fallback..."') - runner_lines.append(f" {_pip_install_fallback_chain('llama-cpp-python[server]', python_cmd='pip')} || true") - runner_lines.append(' fi') - runner_lines.append(' if ! command -v llama-server &>/dev/null && ! python3 -c "import llama_cpp" 2>/dev/null; then') - runner_lines.append(' echo "ERROR: llama.cpp serving is not available after install/build attempts."') - runner_lines.append(' ODYSSEUS_PREFLIGHT_EXIT=127') - runner_lines.append(' fi') - runner_lines.append('fi') + if local_windows: + # LOCAL Windows: no native source compilation (no cmake/compiler on Git Bash). + # Just check python bindings (using native `python` binary) and fall back to pip install. + runner_lines.append('if ! command -v llama-server &>/dev/null && ! python -c "import llama_cpp" 2>/dev/null; then') + runner_lines.append(' echo "llama-server not found — installing Python bindings..."') + runner_lines.append(f" {_pip_install_fallback_chain('llama-cpp-python[server]', python_cmd='python')} || true") + runner_lines.append('fi') + runner_lines.append('if ! command -v llama-server &>/dev/null && ! python -c "import llama_cpp" 2>/dev/null; then') + runner_lines.append(' echo "ERROR: llama.cpp serving is not available after install attempts."') + runner_lines.append(' ODYSSEUS_PREFLIGHT_EXIT=127') + runner_lines.append('fi') + else: + runner_lines.append('if [ -d /data/data/com.termux ]; then') + runner_lines.append(' # Termux: no native build — use the Python bindings (CPU).') + runner_lines.append(' if ! python3 -c "import llama_cpp" 2>/dev/null; then') + runner_lines.append(' pkg install -y cmake 2>/dev/null') + runner_lines.append(' pip install numpy diskcache jinja2 2>/dev/null') + runner_lines.append(' CMAKE_ARGS="-DGGML_BLAS=OFF -DGGML_LLAMAFILE=OFF" pip install \'llama-cpp-python[server]\' --no-build-isolation --no-cache-dir 2>&1 || true') + runner_lines.append(' fi') + runner_lines.append('elif ! command -v llama-server &>/dev/null; then') + runner_lines.append(' echo "Native llama-server not found — building from source (one-time, may take a few minutes)..."') + runner_lines.append(' mkdir -p ~/bin') + runner_lines.append(' cd ~ && [ -d llama.cpp ] || git clone --depth 1 https://github.com/ggml-org/llama.cpp') + # Build with the right accelerator: Metal on macOS (llama.cpp + # enables it automatically, no flag), CUDA on Linux when present, + # else a plain CPU build. nproc is Linux-only — fall back to + # `sysctl hw.ncpu` on macOS. (Tip: `brew install llama.cpp` ships + # a prebuilt llama-server and skips this whole source build.) + runner_lines.append(' NPROC="$(nproc 2>/dev/null || sysctl -n hw.ncpu 2>/dev/null || echo 4)"') + runner_lines.append(' if [ "$(uname -s)" = "Darwin" ]; then') + runner_lines.append(' command -v cmake >/dev/null 2>&1 || echo "WARNING: cmake not found — install it with: brew install cmake (or: brew install llama.cpp for a prebuilt llama-server)."') + # Start from a clean cache: a prior failed configure (e.g. a CUDA + # attempt) poisons build/CMakeCache.txt, so a plain `cmake -B build` + # would reuse the bad settings and fail again. CMAKE_BUILD_TYPE is + # explicit so the binary is optimized (Metal auto-enables on macOS). + runner_lines.append(' cd ~/llama.cpp && rm -rf build && cmake -B build -DCMAKE_BUILD_TYPE=Release \\') + runner_lines.append(' && cmake --build build -j"$NPROC" --target llama-server \\') + runner_lines.append(' && ln -sf ~/llama.cpp/build/bin/llama-server ~/bin/llama-server') + runner_lines.append(' else') + _append_llama_cpp_linux_accel_build_lines(runner_lines) + runner_lines.append(' fi') + # If the native build failed, fall back to the Python bindings. + runner_lines.append(' if ! command -v llama-server &>/dev/null && ! python3 -c "import llama_cpp" 2>/dev/null; then') + runner_lines.append(' echo "llama-server build failed — installing Python bindings as fallback..."') + runner_lines.append(f" {_pip_install_fallback_chain('llama-cpp-python[server]', python_cmd='pip')} || true") + runner_lines.append(' fi') + runner_lines.append(' if ! command -v llama-server &>/dev/null && ! python3 -c "import llama_cpp" 2>/dev/null; then') + runner_lines.append(' echo "ERROR: llama.cpp serving is not available after install/build attempts."') + runner_lines.append(' ODYSSEUS_PREFLIGHT_EXIT=127') + runner_lines.append(' fi') + runner_lines.append('fi') elif "ollama" in req.cmd: handled_ollama_serve = True _ollama_default_host = "0.0.0.0" if remote else "127.0.0.1" @@ -2076,7 +2104,11 @@ def setup_cookbook_routes() -> APIRouter: "inc=os.path.isdir(blobs) and any(x.endswith('.incomplete') for x in os.listdir(blobs));" "sys.exit(0 if ok and not inc else 1)" ) - cmd = ["python3", "-c", py, repo_id] + if not remote_host: + import sys + cmd = [sys.executable, "-c", py, repo_id] + else: + cmd = ["python3", "-c", py, repo_id] try: if remote_host: ssh_base = ["ssh"] diff --git a/routes/shell_routes.py b/routes/shell_routes.py index 3be54ab92..9f9967893 100644 --- a/routes/shell_routes.py +++ b/routes/shell_routes.py @@ -37,6 +37,7 @@ from core.platform_compat import ( IS_WINDOWS, detached_popen_kwargs, find_bash, + git_bash_path, ) @@ -368,8 +369,12 @@ async def _create_shell(command: str, **kwargs): POSIX: /bin/sh via create_subprocess_shell (unchanged behaviour). Windows: prefer a real bash (Git Bash/WSL) so bash-syntax commands behave the same as on Linux; fall back to cmd.exe when no bash is installed. + Powershell commands are executed directly via cmd.exe /c to avoid quoting + and env variable expansion errors under Git Bash. """ if IS_WINDOWS: + if command.strip().lower().startswith("powershell"): + return await asyncio.create_subprocess_shell(command, **kwargs) bash = find_bash() if bash: return await asyncio.create_subprocess_exec(bash, "-c", command, **kwargs) @@ -672,8 +677,8 @@ async def _generate_win_detached(cmd: str, request: Request): if bash: script_path = TMUX_LOG_DIR / f"{session_id}.sh" script_path.write_text( - f"{cmd} > {shlex.quote(str(log_path))} 2>&1\n" - f"echo $? > {shlex.quote(str(exit_path))}\n", + f"{cmd} > {shlex.quote(git_bash_path(log_path))} 2>&1\n" + f"echo $? > {shlex.quote(git_bash_path(exit_path))}\n", encoding="utf-8", ) argv = [bash, str(script_path)] diff --git a/src/bg_jobs.py b/src/bg_jobs.py index 587851b68..c103dfdfc 100644 --- a/src/bg_jobs.py +++ b/src/bg_jobs.py @@ -33,6 +33,7 @@ from core.atomic_io import atomic_write_json from core.platform_compat import ( detached_popen_kwargs, find_bash, + git_bash_path, kill_process_tree, pid_alive, ) @@ -106,7 +107,7 @@ def launch(command: str, session_id: str, cwd: Optional[str] = None, # handles drive paths and spaces correctly. cmd_path = _JOBS_DIR / f"{job_id}.cmd.sh" cmd_path.write_text(command + "\n", encoding="utf-8") - lp, xp, cp = (shlex.quote(p.as_posix()) for p in (log_path, exit_path, cmd_path)) + lp, xp, cp = (shlex.quote(git_bash_path(p)) for p in (log_path, exit_path, cmd_path)) script_path = _JOBS_DIR / f"{job_id}.sh" script_path.write_text( f"bash {cp} > {lp} 2>&1\n" diff --git a/static/js/cookbook-diagnosis.js b/static/js/cookbook-diagnosis.js index af90d9997..19512ab50 100644 --- a/static/js/cookbook-diagnosis.js +++ b/static/js/cookbook-diagnosis.js @@ -426,6 +426,15 @@ export const ERROR_PATTERNS = [ { label: 'Copy install command', action: () => _copyText('pip install "llama-cpp-python[server]"') }, ], }, + { + pattern: /Windows Error 0xc000001d|Illegal instruction|0xc000001d/i, + message: 'AVX2 Instruction Set Mismatch: the precompiled llama-cpp-python wheel requires CPU features (AVX2/FMA) that your processor or virtual machine lacks.', + suggestion: 'Suggested action: switch this serve config to Ollama (highly recommended, has dynamic CPU fallbacks), or choose a remote Linux GPU server.', + fixes: [ + { label: 'Switch to Ollama', action: (panel) => _openServeEditFromDiagnosis(panel, { backend: 'ollama' }) }, + { label: 'Choose remote server', action: (panel) => _openServeEditFromDiagnosis(panel) }, + ], + }, { pattern: /CUDA Toolkit not found|Unable to find cudart library|missing:\s*CUDA_CUDART/i, message: 'llama.cpp found nvcc, but the CUDA runtime library is missing.', diff --git a/static/js/cookbook.js b/static/js/cookbook.js index 358d66411..edcbab33e 100644 --- a/static/js/cookbook.js +++ b/static/js/cookbook.js @@ -161,8 +161,17 @@ function _getPort(hostOrTask) { /** Get platform for a given host (or task object). Returns 'windows', 'termux', 'linux', or '' */ export function _getPlatform(hostOrTask) { - if (!hostOrTask) return _envState.platform || ''; - if (typeof hostOrTask === 'object') return hostOrTask.platform || _getPlatform(hostOrTask.remoteHost); + const isWinBrowser = (window.navigator.userAgent || window.navigator.platform || '').toLowerCase().includes('win'); + if (!hostOrTask || hostOrTask === 'local') { + return _envState.platform || (isWinBrowser ? 'windows' : ''); + } + if (typeof hostOrTask === 'object') { + const h = hostOrTask.remoteHost; + if (!h || h === 'local') { + return hostOrTask.platform || _envState.platform || (isWinBrowser ? 'windows' : ''); + } + return hostOrTask.platform || _getPlatform(h); + } const srv = _envState.servers.find(s => s.host === hostOrTask); return srv?.platform || ''; } @@ -637,7 +646,7 @@ async function _fetchDependencies() { const data = await resp.json(); const pkgs = data.packages || []; if (!pkgs.length) { list.innerHTML = '