Files
OpenViking/openviking/server/config.py
T
t0saki a83b81715b feat(uri)!: remove uid-less current-user shorthand in favor of viking://~ (#4196)
* feat(uri)!: reject uid-less current-user shorthand in favor of viking://~

viking://user/<segment> (memories/resources/skills/peers/privacy/sessions
without a user id) was ambiguous with a user literally named after the
segment, and a user actually named e.g. "memories" was unreachable for
USER/ADMIN callers. Now that the viking://~ home alias (#4167) covers the
same need unambiguously, the shorthand fails closed at the request
boundary instead of expanding:

- resolve_current_user_uri raises NamespaceShapeError with a corrective
  hint naming both viking://~/<rest> and the explicit-uid form. Silently
  parsing the reserved segment as a peer user id would misdirect reads
  and writes, so rejection is the only safe removal.
- Bare viking://user falls through to the canonical parser and keeps
  container semantics (a user key listing it sees only its own space).
- The self-id escape stays: a caller whose user_id equals a reserved
  name keeps viking://user/<own-id> as their canonical root. ROOT-role
  literal parsing and the legacy viking://session alias are unchanged.
- AddTargetsConfig normalizes stored legacy config spellings
  (viking://user/resources|skills) to the viking://~ form at validation
  so existing ov.conf/user_config deployments keep working; the accepted
  per-user spelling is now viking://~/resources and viking://~/skills.
- usage_reporter keeps canonicalizing the historical shorthand found in
  old transcripts and additionally recognizes viking://~/memories/.

BREAKING CHANGE: requests using the uid-less viking://user/<segment>
spelling now fail with 400; use viking://~/<segment> or an explicit
viking://user/{user_id}/<segment> URI.

* refactor(clients): migrate first-party emitters to the viking://~ home alias

Every in-repo client that emitted the removed uid-less current-user
shorthand now sends viking://~/... instead: vikingbot fallbacks and
default sentinels, the LangChain store/tools defaults, the shared
recall-core.mjs (all synced plugin copies), the codex/claude-code/
openclaw/openwebui/dsh/zcode/pi plugin emitters, quick-app examples,
Go SDK example, tau2 benchmark targets, and the eval golden dataset.

Compat kept where legacy strings live in stored user configs: bot and
ov_dream sentinels accept both spellings while emitting only ~, and
recall-core still rewrites legacy viking://user/<reserved> config values
client-side. langchain_openviking._uri now classifies viking://~ with
the explicit-user shape so canonicalized server responses keep matching
a ~ root. Plugin READMEs note the server requirement for the alias.

* docs: replace current-user shorthand guidance with the viking://~ home alias

Rewrite every EN/ZH doc and model-facing prompt that advertised the
uid-less viking://user/<segment> spelling: URI concept catalogue,
context-types/storage/extraction/retrieval/session/privacy concepts,
configuration guide (with the legacy add_targets auto-normalization
note), resources/skills/sessions/retrieval/admin API references, FAQ,
capability reference, and the openviking-memory / ov-experience-memory /
openclaw / ov-resources skills. The stale MCP viking://user/<path>
dialect passage in the MCP guide is replaced by ~ guidance, and bare
viking://user is documented as the container of user spaces.

* test(api): migrate live API session-used tests off the removed shorthand

tests/api_test/sessions sent uid-less viking://user/skills/... URIs to
record_used, which the request boundary now rejects with 400 (caught by
the API & CLI Integration Tests CI job; these tests need a live server
and are not part of the local suites). The api_test client authenticates
as an admin-role user key, so the viking://~ home alias expands for it.
tests/api_test/common/test_edge_cases.py is left as is: it asserts a 400
for a non-resource add target, which still holds.
2026-08-21 19:00:19 +08:00

588 lines
22 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 Any, 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
def _rewrite_legacy_current_user_uri(uri: str, field_name: str) -> str:
"""Rewrite legacy uid-less current-user spellings to the ``~`` home alias.
``viking://user/resources[/...]`` and ``viking://user/skills`` are no longer
expanded at request boundaries, but stored configurations (ov.conf
``user_config_defaults.add_targets``, persisted ``user_config.json``, old
PATCH bodies) may still carry them. Their intent was unambiguous, so
normalize them here instead of breaking existing deployments.
"""
from openviking.core.namespace import uri_parts
try:
parts = uri_parts(uri.rstrip("/"))
except ValueError:
return uri
if parts[:2] not in (["user", "resources"], ["user", "skills"]):
return uri
rewritten = f"viking://~/{'/'.join(parts[1:])}"
logger.info(
"Rewrote legacy current-user %s %r to %r; use the viking://~ home alias instead.",
field_name,
uri,
rewritten,
)
return rewritten
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
uri = _rewrite_legacy_current_user_uri(uri, "resource_uri")
validate_viking_uri(uri, field_name="resource_uri")
normalized = uri.rstrip("/")
parts = uri_parts(normalized)
classification = classify_uri(normalized)
if (
parts[:1] == ["resources"]
or parts[:2] == ["~", "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: viking://resources/..., "
"viking://~/resources[/...], or viking://user/{user_id}/resources[/...]"
)
@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.core.namespace import uri_parts
from openviking.core.uri_validation import validate_viking_uri
uri = _rewrite_legacy_current_user_uri(uri, "skill_uri")
validate_viking_uri(uri, field_name="skill_uri")
normalized = uri.rstrip("/")
parts = uri_parts(normalized)
if parts in (["~", "skills"], ["agent", "skills"]) or (
len(parts) == 3 and parts[0] == "user" and parts[2] == "skills"
):
return normalized
raise ValueError(
"skill_uri must be viking://~/skills, viking://user/{user_id}/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)
memory_policy: Optional[Dict[str, Any]] = None
agent_evolution: DeprecatedUserAgentEvolutionConfig = Field(
default_factory=DeprecatedUserAgentEvolutionConfig,
exclude=True,
)
model_config = {"extra": "forbid"}
@field_validator("memory_policy", mode="before")
@classmethod
def validate_memory_policy(cls, value: Any) -> Optional[Dict[str, Any]]:
if value is None:
return None
from openviking.session.memory_policy import MemoryPolicy
return MemoryPolicy.from_dict(value).to_dict()
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
ttl_seconds: int = Field(12 * 60 * 60, ge=0)
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)