diff --git a/solstone/apps/thinking/call.py b/solstone/apps/thinking/call.py index 3fad76195..a4c6c29c2 100644 --- a/solstone/apps/thinking/call.py +++ b/solstone/apps/thinking/call.py @@ -223,6 +223,16 @@ def scout_status() -> None: _echo_scout_guidance(response.get("state")) +@scout_app.command("check") +@convey_cli +def scout_check() -> None: + """Check Scout hosted Gemini status.""" + + response = _post_scout_action("/app/thinking/api/scout/check") + _echo_json(response) + _echo_scout_guidance(response.get("state")) + + @scout_app.command("enable") @convey_cli def scout_enable( diff --git a/solstone/apps/thinking/copy.py b/solstone/apps/thinking/copy.py index 0034fec3a..31ef83538 100644 --- a/solstone/apps/thinking/copy.py +++ b/solstone/apps/thinking/copy.py @@ -60,6 +60,7 @@ ACTION_LABELS = { "validate": "Validate", "install": "Install", "refresh": "Refresh", + "check": "Check", } SCOUT_STATE_OFF = "off" SCOUT_STATE_REQUESTED = "requested" @@ -82,7 +83,9 @@ SCOUT_STATE_LABELS = { SCOUT_RESTING_GUIDANCE = { SCOUT_STATE_OFF: "Scout is off.", SCOUT_STATE_REQUESTED: "Scout is waiting for approval.", + SCOUT_STATE_INVITED: "Scout is ready.", SCOUT_STATE_ON: "Scout is on; sol pbc keeps a Gemini key on this machine for you.", + SCOUT_STATE_ENDED: "Scout has ended.", SCOUT_STATE_MANUAL_KEY_PRESENT: "A Gemini key you manage is already set.", } SCOUT_MANUAL_KEY_BLOCK_COPY = ( diff --git a/solstone/apps/thinking/routes.py b/solstone/apps/thinking/routes.py index d4189c9e9..71c4ef46f 100644 --- a/solstone/apps/thinking/routes.py +++ b/solstone/apps/thinking/routes.py @@ -421,6 +421,15 @@ def scout_status() -> Any: return _thinking_operation_failed() +@thinking_bp.route("/api/scout/check", methods=["POST"]) +def scout_check() -> Any: + try: + return jsonify({"success": True, **scout_lane.status_payload(force=True)}) + except Exception: + logger.exception("error checking scout status") + return _thinking_operation_failed() + + @thinking_bp.route("/api/scout/enable", methods=["POST"]) def scout_enable() -> Any: try: diff --git a/solstone/apps/thinking/scout_lane.py b/solstone/apps/thinking/scout_lane.py index 487133aea..7eba5ddb2 100644 --- a/solstone/apps/thinking/scout_lane.py +++ b/solstone/apps/thinking/scout_lane.py @@ -8,7 +8,7 @@ from __future__ import annotations from typing import Any from solstone.apps.thinking import copy as thinking_copy -from solstone.think.services import operations +from solstone.think.services import operations, scout from solstone.think.services import status as service_status RESTING_FROM_STATUS = { @@ -16,6 +16,8 @@ RESTING_FROM_STATUS = { "pending": thinking_copy.SCOUT_STATE_REQUESTED, "manual_key": thinking_copy.SCOUT_STATE_MANUAL_KEY_PRESENT, "disabled": thinking_copy.SCOUT_STATE_OFF, + "invited": thinking_copy.SCOUT_STATE_INVITED, + "ended": thinking_copy.SCOUT_STATE_ENDED, } PHASE_TO_PRODUCT = { "starting": thinking_copy.SCOUT_OP_STARTING, @@ -28,8 +30,8 @@ PHASE_TO_PRODUCT = { def resting_state() -> str: - status = service_status.scout_status() - return RESTING_FROM_STATUS[str(status["state"])] + result = scout.update_scout_check() + return RESTING_FROM_STATUS[result.source_state] def resting_guidance(state: str) -> str | None: @@ -38,11 +40,19 @@ def resting_guidance(state: str) -> str | None: def actions_for_state(state: str) -> dict[str, bool]: return { - "enable": state == thinking_copy.SCOUT_STATE_OFF, + "enable": state + in {thinking_copy.SCOUT_STATE_OFF, thinking_copy.SCOUT_STATE_INVITED}, "refresh": state in {thinking_copy.SCOUT_STATE_REQUESTED, thinking_copy.SCOUT_STATE_ON}, "disable": state in {thinking_copy.SCOUT_STATE_REQUESTED, thinking_copy.SCOUT_STATE_ON}, + "check": state + in { + thinking_copy.SCOUT_STATE_OFF, + thinking_copy.SCOUT_STATE_REQUESTED, + thinking_copy.SCOUT_STATE_INVITED, + thinking_copy.SCOUT_STATE_ENDED, + }, } @@ -59,8 +69,9 @@ def remap_operation(raw: dict[str, Any] | None) -> dict[str, Any] | None: return payload -def status_payload() -> dict[str, Any]: - state = resting_state() +def status_payload(*, force: bool = False) -> dict[str, Any]: + result = scout.update_scout_check(force=force) + state = RESTING_FROM_STATUS[result.source_state] return { "service": "scout", "state": state, @@ -68,4 +79,7 @@ def status_payload() -> dict[str, Any]: "provenance": provenance_payload(), "actions": actions_for_state(state), "operation": remap_operation(operations.operation_for_service("scout")), + "checked": result.checked, + "checked_at": result.checked_at, + "check_error": result.check_error, } diff --git a/solstone/apps/thinking/static/thinking.js b/solstone/apps/thinking/static/thinking.js index 9eb1ffcb5..81f3f51ed 100644 --- a/solstone/apps/thinking/static/thinking.js +++ b/solstone/apps/thinking/static/thinking.js @@ -134,6 +134,7 @@ setButtonState('scoutEnable', false, true); setButtonState('scoutRefresh', false, true); setButtonState('scoutDisable', false, true); + setButtonState('scoutCheck', false, true); const switchButton = document.querySelector('#lane-scout [data-switch-lane="scout"]'); if (switchButton) { switchButton.hidden = true; @@ -153,6 +154,7 @@ setButtonState('scoutEnable', !!actions.enable, operationActive || !actions.enable); setButtonState('scoutRefresh', !!actions.refresh, operationActive || !actions.refresh); setButtonState('scoutDisable', !!actions.disable, operationActive || !actions.disable); + setButtonState('scoutCheck', !!actions.check, operationActive || !actions.check); const switchButton = document.querySelector('#lane-scout [data-switch-lane="scout"]'); if (switchButton) { @@ -345,6 +347,11 @@ renderScout(); } + async function checkScout() { + state.scout = await api('api/scout/check', {method: 'POST'}); + renderScout(); + } + async function disableScout() { const result = await api('api/scout/disable', {method: 'POST'}); state.scout = result.status || state.scout; @@ -464,6 +471,7 @@ $('scoutEnable')?.addEventListener('click', () => enableScout().catch((err) => setMessage('scoutLaneOperation', err.message, 'error'))); $('scoutRefresh')?.addEventListener('click', () => refreshScoutOp().catch((err) => setMessage('scoutLaneOperation', err.message, 'error'))); $('scoutDisable')?.addEventListener('click', () => disableScout().catch((err) => setMessage('scoutLaneOperation', err.message, 'error'))); + $('scoutCheck')?.addEventListener('click', () => checkScout().catch((err) => setMessage('scoutLaneOperation', err.message, 'error'))); $('localRefresh')?.addEventListener('click', () => refreshProviders().catch((err) => setMessage('localLaneStatus', err.message, 'error'))); $('localBootstrap')?.addEventListener('click', () => startLocalBootstrap().catch((err) => setMessage('localLaneStatus', err.message, 'error'))); $('localModelSelect')?.addEventListener('change', () => Promise.all([ diff --git a/solstone/apps/thinking/tests/test_scout_lane.py b/solstone/apps/thinking/tests/test_scout_lane.py index 750089ad2..59cfb0dff 100644 --- a/solstone/apps/thinking/tests/test_scout_lane.py +++ b/solstone/apps/thinking/tests/test_scout_lane.py @@ -106,31 +106,31 @@ def test_operation_phase_maps_to_product_phase( [ ( thinking_copy.SCOUT_STATE_OFF, - {"enable": True, "refresh": False, "disable": False}, + {"enable": True, "refresh": False, "disable": False, "check": True}, ), ( thinking_copy.SCOUT_STATE_REQUESTED, - {"enable": False, "refresh": True, "disable": True}, + {"enable": False, "refresh": True, "disable": True, "check": True}, ), ( thinking_copy.SCOUT_STATE_INVITED, - {"enable": False, "refresh": False, "disable": False}, + {"enable": True, "refresh": False, "disable": False, "check": True}, ), ( thinking_copy.SCOUT_STATE_ON, - {"enable": False, "refresh": True, "disable": True}, + {"enable": False, "refresh": True, "disable": True, "check": False}, ), ( thinking_copy.SCOUT_STATE_ENDED, - {"enable": False, "refresh": False, "disable": False}, + {"enable": False, "refresh": False, "disable": False, "check": True}, ), ( thinking_copy.SCOUT_STATE_MANUAL_KEY_PRESENT, - {"enable": False, "refresh": False, "disable": False}, + {"enable": False, "refresh": False, "disable": False, "check": False}, ), ( thinking_copy.SCOUT_STATE_REPAIR_NEEDED, - {"enable": False, "refresh": False, "disable": False}, + {"enable": False, "refresh": False, "disable": False, "check": False}, ), ], ) @@ -171,14 +171,81 @@ def test_manual_key_takes_precedence_over_pending_block(journal_copy: Path) -> N assert scout_lane.resting_state() == thinking_copy.SCOUT_STATE_MANUAL_KEY_PRESENT +@pytest.mark.parametrize( + ("source_state", "product_state"), + [ + ("invited", thinking_copy.SCOUT_STATE_INVITED), + ("ended", thinking_copy.SCOUT_STATE_ENDED), + ], +) +def test_resting_state_maps_live_check_states( + monkeypatch: pytest.MonkeyPatch, + source_state: str, + product_state: str, +) -> None: + monkeypatch.setattr( + scout_lane.scout, + "update_scout_check", + lambda **_kwargs: scout.ScoutCheckResult(source_state, True, "checked", None), + ) + + assert scout_lane.resting_state() == product_state + + +def test_status_payload_uses_single_check_result_and_is_secret_free( + journal_copy: Path, + monkeypatch: pytest.MonkeyPatch, +) -> None: + _clear_scout(journal_copy) + config = _read_config(journal_copy) + config.setdefault("services", {})["scout"] = { + "state": "pending", + "account_id": "acct-secret", + "since": 1770000000000, + "dispatch_token": "dispatch-secret", + "server_status": "approved", + "checked_at": "2026-06-12T00:00:00+00:00", + } + _write_config(config) + calls: list[bool] = [] + + def fake_update(*, force: bool = False) -> scout.ScoutCheckResult: + calls.append(force) + return scout.ScoutCheckResult("invited", True, "checked", None) + + monkeypatch.setattr(scout_lane.scout, "update_scout_check", fake_update) + + payload = scout_lane.status_payload(force=True) + + assert calls == [True] + assert payload["state"] == thinking_copy.SCOUT_STATE_INVITED + assert payload["checked"] is True + assert payload["checked_at"] == "checked" + assert payload["check_error"] is None + serialized = json.dumps(payload).lower() + assert "dispatch_token" not in serialized + assert "dispatch-secret" not in serialized + assert "server_status" not in serialized + assert "acct-secret" not in serialized + + def test_stale_approved_block_without_key_is_off_with_provenance( journal_copy: Path, + monkeypatch: pytest.MonkeyPatch, ) -> None: _clear_scout(journal_copy) scout.provision_scout_handoff(_approved_payload()) config = _read_config(journal_copy) config.setdefault("env", {}).pop("GOOGLE_API_KEY", None) _write_config(config) + monkeypatch.setattr( + scout_lane.scout.portal_client, + "check_scout_status", + lambda _token: scout.portal_client.ScoutStatusOutcome( + kind="failed", + reason="unreachable", + ), + ) assert scout_lane.resting_state() == thinking_copy.SCOUT_STATE_OFF assert scout_lane.provenance_payload()["key_created_at"] == "2026-05-24T00:00:00Z" diff --git a/solstone/apps/thinking/tests/test_scout_routes.py b/solstone/apps/thinking/tests/test_scout_routes.py index 5187074eb..4c2d8effb 100644 --- a/solstone/apps/thinking/tests/test_scout_routes.py +++ b/solstone/apps/thinking/tests/test_scout_routes.py @@ -75,11 +75,20 @@ def test_get_scout_status_fresh_journal_is_secret_free(thinking_client) -> None: assert response.status_code == 200 data = response.get_json() assert data["state"] == thinking_copy.SCOUT_STATE_OFF - assert data["actions"] == {"enable": True, "refresh": False, "disable": False} + assert data["actions"] == { + "enable": True, + "refresh": False, + "disable": False, + "check": True, + } + assert data["checked"] is False + assert data["checked_at"] is None + assert data["check_error"] == "no_credential" assert data["operation"] is None assert data["provenance"] == {} serialized = json.dumps(data).lower() assert "dispatch_token" not in serialized + assert "server_status" not in serialized assert "account_id" not in serialized @@ -91,9 +100,13 @@ def test_get_scout_status_after_provision_is_secret_free(thinking_client) -> Non assert response.status_code == 200 data = response.get_json() assert data["state"] == thinking_copy.SCOUT_STATE_ON + assert data["checked"] is True + assert data["checked_at"] is None + assert data["check_error"] is None assert data["provenance"]["key_created_at"] == "2026-05-24T00:00:00Z" serialized = json.dumps(data).lower() assert "dispatch_token" not in serialized + assert "server_status" not in serialized assert "account_id" not in serialized assert "dispatch-secret" not in serialized assert "acct-secret" not in serialized @@ -219,6 +232,53 @@ def test_disable_preserves_different_manual_key( assert _read_config(journal_copy)["env"]["GOOGLE_API_KEY"] == "manual-key" +def test_check_route_forces_status_payload_without_operation_registry( + thinking_client, + monkeypatch: pytest.MonkeyPatch, +) -> None: + calls: list[bool] = [] + + def fake_status_payload(*, force: bool = False) -> dict: + calls.append(force) + return { + "service": "scout", + "state": thinking_copy.SCOUT_STATE_OFF, + "guidance": "Scout is off.", + "provenance": {}, + "actions": { + "enable": True, + "refresh": False, + "disable": False, + "check": True, + }, + "operation": None, + "checked": False, + "checked_at": None, + "check_error": "no_credential", + } + + monkeypatch.setattr(scout_lane, "status_payload", fake_status_payload) + monkeypatch.setattr( + operations, + "start_operation", + lambda *_args, **_kwargs: (_ for _ in ()).throw( + AssertionError("operation registry should not run") + ), + ) + + response = thinking_client.post("/app/thinking/api/scout/check") + + assert response.status_code == 200 + data = response.get_json() + assert calls == [True] + assert data["success"] is True + assert data["checked"] is False + assert data["check_error"] == "no_credential" + serialized = json.dumps(data).lower() + assert "dispatch_token" not in serialized + assert "server_status" not in serialized + + @pytest.mark.parametrize( ("raw_phase", "expected_phase"), [ @@ -265,6 +325,9 @@ def test_status_payload_key_set(thinking_client) -> None: "provenance", "actions", "operation", + "checked", + "checked_at", + "check_error", } assert set(scout_lane.status_payload()) == { "service", @@ -273,4 +336,7 @@ def test_status_payload_key_set(thinking_client) -> None: "provenance", "actions", "operation", + "checked", + "checked_at", + "check_error", } diff --git a/solstone/apps/thinking/tests/test_workspace_html.py b/solstone/apps/thinking/tests/test_workspace_html.py index 71786f2cd..e2922fcf8 100644 --- a/solstone/apps/thinking/tests/test_workspace_html.py +++ b/solstone/apps/thinking/tests/test_workspace_html.py @@ -33,6 +33,7 @@ def test_workspace_renders_each_lane(settings_env): assert 'id="lane-byo"' in html assert 'id="lane-local"' in html assert 'id="scoutEnable"' in html + assert 'id="scoutCheck"' in html assert 'id="scoutRefresh"' in html assert 'id="scoutDisable"' in html assert 'id="scoutLaneOperation"' in html diff --git a/solstone/apps/thinking/workspace.html b/solstone/apps/thinking/workspace.html index 0259176b8..605423269 100644 --- a/solstone/apps/thinking/workspace.html +++ b/solstone/apps/thinking/workspace.html @@ -207,6 +207,7 @@ + diff --git a/solstone/think/push/portal_dispatch.py b/solstone/think/push/portal_dispatch.py index 9185409df..7880aa988 100644 --- a/solstone/think/push/portal_dispatch.py +++ b/solstone/think/push/portal_dispatch.py @@ -12,7 +12,7 @@ from urllib import request as urllib_request from urllib.error import HTTPError, URLError from solstone.think.services.portal_client import portal_base_url, request_headers -from solstone.think.services.scout import scout_provenance +from solstone.think.services.scout import approved_dispatch_token logger = logging.getLogger(__name__) @@ -20,10 +20,7 @@ _TIMEOUT_SECONDS = 10 def dispatch_via_portal(*, request_id: str, summary: str, category: str) -> dict | None: - scout = scout_provenance() - if not scout: - return None - dispatch_token = scout.get("dispatch_token") + dispatch_token = approved_dispatch_token() if not dispatch_token: return None @@ -90,10 +87,7 @@ def dispatch_via_portal(*, request_id: str, summary: str, category: str) -> dict def dispatch_dedup_via_portal(*, request_id: str, action: str) -> dict | None: - scout = scout_provenance() - if not scout: - return None - dispatch_token = scout.get("dispatch_token") + dispatch_token = approved_dispatch_token() if not dispatch_token: return None diff --git a/solstone/think/push/triggers.py b/solstone/think/push/triggers.py index 54bd9fa71..248f8eb39 100644 --- a/solstone/think/push/triggers.py +++ b/solstone/think/push/triggers.py @@ -43,7 +43,7 @@ from solstone.think.push.portal_dispatch import ( dispatch_dedup_via_portal, dispatch_via_portal, ) -from solstone.think.services.scout import scout_provenance +from solstone.think.services.scout import approved_dispatch_token from solstone.think.utils import get_journal logger = logging.getLogger("solstone.push.triggers") @@ -315,8 +315,7 @@ def handle_sol_chat_request(message: dict[str, Any]) -> None: summary = str(message.get("summary") or "") category = str(message.get("category") or "") - scout = scout_provenance() - if scout and scout.get("dispatch_token"): + if approved_dispatch_token(): portal_result = dispatch_via_portal( request_id=request_id, summary=summary, @@ -388,8 +387,7 @@ def handle_chat_lifecycle(message: dict[str, Any]) -> None: if not request_id: return - scout = scout_provenance() - if scout and scout.get("dispatch_token"): + if approved_dispatch_token(): portal_result = dispatch_dedup_via_portal(request_id=request_id, action=event) if portal_result is not None: _append_nudge_log( diff --git a/solstone/think/services/portal_client.py b/solstone/think/services/portal_client.py index c96e30f6e..c60add109 100644 --- a/solstone/think/services/portal_client.py +++ b/solstone/think/services/portal_client.py @@ -36,6 +36,17 @@ class PollOutcome: detail: str | None = None +@dataclass(frozen=True) +class ScoutStatusOutcome: + kind: str + server_status: str | None = None + reason: str | None = None + detail: str | None = None + + +_SCOUT_STATUS_VALUES = frozenset({"pending", "approved", "revoked"}) + + def mint_nonce() -> str: return "".join(secrets.choice(NONCE_ALPHABET) for _ in range(NONCE_LENGTH_CHARS)) @@ -98,6 +109,70 @@ def read_handoff_payload(raw_body: bytes) -> dict[str, Any]: return payload +def _failed_status(reason: str, detail: str | None = None) -> ScoutStatusOutcome: + return ScoutStatusOutcome(kind="failed", reason=reason, detail=detail) + + +def _read_scout_status(raw_body: bytes) -> str: + if not raw_body: + raise ValueError("scout status response was empty") + try: + payload = json.loads(raw_body.decode("utf-8")) + except (json.JSONDecodeError, UnicodeDecodeError) as exc: + raise ValueError(str(exc)) from exc + if not isinstance(payload, dict): + raise ValueError("scout status payload must be a JSON object") + server_status = payload.get("status") + if server_status not in _SCOUT_STATUS_VALUES: + raise ValueError("scout status payload has unknown status") + return str(server_status) + + +def check_scout_status( + dispatch_token: str, + *, + timeout: float = 10.0, + component: str = "status", +) -> ScoutStatusOutcome: + headers = request_headers(component) + headers["Authorization"] = f"Bearer {dispatch_token}" + request = urllib.request.Request( + f"{portal_base_url()}/account/scout/status", + headers=headers, + method="GET", + ) + try: + with urllib.request.urlopen(request, timeout=timeout) as response: + status = int(getattr(response, "status", response.getcode())) + raw_body = response.read() + except urllib.error.HTTPError as exc: + status = int(exc.code) + if status == 401: + return _failed_status("unauthorized", str(exc)) + if status == 404: + return _failed_status("not_found", str(exc)) + return _failed_status("unreachable", str(exc)) + except ssl.SSLError as exc: + return _failed_status("tls_failed", str(exc)) + except urllib.error.URLError as exc: + if isinstance(exc.reason, ssl.SSLError): + return _failed_status("tls_failed", str(exc.reason)) + return _failed_status("unreachable", str(exc)) + except (socket.timeout, TimeoutError) as exc: + return _failed_status("unreachable", str(exc)) + + if status == 204: + return _failed_status("malformed", "scout status response was empty") + if status != 200: + return _failed_status("unreachable", f"unexpected HTTP status {status}") + + try: + server_status = _read_scout_status(raw_body) + except ValueError as exc: + return _failed_status("malformed", str(exc)) + return ScoutStatusOutcome(kind="ok", server_status=server_status) + + def poll_handoff_once( base_url: str, nonce: str, diff --git a/solstone/think/services/scout.py b/solstone/think/services/scout.py index 9d29a7194..3a6f69fda 100644 --- a/solstone/think/services/scout.py +++ b/solstone/think/services/scout.py @@ -9,6 +9,7 @@ import fcntl import hashlib import logging import os +from collections.abc import Callable from dataclasses import dataclass from datetime import datetime, timezone from pathlib import Path @@ -19,14 +20,21 @@ from solstone.think.journal_config import ( read_journal_config, write_journal_config, ) +from solstone.think.services import portal_client from solstone.think.utils import get_journal log = logging.getLogger(__name__) +STATUS_CHECK_STALENESS_SECONDS = 300 _HANDOFF_FIELDS = ("google_api_key", "dispatch_token", "account_id", "created_at") _SECRET_HANDOFF_FIELDS = frozenset({"google_api_key", "dispatch_token"}) _REDACTED = "***redacted***" KEY_FINGERPRINT_FIELD = "key_fingerprint_sha256" +_SERVER_STATUS_TO_SOURCE_STATE = { + "pending": "pending", + "approved": "invited", + "revoked": "ended", +} def _redact_handoff(payload: dict[str, Any]) -> dict[str, Any]: @@ -46,6 +54,14 @@ class DisableOutcome: env_key_preserved: bool +@dataclass(frozen=True) +class ScoutCheckResult: + source_state: str + checked: bool + checked_at: str | None + check_error: str | None + + def _lock_path() -> Path: return Path(get_journal()) / "config" / ".journal.json.lock" @@ -80,6 +96,53 @@ def _is_approved_provision(block: Any) -> bool: return isinstance(block, dict) and block.get("state") != "pending" +def _now_iso() -> str: + return datetime.now(timezone.utc).isoformat() + + +def _parse_checked_at(value: Any) -> datetime | None: + if not isinstance(value, str) or not value: + return None + try: + parsed = datetime.fromisoformat(value) + except ValueError: + return None + if parsed.tzinfo is None: + parsed = parsed.replace(tzinfo=timezone.utc) + return parsed.astimezone(timezone.utc) + + +def _check_is_fresh(value: Any) -> bool: + checked_at = _parse_checked_at(value) + if checked_at is None: + return False + age = datetime.now(timezone.utc) - checked_at + return 0 <= age.total_seconds() <= STATUS_CHECK_STALENESS_SECONDS + + +def _update_scout_block( + mutate: Callable[[dict[str, Any]], dict[str, Any]], +) -> dict[str, Any]: + _require_journal_config() + + lock_path = _lock_path() + lock_path.parent.mkdir(parents=True, exist_ok=True) + with open(lock_path, "w", encoding="utf-8") as lock_file: + os.chmod(lock_path, 0o600) + fcntl.flock(lock_file, fcntl.LOCK_EX) + try: + _require_journal_config() + config = read_journal_config() + current_raw = config.get("services", {}).get("scout") + current = dict(current_raw) if isinstance(current_raw, dict) else {} + block = mutate(current) + config.setdefault("services", {})["scout"] = block + write_journal_config(config) + return block + finally: + fcntl.flock(lock_file, fcntl.LOCK_UN) + + def provision_scout_handoff(payload: dict[str, Any]) -> None: """Persist a portal-provisioned scout handoff into journal config.""" @@ -112,33 +175,31 @@ def provision_scout_handoff(payload: dict[str, Any]) -> None: fcntl.flock(lock_file, fcntl.LOCK_UN) -def record_scout_pending(account_id: str, since: Any) -> None: - """Store a pending scout-approval marker (no key or dispatch token written).""" +def record_scout_pending( + account_id: str, + since: Any, + dispatch_token: Any = None, +) -> None: + """Store a pending scout-approval marker without writing a Gemini key.""" if not isinstance(account_id, str) or not account_id: raise ValueError( "malformed handoff payload: field 'account_id' must be a non-empty string" ) - _require_journal_config() - lock_path = _lock_path() - lock_path.parent.mkdir(parents=True, exist_ok=True) - with open(lock_path, "w", encoding="utf-8") as lock_file: - os.chmod(lock_path, 0o600) - fcntl.flock(lock_file, fcntl.LOCK_EX) - try: - _require_journal_config() - config = read_journal_config() - config.setdefault("services", {})["scout"] = { - "state": "pending", - "account_id": account_id, - "since": since, - "checked_at": datetime.now(timezone.utc).isoformat(), - } - write_journal_config(config) - log.debug("recorded pending scout marker for account_id=%s", account_id) - finally: - fcntl.flock(lock_file, fcntl.LOCK_UN) + def build_pending(_current: dict[str, Any]) -> dict[str, Any]: + block: dict[str, Any] = { + "state": "pending", + "account_id": account_id, + "since": since, + "checked_at": _now_iso(), + } + if isinstance(dispatch_token, str) and dispatch_token: + block["dispatch_token"] = dispatch_token + return block + + _update_scout_block(build_pending) + log.debug("recorded pending scout marker for account_id=%s", account_id) def disable_scout() -> DisableOutcome: @@ -218,7 +279,7 @@ def apply_scout_state(payload: dict[str, Any]) -> ScoutStateResult: account_id = payload.get("account_id") since = payload.get("since") try: - record_scout_pending(account_id, since) + record_scout_pending(account_id, since, payload.get("dispatch_token")) except ValueError as exc: raise ScoutPayloadError("scout_server_bad_payload", str(exc)) from exc return ScoutStateResult(kind="pending", account_id=account_id, since=since) @@ -266,3 +327,81 @@ def scout_provenance() -> dict[str, Any] | None: provenance = read_journal_config().get("services", {}).get("scout") return provenance if isinstance(provenance, dict) else None + + +def get_scout_dispatch_token() -> str | None: + block = scout_provenance() + if not isinstance(block, dict): + return None + dispatch_token = block.get("dispatch_token") + return ( + dispatch_token if isinstance(dispatch_token, str) and dispatch_token else None + ) + + +def approved_dispatch_token() -> str | None: + block = scout_provenance() + if not _is_approved_provision(block): + return None + dispatch_token = block.get("dispatch_token") + return ( + dispatch_token if isinstance(dispatch_token, str) and dispatch_token else None + ) + + +def _stored_checked_at(block: dict[str, Any] | None) -> str | None: + if not isinstance(block, dict): + return None + checked_at = block.get("checked_at") + return checked_at if isinstance(checked_at, str) and checked_at else None + + +def _stamp_scout_check(server_status: str, checked_at: str) -> None: + def stamp(current: dict[str, Any]) -> dict[str, Any]: + block = dict(current) + block["server_status"] = server_status + block["checked_at"] = checked_at + return block + + _update_scout_block(stamp) + + +def _fallback_source_state(local_state: str) -> str: + return local_state if local_state in {"pending", "disabled"} else "disabled" + + +def update_scout_check(*, force: bool = False) -> ScoutCheckResult: + from solstone.think.services import status as service_status + + local = service_status.scout_status() + local_state = str(local["state"]) + if local_state == "enabled": + return ScoutCheckResult("enabled", True, None, None) + if local_state == "manual_key": + return ScoutCheckResult("manual_key", True, None, None) + + block = scout_provenance() + stored_checked_at = _stored_checked_at(block) + fallback = _fallback_source_state(local_state) + dispatch_token = get_scout_dispatch_token() + if dispatch_token is None: + return ScoutCheckResult(fallback, False, stored_checked_at, "no_credential") + + server_status = block.get("server_status") if isinstance(block, dict) else None + cached_source = _SERVER_STATUS_TO_SOURCE_STATE.get(server_status) + if not force and cached_source and _check_is_fresh(stored_checked_at): + return ScoutCheckResult(cached_source, True, stored_checked_at, None) + + outcome = portal_client.check_scout_status(dispatch_token) + if outcome.kind == "ok" and outcome.server_status: + source_state = _SERVER_STATUS_TO_SOURCE_STATE[outcome.server_status] + checked_at = _now_iso() + _stamp_scout_check(outcome.server_status, checked_at) + return ScoutCheckResult(source_state, True, checked_at, None) + + return ScoutCheckResult( + fallback, + False, + stored_checked_at, + outcome.reason or "malformed", + ) diff --git a/tests/services/test_portal_client_status.py b/tests/services/test_portal_client_status.py new file mode 100644 index 000000000..1ae9f75e2 --- /dev/null +++ b/tests/services/test_portal_client_status.py @@ -0,0 +1,166 @@ +# SPDX-License-Identifier: AGPL-3.0-only +# Copyright (c) 2026 sol pbc + +from __future__ import annotations + +import json +import socket +import ssl +import urllib.error + +import pytest + +from solstone.think.services import portal_client + + +class _Response: + def __init__(self, body: bytes, status: int = 200) -> None: + self.body = body + self.status = status + + def __enter__(self): + return self + + def __exit__(self, *_args): + return False + + def read(self) -> bytes: + return self.body + + def getcode(self) -> int: + return self.status + + +@pytest.fixture(autouse=True) +def _portal_base(monkeypatch: pytest.MonkeyPatch) -> None: + monkeypatch.setattr(portal_client, "portal_base_url", lambda: "https://portal.test") + + +def _http_error(status: int) -> urllib.error.HTTPError: + return urllib.error.HTTPError( + "https://portal.test/account/scout/status", + status, + "error", + hdrs=None, + fp=None, + ) + + +def test_check_scout_status_sends_bearer_get(monkeypatch: pytest.MonkeyPatch) -> None: + calls = [] + + def fake_urlopen(request, timeout): + calls.append((request, timeout)) + return _Response(b'{"status": "pending"}') + + monkeypatch.setattr(portal_client.urllib.request, "urlopen", fake_urlopen) + + outcome = portal_client.check_scout_status( + "dispatch-token", + timeout=7, + component="test", + ) + + assert outcome.kind == "ok" + assert outcome.server_status == "pending" + request, timeout = calls[0] + assert timeout == 7 + assert request.full_url == "https://portal.test/account/scout/status" + assert request.get_method() == "GET" + assert request.get_header("Authorization") == "Bearer dispatch-token" + assert request.get_header("Connection") == "close" + assert request.get_header("User-agent").startswith("solstone-test/") + + +@pytest.mark.parametrize("server_status", ["pending", "approved", "revoked"]) +def test_check_scout_status_valid_statuses( + monkeypatch: pytest.MonkeyPatch, + server_status: str, +) -> None: + monkeypatch.setattr( + portal_client.urllib.request, + "urlopen", + lambda _request, timeout: _Response( + json.dumps({"status": server_status}).encode("utf-8") + ), + ) + + outcome = portal_client.check_scout_status("dispatch-token") + + assert outcome.kind == "ok" + assert outcome.server_status == server_status + assert outcome.reason is None + + +@pytest.mark.parametrize( + ("raised", "reason"), + [ + (ssl.SSLError("cert failed"), "tls_failed"), + (urllib.error.URLError(ssl.SSLError("cert failed")), "tls_failed"), + (urllib.error.URLError("down"), "unreachable"), + (urllib.error.URLError(socket.timeout("timed out")), "unreachable"), + (socket.timeout("timed out"), "unreachable"), + (TimeoutError("timed out"), "unreachable"), + (_http_error(401), "unauthorized"), + (_http_error(404), "not_found"), + (_http_error(500), "unreachable"), + ], +) +def test_check_scout_status_failure_exceptions( + monkeypatch: pytest.MonkeyPatch, + raised: BaseException, + reason: str, +) -> None: + def fake_urlopen(_request, timeout): + raise raised + + monkeypatch.setattr(portal_client.urllib.request, "urlopen", fake_urlopen) + + outcome = portal_client.check_scout_status("dispatch-token") + + assert outcome.kind == "failed" + assert outcome.reason == reason + assert outcome.server_status is None + + +def test_check_scout_status_non_200_status_is_unreachable( + monkeypatch: pytest.MonkeyPatch, +) -> None: + monkeypatch.setattr( + portal_client.urllib.request, + "urlopen", + lambda _request, timeout: _Response(b'{"status": "pending"}', status=503), + ) + + outcome = portal_client.check_scout_status("dispatch-token") + + assert outcome.kind == "failed" + assert outcome.reason == "unreachable" + + +@pytest.mark.parametrize( + "response", + [ + _Response(b"", status=200), + _Response(b"", status=204), + _Response(b"\xff", status=200), + _Response(b"{", status=200), + _Response(b"[]", status=200), + _Response(b"{}", status=200), + _Response(b'{"status": "bogus"}', status=200), + ], +) +def test_check_scout_status_malformed_bodies( + monkeypatch: pytest.MonkeyPatch, + response: _Response, +) -> None: + monkeypatch.setattr( + portal_client.urllib.request, + "urlopen", + lambda _request, timeout: response, + ) + + outcome = portal_client.check_scout_status("dispatch-token") + + assert outcome.kind == "failed" + assert outcome.reason == "malformed" diff --git a/tests/services/test_scout_check.py b/tests/services/test_scout_check.py new file mode 100644 index 000000000..bb11aa3ba --- /dev/null +++ b/tests/services/test_scout_check.py @@ -0,0 +1,237 @@ +# SPDX-License-Identifier: AGPL-3.0-only +# Copyright (c) 2026 sol pbc + +from __future__ import annotations + +import json +from datetime import datetime, timedelta, timezone +from pathlib import Path + +import pytest + +from solstone.think.journal_config import write_journal_config +from solstone.think.services import portal_client, scout + + +def _approved_payload() -> dict[str, str]: + return { + "state": "approved", + "google_api_key": "google-scout-key", + "dispatch_token": "dispatch-secret", + "account_id": "acct-secret", + "created_at": "2026-05-24T00:00:00Z", + } + + +def _read_config(journal: Path) -> dict: + return json.loads((journal / "config" / "journal.json").read_text("utf-8")) + + +def _write_config(payload: dict) -> None: + write_journal_config(payload) + + +def _clear_scout(journal: Path) -> None: + config = _read_config(journal) + config.setdefault("env", {}).pop("GOOGLE_API_KEY", None) + config.setdefault("services", {}).pop("scout", None) + _write_config(config) + + +def _set_scout_block(journal: Path, block: dict) -> None: + config = _read_config(journal) + config.setdefault("env", {}).pop("GOOGLE_API_KEY", None) + config.setdefault("services", {})["scout"] = block + _write_config(config) + + +def _fresh_checked_at() -> str: + return datetime.now(timezone.utc).isoformat() + + +def _stale_checked_at() -> str: + return ( + datetime.now(timezone.utc) + - timedelta(seconds=scout.STATUS_CHECK_STALENESS_SECONDS + 30) + ).isoformat() + + +def _pending_block(**overrides) -> dict: + block = { + "state": "pending", + "account_id": "acct-pending", + "since": 1_770_000_000_000, + "checked_at": _stale_checked_at(), + "dispatch_token": "dispatch-token", + } + block.update(overrides) + return block + + +def test_update_scout_check_enabled_short_circuits_without_network( + journal_copy: Path, + monkeypatch: pytest.MonkeyPatch, +) -> None: + _clear_scout(journal_copy) + scout.provision_scout_handoff(_approved_payload()) + monkeypatch.setattr( + scout.portal_client, + "check_scout_status", + lambda _token: (_ for _ in ()).throw(AssertionError("network should not run")), + ) + + result = scout.update_scout_check() + + assert result == scout.ScoutCheckResult("enabled", True, None, None) + + +def test_update_scout_check_manual_key_short_circuits_without_network( + journal_copy: Path, + monkeypatch: pytest.MonkeyPatch, +) -> None: + _clear_scout(journal_copy) + config = _read_config(journal_copy) + config.setdefault("env", {})["GOOGLE_API_KEY"] = "manual-key" + _write_config(config) + monkeypatch.setattr( + scout.portal_client, + "check_scout_status", + lambda _token: (_ for _ in ()).throw(AssertionError("network should not run")), + ) + + result = scout.update_scout_check() + + assert result == scout.ScoutCheckResult("manual_key", True, None, None) + + +def test_update_scout_check_no_credential_falls_back_to_local_pending( + journal_copy: Path, +) -> None: + _clear_scout(journal_copy) + scout.record_scout_pending("acct-pending", 1_770_000_000_000) + + result = scout.update_scout_check() + + stored = _read_config(journal_copy)["services"]["scout"]["checked_at"] + assert result == scout.ScoutCheckResult( + "pending", + False, + stored, + "no_credential", + ) + + +def test_update_scout_check_uses_fresh_cached_server_status_without_network( + journal_copy: Path, + monkeypatch: pytest.MonkeyPatch, +) -> None: + checked_at = _fresh_checked_at() + _set_scout_block( + journal_copy, + _pending_block(server_status="approved", checked_at=checked_at), + ) + monkeypatch.setattr( + scout.portal_client, + "check_scout_status", + lambda _token: (_ for _ in ()).throw(AssertionError("network should not run")), + ) + + result = scout.update_scout_check() + + assert result == scout.ScoutCheckResult("invited", True, checked_at, None) + + +def test_update_scout_check_force_bypasses_fresh_cached_server_status( + journal_copy: Path, + monkeypatch: pytest.MonkeyPatch, +) -> None: + checked_at = _fresh_checked_at() + _set_scout_block( + journal_copy, + _pending_block(server_status="approved", checked_at=checked_at), + ) + calls: list[str] = [] + + def fake_check(token: str) -> portal_client.ScoutStatusOutcome: + calls.append(token) + return portal_client.ScoutStatusOutcome(kind="ok", server_status="revoked") + + monkeypatch.setattr(scout.portal_client, "check_scout_status", fake_check) + + result = scout.update_scout_check(force=True) + + assert calls == ["dispatch-token"] + assert result.source_state == "ended" + assert result.checked is True + assert result.check_error is None + saved = _read_config(journal_copy)["services"]["scout"] + assert saved["server_status"] == "revoked" + assert saved["checked_at"] != checked_at + assert saved["dispatch_token"] == "dispatch-token" + + +@pytest.mark.parametrize( + ("server_status", "source_state"), + [ + ("pending", "pending"), + ("approved", "invited"), + ("revoked", "ended"), + ], +) +def test_update_scout_check_live_success_maps_and_stamps_preserving_token( + journal_copy: Path, + monkeypatch: pytest.MonkeyPatch, + server_status: str, + source_state: str, +) -> None: + checked_at = _stale_checked_at() + _set_scout_block(journal_copy, _pending_block(checked_at=checked_at)) + + monkeypatch.setattr( + scout.portal_client, + "check_scout_status", + lambda token: portal_client.ScoutStatusOutcome( + kind="ok", + server_status=server_status, + ), + ) + + result = scout.update_scout_check() + + assert result.source_state == source_state + assert result.checked is True + assert result.checked_at is not None + assert result.check_error is None + saved = _read_config(journal_copy)["services"]["scout"] + assert saved["server_status"] == server_status + assert saved["checked_at"] == result.checked_at + assert saved["checked_at"] != checked_at + assert saved["account_id"] == "acct-pending" + assert saved["since"] == 1_770_000_000_000 + assert saved["dispatch_token"] == "dispatch-token" + + +@pytest.mark.parametrize( + "reason", + ["unreachable", "tls_failed", "unauthorized", "malformed", "not_found"], +) +def test_update_scout_check_live_failure_falls_back_without_stamp( + journal_copy: Path, + monkeypatch: pytest.MonkeyPatch, + reason: str, +) -> None: + checked_at = _stale_checked_at() + _set_scout_block(journal_copy, _pending_block(checked_at=checked_at)) + monkeypatch.setattr( + scout.portal_client, + "check_scout_status", + lambda token: portal_client.ScoutStatusOutcome(kind="failed", reason=reason), + ) + + result = scout.update_scout_check() + + assert result == scout.ScoutCheckResult("pending", False, checked_at, reason) + saved = _read_config(journal_copy)["services"]["scout"] + assert "server_status" not in saved + assert saved["checked_at"] == checked_at + assert saved["dispatch_token"] == "dispatch-token" diff --git a/tests/services/test_scout_handoff.py b/tests/services/test_scout_handoff.py index 280041d25..5261af145 100644 --- a/tests/services/test_scout_handoff.py +++ b/tests/services/test_scout_handoff.py @@ -67,6 +67,7 @@ def test_run_scout_handoff_maps_pending(journal_copy: Path) -> None: "state": "pending", "account_id": "acct-pending", "since": 1770000000000, + "dispatch_token": "dispatch-pending", }, ), ) @@ -75,6 +76,7 @@ def test_run_scout_handoff_maps_pending(journal_copy: Path) -> None: assert result.retryable is False saved = _config(journal_copy) assert saved["services"]["scout"]["state"] == "pending" + assert saved["services"]["scout"]["dispatch_token"] == "dispatch-pending" assert "GOOGLE_API_KEY" not in saved.get("env", {}) diff --git a/tests/services/test_scout_storage.py b/tests/services/test_scout_storage.py index 65ac5c9f9..44a3494f6 100644 --- a/tests/services/test_scout_storage.py +++ b/tests/services/test_scout_storage.py @@ -13,11 +13,14 @@ import pytest from solstone.think.journal_config import write_journal_config from solstone.think.services import scout as scout_module +from solstone.think.services import status as service_status from solstone.think.services.scout import ( KEY_FINGERPRINT_FIELD, DisableOutcome, JournalNotInitializedError, + approved_dispatch_token, disable_scout, + get_scout_dispatch_token, is_manual_key_present, is_scout_enabled, provision_scout_handoff, @@ -188,6 +191,80 @@ def test_record_scout_pending_writes_no_google_api_key(journal_copy) -> None: assert "dispatch_token" not in scout +def test_record_scout_pending_stores_non_empty_dispatch_token(journal_copy) -> None: + config = _read_config(journal_copy) + config.setdefault("env", {}).pop("GOOGLE_API_KEY", None) + config.pop("services", None) + write_journal_config(config) + + record_scout_pending("acct-p", 1_700_000_000_000, "dispatch-p") + + saved = _read_config(journal_copy) + scout = saved["services"]["scout"] + assert scout["dispatch_token"] == "dispatch-p" + assert "google_api_key" not in scout + view = service_status.scout_provenance_view() + serialized = json.dumps(view).lower() + assert "dispatch_token" not in view + assert "dispatch-p" not in serialized + assert "acct-p" not in serialized + + +@pytest.mark.parametrize("dispatch_token", [None, "", 123]) +def test_record_scout_pending_ignores_invalid_dispatch_token( + journal_copy, + dispatch_token, +) -> None: + config = _read_config(journal_copy) + config.setdefault("env", {}).pop("GOOGLE_API_KEY", None) + config.pop("services", None) + write_journal_config(config) + + record_scout_pending("acct-p", 1_700_000_000_000, dispatch_token) + + assert "dispatch_token" not in _read_config(journal_copy)["services"]["scout"] + + +def test_get_scout_dispatch_token_reads_pending_and_approved_blocks( + journal_copy, +) -> None: + config = _read_config(journal_copy) + config.setdefault("env", {}).pop("GOOGLE_API_KEY", None) + config.pop("services", None) + write_journal_config(config) + assert get_scout_dispatch_token() is None + + record_scout_pending("acct-p", 1_700_000_000_000) + assert get_scout_dispatch_token() is None + + record_scout_pending("acct-p", 1_700_000_000_000, "dispatch-p") + assert get_scout_dispatch_token() == "dispatch-p" + + provision_scout_handoff(_payload("approved")) + assert get_scout_dispatch_token() == "dispatch-approved" + + +def test_approved_dispatch_token_is_approved_only_and_keeps_stale_push_behavior( + journal_copy, +) -> None: + config = _read_config(journal_copy) + config.setdefault("env", {}).pop("GOOGLE_API_KEY", None) + config.pop("services", None) + write_journal_config(config) + assert approved_dispatch_token() is None + + record_scout_pending("acct-p", 1_700_000_000_000, "dispatch-p") + assert approved_dispatch_token() is None + + provision_scout_handoff(_payload("approved")) + assert approved_dispatch_token() == "dispatch-approved" + + config = _read_config(journal_copy) + config.setdefault("env", {}).pop("GOOGLE_API_KEY", None) + write_journal_config(config) + assert approved_dispatch_token() == "dispatch-approved" + + @pytest.mark.parametrize("field", list(_payload().keys())) def test_payload_validation_missing_field(journal_copy, field: str) -> None: payload = _payload() diff --git a/tests/test_push_triggers.py b/tests/test_push_triggers.py index 66c5cc591..4e9c25fe5 100644 --- a/tests/test_push_triggers.py +++ b/tests/test_push_triggers.py @@ -16,6 +16,7 @@ from solstone.convey.sol_initiated.copy import ( KIND_SOL_CHAT_REQUEST, ) from solstone.think.push import portal_dispatch, triggers +from solstone.think.services import scout def _log_path(tmp_path: Path) -> Path: @@ -702,8 +703,8 @@ class _PortalResponse: def test_dispatch_via_portal_happy_path(monkeypatch): monkeypatch.setattr( portal_dispatch, - "scout_provenance", - lambda: {"dispatch_token": "dispatch-token", "account_id": "acct-1"}, + "approved_dispatch_token", + lambda: "dispatch-token", ) monkeypatch.setattr( portal_dispatch, "portal_base_url", lambda: "https://portal.test" @@ -734,8 +735,8 @@ def test_dispatch_via_portal_4xx_returns_none(monkeypatch, caplog): token = "dispatch-token" monkeypatch.setattr( portal_dispatch, - "scout_provenance", - lambda: {"dispatch_token": token, "account_id": "acct-1"}, + "approved_dispatch_token", + lambda: token, ) caplog.set_level("WARNING", logger=portal_dispatch.logger.name) @@ -759,8 +760,8 @@ def test_dispatch_via_portal_4xx_returns_none(monkeypatch, caplog): def test_dispatch_via_portal_5xx_returns_none(monkeypatch): monkeypatch.setattr( portal_dispatch, - "scout_provenance", - lambda: {"dispatch_token": "dispatch-token", "account_id": "acct-1"}, + "approved_dispatch_token", + lambda: "dispatch-token", ) def fake_urlopen(request, timeout): @@ -781,8 +782,8 @@ def test_dispatch_via_portal_5xx_returns_none(monkeypatch): def test_dispatch_via_portal_timeout_returns_none(monkeypatch): monkeypatch.setattr( portal_dispatch, - "scout_provenance", - lambda: {"dispatch_token": "dispatch-token", "account_id": "acct-1"}, + "approved_dispatch_token", + lambda: "dispatch-token", ) monkeypatch.setattr( portal_dispatch.urllib_request, @@ -802,7 +803,7 @@ def test_dispatch_via_portal_timeout_returns_none(monkeypatch): def test_dispatch_via_portal_no_scout_returns_none(monkeypatch): urlopen_called = False - monkeypatch.setattr(portal_dispatch, "scout_provenance", lambda: None) + monkeypatch.setattr(portal_dispatch, "approved_dispatch_token", lambda: None) def fake_urlopen(request, timeout): nonlocal urlopen_called @@ -824,16 +825,29 @@ def test_dispatch_via_portal_no_scout_returns_none(monkeypatch): def test_dispatch_via_portal_pending_marker_no_token_noops(monkeypatch): urlopen_called = False - monkeypatch.setattr( - portal_dispatch, - "scout_provenance", - lambda: { - "state": "pending", - "account_id": "acct-p", - "since": 1_700_000_000_000, - "checked_at": "2026-06-11T00:00:00+00:00", - }, + monkeypatch.setattr(portal_dispatch, "approved_dispatch_token", lambda: None) + + def fake_urlopen(request, timeout): + nonlocal urlopen_called + urlopen_called = True + return _PortalResponse(b"{}") + + monkeypatch.setattr(portal_dispatch.urllib_request, "urlopen", fake_urlopen) + + assert ( + portal_dispatch.dispatch_via_portal( + request_id="req-1", + summary="x", + category="notice", + ) + is None ) + assert not urlopen_called + + +def test_dispatch_via_portal_pending_block_token_noops(journal_copy, monkeypatch): + scout.record_scout_pending("acct-p", 1_700_000_000_000, "dispatch-pending") + urlopen_called = False def fake_urlopen(request, timeout): nonlocal urlopen_called @@ -856,8 +870,8 @@ def test_dispatch_via_portal_pending_marker_no_token_noops(monkeypatch): def test_dispatch_dedup_via_portal_posts_and_returns_payload(monkeypatch): monkeypatch.setattr( portal_dispatch, - "scout_provenance", - lambda: {"dispatch_token": "dispatch-token", "account_id": "acct-1"}, + "approved_dispatch_token", + lambda: "dispatch-token", ) monkeypatch.setattr( portal_dispatch, "portal_base_url", lambda: "https://portal.test" @@ -885,8 +899,8 @@ def test_dispatch_dedup_via_portal_posts_and_returns_payload(monkeypatch): def test_dispatch_dedup_via_portal_returns_none_on_4xx(monkeypatch): monkeypatch.setattr( portal_dispatch, - "scout_provenance", - lambda: {"dispatch_token": "dispatch-token", "account_id": "acct-1"}, + "approved_dispatch_token", + lambda: "dispatch-token", ) def fake_urlopen(request, timeout): @@ -906,8 +920,8 @@ def test_dispatch_dedup_via_portal_returns_none_on_4xx(monkeypatch): def test_dispatch_dedup_via_portal_returns_none_on_5xx(monkeypatch): monkeypatch.setattr( portal_dispatch, - "scout_provenance", - lambda: {"dispatch_token": "dispatch-token", "account_id": "acct-1"}, + "approved_dispatch_token", + lambda: "dispatch-token", ) def fake_urlopen(request, timeout): @@ -927,8 +941,8 @@ def test_dispatch_dedup_via_portal_returns_none_on_5xx(monkeypatch): def test_dispatch_dedup_via_portal_returns_none_on_timeout(monkeypatch): monkeypatch.setattr( portal_dispatch, - "scout_provenance", - lambda: {"dispatch_token": "dispatch-token", "account_id": "acct-1"}, + "approved_dispatch_token", + lambda: "dispatch-token", ) monkeypatch.setattr( portal_dispatch.urllib_request, @@ -947,7 +961,7 @@ def test_dispatch_dedup_via_portal_returns_none_on_timeout(monkeypatch): def test_dispatch_dedup_via_portal_returns_none_when_no_scout(monkeypatch): urlopen_called = False - monkeypatch.setattr(portal_dispatch, "scout_provenance", lambda: None) + monkeypatch.setattr(portal_dispatch, "approved_dispatch_token", lambda: None) def fake_urlopen(request, timeout): nonlocal urlopen_called @@ -977,8 +991,12 @@ def test_dispatch_dedup_via_portal_returns_none_when_dispatch_token_missing( return _PortalResponse(b"{}") monkeypatch.setattr(portal_dispatch.urllib_request, "urlopen", fake_urlopen) - for scout in ({"account_id": "acct-1"}, {"dispatch_token": ""}): - monkeypatch.setattr(portal_dispatch, "scout_provenance", lambda: scout) + for dispatch_token in (None, ""): + monkeypatch.setattr( + portal_dispatch, + "approved_dispatch_token", + lambda: dispatch_token, + ) assert ( portal_dispatch.dispatch_dedup_via_portal( request_id="req-1", @@ -996,8 +1014,8 @@ def test_handle_sol_chat_request_routes_via_portal_when_scout_enabled( monkeypatch.setenv("SOLSTONE_JOURNAL", str(tmp_path)) monkeypatch.setattr( triggers, - "scout_provenance", - lambda: {"dispatch_token": "dispatch-token", "account_id": "acct-1"}, + "approved_dispatch_token", + lambda: "dispatch-token", ) monkeypatch.setattr(triggers.time, "time", lambda: 123.0) send_calls: list[dict[str, object]] = [] @@ -1048,8 +1066,8 @@ def test_handle_sol_chat_request_falls_back_to_local_when_portal_fails( monkeypatch.setenv("SOLSTONE_JOURNAL", str(tmp_path)) monkeypatch.setattr( triggers, - "scout_provenance", - lambda: {"dispatch_token": "dispatch-token", "account_id": "acct-1"}, + "approved_dispatch_token", + lambda: "dispatch-token", ) monkeypatch.setattr(triggers, "dispatch_via_portal", lambda **kwargs: None) monkeypatch.setattr(triggers, "is_configured", lambda: True) @@ -1081,7 +1099,7 @@ def test_handle_sol_chat_request_falls_back_to_local_when_scout_missing_token( monkeypatch, tmp_path ): monkeypatch.setenv("SOLSTONE_JOURNAL", str(tmp_path)) - monkeypatch.setattr(triggers, "scout_provenance", lambda: {"account_id": "acct-1"}) + monkeypatch.setattr(triggers, "approved_dispatch_token", lambda: None) monkeypatch.setattr( triggers, "dispatch_via_portal", @@ -1112,7 +1130,7 @@ def test_handle_sol_chat_request_falls_back_to_local_when_scout_missing_token( def test_handle_sol_chat_request_no_scout_unchanged(monkeypatch, tmp_path): monkeypatch.setenv("SOLSTONE_JOURNAL", str(tmp_path)) - monkeypatch.setattr(triggers, "scout_provenance", lambda: None) + monkeypatch.setattr(triggers, "approved_dispatch_token", lambda: None) monkeypatch.setattr( triggers, "dispatch_via_portal", @@ -1147,8 +1165,8 @@ def test_handle_chat_lifecycle_routes_via_portal_when_scout_enabled( monkeypatch.setenv("SOLSTONE_JOURNAL", str(tmp_path)) monkeypatch.setattr( triggers, - "scout_provenance", - lambda: {"dispatch_token": "dispatch-token", "account_id": "acct-1"}, + "approved_dispatch_token", + lambda: "dispatch-token", ) monkeypatch.setattr(triggers.time, "time", lambda: 123.0) send_calls: list[dict[str, object]] = [] @@ -1196,8 +1214,8 @@ def test_handle_chat_lifecycle_falls_back_to_local_when_portal_fails( monkeypatch.setenv("SOLSTONE_JOURNAL", str(tmp_path)) monkeypatch.setattr( triggers, - "scout_provenance", - lambda: {"dispatch_token": "dispatch-token", "account_id": "acct-1"}, + "approved_dispatch_token", + lambda: "dispatch-token", ) monkeypatch.setattr(triggers, "dispatch_dedup_via_portal", lambda **kwargs: None) monkeypatch.setattr(triggers, "is_configured", lambda: True) @@ -1252,7 +1270,7 @@ def test_handle_chat_lifecycle_falls_back_to_local_when_scout_missing_token( monkeypatch, tmp_path ): monkeypatch.setenv("SOLSTONE_JOURNAL", str(tmp_path)) - monkeypatch.setattr(triggers, "scout_provenance", lambda: {"account_id": "acct-1"}) + monkeypatch.setattr(triggers, "approved_dispatch_token", lambda: None) monkeypatch.setattr( triggers, "dispatch_dedup_via_portal", @@ -1278,7 +1296,7 @@ def test_handle_chat_lifecycle_falls_back_to_local_when_scout_missing_token( def test_handle_chat_lifecycle_no_scout_unchanged(monkeypatch, tmp_path): monkeypatch.setenv("SOLSTONE_JOURNAL", str(tmp_path)) - monkeypatch.setattr(triggers, "scout_provenance", lambda: None) + monkeypatch.setattr(triggers, "approved_dispatch_token", lambda: None) monkeypatch.setattr( triggers, "dispatch_dedup_via_portal", @@ -1306,7 +1324,7 @@ def test_handle_chat_lifecycle_local_send_many_error_records_outcome_error( monkeypatch, tmp_path ): monkeypatch.setenv("SOLSTONE_JOURNAL", str(tmp_path)) - monkeypatch.setattr(triggers, "scout_provenance", lambda: None) + monkeypatch.setattr(triggers, "approved_dispatch_token", lambda: None) monkeypatch.setattr(triggers, "is_configured", lambda: True) monkeypatch.setattr(triggers, "_eligible_devices", lambda: [{"token": "a" * 64}]) monkeypatch.setattr(triggers.time, "time", lambda: 123.0) @@ -1336,8 +1354,8 @@ def test_dispatch_via_portal_does_not_log_token_plaintext(monkeypatch, caplog): token = "TEST_TOKEN_SHOULD_NEVER_APPEAR" monkeypatch.setattr( portal_dispatch, - "scout_provenance", - lambda: {"dispatch_token": token, "account_id": "acct-1"}, + "approved_dispatch_token", + lambda: token, ) caplog.set_level("WARNING", logger=portal_dispatch.logger.name) @@ -1361,8 +1379,8 @@ def test_dispatch_dedup_via_portal_does_not_log_token_plaintext(monkeypatch, cap token = "TEST_DEDUP_TOKEN_SHOULD_NEVER_APPEAR" monkeypatch.setattr( portal_dispatch, - "scout_provenance", - lambda: {"dispatch_token": token, "account_id": "acct-1"}, + "approved_dispatch_token", + lambda: token, ) caplog.set_level("WARNING", logger=portal_dispatch.logger.name) diff --git a/tests/test_settings_provider_card_js.py b/tests/test_settings_provider_card_js.py index ad95906c6..7d0e8c54d 100644 --- a/tests/test_settings_provider_card_js.py +++ b/tests/test_settings_provider_card_js.py @@ -55,6 +55,7 @@ def test_thinking_static_has_scout_orchestration_structures(): "renderScout", "pollScoutUntilTerminal", "enableScout", + "checkScout", "refreshScoutOp", "disableScout", ): @@ -62,8 +63,10 @@ def test_thinking_static_has_scout_orchestration_structures(): assert "switchLane('scout')" in text assert "phase === 'repair_needed'" in text assert "api('api/scout/enable'" in text + assert "api('api/scout/check'" in text assert "api('api/scout/refresh'" in text assert "api('api/scout/disable'" in text + assert "$('scoutCheck')?.addEventListener" in text def test_thinking_surface_avoids_forbidden_owner_terms(): diff --git a/tests/test_thinking_call_parity.py b/tests/test_thinking_call_parity.py index 3f6ed630a..bff2d8632 100644 --- a/tests/test_thinking_call_parity.py +++ b/tests/test_thinking_call_parity.py @@ -160,6 +160,21 @@ def test_scout_status_matches_http_payload(journal_copy: Path) -> None: assert guidance == thinking_call._SCOUT_GUIDANCE[thinking_copy.SCOUT_STATE_OFF] +def test_scout_check_matches_http_response(journal_copy: Path) -> None: + _clear_scout(journal_copy) + expected = thinking_call._request( + "POST", + "/app/thinking/api/scout/check", + ) + + result = runner.invoke(thinking_call.app, ["scout", "check"]) + + assert result.exit_code == 0 + payload, guidance = _first_json(result.stdout) + assert payload == expected + assert guidance == thinking_call._SCOUT_GUIDANCE[payload["state"]] + + def test_scout_disable_matches_http_response(journal_copy: Path) -> None: _clear_scout(journal_copy) scout.provision_scout_handoff(_approved_scout_payload()) @@ -199,7 +214,7 @@ def test_scout_enable_polls_terminal_success( assert result.exit_code == 0 assert result.stderr == "" - assert "state: on\n" in result.stdout + # Status and operation are sampled non-atomically; phase is stable here. assert "operation: invited\n" in result.stdout assert thinking_call._SCOUT_GUIDANCE[thinking_copy.SCOUT_STATE_INVITED] in ( result.stdout