fix: reject independent Nous account refresh without clearing cooldown

This commit is contained in:
Teknium
2026-09-07 03:45:15 -07:00
parent c13c37a699
commit 134b173efa
4 changed files with 58 additions and 7 deletions

View File

@@ -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))

View File

@@ -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)

View File

@@ -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

View File

@@ -123,6 +123,12 @@ Type [1/2]:
| `hermes auth reset <provider> <target>` | Clear the cooldown on one credential by index, id, or label |
| `hermes auth refresh <provider> [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