feat: link Claude Code OAuth in onboarding
This commit is contained in:
315
api/oauth.py
315
api/oauth.py
@@ -37,11 +37,9 @@ CODEX_REDIRECT_URI = f"{CODEX_ISSUER}/deviceauth/callback"
|
||||
CODEX_BASE_URL = "https://chatgpt.com/backend-api/codex"
|
||||
CODEX_FLOW_MAX_WAIT_SECONDS = 15 * 60
|
||||
|
||||
_ALLOWED_ONBOARDING_OAUTH_PROVIDERS = {"openai-codex"}
|
||||
_ALLOWED_ONBOARDING_OAUTH_PROVIDERS = {"openai-codex", "anthropic", "claude", "claude-code"}
|
||||
_ANTHROPIC_PROVIDER_ALIASES = {"anthropic", "claude", "claude-code"}
|
||||
_REJECTED_ONBOARDING_OAUTH_PROVIDERS = {
|
||||
"anthropic",
|
||||
"claude",
|
||||
"claude-code",
|
||||
"nous",
|
||||
"qwen-oauth",
|
||||
"gemini-cli",
|
||||
@@ -52,10 +50,21 @@ _REJECTED_ONBOARDING_OAUTH_PROVIDERS = {
|
||||
"copilot-acp",
|
||||
}
|
||||
|
||||
ANTHROPIC_CREDENTIAL_POLL_SECONDS = 5
|
||||
ANTHROPIC_FLOW_MAX_WAIT_SECONDS = 15 * 60
|
||||
ANTHROPIC_PUBLIC_LINK_ERROR = "Claude Code credential linking failed. Check server logs."
|
||||
|
||||
_OAUTH_FLOWS: dict[str, dict[str, Any]] = {}
|
||||
_OAUTH_FLOWS_LOCK = threading.Lock()
|
||||
|
||||
|
||||
def _normalize_onboarding_oauth_provider(provider: str) -> str:
|
||||
provider = str(provider or "").strip().lower()
|
||||
if provider in _ANTHROPIC_PROVIDER_ALIASES:
|
||||
return "anthropic"
|
||||
return provider or "openai-codex"
|
||||
|
||||
|
||||
def _get_active_hermes_home() -> Path:
|
||||
try:
|
||||
from api.profiles import get_active_hermes_home
|
||||
@@ -200,6 +209,228 @@ def _save_codex_credentials(token_data):
|
||||
return _persist_codex_credentials(_get_active_hermes_home(), token_data)
|
||||
|
||||
|
||||
# ── Anthropic / Claude Code credential linking ─────────────────────────────
|
||||
|
||||
def _read_claude_code_credentials() -> dict[str, Any] | None:
|
||||
"""Read Claude Code OAuth credentials from the host without exposing them.
|
||||
|
||||
Delegates to the agent adapter which knows about ~/.claude/.credentials.json
|
||||
and macOS Keychain. Returns the credential dict or None.
|
||||
"""
|
||||
try:
|
||||
from agent.anthropic_adapter import (
|
||||
is_claude_code_token_valid,
|
||||
read_claude_code_credentials,
|
||||
)
|
||||
|
||||
creds = read_claude_code_credentials()
|
||||
if creds and (
|
||||
is_claude_code_token_valid(creds) or bool(creds.get("refreshToken"))
|
||||
):
|
||||
return creds
|
||||
except Exception as exc:
|
||||
logger.debug("Could not read Claude Code credentials: %s", exc)
|
||||
return None
|
||||
|
||||
|
||||
def _clear_anthropic_env_values(hermes_home: Path) -> None:
|
||||
"""Clear Anthropic API/setup-token env values in the active profile only."""
|
||||
try:
|
||||
from api.providers import _write_env_file
|
||||
|
||||
_write_env_file(
|
||||
Path(hermes_home) / ".env",
|
||||
{"ANTHROPIC_TOKEN": None, "ANTHROPIC_API_KEY": None},
|
||||
)
|
||||
except Exception as exc:
|
||||
logger.warning("Failed to clear Anthropic env values: %s", exc)
|
||||
os.environ.pop("ANTHROPIC_TOKEN", None)
|
||||
os.environ.pop("ANTHROPIC_API_KEY", None)
|
||||
|
||||
|
||||
def _link_anthropic_credentials(hermes_home: Path) -> None:
|
||||
"""Link Hermes to use Claude Code's credential store.
|
||||
|
||||
Clears ANTHROPIC_TOKEN and ANTHROPIC_API_KEY from the Hermes .env so
|
||||
that resolve_anthropic_token() falls through to reading Claude Code's
|
||||
~/.claude/.credentials.json directly — the same thing the CLI's
|
||||
``use_anthropic_claude_code_credentials()`` does.
|
||||
|
||||
Also writes a marker entry in auth.json credential_pool so that
|
||||
``_provider_oauth_authenticated("anthropic", ...)`` can detect the
|
||||
linked state without touching the actual credential files.
|
||||
"""
|
||||
_clear_anthropic_env_values(hermes_home)
|
||||
|
||||
# Write a pool marker (no secrets) so onboarding status can detect linkage.
|
||||
auth_path = Path(hermes_home) / "auth.json"
|
||||
auth = _read_auth_json(auth_path)
|
||||
auth.setdefault("version", 1)
|
||||
pool = auth.setdefault("credential_pool", {})
|
||||
if not isinstance(pool, dict):
|
||||
pool = {}
|
||||
auth["credential_pool"] = pool
|
||||
entries = pool.setdefault("anthropic", [])
|
||||
if not isinstance(entries, list):
|
||||
entries = []
|
||||
pool["anthropic"] = entries
|
||||
|
||||
now = _now_iso()
|
||||
entry = None
|
||||
for candidate in entries:
|
||||
if isinstance(candidate, dict) and candidate.get("source") == "claude_code_linked":
|
||||
entry = candidate
|
||||
break
|
||||
if entry is None:
|
||||
entry = {
|
||||
"id": "anthropic-claude-code-" + uuid.uuid4().hex[:12],
|
||||
"label": "Claude Code (linked)",
|
||||
"auth_type": "oauth",
|
||||
"priority": 0,
|
||||
"source": "claude_code_linked",
|
||||
"created_at": now,
|
||||
}
|
||||
entries.insert(0, entry)
|
||||
|
||||
entry.update({
|
||||
"label": "Claude Code (linked)",
|
||||
"auth_type": "oauth",
|
||||
"priority": 0,
|
||||
"source": "claude_code_linked",
|
||||
"updated_at": now,
|
||||
})
|
||||
auth["updated_at"] = now
|
||||
_write_auth_json(auth, auth_path)
|
||||
|
||||
try:
|
||||
from api.config import invalidate_credential_pool_cache
|
||||
invalidate_credential_pool_cache("anthropic")
|
||||
except Exception:
|
||||
logger.debug("Failed to invalidate anthropic credential cache", exc_info=True)
|
||||
|
||||
|
||||
def _anthropic_public_start_payload(flow_id: str, flow: dict[str, Any]) -> dict[str, Any]:
|
||||
payload: dict[str, Any] = {
|
||||
"ok": True,
|
||||
"provider": "anthropic",
|
||||
"flow_id": flow_id,
|
||||
"status": flow.get("status", "pending"),
|
||||
"poll_interval_seconds": flow.get("poll_interval_seconds", ANTHROPIC_CREDENTIAL_POLL_SECONDS),
|
||||
}
|
||||
if flow.get("status") == "pending":
|
||||
payload["action_required"] = (
|
||||
"Claude Code credentials were not found on this server. "
|
||||
"Please run 'claude login' or 'claude setup-token' in a terminal "
|
||||
"on the host, then return here — this page will detect the credentials automatically."
|
||||
)
|
||||
if flow.get("expires_at"):
|
||||
payload["expires_at"] = flow["expires_at"]
|
||||
return payload
|
||||
|
||||
|
||||
def _anthropic_public_status_payload(flow_id: str, flow: dict[str, Any]) -> dict[str, Any]:
|
||||
payload: dict[str, Any] = {
|
||||
"ok": True,
|
||||
"provider": "anthropic",
|
||||
"flow_id": flow_id,
|
||||
"status": flow.get("status", "error"),
|
||||
}
|
||||
if flow.get("status") == "error" and flow.get("error"):
|
||||
payload["error"] = ANTHROPIC_PUBLIC_LINK_ERROR
|
||||
return payload
|
||||
|
||||
|
||||
def _spawn_anthropic_credential_worker(flow_id: str) -> None:
|
||||
worker = threading.Thread(
|
||||
target=_run_anthropic_credential_worker, args=(flow_id,), daemon=True,
|
||||
)
|
||||
worker.start()
|
||||
|
||||
|
||||
def _run_anthropic_credential_worker(flow_id: str) -> None:
|
||||
"""Poll for Claude Code credential appearance until found, cancelled, or expired."""
|
||||
while True:
|
||||
with _OAUTH_FLOWS_LOCK:
|
||||
flow = dict(_OAUTH_FLOWS.get(flow_id) or {})
|
||||
if not flow:
|
||||
return
|
||||
if flow.get("status") != "pending":
|
||||
return
|
||||
if float(flow.get("expires_at") or 0) <= time.time():
|
||||
_set_flow_status(flow_id, "expired")
|
||||
return
|
||||
|
||||
time.sleep(max(1, int(flow.get("poll_interval_seconds") or ANTHROPIC_CREDENTIAL_POLL_SECONDS)))
|
||||
|
||||
# Re-check status under lock (cancel may have arrived during sleep)
|
||||
with _OAUTH_FLOWS_LOCK:
|
||||
live = _OAUTH_FLOWS.get(flow_id)
|
||||
if not live or live.get("status") != "pending":
|
||||
return
|
||||
|
||||
try:
|
||||
creds = _read_claude_code_credentials()
|
||||
if creds is None:
|
||||
continue
|
||||
|
||||
# Re-check status under lock before linking — cancel must win
|
||||
with _OAUTH_FLOWS_LOCK:
|
||||
current = _OAUTH_FLOWS.get(flow_id)
|
||||
if not current or current.get("status") != "pending":
|
||||
return
|
||||
|
||||
hermes_home = Path(flow["hermes_home"])
|
||||
_link_anthropic_credentials(hermes_home)
|
||||
with _OAUTH_FLOWS_LOCK:
|
||||
current = _OAUTH_FLOWS.get(flow_id)
|
||||
if not current or current.get("status") != "pending":
|
||||
cancelled = bool(current and current.get("status") == "cancelled")
|
||||
else:
|
||||
current["status"] = "success"
|
||||
current["updated_at"] = time.time()
|
||||
_drop_sensitive_flow_fields(current)
|
||||
cancelled = False
|
||||
if cancelled:
|
||||
_remove_anthropic_link_marker(hermes_home)
|
||||
return
|
||||
except Exception as exc:
|
||||
logger.warning("Anthropic credential polling failed: %s", exc)
|
||||
with _OAUTH_FLOWS_LOCK:
|
||||
current = _OAUTH_FLOWS.get(flow_id)
|
||||
if current and current.get("status") == "pending":
|
||||
current["status"] = "error"
|
||||
current["updated_at"] = time.time()
|
||||
current["error"] = str(exc)
|
||||
_drop_sensitive_flow_fields(current)
|
||||
return
|
||||
|
||||
|
||||
def _remove_anthropic_link_marker(hermes_home: Path) -> None:
|
||||
"""Remove the secret-free Claude Code linked marker after a cancelled race."""
|
||||
auth_path = Path(hermes_home) / "auth.json"
|
||||
auth = _read_auth_json(auth_path)
|
||||
pool = auth.get("credential_pool")
|
||||
if not isinstance(pool, dict):
|
||||
return
|
||||
entries = pool.get("anthropic")
|
||||
if not isinstance(entries, list):
|
||||
return
|
||||
kept = [entry for entry in entries if not (isinstance(entry, dict) and entry.get("source") == "claude_code_linked")]
|
||||
if len(kept) == len(entries):
|
||||
return
|
||||
if kept:
|
||||
pool["anthropic"] = kept
|
||||
else:
|
||||
pool.pop("anthropic", None)
|
||||
auth["updated_at"] = _now_iso()
|
||||
_write_auth_json(auth, auth_path)
|
||||
try:
|
||||
from api.config import invalidate_credential_pool_cache
|
||||
invalidate_credential_pool_cache("anthropic")
|
||||
except Exception:
|
||||
logger.debug("Failed to invalidate anthropic credential cache", exc_info=True)
|
||||
|
||||
|
||||
# ── Codex protocol ──────────────────────────────────────────────────────────
|
||||
|
||||
def _json_request(url: str, payload: dict[str, Any], *, form: bool = False) -> dict[str, Any]:
|
||||
@@ -249,7 +480,7 @@ def _exchange_codex_authorization(authorization_code: str, code_verifier: str) -
|
||||
)
|
||||
|
||||
|
||||
def _public_start_payload(flow_id: str, flow: dict[str, Any]) -> dict[str, Any]:
|
||||
def _codex_public_start_payload(flow_id: str, flow: dict[str, Any]) -> dict[str, Any]:
|
||||
return {
|
||||
"ok": True,
|
||||
"provider": "openai-codex",
|
||||
@@ -262,7 +493,7 @@ def _public_start_payload(flow_id: str, flow: dict[str, Any]) -> dict[str, Any]:
|
||||
}
|
||||
|
||||
|
||||
def _public_status_payload(flow_id: str, flow: dict[str, Any]) -> dict[str, Any]:
|
||||
def _codex_public_status_payload(flow_id: str, flow: dict[str, Any]) -> dict[str, Any]:
|
||||
payload = {
|
||||
"ok": True,
|
||||
"provider": "openai-codex",
|
||||
@@ -274,6 +505,20 @@ def _public_status_payload(flow_id: str, flow: dict[str, Any]) -> dict[str, Any]
|
||||
return payload
|
||||
|
||||
|
||||
def _public_start_payload(flow_id: str, flow: dict[str, Any]) -> dict[str, Any]:
|
||||
provider = flow.get("provider", "openai-codex")
|
||||
if provider == "anthropic":
|
||||
return _anthropic_public_start_payload(flow_id, flow)
|
||||
return _codex_public_start_payload(flow_id, flow)
|
||||
|
||||
|
||||
def _public_status_payload(flow_id: str, flow: dict[str, Any]) -> dict[str, Any]:
|
||||
provider = flow.get("provider", "openai-codex")
|
||||
if provider == "anthropic":
|
||||
return _anthropic_public_status_payload(flow_id, flow)
|
||||
return _codex_public_status_payload(flow_id, flow)
|
||||
|
||||
|
||||
def _drop_sensitive_flow_fields(flow: dict[str, Any]) -> None:
|
||||
for key in (
|
||||
"device_auth_id",
|
||||
@@ -363,19 +608,63 @@ def _run_codex_oauth_worker(flow_id: str) -> None:
|
||||
return
|
||||
|
||||
|
||||
def _start_anthropic_flow(hermes_home: Path) -> dict[str, Any]:
|
||||
"""Start or immediately complete the Anthropic credential-linking flow."""
|
||||
creds = _read_claude_code_credentials()
|
||||
flow_id = uuid.uuid4().hex
|
||||
|
||||
if creds:
|
||||
# Credentials already exist — link and return success immediately.
|
||||
_link_anthropic_credentials(hermes_home)
|
||||
flow = {
|
||||
"provider": "anthropic",
|
||||
"status": "success",
|
||||
"hermes_home": str(hermes_home),
|
||||
"created_at": time.time(),
|
||||
"updated_at": time.time(),
|
||||
}
|
||||
with _OAUTH_FLOWS_LOCK:
|
||||
_OAUTH_FLOWS[flow_id] = flow
|
||||
return _public_start_payload(flow_id, flow)
|
||||
|
||||
# No credentials found — create a pending flow that polls for them.
|
||||
expires_at = time.time() + ANTHROPIC_FLOW_MAX_WAIT_SECONDS
|
||||
flow = {
|
||||
"provider": "anthropic",
|
||||
"status": "pending",
|
||||
"expires_at": expires_at,
|
||||
"poll_interval_seconds": ANTHROPIC_CREDENTIAL_POLL_SECONDS,
|
||||
"hermes_home": str(hermes_home),
|
||||
"created_at": time.time(),
|
||||
"updated_at": time.time(),
|
||||
}
|
||||
with _OAUTH_FLOWS_LOCK:
|
||||
_OAUTH_FLOWS[flow_id] = flow
|
||||
_spawn_anthropic_credential_worker(flow_id)
|
||||
return _public_start_payload(flow_id, flow)
|
||||
|
||||
|
||||
def start_onboarding_oauth_flow(body: dict[str, Any] | None) -> dict[str, Any]:
|
||||
"""Start the supported onboarding OAuth flow.
|
||||
|
||||
Currently v1 intentionally supports only OpenAI Codex. Other providers are
|
||||
rejected instead of silently falling back to terminal-first setup.
|
||||
Supports OpenAI Codex (device-code flow) and Anthropic/Claude Code
|
||||
(credential-linking flow). Other providers are rejected.
|
||||
"""
|
||||
_cleanup_oauth_flows()
|
||||
provider = str((body or {}).get("provider") or "").strip().lower()
|
||||
if provider not in _ALLOWED_ONBOARDING_OAUTH_PROVIDERS:
|
||||
if provider in _REJECTED_ONBOARDING_OAUTH_PROVIDERS or provider:
|
||||
raise ValueError("Only OpenAI Codex OAuth is supported in WebUI onboarding right now")
|
||||
raise ValueError(
|
||||
"Only OpenAI Codex and Anthropic/Claude OAuth are supported "
|
||||
"in WebUI onboarding right now"
|
||||
)
|
||||
raise ValueError("provider is required")
|
||||
|
||||
# Normalize Claude aliases to canonical "anthropic"
|
||||
if provider in _ANTHROPIC_PROVIDER_ALIASES:
|
||||
return _start_anthropic_flow(_get_active_hermes_home())
|
||||
|
||||
# Codex flow
|
||||
hermes_home = _get_active_hermes_home()
|
||||
try:
|
||||
device = _request_codex_user_code()
|
||||
@@ -428,15 +717,19 @@ def cancel_onboarding_oauth_flow(body: dict[str, Any] | None) -> dict[str, Any]:
|
||||
fid = str((body or {}).get("flow_id") or "").strip()
|
||||
if not fid:
|
||||
raise ValueError("flow_id is required")
|
||||
requested_provider = _normalize_onboarding_oauth_provider(str((body or {}).get("provider") or ""))
|
||||
if requested_provider not in {"openai-codex", "anthropic"}:
|
||||
requested_provider = "openai-codex"
|
||||
with _OAUTH_FLOWS_LOCK:
|
||||
flow = _OAUTH_FLOWS.get(fid)
|
||||
if not flow:
|
||||
return {"ok": True, "provider": "openai-codex", "flow_id": fid, "status": "cancelled"}
|
||||
return {"ok": True, "provider": requested_provider, "flow_id": fid, "status": "cancelled"}
|
||||
if flow.get("status") == "pending":
|
||||
flow["status"] = "cancelled"
|
||||
flow["updated_at"] = time.time()
|
||||
_drop_sensitive_flow_fields(flow)
|
||||
return _public_status_payload(fid, dict(flow))
|
||||
result = _public_status_payload(fid, dict(flow))
|
||||
return result
|
||||
|
||||
|
||||
# Backward-compatible names from the abandoned spike. They intentionally do not
|
||||
|
||||
@@ -53,6 +53,8 @@ _SUPPORTED_PROVIDER_SETUPS = {
|
||||
"requires_base_url": False,
|
||||
"models": list(_PROVIDER_MODELS.get("anthropic", [])),
|
||||
"category": "easy_start",
|
||||
"oauth_provider": "anthropic",
|
||||
"oauth_label": "Claude Code OAuth",
|
||||
},
|
||||
"openai": {
|
||||
"label": "OpenAI",
|
||||
@@ -186,8 +188,8 @@ _PROVIDER_CATEGORIES = [
|
||||
|
||||
_UNSUPPORTED_PROVIDER_NOTE = (
|
||||
"Advanced provider flows such as Nous Portal and GitHub Copilot are still "
|
||||
"terminal-first. OpenAI Codex can be authenticated in this onboarding flow "
|
||||
"when your Hermes config selects the openai-codex provider."
|
||||
"terminal-first. OpenAI Codex and Anthropic Claude Code can be authenticated in this onboarding flow "
|
||||
"when your Hermes config selects the corresponding provider."
|
||||
)
|
||||
|
||||
|
||||
@@ -538,7 +540,7 @@ def _provider_api_key_present(
|
||||
# var names and can check os.environ for a valid key.
|
||||
# Exclude known OAuth/token-flow providers — those are handled separately by
|
||||
# _provider_oauth_authenticated() and should not be short-circuited here.
|
||||
_known_oauth = {"openai-codex", "copilot", "copilot-acp", "qwen-oauth", "nous"}
|
||||
_known_oauth = {"openai-codex", "copilot", "copilot-acp", "qwen-oauth", "nous", "anthropic"}
|
||||
if provider not in _SUPPORTED_PROVIDER_SETUPS and provider not in _known_oauth:
|
||||
try:
|
||||
from hermes_cli.auth import get_auth_status as _gas
|
||||
@@ -582,10 +584,11 @@ def _provider_oauth_authenticated(provider: str, hermes_home: "Path") -> bool:
|
||||
used by current Hermes runtime auth resolution.
|
||||
"""
|
||||
provider = (provider or "").strip().lower()
|
||||
provider = {"claude": "anthropic", "claude-code": "anthropic"}.get(provider, provider)
|
||||
if not provider:
|
||||
return False
|
||||
|
||||
_known_oauth_providers = {"openai-codex", "copilot", "copilot-acp", "qwen-oauth", "nous"}
|
||||
_known_oauth_providers = {"openai-codex", "copilot", "copilot-acp", "qwen-oauth", "nous", "anthropic"}
|
||||
if provider not in _known_oauth_providers:
|
||||
return False
|
||||
|
||||
@@ -607,7 +610,16 @@ def _provider_oauth_authenticated(provider: str, hermes_home: "Path") -> bool:
|
||||
if isinstance(pool_store, dict):
|
||||
entries = pool_store.get(provider)
|
||||
if isinstance(entries, list):
|
||||
return any(_oauth_payload_has_token(entry) for entry in entries)
|
||||
for entry in entries:
|
||||
if _oauth_payload_has_token(entry):
|
||||
return True
|
||||
if (
|
||||
provider == "anthropic"
|
||||
and isinstance(entry, dict)
|
||||
and entry.get("auth_type") == "oauth"
|
||||
and entry.get("source") == "claude_code_linked"
|
||||
):
|
||||
return True
|
||||
|
||||
return False
|
||||
except Exception:
|
||||
@@ -648,6 +660,10 @@ def _status_from_runtime(cfg: dict, imports_ok: bool) -> dict:
|
||||
)
|
||||
else:
|
||||
provider_ready = _provider_api_key_present(provider, cfg, env_values)
|
||||
if not provider_ready and meta.get("oauth_provider"):
|
||||
provider_ready = _provider_oauth_authenticated(
|
||||
str(meta.get("oauth_provider")), _get_active_hermes_home()
|
||||
)
|
||||
else:
|
||||
# Unknown provider — may be an OAuth flow (openai-codex, copilot, etc.)
|
||||
# OR an API-key provider not in the quick-setup list (minimax-cn, deepseek,
|
||||
@@ -730,6 +746,8 @@ def _build_setup_catalog(cfg: dict) -> dict:
|
||||
"models": list(meta.get("models", [])),
|
||||
"category": meta.get("category", "easy_start"),
|
||||
"quick": meta.get("quick", False),
|
||||
"oauth_provider": meta.get("oauth_provider") or "",
|
||||
"oauth_label": meta.get("oauth_label") or "",
|
||||
}
|
||||
)
|
||||
|
||||
@@ -749,9 +767,9 @@ def _build_setup_catalog(cfg: dict) -> dict:
|
||||
# Flag whether the currently-configured provider is OAuth-based (not in the
|
||||
# API-key flow). The frontend uses this to show a confirmation card instead
|
||||
# of a key input when the user has already authenticated via 'hermes auth'.
|
||||
current_is_oauth = current_provider not in _SUPPORTED_PROVIDER_SETUPS and bool(
|
||||
current_provider
|
||||
)
|
||||
current_is_oauth = (
|
||||
current_provider not in _SUPPORTED_PROVIDER_SETUPS and bool(current_provider)
|
||||
) or _provider_oauth_authenticated(current_provider, _get_active_hermes_home())
|
||||
|
||||
return {
|
||||
"providers": providers,
|
||||
@@ -916,11 +934,13 @@ def apply_onboarding_setup(body: dict) -> dict:
|
||||
if not api_key and not _provider_api_key_present(provider, cfg, env_values):
|
||||
# Providers that may run keyless (lmstudio, ollama, custom — gated by
|
||||
# `key_optional` in _SUPPORTED_PROVIDER_SETUPS) are allowed to onboard
|
||||
# with no api_key. The agent runtime substitutes a placeholder
|
||||
# (LMSTUDIO_NOAUTH_PLACEHOLDER) for those, and the probe (#1499) gives
|
||||
# the user immediate feedback if their server actually does require
|
||||
# auth (http_4xx with status 401). See #1499 third sub-bug from #1420.
|
||||
if not provider_meta.get("key_optional"):
|
||||
# with no api_key. OAuth-capable wizard providers (currently Anthropic
|
||||
# via Claude Code) are also allowed once their server-side OAuth/link
|
||||
# marker is present.
|
||||
oauth_ready = bool(provider_meta.get("oauth_provider")) and _provider_oauth_authenticated(
|
||||
str(provider_meta.get("oauth_provider")), _get_active_hermes_home()
|
||||
)
|
||||
if not provider_meta.get("key_optional") and not oauth_ready:
|
||||
raise ValueError(f"{provider_meta['env_var']} is required")
|
||||
|
||||
model_cfg = cfg.get("model", {})
|
||||
|
||||
BIN
docs/pr-media/1362/claude-code-onboarding.png
Normal file
BIN
docs/pr-media/1362/claude-code-onboarding.png
Normal file
Binary file not shown.
|
After Width: | Height: | Size: 158 KiB |
@@ -213,6 +213,19 @@ function _renderOnboardingModelField(){
|
||||
return `<label class="onboarding-field"><span>${t('onboarding_model_label')}</span><select id="onboardingModelSelect" onchange="ONBOARDING.form.model=this.value">${options}</select></label><p class="onboarding-copy">${t('onboarding_workspace_help')}</p>`;
|
||||
}
|
||||
|
||||
function _renderOnboardingProviderOAuthField(provider){
|
||||
if(!provider||provider.oauth_provider!=='anthropic')return '';
|
||||
return `<div class="onboarding-oauth-card onboarding-oauth-pending" style="margin-top:12px">
|
||||
<div class="onboarding-oauth-icon">🔑</div>
|
||||
<div style="flex:1">
|
||||
<strong>Use Claude Code OAuth instead</strong>
|
||||
<p style="margin-top:6px;color:var(--muted);font-size:13px">Link this WebUI to Claude Code credentials already available on the server, or start a short polling flow while you complete <code>claude setup-token</code> on the host.</p>
|
||||
<div style="margin-top:10px;display:flex;gap:8px;align-items:center;flex-wrap:wrap"><button class="sm-btn" id="anthropicOAuthBtn" onclick="startAnthropicOAuth()" type="button">Login with Claude Code</button></div>
|
||||
<div id="anthropicOAuthFlow" style="display:none;margin-top:12px"></div>
|
||||
</div>
|
||||
</div>`;
|
||||
}
|
||||
|
||||
function _providerStatusLabel(system){
|
||||
if(system.chat_ready) return t('onboarding_check_provider_ready');
|
||||
if(system.provider_configured) return t('onboarding_check_provider_partial');
|
||||
@@ -316,6 +329,7 @@ function _renderOnboardingBody(){
|
||||
<select id="onboardingProviderSelect" onchange="syncOnboardingProvider(this.value)">${groupedOptions}</select>
|
||||
</label>
|
||||
${_renderOnboardingApiKeyField()}
|
||||
${_renderOnboardingProviderOAuthField(provider)}
|
||||
${_renderOnboardingBaseUrlField(showBaseUrl)}
|
||||
<p class="onboarding-copy">${keyHelp}</p>
|
||||
${showBaseUrl?`<p class="onboarding-copy">${t('onboarding_base_url_help')}</p>`:''}
|
||||
@@ -662,3 +676,121 @@ async function startCodexOAuth(){
|
||||
_setCodexOAuthButton(true);
|
||||
}
|
||||
}
|
||||
|
||||
/* ── Anthropic / Claude Code credential-link flow ── */
|
||||
let _anthropicOAuthPollTimer=null;
|
||||
let _anthropicOAuthFlowId=null;
|
||||
|
||||
function _clearAnthropicOAuthPoll(){
|
||||
if(_anthropicOAuthPollTimer){clearTimeout(_anthropicOAuthPollTimer);_anthropicOAuthPollTimer=null;}
|
||||
}
|
||||
|
||||
function _setAnthropicOAuthButton(enabled){
|
||||
const btn=$('anthropicOAuthBtn');
|
||||
if(btn){btn.disabled=!enabled;btn.textContent=enabled?'Login with Claude Code':'...';}
|
||||
}
|
||||
|
||||
async function cancelAnthropicOAuth(){
|
||||
const flowDiv=$('anthropicOAuthFlow');
|
||||
const flowId=_anthropicOAuthFlowId;
|
||||
_clearAnthropicOAuthPoll();
|
||||
_anthropicOAuthFlowId=null;
|
||||
if(flowId){
|
||||
try{await api('/api/onboarding/oauth/cancel',{method:'POST',body:JSON.stringify({flow_id:flowId,provider:'anthropic'})});}catch(e){}
|
||||
}
|
||||
_setAnthropicOAuthButton(true);
|
||||
if(flowDiv){
|
||||
flowDiv.innerHTML=`<div class="onboarding-oauth-card"><div class="onboarding-oauth-icon">⏹</div><div><strong>Claude Code OAuth cancelled</strong><p style="margin-top:6px;color:var(--muted);font-size:13px">Start again whenever you're ready.</p></div></div>`;
|
||||
}
|
||||
}
|
||||
|
||||
function _renderAnthropicOAuthTerminal(status,message){
|
||||
const flowDiv=$('anthropicOAuthFlow');
|
||||
if(!flowDiv)return;
|
||||
const ok=status==='success';
|
||||
const icon=ok?'✅':status==='expired'?'⌛':status==='cancelled'?'⏹':'❌';
|
||||
const title=ok?'Claude Code OAuth linked':(status==='expired'?'Claude Code polling expired':(status==='cancelled'?'Claude Code OAuth cancelled':'Claude Code OAuth failed'));
|
||||
flowDiv.style.display='block';
|
||||
flowDiv.innerHTML=`
|
||||
<div class="onboarding-oauth-card ${ok?'onboarding-oauth-ready':''}" ${ok?'':'style="border-color:var(--error,#e55)"'}>
|
||||
<div class="onboarding-oauth-icon">${icon}</div>
|
||||
<div><strong>${title}</strong><p style="margin-top:6px;color:var(--muted);font-size:13px">${esc(message||'')}</p></div>
|
||||
</div>`;
|
||||
}
|
||||
|
||||
async function _pollAnthropicOAuth(){
|
||||
const flowId=_anthropicOAuthFlowId;
|
||||
if(!flowId)return;
|
||||
try{
|
||||
const resp=await api('/api/onboarding/oauth/poll?flow_id='+encodeURIComponent(flowId));
|
||||
const status=(resp&&resp.status)||'error';
|
||||
if(status==='pending'){
|
||||
_anthropicOAuthPollTimer=setTimeout(_pollAnthropicOAuth,3000);
|
||||
return;
|
||||
}
|
||||
_clearAnthropicOAuthPoll();
|
||||
_anthropicOAuthFlowId=null;
|
||||
_setAnthropicOAuthButton(true);
|
||||
if(status==='success'){
|
||||
_renderAnthropicOAuthTerminal('success','Hermes is now linked to Claude Code credentials. Refreshing provider status…');
|
||||
showToast('Claude Code OAuth linked');
|
||||
try{await loadOnboardingWizard();}catch(e){}
|
||||
}else if(status==='expired'){
|
||||
_renderAnthropicOAuthTerminal('expired','Claude Code credentials were not detected before this flow expired. Start a new flow to try again.');
|
||||
}else if(status==='cancelled'){
|
||||
_renderAnthropicOAuthTerminal('cancelled','The login flow was cancelled.');
|
||||
}else{
|
||||
_renderAnthropicOAuthTerminal('error',(resp&&resp.error)||'Claude Code OAuth linking failed. Please try again.');
|
||||
}
|
||||
}catch(e){
|
||||
_clearAnthropicOAuthPoll();
|
||||
_anthropicOAuthFlowId=null;
|
||||
_setAnthropicOAuthButton(true);
|
||||
_renderAnthropicOAuthTerminal('error',(e&&e.message)||String(e));
|
||||
}
|
||||
}
|
||||
|
||||
async function startAnthropicOAuth(){
|
||||
const flowDiv=$('anthropicOAuthFlow');
|
||||
if(!flowDiv)return;
|
||||
_clearAnthropicOAuthPoll();
|
||||
_anthropicOAuthFlowId=null;
|
||||
_setAnthropicOAuthButton(false);
|
||||
flowDiv.style.display='block';
|
||||
flowDiv.innerHTML=`<div class="onboarding-oauth-card onboarding-oauth-pending"><div class="onboarding-oauth-icon">⏳</div><div><strong>Checking Claude Code credentials…</strong><p>Hermes is checking for existing Claude Code OAuth credentials on this server.</p></div></div>`;
|
||||
try{
|
||||
const resp=await api('/api/onboarding/oauth/start',{method:'POST',body:JSON.stringify({provider:'anthropic'})});
|
||||
if(resp.error) throw new Error(resp.error);
|
||||
const{flow_id,status,action_required}=resp;
|
||||
if(!flow_id) throw new Error('Invalid OAuth response');
|
||||
_anthropicOAuthFlowId=flow_id;
|
||||
if(status==='success'){
|
||||
_clearAnthropicOAuthPoll();
|
||||
_anthropicOAuthFlowId=null;
|
||||
_setAnthropicOAuthButton(true);
|
||||
_renderAnthropicOAuthTerminal('success','Hermes is now linked to Claude Code credentials. Refreshing provider status…');
|
||||
showToast('Claude Code OAuth linked');
|
||||
try{await loadOnboardingWizard();}catch(e){}
|
||||
return;
|
||||
}
|
||||
flowDiv.innerHTML=`
|
||||
<div class="onboarding-oauth-card onboarding-oauth-pending">
|
||||
<div class="onboarding-oauth-icon">🖥️</div>
|
||||
<div style="flex:1">
|
||||
<strong>Complete Claude Code login on this host</strong>
|
||||
<p style="margin-top:6px">${esc(action_required||"Run 'claude setup-token' on the server, then return here. Hermes will detect the credential automatically.")}</p>
|
||||
<div style="display:flex;gap:8px;align-items:center;flex-wrap:wrap;margin-top:10px">
|
||||
<code style="display:inline-block;background:rgba(255,255,255,.08);padding:6px 10px;border-radius:8px;user-select:all">claude setup-token</code>
|
||||
<button class="sm-btn" type="button" onclick="cancelAnthropicOAuth()">Cancel</button>
|
||||
</div>
|
||||
<p style="margin-top:8px;color:var(--muted);font-size:13px">Waiting for Claude Code credentials...</p>
|
||||
</div>
|
||||
</div>`;
|
||||
_anthropicOAuthPollTimer=setTimeout(_pollAnthropicOAuth,Math.max(1000,Number(resp.poll_interval_seconds||3)*1000));
|
||||
}catch(e){
|
||||
_clearAnthropicOAuthPoll();
|
||||
_anthropicOAuthFlowId=null;
|
||||
_renderAnthropicOAuthTerminal('error',(e&&e.message)||String(e));
|
||||
_setAnthropicOAuthButton(true);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -28,10 +28,10 @@ def test_onboarding_codex_oauth_routes_use_post_start_cancel_and_get_poll():
|
||||
assert '"/api/onboarding/oauth/cancel"' in post_body
|
||||
|
||||
|
||||
def test_onboarding_oauth_rejects_non_codex_providers(monkeypatch):
|
||||
def test_onboarding_oauth_rejects_unsupported_providers(monkeypatch):
|
||||
import api.oauth as oauth
|
||||
|
||||
for provider in ("anthropic", "claude", "claude-code", "nous", "qwen-oauth", "copilot", "bogus"):
|
||||
for provider in ("nous", "qwen-oauth", "copilot", "bogus"):
|
||||
with pytest.raises(ValueError):
|
||||
oauth.start_onboarding_oauth_flow({"provider": provider})
|
||||
|
||||
@@ -226,11 +226,312 @@ def test_frontend_uses_onboarding_oauth_endpoints_and_no_secret_poll_url():
|
||||
assert "cancelCodexOAuth" in js
|
||||
|
||||
|
||||
def test_unsupported_note_no_longer_calls_openai_codex_terminal_first():
|
||||
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 + 400]
|
||||
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 "Anthropic" not in body
|
||||
assert "Claude" not 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 os
|
||||
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_onboarding_setup_allows_linked_oauth_without_api_key(monkeypatch, tmp_path):
|
||||
import api.onboarding as onboarding
|
||||
|
||||
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 "/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"):]
|
||||
|
||||
Reference in New Issue
Block a user