From 44d7aa208b87b7b6cb094efd60e2a9bdf03574f2 Mon Sep 17 00:00:00 2001 From: Jer Miller Date: Sun, 12 Jul 2026 06:41:51 -0600 Subject: [PATCH] fix(spp): tear down confidential transport on disable Disable was removing the confidential config while leaving RA-TLS transport state alive. That left pooled and active attested channels, the loopback listener, the forwarder URL, epoch state, and _CONFIDENTIAL_BLOCK available for later enables, including enables that point at a different service. Call transport teardown after the config disable and make teardown clear all module-owned transport state. The config write remains the authoritative disable; transport cleanup follows it. Also close the related audit gaps: clear stale confidential in-progress UI when polling is cancelled, make the confidential card open setup consistently, drop unread JS helper fields, and cover bucketing across the full reachable RA-TLS reason-code set. Co-Authored-By: Claude Opus 4.8 (1M context) --- solstone/apps/thinking/routes.py | 1 + solstone/apps/thinking/static/thinking.js | 50 ++++++---- .../tests/test_confidential_routes.py | 62 +++++++++++- .../test_thinking_confidential_poll_js.py | 22 ++++- .../thinking/tests/test_workspace_html.py | 2 + solstone/think/services/spp_transport.py | 6 +- tests/services/test_spp_transport.py | 94 ++++++++++++------- 7 files changed, 180 insertions(+), 57 deletions(-) diff --git a/solstone/apps/thinking/routes.py b/solstone/apps/thinking/routes.py index 2deff0115..f7ead3939 100644 --- a/solstone/apps/thinking/routes.py +++ b/solstone/apps/thinking/routes.py @@ -728,6 +728,7 @@ def confidential_enable() -> Any: def confidential_disable() -> Any: try: outcome = spp.disable_confidential() + spp_transport.teardown_confidential_transport() return jsonify( { "success": True, diff --git a/solstone/apps/thinking/static/thinking.js b/solstone/apps/thinking/static/thinking.js index c7080f494..887a9a0d4 100644 --- a/solstone/apps/thinking/static/thinking.js +++ b/solstone/apps/thinking/static/thinking.js @@ -184,32 +184,29 @@ const phase = operation?.phase || ''; const states = text?.operation_states || {}; if (phase === 'starting' || phase === 'waiting') { - return {message: states[phase] || '', tone: '', active: true}; + return {message: states[phase] || '', tone: ''}; } if (phase === 'early_access') { - return {message: states.early_access || '', tone: '', active: false}; + return {message: states.early_access || '', tone: ''}; } if (phase === 'not_verified') { - return {message: '', tone: '', active: false}; + return {message: '', tone: ''}; } if (phase === 'repair_needed') { - return {message: states.repair_needed || '', tone: 'error', active: false}; + return {message: states.repair_needed || '', tone: 'error'}; } - return {message: operation?.guidance || '', tone: '', active: false}; + return {message: operation?.guidance || '', tone: ''}; } function confidentialAttestationRender(attestation, text, checkedLabel = '') { const stateName = attestation?.state || 'off'; const states = text?.attestation_states || {}; - const actions = text?.actions || {}; if (stateName === 'verified' && attestation?.provenance) { return { pill: 'active', tone: 'hot', message: formatCopy(states.verified || '', {checked: checkedLabel}), - action: actions.enabled || '', recheck: false, - disabled: false, }; } if (stateName === 'failed' || stateName === 'stale' || stateName === 'unreachable') { @@ -217,9 +214,7 @@ pill: stateName === 'unreachable' ? 'unreachable' : 'not ready', tone: 'bad', message: states[stateName] || '', - action: actions.enabled || '', recheck: true, - disabled: false, }; } if (stateName === 'verifying') { @@ -227,21 +222,24 @@ pill: 'checking', tone: '', message: states.verifying || '', - action: actions.enabled || '', recheck: false, - disabled: false, }; } return { pill: 'off', tone: '', message: '', - action: actions.off || '', recheck: false, - disabled: false, }; } + function clearConfidentialInProgressOperation(activeLane) { + const operation = activeLane?.confidential_operation; + if (!operation || confidentialOperationIsTerminal(operation)) return false; + activeLane.confidential_operation = null; + return true; + } + function confidentialGlanceForAttestation(attestation, text, checkedLabel = '') { const glance = text?.glance || {}; const stateName = attestation?.state || 'off'; @@ -413,7 +411,7 @@ }); } if (target !== 'confidential-setup') { - stopConfidentialPoll(); + stopConfidentialPoll({clearOperation: true}); } document.querySelectorAll('#providers [data-view]').forEach((section) => { section.hidden = section.dataset.view !== target; @@ -1463,8 +1461,11 @@ state.installPollGeneration += 1; } - function stopConfidentialPoll() { + function stopConfidentialPoll(options = {}) { state.confidentialPollGeneration += 1; + if (options.clearOperation && clearConfidentialInProgressOperation(state.providers.active_lane)) { + renderAll(); + } } function applyConfidentialProviders(payload, generation) { @@ -1488,17 +1489,22 @@ }) .then((payload) => { if (generation !== state.confidentialPollGeneration) return; - if (payload !== null) stopConfidentialPoll(); + if (payload === null) { + if (clearConfidentialInProgressOperation(state.providers.active_lane)) { + renderAll(); + } + return; + } + stopConfidentialPoll(); }) .catch((err) => { handleConfidentialPollError({ generation, currentGeneration: () => state.confidentialPollGeneration, clearOperation: () => { - if (state.providers.active_lane) { - state.providers.active_lane.confidential_operation = null; + if (clearConfidentialInProgressOperation(state.providers.active_lane)) { + renderAll(); } - renderAll(); }, stopPoll: stopConfidentialPoll, showError: (message) => setMessage('confidentialLaneOperation', message, 'error'), @@ -1795,6 +1801,10 @@ } function openLane(lane) { + if (lane === 'confidential') { + showView('confidential-setup'); + return; + } if (laneIsUsable(lane) && activeBrain().kind !== lane) { activateLane(lane).catch((err) => setMessage(`${lane}LaneStatus`, err.message, 'error')); return; diff --git a/solstone/apps/thinking/tests/test_confidential_routes.py b/solstone/apps/thinking/tests/test_confidential_routes.py index ebab6d1fd..a189e3eab 100644 --- a/solstone/apps/thinking/tests/test_confidential_routes.py +++ b/solstone/apps/thinking/tests/test_confidential_routes.py @@ -8,6 +8,7 @@ import threading import time from datetime import datetime, timezone from pathlib import Path +from unittest.mock import Mock import pytest @@ -17,6 +18,28 @@ from solstone.think.services import operations, spp, spp_handoff, spp_transport from solstone.think.services.spp_attest.cadence import AttestationSession +class _FakeChannel: + def __init__(self, verdict: object) -> None: + self.verdict = verdict + self.closed = False + + def close(self) -> None: + self.closed = True + + +class _FakeListener: + def __init__(self) -> None: + self.closed = False + + def close(self) -> None: + self.closed = True + + +class _AliveThread: + def is_alive(self) -> bool: + return True + + def _payload(suffix: str = "one") -> dict[str, str]: return { "endpoint_url": f"https://spp-{suffix}.example.test/v1", @@ -188,7 +211,7 @@ def test_enable_confidential_rejects_when_provenance_exists(thinking_client) -> def test_disable_confidential_restores_synchronously( - thinking_client, journal_copy: Path + thinking_client, journal_copy: Path, monkeypatch: pytest.MonkeyPatch ): spp.provision_confidential_handoff(_payload("disable")) now = datetime.now(timezone.utc) @@ -200,6 +223,15 @@ def test_disable_confidential_restores_synchronously( gpu_reattest_at=now, ) ) + old_channel = _FakeChannel("old") + old_listener = _FakeListener() + spp_transport._POOL[:] = [old_channel] + spp_transport._LISTENER = old_listener + spp_transport._LISTENER_THREAD = _AliveThread() + spp_transport._FORWARDER_BASE_URL = "http://127.0.0.1:1111" + spp_transport._CONFIDENTIAL_BLOCK = { + "endpoint_url": "https://spp-disable.example.test/v1" + } response = thinking_client.post("/app/thinking/api/confidential/disable") @@ -216,6 +248,34 @@ def test_disable_confidential_restores_synchronously( assert state.session is None assert state.failure is None assert state.last_verified is None + assert old_channel.closed is True + assert old_listener.closed is True + assert spp_transport._POOL == [] + assert spp_transport._LISTENER is None + assert spp_transport._LISTENER_THREAD is None + assert spp_transport._FORWARDER_BASE_URL is None + assert spp_transport._CONFIDENTIAL_BLOCK is None + + fresh_channel = _FakeChannel("fresh") + establish = Mock(return_value=fresh_channel) + + def fake_start_listener() -> None: + spp_transport._LISTENER = _FakeListener() + spp_transport._LISTENER_THREAD = _AliveThread() + spp_transport._FORWARDER_BASE_URL = "http://127.0.0.1:2222" + + monkeypatch.setattr(spp_transport, "establish_attested_channel", establish) + monkeypatch.setattr(spp_transport, "_start_listener_locked", fake_start_listener) + spp.provision_confidential_handoff(_payload("fresh")) + + provenance = spp.confidential_provenance() + assert provenance is not None + spp_transport.verify_confidential_attestation(provenance) + + assert establish.call_args.args[0].host == "spp-fresh.example.test" + assert spp_transport._POOL == [fresh_channel] + assert old_channel not in spp_transport._POOL + assert spp_transport._CONFIDENTIAL_BLOCK == provenance def test_recheck_confidential_rejects_when_off(thinking_client) -> None: diff --git a/solstone/apps/thinking/tests/test_thinking_confidential_poll_js.py b/solstone/apps/thinking/tests/test_thinking_confidential_poll_js.py index 4c7013c62..c2b7a748c 100644 --- a/solstone/apps/thinking/tests/test_thinking_confidential_poll_js.py +++ b/solstone/apps/thinking/tests/test_thinking_confidential_poll_js.py @@ -27,6 +27,7 @@ def _node_script(body: str) -> str: extract_js_function(source, "confidentialOperationIsTerminal"), extract_js_function(source, "confidentialOperationRender"), extract_js_function(source, "confidentialAttestationRender"), + extract_js_function(source, "clearConfidentialInProgressOperation"), extract_js_function(source, "confidentialGlanceForAttestation"), extract_js_function(source, "pollConfidentialUntilTerminal"), extract_js_function(source, "handleConfidentialPollError"), @@ -70,7 +71,6 @@ assert(failed.message === "couldn't verify the service — sol isn't sending.", const starting = confidentialOperationRender({phase: 'starting'}, confidentialCopy); assert(starting.message === 'opening your browser to confirm…', 'starting message'); -assert(starting.active === true, 'starting active'); const early = confidentialOperationRender({phase: 'early_access'}, confidentialCopy); assert(early.message === 'confidential processing is coming — scouts get it first.', 'early access copy'); assert(confidentialOperationIsTerminal({phase: 'early_access'}), 'early access terminal'); @@ -182,3 +182,23 @@ main().catch((error) => { console.error(error.stack || error); process.exit(1); """ ) ) + + +def test_confidential_poll_cancellation_clears_only_in_progress_operation() -> None: + _run_node( + _node_script( + """ +const activeLane = {confidential_operation: {phase: 'waiting'}}; +assert(clearConfidentialInProgressOperation(activeLane) === true, 'waiting cleared'); +assert(activeLane.confidential_operation === null, 'operation nulled'); + +const terminalLane = {confidential_operation: {phase: 'early_access'}}; +assert(clearConfidentialInProgressOperation(terminalLane) === false, 'terminal kept'); +assert(terminalLane.confidential_operation.phase === 'early_access', 'terminal preserved'); + +const emptyLane = {}; +assert(clearConfidentialInProgressOperation(emptyLane) === false, 'empty ignored'); +console.log('PASS'); +""" + ) + ) diff --git a/solstone/apps/thinking/tests/test_workspace_html.py b/solstone/apps/thinking/tests/test_workspace_html.py index 99bb50acf..d55643571 100644 --- a/solstone/apps/thinking/tests/test_workspace_html.py +++ b/solstone/apps/thinking/tests/test_workspace_html.py @@ -175,6 +175,8 @@ def test_confidential_live_static_behavior_is_wired() -> None: assert "confidentialProvenancePresent" in js assert "renderConfidentialDetailPanel" in js assert "function pollConfidentialUntilTerminal(" in js + assert "if (lane === 'confidential')" in js + assert "showView('confidential-setup');" in js assert "confidential-setup" in js diff --git a/solstone/think/services/spp_transport.py b/solstone/think/services/spp_transport.py index 780a0b498..af2e52548 100644 --- a/solstone/think/services/spp_transport.py +++ b/solstone/think/services/spp_transport.py @@ -97,7 +97,8 @@ def _close_channel(channel: AttestedChannel) -> None: def _teardown_locked() -> None: - global _EPOCH, _FORWARDER_BASE_URL, _LISTENER, _LISTENER_THREAD + global _CONFIDENTIAL_BLOCK, _EPOCH, _FORWARDER_BASE_URL, _LISTENER + global _LISTENER_THREAD _EPOCH += 1 listener = _LISTENER @@ -106,6 +107,7 @@ def _teardown_locked() -> None: _LISTENER = None _LISTENER_THREAD = None _FORWARDER_BASE_URL = None + _CONFIDENTIAL_BLOCK = None _POOL.clear() _ACTIVE.clear() if listener is not None: @@ -275,9 +277,9 @@ def recheck_confidential_attestation() -> None: return now = datetime.now(timezone.utc) with _LOCK: - _CONFIDENTIAL_BLOCK = dict(block) _teardown_locked() spp.clear_attestation_state() + _CONFIDENTIAL_BLOCK = dict(block) try: _establish_and_record_locked(block, now) except AttestationFailedError: diff --git a/tests/services/test_spp_transport.py b/tests/services/test_spp_transport.py index 57b2d4a94..195156134 100644 --- a/tests/services/test_spp_transport.py +++ b/tests/services/test_spp_transport.py @@ -259,30 +259,74 @@ def test_wrong_epoch_channel_is_not_checked_out() -> None: assert spp_transport._POOL == [] +RATLS_CHANNEL_REASON_CODES = ( + "gateway_unreachable", + "tls_handshake_failed", + "proof_http_failed", +) +RATLS_VERIFICATION_REASON_CODES = ( + "certificate_invalid", + "certificate_extension_missing", + "certificate_extension_not_critical", + "certificate_extension_invalid", + "certificate_evidence_invalid", + "nonce_mismatch", + "spki_mismatch", + "cpu_verification_failed", + "gpu_nonce_mismatch", + "nvattest_unavailable", + "gpu_appraisal_failed", + "composite_appraisal_failed", + "exporter_proof_invalid", + "exporter_mismatch", + "exporter_quote_failed", +) +BUCKETING_CASES = ( + tuple( + ("channel", code, "unreachable" if code == "gateway_unreachable" else "failed") + for code in RATLS_CHANNEL_REASON_CODES + ) + + tuple( + ("verification", code, "failed") for code in RATLS_VERIFICATION_REASON_CODES + ) + + ( + ("endpoint", "endpoint_invalid", "failed"), + ("unexpected", "unexpected_error", "failed"), + ) +) + + @pytest.mark.parametrize( - ("exc", "kind", "reason_code"), - [ - ( - RatlsChannelError("gateway_unreachable"), - "unreachable", - "gateway_unreachable", - ), - (RatlsChannelError("tls_handshake_failed"), "failed", "tls_handshake_failed"), - (RatlsVerificationError("nonce_mismatch"), "failed", "nonce_mismatch"), - (RuntimeError("boom"), "failed", "unexpected_error"), - ], + ("source", "reason_code", "kind"), + BUCKETING_CASES, + ids=[f"{source}:{reason_code}" for source, reason_code, _kind in BUCKETING_CASES], ) -def test_attestation_failure_buckets_at_transport_catch_site( +def test_attestation_failure_buckets_real_reason_codes_at_transport_catch_site( tmp_path: Path, monkeypatch: pytest.MonkeyPatch, - exc: Exception, - kind: str, + source: str, reason_code: str, + kind: str, ) -> None: block = _write_confidential_config(tmp_path, monkeypatch) - monkeypatch.setattr( - spp_transport, "establish_attested_channel", Mock(side_effect=exc) - ) + if source == "channel": + exc: Exception = RatlsChannelError(reason_code) + monkeypatch.setattr( + spp_transport, "establish_attested_channel", Mock(side_effect=exc) + ) + elif source == "verification": + exc = RatlsVerificationError(reason_code) + monkeypatch.setattr( + spp_transport, "establish_attested_channel", Mock(side_effect=exc) + ) + elif source == "endpoint": + block["endpoint_url"] = "not-a-url" + elif source == "unexpected": + monkeypatch.setattr( + spp_transport, + "establish_attested_channel", + Mock(side_effect=RuntimeError("boom")), + ) with pytest.raises(AttestationFailedError): spp_transport.verify_confidential_attestation(block) @@ -293,22 +337,6 @@ def test_attestation_failure_buckets_at_transport_catch_site( assert failure.reason_code == reason_code -def test_endpoint_invalid_buckets_as_failed_without_detail_parsing( - tmp_path: Path, - monkeypatch: pytest.MonkeyPatch, -) -> None: - block = _write_confidential_config(tmp_path, monkeypatch) - block["endpoint_url"] = "not-a-url" - - with pytest.raises(AttestationFailedError): - spp_transport.verify_confidential_attestation(block) - - failure = spp.get_attestation_state().failure - assert failure is not None - assert failure.kind == "failed" - assert failure.reason_code == "endpoint_invalid" - - def test_recheck_confidential_attestation_records_success( tmp_path: Path, monkeypatch: pytest.MonkeyPatch, -- 2.51.2