diff --git a/apps/desktop/electron/connection-config.cjs b/apps/desktop/electron/connection-config.cjs index 4595ca043c9..f9eaaa65e9e 100644 --- a/apps/desktop/electron/connection-config.cjs +++ b/apps/desktop/electron/connection-config.cjs @@ -166,6 +166,39 @@ function profileRemoteOverride(config, profile) { return { url, authMode: normAuthMode(entry.authMode), token: entry.token } } +/** + * In global-remote mode one backend serves every Desktop profile, so REST calls + * that are scoped by renderer-side `request.profile` must carry that scope as a + * query parameter. Local pooled backends and per-profile remote overrides do not + * need this: they already run against a backend scoped to the target profile. + */ +function pathWithGlobalRemoteProfile(path, profile, opts = {}) { + const scopedProfile = connectionScopeKey(profile) + if (!scopedProfile || !opts.globalRemote || opts.profileRemoteOverride) { + return path + } + + const rawPath = String(path || '') + if (!rawPath) { + return path + } + + let parsed + try { + parsed = new URL(rawPath, 'http://hermes.local') + } catch { + return path + } + + if (parsed.searchParams.has('profile')) { + return path + } + + parsed.searchParams.set('profile', scopedProfile) + + return `${parsed.pathname}${parsed.search}${parsed.hash}` +} + function tokenPreview(value) { const raw = String(value || '') @@ -247,6 +280,7 @@ module.exports = { cookiesHaveLiveSession, normAuthMode, normalizeRemoteBaseUrl, + pathWithGlobalRemoteProfile, profileRemoteOverride, resolveAuthMode, resolveTestWsUrl, diff --git a/apps/desktop/electron/connection-config.test.cjs b/apps/desktop/electron/connection-config.test.cjs index 7e7332ca33f..1c7330e78d0 100644 --- a/apps/desktop/electron/connection-config.test.cjs +++ b/apps/desktop/electron/connection-config.test.cjs @@ -24,6 +24,7 @@ const { cookiesHaveLiveSession, normAuthMode, normalizeRemoteBaseUrl, + pathWithGlobalRemoteProfile, profileRemoteOverride, resolveAuthMode, resolveTestWsUrl, @@ -90,6 +91,72 @@ test('profileRemoteOverride tolerates a missing/!object profiles map', () => { assert.equal(profileRemoteOverride(null, 'coder'), null) }) +// --- pathWithGlobalRemoteProfile --- + +test('pathWithGlobalRemoteProfile appends profile in global remote mode', () => { + assert.equal( + pathWithGlobalRemoteProfile('/api/model/info', 'iris', { + globalRemote: true, + profileRemoteOverride: false + }), + '/api/model/info?profile=iris' + ) +}) + +test('pathWithGlobalRemoteProfile preserves existing query params', () => { + assert.equal( + pathWithGlobalRemoteProfile('/api/model/options?force=1', 'iris', { + globalRemote: true, + profileRemoteOverride: false + }), + '/api/model/options?force=1&profile=iris' + ) +}) + +test('pathWithGlobalRemoteProfile does not replace an explicit profile query', () => { + assert.equal( + pathWithGlobalRemoteProfile('/api/model/info?profile=default', 'iris', { + globalRemote: true, + profileRemoteOverride: false + }), + '/api/model/info?profile=default' + ) +}) + +test('pathWithGlobalRemoteProfile skips local and per-profile remote override paths', () => { + assert.equal( + pathWithGlobalRemoteProfile('/api/model/info', 'iris', { + globalRemote: false, + profileRemoteOverride: false + }), + '/api/model/info' + ) + assert.equal( + pathWithGlobalRemoteProfile('/api/model/info', 'iris', { + globalRemote: true, + profileRemoteOverride: true + }), + '/api/model/info' + ) +}) + +test('pathWithGlobalRemoteProfile skips empty profile/path safely', () => { + assert.equal( + pathWithGlobalRemoteProfile('/api/model/info', '', { + globalRemote: true, + profileRemoteOverride: false + }), + '/api/model/info' + ) + assert.equal( + pathWithGlobalRemoteProfile('', 'iris', { + globalRemote: true, + profileRemoteOverride: false + }), + '' + ) +}) + // --- normalizeRemoteBaseUrl --- test('normalizeRemoteBaseUrl strips trailing slashes, hash, and query', () => { diff --git a/apps/desktop/electron/main.cjs b/apps/desktop/electron/main.cjs index 101cd0801f7..19096c61357 100644 --- a/apps/desktop/electron/main.cjs +++ b/apps/desktop/electron/main.cjs @@ -63,6 +63,7 @@ const { cookiesHaveLiveSession, normAuthMode, normalizeRemoteBaseUrl, + pathWithGlobalRemoteProfile, profileRemoteOverride, resolveAuthMode, resolveTestWsUrl, @@ -5612,9 +5613,14 @@ ipcMain.handle('hermes:api', async (_event, request) => { await prepareProfileDeleteRequest(request) - const connection = await ensureBackend(request?.profile) + const profile = request?.profile + const connection = await ensureBackend(profile) const timeoutMs = resolveTimeoutMs(request?.timeoutMs, DEFAULT_FETCH_TIMEOUT_MS) - const url = `${connection.baseUrl}${request.path}` + const requestPath = pathWithGlobalRemoteProfile(request.path, profile, { + globalRemote: globalRemoteActive(), + profileRemoteOverride: profileHasRemoteOverride(profile) + }) + const url = `${connection.baseUrl}${requestPath}` // OAuth gateways authenticate REST via the HttpOnly session cookie held in // the OAuth partition — route through Electron's net stack bound to that // session so the cookie attaches automatically. Token/local modes keep using diff --git a/hermes_cli/auth.py b/hermes_cli/auth.py index f7857b48543..452723a3df6 100644 --- a/hermes_cli/auth.py +++ b/hermes_cli/auth.py @@ -5763,18 +5763,24 @@ def _snapshot_nous_pool_status() -> Dict[str, Any]: # subscription-feature checks) call it many times per render — `hermes tools` → "All Platforms" # was firing the refresh ~31× during one menu paint, racking up >13s of HTTP and burning # single-use refresh tokens. Cache the snapshot for a few seconds, keyed on the auth.json -# mtime so that `hermes auth login/logout/add/remove` invalidate naturally on the next call. +# path + mtime so that profile switches do not share a process memo and +# `hermes auth login/logout/add/remove` invalidate naturally on the next call. _NOUS_AUTH_STATUS_CACHE_TTL = 15.0 # seconds -_nous_auth_status_cache: Optional[Tuple[float, Optional[float], Dict[str, Any]]] = None +_nous_auth_status_cache: Optional[Tuple[float, str, Optional[float], Dict[str, Any]]] = None -def _auth_file_mtime() -> Optional[float]: +def _auth_file_cache_key() -> Tuple[str, Optional[float]]: + auth_file = _auth_file_path() try: - return _auth_file_path().stat().st_mtime - except FileNotFoundError: - return None + auth_file_key = str(auth_file.resolve(strict=False)) except Exception: - return None + auth_file_key = str(auth_file) + try: + return auth_file_key, auth_file.stat().st_mtime + except FileNotFoundError: + return auth_file_key, None + except Exception: + return auth_file_key, None def invalidate_nous_auth_status_cache() -> None: @@ -5806,18 +5812,19 @@ def get_nous_auth_status() -> Dict[str, Any]: """ global _nous_auth_status_cache now = time.monotonic() - mtime = _auth_file_mtime() + auth_file_key, mtime = _auth_file_cache_key() cached = _nous_auth_status_cache if cached is not None: - cached_at, cached_mtime, cached_status = cached + cached_at, cached_auth_file_key, cached_mtime, cached_status = cached if ( - cached_mtime == mtime + cached_auth_file_key == auth_file_key + and cached_mtime == mtime and (now - cached_at) < _NOUS_AUTH_STATUS_CACHE_TTL ): return dict(cached_status) status = _compute_nous_auth_status() - _nous_auth_status_cache = (now, mtime, dict(status)) + _nous_auth_status_cache = (now, auth_file_key, mtime, dict(status)) return status diff --git a/hermes_cli/web_server.py b/hermes_cli/web_server.py index 7cd3a7eaa03..c434fb6751d 100644 --- a/hermes_cli/web_server.py +++ b/hermes_cli/web_server.py @@ -5191,7 +5191,7 @@ def _oauth_provider_disconnect_hint(provider: Dict[str, Any], status: Dict[str, @app.get("/api/providers/oauth") -async def list_oauth_providers(): +async def list_oauth_providers(profile: Optional[str] = None): """Enumerate every OAuth-capable LLM provider with current status. Response shape (per provider): @@ -5208,83 +5208,89 @@ async def list_oauth_providers(): expires_at ISO timestamp string or null has_refresh_token bool """ - providers = [] - for p in _OAUTH_PROVIDER_CATALOG: - status = _resolve_provider_status(p["id"], p.get("status_fn")) - disconnect_hint = _oauth_provider_disconnect_hint(p, status) - providers.append({ - "id": p["id"], - "name": p["name"], - "flow": p["flow"], - "cli_command": p["cli_command"], - "docs_url": p["docs_url"], - "disconnect_hint": disconnect_hint, - "disconnectable": disconnect_hint is None, - "status": status, - }) - return {"providers": providers} + with _profile_scope(profile): + providers = [] + for p in _OAUTH_PROVIDER_CATALOG: + status = _resolve_provider_status(p["id"], p.get("status_fn")) + disconnect_hint = _oauth_provider_disconnect_hint(p, status) + providers.append({ + "id": p["id"], + "name": p["name"], + "flow": p["flow"], + "cli_command": p["cli_command"], + "docs_url": p["docs_url"], + "disconnect_hint": disconnect_hint, + "disconnectable": disconnect_hint is None, + "status": status, + }) + return {"providers": providers} @app.delete("/api/providers/oauth/{provider_id}") -async def disconnect_oauth_provider(provider_id: str, request: Request): +async def disconnect_oauth_provider( + provider_id: str, + request: Request, + profile: Optional[str] = None, +): """Disconnect an OAuth provider. Token-protected (matches /env/reveal).""" _require_token(request) - catalog_by_id = {p["id"]: p for p in _OAUTH_PROVIDER_CATALOG} - provider = catalog_by_id.get(provider_id) - if provider is None: - raise HTTPException( - status_code=400, - detail=f"Unknown provider: {provider_id}. " - f"Available: {', '.join(sorted(catalog_by_id))}", - ) + with _profile_scope(profile): + catalog_by_id = {p["id"]: p for p in _OAUTH_PROVIDER_CATALOG} + provider = catalog_by_id.get(provider_id) + if provider is None: + raise HTTPException( + status_code=400, + detail=f"Unknown provider: {provider_id}. " + f"Available: {', '.join(sorted(catalog_by_id))}", + ) - disconnect_hint = _oauth_provider_disconnect_hint(provider, {}) - if disconnect_hint: - raise HTTPException( - status_code=400, - detail=f"{provider['name']} cannot be disconnected automatically. {disconnect_hint}", - ) + disconnect_hint = _oauth_provider_disconnect_hint(provider, {}) + if disconnect_hint: + raise HTTPException( + status_code=400, + detail=f"{provider['name']} cannot be disconnected automatically. {disconnect_hint}", + ) - status = _resolve_provider_status(provider_id, provider.get("status_fn")) - disconnect_hint = _oauth_provider_disconnect_hint(provider, status) - if disconnect_hint: - raise HTTPException( - status_code=400, - detail=f"{provider['name']} cannot be disconnected automatically. {disconnect_hint}", - ) + status = _resolve_provider_status(provider_id, provider.get("status_fn")) + disconnect_hint = _oauth_provider_disconnect_hint(provider, status) + if disconnect_hint: + raise HTTPException( + status_code=400, + detail=f"{provider['name']} cannot be disconnected automatically. {disconnect_hint}", + ) + + # Anthropic clears only the Hermes-managed PKCE file and auth-store entry. + # The separate claude-code catalog row is external/read-only and rejected + # above so we never pretend to remove ~/.claude/* credentials owned by the CLI. + if provider_id == "anthropic": + cleared = False + try: + from agent.anthropic_adapter import _HERMES_OAUTH_FILE + if _HERMES_OAUTH_FILE.exists(): + _HERMES_OAUTH_FILE.unlink() + cleared = True + except Exception: + pass + # Also clear the credential pool entry if present. + try: + from hermes_cli.auth import clear_provider_auth + cleared = clear_provider_auth("anthropic") or cleared + except Exception: + pass + _log.info("oauth/disconnect: %s", provider_id) + return {"ok": bool(cleared), "provider": provider_id} - # Anthropic clears only the Hermes-managed PKCE file and auth-store entry. - # The separate claude-code catalog row is external/read-only and rejected - # above so we never pretend to remove ~/.claude/* credentials owned by the CLI. - if provider_id == "anthropic": - cleared = False try: - from agent.anthropic_adapter import _HERMES_OAUTH_FILE - if _HERMES_OAUTH_FILE.exists(): - _HERMES_OAUTH_FILE.unlink() - cleared = True - except Exception: - pass - # Also clear the credential pool entry if present. - try: - from hermes_cli.auth import clear_provider_auth - cleared = clear_provider_auth("anthropic") or cleared - except Exception: - pass - _log.info("oauth/disconnect: %s", provider_id) - return {"ok": bool(cleared), "provider": provider_id} - - try: - from hermes_cli.auth import clear_provider_auth, invalidate_nous_auth_status_cache - cleared = clear_provider_auth(provider_id) - if provider_id == "nous": - invalidate_nous_auth_status_cache() - _log.info("oauth/disconnect: %s (cleared=%s)", provider_id, cleared) - return {"ok": bool(cleared), "provider": provider_id} - except Exception as e: - _log.exception("disconnect %s failed", provider_id) - raise HTTPException(status_code=500, detail=str(e)) + from hermes_cli.auth import clear_provider_auth, invalidate_nous_auth_status_cache + cleared = clear_provider_auth(provider_id) + if provider_id == "nous": + invalidate_nous_auth_status_cache() + _log.info("oauth/disconnect: %s (cleared=%s)", provider_id, cleared) + return {"ok": bool(cleared), "provider": provider_id} + except Exception as e: + _log.exception("disconnect %s failed", provider_id) + raise HTTPException(status_code=500, detail=str(e)) # --------------------------------------------------------------------------- @@ -5366,13 +5372,32 @@ def _gc_oauth_sessions() -> None: _oauth_sessions.pop(sid, None) -def _new_oauth_session(provider_id: str, flow: str) -> tuple[str, Dict[str, Any]]: +def _oauth_profile_name(profile: Optional[str]) -> Optional[str]: + requested = (profile or "").strip() + if not requested or requested.lower() == "current": + return None + return requested + + +def _validate_oauth_profile(profile: Optional[str]) -> None: + profile_name = _oauth_profile_name(profile) + if profile_name: + _resolve_profile_dir(profile_name) + + +def _new_oauth_session( + provider_id: str, + flow: str, + profile: Optional[str] = None, +) -> tuple[str, Dict[str, Any]]: """Create + register a new OAuth session, return (session_id, session_dict).""" sid = secrets.token_urlsafe(16) + profile_name = _oauth_profile_name(profile) sess = { "session_id": sid, "provider": provider_id, "flow": flow, + "profile": profile_name, "created_at": time.time(), "status": "pending", # pending | approved | denied | expired | error "error_message": None, @@ -5382,6 +5407,17 @@ def _new_oauth_session(provider_id: str, flow: str) -> tuple[str, Dict[str, Any] return sid, sess +def _oauth_session_profile( + session_id: str, + fallback: Optional[str] = None, +) -> Optional[str]: + """Return the profile that owns an OAuth session, if one was provided.""" + with _oauth_sessions_lock: + sess = _oauth_sessions.get(session_id) + profile = sess.get("profile") if sess else None + return profile or _oauth_profile_name(fallback) + + def _save_anthropic_oauth_creds(access_token: str, refresh_token: str, expires_at_ms: int) -> None: """Persist Anthropic PKCE creds to both Hermes file AND credential pool. @@ -5449,12 +5485,12 @@ def _save_anthropic_oauth_creds(access_token: str, refresh_token: str, expires_a _log.warning("anthropic pool add (dashboard) failed: %s", e) -def _start_anthropic_pkce() -> Dict[str, Any]: +def _start_anthropic_pkce(profile: Optional[str] = None) -> Dict[str, Any]: """Begin PKCE flow. Returns the auth URL the UI should open.""" if not _ANTHROPIC_OAUTH_AVAILABLE: raise HTTPException(status_code=501, detail="Anthropic OAuth not available (missing adapter)") verifier, challenge = _generate_pkce_pair() - sid, sess = _new_oauth_session("anthropic", "pkce") + sid, sess = _new_oauth_session("anthropic", "pkce", profile=profile) sess["verifier"] = verifier sess["state"] = verifier # Anthropic round-trips verifier as state params = { @@ -5476,7 +5512,11 @@ def _start_anthropic_pkce() -> Dict[str, Any]: } -def _submit_anthropic_pkce(session_id: str, code_input: str) -> Dict[str, Any]: +def _submit_anthropic_pkce( + session_id: str, + code_input: str, + profile: Optional[str] = None, +) -> Dict[str, Any]: """Exchange authorization code for tokens. Persists on success.""" with _oauth_sessions_lock: sess = _oauth_sessions.get(session_id) @@ -5530,7 +5570,8 @@ def _submit_anthropic_pkce(session_id: str, code_input: str) -> Dict[str, Any]: expires_at_ms = int(time.time() * 1000) + (expires_in * 1000) try: - _save_anthropic_oauth_creds(access_token, refresh_token, expires_at_ms) + with _profile_scope(_oauth_session_profile(session_id, profile)): + _save_anthropic_oauth_creds(access_token, refresh_token, expires_at_ms) except Exception as e: with _oauth_sessions_lock: sess["status"] = "error" @@ -5542,7 +5583,10 @@ def _submit_anthropic_pkce(session_id: str, code_input: str) -> Dict[str, Any]: return {"ok": True, "status": "approved"} -async def _start_device_code_flow(provider_id: str) -> Dict[str, Any]: +async def _start_device_code_flow( + provider_id: str, + profile: Optional[str] = None, +) -> Dict[str, Any]: """Initiate a device-code flow (Nous, OpenAI Codex, or MiniMax). Calls the provider's device-auth endpoint via the existing CLI helpers, @@ -5582,7 +5626,7 @@ async def _start_device_code_flow(provider_id: str) -> Dict[str, Any]: device_data, effective_scope = await asyncio.get_running_loop().run_in_executor( None, _do_nous_device_request ) - sid, sess = _new_oauth_session("nous", "device_code") + sid, sess = _new_oauth_session("nous", "device_code", profile=profile) sess["device_code"] = str(device_data["device_code"]) sess["interval"] = int(device_data["interval"]) sess["expires_at"] = time.time() + int(device_data["expires_in"]) @@ -5603,7 +5647,7 @@ async def _start_device_code_flow(provider_id: str) -> Dict[str, Any]: if provider_id == "openai-codex": # Codex uses fixed OpenAI device-auth endpoints; reuse the helper. - sid, _ = _new_oauth_session("openai-codex", "device_code") + sid, _ = _new_oauth_session("openai-codex", "device_code", profile=profile) # Use the helper but in a thread because it polls inline. # We can't extract just the start step without refactoring auth.py, # so we run the full helper in a worker and proxy the user_code + @@ -5670,7 +5714,7 @@ async def _start_device_code_flow(provider_id: str) -> Dict[str, Any]: device_data = await asyncio.get_event_loop().run_in_executor( None, _do_minimax_request ) - sid, sess = _new_oauth_session("minimax-oauth", "device_code") + sid, sess = _new_oauth_session("minimax-oauth", "device_code", profile=profile) # The CLI flow names this `interval_ms` because MiniMax's # `interval` field is in milliseconds (defensive default 2000ms # in _minimax_poll_token). @@ -5724,7 +5768,7 @@ async def _start_device_code_flow(provider_id: str) -> Dict[str, Any]: _XAI_LOOPBACK_TIMEOUT_SECONDS = 300.0 -def _start_xai_loopback_flow() -> Dict[str, Any]: +def _start_xai_loopback_flow(profile: Optional[str] = None) -> Dict[str, Any]: """Begin the xAI loopback PKCE flow. Binds the local callback server, builds the authorize URL, and spawns a @@ -5763,7 +5807,7 @@ def _start_xai_loopback_flow() -> Dict[str, Any]: pass raise - sid, sess = _new_oauth_session("xai-oauth", "loopback") + sid, sess = _new_oauth_session("xai-oauth", "loopback", profile=profile) sess["server"] = server sess["thread"] = thread sess["callback_result"] = callback_result @@ -5866,13 +5910,14 @@ def _xai_loopback_worker(session_id: str) -> None: } if _cancelled(): return - hauth._save_xai_oauth_tokens( - tokens, - discovery=sess.get("discovery"), - redirect_uri=sess["redirect_uri"], - last_refresh=last_refresh, - ) - _add_xai_oauth_pool_entry(access_token, refresh_token, base_url, last_refresh) + with _profile_scope(_oauth_session_profile(session_id)): + hauth._save_xai_oauth_tokens( + tokens, + discovery=sess.get("discovery"), + redirect_uri=sess["redirect_uri"], + last_refresh=last_refresh, + ) + _add_xai_oauth_pool_entry(access_token, refresh_token, base_url, last_refresh) except Exception as exc: _fail(f"xAI token exchange failed: {exc}") return @@ -5975,13 +6020,14 @@ def _nous_poller(session_id: str) -> None: ), "expires_in": token_ttl, } - full_state = refresh_nous_oauth_from_state( - auth_state, - timeout_seconds=15.0, - force_refresh=False, - ) - from hermes_cli.auth import persist_nous_credentials - persist_nous_credentials(full_state) + with _profile_scope(_oauth_session_profile(session_id)): + full_state = refresh_nous_oauth_from_state( + auth_state, + timeout_seconds=15.0, + force_refresh=False, + ) + from hermes_cli.auth import persist_nous_credentials + persist_nous_credentials(full_state) with _oauth_sessions_lock: sess["status"] = "approved" _log.info("oauth/device: nous login completed (session=%s)", session_id) @@ -6064,7 +6110,8 @@ def _minimax_poller(session_id: str) -> None: ).isoformat(), "expires_in": expires_in_s, } - _minimax_save_auth_state(auth_state) + with _profile_scope(_oauth_session_profile(session_id)): + _minimax_save_auth_state(auth_state) with _oauth_sessions_lock: sess["status"] = "approved" _log.info("oauth/device: minimax login completed (session=%s)", session_id) @@ -6177,10 +6224,11 @@ def _codex_full_login_worker(session_id: str) -> None: from hermes_cli.auth import _save_codex_tokens - _save_codex_tokens({ - "access_token": access_token, - "refresh_token": refresh_token, - }) + with _profile_scope(_oauth_session_profile(session_id)): + _save_codex_tokens({ + "access_token": access_token, + "refresh_token": refresh_token, + }) with _oauth_sessions_lock: sess["status"] = "approved" _log.info("oauth/device: openai-codex login completed (session=%s)", session_id) @@ -6194,10 +6242,15 @@ def _codex_full_login_worker(session_id: str) -> None: @app.post("/api/providers/oauth/{provider_id}/start") -async def start_oauth_login(provider_id: str, request: Request): +async def start_oauth_login( + provider_id: str, + request: Request, + profile: Optional[str] = None, +): """Initiate an OAuth login flow. Token-protected.""" _require_token(request) _gc_oauth_sessions() + _validate_oauth_profile(profile) valid = {p["id"] for p in _OAUTH_PROVIDER_CATALOG} if provider_id not in valid: raise HTTPException(status_code=400, detail=f"Unknown provider {provider_id}") @@ -6215,12 +6268,12 @@ async def start_oauth_login(provider_id: str, request: Request): # change for MiniMax). New PKCE providers must add their own # start function and an explicit branch here. if catalog_entry["flow"] == "pkce" and provider_id == "anthropic": - return _start_anthropic_pkce() + return _start_anthropic_pkce(profile=profile) if catalog_entry["flow"] == "device_code": - return await _start_device_code_flow(provider_id) + return await _start_device_code_flow(provider_id, profile=profile) if catalog_entry["flow"] == "loopback" and provider_id == "xai-oauth": return await asyncio.get_running_loop().run_in_executor( - None, _start_xai_loopback_flow + None, _start_xai_loopback_flow, profile, ) except HTTPException: raise @@ -6236,18 +6289,27 @@ class OAuthSubmitBody(BaseModel): @app.post("/api/providers/oauth/{provider_id}/submit") -async def submit_oauth_code(provider_id: str, body: OAuthSubmitBody, request: Request): +async def submit_oauth_code( + provider_id: str, + body: OAuthSubmitBody, + request: Request, + profile: Optional[str] = None, +): """Submit the auth code for PKCE flows. Token-protected.""" _require_token(request) if provider_id == "anthropic": return await asyncio.get_running_loop().run_in_executor( - None, _submit_anthropic_pkce, body.session_id, body.code, + None, _submit_anthropic_pkce, body.session_id, body.code, profile, ) raise HTTPException(status_code=400, detail=f"submit not supported for {provider_id}") @app.get("/api/providers/oauth/{provider_id}/poll/{session_id}") -async def poll_oauth_session(provider_id: str, session_id: str): +async def poll_oauth_session( + provider_id: str, + session_id: str, + profile: Optional[str] = None, +): """Poll a session's status (no auth — read-only state). Shared by the device-code flows (Nous, OpenAI Codex, MiniMax) and the @@ -6270,7 +6332,11 @@ async def poll_oauth_session(provider_id: str, session_id: str): @app.delete("/api/providers/oauth/sessions/{session_id}") -async def cancel_oauth_session(session_id: str, request: Request): +async def cancel_oauth_session( + session_id: str, + request: Request, + profile: Optional[str] = None, +): """Cancel a pending OAuth session. Token-protected.""" _require_token(request) with _oauth_sessions_lock: diff --git a/tests/hermes_cli/test_nous_auth_status_cache.py b/tests/hermes_cli/test_nous_auth_status_cache.py index 5f0e733fb4c..0a60ce6adf0 100644 --- a/tests/hermes_cli/test_nous_auth_status_cache.py +++ b/tests/hermes_cli/test_nous_auth_status_cache.py @@ -3,8 +3,9 @@ The cache avoids re-validating Nous credentials on every menu paint — `hermes tools` → "All Platforms" used to fire ~31 OAuth refresh POSTs against portal.nousresearch.com during one render. The cache is keyed -on auth.json mtime so login/logout flows invalidate naturally; tests -and other writers can also call invalidate_nous_auth_status_cache(). +on auth.json path + mtime so profile switches stay isolated while +login/logout flows invalidate naturally; tests and other writers can +also call invalidate_nous_auth_status_cache(). """ from __future__ import annotations @@ -88,6 +89,42 @@ def test_get_nous_auth_status_invalidates_on_auth_file_mtime(tmp_path, monkeypat auth_mod.invalidate_nous_auth_status_cache() +def test_get_nous_auth_status_cache_is_scoped_by_auth_file_path(tmp_path, monkeypatch): + """Two profile homes with missing auth.json must not share cached status.""" + profile_a = tmp_path / "profiles" / "a" + profile_b = tmp_path / "profiles" / "b" + profile_a.mkdir(parents=True) + profile_b.mkdir(parents=True) + + from hermes_cli import auth as auth_mod + + auth_mod.invalidate_nous_auth_status_cache() + + call_count = {"n": 0} + seen_auth_files = [] + + def fake_compute(): + call_count["n"] += 1 + seen_auth_files.append(auth_mod._auth_file_path()) + return {"logged_in": False, "call": call_count["n"]} + + with patch.object(auth_mod, "_compute_nous_auth_status", side_effect=fake_compute): + monkeypatch.setenv("HERMES_HOME", str(profile_a)) + first = auth_mod.get_nous_auth_status() + monkeypatch.setenv("HERMES_HOME", str(profile_b)) + second = auth_mod.get_nous_auth_status() + + assert call_count["n"] == 2 + assert first["call"] == 1 + assert second["call"] == 2 + assert seen_auth_files == [ + profile_a / "auth.json", + profile_b / "auth.json", + ] + + auth_mod.invalidate_nous_auth_status_cache() + + def test_invalidate_nous_auth_status_cache_forces_recompute(tmp_path, monkeypatch): """Explicit invalidate forces the next call to re-compute.""" monkeypatch.setenv("HERMES_HOME", str(tmp_path)) diff --git a/tests/hermes_cli/test_web_oauth_dispatch.py b/tests/hermes_cli/test_web_oauth_dispatch.py index 5cb2c30d8b3..9b1b853c93c 100644 --- a/tests/hermes_cli/test_web_oauth_dispatch.py +++ b/tests/hermes_cli/test_web_oauth_dispatch.py @@ -34,6 +34,13 @@ client = TestClient(app) HEADERS = {"X-Hermes-Session-Token": _SESSION_TOKEN} +def _make_profile_home(tmp_path, monkeypatch, profile="coder"): + monkeypatch.setenv("HERMES_HOME", str(tmp_path)) + profile_home = tmp_path / "profiles" / profile + profile_home.mkdir(parents=True) + return profile_home + + def _fake_nous_device_data(): return { "device_code": "device-code", @@ -127,6 +134,67 @@ def test_nous_dashboard_device_flow_ignores_legacy_scope_override(monkeypatch): ws._oauth_sessions.pop(result["session_id"], None) +def test_oauth_provider_status_uses_profile_query(tmp_path, monkeypatch): + from hermes_cli import web_server as ws + from hermes_constants import get_hermes_home + + profile_home = _make_profile_home(tmp_path, monkeypatch) + observed_homes = [] + + def fake_status(): + observed_homes.append(get_hermes_home()) + return {"logged_in": False, "source": None} + + fake_catalog = ({ + "id": "fake-oauth", + "name": "Fake OAuth", + "flow": "pkce", + "cli_command": "hermes auth add fake-oauth", + "docs_url": "https://example.com", + "status_fn": fake_status, + },) + monkeypatch.setattr(ws, "_OAUTH_PROVIDER_CATALOG", fake_catalog) + + resp = client.get("/api/providers/oauth?profile=coder", headers=HEADERS) + + assert resp.status_code == 200, resp.text + assert observed_homes == [profile_home] + + +def test_oauth_start_stores_profile_for_background_completion(tmp_path, monkeypatch): + from hermes_cli import web_server as ws + + _make_profile_home(tmp_path, monkeypatch) + fake_user_code_resp = { + "user_code": "ABCD-1234", + "verification_uri": "https://api.minimax.io/oauth/verify", + "expired_in": 600, + "interval": 2000, + "state": "stub-state", + } + with patch( + "hermes_cli.auth._minimax_request_user_code", + return_value=fake_user_code_resp, + ), patch( + "hermes_cli.auth._minimax_pkce_pair", + return_value=("verifier-stub", "challenge-stub", "stub-state"), + ), patch( + "hermes_cli.web_server._minimax_poller", + return_value=None, + ): + resp = client.post( + "/api/providers/oauth/minimax-oauth/start?profile=coder", + headers=HEADERS, + ) + + assert resp.status_code == 200, resp.text + session_id = resp.json()["session_id"] + try: + assert ws._oauth_sessions[session_id]["profile"] == "coder" + finally: + ws._oauth_sessions.pop(session_id, None) + + def test_nous_dashboard_device_flow_does_not_retry_legacy_scope_on_invoke_refusal(monkeypatch): from hermes_cli import auth as auth_mod from hermes_cli import web_server as ws @@ -207,6 +275,71 @@ def test_codex_dashboard_worker_persists_runtime_provider(tmp_path, monkeypatch) ws._oauth_sessions.pop(sid, None) +def test_codex_dashboard_worker_persists_inside_session_profile(tmp_path, monkeypatch): + from hermes_cli import auth as auth_mod + from hermes_cli import web_server as ws + from hermes_constants import get_hermes_home + + profile_home = _make_profile_home(tmp_path, monkeypatch) + + class _Resp: + def __init__(self, status_code, payload): + self.status_code = status_code + self._payload = payload + + def json(self): + return self._payload + + class _Client: + def __init__(self, *args, **kwargs): + pass + + def __enter__(self): + return self + + def __exit__(self, *args): + return False + + def post(self, url, **kwargs): + if url.endswith("/deviceauth/usercode"): + return _Resp(200, { + "device_auth_id": "device-auth-id", + "interval": 3, + "user_code": "CODEX-1234", + }) + if url.endswith("/deviceauth/token"): + return _Resp(200, { + "authorization_code": "authorization-code", + "code_verifier": "code-verifier", + }) + return _Resp(200, { + "access_token": "codex-access", + "refresh_token": "codex-refresh", + }) + + saved_homes = [] + monkeypatch.setattr(httpx, "Client", _Client) + monkeypatch.setattr(ws.time, "sleep", lambda _: None) + monkeypatch.setattr( + auth_mod, + "_save_codex_tokens", + lambda tokens: saved_homes.append(get_hermes_home()), + ) + + sid, _ = ws._new_oauth_session( + "openai-codex", + "device_code", + profile="coder", + ) + try: + ws._codex_full_login_worker(sid) + + assert ws._oauth_sessions[sid]["status"] == "approved" + assert saved_homes == [profile_home] + finally: + ws._oauth_sessions.pop(sid, None) + + def test_nous_dashboard_poller_preserves_effective_scope_when_token_omits_scope(monkeypatch): from hermes_cli import auth as auth_mod from hermes_cli import web_server as ws diff --git a/tests/test_tui_gateway_server.py b/tests/test_tui_gateway_server.py index da85cc26ad6..2b37b5788bb 100644 --- a/tests/test_tui_gateway_server.py +++ b/tests/test_tui_gateway_server.py @@ -6928,6 +6928,8 @@ def test_notification_event_dedup_key_preserves_distinct_watch_matches(): def test_notification_poller_emits_distinct_watch_matches_once(monkeypatch): """Distinct watch matches from one process emit; exact replay is deduped.""" + import queue as _queue_mod + from tools.process_registry import process_registry turns = [] @@ -6943,8 +6945,8 @@ def test_notification_poller_emits_distinct_watch_matches_once(monkeypatch): monkeypatch.setattr(server, "_emit", lambda *a, **kw: emitted.append(a)) monkeypatch.setattr(server, "_run_prompt_submit", _fake_run_prompt_submit) - while not process_registry.completion_queue.empty(): - process_registry.completion_queue.get_nowait() + isolated_queue: _queue_mod.Queue = _queue_mod.Queue() + monkeypatch.setattr(process_registry, "completion_queue", isolated_queue) base = { "type": "watch_match", @@ -6954,9 +6956,9 @@ def test_notification_poller_emits_distinct_watch_matches_once(monkeypatch): "output": "READY on port 8000", "suppressed": 0, } - process_registry.completion_queue.put(base) - process_registry.completion_queue.put({**base, "output": "READY on port 9000"}) - process_registry.completion_queue.put(dict(base)) + isolated_queue.put(base) + isolated_queue.put({**base, "output": "READY on port 9000"}) + isolated_queue.put(dict(base)) stop = threading.Event() stop.set()