"""Which credential keeps a live agent session alive, as one typed value. The settings-meta tool lets an agent edit its own Settings autonomously. The single hard rule is "no suicide": it must never disconnect the credential that powers its own run. We enforce that structurally, not with a scattered if-check, by resolving the powering credential to a small closed value HERE, in one place, and having the write guard key off it. Add a provider lane and you add a case here; the exhaustive enumeration in test_settings_meta_guard.py walks every (provider x route x connection_mode) combo and fails until the new lane is classified, so a wrong/forgotten state can't ship silently. Honest scope: only API keys live in writable settings fields, so they're the only credential the guard can be asked to protect. Subscriptions (OpenSwarm Pro/free-trial, and the 9router OAuth lanes for Claude/Codex/Gemini) are either server-owned or live entirely outside settings.json, so the settings-meta tool cannot touch them at all, a stronger protection than the guard itself. """ from __future__ import annotations from dataclasses import dataclass from typing import Any, Literal, TYPE_CHECKING from backend.apps.agents.providers.registry import ( CUSTOM_VALUE_PREFIX, custom_provider_slug_for_lookup, find_builtin_model, find_custom_provider_for_value, get_api_type, ) if TYPE_CHECKING: from backend.apps.settings.models import AppSettings # AppSettings fields holding a user-writable API key, keyed by provider api-type. Blanking whichever of these powers the current run is the one suicide the guard stops. Anything not here (subscription tokens, bearers) is not settings-writable. P_API_KEY_FIELD_BY_API: dict[str, str] = { "anthropic": "anthropic_api_key", "openai": "openai_api_key", "codex": "openai_api_key", "gemini": "google_api_key", "openrouter": "openrouter_api_key", } # Every settings field that can hold an API key (the full guarded set). Custom providers keep their keys inside the custom_providers list, guarded separately. ALL_API_KEY_FIELDS: frozenset[str] = frozenset(P_API_KEY_FIELD_BY_API.values()) CredentialKind = Literal["api_key", "subscription", "unknown"] @dataclass(frozen=True) class PoweringCredential: """The credential keeping THIS run alive, resolved to a closed value. kind=="api_key" -> protected_field (or custom slug) names exactly what the guard must keep alive. kind=="subscription" -> the live credential isn't a settings field at all (Pro/free-trial/9router OAuth), so no api-key field needs guarding; clearing OTHER keys stays allowed. kind=="unknown" -> we couldn't classify the run; fail safe by treating ALL credential fields as protected. """ kind: CredentialKind provider: str protected_field: str | None = None protected_custom_slug: str | None = None label: str = "" def p_custom_slug_for_model(model_value: str, settings: AppSettings) -> str | None: cp = find_custom_provider_for_value(settings, model_value) if cp is not None: return custom_provider_slug_for_lookup(getattr(cp, "name", "")) # Fall back to the slug encoded in the picker value itself. if isinstance(model_value, str) and model_value.startswith(CUSTOM_VALUE_PREFIX): slug = model_value[len(CUSTOM_VALUE_PREFIX):].partition("/")[0] return slug or None return None def resolve_powering_credential(model_value: str, settings: AppSettings) -> PoweringCredential: """Resolve the credential powering a run on `model_value` to a typed value. `model_value` is the session's short model name (e.g. "opus-4-8", "sonnet-api", "custom/lmstudio/llama"), exactly what AgentSession.model holds. """ entry = find_builtin_model(model_value) api = (entry or {}).get("api") or get_api_type(model_value) route = (entry or {}).get("route") mode = getattr(settings, "connection_mode", "own_key") # Custom provider (LM Studio, Ollama, Together, ...). Local servers use a placeholder key, so suicide is removing the provider ENTRY, not blanking its key; the guard keys off the slug. if api == "custom": slug = p_custom_slug_for_model(model_value, settings) return PoweringCredential( kind="api_key", provider="custom", protected_custom_slug=slug, label=f"custom provider '{slug}'" if slug else "custom provider", ) # Explicit API-key route: the matching *_api_key field is the live one. if route == "api": field = P_API_KEY_FIELD_BY_API.get(api) if field: return PoweringCredential(kind="api_key", provider=api, protected_field=field, label=f"{field} (powers this run)") return PoweringCredential(kind="unknown", provider=api, label=f"{api} api route (unclassified)") # Subscription-only routes (cx/ Codex, gc/ Gemini CLI) and pinned cc/ Claude: these lanes live in 9router, never in settings. if route == "cc" or (entry or {}).get("subscription_only"): return PoweringCredential(kind="subscription", provider=api, label=f"{api} subscription") # OpenRouter (its own `openrouter` route, plus xai/meta/deepseek/etc routed through it): always an API key, never a subscription. if api == "openrouter": return PoweringCredential(kind="api_key", provider="openrouter", protected_field="openrouter_api_key", label="OpenRouter API key (powers this run)") # Default Anthropic rows (route is None): connection_mode picks the lane. if api == "anthropic": if mode in ("openswarm-pro", "free-trial"): label = "OpenSwarm Pro" if mode == "openswarm-pro" else "OpenSwarm free trial" return PoweringCredential(kind="subscription", provider="anthropic", label=label) if getattr(settings, "anthropic_api_key", None): return PoweringCredential(kind="api_key", provider="anthropic", protected_field="anthropic_api_key", label="Anthropic API key (powers this run)") # No key, no proxy mode -> the user's Claude subscription via 9router. return PoweringCredential(kind="subscription", provider="anthropic", label="Claude subscription") # Default Gemini rows (api gemini-cli, route None): the AG/gc OAuth lane is a subscription. A bare AI Studio key only powers the explicit -api rows above. if api in ("gemini", "gemini-cli"): return PoweringCredential(kind="subscription", provider="gemini", label="Gemini subscription") # Anything we can't place: protect everything (fail safe), never fail open. return PoweringCredential(kind="unknown", provider=api or "unknown", label=f"{api or 'unknown'} provider (unclassified)") def p_is_blank(value: Any) -> bool: """A credential write that removes the credential: None, "", or whitespace.""" if value is None: return True if isinstance(value, str): return value.strip() == "" return False def p_powering_custom_slug_present(new_providers: Any, slug: str) -> bool: """True if the powering custom provider's entry still exists after the write.""" if not isinstance(new_providers, list): return False for cp in new_providers: name = cp.get("name") if isinstance(cp, dict) else getattr(cp, "name", None) if name and custom_provider_slug_for_lookup(name) == slug: return True return False def write_would_suicide(field: str, new_value: Any, powering: PoweringCredential) -> bool: """True if writing `new_value` to `field` would disconnect the live credential. Pure and total: every (field, value, powering) maps to a definite yes/no, so the guard can't be tricked by an unhandled path. Only blanking/removing a credential counts; SETTING a fresh key is a (re)connect, never suicide. """ if field == "custom_providers": # Removing the entry that powers a custom-provider run is suicide; a local provider's placeholder key being blanked is not. When the run is unknown, any custom run could be the live one, so refuse a vanish. if powering.kind == "api_key" and powering.provider == "custom" and powering.protected_custom_slug: return not p_powering_custom_slug_present(new_value, powering.protected_custom_slug) if powering.kind == "unknown": return not p_powering_custom_slug_present(new_value, powering.protected_custom_slug or "") return False if field in ALL_API_KEY_FIELDS: if not p_is_blank(new_value): return False if powering.kind == "unknown": return True return powering.kind == "api_key" and field == powering.protected_field return False