diff --git a/solstone/convey/__init__.py b/solstone/convey/__init__.py index fd8d499bc..7657bd829 100644 --- a/solstone/convey/__init__.py +++ b/solstone/convey/__init__.py @@ -7,27 +7,10 @@ from __future__ import annotations import os from datetime import timedelta +from typing import TYPE_CHECKING -from flask import Flask, g, request -from jinja2 import ChoiceLoader, FileSystemLoader - -from solstone.apps import AppRegistry -from solstone.convey.secure_listener import ConveyIdentity -from solstone.think.journal_config import write_journal_config -from solstone.think.utils import ensure_journal_config - -from . import state, system -from .apps import register_app_context -from .bridge import emit -from .chat import chat_bp, start_chat_runtime -from .config import bp as config_bp -from .health import bp as health_bp -from .ledger import bp as ledger_bp -from .profile import bp as profile_bp -from .profile import profiles_bp -from .request_id import install_request_id_stamper -from .root import bp as root_bp -from .services_scout import bp as services_scout_bp +if TYPE_CHECKING: + from flask import Flask __all__ = [ "create_app", @@ -35,8 +18,23 @@ __all__ = [ ] +def __getattr__(name: str): + # PEP 562: resolve `emit` lazily so a bare `import solstone.convey.state` + # does not drag bridge/callosum (and the rest of the web stack) into + # sys.modules. The AttributeError for every other name is load-bearing: + # it lets `from solstone.convey import ` fall through to + # normal submodule import. + if name == "emit": + from .bridge import emit + + return emit + raise AttributeError(f"module {__name__!r} has no attribute {name!r}") + + def _get_or_create_secret() -> str: """Load convey.secret from journal.json, generating one if absent.""" + from solstone.think.utils import ensure_journal_config + config = ensure_journal_config() return config["convey"]["secret"] @@ -45,6 +43,7 @@ def _migrate_password_hash() -> None: """Migrate plaintext convey.password to hashed password_hash.""" from werkzeug.security import generate_password_hash + from solstone.think.journal_config import write_journal_config from solstone.think.utils import get_config config = get_config() @@ -69,6 +68,7 @@ def _migrate_setup_completed() -> None: now writes all config atomically in init_finalize(), so this path is only reached for pre-existing journals. """ + from solstone.think.journal_config import write_journal_config from solstone.think.utils import get_config config = get_config() @@ -87,6 +87,10 @@ def _migrate_setup_completed() -> None: def install_identity_stamper(app: Flask) -> None: + from flask import g, request + + from solstone.convey.secure_listener import ConveyIdentity + @app.before_request def _stamp_identity() -> None: stamped = request.environ.get("pl.identity") @@ -104,11 +108,26 @@ def install_identity_stamper(app: Flask) -> None: def create_app(journal: str = "") -> Flask: """Create and configure the Convey Flask application.""" + from flask import Flask + from jinja2 import ChoiceLoader, FileSystemLoader + + from solstone.apps import AppRegistry from solstone.think.link.runtime import start_link_runtime from solstone.think.push.runtime import start_push_runtime from solstone.think.voice.runtime import start_voice_runtime + from . import state, system + from .apps import register_app_context + from .chat import chat_bp, start_chat_runtime + from .config import bp as config_bp + from .health import bp as health_bp + from .ledger import bp as ledger_bp + from .profile import bp as profile_bp + from .profile import profiles_bp from .push import push_bp + from .request_id import install_request_id_stamper + from .root import bp as root_bp + from .services_scout import bp as services_scout_bp from .voice import voice_bp app = Flask( diff --git a/tests/link/certless_helpers.py b/tests/link/certless_helpers.py index 9320696aa..604dd17a0 100644 --- a/tests/link/certless_helpers.py +++ b/tests/link/certless_helpers.py @@ -57,11 +57,12 @@ def make_convey_app( monkeypatch.setenv("SOLSTONE_JOURNAL", str(journal)) import solstone.convey as convey + import solstone.convey.chat as convey_chat import solstone.think.link.runtime as link_runtime import solstone.think.push.runtime as push_runtime import solstone.think.voice.runtime as voice_runtime - monkeypatch.setattr(convey, "start_chat_runtime", lambda _app: None) + monkeypatch.setattr(convey_chat, "start_chat_runtime", lambda _app: None) monkeypatch.setattr(link_runtime, "start_link_runtime", lambda _app: None) monkeypatch.setattr(push_runtime, "start_push_runtime", lambda _app: None) monkeypatch.setattr(voice_runtime, "start_voice_runtime", lambda _app: None) diff --git a/tests/test_convey_lazy_imports.py b/tests/test_convey_lazy_imports.py new file mode 100644 index 000000000..0b28c0cdf --- /dev/null +++ b/tests/test_convey_lazy_imports.py @@ -0,0 +1,90 @@ +# SPDX-License-Identifier: AGPL-3.0-only +# Copyright (c) 2026 sol pbc + +"""Import-firewall regression for the convey app-factory refactor. + +A bare `import solstone.convey.state` (or `.config`) must not drag the web +stack (AppRegistry, provider_readiness) or any provider SDK into sys.modules — +every heavy import now lives inside create_app(). A fresh interpreter per probe +gives a pristine sys.modules unaffected by whatever else ran on this xdist +worker, so the guard measures the real static import graph. +""" + +import json +import subprocess +import sys + +import pytest + +FORBIDDEN = { + "solstone.apps", + "solstone.convey.apps", + "solstone.convey.provider_readiness", + "google.genai", + "openai", + "anthropic", +} + +PROBE_STATE = """ +import json +import sys +import solstone.convey.state # noqa: F401 +print("MODULES_JSON:" + json.dumps(sorted(sys.modules))) +""" + +PROBE_CONFIG = """ +import json +import sys +import solstone.convey.config # noqa: F401 +print("MODULES_JSON:" + json.dumps(sorted(sys.modules))) +""" + + +def _probe_modules(probe: str) -> set[str]: + result = subprocess.run( + [sys.executable, "-c", probe], + capture_output=True, + text=True, + timeout=90, + ) + assert result.returncode == 0, ( + f"probe subprocess exited {result.returncode}\n" + f"--- stdout ---\n{result.stdout}\n--- stderr ---\n{result.stderr}" + ) + sentinel = [ + line for line in result.stdout.splitlines() if line.startswith("MODULES_JSON:") + ] + assert len(sentinel) == 1, ( + f"expected exactly one MODULES_JSON line, got {len(sentinel)}\n" + f"--- stdout ---\n{result.stdout}\n--- stderr ---\n{result.stderr}" + ) + return set(json.loads(sentinel[0][len("MODULES_JSON:") :])) + + +@pytest.mark.timeout(120) +def test_convey_state_import_stays_light(): + modules = _probe_modules(PROBE_STATE) + assert "solstone.convey.state" in modules + leaked = FORBIDDEN & modules + assert not leaked, f"convey.state pulled in forbidden modules: {sorted(leaked)}" + + +@pytest.mark.timeout(120) +def test_convey_config_import_stays_light(): + modules = _probe_modules(PROBE_CONFIG) + assert "solstone.convey.config" in modules + # config legitimately imports flask; that is permitted. + assert "flask" in modules + leaked = FORBIDDEN & modules + assert not leaked, f"convey.config pulled in forbidden modules: {sorted(leaked)}" + + +def test_emit_resolves_via_module_getattr(): + # Exercises __getattr__ via both the from-import and attribute-access paths. + from solstone.convey import emit + + assert callable(emit) + + import solstone.convey as convey + + assert callable(convey.emit) diff --git a/tests/test_system_status.py b/tests/test_system_status.py index 5f8169296..bbade0342 100644 --- a/tests/test_system_status.py +++ b/tests/test_system_status.py @@ -14,11 +14,9 @@ ROOT = Path(__file__).resolve().parents[1] if str(ROOT) not in sys.path: sys.path.insert(0, str(ROOT)) -import solstone.convey as convey +import solstone.convey.system as system_mod from solstone.convey import create_app -system_mod = convey.system - @pytest.fixture(autouse=True) def _temp_journal(monkeypatch, tmp_path):