Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
8 changes: 4 additions & 4 deletions docs/extraction-manifest.json
Original file line number Diff line number Diff line change
Expand Up @@ -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",
Expand Down Expand Up @@ -353,7 +353,7 @@
{
"class": "build-and-install",
"destination": "scripts/verify_public_plugin_candidate.py",
"destination_sha256": "8de69ac64758975370bf149b27526170bc0f00c43b56d63f2d47f3f5f30c457f",
"destination_sha256": "78dee8e9765fd0bda094a8b3ec25b582680506cb0ea4f7229bcaf9d6499697be",
"source": "scripts/verify_public_plugin_candidate.py",
"source_sha256": "4130935d530075fce1758e2e89bd5d973a722e2293b5b1058cfe0d17f326172b",
"transformation": "modified_for_standalone"
Expand Down Expand Up @@ -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"
Expand Down
4 changes: 2 additions & 2 deletions scripts/verify_public_plugin_candidate.py
Original file line number Diff line number Diff line change
Expand Up @@ -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'}),
Expand All @@ -233,7 +233,7 @@
"4b444b2583fbdd340b17d279fd169103c57f87a56dece39988d784b311222920"
)
TRUSTED_HISTORICAL_BLOB_POLICY_SHA256 = (
"567036d3e914d8a3302589f978fe2482eeeff75a08b4e5aa631601d9ba0a5f1f"
"2a94f0ce0443952df1c7be7eb1f12cf7aabad96a4e9ccf482acc0ae73d2606ae"
)
SCANNER_PATH = "scripts/verify_public_plugin_candidate.py"
DESTINATION_MANIFEST_PATH = "docs/extraction-manifest.json"
Expand Down
3 changes: 3 additions & 0 deletions src/substrate_wiki/cli.py
Original file line number Diff line number Diff line change
Expand Up @@ -12,6 +12,7 @@

from .checkpoint import ImportCheckpoint
from .client import SubstrateClient
from .onboarding import OnboardingError
from .history import (
HermesHistoryImporter,
load_hermes_inventory,
Expand Down Expand Up @@ -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:
Expand Down
56 changes: 51 additions & 5 deletions src/substrate_wiki/onboarding.py
Original file line number Diff line number Diff line change
Expand Up @@ -46,6 +46,33 @@
"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",
}
)
_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"}


Expand All @@ -55,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:
Expand All @@ -76,7 +112,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())

Expand Down Expand Up @@ -123,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")
Expand Down Expand Up @@ -175,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]:
Expand Down Expand Up @@ -289,7 +325,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
Expand Down Expand Up @@ -368,7 +405,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":
Expand Down Expand Up @@ -399,6 +444,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()

Expand Down
64 changes: 64 additions & 0 deletions tests/test_onboarding.py
Original file line number Diff line number Diff line change
Expand Up @@ -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")
Expand Down Expand Up @@ -175,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
Expand Down
Loading