feat: link Claude Code OAuth in onboarding

This commit is contained in:
Michael Lam
2026-05-05 18:10:45 -07:00
committed by test
parent 4dca3d9b96
commit e509faec44
5 changed files with 776 additions and 30 deletions

View File

@@ -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

View File

@@ -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", {})

Binary file not shown.

After

Width:  |  Height:  |  Size: 158 KiB

View File

@@ -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);
}
}

View File

@@ -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"):]