mirror of
https://github.com/MervinPraison/PraisonAI.git
synced 2026-11-08 08:11:07 -10:00
Compare commits
1
Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
e510665574 |
No files matched your search
@@ -20,19 +20,39 @@ class HealthCheckRegistry:
|
||||
|
||||
def __init__(self) -> None:
|
||||
self._checks: Dict[str, HealthCheckProtocol] = {}
|
||||
self._protected: set = set()
|
||||
self._plugins_loaded = False
|
||||
|
||||
def register_check(self, check: HealthCheckProtocol) -> None:
|
||||
def register_check(
|
||||
self, check: HealthCheckProtocol, *, protected: bool = False
|
||||
) -> None:
|
||||
"""Register a health check.
|
||||
|
||||
``protected=True`` marks the ID as owned by a trusted first-party
|
||||
surface (for example the gateway's mandatory ``core/gateway/*``
|
||||
checks). A later registration — including a discovered
|
||||
``praisonai.health_checks`` plugin — can NOT silently replace a
|
||||
protected ID, so an installed extension can never shadow a required
|
||||
security/config validation.
|
||||
"""
|
||||
check_id = getattr(check, "id", None) or getattr(check, "check_id", None)
|
||||
if not callable(getattr(check, "detect", None)):
|
||||
raise TypeError("Check must provide detect(context)")
|
||||
if not isinstance(check_id, str) or "/" not in check_id:
|
||||
raise ValueError("Health check IDs must be namespaced (for example channel/name)")
|
||||
if check_id in self._checks:
|
||||
if check_id in self._protected and not protected:
|
||||
warnings.warn(
|
||||
f"Ignoring health check {check_id!r}: this ID is reserved by a "
|
||||
"built-in check and cannot be overridden"
|
||||
)
|
||||
return
|
||||
if check_id in self._checks and check_id not in self._protected:
|
||||
warnings.warn(
|
||||
f"Duplicate health check ID {check_id!r} - replacing existing check"
|
||||
)
|
||||
self._checks[check_id] = check
|
||||
if protected:
|
||||
self._protected.add(check_id)
|
||||
|
||||
def get_checks(self) -> List[HealthCheckProtocol]:
|
||||
if not self._plugins_loaded:
|
||||
@@ -76,6 +96,13 @@ class HealthCheckRegistry:
|
||||
if fix and findings and callable(repair_fn):
|
||||
try:
|
||||
repair = repair_fn(context, findings)
|
||||
if repair is not None and not isinstance(
|
||||
repair, HealthRepairResult
|
||||
):
|
||||
raise TypeError(
|
||||
"Check repair must return HealthRepairResult or None, "
|
||||
f"got {type(repair).__name__}"
|
||||
)
|
||||
residual = list(check.detect(context))
|
||||
except Exception as exc:
|
||||
residual = list(findings) + [Finding(
|
||||
@@ -84,6 +111,10 @@ class HealthCheckRegistry:
|
||||
message=f"Health check repair failed: {exc}",
|
||||
context={"error": str(exc)},
|
||||
)]
|
||||
# Drop any malformed repair value so rendering (to_dict) can
|
||||
# never dereference fields on a non-HealthRepairResult.
|
||||
if not isinstance(repair, HealthRepairResult):
|
||||
repair = None
|
||||
results.append(HealthCheckResult(
|
||||
check_id=check_id,
|
||||
findings=findings,
|
||||
|
||||
@@ -1,5 +1,7 @@
|
||||
"""Tests for extensible runtime health checks."""
|
||||
|
||||
import warnings
|
||||
|
||||
from praisonaiagents.runtime.doctor_protocol import Finding
|
||||
from praisonaiagents.runtime.health_check import HealthRepairResult
|
||||
from praisonaiagents.runtime.health_registry import HealthCheckRegistry
|
||||
@@ -91,3 +93,87 @@ def test_registry_accepts_canonical_id_attribute():
|
||||
|
||||
assert registry.get_check_ids() == ["plugin/canonical"]
|
||||
assert registry.run({})[0].check_id == "plugin/canonical"
|
||||
|
||||
|
||||
def test_protected_check_cannot_be_shadowed():
|
||||
"""A plugin re-using a protected core ID must not override it (Greptile P1)."""
|
||||
class CoreCheck:
|
||||
check_id = "core/gateway/auth-token"
|
||||
|
||||
def detect(self, context):
|
||||
return []
|
||||
|
||||
class ImpostorPlugin:
|
||||
check_id = "core/gateway/auth-token"
|
||||
|
||||
def detect(self, context):
|
||||
return [Finding(
|
||||
rule_id=self.check_id,
|
||||
severity="error",
|
||||
message="should never run",
|
||||
)]
|
||||
|
||||
registry = HealthCheckRegistry()
|
||||
registry._plugins_loaded = True
|
||||
core = CoreCheck()
|
||||
registry.register_check(core, protected=True)
|
||||
|
||||
with warnings.catch_warnings(record=True) as caught:
|
||||
warnings.simplefilter("always")
|
||||
registry.register_check(ImpostorPlugin())
|
||||
|
||||
assert registry.get_check_ids() == ["core/gateway/auth-token"]
|
||||
assert registry._checks["core/gateway/auth-token"] is core
|
||||
assert any("reserved" in str(w.message) for w in caught)
|
||||
|
||||
|
||||
def test_protected_check_replaceable_by_protected_owner():
|
||||
"""The owning surface may re-register its own protected check idempotently."""
|
||||
class CoreCheck:
|
||||
check_id = "core/gateway/auth-token"
|
||||
|
||||
def detect(self, context):
|
||||
return []
|
||||
|
||||
registry = HealthCheckRegistry()
|
||||
registry._plugins_loaded = True
|
||||
first = CoreCheck()
|
||||
second = CoreCheck()
|
||||
registry.register_check(first, protected=True)
|
||||
|
||||
with warnings.catch_warnings(record=True) as caught:
|
||||
warnings.simplefilter("always")
|
||||
registry.register_check(second, protected=True)
|
||||
|
||||
assert registry._checks["core/gateway/auth-token"] is second
|
||||
assert not caught
|
||||
|
||||
|
||||
def test_malformed_repair_result_is_isolated():
|
||||
"""A repair returning a non-HealthRepairResult must not crash rendering (P2)."""
|
||||
class BadRepair:
|
||||
check_id = "plugin/bad-repair"
|
||||
|
||||
def detect(self, context):
|
||||
return [Finding(
|
||||
rule_id=self.check_id,
|
||||
severity="error",
|
||||
message="broken",
|
||||
)]
|
||||
|
||||
def repair(self, context, findings):
|
||||
return {"changed": True} # wrong type on purpose
|
||||
|
||||
registry = HealthCheckRegistry()
|
||||
registry._plugins_loaded = True
|
||||
registry.register_check(BadRepair())
|
||||
|
||||
result = registry.run({}, fix=True)[0]
|
||||
|
||||
assert result.error is not None
|
||||
assert "HealthRepairResult" in result.error
|
||||
assert result.repair is None
|
||||
# to_dict must not raise on a malformed repair value.
|
||||
payload = result.to_dict()
|
||||
assert payload["repair"] is None
|
||||
assert payload["repaired"] is False
|
||||
@@ -895,18 +895,40 @@ class _GatewayConfigVersionHealthCheck:
|
||||
|
||||
|
||||
def _run_gateway_health_checks(config_path: str, *, fix: bool, dry_run: bool):
|
||||
"""Run built-in and third-party checks through one shared lifecycle."""
|
||||
"""Run built-in and third-party checks through one shared lifecycle.
|
||||
|
||||
The mandatory ``core/gateway/*`` checks (auth-token strength, config-version
|
||||
migration) are registered as ``protected`` BEFORE any
|
||||
``praisonai.health_checks`` plugin is discovered, so an installed extension
|
||||
can never claim a canonical gateway ID and shadow the required
|
||||
security/config validation (Greptile P1). ``register_check`` is idempotent
|
||||
for a protected ID, so repeated ``doctor`` invocations do not re-warn.
|
||||
"""
|
||||
import warnings
|
||||
|
||||
from praisonaiagents.runtime.health_registry import get_health_check_registry
|
||||
|
||||
registry = get_health_check_registry()
|
||||
existing = set(registry.get_check_ids())
|
||||
for check in (_GatewaySecretHealthCheck(), _GatewayConfigVersionHealthCheck()):
|
||||
if check.check_id not in existing:
|
||||
registry.register_check(check)
|
||||
return registry.run(
|
||||
{"config_path": config_path, "dry_run": dry_run},
|
||||
fix=fix,
|
||||
)
|
||||
# Advisory registry warnings (plugin-discovery failures, a plugin trying to
|
||||
# shadow a protected ID) must NOT leak onto stderr here: under some Click
|
||||
# versions the CLI test runner merges stderr into stdout, which corrupts the
|
||||
# ``doctor --json`` document into an unparseable payload. The findings the
|
||||
# operator needs are already surfaced structurally in the JSON/plain report,
|
||||
# so silence the transient warnings around discovery + execution.
|
||||
with warnings.catch_warnings():
|
||||
warnings.simplefilter("ignore")
|
||||
# Register the mandatory built-ins as protected FIRST. Plugin discovery
|
||||
# runs lazily inside registry.run(); protecting the IDs here means a
|
||||
# later plugin using the same ID is refused instead of overriding it.
|
||||
for check in (
|
||||
_GatewaySecretHealthCheck(),
|
||||
_GatewayConfigVersionHealthCheck(),
|
||||
):
|
||||
registry.register_check(check, protected=True)
|
||||
return registry.run(
|
||||
{"config_path": config_path, "dry_run": dry_run},
|
||||
fix=fix,
|
||||
)
|
||||
|
||||
|
||||
def _health_result(results, check_id):
|
||||
|
||||
@@ -60,3 +60,45 @@ def test_gateway_doctor_runs_repairs_and_revalidates_registered_check(
|
||||
)
|
||||
assert plugin["repaired"] is True
|
||||
assert plugin["residual_findings"] == []
|
||||
|
||||
|
||||
def test_gateway_doctor_json_stays_pure_when_registry_warns(monkeypatch, tmp_path):
|
||||
"""Registry warnings (e.g. plugin discovery) must not corrupt --json output.
|
||||
|
||||
Under some Click versions the CLI runner merges stderr into stdout, so a
|
||||
stray ``warnings.warn`` during health-check discovery would break
|
||||
``json.loads`` (the CI JSONDecodeError). The doctor must keep stdout as a
|
||||
single valid JSON document regardless.
|
||||
"""
|
||||
import warnings as _warnings
|
||||
|
||||
typer_testing = pytest.importorskip("typer.testing")
|
||||
from praisonaiagents.gateway.config import GATEWAY_CONFIG_VERSION
|
||||
import praisonaiagents.runtime.health_registry as health_registry
|
||||
|
||||
monkeypatch.setattr(health_registry, "_default_registry", None)
|
||||
|
||||
def _noisy_discovery(self):
|
||||
_warnings.warn("Failed to load health check 'demo': boom")
|
||||
|
||||
monkeypatch.setattr(
|
||||
health_registry.HealthCheckRegistry,
|
||||
"_load_entry_points",
|
||||
_noisy_discovery,
|
||||
)
|
||||
|
||||
cfg = tmp_path / "gateway.yaml"
|
||||
cfg.write_text(
|
||||
f"config_version: {GATEWAY_CONFIG_VERSION}\nchannels: {{}}\n",
|
||||
encoding="utf-8",
|
||||
)
|
||||
|
||||
from praisonai_bot.cli.commands.gateway import app
|
||||
|
||||
result = typer_testing.CliRunner().invoke(
|
||||
app, ["doctor", "--config", str(cfg), "--json"]
|
||||
)
|
||||
|
||||
assert result.exit_code == 0, result.stdout
|
||||
payload = json.loads(result.stdout)
|
||||
assert payload["health"]["checksRun"] == 2
|
||||
Reference in new issue
Block a user