diff --git a/.env.example b/.env.example index 0f4dcd449..e96dd151c 100644 --- a/.env.example +++ b/.env.example @@ -169,6 +169,26 @@ SEARXNG_INSTANCE=http://localhost:8080 # ODYSSEUS_STT_MAX_AUDIO_BYTES=26214400 # speech-to-text audio (25 MB) # ODYSSEUS_ICS_MAX_BYTES=10485760 # calendar .ics import (10 MB) +# ============================================================ +# Host Docker access (explicit opt-in) +# ============================================================ +# Default Docker Compose does not mount /var/run/docker.sock. Existing +# Ollama, vLLM, and other OpenAI-compatible endpoints remain usable without it. +# +# Enable this only for intentional Cookbook/local Docker-daemon management. +# Raw socket access is high-trust and can grant broad control over the host +# Docker daemon. Set DOCKER_GID to the host docker group's numeric GID. +# Put these values in .env, or export them before running docker compose. +# COMPOSE_FILE=docker-compose.yml:docker/host-docker.yml +# DOCKER_GID=963 +# docker/host-docker.yml sets this inside the container. Keep it paired +# with the socket overlay; setting it alone is not sufficient. +# ODYSSEUS_ENABLE_HOST_DOCKER=true +# +# Host Docker access can be combined with one GPU overlay: +# COMPOSE_FILE=docker-compose.yml:docker/gpu.nvidia.yml:docker/host-docker.yml +# COMPOSE_FILE=docker-compose.yml:docker/gpu.amd.yml:docker/host-docker.yml + # ============================================================ # GPU support (Docker Compose) # ============================================================ diff --git a/.github/scripts/focused_test_guidance.py b/.github/scripts/focused_test_guidance.py new file mode 100644 index 000000000..1426d35fa --- /dev/null +++ b/.github/scripts/focused_test_guidance.py @@ -0,0 +1,124 @@ +#!/usr/bin/env python3 +"""Report focused pytest guidance for changed paths under tests/.""" + +from __future__ import annotations + +import argparse +import os +import shlex +import subprocess +import sys +from collections.abc import Iterable +from pathlib import PurePosixPath + + +def parse_paths(raw_paths: bytes) -> list[str]: + """Decode the NUL-delimited output of ``git diff --name-only -z``.""" + return [os.fsdecode(path) for path in raw_paths.split(b"\0") if path] + + +def changed_paths_from_merge_base(base_sha: str, head_sha: str) -> list[str]: + """Return changed ``tests/`` paths using GitHub PR three-dot semantics. + + GitHub PR changed files are based on the merge base and the PR head, not a + direct endpoint diff between the current base branch tip and the PR head. + Using the direct endpoint diff can include files changed only on the base + branch when the PR branch is stale. + """ + merge_base = subprocess.check_output( + ["git", "merge-base", base_sha, head_sha], + stderr=subprocess.DEVNULL, + ).strip() + raw_paths = subprocess.check_output( + [ + "git", + "diff", + "--name-only", + "--diff-filter=ACMRT", + "-z", + os.fsdecode(merge_base), + head_sha, + "--", + "tests/", + ], + ) + return parse_paths(raw_paths) + + +def select_test_paths(paths: Iterable[str]) -> list[str]: + """Return unique, repository-relative paths contained by tests/.""" + selected: set[str] = set() + for raw_path in paths: + path = PurePosixPath(raw_path) + if path.is_absolute() or ".." in path.parts: + continue + parts = tuple(part for part in path.parts if part != ".") + if len(parts) >= 2 and parts[0] == "tests": + selected.add(PurePosixPath(*parts).as_posix()) + return sorted(selected) + + +def is_pytest_file(path: str) -> bool: + """Return whether a changed path follows this repository's pytest naming.""" + name = PurePosixPath(path).name + return name.endswith(".py") and ( + name.startswith("test_") or name.endswith("_test.py") + ) + + +def pytest_command(paths: Iterable[str]) -> str: + """Build a copyable pytest command for changed runnable test files.""" + command = ["python3", "-m", "pytest", "-q", *paths] + return shlex.join(command) + + +def format_report(paths: Iterable[str]) -> str: + """Format focused guidance for CI logs and the workflow summary.""" + changed_paths = select_test_paths(paths) + runnable_paths = [path for path in changed_paths if is_pytest_file(path)] + lines = ["## Focused test guidance (report-only)", ""] + if not changed_paths: + lines.append("No changed paths under `tests/`.") + else: + lines.extend(["Changed paths under `tests/`:", ""]) + lines.extend(f"- `{path}`" for path in changed_paths) + lines.extend(["", "Suggested focused validation:", ""]) + if runnable_paths: + lines.append(f"```sh\n{pytest_command(runnable_paths)}\n```") + else: + lines.append("No directly runnable pytest files changed.") + lines.extend( + [ + "", + "This guidance does not infer tests from source changes. " + "Existing blocking CI remains the source of truth.", + ] + ) + return "\n".join(lines) + + +def _parse_args(argv: list[str]) -> argparse.Namespace: + parser = argparse.ArgumentParser( + description="Report focused pytest guidance for changed tests/ paths.", + ) + parser.add_argument("--base-sha", help="Pull request base commit SHA.") + parser.add_argument("--head-sha", help="Pull request head commit SHA.") + return parser.parse_args(argv) + + +def main(argv: list[str] | None = None) -> int: + args = _parse_args(sys.argv[1:] if argv is None else argv) + if bool(args.base_sha) != bool(args.head_sha): + raise SystemExit("--base-sha and --head-sha must be provided together") + + if args.base_sha and args.head_sha: + paths = changed_paths_from_merge_base(args.base_sha, args.head_sha) + else: + paths = parse_paths(sys.stdin.buffer.read()) + + print(format_report(paths)) + return 0 + + +if __name__ == "__main__": + raise SystemExit(main()) diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 787bd9dea..f7d3659e8 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -15,6 +15,60 @@ concurrency: cancel-in-progress: true jobs: + focused-test-guidance: + name: Focused test guidance (report-only) + if: github.event_name == 'pull_request' + runs-on: ubuntu-latest + continue-on-error: true + steps: + - uses: actions/checkout@9c091bb21b7c1c1d1991bb908d89e4e9dddfe3e0 # v7.0.0 + with: + fetch-depth: 0 + persist-credentials: false + - name: Report changed test paths + env: + BASE_SHA: ${{ github.event.pull_request.base.sha }} + HEAD_SHA: ${{ github.event.pull_request.head.sha }} + run: | + report_file="$RUNNER_TEMP/focused-test-guidance.md" + publish_report() { + cat "$report_file" + if [ -n "${GITHUB_STEP_SUMMARY:-}" ]; then + cat "$report_file" >> "$GITHUB_STEP_SUMMARY" || true + fi + return 0 + } + + report_unavailable() { + { + printf '%s\n\n' '## Focused test guidance unavailable (report-only)' + printf '%s\n\n' "$1" + printf '%s\n' 'Existing blocking CI remains the source of truth.' + } > "$report_file" + publish_report + exit 0 + } + + if [ -z "$BASE_SHA" ] || [ -z "$HEAD_SHA" ]; then + report_unavailable "Pull request base/head metadata is missing." + fi + + if ! git cat-file -e "${BASE_SHA}^{commit}" 2>/dev/null; then + report_unavailable "The pull request base commit is unavailable locally." + fi + + if ! git cat-file -e "${HEAD_SHA}^{commit}" 2>/dev/null; then + report_unavailable "The pull request head commit is unavailable locally." + fi + + if ! python3 .github/scripts/focused_test_guidance.py \ + --base-sha "$BASE_SHA" \ + --head-sha "$HEAD_SHA" > "$report_file"; then + report_unavailable "The focused test guidance helper could not produce a report." + fi + + publish_report + python-syntax: name: Python syntax (compileall) runs-on: ubuntu-latest diff --git a/ACKNOWLEDGMENTS.md b/ACKNOWLEDGMENTS.md index fdf55c48a..94092c6ca 100644 --- a/ACKNOWLEDGMENTS.md +++ b/ACKNOWLEDGMENTS.md @@ -86,6 +86,7 @@ Bundled in `static/fonts/`: | [Fira Code](https://github.com/tonsky/FiraCode) | SIL Open Font License 1.1 | Nikita Prokopov & contributors | | [Inter](https://github.com/rsms/inter) | SIL Open Font License 1.1 | Rasmus Andersson | | [GohuFont](https://font.gohu.org/) (`fonts/custom/GohuFont.ttf`) | WTFPL | Hugo Chargois | +| [OpenDyslexic](https://opendyslexic.org/) (`fonts/OpenDyslexic-{Regular,Bold}.woff2`) | SIL Open Font License 1.1 ([`licenses/OpenDyslexic-OFL.txt`](licenses/OpenDyslexic-OFL.txt)) | Abbie Gonzalez | ## Python dependencies diff --git a/Dockerfile b/Dockerfile index bed5e2002..9b305569c 100644 --- a/Dockerfile +++ b/Dockerfile @@ -1,3 +1,14 @@ +# ---- builder: patch + build wheels for Real-ESRGAN's broken-on-3.14 deps ---- +# basicsr/gfpgan/facexlib read their version via exec()+locals()['__version__'], +# which raises KeyError on Python 3.13+ (PEP 667). Build patched wheels here so +# the final image / Cookbook never has to compile the broken sdists. See +# docker/build-realesrgan-wheels.sh for the full rationale. +FROM python:3.14-slim AS realesrgan-wheels +RUN apt-get update && apt-get install -y --no-install-recommends curl \ + && rm -rf /var/lib/apt/lists/* +COPY docker/build-realesrgan-wheels.sh /usr/local/bin/build-realesrgan-wheels.sh +RUN bash /usr/local/bin/build-realesrgan-wheels.sh /wheels + FROM python:3.14-slim # System deps. tmux is required by Cookbook for background downloads/serves. @@ -18,8 +29,27 @@ RUN apt-get update && apt-get install -y --no-install-recommends \ tmux \ openssh-client \ gosu \ + libgl1 \ + libglib2.0-0t64 \ + libxcb1 \ + libmagic1 \ && rm -rf /var/lib/apt/lists/* +# libgl1/libglib2.0-0t64/libxcb1 are runtime shared libs (libGL.so.1, +# libglib-2.0/libgthread, libxcb.so.1) that opencv-python (cv2) loads. The +# slim base omits them, so the Cookbook "install realesrgan" path imports cv2 +# and dies with `libxcb.so.1: cannot open shared object file` despite a clean +# pip install. Using full opencv-python (not -headless) because basicsr/gfpgan/ +# facexlib/realesrgan all depend on the `opencv-python` distribution by name. +# +# libmagic1 is the shared lib (libmagic.so.1) that python-magic dlopens for +# content-based MIME sniffing in src/upload_handler.py. We install both here +# (libmagic1 + the python-magic wrapper, below) rather than in requirements.txt +# because python-magic resolves libmagic at import time: where the lib is +# absent the import can block or raise, so keeping it image-only avoids +# regressing pip/venv installs on hosts without libmagic. Debian always has the +# lib here, so the import is instant and detection actually works. + # Docker CLI (client only — daemon stays on the host via the # /var/run/docker.sock mount). The Debian `docker.io` package ships # dockerd but not the client binary on slim, so grab the static client @@ -46,6 +76,20 @@ COPY requirements.txt requirements-optional.txt ./ RUN pip install --no-cache-dir -r requirements.txt \ && if [ "$INSTALL_OPTIONAL" = "true" ]; then pip install --no-cache-dir -r requirements-optional.txt; fi +# python-magic powers content-based MIME sniffing in src/upload_handler.py. +# Image-only (not in requirements.txt) because it needs the libmagic1 system +# lib installed above; see the apt note near the top of this stage. +RUN pip install --no-cache-dir python-magic==0.4.27 + +# Pre-install the patched basicsr/gfpgan/facexlib wheels built in the +# realesrgan-wheels stage (--no-deps keeps the image lean — torch & friends are +# pulled only when realesrgan is actually installed). With these dists already +# satisfied, the Cookbook's plain `pip install realesrgan` resolves them from +# wheels instead of rebuilding the sdists that fail on Python 3.14. +COPY --from=realesrgan-wheels /wheels/ /tmp/odysseus-wheels/ +RUN pip install --no-cache-dir --no-deps /tmp/odysseus-wheels/*.whl \ + && rm -rf /tmp/odysseus-wheels + # Copy app code COPY . . diff --git a/ROADMAP.md b/ROADMAP.md index 7c59c1f6a..d29ac5c75 100644 --- a/ROADMAP.md +++ b/ROADMAP.md @@ -12,7 +12,6 @@ the codebase, you are probably right to stay away. and WSL all need coverage. - Integration audit: do integrations even work? Confirm what works, what needs setup docs, and what should be removed or hidden. -- Self-host troubleshooting cookbook. Document the weird 30-second fixes that otherwise become 30-minute searches: Dovecot cleartext auth for local stacks, ntfy Android Instant Delivery for non-ntfy.sh servers, clipboard limits on plain-HTTP Tailscale URLs, Radicale collection URLs, and similar traps. - Cookbook reliability on other computers. This is probably the area most likely to need work across different machines, GPUs, drivers, shells, and Python environments. - Cookbook SGLang support across platforms. Make sure SGLang setup/serve works predictably on Linux, Windows/WSL, macOS where possible, Docker, and common diff --git a/app.py b/app.py index 57d091efd..38c848e0b 100644 --- a/app.py +++ b/app.py @@ -2,6 +2,17 @@ import mimetypes import os import sys +import asyncio +import time + +# On Windows, asyncio.create_subprocess_exec/shell require the ProactorEventLoop. +# When started via `python -m uvicorn` from a terminal, uvicorn sets this +# automatically. But the VS Code debugger (and other non-uvicorn entrypoints) +# use the default SelectorEventLoop, which raises NotImplementedError on any +# subprocess call. Force ProactorEventLoop here so the right loop is always +# used, regardless of how the process is launched. +if sys.platform == "win32": + asyncio.set_event_loop_policy(asyncio.WindowsProactorEventLoopPolicy()) def register_static_mime_types() -> None: @@ -44,7 +55,7 @@ from typing import Dict from contextlib import asynccontextmanager from fastapi import FastAPI, Request, HTTPException -from fastapi.responses import JSONResponse, FileResponse, HTMLResponse +from fastapi.responses import JSONResponse, FileResponse from fastapi.middleware.cors import CORSMiddleware from fastapi.staticfiles import StaticFiles from starlette.middleware.base import BaseHTTPMiddleware @@ -65,7 +76,7 @@ from core.exceptions import ( import bcrypt as _bcrypt -from src.app_helpers import abs_join +from src.app_helpers import abs_join, serve_html_with_nonce from src.generated_images import GENERATED_IMAGE_HEADERS, resolve_generated_image_path from starlette.responses import RedirectResponse @@ -187,7 +198,50 @@ class _RequestTimeoutMiddleware(_BaseHTTPMiddleware): ) +class _InteractiveActivityMiddleware(_BaseHTTPMiddleware): + async def dispatch(self, request, call_next): + from src.interactive_gate import should_track_interactive_request, track_interactive_request + + path = request.url.path or "" + if not should_track_interactive_request(path, request.method): + return await call_next(request) + async def _stop_background(): + try: + await task_scheduler.stop_background_tasks_for_foreground(reason=f"foreground request {request.method} {path}") + except Exception: + logging.getLogger("app.foreground_gate").debug("foreground task stop failed", exc_info=True) + asyncio.create_task(_stop_background()) + async with track_interactive_request(path, request.method): + return await call_next(request) + + +class _SlowRequestLogMiddleware(_BaseHTTPMiddleware): + async def dispatch(self, request, call_next): + start = time.perf_counter() + status = 500 + try: + response = await call_next(request) + status = getattr(response, "status_code", 0) or 0 + return response + finally: + elapsed = time.perf_counter() - start + try: + threshold = float(os.getenv("ODYSSEUS_SLOW_REQUEST_LOG_SECONDS", "0.75") or "0.75") + except Exception: + threshold = 0.75 + if elapsed >= threshold: + logging.getLogger("app.slow_request").warning( + "slow_request method=%s path=%s status=%s elapsed=%.3fs", + request.method, + request.url.path, + status, + elapsed, + ) + + app.add_middleware(_RequestTimeoutMiddleware) +app.add_middleware(_InteractiveActivityMiddleware) +app.add_middleware(_SlowRequestLogMiddleware) # ========= AUTH ========= from routes.auth_routes import setup_auth_routes, SESSION_COOKIE @@ -573,6 +627,20 @@ webhook_manager = WebhookManager(api_key_manager=api_key_manager) auth_router = setup_auth_routes(auth_manager) app.include_router(auth_router) + +@app.post("/api/activity/heartbeat") +async def activity_heartbeat(): + from src.interactive_gate import mark_browser_activity + await mark_browser_activity() + async def _stop_background(): + try: + await task_scheduler.stop_background_tasks_for_foreground(reason="browser heartbeat") + except Exception: + logging.getLogger("app.foreground_gate").debug("heartbeat task stop failed", exc_info=True) + asyncio.create_task(_stop_background()) + return {"ok": True} + + # Uploads from routes.upload_routes import setup_upload_routes upload_router, upload_cleanup_func = setup_upload_routes(upload_handler) @@ -587,14 +655,19 @@ app.include_router(setup_emoji_routes()) # Sessions from routes.session_routes import setup_session_routes session_config = {"REQUEST_TIMEOUT": REQUEST_TIMEOUT, "OPENAI_API_KEY": OPENAI_API_KEY, "SESSIONS_FILE": SESSIONS_FILE} -app.include_router(setup_session_routes(session_manager, session_config, webhook_manager=webhook_manager)) +app.include_router(setup_session_routes( + session_manager, + session_config, + webhook_manager=webhook_manager, + upload_handler=upload_handler, +)) # Admin Danger Zone wipes (Settings → System → Danger Zone) from routes.admin_wipe_routes import setup_admin_wipe_routes app.include_router(setup_admin_wipe_routes(session_manager)) # Memory -from routes.memory_routes import setup_memory_routes +from routes.memory.memory_routes import setup_memory_routes memory_router = setup_memory_routes(memory_manager, session_manager, memory_vector=memory_vector) app.include_router(memory_router) from routes.skills_routes import setup_skills_routes @@ -611,12 +684,12 @@ app.include_router(setup_chat_routes( )) # Research (background deep-research tasks) -from routes.research_routes import setup_research_routes +from routes.research.research_routes import setup_research_routes app.include_router(setup_research_routes(research_handler, session_manager=session_manager)) # History -from routes.history_routes import setup_history_routes -app.include_router(setup_history_routes(session_manager)) +from routes.history.history_routes import setup_history_routes +app.include_router(setup_history_routes(session_manager, upload_handler=upload_handler)) # Search from routes.search_routes import setup_search_routes @@ -675,7 +748,7 @@ from routes.signature_routes import setup_signature_routes app.include_router(setup_signature_routes()) # Gallery (image library) -from routes.gallery_routes import setup_gallery_routes +from routes.gallery.gallery_routes import setup_gallery_routes app.include_router(setup_gallery_routes()) # Persisted image-editor drafts (server-backed projects) @@ -695,7 +768,7 @@ app.include_router(setup_assistant_routes(task_scheduler)) # Calendar (CalDAV) from routes.calendar_routes import setup_calendar_routes -calendar_router = setup_calendar_routes() +calendar_router = setup_calendar_routes(upload_handler=upload_handler) app.include_router(calendar_router) # Shell (user-facing command execution) @@ -758,7 +831,7 @@ logger.info("Webhook & API token routes initialized") # Notes (Google Keep-style notes/todos) from routes.note_routes import setup_note_routes -app.include_router(setup_note_routes(task_scheduler)) +app.include_router(setup_note_routes(task_scheduler, upload_handler=upload_handler)) # Email from routes.email_routes import setup_email_routes @@ -783,7 +856,7 @@ from routes.vault_routes import setup_vault_routes app.include_router(setup_vault_routes()) # Contacts (CardDAV) -from routes.contacts_routes import setup_contacts_routes +from routes.contacts.contacts_routes import setup_contacts_routes app.include_router(setup_contacts_routes()) from companion import setup_companion_routes @@ -791,23 +864,17 @@ app.include_router(setup_companion_routes()) # ========= ROUTES (kept in app.py) ========= -def _serve_html_with_nonce(request: Request, file_path: str) -> HTMLResponse: - """Read an HTML file and inject the CSP nonce into inline - - + + @@ -581,6 +587,7 @@ +
`` blocks entirely, unwraps inline
+```` to its text (so ``git reset`` survives), collapses whitespace, and
+truncates.
+
+The first four tests pin the helper directly; the last three prove it is
+wired into the production path and that the combined generated-query +
+sanitization behaviour holds for all three selection outcomes (generated
+success, LLM exception, empty LLM result).
+
+This is intentionally a narrow interim/defensive fix for #4547; it does not
+replace the generated-query flow from #4557.
+"""
+from src.chat_processor import ChatProcessor, _clean_search_query
+
+
+# ── Unit tests: _clean_search_query ──
+
+
+def test_clean_search_query_removes_fenced_code_blocks():
+ """A fenced code block must be dropped entirely, including the code body
+ and the fences — only the surrounding prose survives."""
+ message = '```python\nprint("hello")\n```\nWhat is the capital of France?'
+
+ result = _clean_search_query(message)
+
+ assert result == "What is the capital of France?"
+ # Guards against the original leak: no fences, no code body.
+ assert "```" not in result
+ assert "print" not in result
+
+
+def test_clean_search_query_preserves_inline_code():
+ """Inline code text is search-relevant and must survive unwrapped; only the
+ backticks are removed. This is the ``git reset`` case the reviewer flagged
+ against the earlier regex approach (which dropped the word entirely)."""
+ message = "Is it a good idea to use `git reset` to undo my changes?"
+
+ result = _clean_search_query(message)
+
+ assert result == "Is it a good idea to use git reset to undo my changes?"
+ assert "git reset" in result
+ assert "`" not in result
+
+
+def test_clean_search_query_collapses_whitespace():
+ """Runs of whitespace (tabs, multiple spaces, newlines) collapse to a single
+ space so the query is a single clean line."""
+ message = "hello\tworld foo\n\n bar"
+
+ result = _clean_search_query(message)
+
+ assert result == "hello world foo bar"
+ assert " " not in result
+ assert "\n" not in result
+ assert "\t" not in result
+
+
+def test_clean_search_query_truncates_long_input():
+ """Long queries are capped at ``max_len`` (default 200) to stay within search
+ API limits; truncation is a strict prefix of the cleaned text."""
+ long_message = "x" * 300
+
+ result_default = _clean_search_query(long_message)
+ result_custom = _clean_search_query(long_message, max_len=10)
+
+ assert len(result_default) == 200
+ assert result_default == "x" * 200
+ assert result_custom == "x" * 10
+
+
+# ── Integration tests: the generated-query + sanitization flow ──
+#
+# These cover the combined behaviour requested in review of #4863 after #4557
+# landed: the LLM-generated query is used on success, the first-line fallback is
+# used when the LLM fails or returns empty, and in every case the *final* query
+# handed to comprehensive_web_search() is sanitized.
+
+# A messy user message whose first non-empty line (the #4557 fallback) is
+# inline-code prose followed by a fenced block. After sanitization the fallback
+# collapses to plain prose.
+_MESSY = 'Is `git reset` safe?\n```python\nprint("leaked body")\n```'
+_SANITIZED_FALLBACK = "Is git reset safe?"
+
+
+class _Session:
+ """Minimal stand-in for the session object read by the generated-query
+ flow (endpoint_url / model / headers)."""
+
+ endpoint_url = "http://example.local/v1"
+ model = "test-model"
+ headers = {"Authorization": "Bearer test"}
+
+
+class _Memory:
+ def load(self, owner=None):
+ return []
+
+
+class _Docs:
+ rag_manager = None
+
+
+def _patch_flow(monkeypatch, llm_behaviour, captured):
+ """Wire both seams of the generated-query flow: the LLM call and the
+ search call. ``llm_behaviour`` is either a string to return or an Exception
+ instance to raise."""
+
+ def _fake_search(query, *args, **kwargs):
+ captured["query"] = query
+ captured["kwargs"] = kwargs
+ return ("web context", [{"title": "src"}])
+
+ def _fake_llm(*args, **kwargs):
+ if isinstance(llm_behaviour, Exception):
+ raise llm_behaviour
+ return llm_behaviour
+
+ monkeypatch.setattr("src.chat_processor.comprehensive_web_search", _fake_search)
+ monkeypatch.setattr("src.llm_core.llm_call", _fake_llm)
+
+
+def test_generated_query_is_used_and_sanitized(monkeypatch):
+ """Requirement: on LLM success the generated query wins, and the *final*
+ query handed to comprehensive_web_search() is sanitized.
+
+ The fake LLM returns a query containing inline-code markdown so we can also
+ prove the sanitizer runs on the generated path (not just the fallback)."""
+ captured = {}
+ _patch_flow(monkeypatch, "capital of `France`", captured)
+
+ processor = ChatProcessor(memory_manager=_Memory(), personal_docs_manager=_Docs())
+ preface, _, _ = processor.build_context_preface(
+ message=_MESSY,
+ session=_Session(),
+ use_web=True,
+ use_memory=False,
+ use_rag=False,
+ )
+
+ assert "query" in captured, "comprehensive_web_search was not called"
+
+ # The generated query won (not the sanitized first-line fallback) ...
+ assert captured["query"] == "capital of France"
+ assert captured["query"] != _SANITIZED_FALLBACK
+ # ... and it was sanitized: no residual markdown fences/backticks.
+ assert "`" not in captured["query"]
+ assert "```" not in captured["query"]
+
+ # The other call-site kwargs (return_sources) are still forwarded.
+ assert captured["kwargs"].get("return_sources") is True
+ # And the retrieved context was still appended to the preface.
+ assert any("web context" in (msg.get("content") or "") for msg in preface)
+
+
+def test_falls_back_to_sanitized_first_line_when_llm_raises(monkeypatch):
+ """Requirement: when the LLM call raises, #4557's fallback (first non-empty
+ line) is used — and that fallback is sanitized before the search call."""
+ captured = {}
+ _patch_flow(monkeypatch, RuntimeError("LLM endpoint down"), captured)
+
+ processor = ChatProcessor(memory_manager=_Memory(), personal_docs_manager=_Docs())
+ processor.build_context_preface(
+ message=_MESSY,
+ session=_Session(),
+ use_web=True,
+ use_memory=False,
+ use_rag=False,
+ )
+
+ assert "query" in captured, "comprehensive_web_search was not called"
+ # Fallback was the first line ("Is `git reset` safe?"), sanitized.
+ assert captured["query"] == _SANITIZED_FALLBACK
+ assert "git reset" in captured["query"] # inline code preserved
+ assert "`" not in captured["query"] # backticks stripped
+ # The fenced body from later lines never reached the query.
+ assert "leaked body" not in captured["query"]
+
+
+def test_falls_back_to_sanitized_first_line_when_llm_returns_empty(monkeypatch):
+ """Requirement: when the LLM returns an empty/whitespace-only query, #4557
+ falls back — and that fallback is sanitized before the search call."""
+ captured = {}
+ _patch_flow(monkeypatch, " ", captured)
+
+ processor = ChatProcessor(memory_manager=_Memory(), personal_docs_manager=_Docs())
+ processor.build_context_preface(
+ message=_MESSY,
+ session=_Session(),
+ use_web=True,
+ use_memory=False,
+ use_rag=False,
+ )
+
+ assert "query" in captured, "comprehensive_web_search was not called"
+ assert captured["query"] == _SANITIZED_FALLBACK
+ assert "git reset" in captured["query"]
+ assert "`" not in captured["query"]
diff --git a/tests/test_webhook_dns_rebinding_pin.py b/tests/test_webhook_dns_rebinding_pin.py
new file mode 100644
index 000000000..144a19e96
--- /dev/null
+++ b/tests/test_webhook_dns_rebinding_pin.py
@@ -0,0 +1,156 @@
+"""Regression: webhook delivery must pin the TCP connect to the SSRF-approved IP.
+
+validate_webhook_url resolves the host to accept/reject, but the delivery
+connect previously re-resolved independently — a DNS record flipping between
+the two lookups (rebinding) could slip an internal IP past the check. _deliver
+now resolves+validates once via _validated_public_ips and pins the connect to
+that IP through _PinnedAsyncTransport. These tests drive the real transport
+against local servers so the pin is exercised end-to-end, not mocked away.
+"""
+import asyncio
+import http.server
+import ipaddress
+import socketserver
+import threading
+
+import pytest
+
+from tests.helpers.import_state import clear_module, preserve_import_state
+
+import os
+import sys
+from unittest.mock import patch
+
+with patch.dict(os.environ, {"DATABASE_URL": "sqlite:///:memory:"}), \
+ preserve_import_state("src.database", "core.database"):
+ clear_module("src.database")
+ _core_database = sys.modules.get("core.database")
+ if _core_database is not None and not getattr(_core_database, "__file__", None):
+ del sys.modules["core.database"]
+ import src.webhook_manager as wm
+
+
+# ---------------------------------------------------------------------------
+# _validated_public_ips
+# ---------------------------------------------------------------------------
+
+def test_validated_public_ips_rejects_metadata_literal():
+ with pytest.raises(ValueError):
+ wm._validated_public_ips("http://169.254.169.254/")
+
+
+def test_validated_public_ips_rejects_loopback_literal():
+ with pytest.raises(ValueError):
+ wm._validated_public_ips("http://127.0.0.1/")
+
+
+def test_validated_public_ips_returns_public_literal():
+ ips = wm._validated_public_ips("http://93.184.216.34/")
+ assert ips == [ipaddress.ip_address("93.184.216.34")]
+
+
+def test_validated_public_ips_rejects_hostname_resolving_private(monkeypatch):
+ # Rebinding shape: a hostname that (now) resolves into loopback space.
+ monkeypatch.setattr(wm, "_resolve_hostname_ips",
+ lambda h: [ipaddress.ip_address("127.0.0.1")])
+ with pytest.raises(ValueError):
+ wm._validated_public_ips("http://evil.rebind.example/")
+
+
+# ---------------------------------------------------------------------------
+# End-to-end: the pinned transport actually routes to the pinned IP
+# ---------------------------------------------------------------------------
+
+def _serve(handler):
+ srv = socketserver.TCPServer(("127.0.0.1", 0), handler)
+ port = srv.server_address[1]
+ threading.Thread(target=srv.serve_forever, daemon=True).start()
+ return srv, port
+
+
+def test_pinned_transport_connects_to_pinned_ip():
+ """A request whose URL host is a throwaway hostname is still delivered to
+ the pinned loopback IP — proving the socket destination comes from the pin,
+ not from resolving the URL host."""
+ hits = []
+
+ class _Handler(http.server.BaseHTTPRequestHandler):
+ def do_POST(self): # noqa: N802
+ length = int(self.headers.get("Content-Length", 0))
+ self.rfile.read(length)
+ hits.append(self.path)
+ self.send_response(204)
+ self.end_headers()
+
+ def log_message(self, *a):
+ pass
+
+ srv, port = _serve(_Handler)
+ try:
+ ip = ipaddress.ip_address("127.0.0.1")
+ transport = wm._PinnedAsyncTransport(ip)
+
+ async def go():
+ async with __import__("httpx").AsyncClient(
+ transport=transport, timeout=5, follow_redirects=False,
+ ) as client:
+ # Host "unresolvable.invalid" would never resolve; the pin is
+ # what makes this reach the loopback server on `port`.
+ return await client.post(
+ f"http://unresolvable.invalid:{port}/hook", content=b"{}",
+ )
+
+ resp = asyncio.run(go())
+ assert resp.status_code == 204
+ assert hits == ["/hook"]
+ finally:
+ srv.shutdown()
+
+
+def test_deliver_pins_to_validated_ip_end_to_end(monkeypatch):
+ """Full _deliver path: a hostname that validation resolves to loopback is
+ pinned to loopback and the local server receives the signed POST."""
+ received = {}
+
+ class _Handler(http.server.BaseHTTPRequestHandler):
+ def do_POST(self): # noqa: N802
+ length = int(self.headers.get("Content-Length", 0))
+ received["body"] = self.rfile.read(length)
+ received["event"] = self.headers.get("X-Odysseus-Event")
+ self.send_response(200)
+ self.end_headers()
+
+ def log_message(self, *a):
+ pass
+
+ srv, port = _serve(_Handler)
+
+ class _Query:
+ def filter(self, *a, **k): return self
+ def update(self, values): return None
+
+ class _Db:
+ def query(self, _m): return _Query()
+ def commit(self): pass
+ def rollback(self): pass
+ def close(self): pass
+
+ # Make both the validation resolve and the pin target loopback, and treat
+ # loopback as allowed for this test (production blocks it — here we only
+ # want to prove the pin routes to the validated IP).
+ monkeypatch.setattr(wm, "SessionLocal", lambda: _Db())
+ monkeypatch.setattr(wm, "_is_private_url", lambda url: False)
+ monkeypatch.setattr(wm, "_resolve_hostname_ips",
+ lambda h: [ipaddress.ip_address("127.0.0.1")])
+ monkeypatch.setattr(wm, "_ip_is_private", lambda a: False)
+
+ manager = wm.WebhookManager()
+ try:
+ asyncio.run(manager._deliver(
+ "hook-1", f"http://webhook.test:{port}/cb", "s3cret",
+ "webhook.test", {"ok": True},
+ ))
+ assert received.get("event") == "webhook.test"
+ assert b'"ok": true' in received["body"]
+ finally:
+ srv.shutdown()
diff --git a/tests/test_webhook_ssrf_resilience.py b/tests/test_webhook_ssrf_resilience.py
index e02f17a25..ca82a7595 100644
--- a/tests/test_webhook_ssrf_resilience.py
+++ b/tests/test_webhook_ssrf_resilience.py
@@ -96,26 +96,29 @@ async def test_webhook_delivery_uses_naive_utc_timestamps(monkeypatch):
class _Response:
status_code = 204
- class _Client:
- def __init__(self):
- self.content = ""
-
- async def post(self, _url, content, headers):
- self.content = content
- assert headers["X-Odysseus-Event"] == "webhook.test"
- return _Response()
-
db = _Db()
- client = _Client()
monkeypatch.setattr(wm, "SessionLocal", lambda: db)
manager = wm.WebhookManager()
- await manager._client.aclose()
- manager._client = client
+
+ # Replace the pinned-transport send seam so no real socket is opened. The
+ # public-IP literal below still exercises _validated_public_ips (which pins
+ # the connect); the captured content proves the body/headers are built.
+ captured = {}
+
+ async def _fake_send(url, body, headers, ip):
+ captured["content"] = body
+ captured["ip"] = str(ip)
+ assert headers["X-Odysseus-Event"] == "webhook.test"
+ return _Response()
+
+ monkeypatch.setattr(manager, "_send_request", _fake_send)
await manager._deliver("hook-1", "http://93.184.216.34/", None, "webhook.test", {"ok": True})
- body = json.loads(client.content)
+ # The delivery must have pinned to the literal public IP from the URL.
+ assert captured["ip"] == "93.184.216.34"
+ body = json.loads(captured["content"])
payload_timestamp = datetime.fromisoformat(body["timestamp"])
assert payload_timestamp.tzinfo is None
assert db.updates[0]["last_triggered_at"].tzinfo is None
diff --git a/tests/test_workspace_confine.py b/tests/test_workspace_confine.py
index 81bc7235c..6d90789c9 100644
--- a/tests/test_workspace_confine.py
+++ b/tests/test_workspace_confine.py
@@ -140,6 +140,65 @@ async def test_grep_and_ls_confined_e2e(ws, admin):
assert r["exit_code"] == 1 and "outside the workspace" in r["error"]
+@pytest.mark.asyncio
+async def test_glob_confined_e2e(ws, admin):
+ """glob's literal fast-path must stay inside the workspace. A pattern with
+ ../ or an absolute path outside the root would otherwise leak the existence
+ and full path of arbitrary host files (an oracle), even though read_file
+ blocks reading them."""
+ with open(os.path.join(ws, "found.py"), "w") as f:
+ f.write("x")
+ _, r = await execute_tool_block(_block("glob", json.dumps({"pattern": "found.py"})), owner="a", workspace=ws)
+ assert r["exit_code"] == 0 and "found.py" in r["output"]
+
+ # a secret outside the workspace must not be discoverable via glob
+ outside = tempfile.mkdtemp()
+ secret = os.path.join(outside, "secret.txt")
+ with open(secret, "w") as f:
+ f.write("nope")
+ # An escaping pattern must come back as "No files" (the not-found message),
+ # not as a match that returns the file's path. The not-found message echoes
+ # the pattern the model supplied, so the signal is the absence of a match,
+ # not the absence of the path string.
+ rel = os.path.relpath(secret, os.path.realpath(ws))
+ _, r = await execute_tool_block(_block("glob", json.dumps({"pattern": rel})), owner="a", workspace=ws)
+ assert r["exit_code"] == 0 and "No files" in r["output"] and secret not in r["output"]
+ _, r = await execute_tool_block(_block("glob", json.dumps({"pattern": secret})), owner="a", workspace=ws)
+ assert r["exit_code"] == 0 and "No files" in r["output"]
+
+
+@pytest.mark.asyncio
+async def test_glob_skips_sensitive_files_in_workspace(ws, admin):
+ """glob must not enumerate deny-listed sensitive files that live inside the
+ workspace. read_file/write_file/edit_file refuse them and grep skips them,
+ so glob surfacing their paths is an enumeration oracle for prompt-injection.
+ """
+ with open(os.path.join(ws, "keep.py"), "w") as f:
+ f.write("x")
+ with open(os.path.join(ws, ".env"), "w") as f:
+ f.write("AWS_SECRET=xxx")
+ with open(os.path.join(ws, "id_rsa"), "w") as f: # non-dotfile key at root
+ f.write("KEY")
+ os.makedirs(os.path.join(ws, ".ssh"), exist_ok=True)
+ with open(os.path.join(ws, ".ssh", "authorized_keys"), "w") as f:
+ f.write("ssh-rsa AAAA")
+
+ # A recursive wildcard returns ordinary files but none of the sensitive
+ # ones. The pattern "**/*" contains no secret names, so a secret basename
+ # appearing in the output is a real leak (not the echoed not-found pattern).
+ _, r = await execute_tool_block(_block("glob", json.dumps({"pattern": "**/*"})), owner="a", workspace=ws)
+ assert r["exit_code"] == 0
+ assert "keep.py" in r["output"]
+ for leak in (".env", "id_rsa", "authorized_keys"):
+ assert leak not in r["output"], f"glob leaked sensitive file: {leak}"
+
+ # Directly targeting a sensitive file (literal fast-path and wildcard) must
+ # come back as the not-found message, never a match with the file's path.
+ for pat in (".env", "**/id_rsa", "**/authorized_keys"):
+ _, r = await execute_tool_block(_block("glob", json.dumps({"pattern": pat})), owner="a", workspace=ws)
+ assert r["exit_code"] == 0 and "No files" in r["output"]
+
+
@pytest.mark.asyncio
async def test_subprocess_cwd_is_workspace_e2e(ws, admin):
"""python tool runs with cwd = workspace (OS-agnostic probe)."""