From a1b1817adf2285ee2c9a5acca770f35b73bd1d43 Mon Sep 17 00:00:00 2001 From: rob-maron <132852777+rob-maron@users.noreply.github.com> Date: Mon, 21 Sep 2026 12:49:29 -0400 Subject: [PATCH 1/9] add grok 4.7 to top of model picker --- website/static/api/model-catalog.json | 5 ++++- 1 file changed, 4 insertions(+), 1 deletion(-) diff --git a/website/static/api/model-catalog.json b/website/static/api/model-catalog.json index 5824775a2a..d35465ad42 100644 --- a/website/static/api/model-catalog.json +++ b/website/static/api/model-catalog.json @@ -261,6 +261,9 @@ "note": "The entry labeled \"default\": true is the model Hermes silently lands on when the user never picked one." }, "models": [ + { + "id": "x-ai/grok-4.7" + }, { "id": "anthropic/claude-fable-5.1" }, @@ -394,4 +397,4 @@ ] } } -} +} \ No newline at end of file From 2632229bcffe607fb37337425857da57ff0161bc Mon Sep 17 00:00:00 2001 From: teknium1 <127238744+teknium1@users.noreply.github.com> Date: Mon, 21 Sep 2026 09:50:58 -0700 Subject: [PATCH 2/9] fix(auth): named profiles read the root auth.json again (revert #111724) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Reverts 93889b770da3 ("named profiles no longer inherit the root profile's auth.json"). After the Desktop update every bot profile that had relied on the root OpenAI Codex login failed with "No Codex credentials stored. Run `hermes -p auth add openai-codex --type oauth`", and users had to re-run the device-code flow once per bot (5-6 times in the field report). Sharing one grant across profiles is the intended design: OAuth refresh tokens are single-use, so ONE grant lives at the root, profiles resolve it read-only, and a refresh under a profile writes the rotated chain back to root (Codex / xAI write-through, borrowed-row pool bookkeeping, forked-grant heal) — never a per-profile copy. Restored: `_global_auth_file_path` / `_load_global_auth_store` fallback in `_load_provider_state*` / `read_credential_pool` / `_provider_state_transaction`, Codex + xAI root write-through, `credential_pool` borrowed-root persistence, `heal_forked_single_use_oauth_grants`, `share_auth` on profile creation (Desktop create dialog checkbox), and the docs. `profile_credential_audit.py` (the `hermes update` "profiles without a provider" notice) is removed with it. Kept from after #111724: `_save_codex_tokens(set_active=...)` for image gen, the plugin-auth `status` dispatch and the external-login notice in `hermes auth list`, and the registry-derived env-var hint in agent_init. --- agent/agent_init.py | 4 +- agent/anthropic_credentials.py | 22 +- agent/credential_pool.py | 256 +++++++- agent/credential_pool_admin.py | 23 +- .../onboarding-chat/setup-profile.ts | 1 + .../src/plugins/hermes-bots/create-dialog.tsx | 24 +- apps/shared/src/gateway-contract.generated.ts | 4 +- apps/shared/src/gateway-contract.openrpc.json | 14 +- hermes_cli/AGENTS.md | 4 +- hermes_cli/anon_auth.py | 15 +- hermes_cli/auth.py | 172 +++++- hermes_cli/auth_codex.py | 67 ++- hermes_cli/auth_commands.py | 12 + hermes_cli/auth_oauth_grants.py | 538 ++++++++++++++++- hermes_cli/auth_xai.py | 49 +- hermes_cli/profile_credential_audit.py | 74 --- hermes_cli/profiles.py | 5 +- hermes_cli/update_cmd_maint.py | 8 - tests/agent/test_anthropic_oauth_stress.py | 1 + ...test_credential_pool_oauth_writethrough.py | 228 ++++++- ...test_credential_pool_profile_oauth_fork.py | 563 +++++++++++++++++- .../hermes_cli/test_auth_profile_fallback.py | 383 ++++++++++++ .../hermes_cli/test_auth_profile_isolation.py | 221 ------- .../test_codex_token_writethrough.py | 147 +++++ .../hermes_cli/test_global_auth_store_memo.py | 114 ++++ .../hermes_cli/test_xai_oauth_profile_auth.py | 1 + .../hermes_cli/test_xai_oauth_writethrough.py | 90 +++ tests/test_hermes_constants.py | 5 +- tests/tools/test_managed_tool_gateway.py | 20 +- tools/managed_tool_gateway.py | 6 +- ...ofiles_vault_complete_foreign_subagents.py | 7 +- tui_gateway/methods_profiles.py | 28 +- .../docs/user-guide/multi-profile-gateways.md | 1 - website/docs/user-guide/profiles.md | 8 +- 34 files changed, 2635 insertions(+), 480 deletions(-) delete mode 100644 hermes_cli/profile_credential_audit.py create mode 100644 tests/hermes_cli/test_auth_profile_fallback.py delete mode 100644 tests/hermes_cli/test_auth_profile_isolation.py create mode 100644 tests/hermes_cli/test_codex_token_writethrough.py create mode 100644 tests/hermes_cli/test_global_auth_store_memo.py create mode 100644 tests/hermes_cli/test_xai_oauth_writethrough.py diff --git a/agent/agent_init.py b/agent/agent_init.py index 2241e4daa4..732ff39a47 100644 --- a/agent/agent_init.py +++ b/agent/agent_init.py @@ -873,8 +873,8 @@ def _routed_client_kwargs(agent, fallback_model, _provider_timeout) -> Optional[ from hermes_constants import profile_cli_selector _sel = profile_cli_selector() raise RuntimeError( - f"No LLM provider configured. Run `hermes {_sel}model` to " - f"select a provider, or run `hermes {_sel}setup` for first-time " + "No LLM provider configured. Run `hermes model` to " + "select a provider, or run `hermes setup` for first-time " "configuration." ) diff --git a/agent/anthropic_credentials.py b/agent/anthropic_credentials.py index 43e0a89fa7..a52a5fd970 100644 --- a/agent/anthropic_credentials.py +++ b/agent/anthropic_credentials.py @@ -731,6 +731,17 @@ def _get_hermes_oauth_file() -> Path: return get_hermes_home() / ".anthropic_oauth.json" +def _root_hermes_oauth_file() -> Optional[Path]: + """Global-root ``.anthropic_oauth.json`` inside a named profile (None in classic mode); used to commit a + rotation of a grant the profile borrowed via the pool's root fallback.""" + try: + from hermes_constants import get_default_hermes_root + root = get_default_hermes_root() + return None if root.resolve(strict=False) == get_hermes_home().resolve(strict=False) else root / ".anthropic_oauth.json" + except Exception: + return None + + def _generate_pkce() -> tuple: """Generate PKCE code_verifier and code_challenge (S256).""" verifier = base64.urlsafe_b64encode(secrets.token_bytes(32)).rstrip(b"=").decode() @@ -800,13 +811,14 @@ def read_hermes_oauth_credentials() -> Optional[Dict[str, Any]]: def _write_hermes_oauth_credentials( - access_token: str, refresh_token: Optional[str], expires_at_ms: Optional[int], + access_token: str, refresh_token: Optional[str], expires_at_ms: Optional[int], *, target: Optional[Path] = None ) -> None: - """Commit refreshed hermes_pkce tokens to ``/.anthropic_oauth.json`` (``CredentialPersistError`` - on failure); without it the next ``load_pool()`` re-seeds the stale (consumed) pair from the file over the - rotated pool entry.""" + """Commit refreshed hermes_pkce tokens to ~/.hermes/.anthropic_oauth.json (``CredentialPersistError`` on failure). + ``target`` lets a named profile commit a grant it BORROWED from the global root back to the ROOT singleton + instead of forking a copy under its own HERMES_HOME; without this write-through the next ``load_pool()`` + re-seeds the stale (consumed) pair from the file over the rotated pool entry.""" _commit_private_json( - _get_hermes_oauth_file(), + target if target is not None else _get_hermes_oauth_file(), {"accessToken": access_token, "refreshToken": refresh_token, "expiresAt": expires_at_ms}, "Hermes OAuth credentials", ) diff --git a/agent/credential_pool.py b/agent/credential_pool.py index 533435533a..4b671068eb 100644 --- a/agent/credential_pool.py +++ b/agent/credential_pool.py @@ -36,10 +36,13 @@ from hermes_cli.auth import ( _auth_store_lock, _codex_access_token_is_expiring, _decode_jwt_claims, + _global_auth_file_path, _load_auth_store, _load_provider_state, + _load_provider_state_with_source, _resolve_kimi_base_url, _resolve_zai_base_url, + _same_path, _save_auth_store, _save_provider_state, _store_provider_state, @@ -756,6 +759,180 @@ def resolve_runtime_pool_key(provider: Optional[str], base_url: Optional[str]) - DEFAULT_MAX_CONCURRENT_PER_CREDENTIAL = 1 +# --- Multi-profile root write-through --- + + +def _guarded_global_root(global_path: Optional[Path]) -> Optional[Path]: + """Apply the pytest seat belt to a resolved global-root auth.json path. + + ``None`` means classic mode (profile == root) or "refuse": under pytest, + never write the real user's ``~/.hermes/auth.json`` even when HERMES_HOME + points at a profile path (mirrors the read-side guard in + ``_load_global_auth_store``). Uses the unmodified HOME env, not + ``Path.home()`` which fixtures may monkeypatch. + """ + if global_path is None: + return None + if os.environ.get("PYTEST_CURRENT_TEST"): + real_home_env = os.environ.get("HOME", "") + if real_home_env: + real_root = Path(real_home_env) / ".hermes" / "auth.json" + try: + if global_path.resolve(strict=False) == real_root.resolve(strict=False): + return None + except Exception: + return None + return global_path + + +def _write_through_provider_state_to_global_root( + provider_id: str, state: Dict[str, Any] +) -> None: + """Persist a rotated OAuth ``state`` into the global-root auth.json. + + Best-effort write-through for the multi-profile rotation hazard: nous, + openai-codex, and xai-oauth rotate the refresh_token on refresh, so when + a profile pool refresh rotates a grant it resolved from the root fallback, + the rotated chain must land back in root. Otherwise root keeps a revoked + refresh token and every other profile dies with ``refresh_token_reused`` + / ``invalid_grant`` once its access token expires. + + Only updates ``providers.`` in the root store; never touches + the profile store (the caller already saved that). Swallows all errors — + a failed write-through degrades to root-stale and must never break the + profile's own successful save. Mirrors + ``hermes_cli.auth._write_through_xai_oauth_to_global_root``. + + See #48415. + """ + try: + global_path = _guarded_global_root(auth_mod._global_auth_file_path()) + except Exception: + return + if global_path is None: + return + try: + auth_mod._persist_provider_state_to_store(provider_id, state, global_path, set_active=False) + except Exception as exc: # pragma: no cover - best effort + logger.debug("%s pool refresh: write-through to global root failed: %s", provider_id, exc) + + +def _singleton_target_for_entry(pool: "CredentialPool", entry: "PooledCredential") -> Optional[Path]: + """Root ``.anthropic_oauth.json`` when *entry* is a borrowed hermes_pkce row, else None.""" + if entry.source != "hermes_pkce" or entry.id not in getattr(pool, "_borrowed_root_ids", ()): + return None + try: + from agent.anthropic_credentials import _root_hermes_oauth_file + return _root_hermes_oauth_file() + except Exception: + return None + + +def _profile_owns_pool_provider(provider: str) -> bool: + """True when the ACTIVE auth.json has its own rows for *provider*. + + Named profiles with no local rows read the provider through the + ``read_credential_pool`` global-root fallback ("borrowing"). + """ + try: + pool = _load_auth_store().get("credential_pool") + except Exception: + return True # unreadable store: assume ownership, keep legacy path + entries = pool.get(provider) if isinstance(pool, dict) else None + return isinstance(entries, list) and bool(entries) + + +def _borrowed_single_use_pool_root() -> Optional[Path]: + """Global-root auth.json when persisting a BORROWED single-use pool, else None. + + ``None`` means "persist to the active store as usual": classic mode + (profile == root), or the profile owns its own rows for this provider. + """ + try: + return _guarded_global_root(_global_auth_file_path()) + except Exception: + return None + + +def _update_root_pool_rows( + provider: str, payloads: List[Dict[str, Any]], global_path: Path, + *, status_cleared_ids: Optional[Iterable[str]] = None, +) -> None: + """UPDATE-ONLY merge of *payloads* into the root store's rows for *provider*. + + A borrower may refresh the root's rows (rotation, cooldown state) but + never add or delete them — the root owns their lifecycle. In particular a + profile's singleton-prune (it has no ``.anthropic_oauth.json`` of its own) + must not delete the root grant, so ``removed_ids`` is ignored by callers. + """ + with _auth_store_lock(target_path=global_path): + store = _load_auth_store(global_path) + pool = store.get("credential_pool") + if not isinstance(pool, dict): + pool = {} + store["credential_pool"] = pool + existing = pool.get(provider) + existing_list = existing if isinstance(existing, list) else [] + incoming_by_id = {p.get("id"): p for p in payloads if isinstance(p, dict) and p.get("id")} + cleared = {cid for cid in (status_cleared_ids or ()) if cid} + merged: List[Dict[str, Any]] = [] + changed = False + for disk_entry in existing_list: + did = disk_entry.get("id") if isinstance(disk_entry, dict) else None + incoming = incoming_by_id.get(did) if did else None + if incoming is None: + merged.append(disk_entry) + continue + # A deliberately cleared entry has no disk cooldown worth keeping. + updated = auth_mod._merge_disk_cooldown_state( + incoming, None if did in cleared else disk_entry, provider, + ) + if updated != disk_entry: + changed = True + merged.append(updated) + if changed: + pool[provider] = merged + _save_auth_store(store, target_path=global_path) + + +def persist_pool_entries( + provider: str, + payloads: List[Dict[str, Any]], + *, + removed_ids: Optional[Iterable[str]] = None, + status_cleared_ids: Optional[Iterable[str]] = None, +) -> None: + """Persist a provider's pool rows to the store that OWNS them. + + A named profile that sees a single-use-refresh provider (Anthropic, + Codex, xAI OAuth) only through the global-root fallback must not + materialize a local ``credential_pool.`` copy: that copy forks + the single-use refresh token, the first profile to rotate commits the new + pair only to its own file, and root plus every sibling die with + ``invalid_grant`` (#100339). Such rows are written back to the root store + (under the root lock); everything else goes to the active store. + """ + if provider in SINGLE_USE_REFRESH_POOL_PROVIDERS and not _profile_owns_pool_provider(provider): + global_path = _borrowed_single_use_pool_root() + if global_path is not None: + try: + _update_root_pool_rows( + provider, payloads, global_path, + status_cleared_ids=status_cleared_ids, + ) + except Exception as exc: + # Fail closed on the FORK, not on the save: never fall back to + # writing a local copy (that IS the bug). The in-memory pool + # still holds the rotated pair for this process. + logger.warning( + "%s pool: write-through of borrowed root grant failed (%s); " + "not materializing a profile-local copy", + provider, exc, + ) + return + write_credential_pool( + provider, payloads, removed_ids=removed_ids, status_cleared_ids=status_cleared_ids, + ) # --- Per-provider singleton refresh plumbing ------------------------------- @@ -807,6 +984,9 @@ class CredentialPool(CredentialPoolAdminMixin, CredentialPoolModelCooldownMixin) self.provider = provider self._entries = sorted(entries, key=lambda entry: entry.priority) self._current_id: Optional[str] = None + # Ids of rows read via the global-root fallback (single-use OAuth + # providers only); set by load_pool(), consumed by add_entry(). + self._borrowed_root_ids: Set[str] = set() self._strategy = get_pool_strategy(provider) # RLock: _replace_entry/_persist self-acquire it so the DEFERRED # single-use-token refresh path (network I/O outside the lock by @@ -935,7 +1115,7 @@ class CredentialPool(CredentialPoolAdminMixin, CredentialPoolModelCooldownMixin) ) -> None: # Self-locking: snapshotting self._entries must not race a rotation. with self._lock: - write_credential_pool( + persist_pool_entries( self.provider, [entry.to_dict() for entry in self._entries], removed_ids=removed_ids, @@ -1229,6 +1409,14 @@ class CredentialPool(CredentialPoolAdminMixin, CredentialPoolModelCooldownMixin) ``set_active=False`` everywhere: a sync-back is a token-rotation side effect, not the user choosing a provider; ``_save_provider_state`` would flip ``active_provider`` to whichever provider refreshed last. + + #74339: decide the root write-through on WHERE the state resolved + from (``_load_provider_state_with_source``), not on whether the + profile has a ``providers.`` key — ``_store_provider_state`` + creates that key unconditionally, which self-sealed the check after + the first refresh. When the grant came from the global root, write + back to root ONLY and skip the profile store so it never accrues a + shadowing key that blocks both the fallback and the write-through. """ # Only singleton-seeded entries sync back; ``manual:*`` entries are # independent credentials and must not write to the singleton. @@ -1237,13 +1425,20 @@ class CredentialPool(CredentialPoolAdminMixin, CredentialPoolModelCooldownMixin) try: with _auth_store_lock(): auth_store = _load_auth_store() - state = _load_provider_state(auth_store, self.provider) + state, source_path = _load_provider_state_with_source(auth_store, self.provider) if not isinstance(state, dict): return + global_root = _global_auth_file_path() + is_from_root = bool( + source_path is not None and global_root is not None and _same_path(source_path, global_root) + ) if not self._apply_entry_to_singleton_state(entry, state): return - _store_provider_state(auth_store, self.provider, state, set_active=False) - _save_auth_store(auth_store) + if is_from_root: + _write_through_provider_state_to_global_root(self.provider, state) + else: + _store_provider_state(auth_store, self.provider, state, set_active=False) + _save_auth_store(auth_store) except Exception as exc: logger.debug("Failed to sync %s pool entry back to auth store: %s", self.provider, exc) @@ -1394,10 +1589,11 @@ class CredentialPool(CredentialPoolAdminMixin, CredentialPoolModelCooldownMixin) """Write a rotated Anthropic pair to its authoritative singleton, or fail closed. claude_code -> ~/.claude/.credentials.json (so the fallback resolver - and other profiles see it). hermes_pkce -> /.anthropic_oauth.json - (``_seed_from_singletons`` re-seeds it every load). Not ``endswith``: - manual:hermes_pkce is pool-owned and a singleton for it would be a second - authority for the same refresh-token family. + and other profiles see it). hermes_pkce -> ~/.hermes/.anthropic_oauth.json + (``_seed_from_singletons`` re-seeds it every load; a borrowed row commits + to the ROOT's file, never a new profile-local copy, #100339). Not + ``endswith``: manual:hermes_pkce is pool-owned and a singleton for it + would be a second authority for the same refresh-token family. """ if entry.source == "claude_code": store = "~/.claude/.credentials.json" @@ -1411,7 +1607,7 @@ class CredentialPool(CredentialPoolAdminMixin, CredentialPoolModelCooldownMixin) if entry.source == "claude_code": ac._write_claude_code_credentials(*args, spent_refresh_token=entry.refresh_token or "") else: - ac._write_hermes_oauth_credentials(*args) + ac._write_hermes_oauth_credentials(*args, target=_singleton_target_for_entry(self, entry)) except Exception as wexc: # Authoritative commit failed: do not mark, persist or return the # rotation as successful, and bypass the re-POST recovery path — @@ -2788,6 +2984,10 @@ def _seed_custom_pool(pool_key: str, entries: List[PooledCredential]) -> Tuple[b def load_pool(provider: str) -> CredentialPool: provider = (provider or "").strip().lower() + if provider in SINGLE_USE_REFRESH_POOL_PROVIDERS: + # One-time heal for installs that forked this grant across profiles + # before the clone-strip / root write-through existed (#100339). + auth_mod.heal_forked_single_use_oauth_grants(provider) raw_entries = read_credential_pool(provider) disk_ids = {e.get("id") for e in raw_entries if isinstance(e, dict) and e.get("id")} changed = any( @@ -2802,7 +3002,13 @@ def load_pool(provider: str) -> CredentialPool: ) != payload.get("auth_type", AUTH_TYPE_API_KEY) for payload in raw_entries ) - changed |= raw_needs_auth_normalization + if raw_needs_auth_normalization: + # A profile may be reading this provider from the global-root fallback. + # Keep that fallback read-only: only the owning store may rewrite these + # rows; loading the default/root profile heals global rows. + active_pool = _load_auth_store().get("credential_pool") + active_entries = active_pool.get(provider) if isinstance(active_pool, dict) else None + changed |= bool(active_entries) if provider.startswith(CUSTOM_POOL_PREFIX): custom_changed, custom_sources = _seed_custom_pool(provider, entries) @@ -2814,16 +3020,38 @@ def load_pool(provider: str) -> CredentialPool: changed |= singleton_changed or env_changed # ``load_pool()`` is a non-destructive read for env-seeded entries # (#9331); file-backed singletons still prune when their file is gone. - changed |= _prune_stale_seeded_entries( - entries, singleton_sources | env_sources, prune_env_sources=False, + borrowing_root_grant = ( + provider in SINGLE_USE_REFRESH_POOL_PROVIDERS + and bool(disk_ids) + and not _profile_owns_pool_provider(provider) ) + if borrowing_root_grant: + # Rows read through the global-root fallback are seeded from the + # ROOT's singleton files, which this profile cannot see; pruning + # them would hide (and, via write-through, delete) the shared + # grant. The root's own load_pool() prunes. + borrowed = [e for e in entries if e.id in disk_ids] + others = [e for e in entries if e.id not in disk_ids] + changed |= _prune_stale_seeded_entries( + others, singleton_sources | env_sources, prune_env_sources=False, + ) + entries[:] = borrowed + others + else: + changed |= _prune_stale_seeded_entries( + entries, singleton_sources | env_sources, prune_env_sources=False, + ) changed |= _normalize_pool_priorities(provider, entries) if changed: new_ids = {entry.id for entry in entries} - write_credential_pool( + persist_pool_entries( provider, [entry.to_dict() for entry in sorted(entries, key=lambda item: item.priority)], removed_ids=disk_ids - new_ids, ) - return CredentialPool(provider, entries) + pool = CredentialPool(provider, entries) + # Remember the root's borrowed rows so a later ``add_entry`` in this + # profile leaves them out of the profile's own store (#100339). + if provider in SINGLE_USE_REFRESH_POOL_PROVIDERS and not _profile_owns_pool_provider(provider): + pool._borrowed_root_ids = set(disk_ids) + return pool diff --git a/agent/credential_pool_admin.py b/agent/credential_pool_admin.py index 515a2f09b9..f5b89f7e08 100644 --- a/agent/credential_pool_admin.py +++ b/agent/credential_pool_admin.py @@ -54,12 +54,18 @@ class CredentialPoolAdminMixin: return len(stale) def remove_index(self, index: int) -> Optional[PooledCredential]: + from agent.credential_pool import persist_pool_entries + with self._lock: if index < 1 or index > len(self._entries): return None removed = self._entries.pop(index - 1) self._entries = [replace(e, priority=p) for p, e in enumerate(self._entries)] - self._persist(removed_ids=[removed.id]) + persist_pool_entries( + self.provider, + [entry.to_dict() for entry in self._entries], + removed_ids=[removed.id], + ) if self._current_id == removed.id: self._current_id = None return removed @@ -108,10 +114,21 @@ class CredentialPoolAdminMixin: return None, None, f'No credential matching "{raw}".' def add_entry(self, entry: PooledCredential) -> PooledCredential: - from agent.credential_pool import _next_priority + from agent.credential_pool import _next_priority, write_credential_pool with self._lock: entry = replace(entry, priority=_next_priority(self._entries)) self._entries.append(entry) - self._persist() + borrowed_ids = getattr(self, "_borrowed_root_ids", None) + if borrowed_ids: + # ``hermes -p auth add ``: the + # profile claims its OWN credential. Persist only profile-owned + # rows — copying the borrowed root grant alongside would fork + # its single-use refresh token (#100339). Once the profile owns + # rows, the root fallback for this provider is shadowed. + self._entries = [e for e in self._entries if e.id not in borrowed_ids] + write_credential_pool(self.provider, [e.to_dict() for e in self._entries]) + self._borrowed_root_ids = set() + else: + self._persist() return entry diff --git a/apps/desktop/src/components/onboarding-chat/setup-profile.ts b/apps/desktop/src/components/onboarding-chat/setup-profile.ts index 4d141fd459..ef1587a959 100644 --- a/apps/desktop/src/components/onboarding-chat/setup-profile.ts +++ b/apps/desktop/src/components/onboarding-chat/setup-profile.ts @@ -292,6 +292,7 @@ export async function ensureSetupProfile(request: GatewayRequest): Promise description: 'Where Hermes met you — walks your first run, then checks in as you find your feet.', name: SETUP_PROFILE, clone_from: 'default', + share_auth: true, no_alias: true, soul: composeSetupSoul() }) diff --git a/apps/desktop/src/plugins/hermes-bots/create-dialog.tsx b/apps/desktop/src/plugins/hermes-bots/create-dialog.tsx index 31747afa2d..12a0a3afb5 100644 --- a/apps/desktop/src/plugins/hermes-bots/create-dialog.tsx +++ b/apps/desktop/src/plugins/hermes-bots/create-dialog.tsx @@ -141,7 +141,7 @@ export function CreateAgentDialog({ open, onClose, roster }: CreateAgentDialogPr const [provider, setProvider] = useState('') const [soul, setSoul] = useState('') const [noSkills, setNoSkills] = useState(false) - const [mirrorCredentials, setMirrorCredentials] = useState(true) + const [shareAuth, setShareAuth] = useState(true) const [advTab, setAdvTab] = useState('general') // Where the profile is created: '' = the active gateway (unchanged default), // else a registry connection id — the profiles.create lands on THAT @@ -274,7 +274,7 @@ export function CreateAgentDialog({ open, onClose, roster }: CreateAgentDialogPr setProvider('') setSoul('') setNoSkills(false) - setMirrorCredentials(true) + setShareAuth(true) setAdvTab('general') setCreatedForCaps(null) setCaps(null) @@ -392,10 +392,10 @@ export function CreateAgentDialog({ open, onClose, roster }: CreateAgentDialogPr // the remote box doesn't have. clone_from: cloneFrom === '__none__' ? null : remoteTarget ? 'default' : cloneFrom, no_skills: noSkills, - // Copies the main profile's API keys (.env + auth.json) into the new profile. OAuth - // logins are never copied (single-use refresh tokens fork) and never inherited: a - // profile only reads its own auth.json, so sign the bot in itself for those. - mirror_credentials: mirrorCredentials, + // Shared (not copied) auth keeps ONE OAuth/token pool with the main + // profile, so refreshes can't invalidate each other. Older gateways + // ignore the param and copy — still functional, just forked. + share_auth: shareAuth, soul: composeSoul({ name: slug, title: botTitle, @@ -792,16 +792,12 @@ export function CreateAgentDialog({ open, onClose, roster }: CreateAgentDialogPr /> )}
- Each profile owns its credentials. API keys are copied; OAuth logins (Claude, Codex, xAI, Nous) are - not — sign the bot in with hermes -p <name> model. Uncheck to start with no - credentials. + Subscriptions, OAuth logins, and API keys stay shared (not copied), so token refreshes never + invalidate each other. Uncheck for an isolated snapshot copy.