mirror of
https://github.com/nesquena/hermes-webui.git
synced 2026-05-25 11:10:18 +00:00
8c803c0a07
1. test_issue1362_codex_oauth_onboarding.py::test_anthropic_onboarding_setup_allows_linked_oauth_without_api_key
Pre-existing env-collision bug, surfaced when HERMES_WEBUI_SKIP_ONBOARDING=1
is in the test runner env (set by hosting providers and by isolated test
harnesses). `apply_onboarding_setup()` short-circuits without writing the
config file when SKIP_ONBOARDING is set, but the test asserts the file was
written, so it fails with FileNotFoundError on read_text().
Fix: `monkeypatch.delenv("HERMES_WEBUI_SKIP_ONBOARDING", raising=False)` —
matches the convention already used in test_issue1499_keyless_onboarding.py
and test_issue1500_lmstudio_env_var_alignment.py.
2. test_issue1800_file_html_interactions.py::test_media_html_inline_keeps_csp_sandbox
Slicing-based source-string assertion (4000-char window after `def _handle_media`)
broke because PR #2044's MEDIA_ALLOWED_ROOTS parsing was inserted earlier in
the function and pushed the CSP block to offset 4211. Widened window to 5000.
Assertion content is structural (CSP sandbox string present), not positional.
598 lines
21 KiB
Python
598 lines
21 KiB
Python
"""Regression tests for issue #1362 — Codex OAuth from onboarding."""
|
|
|
|
from __future__ import annotations
|
|
|
|
import json
|
|
import os
|
|
import stat
|
|
import threading
|
|
import time
|
|
from pathlib import Path
|
|
|
|
import pytest
|
|
|
|
REPO = Path(__file__).resolve().parents[1]
|
|
|
|
|
|
def test_onboarding_codex_oauth_routes_use_post_start_cancel_and_get_poll():
|
|
routes = (REPO / "api" / "routes.py").read_text(encoding="utf-8")
|
|
get_idx = routes.find("def handle_get(")
|
|
post_idx = routes.find("def handle_post(")
|
|
assert get_idx != -1 and post_idx != -1
|
|
get_body = routes[get_idx:post_idx]
|
|
post_body = routes[post_idx:]
|
|
|
|
assert '"/api/onboarding/oauth/poll"' in get_body
|
|
assert '"/api/onboarding/oauth/start"' not in get_body
|
|
assert '"/api/oauth/codex/start"' not in routes
|
|
assert '"/api/oauth/codex/poll"' not in routes
|
|
assert '"/api/onboarding/oauth/start"' in post_body
|
|
assert '"/api/onboarding/oauth/cancel"' in post_body
|
|
|
|
|
|
def test_onboarding_oauth_rejects_unsupported_providers(monkeypatch):
|
|
import api.oauth as oauth
|
|
|
|
for provider in ("nous", "qwen-oauth", "copilot", "bogus"):
|
|
with pytest.raises(ValueError):
|
|
oauth.start_onboarding_oauth_flow({"provider": provider})
|
|
|
|
|
|
def test_start_payload_does_not_leak_provider_device_secrets(monkeypatch, tmp_path):
|
|
import api.oauth as oauth
|
|
|
|
oauth._OAUTH_FLOWS.clear()
|
|
monkeypatch.setattr(oauth, "_get_active_hermes_home", lambda: tmp_path)
|
|
monkeypatch.setattr(oauth, "_request_codex_user_code", lambda: {
|
|
"device_auth_id": "device-secret",
|
|
"user_code": "ABCD-EFGH",
|
|
"interval": 3,
|
|
})
|
|
monkeypatch.setattr(oauth, "_spawn_codex_oauth_worker", lambda flow_id: None)
|
|
|
|
payload = oauth.start_onboarding_oauth_flow({"provider": "openai-codex"})
|
|
|
|
assert payload["ok"] is True
|
|
assert payload["provider"] == "openai-codex"
|
|
assert payload["status"] == "pending"
|
|
assert payload["verification_uri"] == "https://auth.openai.com/codex/device"
|
|
assert payload["user_code"] == "ABCD-EFGH"
|
|
serialized = json.dumps(payload)
|
|
for forbidden in (
|
|
"device_auth_id",
|
|
"device-secret",
|
|
"authorization_code",
|
|
"code_verifier",
|
|
"access_token",
|
|
"refresh_token",
|
|
):
|
|
assert forbidden not in serialized
|
|
|
|
|
|
def test_poll_returns_high_level_status_only(monkeypatch, tmp_path):
|
|
import api.oauth as oauth
|
|
|
|
oauth._OAUTH_FLOWS.clear()
|
|
flow_id = "flow-test"
|
|
oauth._OAUTH_FLOWS[flow_id] = {
|
|
"provider": "openai-codex",
|
|
"status": "pending",
|
|
"device_auth_id": "device-secret",
|
|
"user_code": "ABCD-EFGH",
|
|
"code_verifier": "verifier-secret",
|
|
"authorization_code": "auth-secret",
|
|
"expires_at": time.time() + 60,
|
|
"poll_interval_seconds": 3,
|
|
"hermes_home": tmp_path,
|
|
}
|
|
|
|
payload = oauth.poll_onboarding_oauth_flow(flow_id)
|
|
|
|
assert payload == {"ok": True, "provider": "openai-codex", "flow_id": flow_id, "status": "pending"}
|
|
serialized = json.dumps(payload)
|
|
for forbidden in ("device_auth_id", "device-secret", "code_verifier", "authorization_code"):
|
|
assert forbidden not in serialized
|
|
|
|
|
|
def test_cancel_marks_flow_cancelled_and_poll_stops(tmp_path):
|
|
import api.oauth as oauth
|
|
|
|
oauth._OAUTH_FLOWS.clear()
|
|
flow_id = "flow-cancel"
|
|
oauth._OAUTH_FLOWS[flow_id] = {
|
|
"provider": "openai-codex",
|
|
"status": "pending",
|
|
"expires_at": time.time() + 60,
|
|
"hermes_home": tmp_path,
|
|
}
|
|
|
|
cancelled = oauth.cancel_onboarding_oauth_flow({"flow_id": flow_id})
|
|
polled = oauth.poll_onboarding_oauth_flow(flow_id)
|
|
|
|
assert cancelled["status"] == "cancelled"
|
|
assert polled["status"] == "cancelled"
|
|
|
|
|
|
def test_cancel_during_token_exchange_does_not_persist_credentials(monkeypatch, tmp_path):
|
|
"""Cancel arriving while the worker is mid-network-call must win.
|
|
|
|
Without the post-exchange status re-check, the worker would proceed to
|
|
persist credentials to auth.json AND override the cancelled status with
|
|
"success" — silently storing tokens the user explicitly aborted.
|
|
"""
|
|
import threading
|
|
import api.oauth as oauth
|
|
|
|
oauth._OAUTH_FLOWS.clear()
|
|
|
|
poll_started = threading.Event()
|
|
poll_continue = threading.Event()
|
|
|
|
def _slow_poll(device_auth_id, user_code):
|
|
poll_started.set()
|
|
assert poll_continue.wait(timeout=5)
|
|
return {"authorization_code": "auth-code", "code_verifier": "verifier"}
|
|
|
|
def _exchange(authorization_code, code_verifier):
|
|
return {"access_token": "ACCESS", "refresh_token": "REFRESH"}
|
|
|
|
monkeypatch.setattr(oauth, "_poll_codex_authorization", _slow_poll)
|
|
monkeypatch.setattr(oauth, "_exchange_codex_authorization", _exchange)
|
|
|
|
flow_id = "race-flow"
|
|
oauth._OAUTH_FLOWS[flow_id] = {
|
|
"provider": "openai-codex",
|
|
"status": "pending",
|
|
"device_auth_id": "device-secret",
|
|
"user_code": "ABCD-EFGH",
|
|
"expires_at": time.time() + 600,
|
|
"poll_interval_seconds": 1,
|
|
"hermes_home": str(tmp_path),
|
|
"created_at": time.time(),
|
|
"updated_at": time.time(),
|
|
}
|
|
|
|
worker = threading.Thread(target=oauth._run_codex_oauth_worker, args=(flow_id,), daemon=True)
|
|
worker.start()
|
|
assert poll_started.wait(timeout=5)
|
|
|
|
oauth.cancel_onboarding_oauth_flow({"flow_id": flow_id})
|
|
assert oauth._OAUTH_FLOWS[flow_id]["status"] == "cancelled"
|
|
|
|
poll_continue.set()
|
|
worker.join(timeout=5)
|
|
assert not worker.is_alive()
|
|
|
|
assert oauth._OAUTH_FLOWS[flow_id]["status"] == "cancelled"
|
|
assert not (tmp_path / "auth.json").exists()
|
|
|
|
|
|
def test_expired_flow_reports_expired_and_drops_sensitive_lifecycle(tmp_path):
|
|
import api.oauth as oauth
|
|
|
|
oauth._OAUTH_FLOWS.clear()
|
|
flow_id = "flow-expired"
|
|
oauth._OAUTH_FLOWS[flow_id] = {
|
|
"provider": "openai-codex",
|
|
"status": "pending",
|
|
"device_auth_id": "device-secret",
|
|
"expires_at": time.time() - 1,
|
|
"hermes_home": tmp_path,
|
|
}
|
|
|
|
payload = oauth.poll_onboarding_oauth_flow(flow_id)
|
|
|
|
assert payload["status"] == "expired"
|
|
assert oauth._OAUTH_FLOWS[flow_id]["status"] == "expired"
|
|
assert "device_auth_id" not in oauth._OAUTH_FLOWS[flow_id]
|
|
|
|
|
|
def test_codex_credentials_written_to_active_profile_auth_json(monkeypatch, tmp_path):
|
|
import api.oauth as oauth
|
|
from api.onboarding import _provider_oauth_authenticated
|
|
|
|
active_home = tmp_path / "active-profile"
|
|
realish_home = tmp_path / "process-home"
|
|
active_home.mkdir()
|
|
realish_home.mkdir()
|
|
monkeypatch.setattr(Path, "home", lambda: realish_home)
|
|
|
|
auth_path = oauth._persist_codex_credentials(
|
|
active_home,
|
|
{"access_token": "access-secret", "refresh_token": "refresh-secret"},
|
|
)
|
|
|
|
assert auth_path == active_home / "auth.json"
|
|
assert auth_path.exists()
|
|
assert not (realish_home / ".hermes" / "auth.json").exists()
|
|
mode = stat.S_IMODE(auth_path.stat().st_mode)
|
|
assert mode == 0o600
|
|
store = json.loads(auth_path.read_text(encoding="utf-8"))
|
|
entry = store["credential_pool"]["openai-codex"][0]
|
|
assert entry["auth_type"] == "oauth"
|
|
assert entry["source"] == "manual:device_code"
|
|
assert entry["base_url"] == "https://chatgpt.com/backend-api/codex"
|
|
assert _provider_oauth_authenticated("openai-codex", active_home) is True
|
|
|
|
|
|
def test_frontend_uses_onboarding_oauth_endpoints_and_no_secret_poll_url():
|
|
js = (REPO / "static" / "onboarding.js").read_text(encoding="utf-8")
|
|
assert "/api/onboarding/oauth/start" in js
|
|
assert "/api/onboarding/oauth/poll" in js
|
|
assert "/api/onboarding/oauth/cancel" in js
|
|
assert "window.open(verification_uri" not in js
|
|
assert "device_code=" not in js
|
|
assert "device_code" not in js
|
|
assert "flow_id" in js
|
|
assert "copyCodexOAuthCode" in js
|
|
assert "cancelCodexOAuth" in js
|
|
|
|
|
|
def test_unsupported_note_mentions_codex_and_claude_as_in_app():
|
|
src = (REPO / "api" / "onboarding.py").read_text(encoding="utf-8")
|
|
start = src.find("_UNSUPPORTED_PROVIDER_NOTE")
|
|
body = src[start:start + 500]
|
|
assert "OpenAI Codex, and GitHub" not in body
|
|
assert "OpenAI Codex" in body and "authenticated in this onboarding flow" in body
|
|
assert "Claude" in body or "Anthropic" in body
|
|
|
|
|
|
# ── Claude / Anthropic OAuth slice ─────────────────────────────────────────
|
|
|
|
|
|
def test_claude_provider_aliases_normalize_to_anthropic(monkeypatch, tmp_path):
|
|
import api.oauth as oauth
|
|
|
|
oauth._OAUTH_FLOWS.clear()
|
|
monkeypatch.setattr(oauth, "_get_active_hermes_home", lambda: tmp_path)
|
|
monkeypatch.setattr(oauth, "_read_claude_code_credentials", lambda: None)
|
|
monkeypatch.setattr(oauth, "_spawn_anthropic_credential_worker", lambda fid: None)
|
|
|
|
for alias in ("anthropic", "claude", "claude-code"):
|
|
payload = oauth.start_onboarding_oauth_flow({"provider": alias})
|
|
assert payload["ok"] is True
|
|
assert payload["provider"] == "anthropic"
|
|
assert payload["status"] == "pending"
|
|
|
|
|
|
def test_anthropic_immediate_success_when_credentials_exist(monkeypatch, tmp_path):
|
|
import api.oauth as oauth
|
|
|
|
oauth._OAUTH_FLOWS.clear()
|
|
monkeypatch.setattr(oauth, "_get_active_hermes_home", lambda: tmp_path)
|
|
monkeypatch.setattr(oauth, "_read_claude_code_credentials", lambda: {
|
|
"accessToken": "cc-access-secret",
|
|
"refreshToken": "cc-refresh-secret",
|
|
"expiresAt": 9999999999999,
|
|
})
|
|
linked = []
|
|
monkeypatch.setattr(oauth, "_link_anthropic_credentials", lambda hh: linked.append(str(hh)))
|
|
|
|
payload = oauth.start_onboarding_oauth_flow({"provider": "anthropic"})
|
|
|
|
assert payload["status"] == "success"
|
|
assert payload["provider"] == "anthropic"
|
|
assert linked == [str(tmp_path)]
|
|
serialized = json.dumps(payload)
|
|
for forbidden in ("cc-access-secret", "cc-refresh-secret", "accessToken", "refreshToken", "access_token", "refresh_token"):
|
|
assert forbidden not in serialized
|
|
|
|
|
|
def test_anthropic_pending_payload_is_action_only_and_secret_free(monkeypatch, tmp_path):
|
|
import api.oauth as oauth
|
|
|
|
oauth._OAUTH_FLOWS.clear()
|
|
monkeypatch.setattr(oauth, "_get_active_hermes_home", lambda: tmp_path)
|
|
monkeypatch.setattr(oauth, "_read_claude_code_credentials", lambda: None)
|
|
monkeypatch.setattr(oauth, "_spawn_anthropic_credential_worker", lambda fid: None)
|
|
|
|
payload = oauth.start_onboarding_oauth_flow({"provider": "anthropic"})
|
|
|
|
assert payload["status"] == "pending"
|
|
assert payload["provider"] == "anthropic"
|
|
assert payload["flow_id"]
|
|
assert "action_required" in payload
|
|
assert "claude" in payload["action_required"].lower()
|
|
serialized = json.dumps(payload)
|
|
for forbidden in (
|
|
"access_token", "refresh_token", "accessToken", "refreshToken",
|
|
".credentials.json", ".claude", "hermes_home", str(tmp_path),
|
|
"ANTHROPIC_API_KEY", "ANTHROPIC_TOKEN",
|
|
):
|
|
assert forbidden not in serialized
|
|
|
|
|
|
def test_anthropic_poll_and_cancel_return_high_level_status(tmp_path):
|
|
import api.oauth as oauth
|
|
|
|
oauth._OAUTH_FLOWS.clear()
|
|
flow_id = "claude-flow-test"
|
|
oauth._OAUTH_FLOWS[flow_id] = {
|
|
"provider": "anthropic",
|
|
"status": "pending",
|
|
"expires_at": time.time() + 60,
|
|
"poll_interval_seconds": 5,
|
|
"hermes_home": str(tmp_path),
|
|
}
|
|
|
|
assert oauth.poll_onboarding_oauth_flow(flow_id) == {
|
|
"ok": True,
|
|
"provider": "anthropic",
|
|
"flow_id": flow_id,
|
|
"status": "pending",
|
|
}
|
|
assert oauth.cancel_onboarding_oauth_flow({"flow_id": flow_id}) == {
|
|
"ok": True,
|
|
"provider": "anthropic",
|
|
"flow_id": flow_id,
|
|
"status": "cancelled",
|
|
}
|
|
|
|
|
|
def test_anthropic_worker_detects_credentials_and_cancel_wins(monkeypatch, tmp_path):
|
|
import threading
|
|
import api.oauth as oauth
|
|
|
|
oauth._OAUTH_FLOWS.clear()
|
|
started = threading.Event()
|
|
proceed = threading.Event()
|
|
linked = []
|
|
|
|
def _slow_read_creds():
|
|
started.set()
|
|
assert proceed.wait(timeout=5)
|
|
return {"accessToken": "cc-access-secret", "refreshToken": "cc-refresh-secret"}
|
|
|
|
monkeypatch.setattr(oauth, "_read_claude_code_credentials", _slow_read_creds)
|
|
monkeypatch.setattr(oauth, "_link_anthropic_credentials", lambda hh: linked.append(str(hh)))
|
|
|
|
flow_id = "claude-race-flow"
|
|
oauth._OAUTH_FLOWS[flow_id] = {
|
|
"provider": "anthropic",
|
|
"status": "pending",
|
|
"expires_at": time.time() + 600,
|
|
"poll_interval_seconds": 1,
|
|
"hermes_home": str(tmp_path),
|
|
"created_at": time.time(),
|
|
"updated_at": time.time(),
|
|
}
|
|
worker = threading.Thread(target=oauth._run_anthropic_credential_worker, args=(flow_id,), daemon=True)
|
|
worker.start()
|
|
assert started.wait(timeout=5)
|
|
oauth.cancel_onboarding_oauth_flow({"flow_id": flow_id})
|
|
proceed.set()
|
|
worker.join(timeout=5)
|
|
|
|
assert oauth._OAUTH_FLOWS[flow_id]["status"] == "cancelled"
|
|
assert not linked
|
|
|
|
|
|
def test_anthropic_cancel_during_link_keeps_flow_cancelled(monkeypatch, tmp_path):
|
|
import threading
|
|
import api.oauth as oauth
|
|
from api.onboarding import _provider_oauth_authenticated
|
|
|
|
oauth._OAUTH_FLOWS.clear()
|
|
link_started = threading.Event()
|
|
link_continue = threading.Event()
|
|
monkeypatch.setattr(oauth.time, "sleep", lambda _seconds: None)
|
|
monkeypatch.setattr(oauth, "_read_claude_code_credentials", lambda: {"accessToken": "cc-access-secret", "refreshToken": "cc-refresh-secret"})
|
|
|
|
def _slow_clear(_home):
|
|
link_started.set()
|
|
assert link_continue.wait(timeout=5)
|
|
|
|
monkeypatch.setattr(oauth, "_clear_anthropic_env_values", _slow_clear)
|
|
flow_id = "claude-link-cancel-race"
|
|
oauth._OAUTH_FLOWS[flow_id] = {
|
|
"provider": "anthropic",
|
|
"status": "pending",
|
|
"expires_at": time.time() + 60,
|
|
"poll_interval_seconds": 1,
|
|
"hermes_home": str(tmp_path),
|
|
"created_at": time.time(),
|
|
"updated_at": time.time(),
|
|
}
|
|
|
|
worker = threading.Thread(target=oauth._run_anthropic_credential_worker, args=(flow_id,), daemon=True)
|
|
worker.start()
|
|
assert link_started.wait(timeout=5)
|
|
assert oauth.cancel_onboarding_oauth_flow({"flow_id": flow_id})["status"] == "cancelled"
|
|
link_continue.set()
|
|
worker.join(timeout=5)
|
|
|
|
assert not worker.is_alive()
|
|
assert oauth._OAUTH_FLOWS[flow_id]["status"] == "cancelled"
|
|
assert _provider_oauth_authenticated("anthropic", tmp_path) is False
|
|
|
|
|
|
def test_anthropic_cancel_missing_flow_keeps_requested_provider():
|
|
import api.oauth as oauth
|
|
|
|
oauth._OAUTH_FLOWS.clear()
|
|
|
|
assert oauth.cancel_onboarding_oauth_flow({"flow_id": "missing", "provider": "claude-code"}) == {
|
|
"ok": True,
|
|
"provider": "anthropic",
|
|
"flow_id": "missing",
|
|
"status": "cancelled",
|
|
}
|
|
|
|
|
|
def test_anthropic_worker_expires_flow(tmp_path):
|
|
import api.oauth as oauth
|
|
|
|
oauth._OAUTH_FLOWS.clear()
|
|
flow_id = "claude-expired-worker-flow"
|
|
oauth._OAUTH_FLOWS[flow_id] = {
|
|
"provider": "anthropic",
|
|
"status": "pending",
|
|
"expires_at": time.time() - 1,
|
|
"poll_interval_seconds": 1,
|
|
"hermes_home": str(tmp_path),
|
|
"created_at": time.time(),
|
|
"updated_at": time.time(),
|
|
}
|
|
|
|
oauth._run_anthropic_credential_worker(flow_id)
|
|
|
|
assert oauth._OAUTH_FLOWS[flow_id]["status"] == "expired"
|
|
|
|
|
|
def test_anthropic_worker_reports_link_errors(monkeypatch, tmp_path):
|
|
import api.oauth as oauth
|
|
|
|
oauth._OAUTH_FLOWS.clear()
|
|
monkeypatch.setattr(oauth.time, "sleep", lambda _seconds: None)
|
|
monkeypatch.setattr(oauth, "_read_claude_code_credentials", lambda: {"accessToken": "cc-access-secret", "refreshToken": "cc-refresh-secret"})
|
|
|
|
def _raise_link_error(_home):
|
|
raise RuntimeError("link failed without secrets")
|
|
|
|
monkeypatch.setattr(oauth, "_link_anthropic_credentials", _raise_link_error)
|
|
flow_id = "claude-link-error-flow"
|
|
oauth._OAUTH_FLOWS[flow_id] = {
|
|
"provider": "anthropic",
|
|
"status": "pending",
|
|
"expires_at": time.time() + 60,
|
|
"poll_interval_seconds": 1,
|
|
"hermes_home": str(tmp_path),
|
|
"created_at": time.time(),
|
|
"updated_at": time.time(),
|
|
}
|
|
|
|
oauth._run_anthropic_credential_worker(flow_id)
|
|
|
|
assert oauth._OAUTH_FLOWS[flow_id]["status"] == "error"
|
|
assert "link failed" in oauth._OAUTH_FLOWS[flow_id]["error"]
|
|
payload = oauth.poll_onboarding_oauth_flow(flow_id)
|
|
assert payload == {
|
|
"ok": True,
|
|
"provider": "anthropic",
|
|
"flow_id": flow_id,
|
|
"status": "error",
|
|
"error": "Claude Code credential linking failed. Check server logs.",
|
|
}
|
|
|
|
|
|
def test_anthropic_link_clears_env_and_writes_secret_free_marker(monkeypatch, tmp_path):
|
|
import api.oauth as oauth
|
|
from api.onboarding import _provider_oauth_authenticated
|
|
|
|
env_path = tmp_path / ".env"
|
|
env_path.write_text("ANTHROPIC_TOKEN=old-token\nANTHROPIC_API_KEY=old-key\nOTHER=value\n", encoding="utf-8")
|
|
monkeypatch.setenv("ANTHROPIC_TOKEN", "old-token")
|
|
monkeypatch.setenv("ANTHROPIC_API_KEY", "old-key")
|
|
|
|
oauth._link_anthropic_credentials(tmp_path)
|
|
|
|
env_text = env_path.read_text(encoding="utf-8")
|
|
assert "ANTHROPIC_TOKEN" not in env_text
|
|
assert "ANTHROPIC_API_KEY" not in env_text
|
|
assert "OTHER=value" in env_text
|
|
assert "ANTHROPIC_TOKEN" not in os.environ
|
|
assert "ANTHROPIC_API_KEY" not in os.environ
|
|
auth = json.loads((tmp_path / "auth.json").read_text(encoding="utf-8"))
|
|
marker = auth["credential_pool"]["anthropic"][0]
|
|
assert marker["auth_type"] == "oauth"
|
|
assert marker["source"] == "claude_code_linked"
|
|
assert "access_token" not in marker
|
|
assert "refresh_token" not in marker
|
|
assert _provider_oauth_authenticated("anthropic", tmp_path) is True
|
|
assert _provider_oauth_authenticated("claude-code", tmp_path) is True
|
|
|
|
|
|
def test_anthropic_env_clear_waits_for_chat_env_read_lock(monkeypatch, tmp_path):
|
|
import api.oauth as oauth
|
|
import api.providers as providers
|
|
from api.streaming import _ENV_LOCK
|
|
|
|
monkeypatch.setenv("ANTHROPIC_TOKEN", "old-token")
|
|
monkeypatch.setenv("ANTHROPIC_API_KEY", "old-key")
|
|
|
|
def _fail_before_env_lock(_env_path, _updates):
|
|
raise RuntimeError("env write failed before process-env clear")
|
|
|
|
monkeypatch.setattr(providers, "_write_env_file", _fail_before_env_lock)
|
|
|
|
started = threading.Event()
|
|
done = threading.Event()
|
|
errors = []
|
|
|
|
def _onboarding_clear():
|
|
started.set()
|
|
try:
|
|
oauth._clear_anthropic_env_values(tmp_path)
|
|
except Exception as exc: # pragma: no cover - assertion below reports it
|
|
errors.append(exc)
|
|
finally:
|
|
done.set()
|
|
|
|
with _ENV_LOCK:
|
|
worker = threading.Thread(target=_onboarding_clear)
|
|
worker.start()
|
|
assert started.wait(timeout=1)
|
|
assert not done.wait(timeout=0.1)
|
|
assert os.environ["ANTHROPIC_TOKEN"] == "old-token"
|
|
assert os.environ["ANTHROPIC_API_KEY"] == "old-key"
|
|
|
|
worker.join(timeout=1)
|
|
assert done.is_set()
|
|
assert errors == []
|
|
assert "ANTHROPIC_TOKEN" not in os.environ
|
|
assert "ANTHROPIC_API_KEY" not in os.environ
|
|
|
|
|
|
def test_runtime_provider_reads_use_anthropic_env_lock():
|
|
streaming_src = (REPO / "api" / "streaming.py").read_text(encoding="utf-8")
|
|
routes_src = (REPO / "api" / "routes.py").read_text(encoding="utf-8")
|
|
|
|
assert "resolve_runtime_provider_with_anthropic_env_lock" in streaming_src
|
|
assert "resolve_runtime_provider_with_anthropic_env_lock" in routes_src
|
|
|
|
|
|
def test_anthropic_onboarding_setup_allows_linked_oauth_without_api_key(monkeypatch, tmp_path):
|
|
import api.onboarding as onboarding
|
|
|
|
# apply_onboarding_setup() short-circuits when HERMES_WEBUI_SKIP_ONBOARDING
|
|
# is set in the environment (hosting providers like Agent37 use it to ship
|
|
# a pre-configured WebUI). Local test runs may also set it for the same
|
|
# reason. The test exercises the file-writing branch, so delete the var
|
|
# for the test's scope. monkeypatch.delenv is a no-op if the var is unset.
|
|
monkeypatch.delenv("HERMES_WEBUI_SKIP_ONBOARDING", raising=False)
|
|
|
|
cfg_path = tmp_path / "config.yaml"
|
|
home = tmp_path / "home"
|
|
home.mkdir()
|
|
(home / "auth.json").write_text(json.dumps({
|
|
"credential_pool": {"anthropic": [{"auth_type": "oauth", "source": "claude_code_linked"}]}
|
|
}), encoding="utf-8")
|
|
monkeypatch.setattr(onboarding, "_get_config_path", lambda: cfg_path)
|
|
monkeypatch.setattr(onboarding, "_get_active_hermes_home", lambda: home)
|
|
monkeypatch.setattr(onboarding, "get_onboarding_status", lambda: {"ok": True})
|
|
monkeypatch.setattr(onboarding, "reload_config", lambda: None)
|
|
|
|
result = onboarding.apply_onboarding_setup({"provider": "anthropic", "model": "claude-sonnet-4.6"})
|
|
|
|
assert result == {"ok": True}
|
|
saved = cfg_path.read_text(encoding="utf-8")
|
|
assert "provider: anthropic" in saved
|
|
assert "default: claude-sonnet-4.6" in saved
|
|
|
|
|
|
def test_frontend_has_anthropic_oauth_support():
|
|
js = (REPO / "static" / "onboarding.js").read_text(encoding="utf-8")
|
|
assert "startAnthropicOAuth" in js
|
|
assert "cancelAnthropicOAuth" in js
|
|
assert "anthropicOAuthBtn" in js
|
|
assert "Login with Claude Code" in js
|
|
assert "Anthropic API key" in js
|
|
assert "Claude Code subscription" in js
|
|
assert "not the same as an Anthropic API key" in js
|
|
assert "/api/onboarding/oauth/start" in js
|
|
assert "/api/onboarding/oauth/poll" in js
|
|
assert "/api/onboarding/oauth/cancel" in js
|
|
assert "window.open(" not in js[js.find("startAnthropicOAuth"):]
|
|
assert "accessToken" not in js[js.find("startAnthropicOAuth"):]
|
|
assert "refreshToken" not in js[js.find("startAnthropicOAuth"):]
|