From 71dd78efa24288187ee4e0fdca119aab056d5b95 Mon Sep 17 00:00:00 2001 From: Pavel Tkachyk Date: Mon, 17 Aug 2026 15:00:44 -0400 Subject: [PATCH 1/5] Keep approved device polling resilient Signed-off-by: Pavel Tkachyk --- src/substrate_wiki/cli.py | 3 +++ src/substrate_wiki/onboarding.py | 29 +++++++++++++++++--- tests/test_onboarding.py | 45 ++++++++++++++++++++++++++++++++ 3 files changed, 74 insertions(+), 3 deletions(-) diff --git a/src/substrate_wiki/cli.py b/src/substrate_wiki/cli.py index 2e3e045..d0dc310 100644 --- a/src/substrate_wiki/cli.py +++ b/src/substrate_wiki/cli.py @@ -12,6 +12,7 @@ from .checkpoint import ImportCheckpoint from .client import SubstrateClient +from .onboarding import OnboardingError from .history import ( HermesHistoryImporter, load_hermes_inventory, @@ -214,6 +215,8 @@ def substrate_command(args: argparse.Namespace) -> None: raise ValueError("unknown command") except Exception as exc: # noqa: BLE001 - content-free CLI failure contract result = {"error_class": type(exc).__name__, "complete": False} + if isinstance(exc, OnboardingError): + result["error_category"] = exc.category print(json.dumps(result, sort_keys=True) if args.json else f"Substrate command failed: {type(exc).__name__}") raise SystemExit(1) from None if args.json: diff --git a/src/substrate_wiki/onboarding.py b/src/substrate_wiki/onboarding.py index 4d3c656..beb1511 100644 --- a/src/substrate_wiki/onboarding.py +++ b/src/substrate_wiki/onboarding.py @@ -46,6 +46,19 @@ "http_504", } ) +_TRANSIENT_OAUTH_POLL_FAILURES = frozenset( + { + "transport_error", + "invalid_content_type", + "http_408", + "http_425", + "http_429", + "http_500", + "http_502", + "http_503", + "http_504", + } +) _TERMINAL = {"ready", "declined", "failed", "repair_required"} @@ -76,7 +89,7 @@ def _hosted_url(value: Any) -> str: class HostedOAuthClient: """Minimal no-redirect RFC 8628 client pinned to the hosted origin.""" - def __init__(self, *, timeout: float = 15.0) -> None: + def __init__(self, *, timeout: float = 60.0) -> None: self.timeout = timeout self._opener = build_opener(_NoRedirect()) @@ -289,7 +302,8 @@ def status(self) -> dict[str, Any]: for key in ( "phase", "hosted_origin", "mode", "verification_uri", "verification_uri_complete", "user_code", "expires_at", - "history_consent", "error_class", "capability_failure", "import", + "history_consent", "error_class", "capability_failure", + "oauth_poll_failure", "import", "connected_at", "completed_at", ) if key in state @@ -368,7 +382,15 @@ def advance(self) -> dict[str, Any]: state.update(phase="repair_required", error_class="missing_device_credential") self._save(state) return self.status() - response = self.api.poll(device_code) + try: + response = self.api.poll(device_code) + except OnboardingError as exc: + if exc.category not in _TRANSIENT_OAUTH_POLL_FAILURES: + raise + state["oauth_poll_failure"] = exc.category + self._save(state) + return self.status() + state.pop("oauth_poll_failure", None) poll_status = response["status"] if poll_status in {"authorization_pending", "slow_down"}: if poll_status == "slow_down": @@ -399,6 +421,7 @@ def advance(self) -> dict[str, Any]: state.update(phase="awaiting_history_consent", connected_at=time.time()) state.pop("error_class", None) state.pop("capability_failure", None) + state.pop("oauth_poll_failure", None) self._save(state) return self.status() diff --git a/tests/test_onboarding.py b/tests/test_onboarding.py index f95347e..7128181 100644 --- a/tests/test_onboarding.py +++ b/tests/test_onboarding.py @@ -53,6 +53,51 @@ def test_device_credentials_never_enter_state_and_consent_decline_keeps_connecti assert state["history_consent"]["decision"] == "declined" +def test_transient_oauth_poll_failure_preserves_grant_and_retries(tmp_path: Path): + class TransientPollAPI(API): + def poll(self, device_code): + assert device_code == "device-secret" + self.poll_count += 1 + if self.poll_count == 1: + raise OnboardingError("transport_error") + return {"status": "approved", "access_token": "tenant-secret"} + + store = Store() + api = TransientPollAPI() + manager = OnboardingManager( + tmp_path, api=api, store=store, capability_check=lambda token: {"ok": token} + ) + manager.begin(mode="device", open_browser=False) + + pending = manager.advance() + assert pending["phase"] == "authorization_pending" + assert pending["oauth_poll_failure"] == "transport_error" + assert store.get("onboarding-device") == "device-secret" + assert store.get() == "" + + connected = manager.advance() + assert api.poll_count == 2 + assert connected["phase"] == "awaiting_history_consent" + assert "oauth_poll_failure" not in connected + assert store.get() == "tenant-secret" + assert store.get("onboarding-device") == "" + + +def test_permanent_oauth_poll_failure_remains_fail_closed(tmp_path: Path): + class InvalidPollAPI(API): + def poll(self, device_code): + raise OnboardingError("invalid_response") + + store = Store() + manager = OnboardingManager(tmp_path, api=InvalidPollAPI(), store=store) + manager.begin(mode="device", open_browser=False) + + with pytest.raises(OnboardingError, match="invalid_response"): + manager.advance() + assert store.get("onboarding-device") == "device-secret" + assert store.get() == "" + + def test_history_approval_starts_exactly_one_durable_job(tmp_path: Path): store = Store() store.put("tenant-secret") From 8b9c82b50be1cb168e0a883f57195db042aa319a Mon Sep 17 00:00:00 2001 From: Pavel Tkachyk Date: Mon, 17 Aug 2026 15:18:03 -0400 Subject: [PATCH 2/5] Normalize OAuth failure categories Signed-off-by: Pavel Tkachyk --- src/substrate_wiki/onboarding.py | 27 +++++++++++++++++++++++++-- tests/test_onboarding.py | 19 +++++++++++++++++++ 2 files changed, 44 insertions(+), 2 deletions(-) diff --git a/src/substrate_wiki/onboarding.py b/src/substrate_wiki/onboarding.py index beb1511..d00957d 100644 --- a/src/substrate_wiki/onboarding.py +++ b/src/substrate_wiki/onboarding.py @@ -59,6 +59,20 @@ "http_504", } ) +_TRANSIENT_OAUTH_HTTP_STATUSES = frozenset({408, 425, 429, 500, 502, 503, 504}) +_SAFE_OAUTH_ERRORS = frozenset( + { + "access_denied", + "authorization_pending", + "expired_token", + "invalid_client", + "invalid_grant", + "invalid_request", + "invalid_scope", + "slow_down", + "unsupported_grant_type", + } +) _TERMINAL = {"ready", "declined", "failed", "repair_required"} @@ -68,6 +82,15 @@ def __init__(self, category: str) -> None: super().__init__(category) +def _oauth_failure_category(status: int, value: dict[str, Any]) -> str: + if status in _TRANSIENT_OAUTH_HTTP_STATUSES: + return f"http_{status}" + error = value.get("error") + if isinstance(error, str) and error in _SAFE_OAUTH_ERRORS: + return error + return "invalid_response" + + class _NoRedirect(HTTPRedirectHandler): def redirect_request(self, req: Request, fp: Any, code: int, msg: str, headers: Any, newurl: str) -> None: @@ -136,7 +159,7 @@ def begin(self) -> dict[str, Any]: "/oauth/device_authorization", {"client_id": CLIENT_ID, "scope": SCOPES} ) if status != 200: - raise OnboardingError(str(value.get("error") or f"http_{status}")) + raise OnboardingError(_oauth_failure_category(status, value)) required = ("device_code", "user_code", "verification_uri", "expires_in") if not all(isinstance(value.get(key), (str, int)) for key in required): raise OnboardingError("invalid_response") @@ -188,7 +211,7 @@ def poll(self, device_code: str) -> dict[str, Any]: error = value.get("error") if error in {"authorization_pending", "slow_down", "access_denied", "expired_token"}: return {"status": str(error)} - raise OnboardingError(str(error or f"http_{status}")) + raise OnboardingError(_oauth_failure_category(status, value)) def _empty_state() -> dict[str, Any]: diff --git a/tests/test_onboarding.py b/tests/test_onboarding.py index 7128181..7070cbd 100644 --- a/tests/test_onboarding.py +++ b/tests/test_onboarding.py @@ -220,6 +220,25 @@ def _valid_hosted_capabilities() -> dict[str, object]: } +@pytest.mark.parametrize( + ("status", "expected"), + [(503, "http_503"), (400, "invalid_response")], +) +def test_oauth_error_body_cannot_control_failure_category( + monkeypatch, status: int, expected: str +): + client = HostedOAuthClient() + hostile = "server-controlled credential sk_live_must_not_escape" + monkeypatch.setattr( + client, "_post", lambda path, values: (status, {"error": hostile}) + ) + + with pytest.raises(OnboardingError) as caught: + client.poll("device-secret") + assert caught.value.category == expected + assert hostile not in str(caught.value) + + def test_capability_check_retries_one_tenant_cold_start(tmp_path: Path, monkeypatch): from substrate_wiki.client import SubstrateAPIError, SubstrateClient from substrate_wiki import onboarding From 386c5437cfbd7fc310cbd5a5c754dec9d0532274 Mon Sep 17 00:00:00 2001 From: Pavel Tkachyk Date: Mon, 17 Aug 2026 15:19:20 -0400 Subject: [PATCH 3/5] Refresh OAuth polling publication seals Signed-off-by: Pavel Tkachyk --- docs/extraction-manifest.json | 6 +++--- scripts/verify_public_plugin_candidate.py | 4 ++-- 2 files changed, 5 insertions(+), 5 deletions(-) diff --git a/docs/extraction-manifest.json b/docs/extraction-manifest.json index 4667671..a1ab469 100644 --- a/docs/extraction-manifest.json +++ b/docs/extraction-manifest.json @@ -137,13 +137,13 @@ "class": "standalone_plugin_implementation", "path": "src/substrate_wiki/onboarding.py", "reason": "Added for hosted Hermes 0.20 automatic onboarding.", - "sha256": "52e11806e20017c920d4c0caef394570a8fdfb2cabff8a87cdbde3dc7e5a64fc" + "sha256": "2f1d35c0c568a6d817a3cfcb36b99a4447636ed8ab512d656a4c4411f4a48967" }, { "class": "standalone_repository_policy_or_test", "path": "tests/test_onboarding.py", "reason": "Added for hosted Hermes 0.20 automatic onboarding.", - "sha256": "03406c0caae1970d8e1b85021cafd3d560b293c60e54cd52afa5606be72a1b77" + "sha256": "a03dfc461c4cf920f1c297acac0cf7bddbbf59ac7685394a63caa67e43b78dad" }, { "class": "standalone_repository_policy_or_test", @@ -385,7 +385,7 @@ { "class": "plugin-package", "destination": "src/substrate_wiki/cli.py", - "destination_sha256": "4c500ce6348130fb097b8d3661bb883c4d4bf18eaee42773bf1d5cb9dd821f96", + "destination_sha256": "ba978f5f9f0d48eb2288c8d470182c0ef38a56106e5bc90445352bb90981c044", "source": "hermes-plugin/substrate_wiki/cli.py", "source_sha256": "454bffeb76fcde508b7a16cc7597b444611ecb0028e8d43796c57c500cf75d3c", "transformation": "copied" diff --git a/scripts/verify_public_plugin_candidate.py b/scripts/verify_public_plugin_candidate.py index 4845486..3ff0747 100644 --- a/scripts/verify_public_plugin_candidate.py +++ b/scripts/verify_public_plugin_candidate.py @@ -215,7 +215,7 @@ 'src/substrate_wiki/README.md': frozenset({'44a11fda85d6d8170d772beadba2149bd114624479344b69a69cd49634309204'}), 'src/substrate_wiki/__init__.py': frozenset({'425cd191f723805bed85d965d2d74daeac272dd0660eadf6bac92f1fa02d6f4a'}), 'src/substrate_wiki/client.py': frozenset({'082164ad24c879f6ca6434a8f28c251cd1ee7f4b413c5a788a70b351e2187f2a', 'f98518e2eea1d57e813130822ea95de1fcc5b550adf9b65164347468eadc6818', 'a6e7c18e916057835e8cae9e3aa89bcc7357b397f014b8bc5bb71d995a5aa841'}), - 'src/substrate_wiki/onboarding.py': frozenset({'d58fc9693f78cb4d5f8d9738c9895f009e098d73400d4e9530e7454662fabff1', 'da01090b5f007d9741a06d7ff4a9c8140036e4ef26512665b4802800e0b543a3', '52e11806e20017c920d4c0caef394570a8fdfb2cabff8a87cdbde3dc7e5a64fc'}), + 'src/substrate_wiki/onboarding.py': frozenset({'2f1d35c0c568a6d817a3cfcb36b99a4447636ed8ab512d656a4c4411f4a48967', '4ae1938346a5af64b3936913c4b8438891d8170c6caa617b2eb3b5c816079ec0', '52e11806e20017c920d4c0caef394570a8fdfb2cabff8a87cdbde3dc7e5a64fc', 'd58fc9693f78cb4d5f8d9738c9895f009e098d73400d4e9530e7454662fabff1', 'da01090b5f007d9741a06d7ff4a9c8140036e4ef26512665b4802800e0b543a3'}), 'scripts/install_hermes_plugin.py': frozenset({'69af75e4240166896031f3a396fd0b2bdc4d00adbc836d1a4f22019bc6713b75', '33adb95c93f478a91991a97f0b9b6a1c9d2cee77e7894ed37fe331a4403b0bb8', '4b34ee40d0d08ef24d03128e1cfc5ef73c69b39ca77b3fff59f4a4133cef76f2'}), 'tests/test_hardening.py': frozenset({'f5f87125f1edd37bff1d44301d6bb0f44cc7faf3ba6122bdbe7569f349fea7a3'}), 'tests/test_memory_provider.py': frozenset({'2c4517847dfad341063a69afcc737316a74574471fc98a45eaff21cfe4e271fd'}), @@ -233,7 +233,7 @@ "4b444b2583fbdd340b17d279fd169103c57f87a56dece39988d784b311222920" ) TRUSTED_HISTORICAL_BLOB_POLICY_SHA256 = ( - "567036d3e914d8a3302589f978fe2482eeeff75a08b4e5aa631601d9ba0a5f1f" + "e9a0fe224ed8aefb458e9383ed86f1f90fdbb3c3a7445849f28530ebd0557a8d" ) SCANNER_PATH = "scripts/verify_public_plugin_candidate.py" DESTINATION_MANIFEST_PATH = "docs/extraction-manifest.json" From d03be0f622341bd97dc1426b5da0f0cdb6b3026a Mon Sep 17 00:00:00 2001 From: Pavel Tkachyk Date: Mon, 17 Aug 2026 15:45:11 -0400 Subject: [PATCH 4/5] Seal the updated publication scanner Signed-off-by: Pavel Tkachyk --- docs/extraction-manifest.json | 2 +- scripts/verify_public_plugin_candidate.py | 2 +- 2 files changed, 2 insertions(+), 2 deletions(-) diff --git a/docs/extraction-manifest.json b/docs/extraction-manifest.json index a1ab469..df2cbe9 100644 --- a/docs/extraction-manifest.json +++ b/docs/extraction-manifest.json @@ -353,7 +353,7 @@ { "class": "build-and-install", "destination": "scripts/verify_public_plugin_candidate.py", - "destination_sha256": "8de69ac64758975370bf149b27526170bc0f00c43b56d63f2d47f3f5f30c457f", + "destination_sha256": "e9b205477ed467c409774755cd4ded2b0031c8044ea6bb13daf0ee85b1c63a92", "source": "scripts/verify_public_plugin_candidate.py", "source_sha256": "4130935d530075fce1758e2e89bd5d973a722e2293b5b1058cfe0d17f326172b", "transformation": "modified_for_standalone" diff --git a/scripts/verify_public_plugin_candidate.py b/scripts/verify_public_plugin_candidate.py index 3ff0747..ec05964 100644 --- a/scripts/verify_public_plugin_candidate.py +++ b/scripts/verify_public_plugin_candidate.py @@ -233,7 +233,7 @@ "4b444b2583fbdd340b17d279fd169103c57f87a56dece39988d784b311222920" ) TRUSTED_HISTORICAL_BLOB_POLICY_SHA256 = ( - "e9a0fe224ed8aefb458e9383ed86f1f90fdbb3c3a7445849f28530ebd0557a8d" + "52d7ecc6def648b5c1a0bb33e70f2cee55f5484be7487c35009ccc00cdb4a42b" ) SCANNER_PATH = "scripts/verify_public_plugin_candidate.py" DESTINATION_MANIFEST_PATH = "docs/extraction-manifest.json" From b164d85b262bcc8503e667accd3d6e9fa29f90df Mon Sep 17 00:00:00 2001 From: Pavel Tkachyk Date: Mon, 17 Aug 2026 15:50:44 -0400 Subject: [PATCH 5/5] Align publication history with remote refs Signed-off-by: Pavel Tkachyk --- docs/extraction-manifest.json | 2 +- scripts/verify_public_plugin_candidate.py | 2 +- 2 files changed, 2 insertions(+), 2 deletions(-) diff --git a/docs/extraction-manifest.json b/docs/extraction-manifest.json index df2cbe9..58353a3 100644 --- a/docs/extraction-manifest.json +++ b/docs/extraction-manifest.json @@ -353,7 +353,7 @@ { "class": "build-and-install", "destination": "scripts/verify_public_plugin_candidate.py", - "destination_sha256": "e9b205477ed467c409774755cd4ded2b0031c8044ea6bb13daf0ee85b1c63a92", + "destination_sha256": "78dee8e9765fd0bda094a8b3ec25b582680506cb0ea4f7229bcaf9d6499697be", "source": "scripts/verify_public_plugin_candidate.py", "source_sha256": "4130935d530075fce1758e2e89bd5d973a722e2293b5b1058cfe0d17f326172b", "transformation": "modified_for_standalone" diff --git a/scripts/verify_public_plugin_candidate.py b/scripts/verify_public_plugin_candidate.py index ec05964..a637aab 100644 --- a/scripts/verify_public_plugin_candidate.py +++ b/scripts/verify_public_plugin_candidate.py @@ -233,7 +233,7 @@ "4b444b2583fbdd340b17d279fd169103c57f87a56dece39988d784b311222920" ) TRUSTED_HISTORICAL_BLOB_POLICY_SHA256 = ( - "52d7ecc6def648b5c1a0bb33e70f2cee55f5484be7487c35009ccc00cdb4a42b" + "2a94f0ce0443952df1c7be7eb1f12cf7aabad96a4e9ccf482acc0ae73d2606ae" ) SCANNER_PATH = "scripts/verify_public_plugin_candidate.py" DESTINATION_MANIFEST_PATH = "docs/extraction-manifest.json"