feat(webui): worker registry and configuration schema (Closes #798)
Add the declarative worker registry that epic #797 makes the source of truth for the scheduled multi-LLM worker fleet. Providers and configured workers are modelled as separate entities so a provider can be listed with no worker configured, and so provider facts are not copied into every worker record. A worker records provider, model, project, role, namespace, profile, workflow, schedule, timeout, enabled state, and scheduler metadata. Validation fails closed: unknown fields are refused rather than ignored, so a typo cannot silently disable a timeout; a worker naming an undeclared provider is rejected; worker ids, provider ids, and LaunchAgent labels must be unique. Persistence is atomic (temp file in the same directory, fsync, replace). Every superseded document is retained as a numbered revision, and rollback republishes a chosen revision as a new head, so history stays append-only and a rollback is itself reversible. The credential-rejection guard is extracted to webui/registry_safety.py so both registries share one implementation instead of two copies of a security check; project_registry.py keeps identical behaviour. Scope: data model, validation, persistence only. No routes, scheduler, process control, or provider probing - those are #799/#800/#804/#805. The workers array ships empty because populating it is #808. Tests: tests/test_webui_worker_registry.py, 44 cases. Co-Authored-By: Claude Opus 4.8 (1M context) <[email protected]>
This commit is contained in:
@@ -0,0 +1,52 @@
|
||||
"""Shared credential-rejection guard for web UI registries (#427, #798).
|
||||
|
||||
Registries are operator-editable declarative files that the web UI loads and,
|
||||
for the worker registry, writes back. None of them may ever carry a secret:
|
||||
credentials belong in the keychain and reach worker processes through
|
||||
environment injection, never through a file the browser layer can read.
|
||||
|
||||
The check is structural rather than value-based on purpose. A value scanner has
|
||||
to guess what a secret looks like; a key scanner refuses the *shape* of a
|
||||
credential field, so an operator cannot introduce one by accident and a later
|
||||
loader cannot silently pass one through.
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
from typing import Any
|
||||
|
||||
_FORBIDDEN_EXACT_KEYS = frozenset({
|
||||
"token",
|
||||
"password",
|
||||
"secret",
|
||||
"credential",
|
||||
"auth",
|
||||
"api_key",
|
||||
"api-key",
|
||||
})
|
||||
_FORBIDDEN_KEY_PREFIXES = ("auth_", "api_key_", "api-key_")
|
||||
_FORBIDDEN_KEY_SUFFIXES = ("_token", "_secret", "_password", "_credential", "_auth")
|
||||
|
||||
|
||||
def is_forbidden_key(key: str) -> bool:
|
||||
"""Return True when *key* names a credential field."""
|
||||
lowered = key.lower()
|
||||
if lowered in _FORBIDDEN_EXACT_KEYS:
|
||||
return True
|
||||
return (
|
||||
lowered.startswith(_FORBIDDEN_KEY_PREFIXES)
|
||||
or lowered.endswith(_FORBIDDEN_KEY_SUFFIXES)
|
||||
)
|
||||
|
||||
|
||||
def reject_credential_keys(obj: Any, *, path: str = "", subject: str = "registry") -> None:
|
||||
"""Raise ValueError when *obj* carries a credential-shaped key at any depth."""
|
||||
if isinstance(obj, dict):
|
||||
for key, value in obj.items():
|
||||
key_path = f"{path}.{key}" if path else key
|
||||
if is_forbidden_key(key):
|
||||
raise ValueError(f"{subject} must not store credentials ({key_path})")
|
||||
reject_credential_keys(value, path=key_path, subject=subject)
|
||||
elif isinstance(obj, list):
|
||||
for index, item in enumerate(obj):
|
||||
reject_credential_keys(item, path=f"{path}[{index}]", subject=subject)
|
||||
Reference in New Issue
Block a user