mirror of
https://github.com/pewdiepie-archdaemon/odysseus.git
synced 2026-10-23 07:12:21 +02:00
Compare commits
12
Commits
b51d83b16d
...
39a802bea2
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
39a802bea2 | ||
|
|
1cc8a373b0 | ||
|
|
a52ac6822b | ||
|
|
7475779b7c | ||
|
|
e7ffc69729 | ||
|
|
396e26b4bf | ||
|
|
0bfc7750a2 | ||
|
|
790ef81b06 | ||
|
|
804691501f | ||
|
|
8e6a2e89f8 | ||
|
|
dbcc7874bf | ||
|
|
16e660ad09 |
@@ -19,7 +19,7 @@ jobs:
|
||||
name: Python syntax (compileall)
|
||||
runs-on: ubuntu-latest
|
||||
steps:
|
||||
- uses: actions/checkout@df4cb1c069e1874edd31b4311f1884172cec0e10 # v6.0.3
|
||||
- uses: actions/checkout@9c091bb21b7c1c1d1991bb908d89e4e9dddfe3e0 # v7.0.0
|
||||
with:
|
||||
persist-credentials: false
|
||||
- uses: actions/setup-python@a309ff8b426b58ec0e2a45f0f869d46889d02405 # v6.2.0
|
||||
@@ -32,7 +32,7 @@ jobs:
|
||||
name: JS syntax (node --check)
|
||||
runs-on: ubuntu-latest
|
||||
steps:
|
||||
- uses: actions/checkout@df4cb1c069e1874edd31b4311f1884172cec0e10 # v6.0.3
|
||||
- uses: actions/checkout@9c091bb21b7c1c1d1991bb908d89e4e9dddfe3e0 # v7.0.0
|
||||
with:
|
||||
persist-credentials: false
|
||||
- uses: actions/setup-node@48b55a011bda9f5d6aeb4c2d9c7362e8dae4041e # v6.4.0
|
||||
@@ -54,7 +54,7 @@ jobs:
|
||||
# ROADMAP "fresh install smoke tests" item; make this required once green.
|
||||
continue-on-error: true
|
||||
steps:
|
||||
- uses: actions/checkout@df4cb1c069e1874edd31b4311f1884172cec0e10 # v6.0.3
|
||||
- uses: actions/checkout@9c091bb21b7c1c1d1991bb908d89e4e9dddfe3e0 # v7.0.0
|
||||
with:
|
||||
fetch-depth: 0
|
||||
persist-credentials: false
|
||||
|
||||
@@ -37,7 +37,7 @@ jobs:
|
||||
contents: read
|
||||
steps:
|
||||
- name: Checkout repository
|
||||
uses: actions/checkout@df4cb1c069e1874edd31b4311f1884172cec0e10 # v6.0.3
|
||||
uses: actions/checkout@9c091bb21b7c1c1d1991bb908d89e4e9dddfe3e0 # v7.0.0
|
||||
with:
|
||||
persist-credentials: false
|
||||
|
||||
|
||||
@@ -52,7 +52,7 @@ jobs:
|
||||
contents: read
|
||||
steps:
|
||||
- name: Checkout repository
|
||||
uses: actions/checkout@df4cb1c069e1874edd31b4311f1884172cec0e10 # v6.0.3
|
||||
uses: actions/checkout@9c091bb21b7c1c1d1991bb908d89e4e9dddfe3e0 # v7.0.0
|
||||
with:
|
||||
persist-credentials: false
|
||||
|
||||
@@ -93,7 +93,7 @@ jobs:
|
||||
security-events: write # upload SARIF to the Security tab
|
||||
steps:
|
||||
- name: Checkout repository
|
||||
uses: actions/checkout@df4cb1c069e1874edd31b4311f1884172cec0e10 # v6.0.3
|
||||
uses: actions/checkout@9c091bb21b7c1c1d1991bb908d89e4e9dddfe3e0 # v7.0.0
|
||||
with:
|
||||
persist-credentials: false
|
||||
|
||||
|
||||
@@ -36,7 +36,7 @@ jobs:
|
||||
contents: read
|
||||
steps:
|
||||
- name: Checkout repository
|
||||
uses: actions/checkout@df4cb1c069e1874edd31b4311f1884172cec0e10 # v6.0.3
|
||||
uses: actions/checkout@9c091bb21b7c1c1d1991bb908d89e4e9dddfe3e0 # v7.0.0
|
||||
with:
|
||||
persist-credentials: false
|
||||
|
||||
@@ -55,7 +55,7 @@ jobs:
|
||||
contents: read
|
||||
steps:
|
||||
- name: Checkout repository
|
||||
uses: actions/checkout@df4cb1c069e1874edd31b4311f1884172cec0e10 # v6.0.3
|
||||
uses: actions/checkout@9c091bb21b7c1c1d1991bb908d89e4e9dddfe3e0 # v7.0.0
|
||||
with:
|
||||
persist-credentials: false
|
||||
|
||||
|
||||
@@ -45,7 +45,7 @@ jobs:
|
||||
arch: arm64
|
||||
runner: ubuntu-24.04-arm
|
||||
steps:
|
||||
- uses: actions/checkout@df4cb1c069e1874edd31b4311f1884172cec0e10 # v6.0.3
|
||||
- uses: actions/checkout@9c091bb21b7c1c1d1991bb908d89e4e9dddfe3e0 # v7.0.0
|
||||
with:
|
||||
persist-credentials: false
|
||||
- name: Set up Buildx
|
||||
@@ -86,7 +86,7 @@ jobs:
|
||||
contents: read
|
||||
packages: write
|
||||
steps:
|
||||
- uses: actions/checkout@df4cb1c069e1874edd31b4311f1884172cec0e10 # v6.0.3
|
||||
- uses: actions/checkout@9c091bb21b7c1c1d1991bb908d89e4e9dddfe3e0 # v7.0.0
|
||||
with:
|
||||
persist-credentials: false
|
||||
- name: Read APP_VERSION + short sha
|
||||
|
||||
@@ -14,7 +14,7 @@ jobs:
|
||||
# Skip bots (Dependabot, release-drafter, etc.)
|
||||
if: ${{ github.event.issue.user.type != 'Bot' }}
|
||||
steps:
|
||||
- uses: actions/checkout@df4cb1c069e1874edd31b4311f1884172cec0e10 # v6.0.3
|
||||
- uses: actions/checkout@9c091bb21b7c1c1d1991bb908d89e4e9dddfe3e0 # v7.0.0
|
||||
with:
|
||||
sparse-checkout: .github/scripts
|
||||
persist-credentials: false
|
||||
|
||||
@@ -23,7 +23,7 @@ jobs:
|
||||
# Skip bots: they open PRs programmatically and have their own process.
|
||||
if: github.event.pull_request.user.type != 'Bot'
|
||||
steps:
|
||||
- uses: actions/checkout@df4cb1c069e1874edd31b4311f1884172cec0e10 # v6.0.3
|
||||
- uses: actions/checkout@9c091bb21b7c1c1d1991bb908d89e4e9dddfe3e0 # v7.0.0
|
||||
with:
|
||||
ref: ${{ github.base_ref }}
|
||||
sparse-checkout: .github/scripts
|
||||
|
||||
@@ -35,7 +35,7 @@ jobs:
|
||||
contents: read
|
||||
steps:
|
||||
- name: Checkout repository
|
||||
uses: actions/checkout@df4cb1c069e1874edd31b4311f1884172cec0e10 # v6.0.3
|
||||
uses: actions/checkout@9c091bb21b7c1c1d1991bb908d89e4e9dddfe3e0 # v7.0.0
|
||||
with:
|
||||
# Full history so a secret committed in an earlier commit (and later
|
||||
# deleted) is still caught -- deletion does not remove it from Git.
|
||||
|
||||
@@ -36,7 +36,7 @@ jobs:
|
||||
contents: read
|
||||
steps:
|
||||
- name: Checkout repository
|
||||
uses: actions/checkout@df4cb1c069e1874edd31b4311f1884172cec0e10 # v6.0.3
|
||||
uses: actions/checkout@9c091bb21b7c1c1d1991bb908d89e4e9dddfe3e0 # v7.0.0
|
||||
with:
|
||||
persist-credentials: false
|
||||
|
||||
@@ -61,7 +61,7 @@ jobs:
|
||||
contents: read
|
||||
steps:
|
||||
- name: Checkout repository
|
||||
uses: actions/checkout@df4cb1c069e1874edd31b4311f1884172cec0e10 # v6.0.3
|
||||
uses: actions/checkout@9c091bb21b7c1c1d1991bb908d89e4e9dddfe3e0 # v7.0.0
|
||||
with:
|
||||
persist-credentials: false
|
||||
|
||||
|
||||
@@ -39,7 +39,7 @@ load_dotenv(encoding="utf-8-sig")
|
||||
import asyncio
|
||||
import logging
|
||||
import secrets
|
||||
from datetime import datetime
|
||||
from datetime import datetime, timezone
|
||||
from typing import Dict
|
||||
|
||||
from contextlib import asynccontextmanager
|
||||
@@ -863,7 +863,7 @@ async def get_version():
|
||||
|
||||
@app.get("/api/health")
|
||||
async def health_check() -> Dict[str, str]:
|
||||
return {"status": "healthy", "timestamp": datetime.utcnow().isoformat()}
|
||||
return {"status": "healthy", "timestamp": datetime.now(timezone.utc).isoformat()}
|
||||
|
||||
@app.get("/api/ready")
|
||||
async def readiness_check() -> JSONResponse:
|
||||
|
||||
Generated
+8
-8
@@ -5,16 +5,16 @@
|
||||
"packages": {
|
||||
"": {
|
||||
"dependencies": {
|
||||
"@anthropic-ai/sdk": "^0.104.1"
|
||||
"@anthropic-ai/sdk": "^0.105.0"
|
||||
},
|
||||
"devDependencies": {
|
||||
"@antithesishq/bombadil": "^0.5.0"
|
||||
"@antithesishq/bombadil": "^0.6.1"
|
||||
}
|
||||
},
|
||||
"node_modules/@anthropic-ai/sdk": {
|
||||
"version": "0.104.1",
|
||||
"resolved": "https://registry.npmjs.org/@anthropic-ai/sdk/-/sdk-0.104.1.tgz",
|
||||
"integrity": "sha512-gGACa/+IaiXzRRmF96aOhamoBgapKRBiFWbmmTFP8aMkpaEcuStF+Q61bjo4vPxBM7gqWJNZqsngslRdnLHv0Q==",
|
||||
"version": "0.105.0",
|
||||
"resolved": "https://registry.npmjs.org/@anthropic-ai/sdk/-/sdk-0.105.0.tgz",
|
||||
"integrity": "sha512-sDyu+aM9cE6uZE+HgRjjHRb+qqb87GHZOx+8bE0YlWetdL1YcVLxn8h9ltxGOflyChTe6PMEo50kMQV4cw0hfg==",
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"json-schema-to-ts": "^3.1.1",
|
||||
@@ -33,9 +33,9 @@
|
||||
}
|
||||
},
|
||||
"node_modules/@antithesishq/bombadil": {
|
||||
"version": "0.5.0",
|
||||
"resolved": "https://registry.npmjs.org/@antithesishq/bombadil/-/bombadil-0.5.0.tgz",
|
||||
"integrity": "sha512-s0zImmr0iyvSP6QcVLvf40CUiZYIdWBAxiq20uhzujwvfitYa3PGJN652k/pLtVccHM/JrGQxZdvLnihZpltHA==",
|
||||
"version": "0.6.1",
|
||||
"resolved": "https://registry.npmjs.org/@antithesishq/bombadil/-/bombadil-0.6.1.tgz",
|
||||
"integrity": "sha512-d1iufG3MI7gSMSiSmMeNdcMW+qR0yQXL2zdkVynC3n3DYgFJYlYXKUQzygmqU12m4RWlR5iOdQU1hsx5UT6+IA==",
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"bin": {
|
||||
|
||||
+2
-2
@@ -4,9 +4,9 @@
|
||||
"url": "https://github.com/pewdiepie-archdaemon/odysseus.git"
|
||||
},
|
||||
"devDependencies": {
|
||||
"@antithesishq/bombadil": "^0.5.0"
|
||||
"@antithesishq/bombadil": "^0.6.1"
|
||||
},
|
||||
"dependencies": {
|
||||
"@anthropic-ai/sdk": "^0.104.1"
|
||||
"@anthropic-ai/sdk": "^0.105.0"
|
||||
}
|
||||
}
|
||||
|
||||
@@ -12,7 +12,7 @@ import re
|
||||
from pathlib import Path
|
||||
|
||||
from core.atomic_io import atomic_write_json, atomic_write_text
|
||||
from core.auth import AuthManager, RESERVED_USERNAMES, SetAdminResult
|
||||
from core.auth import AuthManager, RESERVED_USERNAMES, SetAdminResult, TOKEN_TTL
|
||||
from src.constants import DEEP_RESEARCH_DIR, MEMORY_FILE, PASSWORD_MIN_LENGTH, SKILLS_DIR
|
||||
from src.rate_limiter import RateLimiter
|
||||
from src.settings_scrub import scrub_settings
|
||||
@@ -161,7 +161,7 @@ def setup_auth_routes(auth_manager: AuthManager) -> APIRouter:
|
||||
path="/",
|
||||
)
|
||||
if body.remember:
|
||||
cookie_kwargs["max_age"] = 60 * 60 * 24 * 7 # 7 days
|
||||
cookie_kwargs["max_age"] = TOKEN_TTL
|
||||
response.set_cookie(**cookie_kwargs)
|
||||
return {"ok": True, "username": username}
|
||||
|
||||
|
||||
+18
-2
@@ -23,6 +23,22 @@ from fastapi import HTTPException
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
|
||||
# Strong references to in-flight fire-and-forget tasks scheduled from this
|
||||
# module. asyncio only keeps weak references to tasks created via
|
||||
# create_task, so without this the GC can collect a task mid-execution and
|
||||
# the background work (extraction, auto-naming) silently never runs.
|
||||
# Mirrors WebhookManager._spawn_tracked from src/webhook_manager.py.
|
||||
_BG_TASKS: set[asyncio.Task] = set()
|
||||
|
||||
|
||||
def _spawn_bg(coro) -> asyncio.Task:
|
||||
"""Schedule a background task and hold a strong reference until it finishes."""
|
||||
task = asyncio.create_task(coro)
|
||||
_BG_TASKS.add(task)
|
||||
task.add_done_callback(_BG_TASKS.discard)
|
||||
return task
|
||||
|
||||
|
||||
# ── Data containers ────────────────────────────────────────────────────── #
|
||||
|
||||
@dataclass
|
||||
@@ -1105,7 +1121,7 @@ def run_post_response_tasks(
|
||||
)))
|
||||
|
||||
if _extraction_jobs:
|
||||
asyncio.create_task(_run_extraction_jobs_sequentially(session_id, _extraction_jobs))
|
||||
_spawn_bg(_run_extraction_jobs_sequentially(session_id, _extraction_jobs))
|
||||
|
||||
# Token accumulation
|
||||
if last_metrics:
|
||||
@@ -1120,4 +1136,4 @@ def run_post_response_tasks(
|
||||
|
||||
# Auto-name
|
||||
if needs_auto_name(sess.name):
|
||||
asyncio.create_task(auto_name_session(session_manager, sess))
|
||||
_spawn_bg(auto_name_session(session_manager, sess))
|
||||
|
||||
@@ -146,17 +146,18 @@ def _canonical_cpu_backend(system):
|
||||
if backend in ("cpu_x86", "cpu_arm"):
|
||||
return backend
|
||||
|
||||
# Raw CPU-architecture aliases
|
||||
# Raw CPU-architecture aliases. Treat plain "arm" as 32-bit ARM, not the
|
||||
# ARM64-class CPU fallback used for Apple Silicon/aarch64 machines.
|
||||
if backend in ("x86_64", "amd64", "i386", "i686"):
|
||||
return "cpu_x86"
|
||||
if backend in ("arm64", "aarch64", "arm"):
|
||||
if backend in ("arm64", "aarch64"):
|
||||
return "cpu_arm"
|
||||
|
||||
# Prefer an explicit CPU architecture field when present
|
||||
if cpu_arch:
|
||||
if cpu_arch in ("x86_64", "amd64", "x86", "i386", "i686"):
|
||||
return "cpu_x86"
|
||||
if cpu_arch in ("arm64", "aarch64", "arm"):
|
||||
if cpu_arch in ("arm64", "aarch64"):
|
||||
return "cpu_arm"
|
||||
|
||||
# Apple Silicon enters ranking as backend="metal"; its CPU path is ARM.
|
||||
|
||||
@@ -320,7 +320,7 @@ def _detect_apple_silicon():
|
||||
|
||||
# Only Apple Silicon (arm64) has a Metal GPU worth serving LLMs on; Intel
|
||||
# Macs fall through to the CPU path.
|
||||
if "arm" not in arch and "aarch64" not in arch:
|
||||
if _canonical_cpu_arch(arch) != "arm64":
|
||||
return None
|
||||
|
||||
# Chip name, e.g. "Apple M4 Max" — carries the Pro/Max/Ultra variant that
|
||||
@@ -503,6 +503,25 @@ def _get_cpu_count():
|
||||
return os.cpu_count() or 1
|
||||
|
||||
|
||||
def _canonical_cpu_arch(value):
|
||||
arch = str(value or "").lower().strip().replace("-", "_")
|
||||
if arch in ("x86_64", "amd64", "x64"):
|
||||
return "x86_64"
|
||||
if arch in ("i386", "i686", "x86"):
|
||||
return "x86"
|
||||
if arch in ("arm64", "aarch64"):
|
||||
return "arm64"
|
||||
if arch == "arm" or arch.startswith("armv"):
|
||||
return "arm"
|
||||
return arch
|
||||
|
||||
|
||||
def _get_cpu_arch():
|
||||
if _remote_host:
|
||||
return _canonical_cpu_arch(_run(["uname", "-m"]) or "")
|
||||
return _canonical_cpu_arch(platform.machine())
|
||||
|
||||
|
||||
def _powershell_exe():
|
||||
"""Pick the best PowerShell executable for LOCAL execution: prefer pwsh
|
||||
(PowerShell 7+), fall back to Windows PowerShell 5.1. Returns an absolute
|
||||
@@ -528,6 +547,7 @@ def _detect_windows():
|
||||
$r.cpu_name = $cpu.Name
|
||||
$r.cpu_cores = (Get-CimInstance Win32_Processor | Measure-Object -Property NumberOfLogicalProcessors -Sum).Sum
|
||||
$r.arch = $cpu.AddressWidth
|
||||
$r.cpu_arch = if ($env:PROCESSOR_ARCHITEW6432) { $env:PROCESSOR_ARCHITEW6432 } else { $env:PROCESSOR_ARCHITECTURE }
|
||||
# GPU detection via nvidia-smi (fastest) or WMI fallback
|
||||
try {
|
||||
$nv = nvidia-smi --query-gpu=memory.total,name --format=csv,noheader,nounits 2>$null
|
||||
@@ -599,6 +619,7 @@ def _detect_windows():
|
||||
"available_ram_gb": d.get("avail_gb", 0),
|
||||
"cpu_cores": _as_int(d.get("cpu_cores"), 1),
|
||||
"cpu_name": _cpu_name,
|
||||
"cpu_arch": _canonical_cpu_arch(d.get("cpu_arch")),
|
||||
"has_gpu": bool(d.get("gpu_name")),
|
||||
"gpu_name": d.get("gpu_name"),
|
||||
"gpu_vram_gb": d.get("gpu_vram_gb"),
|
||||
@@ -794,6 +815,7 @@ def detect_system(host="", ssh_port="", platform="", fresh=False):
|
||||
available_ram = round(_get_available_ram_gb(), 1)
|
||||
cpu_cores = _get_cpu_count()
|
||||
cpu_name = _get_cpu_name()
|
||||
cpu_arch = _get_cpu_arch()
|
||||
|
||||
gpu_info = _detect_apple_silicon() or _detect_nvidia() or _detect_amd()
|
||||
|
||||
@@ -803,6 +825,7 @@ def detect_system(host="", ssh_port="", platform="", fresh=False):
|
||||
"available_ram_gb": available_ram,
|
||||
"cpu_cores": cpu_cores,
|
||||
"cpu_name": cpu_name,
|
||||
"cpu_arch": cpu_arch,
|
||||
"has_gpu": True,
|
||||
"gpu_name": gpu_info["gpu_name"],
|
||||
"gpu_vram_gb": gpu_info["gpu_vram_gb"],
|
||||
@@ -817,17 +840,13 @@ def detect_system(host="", ssh_port="", platform="", fresh=False):
|
||||
"unified_memory": gpu_info.get("unified_memory", False),
|
||||
}
|
||||
else:
|
||||
if _remote_host:
|
||||
arch_out = _run(["uname", "-m"]) or ""
|
||||
else:
|
||||
import platform as _platform
|
||||
arch_out = _platform.machine().lower()
|
||||
backend = "cpu_arm" if "aarch64" in arch_out or "arm" in arch_out else "cpu_x86"
|
||||
backend = "cpu_arm" if cpu_arch == "arm64" else "cpu_x86"
|
||||
result = {
|
||||
"total_ram_gb": total_ram,
|
||||
"available_ram_gb": available_ram,
|
||||
"cpu_cores": cpu_cores,
|
||||
"cpu_name": cpu_name,
|
||||
"cpu_arch": cpu_arch,
|
||||
"has_gpu": False,
|
||||
"gpu_name": None,
|
||||
"gpu_vram_gb": None,
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
import asyncio
|
||||
import json
|
||||
import os
|
||||
import re
|
||||
import difflib
|
||||
import fnmatch
|
||||
import shutil
|
||||
@@ -16,6 +17,31 @@ _CODENAV_SKIP_DIRS = frozenset({
|
||||
_CODENAV_MAX_HITS = 200
|
||||
_CODENAV_MAX_LINE = 400
|
||||
|
||||
|
||||
def _glob_to_regex(pat: str) -> "re.Pattern":
|
||||
"""Translate a forward-slash glob (**, *, ?) into a compiled regex.
|
||||
`**/` matches zero or more complete directories.
|
||||
`*` matches within a single path segment (does not cross /).
|
||||
"""
|
||||
i, n, out = 0, len(pat), []
|
||||
while i < n:
|
||||
if pat[i : i + 3] == "**/":
|
||||
out.append("(?:[^/]+/)*")
|
||||
i += 3
|
||||
elif pat[i : i + 2] == "**":
|
||||
out.append(".*")
|
||||
i += 2
|
||||
elif pat[i] == "*":
|
||||
out.append("[^/]*")
|
||||
i += 1
|
||||
elif pat[i] == "?":
|
||||
out.append("[^/]")
|
||||
i += 1
|
||||
else:
|
||||
out.append(re.escape(pat[i]))
|
||||
i += 1
|
||||
return re.compile("".join(out))
|
||||
|
||||
def _unified_diff(old: str, new: str, path: str) -> Optional[Dict[str, Any]]:
|
||||
if old == new:
|
||||
return None
|
||||
@@ -259,23 +285,38 @@ class GlobTool:
|
||||
return {"error": f"glob: {e}", "exit_code": 1}
|
||||
|
||||
def _glob():
|
||||
from pathlib import Path
|
||||
base = Path(root)
|
||||
if not base.is_dir():
|
||||
base = os.path.abspath(root)
|
||||
if not os.path.isdir(base):
|
||||
return None, f"glob: {root}: not a directory"
|
||||
norm_pat = pattern.replace("\\", "/")
|
||||
# Fast path: literal pattern (no wildcards) → direct path lookup.
|
||||
if not any(c in norm_pat for c in "*?["):
|
||||
cand = os.path.normpath(os.path.join(base, norm_pat))
|
||||
if os.path.exists(cand):
|
||||
return [cand], None
|
||||
# Literal not at exact path — fall through to walk so
|
||||
# e.g. "foo.py" still matches at any depth (like rglob).
|
||||
# Compile glob to regex: * stays within one segment, **/ spans dirs.
|
||||
regex = _glob_to_regex(norm_pat)
|
||||
matched = []
|
||||
cap = _CODENAV_MAX_HITS * 5
|
||||
try:
|
||||
for p in base.rglob(pattern):
|
||||
if set(p.relative_to(base).parts) & _CODENAV_SKIP_DIRS:
|
||||
continue
|
||||
try:
|
||||
mtime = p.stat().st_mtime
|
||||
except OSError:
|
||||
mtime = 0
|
||||
matched.append((mtime, str(p)))
|
||||
if len(matched) > _CODENAV_MAX_HITS * 5:
|
||||
for dp, dns, fns in os.walk(base):
|
||||
# Prune skipped dirs before descending (unlike rglob which
|
||||
# descends first then filters — fatal on large node_modules).
|
||||
dns[:] = [d for d in dns if d not in _CODENAV_SKIP_DIRS]
|
||||
for name in fns + dns:
|
||||
full = os.path.join(dp, name)
|
||||
rel = os.path.relpath(full, base).replace(os.sep, "/")
|
||||
if regex.fullmatch(rel) or regex.fullmatch(name):
|
||||
try:
|
||||
mtime = os.stat(full).st_mtime
|
||||
except OSError:
|
||||
mtime = 0
|
||||
matched.append((mtime, full))
|
||||
if len(matched) > cap:
|
||||
break
|
||||
except (OSError, ValueError) as _e:
|
||||
except OSError as _e:
|
||||
return None, f"glob: {_e}"
|
||||
matched.sort(key=lambda t: t[0], reverse=True)
|
||||
return [pth for _, pth in matched[:_CODENAV_MAX_HITS]], None
|
||||
|
||||
+6
-2
@@ -87,7 +87,7 @@ _host_health_lock = threading.Lock()
|
||||
_model_activity: Dict[str, float] = {}
|
||||
|
||||
_HARMONY_MARKER_RE = re.compile(
|
||||
r"<\|channel\|>(analysis|final)"
|
||||
r"<\|channel\|>(analysis|commentary|final)"
|
||||
r"|<\|start\|>(?:assistant|system|user|tool)?"
|
||||
r"|<\|message\|>"
|
||||
r"|<\|end\|>"
|
||||
@@ -96,6 +96,7 @@ _HARMONY_MARKER_RE = re.compile(
|
||||
)
|
||||
_HARMONY_MARKERS = (
|
||||
"<|channel|>analysis",
|
||||
"<|channel|>commentary",
|
||||
"<|channel|>final",
|
||||
"<|start|>assistant",
|
||||
"<|start|>system",
|
||||
@@ -145,7 +146,10 @@ class _HarmonyStreamRouter:
|
||||
out.append((text, False))
|
||||
return
|
||||
if self._in_message:
|
||||
out.append((text, self._channel == "analysis"))
|
||||
# analysis + commentary (tool-call preambles / function-arg bodies)
|
||||
# are internal, not user-facing — route them to thinking so they
|
||||
# don't leak into the visible answer; only `final` is visible.
|
||||
out.append((text, self._channel in ("analysis", "commentary")))
|
||||
|
||||
def _handle_marker(self, match: re.Match[str]) -> None:
|
||||
marker = match.group(0)
|
||||
|
||||
@@ -12,12 +12,24 @@ import os
|
||||
import re
|
||||
from typing import Any, Dict, List, Optional
|
||||
|
||||
from fastapi import HTTPException
|
||||
from src.constants import MAX_READ_CHARS, DEEP_RESEARCH_DIR, VAULT_FILE
|
||||
from src.tool_utils import get_mcp_manager
|
||||
from core.constants import internal_api_base
|
||||
from routes._validators import validate_remote_host, validate_ssh_port
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
|
||||
def _string_arg(value: Any) -> str:
|
||||
return "" if value is None else str(value).strip()
|
||||
|
||||
|
||||
def _validate_cookbook_ssh_target(remote_host: Any, ssh_port: Any = "") -> tuple[str, str]:
|
||||
remote = validate_remote_host(_string_arg(remote_host) or None) or ""
|
||||
sport = validate_ssh_port(_string_arg(ssh_port) or None) or ""
|
||||
return remote, sport
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Active email state
|
||||
# ---------------------------------------------------------------------------
|
||||
@@ -3025,6 +3037,10 @@ async def _cookbook_kill_session(session_id: str, *, remote_host: str = "",
|
||||
break
|
||||
|
||||
if remote:
|
||||
try:
|
||||
remote, sport = _validate_cookbook_ssh_target(remote, sport)
|
||||
except HTTPException as e:
|
||||
return {"error": str(getattr(e, "detail", e)), "exit_code": 1}
|
||||
_pf = f"-p {shlex.quote(str(sport))} " if sport and str(sport) != "22" else ""
|
||||
cmd = (
|
||||
f"ssh -o ConnectTimeout=5 -o StrictHostKeyChecking=no "
|
||||
@@ -3113,8 +3129,8 @@ async def do_tail_serve_output(content: str, owner: Optional[str] = None) -> Dic
|
||||
tail = 400
|
||||
tail = max(20, min(tail, 4000))
|
||||
headers = _internal_headers()
|
||||
remote = (args.get("remote_host") or args.get("host") or "").strip()
|
||||
sport = (args.get("ssh_port") or "").strip()
|
||||
remote = _string_arg(args.get("remote_host") or args.get("host"))
|
||||
sport = _string_arg(args.get("ssh_port"))
|
||||
# Resolve host from cookbook state if caller didn't pass one — same
|
||||
# lookup _cookbook_kill_session uses.
|
||||
if not remote:
|
||||
@@ -3132,6 +3148,12 @@ async def do_tail_serve_output(content: str, owner: Optional[str] = None) -> Dic
|
||||
if not sport:
|
||||
sport = t.get("sshPort") or ""
|
||||
break
|
||||
if remote:
|
||||
try:
|
||||
remote, sport = _validate_cookbook_ssh_target(remote, sport)
|
||||
except HTTPException as e:
|
||||
return {"error": str(getattr(e, "detail", e)), "exit_code": 1}
|
||||
|
||||
# Prefer the persisted /tmp/odysseus-tmux/SESSION.log file over the
|
||||
# live tmux pane. The pane is what the user would see scrolling on
|
||||
# their screen — including the post-crash neofetch banner and the
|
||||
@@ -3309,7 +3331,7 @@ async def do_adopt_served_model(content: str, owner: Optional[str] = None) -> Di
|
||||
except ValueError:
|
||||
return {"error": "Invalid JSON arguments", "exit_code": 1}
|
||||
|
||||
host = (args.get("host") or args.get("remote_host") or "").strip()
|
||||
host = _string_arg(args.get("host") or args.get("remote_host"))
|
||||
sess = (args.get("tmux_session") or args.get("session_id") or "").strip()
|
||||
model = (args.get("model") or args.get("repo_id") or "").strip()
|
||||
port = args.get("port") or 8000
|
||||
@@ -3320,6 +3342,12 @@ async def do_adopt_served_model(content: str, owner: Optional[str] = None) -> Di
|
||||
return {"error": "tmux_session and model are required", "exit_code": 1}
|
||||
|
||||
# Verify tmux session exists on the target host
|
||||
if host:
|
||||
try:
|
||||
host, _ = _validate_cookbook_ssh_target(host)
|
||||
except HTTPException as e:
|
||||
return {"error": str(getattr(e, "detail", e)), "exit_code": 1}
|
||||
|
||||
headers = _internal_headers()
|
||||
if host:
|
||||
check = f"ssh -o ConnectTimeout=5 -o StrictHostKeyChecking=no {shlex.quote(host)} 'tmux has-session -t {shlex.quote(sess)} 2>&1'"
|
||||
|
||||
@@ -56,7 +56,7 @@ const _RECIPES = [
|
||||
match: () => true,
|
||||
variants: {
|
||||
pip: { commands: ['CMAKE_ARGS="-DGGML_CUDA=on" uv pip install -U "llama-cpp-python[server]"'] },
|
||||
docker: { commands: ['docker pull ghcr.io/ggerganov/llama.cpp:server-cuda'] },
|
||||
docker: { commands: ['docker pull ghcr.io/ggml-org/llama.cpp:server-cuda'] },
|
||||
},
|
||||
},
|
||||
];
|
||||
|
||||
@@ -578,24 +578,50 @@ export const ERROR_PATTERNS = [
|
||||
],
|
||||
},
|
||||
{
|
||||
// Tail-only + healthy-server suppression. tmux capture-pane returns the
|
||||
// entire scrollback every poll, so a one-shot startup traceback would
|
||||
// otherwise stick on the panel forever even while the server happily
|
||||
// serves /v1/models. Only fire if the traceback is in recent output AND
|
||||
// the server isn't currently logging healthy traffic.
|
||||
// Dependency-install (pip) build failure — a required package failed to
|
||||
// build its wheel (common when an old sdist's setup.py breaks on a newer
|
||||
// Python, e.g. basicsr on 3.13). This is an install problem, NOT a serve
|
||||
// problem, so it must never suggest killing vLLM.
|
||||
match: (text) => {
|
||||
const TAIL = text.slice(-6000);
|
||||
// A serve script can run a fallback build and then start serving fine —
|
||||
// don't flag a stale build error once the server is up.
|
||||
if (/Application startup complete|"(?:GET|POST)\s+\/v1\/[^"]+ HTTP\/[\d.]+"\s*2\d\d|Uvicorn running on|server is listening on https?:\/\//i.test(TAIL)) return false;
|
||||
return /Failed to build\b|subprocess-exited-with-error|Could not build wheels|metadata-generation-failed/i.test(TAIL);
|
||||
},
|
||||
message: 'A dependency failed to build during install — usually an older package whose build breaks on this Python version, not a server problem. The install did not finish.',
|
||||
suggestion: 'Suggested action: check the captured output for the package that failed to build; it may need a newer release or a patch to install on this Python version.',
|
||||
fixes: [],
|
||||
},
|
||||
{
|
||||
// vLLM-specific traceback: only offer the kill-processes recovery when the
|
||||
// output is actually about vLLM. Tail-only + healthy-server suppression so
|
||||
// a one-shot startup traceback doesn't stick on the panel forever while
|
||||
// the server happily serves /v1/models.
|
||||
match: (text) => {
|
||||
const TAIL = text.slice(-4096);
|
||||
if (!/Traceback \(most recent call last\)/i.test(TAIL)) return false;
|
||||
// Healthy markers in the tail mean whatever blew up has been recovered
|
||||
// from — the server is up and answering requests.
|
||||
if (/Application startup complete|"GET \/v1\/[^"]+ HTTP\/[\d.]+" 2\d\d|Uvicorn running on/i.test(TAIL)) return false;
|
||||
return true;
|
||||
return /vllm/i.test(TAIL);
|
||||
},
|
||||
message: 'Python traceback detected — may be a handled error, check logs.',
|
||||
message: 'A vLLM process hit a Python traceback and may be wedged.',
|
||||
fixes: [
|
||||
{ label: 'Kill vLLM processes', action: (panel) => _runQuickCmd(panel, 'pkill -f vllm') },
|
||||
],
|
||||
},
|
||||
{
|
||||
// Generic traceback (not vLLM, not a pip build): surface it without
|
||||
// suggesting an unrelated vLLM kill. Same tail-only + healthy suppression.
|
||||
match: (text) => {
|
||||
const TAIL = text.slice(-4096);
|
||||
if (!/Traceback \(most recent call last\)/i.test(TAIL)) return false;
|
||||
if (/Application startup complete|"GET \/v1\/[^"]+ HTTP\/[\d.]+" 2\d\d|Uvicorn running on/i.test(TAIL)) return false;
|
||||
return true;
|
||||
},
|
||||
message: 'Python traceback detected — check the captured output below for the underlying error.',
|
||||
suggestion: 'Suggested action: read the captured output for the failing step; copy the troubleshooting bundle if you need help.',
|
||||
fixes: [],
|
||||
},
|
||||
];
|
||||
|
||||
export function _diagnose(text) {
|
||||
|
||||
@@ -215,3 +215,58 @@ def test_setup_rejects_seven_char_password(tmp_path):
|
||||
asyncio.run(endpoint(body=body, request=request))
|
||||
|
||||
assert exc.value.status_code == 400
|
||||
|
||||
|
||||
# ── Login "remember me" cookie lifetime ────────────────────────────────
|
||||
|
||||
|
||||
class _CapturingResponse:
|
||||
"""Stand-in for fastapi.Response that records set_cookie kwargs."""
|
||||
|
||||
def __init__(self):
|
||||
self.cookie_kwargs = None
|
||||
|
||||
def set_cookie(self, **kwargs):
|
||||
self.cookie_kwargs = kwargs
|
||||
|
||||
|
||||
def _login_endpoint(auth_manager):
|
||||
sys.modules.pop("routes.auth_routes", None)
|
||||
_real_core_package()
|
||||
from routes.auth_routes import LoginRequest, setup_auth_routes
|
||||
|
||||
router = setup_auth_routes(auth_manager)
|
||||
for route in router.routes:
|
||||
if getattr(route, "path", None) == "/api/auth/login":
|
||||
return route.endpoint, LoginRequest
|
||||
raise AssertionError("login route not found")
|
||||
|
||||
|
||||
def test_remember_cookie_max_age_matches_token_ttl(tmp_path):
|
||||
auth_mod = _auth_module()
|
||||
mgr = _make_manager(tmp_path)
|
||||
mgr.create_user("alice", "alice-password", is_admin=False)
|
||||
endpoint, LoginRequest = _login_endpoint(mgr)
|
||||
request = SimpleNamespace(client=SimpleNamespace(host="127.0.0.1"))
|
||||
response = _CapturingResponse()
|
||||
body = LoginRequest(username="alice", password="alice-password", remember=True)
|
||||
|
||||
result = asyncio.run(endpoint(body=body, request=request, response=response))
|
||||
|
||||
assert result == {"ok": True, "username": "alice"}
|
||||
# The persistent cookie must outlive neither more nor less than the token.
|
||||
assert response.cookie_kwargs["max_age"] == auth_mod.TOKEN_TTL
|
||||
|
||||
|
||||
def test_no_remember_omits_cookie_max_age(tmp_path):
|
||||
mgr = _make_manager(tmp_path)
|
||||
mgr.create_user("bob", "bob-password", is_admin=False)
|
||||
endpoint, LoginRequest = _login_endpoint(mgr)
|
||||
request = SimpleNamespace(client=SimpleNamespace(host="127.0.0.1"))
|
||||
response = _CapturingResponse()
|
||||
body = LoginRequest(username="bob", password="bob-password", remember=False)
|
||||
|
||||
asyncio.run(endpoint(body=body, request=request, response=response))
|
||||
|
||||
# Without "remember", the cookie is a session cookie (no max_age).
|
||||
assert "max_age" not in response.cookie_kwargs
|
||||
|
||||
@@ -0,0 +1,71 @@
|
||||
"""Guard: chat hot-path background tasks must go through _spawn_bg.
|
||||
|
||||
asyncio only holds a weak reference to a bare create_task() result, so the
|
||||
GC can collect the outer task before its body runs and the background work
|
||||
(memory/skill extraction, session auto-naming) silently never happens.
|
||||
routes/chat_helpers.py owns these schedules via _spawn_bg(), which adds the
|
||||
task to _BG_TASKS and discards it via a done-callback. This guard catches a
|
||||
regression where a copy-paste re-introduces a bare asyncio.create_task.
|
||||
|
||||
This is the routes/chat_helpers.py-scoped sibling of the webhook-emitter
|
||||
guard added in #4336 (tests/test_webhook_emitters_use_manager.py).
|
||||
"""
|
||||
import ast
|
||||
from pathlib import Path
|
||||
|
||||
CHAT_HELPERS = (
|
||||
Path(__file__).resolve().parent.parent / "routes" / "chat_helpers.py"
|
||||
)
|
||||
|
||||
|
||||
def _untracked_create_task_calls(tree: ast.AST) -> list[tuple[int, str]]:
|
||||
"""(lineno, snippet) for any bare asyncio.create_task(...).
|
||||
|
||||
A call is "bare" when its return value is dropped — i.e. it is the direct
|
||||
expression of an ast.Expr statement. Captured forms (`x = asyncio.create_task(...)`,
|
||||
`[asyncio.create_task(...), ...]`, `await asyncio.create_task(...)`) are fine
|
||||
because something else holds the reference.
|
||||
|
||||
The helper itself (_spawn_bg) is exempt: it calls asyncio.create_task once
|
||||
and registers the task in _BG_TASKS before returning.
|
||||
"""
|
||||
hits: list[tuple[int, str]] = []
|
||||
|
||||
def _is_create_task(call: ast.Call) -> bool:
|
||||
f = call.func
|
||||
return (
|
||||
isinstance(f, ast.Attribute)
|
||||
and f.attr == "create_task"
|
||||
and isinstance(f.value, ast.Name)
|
||||
and f.value.id == "asyncio"
|
||||
)
|
||||
|
||||
spawn_helper_lines: set[int] = set()
|
||||
for node in ast.walk(tree):
|
||||
if isinstance(node, ast.FunctionDef) and node.name == "_spawn_bg":
|
||||
for n in ast.walk(node):
|
||||
if hasattr(n, "lineno"):
|
||||
spawn_helper_lines.add(n.lineno)
|
||||
|
||||
for node in ast.walk(tree):
|
||||
if not isinstance(node, ast.Expr):
|
||||
continue
|
||||
if not isinstance(node.value, ast.Call):
|
||||
continue
|
||||
if not _is_create_task(node.value):
|
||||
continue
|
||||
if node.lineno in spawn_helper_lines:
|
||||
continue
|
||||
hits.append((node.lineno, ast.unparse(node.value)))
|
||||
return hits
|
||||
|
||||
|
||||
def test_no_untracked_create_task_in_chat_helpers():
|
||||
tree = ast.parse(CHAT_HELPERS.read_text(), filename=str(CHAT_HELPERS))
|
||||
offenders = _untracked_create_task_calls(tree)
|
||||
assert not offenders, (
|
||||
"Background tasks scheduled from routes/chat_helpers.py must go through "
|
||||
"_spawn_bg(coro) so the task is registered in _BG_TASKS and survives until "
|
||||
"it finishes. Found bare asyncio.create_task(...) call(s):\n "
|
||||
+ "\n ".join(f"chat_helpers.py:{ln}: {snip}" for ln, snip in offenders)
|
||||
)
|
||||
@@ -24,6 +24,9 @@ def repo():
|
||||
os.mkdir(os.path.join(root, "sub"))
|
||||
with open(os.path.join(root, "sub", "b.txt"), "w") as f:
|
||||
f.write("nothing\nNEEDLE upper\n")
|
||||
os.mkdir(os.path.join(root, "sub", "deep"))
|
||||
with open(os.path.join(root, "sub", "deep", "c.py"), "w") as f:
|
||||
f.write("# deep python\n")
|
||||
os.mkdir(os.path.join(root, "node_modules"))
|
||||
with open(os.path.join(root, "node_modules", "dep.py"), "w") as f:
|
||||
f.write("needle in dep\n")
|
||||
@@ -107,6 +110,37 @@ def test_glob_requires_pattern(repo):
|
||||
assert r["exit_code"] == 1
|
||||
|
||||
|
||||
def test_glob_literal_in_subdir(repo):
|
||||
"""Bare literal should match at any depth (like rglob), not only at root."""
|
||||
r = _run("glob", f'{{"pattern": "b.txt", "path": "{repo}"}}')
|
||||
assert r["exit_code"] == 0
|
||||
assert "b.txt" in r["output"]
|
||||
|
||||
|
||||
def test_glob_multi_segment_single_star(repo):
|
||||
"""sub/*.txt matches sub/b.txt but NOT sub/deep/c.py (single * stays in one segment)."""
|
||||
r = _run("glob", f'{{"pattern": "sub/*.txt", "path": "{repo}"}}')
|
||||
assert r["exit_code"] == 0
|
||||
assert "b.txt" in r["output"]
|
||||
assert "c.py" not in r["output"]
|
||||
|
||||
|
||||
def test_glob_star_does_not_cross_slash(repo):
|
||||
"""src/*.py must NOT match src/a/b/x.py — * is single-segment only."""
|
||||
r = _run("glob", f'{{"pattern": "sub/*.py", "path": "{repo}"}}')
|
||||
assert r["exit_code"] == 0
|
||||
# sub/ has no .py directly, only sub/deep/c.py — should NOT match
|
||||
assert "No files matching" in r["output"]
|
||||
|
||||
|
||||
def test_glob_double_star_matches_deep(repo):
|
||||
"""**/*.py should match files at any depth."""
|
||||
r = _run("glob", f'{{"pattern": "**/*.py", "path": "{repo}"}}')
|
||||
assert r["exit_code"] == 0
|
||||
assert "a.py" in r["output"]
|
||||
assert "c.py" in r["output"]
|
||||
|
||||
|
||||
# ── ls ────────────────────────────────────────────────────────────────────
|
||||
|
||||
def test_ls_lists_entries(repo):
|
||||
|
||||
@@ -0,0 +1,215 @@
|
||||
import json
|
||||
|
||||
import pytest
|
||||
|
||||
from src import tool_implementations as tools
|
||||
|
||||
|
||||
class FakeResponse:
|
||||
def __init__(self, data=None, status_code=200):
|
||||
self._data = data or {}
|
||||
self.status_code = status_code
|
||||
self.text = json.dumps(self._data)
|
||||
self.content = self.text.encode("utf-8")
|
||||
self.headers = {"content-type": "application/json"}
|
||||
|
||||
def json(self):
|
||||
return self._data
|
||||
|
||||
|
||||
def _install_httpx_client(monkeypatch, *, state=None, posts=None):
|
||||
import httpx
|
||||
|
||||
posts = posts if posts is not None else []
|
||||
state = state if state is not None else {"tasks": []}
|
||||
|
||||
class FakeAsyncClient:
|
||||
def __init__(self, *args, **kwargs):
|
||||
pass
|
||||
|
||||
async def __aenter__(self):
|
||||
return self
|
||||
|
||||
async def __aexit__(self, exc_type, exc, tb):
|
||||
return False
|
||||
|
||||
async def get(self, url, **kwargs):
|
||||
return FakeResponse(state)
|
||||
|
||||
async def post(self, url, json=None, **kwargs):
|
||||
posts.append((url, json, kwargs))
|
||||
return FakeResponse({"stdout": "", "stderr": "", "exit_code": 0})
|
||||
|
||||
monkeypatch.setattr(httpx, "AsyncClient", FakeAsyncClient)
|
||||
return posts
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_stop_served_model_rejects_invalid_remote_host_before_shell(monkeypatch):
|
||||
posts = _install_httpx_client(monkeypatch)
|
||||
|
||||
result = await tools.do_stop_served_model(
|
||||
json.dumps({"session_id": "serve-abc123", "remote_host": "-bad"})
|
||||
)
|
||||
|
||||
assert result["exit_code"] == 1
|
||||
assert "Invalid remote_host" in result["error"]
|
||||
assert posts == []
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_stop_served_model_rejects_invalid_state_host_before_shell(monkeypatch):
|
||||
posts = _install_httpx_client(
|
||||
monkeypatch,
|
||||
state={
|
||||
"tasks": [
|
||||
{
|
||||
"sessionId": "serve-abc123",
|
||||
"remoteHost": "-bad",
|
||||
"sshPort": "22",
|
||||
}
|
||||
]
|
||||
},
|
||||
)
|
||||
|
||||
result = await tools.do_stop_served_model(
|
||||
json.dumps({"session_id": "serve-abc123"})
|
||||
)
|
||||
|
||||
assert result["exit_code"] == 1
|
||||
assert "Invalid remote_host" in result["error"]
|
||||
assert posts == []
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_stop_served_model_rejects_invalid_ssh_port_before_shell(monkeypatch):
|
||||
posts = _install_httpx_client(monkeypatch)
|
||||
|
||||
result = await tools.do_stop_served_model(
|
||||
json.dumps(
|
||||
{
|
||||
"session_id": "serve-abc123",
|
||||
"remote_host": "gpu-box",
|
||||
"ssh_port": "not-a-port",
|
||||
}
|
||||
)
|
||||
)
|
||||
|
||||
assert result["exit_code"] == 1
|
||||
assert "Invalid ssh_port" in result["error"]
|
||||
assert posts == []
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_stop_served_model_uses_validated_remote_target(monkeypatch):
|
||||
posts = _install_httpx_client(monkeypatch)
|
||||
|
||||
result = await tools.do_stop_served_model(
|
||||
json.dumps(
|
||||
{
|
||||
"session_id": "serve-abc123",
|
||||
"remote_host": "user@gpu-box",
|
||||
"ssh_port": 2222,
|
||||
}
|
||||
)
|
||||
)
|
||||
|
||||
assert result["exit_code"] == 0
|
||||
assert len(posts) == 1
|
||||
command = posts[0][1]["command"]
|
||||
assert "ssh -o ConnectTimeout=5 -o StrictHostKeyChecking=no" in command
|
||||
assert "-p 2222 user@gpu-box" in command
|
||||
assert "tmux kill-session -t serve-abc123" in command
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_cancel_download_rejects_invalid_remote_host_before_shell(monkeypatch):
|
||||
posts = _install_httpx_client(monkeypatch)
|
||||
|
||||
result = await tools.do_cancel_download(
|
||||
json.dumps({"session_id": "cookbook-abc123", "remote_host": "-bad"})
|
||||
)
|
||||
|
||||
assert result["exit_code"] == 1
|
||||
assert "Invalid remote_host" in result["error"]
|
||||
assert posts == []
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_cancel_download_rejects_invalid_state_host_before_shell(monkeypatch):
|
||||
posts = _install_httpx_client(
|
||||
monkeypatch,
|
||||
state={
|
||||
"tasks": [
|
||||
{
|
||||
"sessionId": "cookbook-abc123",
|
||||
"remoteHost": "-bad",
|
||||
"sshPort": "22",
|
||||
}
|
||||
]
|
||||
},
|
||||
)
|
||||
|
||||
result = await tools.do_cancel_download(
|
||||
json.dumps({"session_id": "cookbook-abc123"})
|
||||
)
|
||||
|
||||
assert result["exit_code"] == 1
|
||||
assert "Invalid remote_host" in result["error"]
|
||||
assert posts == []
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_tail_serve_output_rejects_invalid_remote_host_before_shell(monkeypatch):
|
||||
posts = _install_httpx_client(monkeypatch)
|
||||
|
||||
result = await tools.do_tail_serve_output(
|
||||
json.dumps({"session_id": "serve-abc123", "remote_host": "-bad"})
|
||||
)
|
||||
|
||||
assert result["exit_code"] == 1
|
||||
assert "Invalid remote_host" in result["error"]
|
||||
assert posts == []
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_tail_serve_output_rejects_invalid_state_host_before_shell(monkeypatch):
|
||||
posts = _install_httpx_client(
|
||||
monkeypatch,
|
||||
state={
|
||||
"tasks": [
|
||||
{
|
||||
"sessionId": "serve-abc123",
|
||||
"remoteHost": "-bad",
|
||||
"sshPort": "22",
|
||||
}
|
||||
]
|
||||
},
|
||||
)
|
||||
|
||||
result = await tools.do_tail_serve_output(
|
||||
json.dumps({"session_id": "serve-abc123"})
|
||||
)
|
||||
|
||||
assert result["exit_code"] == 1
|
||||
assert "Invalid remote_host" in result["error"]
|
||||
assert posts == []
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_adopt_served_model_rejects_invalid_remote_host_before_shell(monkeypatch):
|
||||
posts = _install_httpx_client(monkeypatch)
|
||||
|
||||
result = await tools.do_adopt_served_model(
|
||||
json.dumps(
|
||||
{
|
||||
"tmux_session": "serve_abc123",
|
||||
"model": "org/model",
|
||||
"host": "-bad",
|
||||
}
|
||||
)
|
||||
)
|
||||
|
||||
assert result["exit_code"] == 1
|
||||
assert "Invalid remote_host" in result["error"]
|
||||
assert posts == []
|
||||
@@ -0,0 +1,27 @@
|
||||
"""Guard the llama.cpp Docker pull recipe surfaced in Cookbook → Dependencies.
|
||||
|
||||
The upstream repo moved from github.com/ggerganov/llama.cpp to
|
||||
github.com/ggml-org/llama.cpp. The old GHCR namespace
|
||||
(ghcr.io/ggerganov/llama.cpp) no longer publishes images, so the
|
||||
docker variant in the Dependencies panel returned
|
||||
"failed to resolve reference … not found" when copied verbatim (#4457).
|
||||
The other llama.cpp reference in routes/cookbook_routes.py already uses
|
||||
ggml-org; this guards the JS recipe so the two stay aligned.
|
||||
"""
|
||||
from pathlib import Path
|
||||
|
||||
RECIPES_JS = (
|
||||
Path(__file__).resolve().parent.parent / "static" / "js" / "cookbook-deps-recipes.js"
|
||||
)
|
||||
|
||||
|
||||
def test_llama_cpp_docker_recipe_uses_ggml_org_namespace():
|
||||
source = RECIPES_JS.read_text(encoding="utf-8")
|
||||
|
||||
assert "ghcr.io/ggml-org/llama.cpp:server-cuda" in source, (
|
||||
"Expected the llama.cpp docker recipe to pull from the ggml-org namespace."
|
||||
)
|
||||
assert "ghcr.io/ggerganov/llama.cpp" not in source, (
|
||||
"The ggerganov GHCR namespace no longer publishes llama.cpp images. "
|
||||
"Use ghcr.io/ggml-org/llama.cpp:server-cuda."
|
||||
)
|
||||
@@ -0,0 +1,55 @@
|
||||
"""CPU architecture normalization for HW Fit hardware detection."""
|
||||
|
||||
import pytest
|
||||
|
||||
from services.hwfit import hardware
|
||||
|
||||
|
||||
@pytest.fixture(autouse=True)
|
||||
def _clear_hwfit_cache(monkeypatch):
|
||||
hardware._cache_by_host.clear()
|
||||
monkeypatch.setattr(hardware, "_remote_host", None)
|
||||
monkeypatch.setattr(hardware, "_remote_platform", None)
|
||||
monkeypatch.setattr(hardware, "_is_containerized", lambda: False)
|
||||
yield
|
||||
hardware._cache_by_host.clear()
|
||||
|
||||
|
||||
def _stub_common_probe(monkeypatch, machine):
|
||||
monkeypatch.setattr(hardware.platform, "machine", lambda: machine)
|
||||
monkeypatch.setattr(hardware, "_get_ram_gb", lambda: 64.0)
|
||||
monkeypatch.setattr(hardware, "_get_available_ram_gb", lambda: 48.0)
|
||||
monkeypatch.setattr(hardware, "_get_cpu_count", lambda: 16)
|
||||
monkeypatch.setattr(hardware, "_get_cpu_name", lambda: "Test CPU")
|
||||
monkeypatch.setattr(hardware, "_detect_apple_silicon", lambda: None)
|
||||
monkeypatch.setattr(hardware, "_detect_amd", lambda: None)
|
||||
|
||||
|
||||
def test_detect_system_reports_cpu_arch_for_gpu_backends(monkeypatch):
|
||||
"""GPU-backed systems still need CPU architecture for cpu_only estimates."""
|
||||
_stub_common_probe(monkeypatch, "aarch64")
|
||||
monkeypatch.setattr(hardware, "_detect_nvidia", lambda: {
|
||||
"gpu_name": "NVIDIA GB10",
|
||||
"gpu_vram_gb": 64.0,
|
||||
"gpu_count": 1,
|
||||
"gpus": [],
|
||||
"gpu_groups": [],
|
||||
"homogeneous": True,
|
||||
"backend": "cuda",
|
||||
})
|
||||
|
||||
system = hardware.detect_system(fresh=True)
|
||||
|
||||
assert system["backend"] == "cuda"
|
||||
assert system["cpu_arch"] == "arm64"
|
||||
|
||||
|
||||
def test_detect_system_keeps_32_bit_arm_on_conservative_cpu_backend(monkeypatch):
|
||||
"""Plain arm/armv7 is not the same as the ARM64-class cpu_arm fallback."""
|
||||
_stub_common_probe(monkeypatch, "armv7l")
|
||||
monkeypatch.setattr(hardware, "_detect_nvidia", lambda: None)
|
||||
|
||||
system = hardware.detect_system(fresh=True)
|
||||
|
||||
assert system["cpu_arch"] == "arm"
|
||||
assert system["backend"] == "cpu_x86"
|
||||
@@ -47,6 +47,12 @@ ARM64_SYSTEM = {
|
||||
"gpu_vram_gb": 0,
|
||||
}
|
||||
|
||||
ARM32_SYSTEM = {
|
||||
"backend": "arm",
|
||||
"gpu_name": None,
|
||||
"gpu_vram_gb": 0,
|
||||
}
|
||||
|
||||
AARCH64_SYSTEM = {
|
||||
"backend": "aarch64",
|
||||
"gpu_name": None,
|
||||
@@ -79,6 +85,16 @@ def test_cpu_only_on_metal_apple_silicon_uses_cpu_arm_fallback():
|
||||
assert metal_tps > 0
|
||||
|
||||
|
||||
def test_cpu_only_on_gpu_backend_uses_detected_arm64_cpu_arch():
|
||||
"""A GPU backend on an ARM64 host should use the ARM CPU fallback for cpu_only."""
|
||||
cuda_arm64 = dict(CUDA_SYSTEM, cpu_arch="aarch64", cpu_name="Ampere Altra")
|
||||
cuda_arm64_tps = _estimate_speed(DENSE_MODEL, QUANT, "cpu_only", cuda_arm64)
|
||||
arm_tps = _estimate_speed(DENSE_MODEL, QUANT, "cpu_only", CPU_ARM_SYSTEM)
|
||||
|
||||
assert cuda_arm64_tps == pytest.approx(arm_tps, rel=1e-9, abs=1e-9)
|
||||
assert cuda_arm64_tps > 0
|
||||
|
||||
|
||||
@pytest.mark.parametrize(
|
||||
"arm_alias_system",
|
||||
[ARM64_SYSTEM, AARCH64_SYSTEM, CPU_ARM_SYSTEM],
|
||||
@@ -93,6 +109,15 @@ def test_cpu_only_preserves_arm_backends(arm_alias_system):
|
||||
assert alias_tps > 0
|
||||
|
||||
|
||||
def test_cpu_only_does_not_treat_plain_arm_as_arm64_fallback():
|
||||
"""Docker/OCI plain arm is not the ARM64-class fallback used for Apple Silicon."""
|
||||
arm32_tps = _estimate_speed(DENSE_MODEL, QUANT, "cpu_only", ARM32_SYSTEM)
|
||||
x86_tps = _estimate_speed(DENSE_MODEL, QUANT, "cpu_only", CPU_X86_SYSTEM)
|
||||
|
||||
assert arm32_tps == pytest.approx(x86_tps, rel=1e-9, abs=1e-9)
|
||||
assert arm32_tps > 0
|
||||
|
||||
|
||||
def test_cpu_only_preserves_known_cpu_backends():
|
||||
"""Known CPU backends should be preserved, not rewritten to cpu_x86."""
|
||||
for system in (CPU_X86_SYSTEM, CPU_ARM_SYSTEM):
|
||||
|
||||
@@ -165,6 +165,15 @@ def test_intel_mac_skipped(monkeypatch):
|
||||
assert hardware._detect_apple_silicon() is None
|
||||
|
||||
|
||||
def test_plain_arm_mac_skipped(monkeypatch):
|
||||
"""Only ARM64-class Macs should enter the Apple Silicon Metal path."""
|
||||
monkeypatch.setattr(hardware, "_remote_host", None)
|
||||
monkeypatch.setattr(hardware.platform, "system", lambda: "Darwin")
|
||||
monkeypatch.setattr(hardware.platform, "machine", lambda: "armv7l")
|
||||
monkeypatch.setattr(hardware, "_run", _fake_sysctl())
|
||||
assert hardware._detect_apple_silicon() is None
|
||||
|
||||
|
||||
def test_detect_system_propagates_unified_memory(monkeypatch):
|
||||
"""The unified_memory flag set by GPU detection must survive into the
|
||||
system dict so the API and UI can report it (it was being dropped)."""
|
||||
|
||||
@@ -206,3 +206,33 @@ def test_harmony_analysis_channel_routes_to_thinking(monkeypatch):
|
||||
assert answer == "Here are the files."
|
||||
assert "<|channel|>" not in thinking + answer
|
||||
assert "<|message|>" not in thinking + answer
|
||||
|
||||
|
||||
def test_harmony_commentary_channel_no_marker_or_toolarg_leak(monkeypatch):
|
||||
# gpt-oss commentary channel (tool-call preambles / function-arg bodies) is
|
||||
# internal — it must not leak the channel marker, the `to=functions.*`
|
||||
# recipient, or its body into the visible answer. The `<|channel|>comm` /
|
||||
# `entary` split also exercises the suffix-hold for the new marker.
|
||||
deltas = _run_stream(
|
||||
"gpt-oss:20b",
|
||||
[
|
||||
'data: {"choices":[{"delta":{"content":"<|channel|>comm"}}]}',
|
||||
'data: {"choices":[{"delta":{"content":"entary to=functions.web_search<|message|>Let me search the web."}}]}',
|
||||
'data: {"choices":[{"delta":{"content":"<|end|><|channel|>final<|message|>Here are the "}}]}',
|
||||
'data: {"choices":[{"delta":{"content":"results.<|end|>"}}]}',
|
||||
"data: [DONE]",
|
||||
],
|
||||
monkeypatch,
|
||||
)
|
||||
thinking = "".join(d["delta"] for d in deltas if d.get("thinking"))
|
||||
answer = "".join(d["delta"] for d in deltas if not d.get("thinking"))
|
||||
|
||||
# final channel is the only user-facing text
|
||||
assert answer == "Here are the results."
|
||||
# commentary body routed to thinking, not the visible answer
|
||||
assert thinking == "Let me search the web."
|
||||
# no harmony markers, channel name, or tool recipient leak anywhere
|
||||
assert "<|channel|>" not in thinking + answer
|
||||
assert "<|message|>" not in thinking + answer
|
||||
assert "commentary" not in answer
|
||||
assert "to=functions.web_search" not in thinking + answer
|
||||
|
||||
@@ -105,7 +105,7 @@ def _patch_prefs(monkeypatch, data_dir):
|
||||
"skills_enabled": True,
|
||||
"auto_approve_skills": True,
|
||||
}
|
||||
sys.modules["routes.prefs_routes"] = fake_prefs
|
||||
monkeypatch.setitem(sys.modules, "routes.prefs_routes", fake_prefs)
|
||||
|
||||
# Bust the base-prompt cache so our test re-reads the skill index.
|
||||
from src import agent_loop
|
||||
|
||||
Reference in New Issue
Block a user