diff --git a/agent/error_classifier.py b/agent/error_classifier.py index d35dfca763..61ef9ec610 100644 --- a/agent/error_classifier.py +++ b/agent/error_classifier.py @@ -569,7 +569,11 @@ def _nous_welcome_tier(c: _Ctx) -> Optional[Verdict]: if refusal["retry_after"] > 0: ctx["reset_at"] = time.time() + refusal["retry_after"] return _v(_R.rate_limit, should_fallback=True, error_context=ctx) - kind = welcome_route_refusal(status, c.msg, c.base_url if c.provider == "nous" else None) + # The route-keyed dark-tier 403 applies only to a 403 that says nothing else: a safety refusal + # or a billing wall on the welcome host keeps its own classification (and its own recovery). + plain_403 = c.provider == "nous" and not any( + p in c.msg for p in _CONTENT_POLICY_BLOCKED_PATTERNS + _BILLING_PATTERNS) + kind = welcome_route_refusal(status, c.msg, c.base_url if plain_403 else None) if kind is None: return None ctx = {"welcome_route": kind} diff --git a/agent/turn_recovery.py b/agent/turn_recovery.py index e38fedd857..08b7eae0d3 100644 --- a/agent/turn_recovery.py +++ b/agent/turn_recovery.py @@ -719,14 +719,21 @@ def _welcome_tier_guidance(classified: Any, *, model: Any, in_chat: bool, door: return welcome_route_refusal_copy(str(route), in_chat=in_chat, door=door) +# Closed table: every card kind the desktop has copy for. An unknown gateway reason lands on +# "refused" (generic card, sentence kept) rather than a code the desktop cannot key on. +_WELCOME_SURFACE_KINDS = { + "rate_limited": "rate_limited", "at_capacity": "at_capacity", "admission_closed": "at_capacity", + "model_not_free": "model_not_free", "feature_not_free": "model_not_free", +} + + def _welcome_surface_kind(classified: Any) -> str: """The free-tier failure kind a client renders its card from (``error_surface`` code ``free_tier_``): the welcome refusal's reason, or the route refusal; "" otherwise.""" ctx = getattr(classified, "error_context", None) or {} refusal = ctx.get("welcome_refusal") if isinstance(ctx, dict) else None if isinstance(refusal, dict): - reason = str(refusal.get("reason") or "") - return {"admission_closed": "at_capacity", "feature_not_free": "model_not_free"}.get(reason, reason) or "refused" + return _WELCOME_SURFACE_KINDS.get(str(refusal.get("reason") or ""), "refused") route = ctx.get("welcome_route") if isinstance(ctx, dict) else None if route == "tier_disabled": return "disabled" @@ -748,8 +755,9 @@ def _welcome_outage_copy(base_url: Any, classified: Any) -> str: from hermes_cli.anon_auth import FREE_TIER_OUTAGE_COPY, route_is_welcome_host if not route_is_welcome_host(base_url): return "" - if classified.reason in (FailoverReason.timeout, FailoverReason.overloaded, - FailoverReason.server_error, FailoverReason.unknown): + # Not ``unknown``: that is the classifier's catch-all for status-less local failures, which + # are not the free model's trouble. + if classified.reason in (FailoverReason.timeout, FailoverReason.overloaded, FailoverReason.server_error): return FREE_TIER_OUTAGE_COPY except Exception: pass @@ -1410,9 +1418,12 @@ def _is_genuine_nous_rate_limit(agent: Any, api_error: Exception, error_context: is_genuine_nous_rate_limit, is_long_welcome_rate_limit, record_nous_rate_limit) _err_resp = getattr(api_error, "response", None) _err_hdrs = getattr(_err_resp, "headers", None) if _err_resp else None + from hermes_cli.anon_auth import route_is_welcome_host _classified_ctx = getattr(classified, "error_context", None) or {} + # Route-gated: only the welcome host's fairshare body is an allowance verdict; a paid-host + # 429 keeps main's rule (an exhausted x-ratelimit bucket), whatever its body says. _genuine = ( - is_long_welcome_rate_limit(_classified_ctx) + (route_is_welcome_host(getattr(agent, "base_url", "")) and is_long_welcome_rate_limit(_classified_ctx)) or is_genuine_nous_rate_limit(headers=_err_hdrs, last_known_state=agent._rate_limit_state)) if _genuine: _merged = {**(error_context if isinstance(error_context, dict) else {}), **_classified_ctx} diff --git a/hermes_cli/anon_auth.py b/hermes_cli/anon_auth.py index 3c8f87b012..1cba7f2d07 100644 --- a/hermes_cli/anon_auth.py +++ b/hermes_cli/anon_auth.py @@ -68,10 +68,9 @@ class AnonCredentialDead(AuthError): """ -def _anon_err( - message: str, code: str, *, retry_after: Optional[float] = None, retryable: bool = True, -) -> AuthError: - return AuthError(message, code=code, retry_after=retry_after, retryable=retryable) +def _anon_err(message: str, code: str, *, retry_after: Optional[float] = None) -> AuthError: + """An ``AuthError`` for a free-tier failure *code*; terminal-ness is the code's (``ANON_TERMINAL_CODES``).""" + return AuthError(message, code=code, retry_after=retry_after, retryable=code not in ANON_TERMINAL_CODES) # --- Free-tier failure codes ------------------------------------------------------------------------ @@ -250,6 +249,22 @@ def _anon_headers() -> Dict[str, str]: return headers +# (status, NAS ``error``) -> (exception class, code). ``None`` matches any error string for that +# status; an exact pair wins over the wildcard. Anything unlisted is a server error. +_NAS_REFUSALS: Dict[tuple, tuple] = { + (404, "unknown_token"): (AnonCredentialDead, ANON_CREDENTIAL_DEAD), + (404, None): (AuthError, ANON_GATE_CLOSED), # uniform with a nonexistent route, on purpose + (401, "invalid_shared_secret"): (AuthError, ANON_GATE_CLOSED), # pre-launch NAS builds only + (401, None): (AnonCredentialDead, ANON_CREDENTIAL_DEAD), + (403, "account_locked"): (AnonCredentialDead, ANON_ACCOUNT_LOCKED), + (403, "anonymous_accounts_disabled"): (AuthError, ANON_GATE_PAUSED), # pre-launch names + (403, "circuit_open"): (AuthError, ANON_GATE_PAUSED), + (428, None): (AuthError, ANON_POW_REQUIRED), + (429, None): (AuthError, ANON_RATE_LIMITED), + (503, "temporarily_disabled"): (AuthError, ANON_GATE_PAUSED), +} + + def _raise_for_anon_status(response: httpx.Response, *, action: str) -> Dict[str, Any]: try: payload = response.json() @@ -261,31 +276,15 @@ def _raise_for_anon_status(response: httpx.Response, *, action: str) -> Dict[str status = response.status_code if status in (200, 201): return payload - if status == 404 and error == "unknown_token": - raise AnonCredentialDead(ANON_FAILURE_COPY[ANON_CREDENTIAL_DEAD], code=ANON_CREDENTIAL_DEAD, retryable=False) - if status == 404: - # The gate's "surface not enabled" answer is uniform with a nonexistent route on purpose. - raise _anon_err(ANON_FAILURE_COPY[ANON_GATE_CLOSED], ANON_GATE_CLOSED, retryable=False) - if status == 401 and error == "invalid_shared_secret": # pre-launch NAS builds only - raise _anon_err(ANON_FAILURE_COPY[ANON_GATE_CLOSED], ANON_GATE_CLOSED, retryable=False) - if status == 401: - raise AnonCredentialDead(ANON_FAILURE_COPY[ANON_CREDENTIAL_DEAD], code=ANON_CREDENTIAL_DEAD, retryable=False) - if status == 403 and error == "account_locked": - raise AnonCredentialDead(ANON_FAILURE_COPY[ANON_ACCOUNT_LOCKED], code=ANON_ACCOUNT_LOCKED, retryable=False) - if status == 403 and error in {"anonymous_accounts_disabled", "circuit_open"}: # pre-launch names - raise _anon_err(ANON_FAILURE_COPY[ANON_GATE_PAUSED], ANON_GATE_PAUSED) - if status == 429: - retry_after = parse_retry_after_seconds(response.headers) - raise _anon_err(anon_failure_copy(ANON_RATE_LIMITED, retry_after=retry_after), ANON_RATE_LIMITED, - retry_after=retry_after) - if status == 428 or error.startswith("pow_"): - raise _anon_err(ANON_FAILURE_COPY[ANON_POW_REQUIRED], ANON_POW_REQUIRED, retryable=False) - if status == 503 and error == "temporarily_disabled": - raise _anon_err(ANON_FAILURE_COPY[ANON_GATE_PAUSED], ANON_GATE_PAUSED, - retry_after=parse_retry_after_seconds(response.headers)) - logger.info("Nous free tier %s failed (%s%s)", action, status, f": {error}" if error else "") - raise _anon_err(ANON_FAILURE_COPY[ANON_SERVER_ERROR], ANON_SERVER_ERROR, - retry_after=parse_retry_after_seconds(response.headers)) + if error.startswith("pow_"): + error = "pow_" # pow_required / pow_invalid / pow_replayed are one verdict + cls, code = (_NAS_REFUSALS.get((status, error)) or _NAS_REFUSALS.get((status, None)) + or ((AuthError, ANON_POW_REQUIRED) if error == "pow_" else (AuthError, ANON_SERVER_ERROR))) + if code == ANON_SERVER_ERROR: + logger.info("Nous free tier %s failed (%s%s)", action, status, f": {error}" if error else "") + retry_after = parse_retry_after_seconds(response.headers) + raise cls(anon_failure_copy(code, retry_after=retry_after), code=code, retry_after=retry_after, + retryable=code not in ANON_TERMINAL_CODES) def mint_guest(client: httpx.Client, portal_base_url: str) -> Dict[str, Any]: @@ -443,12 +442,11 @@ def last_mint_failure() -> Optional[Dict[str, Any]]: return failure.as_payload() if failure else None -def _classify_mint_exception(exc: BaseException) -> AuthError: +def classify_mint_exception(exc: BaseException) -> AuthError: """Every mint failure as one ``AuthError`` with a code: the portal's own refusals already are; - the wire's (timeout, DNS, refused connection) and anything else get a code here.""" - if isinstance(exc, AuthError): - if exc.code is None: - exc.code = ANON_SERVER_ERROR + the wire's (timeout, DNS, refused connection) and anything else get a code here. Pure: *exc* is + never mutated (an uncoded ``AuthError`` is re-raised as a server-error twin).""" + if isinstance(exc, AuthError) and exc.code: return exc transport = (TimeoutError, ConnectionError, OSError) try: @@ -465,7 +463,7 @@ def _note_mint_failure(err: AuthError) -> MintFailure: code = str(err.code or ANON_SERVER_ERROR) previous = _mint_failure_for_profile() attempts = previous.attempts + 1 if previous and previous.code == code else 1 - retryable = code not in ANON_TERMINAL_CODES and err.retryable is not False + retryable = code not in ANON_TERMINAL_CODES if not retryable: wait, not_before = 0.0, float("inf") else: @@ -555,7 +553,7 @@ def ensure_portal_identity( state = _reconcile_and_provision( timeout_seconds=timeout_seconds, carries_inference=carries_inference) except Exception as exc: - err = _classify_mint_exception(exc) + err = classify_mint_exception(exc) noted = _note_mint_failure(err) logger.info("Nous free tier not set up (%s, attempt %d%s)", noted.code, noted.attempts, f", next try in {noted.retry_after:.0f}s" if noted.retryable else ", not retried") @@ -576,7 +574,7 @@ def refresh_guest_state(state: Dict[str, Any], client: httpx.Client) -> None: """ anon_token = state.get("anon_token") if not isinstance(anon_token, str) or not anon_token: - raise AnonCredentialDead(ANON_FAILURE_COPY[ANON_CREDENTIAL_DEAD], code=ANON_CREDENTIAL_DEAD, retryable=False) + raise AnonCredentialDead(ANON_FAILURE_COPY[ANON_CREDENTIAL_DEAD], code=ANON_CREDENTIAL_DEAD) from hermes_cli.auth import _nous_portal_base_url apply_exchange_to_state(state, exchange_anon_jwt(client, _nous_portal_base_url(state), anon_token)) @@ -690,7 +688,7 @@ def welcome_refusal_copy(refusal: Dict[str, Any], *, model: str = "", in_chat: b if reason == "model_not_free": what = f"{model} isn't" if model else "That model isn't" return (f"{what} available without signing in, so Hermes uses {serves} for now. " - f"Sign in for more models. {signin}") + f"Sign in for more models. {signin}").rstrip() if reason == "feature_not_free": return f"That isn't available without signing in. Sign in to use it, it's free. {signin}".rstrip() if reason == "at_capacity": diff --git a/hermes_cli/anon_sign_in.py b/hermes_cli/anon_sign_in.py index 62ae8f7273..d1238c4bfb 100644 --- a/hermes_cli/anon_sign_in.py +++ b/hermes_cli/anon_sign_in.py @@ -51,7 +51,7 @@ LOGIN_NOT_ALLOWED = "Only an operator of this Hermes can sign it in." FREE_TIER_RATE_LIMIT_CARD = ( "You've used up the allowance for chatting without signing in. It refreshes in {reset}. " "Sign in for a bigger allowance, it's free.") -FREE_TIER_RATE_LIMIT_CHAT = FREE_TIER_RATE_LIMIT_CARD[:-1] + ": /login." +FREE_TIER_RATE_LIMIT_CHAT = FREE_TIER_RATE_LIMIT_CARD + " To sign in: /login." def format_wait_line(expires_in: int) -> str: @@ -255,7 +255,7 @@ def _failed_from_exception(exc: BaseException) -> Failed: an ``AuthError`` carries, or the wire's shape for a transport error. The raw detail never reaches a chat; ``copy_terminal`` may show it when nothing better is known.""" from hermes_cli import anon_auth as _core - err = _core._classify_mint_exception(exc) + err = _core.classify_mint_exception(exc) reason = str(err.code or "") if reason == _core.ANON_SERVER_ERROR and not isinstance(exc, _core.AuthError): # An unnamed local failure (a bad CA bundle, a lock timeout): keep today's generic copy diff --git a/hermes_cli/free_tier_bootstrap.py b/hermes_cli/free_tier_bootstrap.py index ebe7b5467b..c77cd71988 100644 --- a/hermes_cli/free_tier_bootstrap.py +++ b/hermes_cli/free_tier_bootstrap.py @@ -38,24 +38,17 @@ class SetupRecord: has_identity: bool # a Nous identity (free tier or account) is on disk other_providers: bool # the inventory found something usable BESIDES the free tier error: str = "" # why the mint did not happen, when it did not; "" otherwise - # The structured form of ``error`` (``anon_auth.ANON_*`` codes): what a renderer keys its copy - # and its doors on. ``retryable`` says whether a later attempt can succeed; ``retry_after`` is - # the seconds still to wait before one may (0 when none is owed, or the code is terminal). - error_code: str = "" - retryable: bool = False - retry_after: int = 0 + # The mint memo's verdict, verbatim (``anon_auth.MintFailure.as_payload``): + # ``{error, error_code, retryable, retry_after}`` when the mint did not happen, else ``{}``. + # One wire shape: every status RPC spreads it as is. + failure: Dict[str, Any] = field(default_factory=dict) finished_at: float = field(default_factory=time.time) def as_payload(self) -> Dict[str, Any]: return asdict(self) def failure_fields(self) -> Dict[str, Any]: - """The additive ``{error, error_code, retryable, retry_after}`` block every status RPC - carries when the mint did not happen; ``{}`` otherwise.""" - if not self.error_code: - return {} - return {"error": self.error, "error_code": self.error_code, "retryable": self.retryable, - "retry_after": self.retry_after} + return dict(self.failure) _lock = threading.Lock() @@ -138,9 +131,7 @@ def _build_record(*, other: bool, force: bool) -> SetupRecord: has_identity=bool(state), other_providers=other, error=error, - error_code=str(failure.get("error_code") or ""), - retryable=bool(failure.get("retryable", False)), - retry_after=int(failure.get("retry_after") or 0), + failure=failure, ) @@ -194,6 +185,10 @@ def retry_bootstrap_mint(*, force: bool = False, announce: bool = True) -> Setup # boot-time answer is stale by now. record = _build_record(other=_inventory_other_providers(), force=force) with _lock: + # Two retries can race (the background loop and the user's click): a build that found no + # identity must not overwrite one that did. + if _record is not None and _record.has_identity and not record.has_identity: + return _record _record = record if announce: _broadcast(record) @@ -205,9 +200,9 @@ def _retry_until_settled() -> None: again, up to ``BOOTSTRAP_RETRY_ATTEMPTS``, while the record says a later attempt can succeed.""" for _ in range(BOOTSTRAP_RETRY_ATTEMPTS): record = _record - if record is None or record.has_identity or not record.error_code or not record.retryable: + if record is None or record.has_identity or not record.failure.get("retryable"): return - _sleep(max(1, int(record.retry_after or 0))) + _sleep(max(1, int(record.failure.get("retry_after") or 0))) record = retry_bootstrap_mint(force=False) if record.has_identity: logger.info("Nous free tier set up after a boot-time retry") diff --git a/tests/agent/test_nous_welcome_client_contract.py b/tests/agent/test_nous_welcome_client_contract.py index 6ff7017cfc..ebb7183c16 100644 --- a/tests/agent/test_nous_welcome_client_contract.py +++ b/tests/agent/test_nous_welcome_client_contract.py @@ -131,7 +131,9 @@ class TestRefusalCopy: def test_copy_names_the_served_model_and_the_sign_in(self): refusal = anon_auth.parse_welcome_refusal({"reason": "model_not_free", "alternates": ["nous/welcome"]}) chat = anon_auth.welcome_refusal_copy(refusal, model="gpt-5", in_chat=True) - assert chat == "gpt-5 isn't available without signing in, so Hermes uses nous/welcome for now. Sign in for more models. To sign in: /login." + assert "gpt-5" in chat and "nous/welcome" in chat and chat.endswith(anon_auth._SIGNIN_CHAT) + card = anon_auth.welcome_refusal_copy(refusal, model="gpt-5", in_chat=True, door=False) + assert chat == f"{card} {anon_auth._SIGNIN_CHAT}" # door=False drops exactly the tail terminal = anon_auth.welcome_refusal_copy(refusal, model="gpt-5", in_chat=False) assert "`hermes auth upgrade`" in terminal and "/login" not in terminal diff --git a/tests/agent/test_welcome_tier_recovery.py b/tests/agent/test_welcome_tier_recovery.py index 6d6f1699b3..663145112e 100644 --- a/tests/agent/test_welcome_tier_recovery.py +++ b/tests/agent/test_welcome_tier_recovery.py @@ -55,6 +55,13 @@ class TestDarkTier403: def test_the_same_403_from_the_paid_host_stays_an_ordinary_403(self): assert "welcome_route" not in _classify(_generic_403(), base_url=PAID).error_context + def test_a_403_that_says_something_else_keeps_its_own_classification(self): + """A safety refusal or a billing wall on the welcome host is not the tier going dark.""" + body = {"status": 403, "message": "This request violates our usage policies."} + result = _classify(_gateway_error(403, body)) + assert "welcome_route" not in result.error_context + assert result.reason == FailoverReason.content_policy_blocked + def test_a_403_from_another_provider_on_any_host_is_untouched(self): result = classify_api_error(_generic_403(), provider="openrouter", base_url=WELCOME) assert "welcome_route" not in result.error_context @@ -140,6 +147,10 @@ class TestLongWaitRule: classified = _classify(err) assert _is_genuine_nous_rate_limit(_agent(), err, turn_ctx, classified) is True assert recorded and recorded[0]["error_context"]["reset_at"] == classified.error_context["reset_at"] + # The same body from the PAID host is not an allowance verdict: main's header rule stands. + recorded.clear() + assert _is_genuine_nous_rate_limit(_agent(base_url=PAID), err, turn_ctx, classified) is False + assert recorded == [] # A short one is not an exhausted allowance: nothing recorded, the turn waits it out. recorded.clear() short = _refusal("rate_limited", retry_after=5) @@ -149,7 +160,7 @@ class TestLongWaitRule: class TestOutageCopy: @pytest.mark.parametrize("reason", [FailoverReason.timeout, FailoverReason.overloaded, - FailoverReason.server_error, FailoverReason.unknown]) + FailoverReason.server_error]) def test_a_spent_transport_failure_on_the_welcome_host_reads_as_one_sentence(self, reason): from agent.turn_recovery import _welcome_outage_copy from hermes_cli.anon_auth import FREE_TIER_OUTAGE_COPY @@ -159,6 +170,8 @@ class TestOutageCopy: from agent.turn_recovery import _welcome_outage_copy assert _welcome_outage_copy(PAID, SimpleNamespace(reason=FailoverReason.timeout)) == "" assert _welcome_outage_copy(WELCOME, SimpleNamespace(reason=FailoverReason.rate_limit)) == "" + # ``unknown`` is the catch-all for status-less local failures, not the free model's trouble. + assert _welcome_outage_copy(WELCOME, SimpleNamespace(reason=FailoverReason.unknown)) == "" class TestTerminalResultsCarryTheFreeTierBlock: @@ -184,7 +197,7 @@ class TestTerminalResultsCarryTheFreeTierBlock: # The chat text names /login; the card text (a button beside it) leaves that tail off. assert "switched off" in result["final_response"] and "/login" in result["final_response"] assert result["free_tier"]["kind"] == "disabled" - assert result["free_tier"]["message"] == result["final_response"].replace(" To sign in: /login.", "") + assert result["final_response"].startswith(result["free_tier"]["message"]) assert "/login" not in result["free_tier"]["message"] assert result["error"] == "HTTP 403: no permissions" # the technical detail stays in the log line diff --git a/tests/hermes_cli/test_anon_failure_modes.py b/tests/hermes_cli/test_anon_failure_modes.py index 808788ece5..0d14c51d39 100644 --- a/tests/hermes_cli/test_anon_failure_modes.py +++ b/tests/hermes_cli/test_anon_failure_modes.py @@ -130,6 +130,12 @@ class TestNasRefusalCodes: assert err.code == anon_auth.ANON_SERVER_ERROR and err.retryable is not False assert "hiccup" in str(err) + def test_a_bare_401_on_sign_up_rides_the_ladder_rather_than_dying_for_the_process(self, nas): + nas.create_response = httpx.Response(401, json={}) + err = _mint_error(nas) + assert err.code == anon_auth.ANON_CREDENTIAL_DEAD + assert anon_auth.last_mint_failure()["retryable"] is True + def test_the_wire_failing_is_unreachable(self, nas): nas.raise_transport = httpx.ConnectTimeout("no route") err = _mint_error(nas) @@ -192,11 +198,11 @@ class TestBootstrapRecord: 429, json={"error": "temporarily_unavailable"}, headers={"Retry-After": "30"}) record = free_tier_bootstrap.run_bootstrap(announce=False) assert record.has_identity is False and record.free_tier is False - assert record.error_code == anon_auth.ANON_RATE_LIMITED - assert record.retryable is True and 28 <= record.retry_after <= 30 + assert record.failure["error_code"] == anon_auth.ANON_RATE_LIMITED + assert record.failure["retryable"] is True and 28 <= record.failure["retry_after"] <= 30 assert record.failure_fields() == { "error": record.error, "error_code": anon_auth.ANON_RATE_LIMITED, "retryable": True, - "retry_after": record.retry_after} + "retry_after": record.failure["retry_after"]} def test_a_clean_boot_carries_no_failure_block(self, nas): record = free_tier_bootstrap.run_bootstrap(announce=False) @@ -224,7 +230,7 @@ class TestBootstrapRecord: nas.create_response = httpx.Response(404, json={"error": "not_found"}) monkeypatch.setattr(free_tier_bootstrap, "_sleep", lambda s: pytest.fail("must not sleep")) free_tier_bootstrap._bootstrap_then_retry() - assert free_tier_bootstrap.current_record().error_code == anon_auth.ANON_GATE_CLOSED + assert free_tier_bootstrap.current_record().failure["error_code"] == anon_auth.ANON_GATE_CLOSED assert nas.creates() == 1 def test_the_background_loop_is_bounded(self, nas, monkeypatch): @@ -236,7 +242,7 @@ class TestBootstrapRecord: monkeypatch.setattr(free_tier_bootstrap, "_sleep", _sleep) free_tier_bootstrap._bootstrap_then_retry() assert nas.creates() == 1 + free_tier_bootstrap.BOOTSTRAP_RETRY_ATTEMPTS - assert free_tier_bootstrap.current_record().error_code == anon_auth.ANON_UNREACHABLE + assert free_tier_bootstrap.current_record().failure["error_code"] == anon_auth.ANON_UNREACHABLE def test_a_retry_re_inventories_so_a_provider_connected_meanwhile_keeps_inference(self, nas, monkeypatch): nas.raise_transport = httpx.ConnectTimeout("no route") @@ -248,6 +254,24 @@ class TestBootstrapRecord: assert record.has_identity is True and record.other_providers is True assert _load_auth_store().get("active_provider") != "nous" + def test_a_late_failed_build_never_overwrites_a_success_that_landed_meanwhile(self, nas, monkeypatch): + """The background loop and the user's click can race: the loop's build (no identity, still + in cooldown) must not replace the record the click just wrote.""" + nas.raise_transport = httpx.ConnectTimeout("no route") + free_tier_bootstrap.run_bootstrap(announce=False) + real_build = free_tier_bootstrap._build_record + + def slow_build(**kw): + stale = real_build(**kw) # no identity: still inside the cooldown + monkeypatch.setattr(free_tier_bootstrap, "_build_record", real_build) + nas.raise_transport = None # ...meanwhile the click's forced mint succeeds + free_tier_bootstrap.retry_bootstrap_mint(force=True, announce=False) + return stale + monkeypatch.setattr(free_tier_bootstrap, "_build_record", slow_build) + record = free_tier_bootstrap.retry_bootstrap_mint(force=False, announce=False) + assert record.has_identity is True + assert free_tier_bootstrap.current_record().has_identity is True + def test_the_desktop_retry_refreshes_the_boot_record(self, nas): nas.raise_transport = httpx.ConnectTimeout("no route") free_tier_bootstrap.run_bootstrap(announce=False) @@ -270,7 +294,7 @@ class TestSignInFailures: ]) def test_a_service_verdict_keeps_its_code_wait_and_copy(self, code, retry_after, retryable, needle): state = anon_sign_in._failed_from_exception( - anon_auth._anon_err("x", code, retry_after=retry_after or None, retryable=retryable)) + anon_auth._anon_err("x", code, retry_after=retry_after or None)) assert state.kind == "failed" and state.reason == code assert state.retryable is retryable assert state.retry_after == retry_after diff --git a/tests/tui_gateway/test_free_tier_rpc.py b/tests/tui_gateway/test_free_tier_rpc.py index c1c6606de9..3268adaf06 100644 --- a/tests/tui_gateway/test_free_tier_rpc.py +++ b/tests/tui_gateway/test_free_tier_rpc.py @@ -93,6 +93,7 @@ def test_provision_sets_the_free_tier_up_through_the_lifecycle_primitive(tmp_pat monkeypatch.setenv("HERMES_SHARED_AUTH_DIR", str(tmp_path / "shared-store")) monkeypatch.setenv("HERMES_GUEST_ONBOARDING", "1") calls = [] + real_ensure = anon_auth.ensure_portal_identity def fake_provision(**kw): calls.append(kw) @@ -114,9 +115,13 @@ def test_provision_sets_the_free_tier_up_through_the_lifecycle_primitive(tmp_pat with _auth_store_lock(): store = _load_auth_store(); store["providers"].pop("nous"); _save_auth_store(store) - monkeypatch.setattr(anon_auth, "ensure_portal_identity", refused) + # The real primitive memoises the refusal; the RPC reports that memo. + monkeypatch.setattr(anon_auth, "ensure_portal_identity", real_ensure) + monkeypatch.setattr(anon_auth, "_reconcile_and_provision", refused) + anon_auth.reset_mint_memo_for_tests() result = _call("free_tier.provision") assert result["has_guest"] is False and "not open" in result["error"] + assert result["error_code"] == "anon_gate_closed" and result["retryable"] is False _set_guest_off(monkeypatch) monkeypatch.setattr(anon_auth, "ensure_portal_identity", lambda **kw: (_ for _ in ()).throw(AssertionError("must not run"))) diff --git a/tui_gateway/methods_free_tier.py b/tui_gateway/methods_free_tier.py index 8ceacf63d1..a461d1face 100644 --- a/tui_gateway/methods_free_tier.py +++ b/tui_gateway/methods_free_tier.py @@ -57,25 +57,20 @@ def _(rid, params: dict) -> dict: from hermes_cli import anon_auth from hermes_cli import free_tier_bootstrap enabled = anon_auth.guest_enabled() - failure = None if enabled and not anon_auth.has_guest(): if free_tier_bootstrap.current_record() is not None and not params.get("profile"): # The launch profile: refresh the boot record too, so ``setup.status`` and the # ``setup.ready`` listeners move with the outcome. - failure = free_tier_bootstrap.retry_bootstrap_mint(force=True).failure_fields() + free_tier_bootstrap.retry_bootstrap_mint(force=True) else: try: anon_auth.ensure_portal_identity(explicit=True, force=True) - except Exception as exc: + except Exception as exc: # memoised by the primitive before it re-raised logger.info("free tier provisioning failed: %s", exc) - err = anon_auth._classify_mint_exception(exc) - failure = {"error": str(err), "error_code": str(err.code or ""), - "retryable": err.code not in anon_auth.ANON_TERMINAL_CODES, - "retry_after": int(err.retry_after or 0)} has_guest = anon_auth.has_guest() payload = {"has_guest": has_guest, "enabled": enabled} if enabled and not has_guest: - payload.update(anon_auth.last_mint_failure() or failure or {}) + payload.update(anon_auth.last_mint_failure() or {}) return _ok(rid, payload) except Exception as e: return _err(rid, 5092, str(e)) diff --git a/tui_gateway/server.py b/tui_gateway/server.py index ebe1c1e721..2d05f45567 100644 --- a/tui_gateway/server.py +++ b/tui_gateway/server.py @@ -167,7 +167,7 @@ _LONG_HANDLERS = frozenset({ "profiles.list", "profiles.set_asset", "bot_relay.roster.sync", "bot_relay.outbox.drain", "bot_relay.deliver", "bot_relay.reply", "image.generate", "projects.discover_repos", "projects.record_repos", "projects.for_cwd", "projects.tree", "projects.project_sessions", - "setup.runtime_check", "setup.status", "voice.toggle", "voice.record", "voice.tts", "wake.start", + "setup.runtime_check", "setup.status", "free_tier.provision", "voice.toggle", "voice.record", "voice.tts", "wake.start", "wake.status", "session.active_list", "session.branch", "session.compress", "session.list", "session.resume", "session.workspace.move", "shell.exec", "skills.manage", "slash.exec", "command.dispatch", # /goal draft invokes the auxiliary model; never block the RPC reader