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