mirror of
https://github.com/NousResearch/hermes-agent.git
synced 2026-06-17 09:41:58 +00:00
fix(desktop): route global remote profile REST calls (#47011)
* fix(desktop): route global remote profile REST calls * fix(dashboard): scope oauth provider routes by profile * test(tui): isolate notification poller queue
This commit is contained in:
parent
7cd71de1f4
commit
0441b7f19f
8 changed files with 478 additions and 126 deletions
|
|
@ -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,
|
||||
|
|
|
|||
|
|
@ -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', () => {
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
||||
|
||||
|
|
|
|||
|
|
@ -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:
|
||||
|
|
|
|||
|
|
@ -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))
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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()
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue