mirror of
https://github.com/openswarm-ai/openswarm.git
synced 2026-08-20 19:52:23 +02:00
189 lines
9.0 KiB
Python
189 lines
9.0 KiB
Python
"""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
|