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"