mirror of
https://github.com/HKUDS/nanobot.git
synced 2026-08-08 05:18:49 +03:00
feat(config): add actionable startup diagnostics and WebUI recovery (#5110)
This commit is contained in:
@@ -2,6 +2,7 @@ import json
|
||||
|
||||
import pytest
|
||||
|
||||
from nanobot.config.errors import ConfigLoadError
|
||||
from nanobot.config.loader import load_config
|
||||
from nanobot.config.schema import ApiConfig
|
||||
|
||||
@@ -12,13 +13,35 @@ def test_load_config_missing_file_uses_defaults(tmp_path) -> None:
|
||||
assert config.agents.defaults.model
|
||||
|
||||
|
||||
def test_load_config_reports_malformed_environment_safely(
|
||||
tmp_path,
|
||||
monkeypatch,
|
||||
) -> None:
|
||||
config_path = tmp_path / "missing.json"
|
||||
invalid_value = "sensitive-not-json"
|
||||
monkeypatch.setenv("NANOBOT_PROVIDERS", invalid_value)
|
||||
|
||||
with pytest.raises(ConfigLoadError) as exc_info:
|
||||
load_config(config_path)
|
||||
|
||||
error = exc_info.value
|
||||
assert error.kind == "invalid_schema"
|
||||
assert error.path == config_path
|
||||
assert "complex NANOBOT_* values use valid JSON" in str(error)
|
||||
assert invalid_value not in str(error)
|
||||
|
||||
|
||||
def test_load_config_invalid_json_fails_fast(tmp_path) -> None:
|
||||
config_path = tmp_path / "config.json"
|
||||
config_path.write_text("{broken json", encoding="utf-8")
|
||||
|
||||
with pytest.raises(ValueError, match="Failed to load config"):
|
||||
with pytest.raises(ConfigLoadError) as exc_info:
|
||||
load_config(config_path)
|
||||
|
||||
error = exc_info.value
|
||||
assert error.kind == "invalid_json"
|
||||
assert "line 1, column 2" in str(error)
|
||||
|
||||
|
||||
def test_load_config_invalid_schema_fails_fast(tmp_path) -> None:
|
||||
config_path = tmp_path / "config.json"
|
||||
@@ -27,9 +50,113 @@ def test_load_config_invalid_schema_fails_fast(tmp_path) -> None:
|
||||
encoding="utf-8",
|
||||
)
|
||||
|
||||
with pytest.raises(ValueError, match="Failed to load config"):
|
||||
with pytest.raises(ConfigLoadError) as exc_info:
|
||||
load_config(config_path)
|
||||
|
||||
error = exc_info.value
|
||||
message = str(error)
|
||||
assert error.kind == "invalid_schema"
|
||||
assert "tools.exec.timeout" in message
|
||||
assert "Must be greater than or equal to 0." in message
|
||||
assert "input_value" not in message
|
||||
assert "errors.pydantic.dev" not in message
|
||||
|
||||
|
||||
@pytest.mark.parametrize(
|
||||
("content", "root_type"),
|
||||
[("[]", "list"), ("null", "NoneType"), ('"value"', "str")],
|
||||
)
|
||||
def test_load_config_rejects_non_object_root(tmp_path, content: str, root_type: str) -> None:
|
||||
config_path = tmp_path / "config.json"
|
||||
config_path.write_text(content, encoding="utf-8")
|
||||
|
||||
with pytest.raises(ConfigLoadError) as exc_info:
|
||||
load_config(config_path)
|
||||
|
||||
error = exc_info.value
|
||||
assert error.kind == "invalid_root"
|
||||
assert f"Expected an object, but found {root_type}." in str(error)
|
||||
|
||||
|
||||
def test_load_config_error_does_not_expose_invalid_secret_value(tmp_path) -> None:
|
||||
config_path = tmp_path / "config.json"
|
||||
secret = "should-never-appear"
|
||||
config_path.write_text(
|
||||
json.dumps({"providers": {"openrouter": {"apiKey": [secret]}}}),
|
||||
encoding="utf-8",
|
||||
)
|
||||
|
||||
with pytest.raises(ConfigLoadError) as exc_info:
|
||||
load_config(config_path)
|
||||
|
||||
assert secret not in str(exc_info.value)
|
||||
|
||||
|
||||
def test_load_config_error_redacts_untrusted_location_parts(tmp_path) -> None:
|
||||
config_path = tmp_path / "config.json"
|
||||
secret = "should-never-appear-in-location"
|
||||
server_name = f"https://user:{secret}@example.test"
|
||||
config_path.write_text(
|
||||
json.dumps(
|
||||
{
|
||||
"tools": {
|
||||
"mcpServers": {
|
||||
server_name: {"toolTimeout": "not-a-number"},
|
||||
}
|
||||
}
|
||||
}
|
||||
),
|
||||
encoding="utf-8",
|
||||
)
|
||||
|
||||
with pytest.raises(ConfigLoadError) as exc_info:
|
||||
load_config(config_path)
|
||||
|
||||
message = str(exc_info.value)
|
||||
assert "tools.mcpServers.<redacted>.toolTimeout" in message
|
||||
assert server_name not in message
|
||||
assert secret not in message
|
||||
|
||||
|
||||
def test_load_config_error_does_not_trust_custom_validator_message(tmp_path) -> None:
|
||||
config_path = tmp_path / "config.json"
|
||||
secret = "diagnostic-secret-should-not-print"
|
||||
config_path.write_text(
|
||||
json.dumps({"providers": {"openrouter": {"thinkingStyle": secret}}}),
|
||||
encoding="utf-8",
|
||||
)
|
||||
|
||||
with pytest.raises(ConfigLoadError) as exc_info:
|
||||
load_config(config_path)
|
||||
|
||||
message = str(exc_info.value)
|
||||
assert "providers.openrouter.thinkingStyle" in message
|
||||
assert "Value does not satisfy this setting's requirements." in message
|
||||
assert secret not in message
|
||||
|
||||
|
||||
@pytest.mark.parametrize(
|
||||
"tools",
|
||||
[
|
||||
[],
|
||||
{"exec": []},
|
||||
{"my": 1, "myEnabled": True},
|
||||
],
|
||||
)
|
||||
def test_load_config_malformed_legacy_sections_use_structured_error(
|
||||
tmp_path,
|
||||
tools: object,
|
||||
) -> None:
|
||||
config_path = tmp_path / "config.json"
|
||||
config_path.write_text(json.dumps({"tools": tools}), encoding="utf-8")
|
||||
|
||||
with pytest.raises(ConfigLoadError) as exc_info:
|
||||
load_config(config_path)
|
||||
|
||||
error = exc_info.value
|
||||
assert error.kind == "invalid_schema"
|
||||
assert "tools" in str(error)
|
||||
|
||||
|
||||
@pytest.mark.parametrize("host", ["0.0.0.0", "::"])
|
||||
def test_api_config_requires_key_for_wildcard_hosts(host: str) -> None:
|
||||
|
||||
@@ -2,6 +2,7 @@ import json
|
||||
|
||||
import pytest
|
||||
|
||||
from nanobot.config.errors import ConfigLoadError
|
||||
from nanobot.config.loader import (
|
||||
_resolve_env_vars,
|
||||
load_config,
|
||||
@@ -66,6 +67,22 @@ class TestResolveConfig:
|
||||
resolved = resolve_config_env_vars(raw)
|
||||
assert resolved.providers.groq.api_key == "resolved-key"
|
||||
|
||||
def test_missing_env_var_reports_config_field(self, tmp_path, monkeypatch):
|
||||
name = "NANOBOT_TEST_MISSING_PROVIDER_KEY"
|
||||
monkeypatch.delenv(name, raising=False)
|
||||
config_path = tmp_path / "config.json"
|
||||
config = Config.model_validate(
|
||||
{"providers": {"openrouter": {"apiKey": f"${{{name}}}"}}}
|
||||
)
|
||||
|
||||
with pytest.raises(ConfigLoadError) as exc_info:
|
||||
resolve_config_env_vars(config, config_path=config_path)
|
||||
|
||||
error = exc_info.value
|
||||
assert error.kind == "missing_env"
|
||||
assert "providers.openrouter.apiKey" in str(error)
|
||||
assert name in str(error)
|
||||
|
||||
def test_save_preserves_templates(self, tmp_path, monkeypatch):
|
||||
monkeypatch.setenv("MY_TOKEN", "real-token")
|
||||
config_path = tmp_path / "config.json"
|
||||
|
||||
@@ -1,7 +1,10 @@
|
||||
import json
|
||||
import warnings
|
||||
|
||||
import pytest
|
||||
|
||||
from nanobot.agent.model_presets import load_model_preset_catalog
|
||||
from nanobot.config.errors import ConfigLoadError
|
||||
from nanobot.config.schema import Config
|
||||
|
||||
|
||||
@@ -16,6 +19,24 @@ def test_resolve_preset_returns_defaults_when_no_preset() -> None:
|
||||
assert resolved.reasoning_effort == config.agents.defaults.reasoning_effort
|
||||
|
||||
|
||||
def test_model_preset_catalog_missing_env_reports_explicit_config_path(
|
||||
tmp_path,
|
||||
monkeypatch,
|
||||
) -> None:
|
||||
name = "NANOBOT_TEST_CATALOG_MISSING_KEY"
|
||||
monkeypatch.delenv(name, raising=False)
|
||||
config_path = tmp_path / "custom.json"
|
||||
config_path.write_text(
|
||||
json.dumps({"providers": {"openrouter": {"apiKey": f"${{{name}}}"}}}),
|
||||
encoding="utf-8",
|
||||
)
|
||||
|
||||
with pytest.raises(ConfigLoadError) as exc_info:
|
||||
load_model_preset_catalog(config_path)
|
||||
|
||||
assert exc_info.value.path == config_path
|
||||
|
||||
|
||||
def test_agent_timezone_rejects_unknown_iana_name() -> None:
|
||||
with pytest.raises(ValueError, match="unknown timezone"):
|
||||
Config.model_validate({"agents": {"defaults": {"timezone": "Not/AZone"}}})
|
||||
|
||||
Reference in New Issue
Block a user