diff --git a/agent/credential_pool.py b/agent/credential_pool.py index 43a67a923a..a67eee6c42 100644 --- a/agent/credential_pool.py +++ b/agent/credential_pool.py @@ -1130,6 +1130,14 @@ def _seed_from_singletons(provider: str, entries: List[PooledCredential]) -> Tup state = _load_provider_state(auth_store, "nous") if state: 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 `). 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( entries, provider, @@ -1148,7 +1156,7 @@ def _seed_from_singletons(provider: str, entries: List[PooledCredential]) -> Tup "agent_key": state.get("agent_key"), "agent_key_expires_at": state.get("agent_key_expires_at"), "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, }, ) diff --git a/hermes_cli/auth.py b/hermes_cli/auth.py index 0843b5ce71..831f81bf26 100644 --- a/hermes_cli/auth.py +++ b/hermes_cli/auth.py @@ -2162,7 +2162,11 @@ def refresh_nous_oauth_from_state( 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 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 place; the pool never accumulates duplicate device_code rows. + ``label`` is an optional user-chosen display name (from + ``hermes auth add nous --label ``). 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 seeding somehow produced no match — shouldn't happen). """ 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(): auth_store = _load_auth_store() - _save_provider_state(auth_store, "nous", creds) + _save_provider_state(auth_store, "nous", state) _save_auth_store(auth_store) pool = load_pool("nous") diff --git a/hermes_cli/auth_commands.py b/hermes_cli/auth_commands.py index 71fca04912..30e5182949 100644 --- a/hermes_cli/auth_commands.py +++ b/hermes_cli/auth_commands.py @@ -217,7 +217,11 @@ def auth_add_command(args) -> None: ca_bundle=getattr(args, "ca_bundle", None), min_key_ttl_seconds=max(60, int(getattr(args, "min_key_ttl_seconds", 5 * 60))), ) - entry = auth_mod.persist_nous_credentials(creds) + # Honor `--label ` 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( creds.get("access_token", ""), _oauth_default_label(provider, 1), ) diff --git a/tests/hermes_cli/test_auth_commands.py b/tests/hermes_cli/test_auth_commands.py index 6294526218..5b0d9062b9 100644 --- a/tests/hermes_cli/test_auth_commands.py +++ b/tests/hermes_cli/test_auth_commands.py @@ -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" +def test_auth_add_nous_oauth_honors_custom_label(tmp_path, monkeypatch): + """`hermes auth add nous --type oauth --label ` 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): monkeypatch.setenv("HERMES_HOME", str(tmp_path / "hermes")) _write_auth_store(tmp_path, {"version": 1, "providers": {}}) diff --git a/tests/hermes_cli/test_auth_nous_provider.py b/tests/hermes_cli/test_auth_nous_provider.py index 8a54a879ec..89a2455041 100644 --- a/tests/hermes_cli/test_auth_nous_provider.py +++ b/tests/hermes_cli/test_auth_nous_provider.py @@ -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 entry.access_token == "access-tok" 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 ` 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"]