From 134b173efab4038023274cbc56d20140bc8b6c49 Mon Sep 17 00:00:00 2001 From: Teknium <127238744+teknium1@users.noreply.github.com> Date: Mon, 7 Sep 2026 03:45:15 -0700 Subject: [PATCH] fix: reject independent Nous account refresh without clearing cooldown --- evals/auth_pool_controls.py | 47 ++++++++++++++++--- hermes_cli/auth_commands.py | 6 +++ tests/hermes_cli/test_auth_pool_operations.py | 6 +++ .../user-guide/features/credential-pools.md | 6 +++ 4 files changed, 58 insertions(+), 7 deletions(-) diff --git a/evals/auth_pool_controls.py b/evals/auth_pool_controls.py index 58a6552e61..aa17a51068 100644 --- a/evals/auth_pool_controls.py +++ b/evals/auth_pool_controls.py @@ -5,6 +5,7 @@ No vendor requests or real credentials are used. The token URL alone is redirect the production parser, command, pool refresh, HTTP client, and persistence execute. """ import argparse +import base64 import json import os from pathlib import Path @@ -32,8 +33,10 @@ def main(): def do_POST(self): payload = parse_qs(self.rfile.read(int(self.headers["Content-Length"])).decode()) requests.append({"grant_type": payload.get("grant_type"), - "target_grant": payload.get("refresh_token") == ["fixture-refresh-1"]}) - body = ({"access_token": "fixture-new-access", "refresh_token": "fixture-new-refresh"} + "target_grant": (payload.get("refresh_token") == ["fixture-refresh-1"] + or self.headers.get("x-nous-refresh-token") == "fixture-refresh-1")}) + body = ({"access_token": nous_new_token if self.path == "/api/oauth/token" else "fixture-new-access", + "refresh_token": "fixture-new-refresh", "expires_in": 3600, "scope": "inference:invoke"} if response_status == 200 else {"error": "invalid_grant" if response_status == 401 else "unavailable"}) self.send_response(response_status) self.send_header("Content-Type", "application/json") @@ -43,6 +46,12 @@ def main(): def log_message(self, format, *values): pass + def token(subject): + encode = lambda value: base64.urlsafe_b64encode(json.dumps(value).encode()).decode().rstrip("=") + return encode({"alg": "none"}) + "." + encode({"sub": subject, "exp": int(time.time()) + 3600, + "scope": "inference:invoke"}) + ".fixture" + + nous_new_token = token("singleton-renewed") server = ThreadingHTTPServer(("127.0.0.1", 0), Endpoint) thread = threading.Thread(target=server.serve_forever, daemon=True) thread.start() @@ -59,6 +68,8 @@ def main(): *[(f"refresh-{status}", "openai-codex", ["refresh", "openai-codex", "row1"], status) for status in (200, 503, 401)], ] + cases.extend((name, "nous", ["refresh", "nous", target], None) for name, target in + (("nous-independent", "row0"), ("nous-singleton", "row1"))) results = [] try: for name, provider, command, status in cases: @@ -73,12 +84,24 @@ def main(): access_token=f"fixture-access-{i}", refresh_token=f"fixture-refresh-{i}", priority=i, last_status="exhausted", last_status_at=now, last_error_code=429, last_error_reset_at=now+3600) for i in range(2)] + providers = {} + if provider == "nous": + for row in rows: + row.update(auth_type="oauth", source="manual:device_code") + state = dict(access_token=token("singleton"), refresh_token="fixture-refresh-1", + expires_at=now+3600, portal_base_url=f"http://127.0.0.1:{server.server_port}", + scope="inference:invoke", inference_base_url="https://inference-api.nousresearch.com/v1") + rows[1].update(source="device_code", **state) + providers["nous"] = state store = home / "auth.json" - store.write_text(json.dumps({"version": 1, "providers": {}, "credential_pool": {provider: rows}}), encoding="utf-8") + store.write_text(json.dumps({"version": 1, "providers": providers, "active_provider": provider, "credential_pool": {provider: rows}}), encoding="utf-8") env = {k: v for k, v in os.environ.items() if not any(t in k for t in ("TOKEN", "API_KEY", "SECRET", "PASSWORD", "HERMES", "PYTEST"))} - env.update(HOME=temp, HERMES_HOME=temp, PYTHONPATH=str(Path(args.repo).absolute()), TERM="xterm") - bootstrap = ("import sys; from hermes_cli import auth_codex; " + env.update(HOME=temp, HERMES_HOME=temp, HERMES_SHARED_AUTH_DIR=str(home / "shared"), PYTHONPATH=str(Path(args.repo).absolute()), TERM="xterm") + bootstrap = ("import sys, httpx; original_send=httpx.Client.send; " + "httpx.Client.send=lambda self, request, **kw: original_send(self, request, **kw) " + "if request.url.host == '127.0.0.1' else (_ for _ in ()).throw(AssertionError('NONLOCAL_NETWORK')); " + "from hermes_cli import auth_codex; " f"auth_codex.CODEX_OAUTH_TOKEN_URL='http://127.0.0.1:{server.server_port}/token'; " "from hermes_cli.main import main; " f"sys.argv=['hermes','auth',*{command!r}]; main()") @@ -102,16 +125,26 @@ def main(): exit_code = child.wait(timeout=30) disk = json.loads(store.read_text(encoding="utf-8"))["credential_pool"][provider] results.append({"case": name, "exit": exit_code, "output": output, - "wire": list(requests), "secrets_printed": "fixture-" in output, + "wire": list(requests), "secrets_printed": "fixture-" in output or ".fixture" in output, "disk": [{k: e.get(k) for k in ("id", "priority", "last_status", "last_error_reset_at", "request_count")} for e in disk], - "target_rotated": any(e["id"] == "row1" and e.get("access_token") == "fixture-new-access" for e in disk), + "target_refresh_rotated": any(e["id"] == "row1" and e.get("refresh_token") == "fixture-new-refresh" for e in disk), + "target_rotated": any(e["id"] == "row1" and e.get("access_token") == (nous_new_token if provider == "nous" else "fixture-new-access") for e in disk), + "independent_tokens_preserved": all(next(e for e in disk if e["id"] == "row0").get(k) == rows[0].get(k) for k in ("access_token", "refresh_token")), "sibling_cooldown_preserved": next(e for e in disk if e["id"] == "row0").get("last_error_reset_at") == now+3600}) finally: server.shutdown() thread.join(timeout=5) server.server_close() Path(args.output).write_text(json.dumps({"repo": args.repo, "cases": results}, indent=2), encoding="utf-8") + independent = next(r for r in results if r["case"] == "nous-independent") + assert independent["exit"] != 0 and not independent["wire"], independent + assert independent["independent_tokens_preserved"] and independent["sibling_cooldown_preserved"], independent + singleton = next(r for r in results if r["case"] == "nous-singleton") + assert singleton["exit"] == 0 and singleton["target_rotated"] and singleton["target_refresh_rotated"], singleton + assert len(singleton["wire"]) == 1 and singleton["wire"][0]["target_grant"], singleton + assert singleton["sibling_cooldown_preserved"] and singleton["independent_tokens_preserved"], singleton + assert all(not r["secrets_printed"] and "NONLOCAL_NETWORK" not in r["output"] for r in results) print(json.dumps([{"case": r["case"], "exit": r["exit"], "posts": len(r["wire"]), "target_rotated": r["target_rotated"], "secrets_printed": r["secrets_printed"]} for r in results], indent=2)) diff --git a/hermes_cli/auth_commands.py b/hermes_cli/auth_commands.py index 04d66e76f7..0b51aea7a7 100644 --- a/hermes_cli/auth_commands.py +++ b/hermes_cli/auth_commands.py @@ -532,6 +532,12 @@ def auth_refresh_command(args) -> None: raise SystemExit( f"{provider} credential #{index} ({matched.label}) is not a refreshable OAuth " f"credential.") + # Nous's resolver is singleton-bound, not an independent-account refresher. + if provider == "nous" and matched.source != "device_code": + raise SystemExit( + f"nous credential #{index} ({matched.label}) is not a refreshable OAuth " + "credential: only the device_code singleton supports refresh. " + "Reauthenticate with `hermes auth add nous --type oauth`.") refreshed = pool.try_refresh_matching(credential_id=matched.id) if refreshed is None: after = next((e for e in pool.entries() if e.id == matched.id), None) diff --git a/tests/hermes_cli/test_auth_pool_operations.py b/tests/hermes_cli/test_auth_pool_operations.py index 26a8a06de8..d717718688 100644 --- a/tests/hermes_cli/test_auth_pool_operations.py +++ b/tests/hermes_cli/test_auth_pool_operations.py @@ -110,3 +110,9 @@ def test_refresh_rejects_ambiguous_and_non_oauth_targets(): write_credential_pool("openrouter", rows[:1]) with pytest.raises(SystemExit, match="not a refreshable"): auth_commands.auth_refresh_command(SimpleNamespace(provider="openrouter", target=None)) + # Nous's resolver refreshes only its singleton, never an independent pool grant. + write_credential_pool("nous", _rows()) + before = read_credential_pool("nous") + with pytest.raises(SystemExit, match="not a refreshable"): + auth_commands.auth_refresh_command(SimpleNamespace(provider="nous", target="row0")) + assert read_credential_pool("nous") == before diff --git a/website/docs/user-guide/features/credential-pools.md b/website/docs/user-guide/features/credential-pools.md index 59b7375469..fd3ce5e663 100644 --- a/website/docs/user-guide/features/credential-pools.md +++ b/website/docs/user-guide/features/credential-pools.md @@ -123,6 +123,12 @@ Type [1/2]: | `hermes auth reset ` | Clear the cooldown on one credential by index, id, or label | | `hermes auth refresh [target]` | Refresh one OAuth credential's tokens and return it to rotation (proves the grant is alive; the next request re-checks quota) | +For Nous, `auth refresh` supports only the login's `device_code` singleton. +Independent Nous pool accounts are rejected before refresh; their tokens and +cooldowns are preserved. Reauthenticate with `hermes auth add nous --type oauth` +to update the singleton; this does not refresh an independent account. Other +providers retain their existing source-specific refresh support. + ## Rotation Strategies Priority positions are zero-based and clamp to the pool's ends; displayed targets