mirror of
https://github.com/volcengine/OpenViking.git
synced 2026-10-01 01:38:07 +08:00
Read replicas load the API key store once at startup and never rewrite it, so a user registered/rotated/removed on the writer stays invisible (new key -> "Invalid API Key"; removed key -> still accepted). Add an optional background watcher that polls the shared key store and reloads the in-memory index only when it actually changes: - APIKeyManager.reload(): strictly read-only refresh that rebuilds state and swaps it in atomically, never writing or migrating plaintext keys. - compute_store_signature(): cheap (path, size, modTime) signature over accounts.json + every users.json so the watcher skips unchanged polls. - ApiKeyAuthPlugin starts/stops the watcher behind api_key_watch_enabled (default off) with api_key_watch_interval_seconds; AuthPlugin.shutdown() is wired into app shutdown to cancel it cleanly. Add coverage for reload convergence, read-only/no-migrate guarantees, uninitialized-store tolerance, signature change detection, and watcher reload/skip/shutdown behavior. Co-authored-by: TRAE CLI <noreply@bytedance.com>
535 lines
20 KiB
Python
535 lines
20 KiB
Python
# Copyright (c) 2026 Beijing Volcano Engine Technology Co., Ltd.
|
|
# SPDX-License-Identifier: AGPL-3.0
|
|
"""Server configuration for OpenViking HTTP Server."""
|
|
|
|
import sys
|
|
from typing import Dict, List, Literal, Optional
|
|
|
|
from pydantic import BaseModel, Field, ValidationError, field_validator
|
|
|
|
# Import auth plugin registry for config validation
|
|
from openviking.server.auth.ldap_config import LDAPConfig
|
|
from openviking.server.auth.oidc_config import OIDCConfig
|
|
from openviking.server.auth.registry import get_registry
|
|
from openviking.server.identity import AuthMode
|
|
from openviking_cli.utils import get_logger
|
|
from openviking_cli.utils.config.config_loader import (
|
|
load_json_config,
|
|
resolve_config_path,
|
|
)
|
|
from openviking_cli.utils.config.config_utils import format_validation_error
|
|
from openviking_cli.utils.config.consts import (
|
|
DEFAULT_CONFIG_DIR,
|
|
DEFAULT_OV_CONF,
|
|
OPENVIKING_CONFIG_ENV,
|
|
SYSTEM_CONFIG_DIR,
|
|
)
|
|
|
|
logger = get_logger(__name__)
|
|
|
|
|
|
def _normalize_config_uri(value: Optional[str], field_name: str) -> Optional[str]:
|
|
if value is None:
|
|
return None
|
|
if not isinstance(value, str):
|
|
raise ValueError(f"{field_name} must be a string")
|
|
uri = value.strip().rstrip("/")
|
|
if not uri:
|
|
raise ValueError(f"{field_name} must not be empty")
|
|
return uri
|
|
|
|
|
|
class AddTargetsConfig(BaseModel):
|
|
"""Add targets for resource and skill writes."""
|
|
|
|
resource_uri: Optional[str] = None
|
|
skill_uri: Optional[str] = None
|
|
|
|
model_config = {"extra": "forbid"}
|
|
|
|
@field_validator("resource_uri")
|
|
@classmethod
|
|
def validate_resource_uri(cls, value: Optional[str]) -> Optional[str]:
|
|
uri = _normalize_config_uri(value, "resource_uri")
|
|
if uri is None:
|
|
return None
|
|
from openviking.core.namespace import classify_uri, uri_parts
|
|
from openviking.core.uri_validation import validate_viking_uri
|
|
from openviking_cli.utils.uri import VikingURI
|
|
|
|
validate_viking_uri(uri, field_name="resource_uri")
|
|
normalized = VikingURI.normalize(uri).rstrip("/")
|
|
parts = uri_parts(normalized)
|
|
classification = classify_uri(normalized)
|
|
if parts[:1] == ["resources"] or (
|
|
parts[:1] == ["user"]
|
|
and classification.context_type == "resource"
|
|
and classification.content_index is not None
|
|
):
|
|
return normalized
|
|
raise ValueError("resource_uri must be a resource directory URI")
|
|
|
|
@field_validator("skill_uri")
|
|
@classmethod
|
|
def validate_skill_uri(cls, value: Optional[str]) -> Optional[str]:
|
|
uri = _normalize_config_uri(value, "skill_uri")
|
|
if uri is None:
|
|
return None
|
|
from openviking_cli.utils.uri import VikingURI
|
|
|
|
normalized = VikingURI.normalize(uri).rstrip("/")
|
|
if normalized in {"viking://user/skills", "viking://agent/skills"}:
|
|
return normalized
|
|
raise ValueError("skill_uri must be viking://user/skills or viking://agent/skills")
|
|
|
|
|
|
class AgentEvolutionConfig(BaseModel):
|
|
"""Default Agent Evolution setting for accounts without an override."""
|
|
|
|
enabled: bool = False
|
|
|
|
model_config = {"extra": "forbid"}
|
|
|
|
|
|
class DeprecatedUserAgentEvolutionConfig(BaseModel):
|
|
"""Parse-only compatibility for legacy per-user configuration files."""
|
|
|
|
enabled: Optional[bool] = None
|
|
|
|
model_config = {"extra": "forbid"}
|
|
|
|
|
|
class UserConfig(BaseModel):
|
|
"""User configuration values that can be defaulted or initialized."""
|
|
|
|
add_targets: AddTargetsConfig = Field(default_factory=AddTargetsConfig)
|
|
agent_evolution: DeprecatedUserAgentEvolutionConfig = Field(
|
|
default_factory=DeprecatedUserAgentEvolutionConfig,
|
|
exclude=True,
|
|
)
|
|
|
|
model_config = {"extra": "forbid"}
|
|
|
|
|
|
class MetricsAccountDimensionConfig(BaseModel):
|
|
"""Account-dimension configuration for metrics label injection."""
|
|
|
|
# Enabled by default, but still allowlist-gated to avoid accidental high-cardinality exposure.
|
|
enabled: bool = True
|
|
max_active_accounts: int = 100
|
|
metric_allowlist: List[str] = Field(default_factory=list)
|
|
|
|
model_config = {"extra": "forbid"}
|
|
|
|
|
|
class PrometheusExporterConfig(BaseModel):
|
|
"""Prometheus exporter configuration."""
|
|
|
|
enabled: bool = True
|
|
|
|
model_config = {"extra": "forbid"}
|
|
|
|
|
|
class OTelExporterConfig(BaseModel):
|
|
"""OpenTelemetry exporter configuration."""
|
|
|
|
class TLSConfig(BaseModel):
|
|
"""TLS configuration for OTLP exporters."""
|
|
|
|
insecure: bool = False
|
|
|
|
model_config = {"extra": "forbid"}
|
|
|
|
enabled: bool = False
|
|
protocol: str = "grpc" # "grpc", "http", or "local" for traces
|
|
tls: TLSConfig = Field(default_factory=TLSConfig)
|
|
endpoint: str = "localhost:4317" # gRPC default: 4317; HTTP default: 4318
|
|
service_name: str = "openviking-server"
|
|
export_interval_ms: int = 10000
|
|
headers: Dict[str, str] = Field(default_factory=dict)
|
|
local_path: str = "~/.openviking/logs/traces.jsonl"
|
|
local_rotation_mb: int = Field(default=40, gt=0)
|
|
local_backup_count: int = Field(default=2, ge=0)
|
|
|
|
model_config = {"extra": "forbid"}
|
|
|
|
|
|
class MetricsExportersConfig(BaseModel):
|
|
"""Metrics exporters configuration."""
|
|
|
|
prometheus: PrometheusExporterConfig = Field(default_factory=PrometheusExporterConfig)
|
|
otel: OTelExporterConfig = Field(default_factory=OTelExporterConfig)
|
|
|
|
model_config = {"extra": "forbid"}
|
|
|
|
|
|
class MetricsConfig(BaseModel):
|
|
"""Metrics subsystem configuration."""
|
|
|
|
enabled: bool = False
|
|
bot_data_path: Optional[str] = None
|
|
account_dimension: MetricsAccountDimensionConfig = Field(
|
|
default_factory=MetricsAccountDimensionConfig
|
|
)
|
|
exporters: MetricsExportersConfig = Field(default_factory=MetricsExportersConfig)
|
|
|
|
model_config = {"extra": "forbid"}
|
|
|
|
|
|
class UsageAuditConfig(BaseModel):
|
|
"""Product usage and audit projection configuration."""
|
|
|
|
enabled: bool = True
|
|
backend: Literal["sqlite"] = "sqlite"
|
|
sqlite_path: Optional[str] = None
|
|
queue_size: int = Field(10_000, gt=0)
|
|
batch_size: int = Field(500, gt=0)
|
|
flush_interval_seconds: float = Field(1.0, gt=0)
|
|
shutdown_flush_timeout_seconds: float = Field(3.0, gt=0)
|
|
usage_retention_days: int = Field(14, ge=0)
|
|
audit_retention_days: int = Field(7, ge=0)
|
|
audit_retention_per_account: int = Field(1000, ge=0)
|
|
timezone: str = "local"
|
|
inventory_ttl_seconds: float = Field(10.0, ge=0)
|
|
|
|
model_config = {"extra": "forbid"}
|
|
|
|
|
|
class UsageReporterSinkConfig(BaseModel):
|
|
"""Usage reporter sink configuration."""
|
|
|
|
type: Literal["custom", "file_log"] = "custom"
|
|
class_path: Optional[str] = None
|
|
config: Dict[str, object] = Field(default_factory=dict)
|
|
|
|
model_config = {"extra": "forbid"}
|
|
|
|
|
|
class UsageReporterConfig(BaseModel):
|
|
"""Usage event reporter configuration."""
|
|
|
|
enabled: bool = False
|
|
extractors: List[Literal["memory_usage"]] = Field(default_factory=lambda: ["memory_usage"])
|
|
sinks: List[UsageReporterSinkConfig] = Field(default_factory=list)
|
|
|
|
model_config = {"extra": "forbid"}
|
|
|
|
|
|
class TraceDumpBodyConfig(BaseModel):
|
|
"""HTTP body dump configuration.
|
|
|
|
Attaches request/response bodies as attributes on the active trace span so
|
|
they can be inspected in trace UIs. Off by default — bodies may contain
|
|
secrets and high-cardinality content.
|
|
"""
|
|
|
|
enabled: bool = False
|
|
max_bytes: int = 4096
|
|
|
|
model_config = {"extra": "forbid"}
|
|
|
|
|
|
class ObservabilityConfig(BaseModel):
|
|
"""Server-side observability configuration."""
|
|
|
|
metrics: MetricsConfig = Field(default_factory=MetricsConfig)
|
|
usage_audit: UsageAuditConfig = Field(default_factory=UsageAuditConfig)
|
|
traces: OTelExporterConfig = Field(default_factory=OTelExporterConfig)
|
|
logs: OTelExporterConfig = Field(default_factory=OTelExporterConfig)
|
|
dump_body: TraceDumpBodyConfig = Field(default_factory=TraceDumpBodyConfig)
|
|
|
|
model_config = {"extra": "forbid"}
|
|
|
|
|
|
class TempUploadConfig(BaseModel):
|
|
"""Temporary upload configuration."""
|
|
|
|
default_mode: Literal["local", "shared"] = "local"
|
|
shared_max_size_bytes: int = 512 * 1024 * 1024
|
|
shared_prefix: str = "viking://upload"
|
|
|
|
model_config = {"extra": "forbid"}
|
|
|
|
|
|
class ToolOutputExternalizationConfig(BaseModel):
|
|
"""External storage controls for oversized tool outputs."""
|
|
|
|
enabled: bool = True
|
|
threshold_chars: int = 20_000
|
|
preview_chars: int = 2_000
|
|
assistant_turn_inline_budget_chars: int = 100_000
|
|
assistant_turn_preview_budget_chars: int = 10_000
|
|
min_preview_chars: int = 1_000
|
|
aggregate_selection_strategy: Literal["largest_first"] = "largest_first"
|
|
failure_mode: Literal["reject", "preserve_raw", "preview_only"] = "preserve_raw"
|
|
|
|
model_config = {"extra": "forbid"}
|
|
|
|
|
|
class ServerConfig(BaseModel):
|
|
host: str = "127.0.0.1"
|
|
port: int = 1933
|
|
workers: int = 1
|
|
# Seconds an idle HTTP keep-alive connection is kept open before the server
|
|
# closes it. Defaults to 5 to match uvicorn's built-in default and preserve
|
|
# the existing service behavior. Raise it above the idle-connection lifetime
|
|
# of any upstream client or load balancer that reuses connections (e.g. the
|
|
# serverless VKE forwarder keeps idle connections for up to 60s via
|
|
# WithMaxIdleConnDuration(1*time.Minute)); otherwise the server may close
|
|
# connections the client still believes are reusable, causing sporadic
|
|
# connection-reset / EOF errors.
|
|
timeout_keep_alive: int = 5
|
|
auth_mode: Optional[str] = None # If None, auto-detect based on root_api_key
|
|
root_api_key: Optional[str] = None
|
|
# OIDC/LDAP authentication configuration
|
|
oidc: Optional[OIDCConfig] = None
|
|
ldap: Optional[LDAPConfig] = None
|
|
profile_enabled: bool = False
|
|
cors_origins: List[str] = Field(default_factory=lambda: ["*"])
|
|
with_bot: bool = False # Enable Bot API proxy to Vikingbot
|
|
bot_api_url: str = "http://localhost:18790" # Vikingbot OpenAPIChannel URL (default port)
|
|
encryption_enabled: bool = False # Whether file-level AES encryption is enabled
|
|
api_key_hashing_enabled: bool = False # Whether API key Argon2id hashing is enabled (default: false, rely on file encryption)
|
|
# When true, poll the shared key store and reload the in-memory index on change so
|
|
# read replicas pick up writer-side user add/rotate/remove. Default off (single writer).
|
|
api_key_watch_enabled: bool = False
|
|
# Poll interval; each check only stats registry files and reads fully on change.
|
|
api_key_watch_interval_seconds: float = 30.0
|
|
observability: ObservabilityConfig = Field(default_factory=ObservabilityConfig)
|
|
usage_reporter: UsageReporterConfig = Field(default_factory=UsageReporterConfig)
|
|
# Public-facing base URL emitted in MCP-issued upload instructions. See
|
|
# ``openviking.server.mcp_endpoint._resolve_public_base_url`` for the full
|
|
# resolution chain: env var > this field > X-Forwarded-Host/Proto > Host header
|
|
# > listen-address fallback. Set this (or the env var) when the server runs
|
|
# behind a reverse proxy that does not forward X-Forwarded-* headers.
|
|
public_base_url: Optional[str] = None
|
|
upload_signed_ttl_seconds: int = 600
|
|
temp_upload: TempUploadConfig = Field(default_factory=TempUploadConfig)
|
|
user_config_defaults: UserConfig = Field(default_factory=UserConfig)
|
|
agent_evolution: AgentEvolutionConfig = Field(default_factory=AgentEvolutionConfig)
|
|
tool_output_externalization: ToolOutputExternalizationConfig = Field(
|
|
default_factory=ToolOutputExternalizationConfig
|
|
)
|
|
|
|
model_config = {"extra": "forbid"}
|
|
|
|
def get_effective_auth_mode(self) -> str:
|
|
"""Get effective auth mode, auto-detecting if not explicitly set.
|
|
|
|
- If root_api_key is configured (non-empty) and auth_mode is None: api_key
|
|
- If root_api_key is not configured and auth_mode is None: dev
|
|
"""
|
|
auth_mode_text = str(self.auth_mode).strip() if self.auth_mode is not None else ""
|
|
if auth_mode_text:
|
|
return auth_mode_text
|
|
|
|
if self.root_api_key is not None and str(self.root_api_key).strip():
|
|
return AuthMode.API_KEY.value
|
|
return AuthMode.DEV.value
|
|
|
|
|
|
def map_bind_host_to_loopback(host: str) -> str:
|
|
"""Map a server *bind* host to a client-connectable *loopback* host.
|
|
|
|
``server.host`` configures the address the server binds/listens on. A
|
|
wildcard bind address such as ``0.0.0.0`` (or IPv6 ``::``) is valid to
|
|
listen on but is *not* a destination a client can connect to. Clients that
|
|
derive a connect URL from the configured host (the bot proxy, the bot's
|
|
own config loader, ``doctor``) must translate the wildcard to loopback:
|
|
|
|
- ``"0.0.0.0"``, ``""``, ``"*"`` -> ``"127.0.0.1"``
|
|
- ``"::"``, ``"::0"``, ``"[::]"`` -> ``"[::1]"``
|
|
- bare IPv6 literals (e.g. ``"::1"``) are bracketed for URL syntax
|
|
- everything else passes through unchanged
|
|
"""
|
|
host = str(host or "").strip()
|
|
if host in ("0.0.0.0", "", "*"):
|
|
return "127.0.0.1"
|
|
if host in ("::", "::0", "[::]"):
|
|
return "[::1]"
|
|
if ":" in host and not (host.startswith("[") and host.endswith("]")):
|
|
return f"[{host}]"
|
|
return host
|
|
|
|
|
|
def get_server_url_from_server_data(server_data: object) -> str:
|
|
"""Return the loopback URL clients use for the configured OpenViking server."""
|
|
if isinstance(server_data, dict):
|
|
host_value = server_data.get("host")
|
|
port_value = server_data.get("port")
|
|
else:
|
|
host_value = getattr(server_data, "host", None)
|
|
port_value = getattr(server_data, "port", None)
|
|
host = map_bind_host_to_loopback(str(host_value or "127.0.0.1"))
|
|
port = str(port_value or "1933").strip()
|
|
return f"http://{host}:{port}"
|
|
|
|
|
|
def load_server_config(config_path: Optional[str] = None) -> ServerConfig:
|
|
"""Load server configuration from ov.conf.
|
|
|
|
Reads the ``server`` section of ov.conf and also ensures the full
|
|
ov.conf is loaded into the OpenVikingConfigSingleton so that model
|
|
and storage settings are available.
|
|
|
|
Resolution chain:
|
|
1. Explicit ``config_path`` (from --config)
|
|
2. OPENVIKING_CONFIG_FILE environment variable
|
|
3. ~/.openviking/ov.conf
|
|
|
|
Args:
|
|
config_path: Explicit path to ov.conf.
|
|
|
|
Returns:
|
|
ServerConfig instance with defaults for missing fields.
|
|
|
|
Raises:
|
|
FileNotFoundError: If no config file is found.
|
|
"""
|
|
path = resolve_config_path(config_path, OPENVIKING_CONFIG_ENV, DEFAULT_OV_CONF)
|
|
if path is None:
|
|
default_path_user = DEFAULT_CONFIG_DIR / DEFAULT_OV_CONF
|
|
default_path_system = SYSTEM_CONFIG_DIR / DEFAULT_OV_CONF
|
|
raise FileNotFoundError(
|
|
f"OpenViking configuration file not found.\n"
|
|
f"Please create {default_path_user} or {default_path_system}, or set {OPENVIKING_CONFIG_ENV}.\n"
|
|
f"See: https://openviking.ai/docs"
|
|
)
|
|
|
|
data = load_json_config(path)
|
|
server_data = data.get("server", {})
|
|
if server_data is None:
|
|
server_data = {}
|
|
if not isinstance(server_data, dict):
|
|
raise ValueError("Invalid server config: 'server' section must be an object")
|
|
|
|
# Convert auth_mode string — built-in enums are converted to their string
|
|
# value; custom modes are kept as-is for plugin extensibility.
|
|
if "auth_mode" in server_data and isinstance(server_data["auth_mode"], str):
|
|
try:
|
|
server_data["auth_mode"] = AuthMode(server_data["auth_mode"]).value
|
|
except ValueError:
|
|
# Custom auth mode — keep as string for plugin registration
|
|
pass
|
|
|
|
# Get encryption enabled from config data directly (for test compatibility)
|
|
encryption_enabled = data.get("encryption", {}).get("enabled", False)
|
|
# Get API key hashing enabled from config data directly (under encryption namespace)
|
|
# Default: false - rely on file-level encryption for API key protection
|
|
api_key_hashing_enabled = (
|
|
data.get("encryption", {}).get("api_key_hashing", {}).get("enabled", False)
|
|
)
|
|
|
|
# BREAKING CHANGE: Previously, encryption.enabled=true implicitly enabled API key Argon2id hashing.
|
|
# Now, you must explicitly configure encryption.api_key_hashing.enabled=true to enable hashing.
|
|
# When encryption.enabled=true but api_key_hashing.enabled=false (default),
|
|
# API keys will be stored in plaintext within AES-GCM encrypted files.
|
|
if encryption_enabled and not api_key_hashing_enabled:
|
|
logger.info(
|
|
"API key hashing is disabled while file encryption is enabled. "
|
|
"Previously, encryption.enabled=true implicitly enabled API key Argon2id hashing. "
|
|
"Now, API keys will be stored in plaintext within AES-GCM encrypted files. "
|
|
"To maintain the previous behavior, set encryption.api_key_hashing.enabled=true. "
|
|
"See documentation for more details."
|
|
)
|
|
|
|
try:
|
|
config = ServerConfig.model_validate(server_data)
|
|
except ValidationError as e:
|
|
raise ValueError(
|
|
f"Invalid server config in {path}:\n"
|
|
f"{format_validation_error(root_model=ServerConfig, error=e, path_prefix='server')}"
|
|
) from e
|
|
|
|
return config.model_copy(
|
|
update={
|
|
"encryption_enabled": encryption_enabled,
|
|
"api_key_hashing_enabled": api_key_hashing_enabled,
|
|
}
|
|
)
|
|
|
|
|
|
_LOCALHOST_HOSTS = {"127.0.0.1", "localhost", "::1"}
|
|
|
|
|
|
def _is_localhost(host: str) -> bool:
|
|
"""Return True if *host* resolves to a loopback address."""
|
|
return host in _LOCALHOST_HOSTS
|
|
|
|
|
|
def load_bot_gateway_token(config_path: Optional[str] = None) -> str:
|
|
"""Load bot gateway token from ov.conf bot.gateway.token."""
|
|
path = resolve_config_path(config_path, OPENVIKING_CONFIG_ENV, DEFAULT_OV_CONF)
|
|
if path is None:
|
|
return ""
|
|
|
|
data = load_json_config(path)
|
|
bot_config = data.get("bot", {})
|
|
if not isinstance(bot_config, dict):
|
|
return ""
|
|
gateway_config = bot_config.get("gateway", {})
|
|
if not isinstance(gateway_config, dict):
|
|
return ""
|
|
return gateway_config.get("token", "") or ""
|
|
|
|
|
|
def validate_server_config(config: ServerConfig) -> None:
|
|
"""Validate server config for safe startup.
|
|
|
|
Validation is delegated to the auth plugin registered for the effective
|
|
auth_mode. Built-in plugins (dev, api_key, trusted) preserve the original
|
|
validation behaviour.
|
|
|
|
If auth_mode is not explicitly configured:
|
|
- If root_api_key is configured (non-empty): auto-select api_key mode
|
|
- If root_api_key is not configured: auto-select dev mode
|
|
|
|
Raises:
|
|
SystemExit: If the configuration is unsafe.
|
|
"""
|
|
# Check for empty root_api_key
|
|
if config.root_api_key == "":
|
|
logger.error(
|
|
"Invalid server.root_api_key: empty string is not allowed. "
|
|
"Either set a non-empty root_api_key or remove the setting entirely."
|
|
)
|
|
sys.exit(1)
|
|
|
|
effective_auth_mode = config.get_effective_auth_mode()
|
|
|
|
# Ensure built-in plugins are registered before validation.
|
|
# If a non-built-in plugin has already claimed a built-in mode name,
|
|
# log a security warning and forcefully override it.
|
|
from openviking.server.auth.plugins import ApiKeyAuthPlugin, DevAuthPlugin, TrustedAuthPlugin
|
|
|
|
registry = get_registry()
|
|
_BUILTIN_PLUGINS = {
|
|
"dev": DevAuthPlugin,
|
|
"api_key": ApiKeyAuthPlugin,
|
|
"trusted": TrustedAuthPlugin,
|
|
}
|
|
for mode, plugin_cls in _BUILTIN_PLUGINS.items():
|
|
existing = registry.get(mode)
|
|
if existing is None:
|
|
registry.register(plugin_cls)
|
|
elif existing is not plugin_cls:
|
|
logger.warning(
|
|
"SECURITY: Auth mode %r was registered by %s but is being "
|
|
"overridden by the built-in %s.",
|
|
mode,
|
|
existing.__name__,
|
|
plugin_cls.__name__,
|
|
)
|
|
registry._plugins[mode] = plugin_cls
|
|
|
|
plugin_cls = registry.get(effective_auth_mode)
|
|
if plugin_cls is None:
|
|
logger.error(
|
|
"Unknown auth_mode: %r. No auth plugin registered for this mode. Registered modes: %s.",
|
|
effective_auth_mode,
|
|
", ".join(registry.list_modes()),
|
|
)
|
|
sys.exit(1)
|
|
|
|
plugin_cls().validate_config(config)
|