fix(auth): restore --label for hermes auth add nous --type oauth
persist_nous_credentials() now accepts an optional label kwarg which
gets embedded in providers.nous under the 'label' key.
_seed_from_singletons() prefers the embedded label over the
auto-derived label_from_token() fingerprint when materialising the
pool entry, so re-seeding on every load_pool('nous') preserves the
user's chosen label.
auth_commands.py threads --label through to the helper, restoring
parity with how other OAuth providers (anthropic, codex, google,
qwen) honor the flag.
Tests: 4 new (embed, reseed-survives, no-label fallback, end-to-end
through auth_add_command). All 390 nous/auth/credential_pool tests
pass.
This commit is contained in:
@@ -1130,6 +1130,14 @@ def _seed_from_singletons(provider: str, entries: List[PooledCredential]) -> Tup
|
|||||||
state = _load_provider_state(auth_store, "nous")
|
state = _load_provider_state(auth_store, "nous")
|
||||||
if state:
|
if state:
|
||||||
active_sources.add("device_code")
|
active_sources.add("device_code")
|
||||||
|
# Prefer a user-supplied label embedded in the singleton state
|
||||||
|
# (set by persist_nous_credentials(label=...) when the user ran
|
||||||
|
# `hermes auth add nous --label <name>`). Fall back to the
|
||||||
|
# auto-derived token fingerprint for logins that didn't supply one.
|
||||||
|
custom_label = str(state.get("label") or "").strip()
|
||||||
|
seeded_label = custom_label or label_from_token(
|
||||||
|
state.get("access_token", ""), "device_code"
|
||||||
|
)
|
||||||
changed |= _upsert_entry(
|
changed |= _upsert_entry(
|
||||||
entries,
|
entries,
|
||||||
provider,
|
provider,
|
||||||
@@ -1148,7 +1156,7 @@ def _seed_from_singletons(provider: str, entries: List[PooledCredential]) -> Tup
|
|||||||
"agent_key": state.get("agent_key"),
|
"agent_key": state.get("agent_key"),
|
||||||
"agent_key_expires_at": state.get("agent_key_expires_at"),
|
"agent_key_expires_at": state.get("agent_key_expires_at"),
|
||||||
"tls": state.get("tls") if isinstance(state.get("tls"), dict) else None,
|
"tls": state.get("tls") if isinstance(state.get("tls"), dict) else None,
|
||||||
"label": label_from_token(state.get("access_token", ""), "device_code"),
|
"label": seeded_label,
|
||||||
},
|
},
|
||||||
)
|
)
|
||||||
|
|
||||||
|
|||||||
@@ -2162,7 +2162,11 @@ def refresh_nous_oauth_from_state(
|
|||||||
NOUS_DEVICE_CODE_SOURCE = "device_code"
|
NOUS_DEVICE_CODE_SOURCE = "device_code"
|
||||||
|
|
||||||
|
|
||||||
def persist_nous_credentials(creds: Dict[str, Any]):
|
def persist_nous_credentials(
|
||||||
|
creds: Dict[str, Any],
|
||||||
|
*,
|
||||||
|
label: Optional[str] = None,
|
||||||
|
):
|
||||||
"""Persist minted Nous OAuth credentials as the singleton provider state
|
"""Persist minted Nous OAuth credentials as the singleton provider state
|
||||||
and ensure the credential pool is in sync.
|
and ensure the credential pool is in sync.
|
||||||
|
|
||||||
@@ -2183,14 +2187,25 @@ def persist_nous_credentials(creds: Dict[str, Any]):
|
|||||||
entry from the singleton. Re-running login upserts the same entry in
|
entry from the singleton. Re-running login upserts the same entry in
|
||||||
place; the pool never accumulates duplicate device_code rows.
|
place; the pool never accumulates duplicate device_code rows.
|
||||||
|
|
||||||
|
``label`` is an optional user-chosen display name (from
|
||||||
|
``hermes auth add nous --label <name>``). It gets embedded in the
|
||||||
|
singleton state so that ``_seed_from_singletons`` uses it as the pool
|
||||||
|
entry's label on every subsequent ``load_pool("nous")`` instead of the
|
||||||
|
auto-derived token fingerprint. When ``None``, the auto-derived label
|
||||||
|
via ``label_from_token`` is used (unchanged default behaviour).
|
||||||
|
|
||||||
Returns the upserted :class:`PooledCredential` entry (or ``None`` if
|
Returns the upserted :class:`PooledCredential` entry (or ``None`` if
|
||||||
seeding somehow produced no match — shouldn't happen).
|
seeding somehow produced no match — shouldn't happen).
|
||||||
"""
|
"""
|
||||||
from agent.credential_pool import load_pool
|
from agent.credential_pool import load_pool
|
||||||
|
|
||||||
|
state = dict(creds)
|
||||||
|
if label and str(label).strip():
|
||||||
|
state["label"] = str(label).strip()
|
||||||
|
|
||||||
with _auth_store_lock():
|
with _auth_store_lock():
|
||||||
auth_store = _load_auth_store()
|
auth_store = _load_auth_store()
|
||||||
_save_provider_state(auth_store, "nous", creds)
|
_save_provider_state(auth_store, "nous", state)
|
||||||
_save_auth_store(auth_store)
|
_save_auth_store(auth_store)
|
||||||
|
|
||||||
pool = load_pool("nous")
|
pool = load_pool("nous")
|
||||||
|
|||||||
@@ -217,7 +217,11 @@ def auth_add_command(args) -> None:
|
|||||||
ca_bundle=getattr(args, "ca_bundle", None),
|
ca_bundle=getattr(args, "ca_bundle", None),
|
||||||
min_key_ttl_seconds=max(60, int(getattr(args, "min_key_ttl_seconds", 5 * 60))),
|
min_key_ttl_seconds=max(60, int(getattr(args, "min_key_ttl_seconds", 5 * 60))),
|
||||||
)
|
)
|
||||||
entry = auth_mod.persist_nous_credentials(creds)
|
# Honor `--label <name>` so nous matches other providers' UX. The
|
||||||
|
# helper embeds this into providers.nous so that label_from_token
|
||||||
|
# doesn't overwrite it on every subsequent load_pool("nous").
|
||||||
|
custom_label = (getattr(args, "label", None) or "").strip() or None
|
||||||
|
entry = auth_mod.persist_nous_credentials(creds, label=custom_label)
|
||||||
shown_label = entry.label if entry is not None else label_from_token(
|
shown_label = entry.label if entry is not None else label_from_token(
|
||||||
creds.get("access_token", ""), _oauth_default_label(provider, 1),
|
creds.get("access_token", ""), _oauth_default_label(provider, 1),
|
||||||
)
|
)
|
||||||
|
|||||||
@@ -168,6 +168,67 @@ def test_auth_add_nous_oauth_persists_pool_entry(tmp_path, monkeypatch):
|
|||||||
assert singleton["inference_base_url"] == "https://inference.example.com/v1"
|
assert singleton["inference_base_url"] == "https://inference.example.com/v1"
|
||||||
|
|
||||||
|
|
||||||
|
def test_auth_add_nous_oauth_honors_custom_label(tmp_path, monkeypatch):
|
||||||
|
"""`hermes auth add nous --type oauth --label <name>` must preserve the
|
||||||
|
custom label end-to-end — it was silently dropped in the first cut of the
|
||||||
|
persist_nous_credentials helper because `--label` wasn't threaded through.
|
||||||
|
"""
|
||||||
|
monkeypatch.setenv("HERMES_HOME", str(tmp_path / "hermes"))
|
||||||
|
_write_auth_store(tmp_path, {"version": 1, "providers": {}})
|
||||||
|
token = _jwt_with_email("nous@example.com")
|
||||||
|
monkeypatch.setattr(
|
||||||
|
"hermes_cli.auth._nous_device_code_login",
|
||||||
|
lambda **kwargs: {
|
||||||
|
"portal_base_url": "https://portal.example.com",
|
||||||
|
"inference_base_url": "https://inference.example.com/v1",
|
||||||
|
"client_id": "hermes-cli",
|
||||||
|
"scope": "inference:mint_agent_key",
|
||||||
|
"token_type": "Bearer",
|
||||||
|
"access_token": token,
|
||||||
|
"refresh_token": "refresh-token",
|
||||||
|
"obtained_at": "2026-03-23T10:00:00+00:00",
|
||||||
|
"expires_at": "2026-03-23T11:00:00+00:00",
|
||||||
|
"expires_in": 3600,
|
||||||
|
"agent_key": "ak-test",
|
||||||
|
"agent_key_id": "ak-id",
|
||||||
|
"agent_key_expires_at": "2026-03-23T10:30:00+00:00",
|
||||||
|
"agent_key_expires_in": 1800,
|
||||||
|
"agent_key_reused": False,
|
||||||
|
"agent_key_obtained_at": "2026-03-23T10:00:10+00:00",
|
||||||
|
"tls": {"insecure": False, "ca_bundle": None},
|
||||||
|
},
|
||||||
|
)
|
||||||
|
|
||||||
|
from hermes_cli.auth_commands import auth_add_command
|
||||||
|
|
||||||
|
class _Args:
|
||||||
|
provider = "nous"
|
||||||
|
auth_type = "oauth"
|
||||||
|
api_key = None
|
||||||
|
label = "my-nous"
|
||||||
|
portal_url = None
|
||||||
|
inference_url = None
|
||||||
|
client_id = None
|
||||||
|
scope = None
|
||||||
|
no_browser = False
|
||||||
|
timeout = None
|
||||||
|
insecure = False
|
||||||
|
ca_bundle = None
|
||||||
|
|
||||||
|
auth_add_command(_Args())
|
||||||
|
|
||||||
|
payload = json.loads((tmp_path / "hermes" / "auth.json").read_text())
|
||||||
|
|
||||||
|
# Custom label reaches the pool entry …
|
||||||
|
pool_entry = payload["credential_pool"]["nous"][0]
|
||||||
|
assert pool_entry["source"] == "device_code"
|
||||||
|
assert pool_entry["label"] == "my-nous"
|
||||||
|
|
||||||
|
# … and survives in providers.nous so a subsequent load_pool() re-seeds
|
||||||
|
# it without reverting to the auto-derived fingerprint.
|
||||||
|
assert payload["providers"]["nous"]["label"] == "my-nous"
|
||||||
|
|
||||||
|
|
||||||
def test_auth_add_codex_oauth_persists_pool_entry(tmp_path, monkeypatch):
|
def test_auth_add_codex_oauth_persists_pool_entry(tmp_path, monkeypatch):
|
||||||
monkeypatch.setenv("HERMES_HOME", str(tmp_path / "hermes"))
|
monkeypatch.setenv("HERMES_HOME", str(tmp_path / "hermes"))
|
||||||
_write_auth_store(tmp_path, {"version": 1, "providers": {}})
|
_write_auth_store(tmp_path, {"version": 1, "providers": {}})
|
||||||
|
|||||||
@@ -633,3 +633,81 @@ def test_persist_nous_credentials_reloads_pool_after_singleton_write(tmp_path, m
|
|||||||
# assert its exact value, just that the helper returned a real entry.
|
# assert its exact value, just that the helper returned a real entry.
|
||||||
assert entry.access_token == "access-tok"
|
assert entry.access_token == "access-tok"
|
||||||
assert entry.agent_key == "agent-key-value"
|
assert entry.agent_key == "agent-key-value"
|
||||||
|
|
||||||
|
|
||||||
|
def test_persist_nous_credentials_embeds_custom_label(tmp_path, monkeypatch):
|
||||||
|
"""User-supplied ``--label`` round-trips through providers.nous and the pool.
|
||||||
|
|
||||||
|
Previously `hermes auth add nous --type oauth --label <name>` silently
|
||||||
|
dropped the label because persist_nous_credentials() ignored it and
|
||||||
|
_seed_from_singletons always auto-derived via label_from_token(). The
|
||||||
|
fix stashes the label inside providers.nous so seeding prefers it.
|
||||||
|
"""
|
||||||
|
from hermes_cli.auth import persist_nous_credentials, NOUS_DEVICE_CODE_SOURCE
|
||||||
|
|
||||||
|
hermes_home = tmp_path / "hermes"
|
||||||
|
hermes_home.mkdir(parents=True, exist_ok=True)
|
||||||
|
(hermes_home / "auth.json").write_text(json.dumps({
|
||||||
|
"version": 1, "providers": {},
|
||||||
|
}))
|
||||||
|
monkeypatch.setenv("HERMES_HOME", str(hermes_home))
|
||||||
|
|
||||||
|
entry = persist_nous_credentials(_full_state_fixture(), label="my-personal")
|
||||||
|
assert entry is not None
|
||||||
|
assert entry.source == NOUS_DEVICE_CODE_SOURCE
|
||||||
|
assert entry.label == "my-personal"
|
||||||
|
|
||||||
|
# providers.nous carries the label so re-seeding on the next load_pool
|
||||||
|
# doesn't overwrite it with the auto-derived fingerprint.
|
||||||
|
payload = json.loads((hermes_home / "auth.json").read_text())
|
||||||
|
assert payload["providers"]["nous"]["label"] == "my-personal"
|
||||||
|
|
||||||
|
|
||||||
|
def test_persist_nous_credentials_custom_label_survives_reseed(tmp_path, monkeypatch):
|
||||||
|
"""Reopening the pool (which re-runs _seed_from_singletons) must keep the
|
||||||
|
user-chosen label instead of clobbering it with label_from_token output.
|
||||||
|
"""
|
||||||
|
from hermes_cli.auth import persist_nous_credentials
|
||||||
|
from agent.credential_pool import load_pool
|
||||||
|
|
||||||
|
hermes_home = tmp_path / "hermes"
|
||||||
|
hermes_home.mkdir(parents=True, exist_ok=True)
|
||||||
|
(hermes_home / "auth.json").write_text(json.dumps({
|
||||||
|
"version": 1, "providers": {},
|
||||||
|
}))
|
||||||
|
monkeypatch.setenv("HERMES_HOME", str(hermes_home))
|
||||||
|
|
||||||
|
persist_nous_credentials(_full_state_fixture(), label="work-acct")
|
||||||
|
|
||||||
|
# Second load_pool triggers _seed_from_singletons again. Without the
|
||||||
|
# fix, this call overwrote the label with label_from_token(access_token).
|
||||||
|
pool = load_pool("nous")
|
||||||
|
entries = pool.entries()
|
||||||
|
assert len(entries) == 1
|
||||||
|
assert entries[0].label == "work-acct"
|
||||||
|
|
||||||
|
|
||||||
|
def test_persist_nous_credentials_no_label_uses_auto_derived(tmp_path, monkeypatch):
|
||||||
|
"""When the caller doesn't pass ``label``, the auto-derived fingerprint
|
||||||
|
is used (unchanged default behaviour — regression guard).
|
||||||
|
"""
|
||||||
|
from hermes_cli.auth import persist_nous_credentials
|
||||||
|
|
||||||
|
hermes_home = tmp_path / "hermes"
|
||||||
|
hermes_home.mkdir(parents=True, exist_ok=True)
|
||||||
|
(hermes_home / "auth.json").write_text(json.dumps({
|
||||||
|
"version": 1, "providers": {},
|
||||||
|
}))
|
||||||
|
monkeypatch.setenv("HERMES_HOME", str(hermes_home))
|
||||||
|
|
||||||
|
entry = persist_nous_credentials(_full_state_fixture())
|
||||||
|
assert entry is not None
|
||||||
|
# label_from_token derives from the access_token; exact value depends on
|
||||||
|
# the fingerprinter but it must not be empty and must not equal an
|
||||||
|
# arbitrary user string we never passed.
|
||||||
|
assert entry.label
|
||||||
|
assert entry.label != "my-personal"
|
||||||
|
|
||||||
|
# No "label" key embedded in providers.nous when the caller didn't supply one.
|
||||||
|
payload = json.loads((hermes_home / "auth.json").read_text())
|
||||||
|
assert "label" not in payload["providers"]["nous"]
|
||||||
|
|||||||
Reference in New Issue
Block a user