mirror of
https://github.com/HKUDS/nanobot.git
synced 2026-08-07 21:08:34 +03:00
feat(config): add actionable startup diagnostics and WebUI recovery (#5110)
This commit is contained in:
@@ -1,4 +1,5 @@
|
||||
import asyncio
|
||||
import json
|
||||
from pathlib import Path
|
||||
from types import SimpleNamespace
|
||||
from unittest.mock import MagicMock
|
||||
@@ -8,6 +9,7 @@ import pytest
|
||||
from nanobot.agent.loop import AgentLoop
|
||||
from nanobot.bus.queue import MessageBus
|
||||
from nanobot.bus.runtime_events import RuntimeModelChanged
|
||||
from nanobot.config.errors import ConfigLoadError
|
||||
from nanobot.config.loader import save_config
|
||||
from nanobot.config.schema import Config, ModelPresetConfig
|
||||
from nanobot.providers.base import GenerationSettings
|
||||
@@ -127,6 +129,24 @@ def test_llm_runtime_surfaces_invalidated_config_errors(tmp_path: Path) -> None:
|
||||
loop.llm_runtime()
|
||||
|
||||
|
||||
def test_provider_snapshot_missing_env_reports_explicit_config_path(
|
||||
tmp_path: Path,
|
||||
monkeypatch,
|
||||
) -> None:
|
||||
name = "NANOBOT_TEST_REFRESH_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_provider_snapshot(config_path)
|
||||
|
||||
assert exc_info.value.path == config_path
|
||||
|
||||
|
||||
def test_same_snapshot_default_clears_preset_and_publishes_update(tmp_path: Path) -> None:
|
||||
base_provider = _provider("base-model")
|
||||
fast_provider = _provider("fast-model")
|
||||
|
||||
@@ -1833,12 +1833,10 @@ def _test_provider_snapshot(provider: object, config: Config) -> ProviderSnapsho
|
||||
|
||||
|
||||
def _patch_webui_provider_ready(monkeypatch) -> None:
|
||||
provider = _fake_provider()
|
||||
|
||||
def _snapshot(config: Config, **_kwargs) -> ProviderSnapshot:
|
||||
return _test_provider_snapshot(provider, config)
|
||||
|
||||
monkeypatch.setattr("nanobot.providers.factory.build_provider_snapshot", _snapshot)
|
||||
monkeypatch.setattr(
|
||||
"nanobot.providers.factory.validate_provider_setup",
|
||||
lambda _config: None,
|
||||
)
|
||||
|
||||
|
||||
def _patch_gateway_ports_free(monkeypatch) -> None:
|
||||
@@ -1883,6 +1881,10 @@ def _patch_cli_command_runtime(
|
||||
"nanobot.providers.factory.load_provider_snapshot",
|
||||
lambda _config_path=None: _test_provider_snapshot(provider_factory(config), config),
|
||||
)
|
||||
monkeypatch.setattr(
|
||||
"nanobot.cli.commands._provider_setup_error",
|
||||
lambda _config: None,
|
||||
)
|
||||
_patch_gateway_ports_free(monkeypatch)
|
||||
|
||||
if message_bus is not None:
|
||||
@@ -2116,6 +2118,9 @@ def test_webui_missing_runtime_env_fails_before_starting_gateway(
|
||||
|
||||
assert result.exit_code == 1
|
||||
assert missing_env in result.stdout
|
||||
assert "nanobot status --config" in result.stdout
|
||||
assert config_file.name in result.stdout
|
||||
assert "Traceback" not in result.stdout
|
||||
assert f"${{{missing_env}}}" in config_file.read_text(encoding="utf-8")
|
||||
|
||||
|
||||
@@ -2145,6 +2150,10 @@ def test_webui_yes_still_refuses_invalid_custom_model_setup(
|
||||
|
||||
assert result.exit_code == 1
|
||||
assert "provider/model setup is incomplete" in result.stdout
|
||||
assert "Settings → Models" in result.stdout
|
||||
assert "nanobot onboard --wizard" in result.stdout
|
||||
assert "nanobot status --config" in result.stdout
|
||||
assert config_file.name in result.stdout
|
||||
|
||||
|
||||
def test_webui_background_starts_runtime_and_opens_browser(monkeypatch, tmp_path: Path) -> None:
|
||||
@@ -2564,6 +2573,7 @@ def test_gateway_unbound_agent_cron_is_skipped(
|
||||
monkeypatch.setattr("nanobot.config.loader.load_config", lambda _path=None: config)
|
||||
monkeypatch.setattr("nanobot.cli.commands.sync_workspace_templates", lambda _path: None)
|
||||
monkeypatch.setattr("nanobot.providers.factory.make_provider", lambda _config: provider)
|
||||
monkeypatch.setattr("nanobot.cli.commands._provider_setup_error", lambda _config: None)
|
||||
_patch_gateway_ports_free(monkeypatch)
|
||||
monkeypatch.setattr(
|
||||
"nanobot.providers.factory.build_provider_snapshot",
|
||||
@@ -2691,6 +2701,7 @@ def test_gateway_bound_cron_runs_as_session_turn(
|
||||
monkeypatch.setattr("nanobot.config.loader.load_config", lambda _path=None: config)
|
||||
monkeypatch.setattr("nanobot.cli.commands.sync_workspace_templates", lambda _path: None)
|
||||
monkeypatch.setattr("nanobot.providers.factory.make_provider", lambda _config: provider)
|
||||
monkeypatch.setattr("nanobot.cli.commands._provider_setup_error", lambda _config: None)
|
||||
_patch_gateway_ports_free(monkeypatch)
|
||||
monkeypatch.setattr(
|
||||
"nanobot.providers.factory.build_provider_snapshot",
|
||||
@@ -2997,6 +3008,8 @@ def test_gateway_local_trigger_queue_submits_agent_turns(
|
||||
agent_kwargs = seen["agent_from_config_kwargs"]
|
||||
kwargs = seen["local_trigger_queue_kwargs"]
|
||||
assert isinstance(agent_kwargs["provider"], UnconfiguredProvider) is bool(setup_error)
|
||||
refreshed_snapshot = agent_kwargs["provider_snapshot_loader"]()
|
||||
assert not isinstance(refreshed_snapshot.provider, UnconfiguredProvider)
|
||||
assert "local_trigger_store" in agent_kwargs
|
||||
assert kwargs["store"] is agent_kwargs["local_trigger_store"]
|
||||
assert "bus" not in kwargs
|
||||
|
||||
@@ -0,0 +1,539 @@
|
||||
import json
|
||||
|
||||
import pytest
|
||||
from typer.testing import CliRunner
|
||||
|
||||
from nanobot.cli.commands import app
|
||||
from nanobot.gateway import GatewayRuntime, GatewayStartOptions, GatewayStatus, RuntimeResult
|
||||
|
||||
runner = CliRunner()
|
||||
|
||||
_ANTHROPIC_BACKEND_CASES = (
|
||||
("anthropic", "anthropic", "claude-sonnet-4-5", "ANTHROPIC_API_KEY", "Anthropic"),
|
||||
("kimi_coding", "kimiCoding", "kimi-for-coding", "KIMI_CODING_API_KEY", "Kimi Coding"),
|
||||
(
|
||||
"minimax_anthropic",
|
||||
"minimaxAnthropic",
|
||||
"MiniMax-M2.7-highspeed",
|
||||
"MINIMAX_API_KEY",
|
||||
"MiniMax (Anthropic)",
|
||||
),
|
||||
)
|
||||
|
||||
|
||||
def _without_rendered_line_breaks(output: str) -> str:
|
||||
return "".join(output.splitlines())
|
||||
|
||||
|
||||
def _write_ready_config(config_path, *, channels: dict | None = None) -> None:
|
||||
config_path.write_text(
|
||||
json.dumps(
|
||||
{
|
||||
"agents": {
|
||||
"defaults": {
|
||||
"model": "ollama/llama3.2",
|
||||
"provider": "ollama",
|
||||
}
|
||||
},
|
||||
"providers": {
|
||||
"ollama": {
|
||||
"apiBase": "http://localhost:11434/v1",
|
||||
}
|
||||
},
|
||||
"channels": channels or {},
|
||||
}
|
||||
),
|
||||
encoding="utf-8",
|
||||
)
|
||||
|
||||
|
||||
def test_status_reports_ready_provider_and_next_step(tmp_path) -> None:
|
||||
config_path = tmp_path / "config.json"
|
||||
_write_ready_config(config_path)
|
||||
|
||||
result = runner.invoke(app, ["status", "--config", str(config_path)])
|
||||
|
||||
assert result.exit_code == 0
|
||||
assert "Agent: ✓ provider/model configuration is ready" in result.stdout
|
||||
assert "Ollama:" in result.stdout
|
||||
assert "Model: ollama/llama3.2" in result.stdout
|
||||
assert 'nanobot agent -m "Hello!"' in result.stdout
|
||||
assert "Status does not call the model" in result.stdout
|
||||
|
||||
|
||||
def test_status_validates_bedrock_without_constructing_provider(
|
||||
tmp_path,
|
||||
monkeypatch,
|
||||
) -> None:
|
||||
from nanobot.providers.bedrock_provider import BedrockProvider
|
||||
|
||||
config_path = tmp_path / "config.json"
|
||||
config_path.write_text(
|
||||
json.dumps(
|
||||
{
|
||||
"agents": {
|
||||
"defaults": {
|
||||
"model": "bedrock/amazon.nova-lite-v1:0",
|
||||
"provider": "bedrock",
|
||||
}
|
||||
},
|
||||
"providers": {"bedrock": {"region": "us-east-1"}},
|
||||
}
|
||||
),
|
||||
encoding="utf-8",
|
||||
)
|
||||
|
||||
def _unexpected_init(*_args, **_kwargs) -> None:
|
||||
pytest.fail("status must not construct a provider client")
|
||||
|
||||
monkeypatch.setattr(BedrockProvider, "__init__", _unexpected_init)
|
||||
|
||||
result = runner.invoke(app, ["status", "--config", str(config_path)])
|
||||
|
||||
assert result.exit_code == 0
|
||||
assert "Agent: ✓ provider/model configuration is ready" in result.stdout
|
||||
assert "Status does not call the model or verify network access" in result.stdout
|
||||
|
||||
|
||||
@pytest.mark.parametrize(
|
||||
("provider", "provider_key", "model", "env_name", "label"),
|
||||
_ANTHROPIC_BACKEND_CASES,
|
||||
)
|
||||
def test_status_reports_missing_key_for_anthropic_backends(
|
||||
tmp_path,
|
||||
monkeypatch,
|
||||
provider: str,
|
||||
provider_key: str,
|
||||
model: str,
|
||||
env_name: str,
|
||||
label: str,
|
||||
) -> None:
|
||||
monkeypatch.delenv("ANTHROPIC_API_KEY", raising=False)
|
||||
monkeypatch.delenv(env_name, raising=False)
|
||||
config_path = tmp_path / "config.json"
|
||||
config_path.write_text(
|
||||
json.dumps(
|
||||
{
|
||||
"agents": {"defaults": {"model": model, "provider": provider}},
|
||||
"providers": {provider_key: {}},
|
||||
}
|
||||
),
|
||||
encoding="utf-8",
|
||||
)
|
||||
|
||||
result = runner.invoke(app, ["status", "--config", str(config_path)])
|
||||
output = _without_rendered_line_breaks(result.stdout)
|
||||
|
||||
assert result.exit_code == 0
|
||||
assert f"Agent: ✗ No API key configured for provider '{provider}'." in output
|
||||
assert f"{label}: not set" in output
|
||||
assert "provider/model configuration is ready" not in output
|
||||
assert 'Next: nanobot agent -m "Hello!"' not in output
|
||||
assert "Settings → Models" in output
|
||||
|
||||
|
||||
@pytest.mark.parametrize(
|
||||
("provider", "provider_key", "model", "env_name", "label"),
|
||||
_ANTHROPIC_BACKEND_CASES,
|
||||
)
|
||||
def test_status_accepts_resolved_key_for_anthropic_backends(
|
||||
tmp_path,
|
||||
monkeypatch,
|
||||
provider: str,
|
||||
provider_key: str,
|
||||
model: str,
|
||||
env_name: str,
|
||||
label: str,
|
||||
) -> None:
|
||||
monkeypatch.setenv(env_name, "test-api-key")
|
||||
config_path = tmp_path / "config.json"
|
||||
config_path.write_text(
|
||||
json.dumps(
|
||||
{
|
||||
"agents": {"defaults": {"model": model, "provider": provider}},
|
||||
"providers": {provider_key: {"apiKey": f"${{{env_name}}}"}},
|
||||
}
|
||||
),
|
||||
encoding="utf-8",
|
||||
)
|
||||
|
||||
result = runner.invoke(app, ["status", "--config", str(config_path)])
|
||||
|
||||
assert result.exit_code == 0
|
||||
assert "Agent: ✓ provider/model configuration is ready" in result.stdout
|
||||
assert f"{label}: ✓" in result.stdout
|
||||
assert 'nanobot agent -m "Hello!"' in result.stdout
|
||||
|
||||
|
||||
def test_status_reports_missing_provider_with_shortest_setup_routes(tmp_path) -> None:
|
||||
config_path = tmp_path / "config.json"
|
||||
config_path.write_text("{}", encoding="utf-8")
|
||||
|
||||
result = runner.invoke(app, ["status", "--config", str(config_path)])
|
||||
|
||||
assert result.exit_code == 0
|
||||
assert "Agent: ✗" in result.stdout
|
||||
assert "No provider is configured for model" in result.stdout
|
||||
assert "Settings → Models" in result.stdout
|
||||
assert "nanobot onboard --wizard" in result.stdout
|
||||
assert "nanobot status --config" in result.stdout
|
||||
|
||||
|
||||
def test_status_readiness_does_not_validate_channel_configuration(tmp_path) -> None:
|
||||
config_path = tmp_path / "config.json"
|
||||
_write_ready_config(
|
||||
config_path,
|
||||
channels={"websocket": {"enabled": False, "path": "missing-slash"}},
|
||||
)
|
||||
|
||||
result = runner.invoke(app, ["status", "--config", str(config_path)])
|
||||
|
||||
assert result.exit_code == 0
|
||||
assert "Agent: ✓ provider/model configuration is ready" in result.stdout
|
||||
assert "channels.websocket" not in result.stdout
|
||||
|
||||
|
||||
def test_status_reports_json_location_without_traceback(tmp_path) -> None:
|
||||
config_path = tmp_path / "config.json"
|
||||
config_path.write_text("{broken", encoding="utf-8")
|
||||
|
||||
result = runner.invoke(app, ["status", "--config", str(config_path)])
|
||||
|
||||
assert result.exit_code == 1
|
||||
assert "Invalid configuration" in result.stdout
|
||||
assert "JSON syntax error at line 1, column 2" in result.stdout
|
||||
assert "Traceback" not in result.stdout
|
||||
|
||||
|
||||
def test_status_reports_field_without_exposing_secret(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",
|
||||
)
|
||||
|
||||
result = runner.invoke(app, ["status", "--config", str(config_path)])
|
||||
|
||||
assert result.exit_code == 1
|
||||
assert "providers.openrouter.apiKey" in result.stdout
|
||||
assert secret not in result.stdout
|
||||
assert "input_value" not in result.stdout
|
||||
assert "errors.pydantic.dev" not in result.stdout
|
||||
|
||||
|
||||
def test_status_reports_missing_env_var_at_field(tmp_path, monkeypatch) -> None:
|
||||
name = "NANOBOT_TEST_STATUS_MISSING"
|
||||
monkeypatch.delenv(name, raising=False)
|
||||
config_path = tmp_path / "config.json"
|
||||
config_path.write_text(
|
||||
json.dumps({"providers": {"openrouter": {"apiKey": f"${{{name}}}"}}}),
|
||||
encoding="utf-8",
|
||||
)
|
||||
|
||||
result = runner.invoke(app, ["status", "--config", str(config_path)])
|
||||
|
||||
assert result.exit_code == 0
|
||||
assert "providers.openrouter.apiKey" in result.stdout
|
||||
assert name in result.stdout
|
||||
assert "OpenRouter: not set" in result.stdout
|
||||
assert "OpenRouter: ✓" not in result.stdout
|
||||
|
||||
|
||||
def test_webui_reports_malformed_environment_config_without_traceback(
|
||||
tmp_path,
|
||||
monkeypatch,
|
||||
) -> None:
|
||||
config_path = tmp_path / "missing.json"
|
||||
invalid_value = "sensitive-not-json"
|
||||
monkeypatch.setenv("NANOBOT_PROVIDERS", invalid_value)
|
||||
|
||||
result = runner.invoke(
|
||||
app,
|
||||
["webui", "--config", str(config_path), "--yes", "--no-open"],
|
||||
)
|
||||
|
||||
assert result.exit_code == 1
|
||||
assert isinstance(result.exception, SystemExit)
|
||||
assert "Environment-based configuration could not be parsed" in result.stdout
|
||||
assert "nanobot status --config" in result.stdout
|
||||
assert invalid_value not in result.stdout
|
||||
assert not config_path.exists()
|
||||
|
||||
|
||||
@pytest.mark.parametrize(
|
||||
"args",
|
||||
[
|
||||
["webui", "--yes", "--no-open"],
|
||||
["agent", "--message", "hello"],
|
||||
],
|
||||
)
|
||||
def test_agent_entrypoints_point_invalid_config_to_status(tmp_path, args: list[str]) -> None:
|
||||
config_path = tmp_path / "config.json"
|
||||
config_path.write_text("{broken", encoding="utf-8")
|
||||
|
||||
result = runner.invoke(app, [*args, "--config", str(config_path)])
|
||||
|
||||
assert result.exit_code == 1
|
||||
assert "Invalid configuration" in result.stdout
|
||||
assert "nanobot status --config" in result.stdout
|
||||
assert "Traceback" not in result.stdout
|
||||
|
||||
|
||||
def test_agent_provider_setup_failure_points_to_shortest_routes(tmp_path) -> None:
|
||||
config_path = tmp_path / "config.json"
|
||||
workspace = tmp_path / "workspace"
|
||||
config_path.write_text(
|
||||
json.dumps({"agents": {"defaults": {"workspace": str(workspace)}}}),
|
||||
encoding="utf-8",
|
||||
)
|
||||
|
||||
result = runner.invoke(
|
||||
app,
|
||||
["agent", "--message", "hello", "--config", str(config_path)],
|
||||
)
|
||||
output = _without_rendered_line_breaks(result.stdout)
|
||||
|
||||
assert result.exit_code == 1
|
||||
assert "Agent cannot start: No provider is configured for model" in output
|
||||
assert "Settings → Models" in output
|
||||
assert "nanobot onboard --wizard" in output
|
||||
assert "nanobot status --config" in output
|
||||
assert "Traceback" not in output
|
||||
assert not workspace.exists()
|
||||
|
||||
|
||||
@pytest.mark.parametrize(
|
||||
"args",
|
||||
[
|
||||
["gateway"],
|
||||
["gateway", "--background"],
|
||||
["gateway", "restart"],
|
||||
],
|
||||
)
|
||||
def test_gateway_provider_setup_failure_points_to_shortest_routes_when_webui_disabled(
|
||||
tmp_path,
|
||||
monkeypatch,
|
||||
args: list[str],
|
||||
) -> None:
|
||||
config_path = tmp_path / "explicit-gateway-config.json"
|
||||
workspace = tmp_path / "workspace"
|
||||
config_path.write_text(
|
||||
json.dumps(
|
||||
{
|
||||
"agents": {"defaults": {"workspace": str(workspace)}},
|
||||
"channels": {"websocket": {"enabled": False}},
|
||||
}
|
||||
),
|
||||
encoding="utf-8",
|
||||
)
|
||||
|
||||
def unexpected_managed_start(*_args, **_kwargs) -> RuntimeResult:
|
||||
pytest.fail("provider validation must fail before a managed gateway start")
|
||||
|
||||
monkeypatch.setattr(GatewayRuntime, "start_background", unexpected_managed_start)
|
||||
monkeypatch.setattr(GatewayRuntime, "restart", unexpected_managed_start)
|
||||
|
||||
result = runner.invoke(app, [*args, "--config", str(config_path)])
|
||||
output = _without_rendered_line_breaks(result.stdout)
|
||||
|
||||
assert result.exit_code == 1
|
||||
assert "Gateway cannot start: No provider is configured for model" in output
|
||||
assert "Settings → Models" in output
|
||||
assert "nanobot onboard --wizard" in output
|
||||
assert "nanobot status --config" in output
|
||||
assert config_path.name in output
|
||||
assert "Traceback" not in output
|
||||
assert not workspace.exists()
|
||||
|
||||
|
||||
@pytest.mark.parametrize(
|
||||
("args", "start_mode"),
|
||||
[
|
||||
(["gateway", "--background"], "background"),
|
||||
(["gateway", "restart"], "restart"),
|
||||
],
|
||||
)
|
||||
@pytest.mark.parametrize("secret_field", ["tokenIssueSecret", "token"])
|
||||
def test_gateway_missing_provider_managed_start_for_webui_setup(
|
||||
tmp_path,
|
||||
monkeypatch,
|
||||
args: list[str],
|
||||
start_mode: str,
|
||||
secret_field: str,
|
||||
) -> None:
|
||||
config_path = tmp_path / "explicit-gateway-config.json"
|
||||
workspace = tmp_path / "workspace"
|
||||
webui_port = 18776
|
||||
bootstrap_secret = "must-not-appear-in-gateway-output"
|
||||
config_path.write_text(
|
||||
json.dumps(
|
||||
{
|
||||
"agents": {"defaults": {"workspace": str(workspace)}},
|
||||
"channels": {
|
||||
"websocket": {
|
||||
"enabled": True,
|
||||
"host": "127.0.0.1",
|
||||
"port": webui_port,
|
||||
secret_field: bootstrap_secret,
|
||||
}
|
||||
},
|
||||
}
|
||||
),
|
||||
encoding="utf-8",
|
||||
)
|
||||
started_options: list[tuple[str, GatewayStartOptions]] = []
|
||||
status = GatewayStatus(
|
||||
running=True,
|
||||
pid=12345,
|
||||
state_path=tmp_path / "gateway.json",
|
||||
log_path=tmp_path / "gateway.log",
|
||||
started_at="2026-07-28T00:00:00Z",
|
||||
port=18790,
|
||||
reason="running",
|
||||
)
|
||||
|
||||
def fake_start_background(
|
||||
_runtime: GatewayRuntime,
|
||||
options: GatewayStartOptions,
|
||||
) -> RuntimeResult:
|
||||
started_options.append(("background", options))
|
||||
return RuntimeResult(True, "gateway_started_background", status)
|
||||
|
||||
def fake_restart(
|
||||
_runtime: GatewayRuntime,
|
||||
options: GatewayStartOptions,
|
||||
*,
|
||||
timeout_s: int,
|
||||
) -> RuntimeResult:
|
||||
assert timeout_s == 20
|
||||
started_options.append(("restart", options))
|
||||
return RuntimeResult(True, "gateway_started_background", status)
|
||||
|
||||
monkeypatch.setattr(GatewayRuntime, "start_background", fake_start_background)
|
||||
monkeypatch.setattr(GatewayRuntime, "restart", fake_restart)
|
||||
monkeypatch.setattr(
|
||||
"nanobot.cli.commands.ensure_webui_bundle",
|
||||
lambda **_kwargs: None,
|
||||
)
|
||||
|
||||
result = runner.invoke(
|
||||
app,
|
||||
[*args, "--config", str(config_path)],
|
||||
)
|
||||
output = _without_rendered_line_breaks(result.stdout)
|
||||
|
||||
assert result.exit_code == 0
|
||||
assert "Provider/model setup is incomplete: No provider is configured for model" in output
|
||||
assert "Gateway will start so you can configure a provider and model" in output
|
||||
assert "WebUI Settings" in output
|
||||
assert "Models." in output
|
||||
assert f"WebUI: http://127.0.0.1:{webui_port}" in output
|
||||
assert f"channels.websocket.{secret_field}" in output
|
||||
if secret_field == "token":
|
||||
assert "channels.websocket.tokenIssueSecret" not in output
|
||||
assert "bootstrapSecret" not in output
|
||||
assert bootstrap_secret not in output
|
||||
assert "Gateway cannot start" not in output
|
||||
assert started_options == [
|
||||
(
|
||||
start_mode,
|
||||
GatewayStartOptions(
|
||||
port=18790,
|
||||
config_path=str(config_path.resolve()),
|
||||
),
|
||||
)
|
||||
]
|
||||
assert not workspace.exists()
|
||||
|
||||
|
||||
def test_gateway_invalid_webui_config_blocks_unconfigured_setup_mode(tmp_path) -> None:
|
||||
config_path = tmp_path / "invalid-webui-config.json"
|
||||
workspace = tmp_path / "workspace"
|
||||
config_path.write_text(
|
||||
json.dumps(
|
||||
{
|
||||
"agents": {"defaults": {"workspace": str(workspace)}},
|
||||
"channels": {
|
||||
"websocket": {
|
||||
"enabled": True,
|
||||
"port": "not-a-port",
|
||||
}
|
||||
},
|
||||
}
|
||||
),
|
||||
encoding="utf-8",
|
||||
)
|
||||
|
||||
result = runner.invoke(app, ["gateway", "--config", str(config_path)])
|
||||
output = _without_rendered_line_breaks(result.stdout)
|
||||
|
||||
assert result.exit_code == 1
|
||||
assert "Gateway configuration is invalid." in output
|
||||
assert "channels.websocket.port" in output
|
||||
assert "Provider/model setup is incomplete" not in output
|
||||
assert "Traceback" not in output
|
||||
assert not workspace.exists()
|
||||
|
||||
|
||||
@pytest.mark.parametrize(
|
||||
("args", "summary", "retry_command"),
|
||||
[
|
||||
(
|
||||
["webui", "--yes", "--no-open"],
|
||||
"WebUI configuration is invalid.",
|
||||
"nanobot webui --config",
|
||||
),
|
||||
(
|
||||
["gateway"],
|
||||
"Gateway configuration is invalid.",
|
||||
"nanobot gateway --config",
|
||||
),
|
||||
],
|
||||
)
|
||||
def test_runtime_config_validation_is_redacted_and_actionable(
|
||||
tmp_path,
|
||||
args: list[str],
|
||||
summary: str,
|
||||
retry_command: str,
|
||||
) -> None:
|
||||
config_path = tmp_path / "explicit-runtime-config.json"
|
||||
workspace = tmp_path / "workspace"
|
||||
invalid_value = "sensitive-not-a-port"
|
||||
_write_ready_config(
|
||||
config_path,
|
||||
channels={
|
||||
"websocket": {
|
||||
"enabled": True,
|
||||
"port": invalid_value,
|
||||
}
|
||||
},
|
||||
)
|
||||
data = json.loads(config_path.read_text(encoding="utf-8"))
|
||||
data["agents"]["defaults"]["workspace"] = str(workspace)
|
||||
config_path.write_text(json.dumps(data), encoding="utf-8")
|
||||
|
||||
result = runner.invoke(app, [*args, "--config", str(config_path)])
|
||||
output = _without_rendered_line_breaks(result.stdout)
|
||||
|
||||
assert result.exit_code == 1
|
||||
assert summary in output
|
||||
assert "channels.websocket.port" in output
|
||||
assert retry_command in output
|
||||
assert config_path.name in output
|
||||
assert invalid_value not in output
|
||||
assert "input_value" not in output
|
||||
assert "errors.pydantic.dev" not in output
|
||||
assert "Traceback" not in output
|
||||
assert not workspace.exists()
|
||||
|
||||
|
||||
def test_status_missing_file_points_to_setup_without_changing_exit_contract(tmp_path) -> None:
|
||||
config_path = tmp_path / "missing.json"
|
||||
|
||||
result = runner.invoke(app, ["status", "--config", str(config_path)])
|
||||
|
||||
assert result.exit_code == 0
|
||||
assert "configuration file not found" in result.stdout
|
||||
assert "nanobot webui" in result.stdout
|
||||
assert "nanobot onboard --wizard" in result.stdout
|
||||
@@ -1,5 +1,6 @@
|
||||
from pathlib import Path
|
||||
|
||||
import pytest
|
||||
import typer
|
||||
from rich.console import Console
|
||||
from typer.testing import CliRunner
|
||||
@@ -27,6 +28,7 @@ class FakeRuntime:
|
||||
self.restarted_options: GatewayStartOptions | None = None
|
||||
self.stop_timeout: int | None = None
|
||||
self.follow_tail: int | None = None
|
||||
self.validated_configs: list[Config] = []
|
||||
|
||||
def start_background(self, options: GatewayStartOptions) -> RuntimeResult:
|
||||
self.started_options = options
|
||||
@@ -84,11 +86,15 @@ class FakeServiceInstaller:
|
||||
)
|
||||
|
||||
|
||||
def _test_app(tmp_path: Path, config: Config | None = None):
|
||||
def _test_app(
|
||||
tmp_path: Path,
|
||||
config: Config | None = None,
|
||||
startup_error: str | None = None,
|
||||
):
|
||||
app = typer.Typer()
|
||||
fake_runtime = FakeRuntime(tmp_path)
|
||||
fake_service = FakeServiceInstaller(tmp_path)
|
||||
run_calls: list[tuple[Config, int | None, str | None]] = []
|
||||
run_calls: list[tuple[Config, int | None, str | None, str | None]] = []
|
||||
prepare_calls: list[tuple[Config, str]] = []
|
||||
|
||||
def load_runtime_config(_config_path: str | None, _workspace: str | None) -> Config:
|
||||
@@ -99,18 +105,28 @@ def _test_app(tmp_path: Path, config: Config | None = None):
|
||||
*,
|
||||
port: int | None = None,
|
||||
webui_bundle_mode: str | None = None,
|
||||
unconfigured_provider_error: str | None = None,
|
||||
) -> None:
|
||||
run_calls.append((config, port, webui_bundle_mode))
|
||||
run_calls.append(
|
||||
(config, port, webui_bundle_mode, unconfigured_provider_error)
|
||||
)
|
||||
|
||||
def prepare_webui_bundle(config: Config, mode: str) -> None:
|
||||
prepare_calls.append((config, mode))
|
||||
|
||||
def validate_startup_config(config: Config) -> str | None:
|
||||
fake_runtime.validated_configs.append(config)
|
||||
return startup_error
|
||||
|
||||
app.add_typer(
|
||||
create_gateway_app(
|
||||
console=Console(),
|
||||
log_handler_id=0,
|
||||
load_runtime_config=load_runtime_config,
|
||||
run_gateway=run_gateway,
|
||||
validate_startup_config=(
|
||||
validate_startup_config if startup_error is not None else None
|
||||
),
|
||||
runtime_factory=lambda **_kwargs: fake_runtime,
|
||||
service_factory=lambda: fake_service,
|
||||
prepare_webui_bundle=prepare_webui_bundle,
|
||||
@@ -131,6 +147,46 @@ def test_gateway_default_still_runs_foreground(tmp_path):
|
||||
assert calls[0][2] == "warn"
|
||||
|
||||
|
||||
def test_gateway_foreground_passes_recoverable_provider_error_to_runner(tmp_path):
|
||||
setup_error = "No provider is configured."
|
||||
app, _runtime, _service, calls, _prepare_calls = _test_app(
|
||||
tmp_path,
|
||||
startup_error=setup_error,
|
||||
)
|
||||
|
||||
result = runner.invoke(app, ["gateway"])
|
||||
|
||||
assert result.exit_code == 0
|
||||
assert len(calls) == 1
|
||||
assert calls[0][3] == setup_error
|
||||
assert len(_runtime.validated_configs) == 1
|
||||
|
||||
|
||||
@pytest.mark.parametrize(
|
||||
("args", "runtime_attribute"),
|
||||
[
|
||||
(["gateway", "--background"], "started_options"),
|
||||
(["gateway", "restart"], "restarted_options"),
|
||||
],
|
||||
)
|
||||
def test_gateway_managed_start_allows_recoverable_provider_error(
|
||||
tmp_path,
|
||||
args: list[str],
|
||||
runtime_attribute: str,
|
||||
) -> None:
|
||||
app, fake_runtime, _service, calls, _prepare_calls = _test_app(
|
||||
tmp_path,
|
||||
startup_error="No provider is configured.",
|
||||
)
|
||||
|
||||
result = runner.invoke(app, args)
|
||||
|
||||
assert result.exit_code == 0
|
||||
assert calls == []
|
||||
assert len(fake_runtime.validated_configs) == 1
|
||||
assert getattr(fake_runtime, runtime_attribute) is not None
|
||||
|
||||
|
||||
def test_gateway_background_starts_detached_runtime(tmp_path):
|
||||
config = Config()
|
||||
config.gateway.port = 18792
|
||||
|
||||
@@ -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"}}})
|
||||
|
||||
@@ -74,6 +74,26 @@ def test_from_config_missing_file():
|
||||
Nanobot.from_config("/nonexistent/config.json")
|
||||
|
||||
|
||||
def test_from_config_missing_env_reports_explicit_config_path(
|
||||
tmp_path,
|
||||
monkeypatch,
|
||||
) -> None:
|
||||
from nanobot.config.errors import ConfigLoadError
|
||||
|
||||
name = "NANOBOT_TEST_SDK_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:
|
||||
Nanobot.from_config(config_path)
|
||||
|
||||
assert exc_info.value.path == config_path.resolve()
|
||||
|
||||
|
||||
def test_from_config_creates_instance(tmp_path):
|
||||
config_path = _write_config(tmp_path)
|
||||
bot = Nanobot.from_config(config_path, workspace=tmp_path)
|
||||
|
||||
Reference in New Issue
Block a user