fix(auth): a token refresh no longer replaces a guest in the shared Nous store

Every write of the shared Nous store recorded a guest-to-account move,
including routine token refreshes. When one profile logged out and then
minted a free-tier guest, another profile's keepalive refresh of the
account overwrote that guest, recorded a move of the guest's apps into
the account, and left the account in the store for the logged-out
profile to adopt again.

Each caller of _write_shared_nous_state now declares renewal. A
renewal (a token refresh) writes only over the identity the store
already holds, compared by the guest's anon_ credential or the
account's JWT sub, which survives a refresh. Sign-ins and mints still
replace whatever the store holds, so only a sign-in can record a move.
An empty store still takes a refresh, so profiles sharing one account
keep their rotating refresh token in step.
This commit is contained in:
Siddharth Balyan authored and Siddharth Balyan committed 2026-10-23 17:52:26 +05:30
1 parent 8edc5ea255
commit 7f16d66f07
12 files changed
+148 -21

No files matched your search

+2 -2
View File
@@ -512,7 +512,7 @@ def _mint_locked(client: httpx.Client, portal: str, auth_store: dict[str, Any],
}
_store_provider_state(auth_store, "nous", state, set_active=False)
_save_auth_store(auth_store)
_write_shared_nous_state(state)
_write_shared_nous_state(state, renewal=False)
logger.info("Nous free tier ready (identity minted)")
return state
@@ -644,7 +644,7 @@ def _reconcile_and_provision(*, timeout_seconds: float, force: bool = False,
return state
if profile_state:
if not shared:
_write_shared_nous_state(profile_state)
_write_shared_nous_state(profile_state, renewal=False)
return profile_state
# A caller queued on these locks behind a mint that just failed waits out its cooldown.
failure = _mint_failure_for_profile()
+2 -2
View File
@@ -1858,7 +1858,7 @@ def _resolve_nous_access_token(
headers={"Accept": "application/json"}, verify=verify) as client:
minted = refresh_guest_state(state, client, purpose="connectors")
persist()
_write_shared_nous_state(state)
_write_shared_nous_state(state, renewal=True)
return _memo(minted)
merged_shared = _merge_shared_nous_oauth_state(state)
@@ -1893,7 +1893,7 @@ def _resolve_nous_access_token(
state["client_id"] = client_id
state["tls"] = _tls_state_from_verify(verify)
persist()
_write_shared_nous_state(state)
_write_shared_nous_state(state, renewal=True)
return _memo(state["access_token"])
+23 -8
View File
@@ -449,9 +449,21 @@ def _nous_shared_shape(src: dict[str, Any]) -> dict[str, Any]:
if src.get(k) not in (None, "")}}
def _write_shared_nous_state(state: dict[str, Any]) -> None:
def _nous_identity(state: dict[str, Any]) -> tuple[str, Any]:
"""Who a Nous state is: the guest's ``anon_`` credential, else the account's JWT subject (which,
unlike the refresh token, survives a refresh)."""
from hermes_cli.anon_auth import is_guest_state
if is_guest_state(state):
return "guest", state.get("anon_token")
return "account", _decode_jwt_claims(state.get("access_token")).get("sub")
def _write_shared_nous_state(state: dict[str, Any], *, renewal: bool) -> None:
"""Persist a minimal copy of the Nous OAuth state to the shared store.
A *renewal* (a token refresh) never replaces another identity the store holds: only a sign-in
(or a mint) does, so only a sign-in can replace a guest there. An empty store takes a renewal,
so profiles sharing one account keep their rotated refresh token in step.
Best-effort: failures are logged and swallowed; per-profile auth.json stays the source of truth.
"""
from hermes_cli.auth import _nonempty_str, _save_private_json
@@ -467,7 +479,10 @@ def _write_shared_nous_state(state: dict[str, Any]) -> None:
try:
with _nous_shared_store_lock():
path = _nous_shared_store_path()
note_replacement(_read_shared_nous_state(), state)
held = _read_shared_nous_state()
if renewal and held is not None and _nous_identity(held) != _nous_identity(state):
return
note_replacement(held, state)
_save_private_json(path, shared, sort_keys=True)
_oauth_trace(
"nous_shared_store_written", path=str(path),
@@ -608,8 +623,8 @@ def _try_import_shared_nous_state(*, timeout_seconds: float = 15.0) -> Optional[
"tls": {"insecure": False, "ca_bundle": None}}
refreshed = refresh_nous_oauth_from_state(
state, timeout_seconds=timeout_seconds, force_refresh=True,
on_state_update=lambda updated, _reason: _write_shared_nous_state(updated))
_write_shared_nous_state(refreshed)
on_state_update=lambda updated, _reason: _write_shared_nous_state(updated, renewal=True))
_write_shared_nous_state(refreshed, renewal=True)
except Exception as exc:
is_auth = isinstance(exc, AuthError)
_oauth_trace(
@@ -873,7 +888,7 @@ def persist_nous_credentials(creds: dict[str, Any], *, label: Optional[str] = No
if label and str(label).strip():
state["label"] = str(label).strip()
_save_active_provider_state("nous", state)
_write_shared_nous_state(state)
_write_shared_nous_state(state, renewal=False)
pool = load_pool("nous")
return next((e for e in pool.entries() if e.source == NOUS_DEVICE_CODE_SOURCE), None)
@@ -969,7 +984,7 @@ class _NousRuntimeResolve:
access_token_fp=_token_fingerprint(state.get("access_token")))
self._persisted_state = dict(state)
self.persisted_any = True
_write_shared_nous_state(state)
_write_shared_nous_state(state, renewal=True)
def shared_lock(self):
return _nous_shared_store_lock(timeout_seconds=_shared_lock_timeout(self.timeout_seconds))
@@ -1462,7 +1477,7 @@ def _mirror_nous_state_best_effort(auth_state: dict[str, Any]) -> None:
"""Mirror to the shared store + reseed the pool, swallowing all errors (same as _login_nous)."""
from hermes_cli.auth import _sync_nous_pool_from_auth_store, _write_shared_nous_state
with suppress(Exception):
_write_shared_nous_state(auth_state)
_write_shared_nous_state(auth_state, renewal=False)
with suppress(Exception):
_sync_nous_pool_from_auth_store()
@@ -1658,7 +1673,7 @@ def _login_nous(args, pconfig: ProviderConfig) -> None:
with deferred() as handoff: # a replaced free tier hands its apps over once both saves land
saved_to = _save_active_provider_state("nous", auth_state)
# Mirror to the shared store so other profiles can one-tap import (best-effort inside).
_write_shared_nous_state(auth_state)
_write_shared_nous_state(auth_state, renewal=False)
_sync_nous_pool_from_auth_store()
from hermes_cli.anon_auth import settle_after_upgrade
settle_after_upgrade(auth_state) # a config left on the free tier's route moves to the account
+1 -1
View File
@@ -709,7 +709,7 @@ def _run_nous_portal_login_only(*, capability: str) -> bool:
else:
auth_store.pop("active_provider", None)
auth._save_auth_store(auth_store)
auth._write_shared_nous_state(auth_state)
auth._write_shared_nous_state(auth_state, renewal=False)
auth._sync_nous_pool_from_auth_store()
settle_after_upgrade(auth_state) # a config left on the free tier's route moves to the account
print(" Nous Portal login successful.")
+1 -1
View File
@@ -128,7 +128,7 @@ class NousPortalAdapter(UpstreamAdapter):
providers = store.setdefault("providers", {})
providers["nous"] = state
_save_auth_store(store)
_write_shared_nous_state(state)
_write_shared_nous_state(state, renewal=True)
except Exception as exc:
logger.warning("proxy: failed to persist Nous quarantine state: %s", exc)
+3 -2
View File
@@ -374,7 +374,7 @@ class TestIdentityOfRecordIsTheSharedStore:
from hermes_cli.auth_nous import _write_shared_nous_state
_write_shared_nous_state({"access_token": _jwt(client_id="hermes-cli", account_tier="free"),
"refresh_token": "rt-sibling", "expires_at": "2030-01-01T00:00:00+00:00",
"auth_method": "oauth_device_code"})
"auth_method": "oauth_device_code"}, renewal=False)
state = anon_auth.ensure_portal_identity(explicit=True)
assert not anon_auth.is_guest_state(state)
assert state["refresh_token"] == "rt-sibling"
@@ -395,7 +395,8 @@ class TestIdentityOfRecordIsTheSharedStore:
anon_auth.ensure_portal_identity(explicit=True)
from hermes_cli.auth_nous import _write_shared_nous_state
_write_shared_nous_state({"access_token": _jwt(client_id="hermes-cli"), "refresh_token": "rt-sibling",
"expires_at": "2030-01-01T00:00:00+00:00", "auth_method": "oauth_device_code"})
"expires_at": "2030-01-01T00:00:00+00:00", "auth_method": "oauth_device_code"},
renewal=False)
anon_auth.clear_dead_guest("test")
assert "nous" not in _load_auth_store().get("providers", {})
assert _shared_store(tmp_path)["refresh_token"] == "rt-sibling"
+2 -2
View File
@@ -919,7 +919,7 @@ def test_shared_store_write_and_read_roundtrip(shared_store_env):
)
state = _full_state_fixture()
_write_shared_nous_state(state)
_write_shared_nous_state(state, renewal=False)
path = _nous_shared_store_path()
assert path.is_file()
@@ -981,7 +981,7 @@ def test_try_import_shared_rehydrates_on_success(shared_store_env, monkeypatch):
from hermes_cli import auth as auth_mod
from hermes_cli import auth_nous
auth_mod._write_shared_nous_state(_full_state_fixture())
auth_mod._write_shared_nous_state(_full_state_fixture(), renewal=False)
fresh_jwt = _invoke_jwt(seconds=7200)
def _fake_refresh(state, **kwargs):
@@ -130,7 +130,7 @@ def test_shared_nous_store_writes_0o600_with_0o700_parent(tmp_path, monkeypatch)
"obtained_at": "2026-01-01T00:00:00Z",
"expires_at": "2026-01-01T01:00:00Z",
}
auth_mod._write_shared_nous_state(state)
auth_mod._write_shared_nous_state(state, renewal=False)
path = auth_mod._nous_shared_store_path()
finally:
os.umask(old_umask)
+1 -1
View File
@@ -134,7 +134,7 @@ def _sibling_adoption(account, monkeypatch, tmp_path):
sibling.mkdir()
monkeypatch.setenv("HERMES_HOME", str(sibling))
monkeypatch.setattr(auth_nous, "drain", lambda: None) # the sibling's own run is not this test
auth_nous._write_shared_nous_state(account)
auth_nous._write_shared_nous_state(account, renewal=False)
monkeypatch.setattr(auth_nous, "drain", guest_handoff.drain)
monkeypatch.setenv("HERMES_HOME", mine)
anon_auth.ensure_portal_identity(explicit=True)
@@ -0,0 +1,106 @@
"""A token refresh renews the identity the shared Nous store holds; only a sign-in replaces it.
A keepalive refresh of one profile's account used to overwrite a guest another profile had just
minted into the shared store. That recorded a move of the guest's apps into the account, and the
profile that had logged out of the account adopted it again from the store.
"""
from __future__ import annotations
import os
from types import SimpleNamespace
import pytest
from tests.hermes_cli import test_guest_handoff as _gh
portal = _gh.portal
def _refresh_access_token():
from hermes_cli import auth
auth.resolve_nous_access_token(refresh_skew_seconds=10**9)
def _refresh_runtime_credentials():
from hermes_cli import auth
auth.resolve_nous_runtime_credentials(force_refresh=True)
REFRESHERS = {
"resolve_nous_access_token": _refresh_access_token,
"resolve_nous_runtime_credentials": _refresh_runtime_credentials,
}
def _shared_anon_token():
from hermes_cli import auth_nous
return (auth_nous._read_shared_nous_state() or {}).get("anon_token")
def _guest_in(monkeypatch, home) -> str:
"""*home* mints a guest into the (empty) shared store."""
monkeypatch.setenv("HERMES_HOME", str(home))
return _gh._mint_guest()
@pytest.mark.parametrize("refresh", sorted(REFRESHERS))
def test_a_refresh_over_a_guest_in_the_shared_store_records_no_move(portal, monkeypatch, tmp_path, refresh):
from hermes_cli import auth_nous
mine, sibling = os.environ["HERMES_HOME"], tmp_path / "sibling-profile"
sibling.mkdir()
_gh._persist(_gh._account())
auth_nous._clear_shared_nous_state("logout")
anon = _guest_in(monkeypatch, sibling)
monkeypatch.setenv("HERMES_HOME", mine)
REFRESHERS[refresh]()
assert portal.refreshes == 1
assert _gh._records() == {} and portal.claims == []
assert _shared_anon_token() == anon
@pytest.mark.parametrize("emptied", [False, True], ids=["store-holds-it", "store-emptied-by-a-logout"])
def test_a_refresh_still_publishes_the_rotated_token_for_profiles_on_the_same_account(portal, emptied):
from hermes_cli import auth_nous
_gh._persist(_gh._account(refresh="rt_1"))
if emptied:
auth_nous._clear_shared_nous_state("logout")
_refresh_access_token()
assert auth_nous._read_shared_nous_state()["refresh_token"] == "rt_2"
def test_a_sign_in_over_the_guest_in_the_shared_store_still_hands_it_over(portal, monkeypatch, tmp_path):
mine, sibling = os.environ["HERMES_HOME"], tmp_path / "sibling-profile"
sibling.mkdir()
anon = _guest_in(monkeypatch, sibling)
monkeypatch.setenv("HERMES_HOME", mine)
_gh._persist(_gh._account())
assert [c["body"] for c in portal.claims] == [{"token": anon}]
assert _gh._only_record()["state"] == "claimed"
def test_a_keepalive_refresh_does_not_sign_a_logged_out_profile_back_in(portal, monkeypatch, tmp_path):
"""Two profiles on one account; the first logs out and mints a guest; the second refreshes."""
from hermes_cli import anon_auth, auth
first, second = os.environ["HERMES_HOME"], tmp_path / "second-profile"
second.mkdir()
_gh._persist(_gh._account())
monkeypatch.setenv("HERMES_HOME", str(second))
_gh._persist(_gh._account())
monkeypatch.setenv("HERMES_HOME", first)
auth.logout_command(SimpleNamespace(provider="nous"))
anon = _gh._mint_guest()
monkeypatch.setenv("HERMES_HOME", str(second))
from hermes_cli import nous_auth_keepalive
assert nous_auth_keepalive.refresh_nous_auth_keepalive_once(min_access_ttl_seconds=10**9)
monkeypatch.setenv("HERMES_HOME", first)
state = anon_auth.ensure_portal_identity(explicit=True)
assert anon_auth.is_guest_state(state) and state["anon_token"] == anon
assert _gh._records() == {} and portal.claims == []
@@ -187,7 +187,7 @@ def _connector_guest_from_another_profile(_managed: Path) -> None:
_write_shared_nous_state({"auth_method": anon_auth.ANON_AUTH_METHOD, "account_tier": anon_auth.ANON_ACCOUNT_TIER,
"anon_token": "anon_other", "client_id": anon_auth.ANON_CLIENT_ID,
"portal_base_url": PORTAL, anon_auth.WELCOME_CLAIM_FLAG: False})
"portal_base_url": PORTAL, anon_auth.WELCOME_CLAIM_FLAG: False}, renewal=False)
ELSEWHERE = {
@@ -164,6 +164,11 @@ call it:
record. Records live in `guest_handoff.json` beside the shared Nous store, keyed by a hash of the
`anon_` credential and bound to the destination account, so profiles sharing one free-tier
identity share one record and one claim.
- Only a sign-in changes which identity the shared store holds. Every `_write_shared_nous_state`
caller declares `renewal`: a token refresh (`renewal=True`) never replaces another identity there
(identity = the guest's `anon_` credential, or the account's JWT `sub`), so a refresh of one
profile's account never replaces a guest another profile minted. An empty store still takes a
refresh, which keeps profiles that share one account's rotating refresh token in step.
- `drain` runs the claim outside every lock, bounded to about 10 s (each record, lock wait, token
read and request counts against the budget): after a shared-store write, when a
`guest_handoff.deferred()` block exits (a surface that saves under its own lock, such as the desktop