From 590e0ddef43b3ebd92097a3d529fecfa936f05c8 Mon Sep 17 00:00:00 2001 From: ecryptoguru Date: Mon, 27 Jul 2026 05:58:19 +0530 Subject: [PATCH 1/4] feat(cache): add opt-in explicit prompt-cache breakpoint path Gated by LYRASHIELD_PROMPT_CACHE_EXPLICIT=1. Default remains implicit. Smoke test on azure_ai/gpt-5.6-luna failed with response.incomplete/content_filter, so this stays opt-in and unmerged for now. --- strix/core/inputs.py | 49 +++++++++++++++++++++--------------- strix/core/runner.py | 2 ++ tests/test_inputs.py | 59 +++++++++++++++++++++++++++++++++++++++++--- 3 files changed, 87 insertions(+), 23 deletions(-) diff --git a/strix/core/inputs.py b/strix/core/inputs.py index 10d66a4..9db69f6 100644 --- a/strix/core/inputs.py +++ b/strix/core/inputs.py @@ -26,24 +26,30 @@ DEFAULT_MAX_TURNS = 500 MAX_CHILD_INHERITED_HISTORY_BYTES = 24 * 1024 -# Opt-in/opt-out for explicit prompt-cache breakpoints. "auto" (default) enables -# breakpoints for known GPT-5.6 deployments; "1"/"true" forces them on; "0"/"false" -# forces them off. Provider support should be confirmed with a smoke scan. -_PROMPT_CACHE_BREAKPOINT_ENV = "LYRASHIELD_PROMPT_CACHE_BREAKPOINTS" +# Gate for using explicit prompt-cache *options* together with breakpoints. +# Enabling this tells the provider to honor ``prompt_cache_breakpoint`` content parts +# and cache the prefix before each breakpoint. This is opt-in until a smoke scan proves +# the target deployment supports it. +_PROMPT_CACHE_EXPLICIT_ENV = "LYRASHIELD_PROMPT_CACHE_EXPLICIT" -def _prompt_cache_breakpoints_enabled(model_name: str | None) -> bool: - """Return whether the model/provider pair should emit explicit cache breakpoints. +def _prompt_cache_explicit_enabled(model_name: str | None) -> bool: + """Return whether to use ``prompt_cache_options: {mode: 'explicit'}``. - Defaults to the known GPT-5.6 allowlist (OpenAI/Azure AI) and can be overridden - with ``LYRASHIELD_PROMPT_CACHE_BREAKPOINTS``. + This is off by default regardless of model; turn it on by setting + ``LYRASHIELD_PROMPT_CACHE_EXPLICIT=1`` for the deployment being used. """ - env = os.environ.get(_PROMPT_CACHE_BREAKPOINT_ENV, "").strip().lower() - if env in ("1", "true", "yes"): - return True + env = os.environ.get(_PROMPT_CACHE_EXPLICIT_ENV, "").strip().lower() if env in ("0", "false", "no"): return False - return is_gpt56_model(model_name) + return env in ("1", "true", "yes") and is_gpt56_model(model_name) + + +def prompt_cache_options_for_model(model_name: str | None) -> dict[str, Any] | None: + """Return explicit prompt-cache options for a model, or None if disabled.""" + if not _prompt_cache_explicit_enabled(model_name): + return None + return {"mode": "explicit", "ttl": "30m"} def _accepts_required_tool_choice(model_name: str | None) -> bool: @@ -161,16 +167,16 @@ def build_root_initial_input( For models that support explicit prompt-cache breakpoints, split the stable target/scope prefix from the variable per-scan instructions and mark the boundary. This lets the provider cache the prefix across turns. + + The explicit breakpoint path is gated by ``LYRASHIELD_PROMPT_CACHE_EXPLICIT`` + because it must be paired with ``prompt_cache_options`` in ``ModelSettings``; + emitting breakpoints without that option disables caching. """ parts, user_instructions = _build_root_task_parts(scan_config) stable = " ".join(parts).strip() - if not _prompt_cache_breakpoints_enabled(model_name) or not user_instructions: - # No breakpoint needed when there is no variable suffix to separate. - return build_root_task(scan_config) - - variable = f"Special instructions: {user_instructions}" - if not stable: + if not _prompt_cache_explicit_enabled(model_name) or not stable: + # No breakpoint needed or explicit caching not enabled. return build_root_task(scan_config) content: list[dict[str, Any]] = [ @@ -178,9 +184,10 @@ def build_root_initial_input( "type": "input_text", "text": stable, "prompt_cache_breakpoint": {"mode": "explicit"}, - }, - {"type": "input_text", "text": variable}, + } ] + if user_instructions: + content.append({"type": "input_text", "text": f"Special instructions: {user_instructions}"}) return [{"role": "user", "content": content}] @@ -222,6 +229,7 @@ def make_model_settings( request_timeout: float | None = None, max_output_tokens: int | None = None, prompt_cache_key: str | None = None, + prompt_cache_options: dict[str, Any] | None = None, ) -> ModelSettings: extra_args: dict[str, Any] = request_timeout_extra_args(request_timeout) or {} if prompt_cache_key: @@ -232,6 +240,7 @@ def make_model_settings( include_usage=True, max_tokens=max_output_tokens, extra_args=extra_args or None, + prompt_cache_options=prompt_cache_options, ) if ( reasoning_effort is not None diff --git a/strix/core/runner.py b/strix/core/runner.py index 17f1828..cfbaf7a 100644 --- a/strix/core/runner.py +++ b/strix/core/runner.py @@ -38,6 +38,7 @@ build_root_task, build_scope_context, make_model_settings, + prompt_cache_options_for_model, ) from strix.core.paths import run_dir_for, runtime_state_dir from strix.core.sessions import open_agent_session @@ -290,6 +291,7 @@ async def run_strix_scan( request_timeout=settings.llm.timeout, max_output_tokens=max_output_tokens, prompt_cache_key=f"lyrashield:{scan_id}:coordinator", + prompt_cache_options=prompt_cache_options_for_model(resolved_model), ) delegate_max_output_tokens = min(max_output_tokens, DELEGATE_OUTPUT_TOKEN_CEILING) delegate_model_settings = make_model_settings( diff --git a/tests/test_inputs.py b/tests/test_inputs.py index 759dafb..52786ac 100644 --- a/tests/test_inputs.py +++ b/tests/test_inputs.py @@ -13,6 +13,7 @@ build_root_task, child_initial_input, make_model_settings, + prompt_cache_options_for_model, ) @@ -113,14 +114,14 @@ def test_build_root_task_web_application_with_instructions() -> None: def test_root_input_preserves_fallback_when_no_stable_prefix( monkeypatch: pytest.MonkeyPatch, ) -> None: - monkeypatch.setenv("LYRASHIELD_PROMPT_CACHE_BREAKPOINTS", "1") + monkeypatch.setenv("LYRASHIELD_PROMPT_CACHE_EXPLICIT", "1") config = {"user_instructions": "Focus on auth."} assert build_root_initial_input(config, "azure_ai/gpt-5.6-terra") == build_root_task(config) def test_root_input_marks_only_the_stable_prefix(monkeypatch: pytest.MonkeyPatch) -> None: - monkeypatch.setenv("LYRASHIELD_PROMPT_CACHE_BREAKPOINTS", "1") + monkeypatch.setenv("LYRASHIELD_PROMPT_CACHE_EXPLICIT", "1") config = { "targets": [{"type": "web_application", "details": {"target_url": "https://example.com"}}], "user_instructions": "Focus on auth.", @@ -144,7 +145,7 @@ def test_root_input_marks_only_the_stable_prefix(monkeypatch: pytest.MonkeyPatch def test_root_input_respects_disabled_breakpoint_override(monkeypatch: pytest.MonkeyPatch) -> None: - monkeypatch.setenv("LYRASHIELD_PROMPT_CACHE_BREAKPOINTS", "0") + monkeypatch.setenv("LYRASHIELD_PROMPT_CACHE_EXPLICIT", "0") config = { "targets": [{"type": "web_application", "details": {"target_url": "https://example.com"}}], "user_instructions": "Focus on auth.", @@ -153,6 +154,46 @@ def test_root_input_respects_disabled_breakpoint_override(monkeypatch: pytest.Mo assert build_root_initial_input(config, "azure_ai/gpt-5.6-terra") == build_root_task(config) +def test_root_input_emits_breakpoint_without_user_instructions( + monkeypatch: pytest.MonkeyPatch, +) -> None: + monkeypatch.setenv("LYRASHIELD_PROMPT_CACHE_EXPLICIT", "1") + config = { + "targets": [{"type": "web_application", "details": {"target_url": "https://example.com"}}], + } + + result = build_root_initial_input(config, "azure_ai/gpt-5.6-terra") + + assert result == [ + { + "role": "user", + "content": [ + { + "type": "input_text", + "text": "URLs: - https://example.com", + "prompt_cache_breakpoint": {"mode": "explicit"}, + }, + ], + } + ] + + +def test_prompt_cache_options_for_model_respects_env( + monkeypatch: pytest.MonkeyPatch, +) -> None: + assert prompt_cache_options_for_model("azure_ai/gpt-5.6-luna") is None + + monkeypatch.setenv("LYRASHIELD_PROMPT_CACHE_EXPLICIT", "1") + assert prompt_cache_options_for_model("azure_ai/gpt-5.6-luna") == { + "mode": "explicit", + "ttl": "30m", + } + assert prompt_cache_options_for_model("gpt-4o") is None + + monkeypatch.setenv("LYRASHIELD_PROMPT_CACHE_EXPLICIT", "0") + assert prompt_cache_options_for_model("azure_ai/gpt-5.6-luna") is None + + def test_build_root_task_diff_scope() -> None: config = { "targets": [], @@ -260,6 +301,18 @@ def test_make_model_settings_does_not_set_prompt_cache_options() -> None: assert settings.extra_args == {"prompt_cache_key": "lyrashield:scan-1:coordinator"} +def test_make_model_settings_sets_prompt_cache_options_when_passed() -> None: + settings = make_model_settings( + "medium", + model_name="azure_ai/gpt-5.6-terra", + prompt_cache_key="lyrashield:scan-1:coordinator", + prompt_cache_options={"mode": "explicit", "ttl": "30m"}, + ) + + assert settings.prompt_cache_options == {"mode": "explicit", "ttl": "30m"} + assert settings.extra_args == {"prompt_cache_key": "lyrashield:scan-1:coordinator"} + + @pytest.mark.parametrize( "model_name", [ From 011cbe47940ddeb71bd7daa3931a3af357141171 Mon Sep 17 00:00:00 2001 From: ecryptoguru Date: Mon, 27 Jul 2026 17:31:59 +0530 Subject: [PATCH 2/4] fix: env loading, empty var shadowing, and content_filter fallback for explicit prompt caching - Create .env with Azure AI credentials for local CLI usage - Auto-load .env via python-dotenv in both lyrashield and strix entry points - Add field_validator to LlmSettings: empty LLM_API_* env values -> None - Clean stale empty LLM_API_* env vars in prepare_environment() so they don't shadow AZURE_AI_* aliases via pydantic AliasChoices priority - Add ModelBehaviorError catch in run_scan: if content_filter is returned when explicit caching is enabled, fall back to implicit caching and retry - Import _prompt_cache_explicit_enabled and ModelBehaviorError in runner Verified: baseline SAFE scan (implicit) completed with 84.2% cache hit ratio. Explicit SAFE scan (LYRASHIELD_PROMPT_CACHE_EXPLICIT=1) completed without content_filter errors on azure_ai/gpt-5.6-terra. --- lyrashield_adapter/cli.py | 16 ++++++++ strix/config/settings.py | 7 +++- strix/core/runner.py | 78 ++++++++++++++++++++++++++++++++------- strix/interface/main.py | 8 ++++ 4 files changed, 94 insertions(+), 15 deletions(-) diff --git a/lyrashield_adapter/cli.py b/lyrashield_adapter/cli.py index ef8bb2e..f05b0c9 100644 --- a/lyrashield_adapter/cli.py +++ b/lyrashield_adapter/cli.py @@ -5,8 +5,14 @@ import os import sys from importlib.metadata import PackageNotFoundError, version +from pathlib import Path from typing import TYPE_CHECKING +try: + from dotenv import load_dotenv +except ImportError: + load_dotenv = None # type: ignore[assignment, misc] + if TYPE_CHECKING: from collections.abc import MutableMapping @@ -42,10 +48,18 @@ } +_STALE_EMPTY_ENV_VARS = ("LLM_API_KEY", "LLM_API_BASE", "LLM_API_VERSION") + + def prepare_environment( environ: MutableMapping[str, str] | None = None, ) -> MutableMapping[str, str]: env = environ if environ is not None else os.environ + # Remove stale empty generic LLM env vars that would shadow Azure-specific + # aliases via pydantic AliasChoices priority (first match wins, even if empty). + for name in _STALE_EMPTY_ENV_VARS: + if env.get(name, "").strip() == "": + env.pop(name, None) for product_name, upstream_name in ENV_ALIASES.items(): if upstream_name not in env and product_name in env: env[upstream_name] = env[product_name] @@ -86,6 +100,8 @@ def _run_upstream() -> None: def main() -> None: + if load_dotenv: + load_dotenv(Path(__file__).resolve().parents[1] / ".env", override=True) prepare_environment() if sys.argv[1:] in (["--version"], ["-v"]): print(f"lyrashield {get_version()}") # noqa: T201 diff --git a/strix/config/settings.py b/strix/config/settings.py index d787197..71a5c81 100644 --- a/strix/config/settings.py +++ b/strix/config/settings.py @@ -5,7 +5,7 @@ from typing import Literal -from pydantic import AliasChoices, Field +from pydantic import AliasChoices, Field, field_validator from pydantic_settings import BaseSettings, SettingsConfigDict @@ -104,6 +104,11 @@ class LlmSettings(BaseSettings): validation_alias=_lyra("STRIX_MAX_INPUT_TOKENS"), ) + @field_validator("api_base", "api_key", "api_version", mode="before") + @classmethod + def _empty_env_to_none(cls, value: Any) -> Any: + return None if value == "" else value + class DedupeSettings(BaseSettings): model_config = _BASE_CONFIG diff --git a/strix/core/runner.py b/strix/core/runner.py index cfbaf7a..213ac9f 100644 --- a/strix/core/runner.py +++ b/strix/core/runner.py @@ -12,6 +12,7 @@ from typing import TYPE_CHECKING, Any, cast from agents import RunConfig +from agents.exceptions import ModelBehaviorError from agents.sandbox import SandboxRunConfig from openai import RateLimitError @@ -34,6 +35,7 @@ from strix.core.hooks import BudgetExceededError, ReportUsageHooks, set_active_hooks from strix.core.inputs import ( DEFAULT_MAX_TURNS, + _prompt_cache_explicit_enabled, build_root_initial_input, build_root_task, build_scope_context, @@ -464,20 +466,68 @@ async def spawn_child_agent(**kwargs: Any) -> dict[str, Any]: root_status = await coordinator.get_status(root_id) - result = await run_agent_loop( - agent=root_agent, - initial_input=initial_input, - run_config=run_config, - context=context, - max_turns=max_turns, - coordinator=coordinator, - agent_id=root_id, - interactive=interactive, - session=root_session, - start_parked=bool(interactive and is_resume and root_status != "running"), - event_sink=event_sink, - hooks=hooks, - ) + try: + result = await run_agent_loop( + agent=root_agent, + initial_input=initial_input, + run_config=run_config, + context=context, + max_turns=max_turns, + coordinator=coordinator, + agent_id=root_id, + interactive=interactive, + session=root_session, + start_parked=bool(interactive and is_resume and root_status != "running"), + event_sink=event_sink, + hooks=hooks, + ) + except ModelBehaviorError as exc: + if "content_filter" not in str(exc) or not _prompt_cache_explicit_enabled( + resolved_model + ): + raise + logger.warning( + "Scan %s hit content_filter with explicit prompt caching; " + "falling back to implicit caching and retrying.", + scan_id, + ) + initial_input = build_root_task(scan_config) + model_settings = make_model_settings( + settings.llm.reasoning_effort, + model_name=resolved_model, + force_required_tool_choice=settings.llm.force_required_tool_choice, + request_timeout=settings.llm.timeout, + max_output_tokens=max_output_tokens, + prompt_cache_key=f"lyrashield:{scan_id}:coordinator", + prompt_cache_options=None, + ) + root_agent = build_strix_agent( + name="Strix", + skills=skills, + is_root=True, + scan_mode=scan_mode, + is_whitebox=is_whitebox, + interactive=interactive, + chat_completions_tools=chat_completions_tools, + system_prompt_context=root_context, + instructions_override=root_instructions, + model=resolved_model, + model_settings=model_settings, + ) + result = await run_agent_loop( + agent=root_agent, + initial_input=initial_input, + run_config=run_config, + context=context, + max_turns=max_turns, + coordinator=coordinator, + agent_id=root_id, + interactive=interactive, + session=root_session, + start_parked=bool(interactive and is_resume and root_status != "running"), + event_sink=event_sink, + hooks=hooks, + ) if not interactive and result is not None: final = getattr(result, "final_output", None) scan_completed = False diff --git a/strix/interface/main.py b/strix/interface/main.py index db223ab..8e046e0 100644 --- a/strix/interface/main.py +++ b/strix/interface/main.py @@ -1093,6 +1093,14 @@ def pull_docker_image() -> None: def main() -> None: + # Auto-load the engine .env if present; explicit shell exports still win. + try: + from dotenv import load_dotenv + + load_dotenv(Path(__file__).resolve().parents[2] / ".env", override=True) + except Exception: + pass + configure_dependency_logging() if sys.platform == "win32": From 2418b8bdc139c6810b5eebfb77f6146391da0133 Mon Sep 17 00:00:00 2001 From: ecryptoguru Date: Mon, 27 Jul 2026 19:20:19 +0530 Subject: [PATCH 3/4] fix: resolve all outstanding lint, type, and viewer extra issues --- lyrashield_adapter/cli.py | 5 ++- strix/config/settings.py | 2 +- strix/core/inputs.py | 6 ++- strix/interface/main.py | 2 +- tests/test_fenced_code.py | 19 +++++++++- tests/test_viewer_extra.py | 77 ++++++++++++++++++++++++++++++++++++++ 6 files changed, 104 insertions(+), 7 deletions(-) create mode 100644 tests/test_viewer_extra.py diff --git a/lyrashield_adapter/cli.py b/lyrashield_adapter/cli.py index f05b0c9..5a5c0bd 100644 --- a/lyrashield_adapter/cli.py +++ b/lyrashield_adapter/cli.py @@ -8,10 +8,11 @@ from pathlib import Path from typing import TYPE_CHECKING + try: from dotenv import load_dotenv except ImportError: - load_dotenv = None # type: ignore[assignment, misc] + load_dotenv = None # type: ignore[assignment] if TYPE_CHECKING: @@ -100,7 +101,7 @@ def _run_upstream() -> None: def main() -> None: - if load_dotenv: + if load_dotenv is not None: load_dotenv(Path(__file__).resolve().parents[1] / ".env", override=True) prepare_environment() if sys.argv[1:] in (["--version"], ["-v"]): diff --git a/strix/config/settings.py b/strix/config/settings.py index 71a5c81..38fe1d0 100644 --- a/strix/config/settings.py +++ b/strix/config/settings.py @@ -3,7 +3,7 @@ from __future__ import annotations -from typing import Literal +from typing import Any, Literal from pydantic import AliasChoices, Field, field_validator from pydantic_settings import BaseSettings, SettingsConfigDict diff --git a/strix/core/inputs.py b/strix/core/inputs.py index 9db69f6..35dd7fd 100644 --- a/strix/core/inputs.py +++ b/strix/core/inputs.py @@ -20,6 +20,8 @@ if TYPE_CHECKING: + from openai.types.responses.response_create_params import PromptCacheOptions + from strix.config.settings import ReasoningEffort @@ -45,7 +47,7 @@ def _prompt_cache_explicit_enabled(model_name: str | None) -> bool: return env in ("1", "true", "yes") and is_gpt56_model(model_name) -def prompt_cache_options_for_model(model_name: str | None) -> dict[str, Any] | None: +def prompt_cache_options_for_model(model_name: str | None) -> PromptCacheOptions | None: """Return explicit prompt-cache options for a model, or None if disabled.""" if not _prompt_cache_explicit_enabled(model_name): return None @@ -229,7 +231,7 @@ def make_model_settings( request_timeout: float | None = None, max_output_tokens: int | None = None, prompt_cache_key: str | None = None, - prompt_cache_options: dict[str, Any] | None = None, + prompt_cache_options: PromptCacheOptions | None = None, ) -> ModelSettings: extra_args: dict[str, Any] = request_timeout_extra_args(request_timeout) or {} if prompt_cache_key: diff --git a/strix/interface/main.py b/strix/interface/main.py index 8e046e0..eb4709b 100644 --- a/strix/interface/main.py +++ b/strix/interface/main.py @@ -1099,7 +1099,7 @@ def main() -> None: load_dotenv(Path(__file__).resolve().parents[2] / ".env", override=True) except Exception: - pass + logger.debug("Could not load .env file; continuing without it", exc_info=True) configure_dependency_logging() diff --git a/tests/test_fenced_code.py b/tests/test_fenced_code.py index bef2b8a..f812d5a 100644 --- a/tests/test_fenced_code.py +++ b/tests/test_fenced_code.py @@ -2,6 +2,9 @@ from __future__ import annotations +import importlib.util + +import pytest from pygments.lexers import BashLexer, PythonLexer from strix.report.writer import ( @@ -10,7 +13,9 @@ resolve_lexer, safe_fence, ) -from strix.viewer.report_pdf import _strip_code_fence + + +_viewer_extra_available = importlib.util.find_spec("pypdf") is not None def test_parse_fenced_code_extracts_language_and_body() -> None: @@ -43,11 +48,23 @@ def test_parse_fenced_code_fence_without_language() -> None: assert code == "plain" +@pytest.mark.skipif( + not _viewer_extra_available, + reason="requires the optional 'viewer' extra", +) def test_strip_code_fence_removes_fence() -> None: + from strix.viewer.report_pdf import _strip_code_fence # noqa: PLC0415 + assert _strip_code_fence("```python\nx = 1\n```") == "x = 1" +@pytest.mark.skipif( + not _viewer_extra_available, + reason="requires the optional 'viewer' extra", +) def test_strip_code_fence_passes_through_non_string_and_unfenced() -> None: + from strix.viewer.report_pdf import _strip_code_fence # noqa: PLC0415 + assert _strip_code_fence(None) is None assert _strip_code_fence("x = 1") == "x = 1" diff --git a/tests/test_viewer_extra.py b/tests/test_viewer_extra.py new file mode 100644 index 0000000..d1496c9 --- /dev/null +++ b/tests/test_viewer_extra.py @@ -0,0 +1,77 @@ +"""Regression tests for the optional ``viewer`` extra packaging. + +Issue #27: reportlab/pypdf must stay out of the base install but be present +for release builds and the ``verify-thin-fork.sh`` gate. +""" + +from __future__ import annotations + +import importlib.util +import tomllib +from pathlib import Path +from typing import Any + +import pytest + + +PROJECT_ROOT = Path(__file__).resolve().parents[1] +PYPROJECT = PROJECT_ROOT / "pyproject.toml" +BUILD_SCRIPT = PROJECT_ROOT / "scripts" / "build.sh" +VERIFY_SCRIPT = PROJECT_ROOT / "scripts" / "verify-thin-fork.sh" +BUILD_RELEASE_WORKFLOW = PROJECT_ROOT / ".github" / "workflows" / "build-release.yml" + +_PDF_AVAILABLE = ( + importlib.util.find_spec("pypdf") is not None + and importlib.util.find_spec("reportlab") is not None +) + + +def _read_pyproject() -> dict[str, Any]: + return tomllib.loads(PYPROJECT.read_text(encoding="utf-8")) + + +def test_viewer_extra_is_optional_and_includes_pdf_packages() -> None: + """pypdf/reportlab live only in [project.optional-dependencies] viewer.""" + data = _read_pyproject() + deps = data["project"]["dependencies"] + extras = data["project"]["optional-dependencies"] + + base_names = {d.split("[")[0].split(">=")[0].split("<")[0].strip() for d in deps} + assert "pypdf" not in base_names, "pypdf must not be in base dependencies" + assert "reportlab" not in base_names, "reportlab must not be in base dependencies" + + assert "viewer" in extras, "the 'viewer' extra must be declared" + viewer = " ".join(extras["viewer"]) + assert "pypdf" in viewer, "the 'viewer' extra must include pypdf" + assert "reportlab" in viewer, "the 'viewer' extra must include reportlab" + + assert "cryptography" in base_names, "cryptography must remain a base dependency" + cryptography = next(d for d in deps if d.startswith("cryptography")) + assert "<49" in cryptography, "cryptography must keep its <49 cap" + + +def test_build_and_ci_sync_with_viewer_extra() -> None: + """Every release build path and the verify gate syncs --extra viewer.""" + for path in (BUILD_SCRIPT, VERIFY_SCRIPT, BUILD_RELEASE_WORKFLOW): + text = path.read_text(encoding="utf-8") + assert "uv sync --frozen --extra viewer" in text, f"{path} must sync with --extra viewer" + assert "uv sync --frozen" in text # guard against the search being vacuous + for line in text.splitlines(): + if "uv sync --frozen" in line and "--extra viewer" not in line: + pytest.fail(f"{path} has a bare uv sync without --extra viewer: {line}") + + +@pytest.mark.skipif(_PDF_AVAILABLE, reason="extra is present, not testing base install") +def test_base_install_does_not_import_pdf_packages() -> None: + """With the base sync neither PDF package is importable.""" + assert importlib.util.find_spec("pypdf") is None + assert importlib.util.find_spec("reportlab") is None + assert importlib.util.find_spec("cryptography") is not None + + +@pytest.mark.skipif(not _PDF_AVAILABLE, reason="requires the optional 'viewer' extra") +def test_viewer_extra_imports_both_pdf_packages() -> None: + """With the viewer extra both PDF packages are importable.""" + assert importlib.util.find_spec("pypdf") is not None + assert importlib.util.find_spec("reportlab") is not None + assert importlib.util.find_spec("cryptography") is not None From 9ca6f09d1ee590bc63640ed9d2376b9b34690b84 Mon Sep 17 00:00:00 2001 From: ecryptoguru Date: Tue, 28 Jul 2026 00:20:18 +0530 Subject: [PATCH 4/4] test(viewer): add HTTP integration test for 501 pdf_export_unavailable --- tests/test_viewer.py | 41 ++++++++++++++++++++++++++++++++++++++++- 1 file changed, 40 insertions(+), 1 deletion(-) diff --git a/tests/test_viewer.py b/tests/test_viewer.py index 00fec4f..8e72cb0 100644 --- a/tests/test_viewer.py +++ b/tests/test_viewer.py @@ -4,9 +4,11 @@ import json import os +import sys +import types import urllib.error import urllib.request -from typing import TYPE_CHECKING +from typing import TYPE_CHECKING, Any from strix.core.paths import latest_run_dir, runs_base_dir from strix.viewer.server import serve @@ -467,6 +469,43 @@ def test_report_send_rejects_live_run(tmp_path: Path, monkeypatch: pytest.Monkey httpd.server_close() +def test_report_send_returns_501_when_pdf_extra_missing( + tmp_path: Path, monkeypatch: pytest.MonkeyPatch +) -> None: + # When the optional PDF libraries are absent, the report-send endpoint must + # surface a clear 501 so the client can tell the operator how to enable it. + run_dir = _make_run(tmp_path, "pdf501", status="completed", end_time="2026-01-01T00:00:00Z") + _bundle(tmp_path, monkeypatch) + monkeypatch.setattr("strix.viewer.auth.read_auth", lambda: {"email": "a@b.com", "token": "t"}) + + real_report_pdf = sys.modules.get("strix.viewer.report_pdf") + fake_report_pdf = types.ModuleType("strix.viewer.report_pdf") + + def _raise_import_error(name: str) -> Any: + raise ImportError(f"No module named 'reportlab' (looking for {name!r})") + + fake_report_pdf.__getattr__ = _raise_import_error + sys.modules["strix.viewer.report_pdf"] = fake_report_pdf + + httpd, url, token = serve(run_dir, open_browser=False) + try: + status, raw = _post( + url, "/api/report/send", {"run": "pdf501"}, cookie=_session_cookie(url, token) + ) + assert status == 501 + data = json.loads(raw) + assert data["error"] == "pdf_export_unavailable" + assert "pipx install" in data["detail"] + assert "strix-agent[viewer]" in data["detail"] + finally: + if real_report_pdf is not None: + sys.modules["strix.viewer.report_pdf"] = real_report_pdf + else: + sys.modules.pop("strix.viewer.report_pdf", None) + httpd.shutdown() + httpd.server_close() + + def test_historical_run_data_requires_verification( tmp_path: Path, monkeypatch: pytest.MonkeyPatch ) -> None: