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:
+13
-1
@@ -11,7 +11,7 @@ Use this page when you know what you want to run and need the command shape. For
|
|||||||
| Refresh config non-interactively | `nanobot onboard --refresh` | Preserves existing values and adds missing default fields without prompting |
|
| Refresh config non-interactively | `nanobot onboard --refresh` | Preserves existing values and adds missing default fields without prompting |
|
||||||
| Use guided setup | `nanobot onboard --wizard` | Best when you prefer prompts over hand-editing JSON |
|
| Use guided setup | `nanobot onboard --wizard` | Best when you prefer prompts over hand-editing JSON |
|
||||||
| Open the browser workbench | `nanobot webui` | Prepares local WebUI settings, starts the gateway, and opens the browser |
|
| Open the browser workbench | `nanobot webui` | Prepares local WebUI settings, starts the gateway, and opens the browser |
|
||||||
| Check config without calling a model | `nanobot status` | Summarizes the selected config, workspace, active model, and providers |
|
| Check readiness without calling a model | `nanobot status` | Summarizes config/workspace and validates the active provider/model configuration |
|
||||||
| Send one test message | `nanobot agent -m "Hello!"` | First proof that install, config, provider, model, and workspace all work |
|
| Send one test message | `nanobot agent -m "Hello!"` | First proof that install, config, provider, model, and workspace all work |
|
||||||
| Chat in the terminal | `nanobot agent` | Interactive local chat; exit with `exit`, `/exit`, `:q`, or `Ctrl+D` |
|
| Chat in the terminal | `nanobot agent` | Interactive local chat; exit with `exit`, `/exit`, `:q`, or `Ctrl+D` |
|
||||||
| Run the gateway directly | `nanobot gateway` | Service/ops command for WebUI, chat apps, cron, and heartbeat |
|
| Run the gateway directly | `nanobot gateway` | Service/ops command for WebUI, chat apps, cron, and heartbeat |
|
||||||
@@ -70,6 +70,18 @@ Default paths:
|
|||||||
| Config | `~/.nanobot/config.json` |
|
| Config | `~/.nanobot/config.json` |
|
||||||
| Workspace | `~/.nanobot/workspace/` |
|
| Workspace | `~/.nanobot/workspace/` |
|
||||||
|
|
||||||
|
## Status
|
||||||
|
|
||||||
|
| Command | Description |
|
||||||
|
|---|---|
|
||||||
|
| `nanobot status` | Summarize the default config/workspace and check Agent provider/model readiness |
|
||||||
|
| `nanobot status --config <path>` | Check a specific config file |
|
||||||
|
| `nanobot status --workspace <path>` | Show status with a workspace override |
|
||||||
|
|
||||||
|
Status does not send a model request. On success, run the printed
|
||||||
|
`nanobot agent -m "Hello!"` command to verify network access and credentials. On failure,
|
||||||
|
follow the printed WebUI **Settings → Models** or `nanobot onboard --wizard` route.
|
||||||
|
|
||||||
## Agent CLI
|
## Agent CLI
|
||||||
|
|
||||||
| Command | Description |
|
| Command | Description |
|
||||||
|
|||||||
@@ -90,7 +90,9 @@ Instead of storing secrets directly in `config.json`, you can use `${VAR_NAME}`
|
|||||||
|
|
||||||
Any string value in `config.json` can use `${VAR_NAME}`. Resolution runs once at startup, in memory only — resolved values are never written back to disk, so editing config through `nanobot onboard` or the WebUI preserves the placeholder.
|
Any string value in `config.json` can use `${VAR_NAME}`. Resolution runs once at startup, in memory only — resolved values are never written back to disk, so editing config through `nanobot onboard` or the WebUI preserves the placeholder.
|
||||||
|
|
||||||
If a referenced variable is unset, nanobot fails fast at startup with `ValueError: Environment variable 'NAME' referenced in config is not set`.
|
If a referenced variable is unset, nanobot fails fast and reports the exact config field
|
||||||
|
and variable name without echoing the field value. Run `nanobot status` with the same
|
||||||
|
`--config` path to inspect the problem.
|
||||||
|
|
||||||
### More examples
|
### More examples
|
||||||
|
|
||||||
|
|||||||
+15
-2
@@ -23,15 +23,20 @@ This separates failures into layers:
|
|||||||
| Layer | What it proves |
|
| Layer | What it proves |
|
||||||
|---|---|
|
|---|---|
|
||||||
| `nanobot --version` | Install and shell command discovery |
|
| `nanobot --version` | Install and shell command discovery |
|
||||||
| `nanobot status` | Config path, workspace path, active model, and provider summary |
|
| `nanobot status` | Config path, workspace, environment references, and active provider/model configuration |
|
||||||
| `nanobot agent -m "Hello!"` | Config loading, provider/model access, workspace writes, and agent loop |
|
| `nanobot agent -m "Hello!"` | Config loading, provider/model access, workspace writes, and agent loop |
|
||||||
| `nanobot gateway` | Channel startup, cron system jobs, heartbeat, WebUI/WebSocket, and health endpoint |
|
| `nanobot gateway` | Channel startup, cron system jobs, heartbeat, WebUI/WebSocket, and health endpoint |
|
||||||
|
|
||||||
If `nanobot agent -m "Hello!"` fails, fix that before debugging WebUI, Telegram, Discord, Docker, systemd, or any chat app.
|
If `nanobot agent -m "Hello!"` fails, fix that before debugging WebUI, Telegram, Discord, Docker, systemd, or any chat app.
|
||||||
|
|
||||||
|
`nanobot status` does not call the model. If provider/model setup is incomplete, it points to
|
||||||
|
WebUI **Settings → Models** or the CLI setup wizard, then prints the command to check again.
|
||||||
|
|
||||||
## How to Read `nanobot status`
|
## How to Read `nanobot status`
|
||||||
|
|
||||||
`nanobot status` does not call a model. It only checks whether nanobot can find the selected config, selected workspace, active model or preset, and provider setup summary.
|
`nanobot status` does not call a model. It checks the selected config and workspace,
|
||||||
|
resolves environment references, and validates the local settings required by the active
|
||||||
|
provider/model without constructing a provider client.
|
||||||
|
|
||||||
The output has this shape:
|
The output has this shape:
|
||||||
|
|
||||||
@@ -41,6 +46,7 @@ nanobot Status
|
|||||||
Config: /path/to/config.json ✓
|
Config: /path/to/config.json ✓
|
||||||
Workspace: /path/to/workspace ✓
|
Workspace: /path/to/workspace ✓
|
||||||
Model: provider/model-name (preset: primary)
|
Model: provider/model-name (preset: primary)
|
||||||
|
Agent: ✓ provider/model configuration is ready
|
||||||
Provider A: not set
|
Provider A: not set
|
||||||
Provider B: ✓
|
Provider B: ✓
|
||||||
Local Provider: ✓ http://localhost:11434/v1
|
Local Provider: ✓ http://localhost:11434/v1
|
||||||
@@ -54,6 +60,7 @@ Read it like this:
|
|||||||
| `Config` | It points to the config file you meant to use and shows `✓`. | Run `nanobot onboard`, or pass `--config` to `nanobot agent`, `gateway`, or `serve` when testing a non-default instance. |
|
| `Config` | It points to the config file you meant to use and shows `✓`. | Run `nanobot onboard`, or pass `--config` to `nanobot agent`, `gateway`, or `serve` when testing a non-default instance. |
|
||||||
| `Workspace` | It points to the workspace you meant to use and shows `✓`. | Run `nanobot onboard`, create the folder, fix permissions, or pass `--workspace` on commands that support it. |
|
| `Workspace` | It points to the workspace you meant to use and shows `✓`. | Run `nanobot onboard`, create the folder, fix permissions, or pass `--workspace` on commands that support it. |
|
||||||
| `Model` | It shows the active model or the preset name you expect. | Set `agents.defaults.modelPreset` to the intended preset, or check `/model` if you changed models during a chat session. |
|
| `Model` | It shows the active model or the preset name you expect. | Set `agents.defaults.modelPreset` to the intended preset, or check `/model` if you changed models during a chat session. |
|
||||||
|
| `Agent` | It says `provider/model configuration is ready`. | Follow the printed WebUI or CLI setup route, then run `nanobot status` again. |
|
||||||
| Provider rows | The provider used by the active preset shows `✓`, an OAuth marker, or a local URL. | Configure only the active provider first. It is normal for unused providers to say `not set`. |
|
| Provider rows | The provider used by the active preset shows `✓`, an OAuth marker, or a local URL. | Configure only the active provider first. It is normal for unused providers to say `not set`. |
|
||||||
|
|
||||||
If `nanobot status` looks right but `nanobot agent -m "Hello!"` fails, the install and config paths are probably fine. Continue with [Provider and Model Problems](#provider-and-model-problems).
|
If `nanobot status` looks right but `nanobot agent -m "Hello!"` fails, the install and config paths are probably fine. Continue with [Provider and Model Problems](#provider-and-model-problems).
|
||||||
@@ -108,6 +115,12 @@ Common config mistakes:
|
|||||||
| Environment variable error | `${VAR_NAME}` references are resolved at startup. Set the variable before running nanobot. |
|
| Environment variable error | `${VAR_NAME}` references are resolved at startup. Set the variable before running nanobot. |
|
||||||
| Edited config but behavior did not change | Restart `nanobot gateway`; long-running processes read config at startup. |
|
| Edited config but behavior did not change | Restart `nanobot gateway`; long-running processes read config at startup. |
|
||||||
|
|
||||||
|
After editing config, check the shortest path to an Agent reply:
|
||||||
|
|
||||||
|
```bash
|
||||||
|
nanobot status
|
||||||
|
```
|
||||||
|
|
||||||
To refresh missing defaults without overwriting existing settings, run:
|
To refresh missing defaults without overwriting existing settings, run:
|
||||||
|
|
||||||
```bash
|
```bash
|
||||||
|
|||||||
@@ -33,7 +33,10 @@ def load_model_preset_catalog(
|
|||||||
from nanobot.config.loader import load_config, resolve_config_env_vars
|
from nanobot.config.loader import load_config, resolve_config_env_vars
|
||||||
|
|
||||||
return configured_model_presets(
|
return configured_model_presets(
|
||||||
resolve_config_env_vars(load_config(config_path)),
|
resolve_config_env_vars(
|
||||||
|
load_config(config_path),
|
||||||
|
config_path=config_path,
|
||||||
|
),
|
||||||
)
|
)
|
||||||
|
|
||||||
|
|
||||||
|
|||||||
+218
-25
@@ -54,6 +54,7 @@ from prompt_toolkit.history import FileHistory # noqa: E402
|
|||||||
from prompt_toolkit.key_binding import KeyBindings # noqa: E402
|
from prompt_toolkit.key_binding import KeyBindings # noqa: E402
|
||||||
from prompt_toolkit.keys import Keys # noqa: E402
|
from prompt_toolkit.keys import Keys # noqa: E402
|
||||||
from prompt_toolkit.patch_stdout import patch_stdout # noqa: E402
|
from prompt_toolkit.patch_stdout import patch_stdout # noqa: E402
|
||||||
|
from pydantic import ValidationError # noqa: E402
|
||||||
from rich.console import Console # noqa: E402
|
from rich.console import Console # noqa: E402
|
||||||
from rich.markdown import Markdown # noqa: E402
|
from rich.markdown import Markdown # noqa: E402
|
||||||
from rich.markup import escape # noqa: E402
|
from rich.markup import escape # noqa: E402
|
||||||
@@ -797,9 +798,89 @@ def _model_display(config: Config) -> tuple[str, str]:
|
|||||||
return resolved.model, tag
|
return resolved.model, tag
|
||||||
|
|
||||||
|
|
||||||
|
def _print_config_error(error: Exception) -> None:
|
||||||
|
"""Render a configuration failure without exposing traceback internals."""
|
||||||
|
from nanobot.config.errors import ConfigLoadError
|
||||||
|
|
||||||
|
console.print(Text(str(error), style="red"))
|
||||||
|
if isinstance(error, ConfigLoadError):
|
||||||
|
command = _status_command(error.path)
|
||||||
|
console.print(f"[dim]Check again after editing: {escape(command)}[/dim]")
|
||||||
|
|
||||||
|
|
||||||
|
def _print_runtime_config_validation_error(
|
||||||
|
error: ValidationError,
|
||||||
|
*,
|
||||||
|
config_path: Path,
|
||||||
|
summary: str,
|
||||||
|
path_prefix: tuple[str | int, ...],
|
||||||
|
retry_command: str,
|
||||||
|
) -> None:
|
||||||
|
"""Render a runtime-owned Pydantic config error without exposing input values."""
|
||||||
|
from nanobot.config.errors import ConfigIssue, ConfigLoadError, validation_issues
|
||||||
|
|
||||||
|
issues = tuple(
|
||||||
|
ConfigIssue(
|
||||||
|
path=(*path_prefix, *issue.path),
|
||||||
|
message=issue.message,
|
||||||
|
)
|
||||||
|
for issue in validation_issues(error)
|
||||||
|
)
|
||||||
|
diagnostic = ConfigLoadError(
|
||||||
|
config_path,
|
||||||
|
kind="invalid_schema",
|
||||||
|
summary=summary,
|
||||||
|
issues=issues,
|
||||||
|
)
|
||||||
|
console.print(Text(str(diagnostic), style="red"))
|
||||||
|
console.print(f"[dim]Fix the listed setting, then retry: {escape(retry_command)}[/dim]")
|
||||||
|
|
||||||
|
|
||||||
|
def _status_command(config_path: Path) -> str:
|
||||||
|
return f'nanobot status --config "{config_path}"'
|
||||||
|
|
||||||
|
|
||||||
|
def _print_model_setup_steps(config_path: Path) -> None:
|
||||||
|
"""Show the shortest setup routes shared by Status and Agent startup."""
|
||||||
|
config_arg = f'--config "{config_path}"'
|
||||||
|
console.print(
|
||||||
|
f" WebUI: run [cyan]nanobot webui {escape(config_arg)}[/cyan], "
|
||||||
|
"then open Settings → Models"
|
||||||
|
)
|
||||||
|
console.print(f" CLI: run [cyan]nanobot onboard --wizard {escape(config_arg)}[/cyan]")
|
||||||
|
console.print(f" Check: [cyan]{escape(_status_command(config_path))}[/cyan]")
|
||||||
|
|
||||||
|
|
||||||
|
def _print_agent_start_error(error: ValueError) -> None:
|
||||||
|
from nanobot.config.loader import get_config_path
|
||||||
|
|
||||||
|
console.print(Text(f"Agent cannot start: {error}", style="red"))
|
||||||
|
console.print("Complete provider/model setup:")
|
||||||
|
_print_model_setup_steps(get_config_path())
|
||||||
|
|
||||||
|
|
||||||
|
def _load_config_for_cli(
|
||||||
|
config_path: Path | None = None,
|
||||||
|
*,
|
||||||
|
resolve_env: bool = False,
|
||||||
|
) -> Config:
|
||||||
|
"""Load CLI configuration and turn expected failures into a clean exit."""
|
||||||
|
from nanobot.config.errors import ConfigLoadError
|
||||||
|
from nanobot.config.loader import load_config, resolve_config_env_vars
|
||||||
|
|
||||||
|
try:
|
||||||
|
loaded = load_config(config_path)
|
||||||
|
if resolve_env:
|
||||||
|
loaded = resolve_config_env_vars(loaded)
|
||||||
|
return loaded
|
||||||
|
except ConfigLoadError as exc:
|
||||||
|
_print_config_error(exc)
|
||||||
|
raise typer.Exit(1) from exc
|
||||||
|
|
||||||
|
|
||||||
def _load_runtime_config(config: str | None = None, workspace: str | None = None) -> Config:
|
def _load_runtime_config(config: str | None = None, workspace: str | None = None) -> Config:
|
||||||
"""Load config and optionally override the active workspace."""
|
"""Load config and optionally override the active workspace."""
|
||||||
from nanobot.config.loader import load_config, resolve_config_env_vars, set_config_path
|
from nanobot.config.loader import set_config_path
|
||||||
|
|
||||||
config_path = None
|
config_path = None
|
||||||
if config:
|
if config:
|
||||||
@@ -810,11 +891,7 @@ def _load_runtime_config(config: str | None = None, workspace: str | None = None
|
|||||||
set_config_path(config_path)
|
set_config_path(config_path)
|
||||||
console.print(f"[dim]Using config: {config_path}[/dim]")
|
console.print(f"[dim]Using config: {config_path}[/dim]")
|
||||||
|
|
||||||
try:
|
loaded = _load_config_for_cli(config_path, resolve_env=True)
|
||||||
loaded = resolve_config_env_vars(load_config(config_path))
|
|
||||||
except ValueError as e:
|
|
||||||
console.print(f"[red]Error: {e}[/red]")
|
|
||||||
raise typer.Exit(1)
|
|
||||||
if workspace:
|
if workspace:
|
||||||
loaded.agents.defaults.workspace = workspace
|
loaded.agents.defaults.workspace = workspace
|
||||||
return loaded
|
return loaded
|
||||||
@@ -840,6 +917,7 @@ def _load_inspection_config(
|
|||||||
workspace: str | None = None,
|
workspace: str | None = None,
|
||||||
) -> tuple[Path, Config]:
|
) -> tuple[Path, Config]:
|
||||||
"""Load config for diagnostic commands without resolving secret env refs."""
|
"""Load config for diagnostic commands without resolving secret env refs."""
|
||||||
|
from nanobot.config.errors import ConfigLoadError
|
||||||
from nanobot.config.loader import get_config_path, load_config, set_config_path
|
from nanobot.config.loader import get_config_path, load_config, set_config_path
|
||||||
|
|
||||||
config_path = None
|
config_path = None
|
||||||
@@ -851,6 +929,9 @@ def _load_inspection_config(
|
|||||||
display_path = config_path or get_config_path()
|
display_path = config_path or get_config_path()
|
||||||
try:
|
try:
|
||||||
loaded = load_config(config_path)
|
loaded = load_config(config_path)
|
||||||
|
except ConfigLoadError as exc:
|
||||||
|
_print_config_error(exc)
|
||||||
|
raise typer.Exit(1) from exc
|
||||||
except ValueError as exc:
|
except ValueError as exc:
|
||||||
console.print(f"[red]Error: {exc}[/red]")
|
console.print(f"[red]Error: {exc}[/red]")
|
||||||
raise typer.Exit(1) from exc
|
raise typer.Exit(1) from exc
|
||||||
@@ -901,21 +982,15 @@ def _resolve_webui_config_path(config: str | None) -> Path:
|
|||||||
|
|
||||||
def _load_webui_setup_config(config_path: Path) -> Config:
|
def _load_webui_setup_config(config_path: Path) -> Config:
|
||||||
"""Load config for first-run mutation without resolving env-var placeholders."""
|
"""Load config for first-run mutation without resolving env-var placeholders."""
|
||||||
from nanobot.config.loader import load_config
|
return _load_config_for_cli(config_path)
|
||||||
|
|
||||||
try:
|
|
||||||
return load_config(config_path)
|
|
||||||
except ValueError as e:
|
|
||||||
console.print(f"[red]Error: {e}[/red]")
|
|
||||||
raise typer.Exit(1) from e
|
|
||||||
|
|
||||||
|
|
||||||
def _provider_setup_error(config: Config) -> str | None:
|
def _provider_setup_error(config: Config) -> str | None:
|
||||||
"""Return the provider setup error, or None when the current model can start."""
|
"""Return a local provider/model configuration error, or None."""
|
||||||
from nanobot.providers.factory import build_provider_snapshot
|
from nanobot.providers.factory import validate_provider_setup
|
||||||
|
|
||||||
try:
|
try:
|
||||||
build_provider_snapshot(config)
|
validate_provider_setup(config)
|
||||||
except ValueError as exc:
|
except ValueError as exc:
|
||||||
return str(exc)
|
return str(exc)
|
||||||
return None
|
return None
|
||||||
@@ -937,6 +1012,60 @@ def _webui_channel_enabled(config: Config) -> bool:
|
|||||||
return bool(WebSocketConfig.model_validate(current).enabled)
|
return bool(WebSocketConfig.model_validate(current).enabled)
|
||||||
|
|
||||||
|
|
||||||
|
def _validate_gateway_startup(config: Config) -> str | None:
|
||||||
|
"""Validate gateway startup and return a provider error recoverable through WebUI."""
|
||||||
|
from nanobot.config.loader import get_config_path
|
||||||
|
|
||||||
|
config_path = get_config_path()
|
||||||
|
try:
|
||||||
|
webui_config = _webui_config_dict(config)
|
||||||
|
except ValidationError as exc:
|
||||||
|
retry_command = f'nanobot gateway --config "{config_path}"'
|
||||||
|
_print_runtime_config_validation_error(
|
||||||
|
exc,
|
||||||
|
config_path=config_path,
|
||||||
|
summary="Gateway configuration is invalid.",
|
||||||
|
path_prefix=("channels", "websocket"),
|
||||||
|
retry_command=retry_command,
|
||||||
|
)
|
||||||
|
raise typer.Exit(1) from exc
|
||||||
|
|
||||||
|
provider_error = _provider_setup_error(config)
|
||||||
|
if not provider_error:
|
||||||
|
return None
|
||||||
|
|
||||||
|
if bool(webui_config["enabled"]):
|
||||||
|
console.print(
|
||||||
|
Text(f"Provider/model setup is incomplete: {provider_error}", style="yellow")
|
||||||
|
)
|
||||||
|
console.print(
|
||||||
|
"Gateway will start so you can configure a provider and model "
|
||||||
|
"in WebUI Settings → Models."
|
||||||
|
)
|
||||||
|
browser_url = _webui_browser_url(config)
|
||||||
|
webui_url = browser_url.split("/#/", 1)[0]
|
||||||
|
console.print(Text(f"WebUI: {webui_url}", style="cyan"))
|
||||||
|
if browser_url != webui_url:
|
||||||
|
secret_key = (
|
||||||
|
"tokenIssueSecret"
|
||||||
|
if str(webui_config.get("tokenIssueSecret") or "").strip()
|
||||||
|
else "token"
|
||||||
|
)
|
||||||
|
console.print(
|
||||||
|
Text(
|
||||||
|
f"If prompted, enter the configured channels.websocket.{secret_key} "
|
||||||
|
f"value (see {config_path}).",
|
||||||
|
style="dim",
|
||||||
|
)
|
||||||
|
)
|
||||||
|
return provider_error
|
||||||
|
|
||||||
|
console.print(Text(f"Gateway cannot start: {provider_error}", style="red"))
|
||||||
|
console.print("Complete provider/model setup:")
|
||||||
|
_print_model_setup_steps(config_path)
|
||||||
|
raise typer.Exit(1)
|
||||||
|
|
||||||
|
|
||||||
def _prepare_webui_bundle_for_gateway(
|
def _prepare_webui_bundle_for_gateway(
|
||||||
config: Config,
|
config: Config,
|
||||||
*,
|
*,
|
||||||
@@ -1234,14 +1363,20 @@ def _gateway_instance_command(
|
|||||||
return " ".join(shlex.quote(part) for part in parts)
|
return " ".join(shlex.quote(part) for part in parts)
|
||||||
|
|
||||||
|
|
||||||
def _run_quick_start_for_webui(config: Config, *, yes: bool) -> Config:
|
def _run_quick_start_for_webui(
|
||||||
|
config: Config,
|
||||||
|
*,
|
||||||
|
yes: bool,
|
||||||
|
config_path: Path,
|
||||||
|
) -> Config:
|
||||||
"""Offer the existing Quick Start flow when provider setup is missing."""
|
"""Offer the existing Quick Start flow when provider setup is missing."""
|
||||||
if yes:
|
if yes:
|
||||||
console.print(
|
console.print(
|
||||||
"[red]Error: provider/model setup is incomplete, and --yes cannot answer "
|
"[red]Error: provider/model setup is incomplete, and --yes cannot answer "
|
||||||
"provider credentials. Run `nanobot webui` interactively or "
|
"provider credentials.[/red]"
|
||||||
"`nanobot onboard --wizard`.[/red]"
|
|
||||||
)
|
)
|
||||||
|
console.print("Complete provider/model setup:")
|
||||||
|
_print_model_setup_steps(config_path)
|
||||||
raise typer.Exit(1)
|
raise typer.Exit(1)
|
||||||
|
|
||||||
console.print()
|
console.print()
|
||||||
@@ -1433,9 +1568,12 @@ def webui(
|
|||||||
setup_config.agents.defaults.workspace = workspace
|
setup_config.agents.defaults.workspace = workspace
|
||||||
|
|
||||||
try:
|
try:
|
||||||
resolved_setup_config = resolve_config_env_vars(setup_config.model_copy(deep=True))
|
resolved_setup_config = resolve_config_env_vars(
|
||||||
|
setup_config.model_copy(deep=True),
|
||||||
|
config_path=config_path,
|
||||||
|
)
|
||||||
except ValueError as exc:
|
except ValueError as exc:
|
||||||
console.print(f"[red]Error: {exc}[/red]")
|
_print_config_error(exc)
|
||||||
raise typer.Exit(1) from exc
|
raise typer.Exit(1) from exc
|
||||||
|
|
||||||
provider_error = _provider_setup_error(resolved_setup_config)
|
provider_error = _provider_setup_error(resolved_setup_config)
|
||||||
@@ -1451,7 +1589,11 @@ def webui(
|
|||||||
raise typer.Exit(1)
|
raise typer.Exit(1)
|
||||||
elif provider_error:
|
elif provider_error:
|
||||||
console.print(f"[dim]Provider check: {provider_error}[/dim]")
|
console.print(f"[dim]Provider check: {provider_error}[/dim]")
|
||||||
setup_config = _run_quick_start_for_webui(setup_config, yes=yes)
|
setup_config = _run_quick_start_for_webui(
|
||||||
|
setup_config,
|
||||||
|
yes=yes,
|
||||||
|
config_path=config_path,
|
||||||
|
)
|
||||||
if workspace:
|
if workspace:
|
||||||
setup_config.agents.defaults.workspace = workspace
|
setup_config.agents.defaults.workspace = workspace
|
||||||
|
|
||||||
@@ -1463,6 +1605,16 @@ def webui(
|
|||||||
)
|
)
|
||||||
_warn_webui_bind_scope(setup_config)
|
_warn_webui_bind_scope(setup_config)
|
||||||
webui_url = _webui_browser_url(setup_config)
|
webui_url = _webui_browser_url(setup_config)
|
||||||
|
except ValidationError as exc:
|
||||||
|
retry_command = f'nanobot webui --config "{config_path}"'
|
||||||
|
_print_runtime_config_validation_error(
|
||||||
|
exc,
|
||||||
|
config_path=config_path,
|
||||||
|
summary="WebUI configuration is invalid.",
|
||||||
|
path_prefix=("channels", "websocket"),
|
||||||
|
retry_command=retry_command,
|
||||||
|
)
|
||||||
|
raise typer.Exit(1) from exc
|
||||||
except ValueError as exc:
|
except ValueError as exc:
|
||||||
console.print(f"[red]Error: invalid WebUI channel config: {exc}[/red]")
|
console.print(f"[red]Error: invalid WebUI channel config: {exc}[/red]")
|
||||||
raise typer.Exit(1) from exc
|
raise typer.Exit(1) from exc
|
||||||
@@ -2228,6 +2380,7 @@ app.add_typer(
|
|||||||
log_handler_id=_log_handler_id,
|
log_handler_id=_log_handler_id,
|
||||||
load_runtime_config=_load_runtime_config,
|
load_runtime_config=_load_runtime_config,
|
||||||
run_gateway=_run_gateway,
|
run_gateway=_run_gateway,
|
||||||
|
validate_startup_config=_validate_gateway_startup,
|
||||||
prepare_webui_bundle=lambda config, mode: _prepare_webui_bundle_for_gateway(
|
prepare_webui_bundle=lambda config, mode: _prepare_webui_bundle_for_gateway(
|
||||||
config,
|
config,
|
||||||
mode=mode,
|
mode=mode,
|
||||||
@@ -2254,9 +2407,16 @@ def agent(
|
|||||||
"""Interact with the agent directly."""
|
"""Interact with the agent directly."""
|
||||||
from nanobot.bus.queue import MessageBus
|
from nanobot.bus.queue import MessageBus
|
||||||
from nanobot.cron.service import CronService
|
from nanobot.cron.service import CronService
|
||||||
|
from nanobot.providers.factory import make_provider
|
||||||
from nanobot.providers.image_generation import image_gen_provider_configs
|
from nanobot.providers.image_generation import image_gen_provider_configs
|
||||||
|
|
||||||
config = _load_runtime_config(config, workspace)
|
config = _load_runtime_config(config, workspace)
|
||||||
|
try:
|
||||||
|
provider = make_provider(config)
|
||||||
|
except ValueError as exc:
|
||||||
|
_print_agent_start_error(exc)
|
||||||
|
raise typer.Exit(1) from exc
|
||||||
|
|
||||||
sync_workspace_templates(config.workspace_path)
|
sync_workspace_templates(config.workspace_path)
|
||||||
|
|
||||||
bus = MessageBus()
|
bus = MessageBus()
|
||||||
@@ -2274,12 +2434,13 @@ def agent(
|
|||||||
try:
|
try:
|
||||||
agent_loop = AgentLoop.from_config(
|
agent_loop = AgentLoop.from_config(
|
||||||
config, bus,
|
config, bus,
|
||||||
|
provider=provider,
|
||||||
cron_service=cron,
|
cron_service=cron,
|
||||||
image_generation_provider_configs=image_gen_provider_configs(config),
|
image_generation_provider_configs=image_gen_provider_configs(config),
|
||||||
hook_factories=[create_file_edit_activity_hook],
|
hook_factories=[create_file_edit_activity_hook],
|
||||||
)
|
)
|
||||||
except ValueError as exc:
|
except ValueError as exc:
|
||||||
console.print(f"[red]Error: {exc}[/red]")
|
_print_agent_start_error(exc)
|
||||||
raise typer.Exit(1) from exc
|
raise typer.Exit(1) from exc
|
||||||
restart_notice = consume_restart_notice_from_env()
|
restart_notice = consume_restart_notice_from_env()
|
||||||
if restart_notice and should_show_cli_restart_notice(restart_notice, session_id):
|
if restart_notice and should_show_cli_restart_notice(restart_notice, session_id):
|
||||||
@@ -2683,11 +2844,32 @@ def status(
|
|||||||
)
|
)
|
||||||
|
|
||||||
if config_path.exists():
|
if config_path.exists():
|
||||||
|
from nanobot.config.errors import ConfigLoadError
|
||||||
|
from nanobot.config.loader import resolve_config_env_vars, resolve_env_refs
|
||||||
from nanobot.providers.registry import PROVIDERS
|
from nanobot.providers.registry import PROVIDERS
|
||||||
|
|
||||||
_model, _preset_tag = _model_display(loaded)
|
_model, _preset_tag = _model_display(loaded)
|
||||||
console.print(f"Model: {_model}{_preset_tag}")
|
console.print(f"Model: {_model}{_preset_tag}")
|
||||||
|
|
||||||
|
provider_ready = False
|
||||||
|
try:
|
||||||
|
resolved = resolve_config_env_vars(
|
||||||
|
loaded.model_copy(deep=True),
|
||||||
|
config_path=config_path,
|
||||||
|
)
|
||||||
|
except ConfigLoadError as exc:
|
||||||
|
console.print("Agent: [red]✗ configuration is not ready[/red]")
|
||||||
|
_print_config_error(exc)
|
||||||
|
else:
|
||||||
|
provider_error = _provider_setup_error(resolved)
|
||||||
|
if provider_error:
|
||||||
|
console.print(Text(f"Agent: ✗ {provider_error}", style="red"))
|
||||||
|
console.print("Complete provider/model setup:")
|
||||||
|
_print_model_setup_steps(config_path)
|
||||||
|
else:
|
||||||
|
provider_ready = True
|
||||||
|
console.print("Agent: [green]✓ provider/model configuration is ready[/green]")
|
||||||
|
|
||||||
# Check API keys from registry
|
# Check API keys from registry
|
||||||
for spec in PROVIDERS:
|
for spec in PROVIDERS:
|
||||||
p = getattr(loaded.providers, spec.name, None)
|
p = getattr(loaded.providers, spec.name, None)
|
||||||
@@ -2697,14 +2879,25 @@ def status(
|
|||||||
console.print(f"{spec.label}: [green]✓ (OAuth)[/green]")
|
console.print(f"{spec.label}: [green]✓ (OAuth)[/green]")
|
||||||
elif spec.is_local:
|
elif spec.is_local:
|
||||||
# Local deployments show api_base instead of api_key
|
# Local deployments show api_base instead of api_key
|
||||||
if p.api_base:
|
if resolve_env_refs(p.api_base or ""):
|
||||||
console.print(f"{spec.label}: [green]✓ {p.api_base}[/green]")
|
console.print(f"{spec.label}: [green]✓ {p.api_base}[/green]")
|
||||||
else:
|
else:
|
||||||
console.print(f"{spec.label}: [dim]not set[/dim]")
|
console.print(f"{spec.label}: [dim]not set[/dim]")
|
||||||
else:
|
else:
|
||||||
has_key = bool(p.api_key)
|
has_key = bool(resolve_env_refs(p.api_key or ""))
|
||||||
console.print(f"{spec.label}: {'[green]✓[/green]' if has_key else '[dim]not set[/dim]'}")
|
console.print(f"{spec.label}: {'[green]✓[/green]' if has_key else '[dim]not set[/dim]'}")
|
||||||
|
|
||||||
|
if provider_ready:
|
||||||
|
console.print()
|
||||||
|
console.print('Next: [cyan]nanobot agent -m "Hello!"[/cyan]')
|
||||||
|
console.print(
|
||||||
|
"[dim]Status does not call the model or verify network access and credentials.[/dim]"
|
||||||
|
)
|
||||||
|
else:
|
||||||
|
console.print("Agent: [red]✗ configuration file not found[/red]")
|
||||||
|
console.print("Create the provider/model configuration:")
|
||||||
|
_print_model_setup_steps(config_path)
|
||||||
|
|
||||||
|
|
||||||
# ============================================================================
|
# ============================================================================
|
||||||
# OAuth Login
|
# OAuth Login
|
||||||
|
|||||||
+18
-1
@@ -29,6 +29,7 @@ from nanobot.webui.build import BuildMode
|
|||||||
|
|
||||||
RuntimeConfigLoader = Callable[[str | None, str | None], Config]
|
RuntimeConfigLoader = Callable[[str | None, str | None], Config]
|
||||||
GatewayRunner = Callable[..., None]
|
GatewayRunner = Callable[..., None]
|
||||||
|
GatewayConfigValidator = Callable[[Config], str | None]
|
||||||
GatewayRuntimeFactory = Callable[..., Any]
|
GatewayRuntimeFactory = Callable[..., Any]
|
||||||
GatewayServiceFactory = Callable[[], Any]
|
GatewayServiceFactory = Callable[[], Any]
|
||||||
WebUIBundlePreparer = Callable[[Config, BuildMode], None]
|
WebUIBundlePreparer = Callable[[Config, BuildMode], None]
|
||||||
@@ -40,6 +41,7 @@ def create_gateway_app(
|
|||||||
log_handler_id: int,
|
log_handler_id: int,
|
||||||
load_runtime_config: RuntimeConfigLoader,
|
load_runtime_config: RuntimeConfigLoader,
|
||||||
run_gateway: GatewayRunner,
|
run_gateway: GatewayRunner,
|
||||||
|
validate_startup_config: GatewayConfigValidator | None = None,
|
||||||
runtime_factory: GatewayRuntimeFactory | None = None,
|
runtime_factory: GatewayRuntimeFactory | None = None,
|
||||||
service_factory: GatewayServiceFactory | None = None,
|
service_factory: GatewayServiceFactory | None = None,
|
||||||
prepare_webui_bundle: WebUIBundlePreparer | None = None,
|
prepare_webui_bundle: WebUIBundlePreparer | None = None,
|
||||||
@@ -149,6 +151,8 @@ def create_gateway_app(
|
|||||||
raise typer.Exit(1)
|
raise typer.Exit(1)
|
||||||
if background:
|
if background:
|
||||||
cfg = load_runtime_config(config, workspace)
|
cfg = load_runtime_config(config, workspace)
|
||||||
|
if validate_startup_config is not None:
|
||||||
|
validate_startup_config(cfg)
|
||||||
if prepare_webui_bundle is not None:
|
if prepare_webui_bundle is not None:
|
||||||
prepare_webui_bundle(cfg, interactive_build_mode())
|
prepare_webui_bundle(cfg, interactive_build_mode())
|
||||||
runtime = runtime_for_instance(workspace=workspace, config=config)
|
runtime = runtime_for_instance(workspace=workspace, config=config)
|
||||||
@@ -171,7 +175,18 @@ def create_gateway_app(
|
|||||||
|
|
||||||
configure_logging(verbose)
|
configure_logging(verbose)
|
||||||
cfg = load_runtime_config(config, workspace)
|
cfg = load_runtime_config(config, workspace)
|
||||||
run_gateway(cfg, port=port, webui_bundle_mode=interactive_build_mode())
|
unconfigured_provider_error = None
|
||||||
|
if validate_startup_config is not None:
|
||||||
|
unconfigured_provider_error = validate_startup_config(cfg)
|
||||||
|
if unconfigured_provider_error is None:
|
||||||
|
run_gateway(cfg, port=port, webui_bundle_mode=interactive_build_mode())
|
||||||
|
else:
|
||||||
|
run_gateway(
|
||||||
|
cfg,
|
||||||
|
port=port,
|
||||||
|
webui_bundle_mode=interactive_build_mode(),
|
||||||
|
unconfigured_provider_error=unconfigured_provider_error,
|
||||||
|
)
|
||||||
|
|
||||||
@gateway_app.command("status")
|
@gateway_app.command("status")
|
||||||
def gateway_status(
|
def gateway_status(
|
||||||
@@ -225,6 +240,8 @@ def create_gateway_app(
|
|||||||
) -> None:
|
) -> None:
|
||||||
"""Restart the background gateway."""
|
"""Restart the background gateway."""
|
||||||
cfg = load_runtime_config(config, workspace)
|
cfg = load_runtime_config(config, workspace)
|
||||||
|
if validate_startup_config is not None:
|
||||||
|
validate_startup_config(cfg)
|
||||||
if prepare_webui_bundle is not None:
|
if prepare_webui_bundle is not None:
|
||||||
prepare_webui_bundle(cfg, interactive_build_mode())
|
prepare_webui_bundle(cfg, interactive_build_mode())
|
||||||
runtime = runtime_for_instance(workspace=workspace, config=config)
|
runtime = runtime_for_instance(workspace=workspace, config=config)
|
||||||
|
|||||||
@@ -1,5 +1,6 @@
|
|||||||
"""Configuration module for nanobot."""
|
"""Configuration module for nanobot."""
|
||||||
|
|
||||||
|
from nanobot.config.errors import ConfigIssue, ConfigLoadError
|
||||||
from nanobot.config.loader import get_config_path, load_config
|
from nanobot.config.loader import get_config_path, load_config
|
||||||
from nanobot.config.paths import (
|
from nanobot.config.paths import (
|
||||||
get_cli_history_path,
|
get_cli_history_path,
|
||||||
@@ -17,6 +18,8 @@ from nanobot.config.schema import Config
|
|||||||
|
|
||||||
__all__ = [
|
__all__ = [
|
||||||
"Config",
|
"Config",
|
||||||
|
"ConfigIssue",
|
||||||
|
"ConfigLoadError",
|
||||||
"load_config",
|
"load_config",
|
||||||
"get_config_path",
|
"get_config_path",
|
||||||
"get_data_dir",
|
"get_data_dir",
|
||||||
|
|||||||
@@ -0,0 +1,112 @@
|
|||||||
|
"""User-safe configuration diagnostics."""
|
||||||
|
|
||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
import re
|
||||||
|
from dataclasses import dataclass
|
||||||
|
from pathlib import Path
|
||||||
|
from typing import Literal
|
||||||
|
|
||||||
|
from pydantic import ValidationError
|
||||||
|
|
||||||
|
ConfigErrorKind = Literal[
|
||||||
|
"invalid_json",
|
||||||
|
"invalid_root",
|
||||||
|
"invalid_schema",
|
||||||
|
"missing_env",
|
||||||
|
"io_error",
|
||||||
|
]
|
||||||
|
ConfigPathPart = str | int
|
||||||
|
_SAFE_LOCATION_PART = re.compile(r"[A-Za-z_][A-Za-z0-9_-]{0,63}")
|
||||||
|
|
||||||
|
|
||||||
|
def _display_location_part(part: ConfigPathPart) -> str:
|
||||||
|
if isinstance(part, int):
|
||||||
|
return str(part)
|
||||||
|
return part if _SAFE_LOCATION_PART.fullmatch(part) else "<redacted>"
|
||||||
|
|
||||||
|
|
||||||
|
@dataclass(frozen=True)
|
||||||
|
class ConfigIssue:
|
||||||
|
"""One actionable configuration problem."""
|
||||||
|
|
||||||
|
path: tuple[ConfigPathPart, ...]
|
||||||
|
message: str
|
||||||
|
|
||||||
|
@property
|
||||||
|
def location(self) -> str:
|
||||||
|
# Pydantic locations can contain user-controlled mapping keys. Only
|
||||||
|
# render conventional config identifiers so credential-bearing URLs
|
||||||
|
# and other free-form values cannot leak through a redacted error.
|
||||||
|
if not self.path:
|
||||||
|
return "<root>"
|
||||||
|
return ".".join(_display_location_part(part) for part in self.path)
|
||||||
|
|
||||||
|
|
||||||
|
class ConfigLoadError(ValueError):
|
||||||
|
"""A structured, user-safe configuration loading failure."""
|
||||||
|
|
||||||
|
def __init__(
|
||||||
|
self,
|
||||||
|
path: Path,
|
||||||
|
*,
|
||||||
|
kind: ConfigErrorKind,
|
||||||
|
summary: str,
|
||||||
|
issues: tuple[ConfigIssue, ...] = (),
|
||||||
|
) -> None:
|
||||||
|
self.path = path
|
||||||
|
self.kind = kind
|
||||||
|
self.summary = summary
|
||||||
|
self.issues = issues
|
||||||
|
super().__init__(summary)
|
||||||
|
|
||||||
|
def __str__(self) -> str:
|
||||||
|
lines = [f"Invalid configuration: {self.path}", "", self.summary]
|
||||||
|
for issue in self.issues[:10]:
|
||||||
|
lines.extend(("", f" {issue.location}", f" {issue.message}"))
|
||||||
|
remaining = len(self.issues) - 10
|
||||||
|
if remaining > 0:
|
||||||
|
lines.extend(("", f" … and {remaining} more issue(s)"))
|
||||||
|
return "\n".join(lines)
|
||||||
|
|
||||||
|
|
||||||
|
def validation_issues(
|
||||||
|
error: ValidationError,
|
||||||
|
) -> tuple[ConfigIssue, ...]:
|
||||||
|
"""Convert Pydantic details to actionable messages without exposing input values."""
|
||||||
|
issues: list[ConfigIssue] = []
|
||||||
|
for detail in error.errors(
|
||||||
|
include_url=False,
|
||||||
|
include_context=False,
|
||||||
|
include_input=False,
|
||||||
|
):
|
||||||
|
location = tuple(detail.get("loc", ()))
|
||||||
|
code = str(detail.get("type") or "")
|
||||||
|
message = _friendly_validation_message(
|
||||||
|
str(detail.get("msg") or "Invalid value"),
|
||||||
|
code,
|
||||||
|
)
|
||||||
|
issues.append(ConfigIssue(path=location, message=message))
|
||||||
|
return tuple(issues)
|
||||||
|
|
||||||
|
|
||||||
|
def _friendly_validation_message(message: str, code: str) -> str:
|
||||||
|
if code == "extra_forbidden":
|
||||||
|
return "Unknown setting."
|
||||||
|
if code == "missing":
|
||||||
|
return "This setting is required."
|
||||||
|
if code in {"assertion_error", "value_error"}:
|
||||||
|
# Custom validators control these messages and may interpolate the
|
||||||
|
# rejected value. Keep the field location, but never render that text.
|
||||||
|
return "Value does not satisfy this setting's requirements."
|
||||||
|
if message.startswith("Value error, "):
|
||||||
|
message = message.removeprefix("Value error, ")
|
||||||
|
elif message.startswith("Input should be "):
|
||||||
|
message = "Must be " + message.removeprefix("Input should be ")
|
||||||
|
elif message.startswith("Input should have "):
|
||||||
|
message = "Must have " + message.removeprefix("Input should have ")
|
||||||
|
if message:
|
||||||
|
message = message[:1].upper() + message[1:]
|
||||||
|
if message and message[-1] not in ".!?":
|
||||||
|
message += "."
|
||||||
|
return message or "Invalid value."
|
||||||
+145
-14
@@ -6,9 +6,10 @@ import re
|
|||||||
from pathlib import Path
|
from pathlib import Path
|
||||||
from typing import Any
|
from typing import Any
|
||||||
|
|
||||||
import pydantic
|
from pydantic import BaseModel, ValidationError
|
||||||
from pydantic import BaseModel
|
from pydantic_settings import SettingsError
|
||||||
|
|
||||||
|
from nanobot.config.errors import ConfigIssue, ConfigLoadError, validation_issues
|
||||||
from nanobot.config.schema import Config, _resolve_tool_config_refs
|
from nanobot.config.schema import Config, _resolve_tool_config_refs
|
||||||
from nanobot.utils.helpers import _write_text_atomic
|
from nanobot.utils.helpers import _write_text_atomic
|
||||||
|
|
||||||
@@ -47,15 +48,79 @@ def load_config(config_path: Path | None = None) -> Config:
|
|||||||
|
|
||||||
path = config_path or get_config_path()
|
path = config_path or get_config_path()
|
||||||
|
|
||||||
config = Config()
|
if not path.exists():
|
||||||
if path.exists():
|
|
||||||
try:
|
try:
|
||||||
with open(path, encoding="utf-8") as f:
|
config = Config()
|
||||||
data = json.load(f)
|
except SettingsError as exc:
|
||||||
data = _migrate_config(data)
|
raise ConfigLoadError(
|
||||||
config = Config.model_validate(data)
|
path,
|
||||||
except (json.JSONDecodeError, ValueError, pydantic.ValidationError) as e:
|
kind="invalid_schema",
|
||||||
raise ValueError(f"Failed to load config from {path}: {e}") from e
|
summary=(
|
||||||
|
"Environment-based configuration could not be parsed. "
|
||||||
|
"Check that complex NANOBOT_* values use valid JSON."
|
||||||
|
),
|
||||||
|
) from exc
|
||||||
|
except ValidationError as exc:
|
||||||
|
raise ConfigLoadError(
|
||||||
|
path,
|
||||||
|
kind="invalid_schema",
|
||||||
|
summary="Environment-based configuration is invalid.",
|
||||||
|
issues=validation_issues(exc),
|
||||||
|
) from exc
|
||||||
|
_apply_ssrf_whitelist(config)
|
||||||
|
return config
|
||||||
|
|
||||||
|
try:
|
||||||
|
with path.open(encoding="utf-8") as handle:
|
||||||
|
data = json.load(handle)
|
||||||
|
except json.JSONDecodeError as exc:
|
||||||
|
raise ConfigLoadError(
|
||||||
|
path,
|
||||||
|
kind="invalid_json",
|
||||||
|
summary=(
|
||||||
|
f"JSON syntax error at line {exc.lineno}, column {exc.colno}: "
|
||||||
|
f"{_sentence(exc.msg)}"
|
||||||
|
),
|
||||||
|
) from exc
|
||||||
|
except UnicodeDecodeError as exc:
|
||||||
|
raise ConfigLoadError(
|
||||||
|
path,
|
||||||
|
kind="io_error",
|
||||||
|
summary="The file is not valid UTF-8.",
|
||||||
|
) from exc
|
||||||
|
except OSError as exc:
|
||||||
|
detail = exc.strerror or type(exc).__name__
|
||||||
|
raise ConfigLoadError(
|
||||||
|
path,
|
||||||
|
kind="io_error",
|
||||||
|
summary=f"Unable to read the file: {_sentence(detail)}",
|
||||||
|
) from exc
|
||||||
|
|
||||||
|
if not isinstance(data, dict):
|
||||||
|
root_type = type(data).__name__
|
||||||
|
raise ConfigLoadError(
|
||||||
|
path,
|
||||||
|
kind="invalid_root",
|
||||||
|
summary="The top level of config.json must be a JSON object.",
|
||||||
|
issues=(
|
||||||
|
ConfigIssue(
|
||||||
|
path=(),
|
||||||
|
message=f"Expected an object, but found {root_type}.",
|
||||||
|
),
|
||||||
|
),
|
||||||
|
)
|
||||||
|
|
||||||
|
data = _migrate_config(data)
|
||||||
|
try:
|
||||||
|
config = Config.model_validate(data)
|
||||||
|
except ValidationError as exc:
|
||||||
|
issues = validation_issues(exc)
|
||||||
|
raise ConfigLoadError(
|
||||||
|
path,
|
||||||
|
kind="invalid_schema",
|
||||||
|
summary=f"Found {len(issues)} invalid setting(s).",
|
||||||
|
issues=issues,
|
||||||
|
) from exc
|
||||||
|
|
||||||
_apply_ssrf_whitelist(config)
|
_apply_ssrf_whitelist(config)
|
||||||
return config
|
return config
|
||||||
@@ -116,13 +181,25 @@ def merge_missing_defaults(existing: Any, defaults: Any) -> Any:
|
|||||||
_ENV_REF_PATTERN = re.compile(r"\$\{([A-Za-z_][A-Za-z0-9_]*)\}")
|
_ENV_REF_PATTERN = re.compile(r"\$\{([A-Za-z_][A-Za-z0-9_]*)\}")
|
||||||
|
|
||||||
|
|
||||||
def resolve_config_env_vars(config: Config) -> Config:
|
def resolve_config_env_vars(
|
||||||
|
config: Config,
|
||||||
|
*,
|
||||||
|
config_path: Path | None = None,
|
||||||
|
) -> Config:
|
||||||
"""Return *config* with ``${VAR}`` env-var references resolved.
|
"""Return *config* with ``${VAR}`` env-var references resolved.
|
||||||
|
|
||||||
Walks in place so fields declared with ``exclude=True`` survive;
|
Walks in place so fields declared with ``exclude=True`` survive;
|
||||||
returns the same instance when no references are present.
|
returns the same instance when no references are present.
|
||||||
Raises ``ValueError`` if a referenced variable is not set.
|
Raises ``ConfigLoadError`` if a referenced variable is not set.
|
||||||
"""
|
"""
|
||||||
|
missing = tuple(_missing_env_issues(config))
|
||||||
|
if missing:
|
||||||
|
raise ConfigLoadError(
|
||||||
|
config_path or get_config_path(),
|
||||||
|
kind="missing_env",
|
||||||
|
summary=f"Found {len(missing)} missing environment variable reference(s).",
|
||||||
|
issues=missing,
|
||||||
|
)
|
||||||
return _resolve_in_place(config)
|
return _resolve_in_place(config)
|
||||||
|
|
||||||
|
|
||||||
@@ -176,6 +253,42 @@ def _resolve_in_place(obj: Any) -> Any:
|
|||||||
return obj
|
return obj
|
||||||
|
|
||||||
|
|
||||||
|
def _missing_env_issues(
|
||||||
|
obj: Any,
|
||||||
|
path: tuple[str | int, ...] = (),
|
||||||
|
) -> list[ConfigIssue]:
|
||||||
|
if isinstance(obj, str):
|
||||||
|
return [
|
||||||
|
ConfigIssue(
|
||||||
|
path=path,
|
||||||
|
message=f"Environment variable '{name}' is not set.",
|
||||||
|
)
|
||||||
|
for name in dict.fromkeys(_ENV_REF_PATTERN.findall(obj))
|
||||||
|
if name not in os.environ
|
||||||
|
]
|
||||||
|
if isinstance(obj, BaseModel):
|
||||||
|
issues: list[ConfigIssue] = []
|
||||||
|
for name, field in type(obj).model_fields.items():
|
||||||
|
alias = field.serialization_alias or field.alias or name
|
||||||
|
part = alias if isinstance(alias, str) else name
|
||||||
|
issues.extend(_missing_env_issues(getattr(obj, name), (*path, part)))
|
||||||
|
for name, value in (obj.__pydantic_extra__ or {}).items():
|
||||||
|
issues.extend(_missing_env_issues(value, (*path, name)))
|
||||||
|
return issues
|
||||||
|
if isinstance(obj, dict):
|
||||||
|
issues = []
|
||||||
|
for name, value in obj.items():
|
||||||
|
part = name if isinstance(name, (str, int)) else str(name)
|
||||||
|
issues.extend(_missing_env_issues(value, (*path, part)))
|
||||||
|
return issues
|
||||||
|
if isinstance(obj, list):
|
||||||
|
issues = []
|
||||||
|
for index, value in enumerate(obj):
|
||||||
|
issues.extend(_missing_env_issues(value, (*path, index)))
|
||||||
|
return issues
|
||||||
|
return []
|
||||||
|
|
||||||
|
|
||||||
def _resolve_env_vars(obj: object) -> object:
|
def _resolve_env_vars(obj: object) -> object:
|
||||||
"""Recursively resolve ``${VAR}`` patterns in plain strings/dicts/lists."""
|
"""Recursively resolve ``${VAR}`` patterns in plain strings/dicts/lists."""
|
||||||
if isinstance(obj, str):
|
if isinstance(obj, str):
|
||||||
@@ -201,15 +314,26 @@ def _migrate_config(data: dict) -> dict:
|
|||||||
"""Migrate old config formats to current."""
|
"""Migrate old config formats to current."""
|
||||||
# Move tools.exec.restrictToWorkspace → tools.restrictToWorkspace
|
# Move tools.exec.restrictToWorkspace → tools.restrictToWorkspace
|
||||||
tools = data.get("tools", {})
|
tools = data.get("tools", {})
|
||||||
|
if not isinstance(tools, dict):
|
||||||
|
return data
|
||||||
exec_cfg = tools.get("exec", {})
|
exec_cfg = tools.get("exec", {})
|
||||||
if "restrictToWorkspace" in exec_cfg and "restrictToWorkspace" not in tools:
|
if (
|
||||||
|
isinstance(exec_cfg, dict)
|
||||||
|
and "restrictToWorkspace" in exec_cfg
|
||||||
|
and "restrictToWorkspace" not in tools
|
||||||
|
):
|
||||||
tools["restrictToWorkspace"] = exec_cfg.pop("restrictToWorkspace")
|
tools["restrictToWorkspace"] = exec_cfg.pop("restrictToWorkspace")
|
||||||
|
|
||||||
# Move tools.myEnabled / tools.mySet → tools.my.{enable, allowSet}.
|
# Move tools.myEnabled / tools.mySet → tools.my.{enable, allowSet}.
|
||||||
# The old flat keys shipped in the initial MyTool landing; wrapping them in a
|
# The old flat keys shipped in the initial MyTool landing; wrapping them in a
|
||||||
# sub-config keeps `web` / `exec` / `my` symmetric and gives room to grow.
|
# sub-config keeps `web` / `exec` / `my` symmetric and gives room to grow.
|
||||||
if "myEnabled" in tools or "mySet" in tools:
|
if "myEnabled" in tools or "mySet" in tools:
|
||||||
my_cfg = tools.setdefault("my", {})
|
my_cfg = tools.get("my")
|
||||||
|
if my_cfg is None:
|
||||||
|
my_cfg = {}
|
||||||
|
tools["my"] = my_cfg
|
||||||
|
if not isinstance(my_cfg, dict):
|
||||||
|
return data
|
||||||
if "myEnabled" in tools and "enable" not in my_cfg:
|
if "myEnabled" in tools and "enable" not in my_cfg:
|
||||||
my_cfg["enable"] = tools.pop("myEnabled")
|
my_cfg["enable"] = tools.pop("myEnabled")
|
||||||
else:
|
else:
|
||||||
@@ -220,3 +344,10 @@ def _migrate_config(data: dict) -> dict:
|
|||||||
tools.pop("mySet", None)
|
tools.pop("mySet", None)
|
||||||
|
|
||||||
return data
|
return data
|
||||||
|
|
||||||
|
|
||||||
|
def _sentence(message: str) -> str:
|
||||||
|
message = message.strip()
|
||||||
|
if message and message[-1] not in ".!?":
|
||||||
|
message += "."
|
||||||
|
return message
|
||||||
|
|||||||
+4
-1
@@ -105,7 +105,10 @@ class Nanobot:
|
|||||||
if not resolved.exists():
|
if not resolved.exists():
|
||||||
raise FileNotFoundError(f"Config not found: {resolved}")
|
raise FileNotFoundError(f"Config not found: {resolved}")
|
||||||
|
|
||||||
config: Config = resolve_config_env_vars(load_config(resolved))
|
config: Config = resolve_config_env_vars(
|
||||||
|
load_config(resolved),
|
||||||
|
config_path=resolved,
|
||||||
|
)
|
||||||
if workspace is not None:
|
if workspace is not None:
|
||||||
config.agents.defaults.workspace = str(
|
config.agents.defaults.workspace = str(
|
||||||
Path(workspace).expanduser().resolve()
|
Path(workspace).expanduser().resolve()
|
||||||
|
|||||||
@@ -21,6 +21,15 @@ class ProviderSnapshot:
|
|||||||
model_preset: str | None = None
|
model_preset: str | None = None
|
||||||
|
|
||||||
|
|
||||||
|
@dataclass(frozen=True)
|
||||||
|
class _ProviderSetup:
|
||||||
|
model: str
|
||||||
|
provider_name: str
|
||||||
|
provider_config: ProviderConfig | None
|
||||||
|
spec: ProviderSpec | None
|
||||||
|
backend: str
|
||||||
|
|
||||||
|
|
||||||
def _resolve_model_preset(
|
def _resolve_model_preset(
|
||||||
config: Config,
|
config: Config,
|
||||||
*,
|
*,
|
||||||
@@ -40,18 +49,20 @@ def _provider_extra_headers(
|
|||||||
return headers or None
|
return headers or None
|
||||||
|
|
||||||
|
|
||||||
def _make_provider_core(
|
def _resolve_provider_setup(
|
||||||
config: Config,
|
config: Config,
|
||||||
*,
|
*,
|
||||||
preset: ModelPresetConfig,
|
preset: ModelPresetConfig,
|
||||||
model: str | None = None,
|
model: str | None = None,
|
||||||
) -> LLMProvider:
|
) -> _ProviderSetup:
|
||||||
"""Create a plain LLM provider without failover wrapping."""
|
"""Resolve and validate provider configuration without constructing a client."""
|
||||||
model = model or preset.model
|
model = model or preset.model
|
||||||
provider_name = config.get_provider_name(model, preset=preset)
|
provider_name = config.get_provider_name(model, preset=preset)
|
||||||
p = config.get_provider(model, preset=preset)
|
p = config.get_provider(model, preset=preset)
|
||||||
spec = find_by_name(provider_name) if provider_name else None
|
if not provider_name:
|
||||||
if provider_name and not spec and p:
|
raise ValueError(f"No provider is configured for model '{model}'.")
|
||||||
|
spec = find_by_name(provider_name)
|
||||||
|
if not spec and p:
|
||||||
if not p.api_base:
|
if not p.api_base:
|
||||||
raise ValueError(f"Provider '{provider_name}' requires api_base in config.")
|
raise ValueError(f"Provider '{provider_name}' requires api_base in config.")
|
||||||
spec = create_dynamic_spec(
|
spec = create_dynamic_spec(
|
||||||
@@ -79,12 +90,57 @@ def _make_provider_core(
|
|||||||
and not (p and p.api_base)
|
and not (p and p.api_base)
|
||||||
):
|
):
|
||||||
raise ValueError(f"Provider '{provider_name}' requires api_base in config.")
|
raise ValueError(f"Provider '{provider_name}' requires api_base in config.")
|
||||||
elif backend == "openai_compat" and not model.startswith("bedrock/"):
|
elif backend in {"anthropic", "openai_compat"} and not (
|
||||||
|
backend == "openai_compat" and model.startswith("bedrock/")
|
||||||
|
):
|
||||||
needs_key = not (p and p.api_key)
|
needs_key = not (p and p.api_key)
|
||||||
exempt = spec and (spec.is_oauth or spec.is_local or spec.is_direct)
|
exempt = spec and (spec.is_oauth or spec.is_local or spec.is_direct)
|
||||||
if needs_key and not exempt:
|
if needs_key and not exempt:
|
||||||
raise ValueError(f"No API key configured for provider '{provider_name}'.")
|
raise ValueError(f"No API key configured for provider '{provider_name}'.")
|
||||||
|
|
||||||
|
return _ProviderSetup(
|
||||||
|
model=model,
|
||||||
|
provider_name=provider_name,
|
||||||
|
provider_config=p,
|
||||||
|
spec=spec,
|
||||||
|
backend=backend,
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
def validate_provider_setup(
|
||||||
|
config: Config,
|
||||||
|
*,
|
||||||
|
preset_name: str | None = None,
|
||||||
|
preset: ModelPresetConfig | None = None,
|
||||||
|
model: str | None = None,
|
||||||
|
) -> None:
|
||||||
|
"""Validate local provider/model settings without loading a provider client."""
|
||||||
|
resolved = _resolve_model_preset(config, preset_name=preset_name, preset=preset)
|
||||||
|
_resolve_provider_setup(
|
||||||
|
config,
|
||||||
|
preset=resolved,
|
||||||
|
model=model,
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
def _make_provider_core(
|
||||||
|
config: Config,
|
||||||
|
*,
|
||||||
|
preset: ModelPresetConfig,
|
||||||
|
model: str | None = None,
|
||||||
|
) -> LLMProvider:
|
||||||
|
"""Create a plain LLM provider without failover wrapping."""
|
||||||
|
setup = _resolve_provider_setup(
|
||||||
|
config,
|
||||||
|
preset=preset,
|
||||||
|
model=model,
|
||||||
|
)
|
||||||
|
model = setup.model
|
||||||
|
provider_name = setup.provider_name
|
||||||
|
p = setup.provider_config
|
||||||
|
spec = setup.spec
|
||||||
|
backend = setup.backend
|
||||||
|
|
||||||
if backend == "openai_codex":
|
if backend == "openai_codex":
|
||||||
from nanobot.providers.openai_codex_provider import OpenAICodexProvider
|
from nanobot.providers.openai_codex_provider import OpenAICodexProvider
|
||||||
|
|
||||||
@@ -315,6 +371,9 @@ def load_provider_snapshot(
|
|||||||
from nanobot.config.loader import load_config, resolve_config_env_vars
|
from nanobot.config.loader import load_config, resolve_config_env_vars
|
||||||
|
|
||||||
return build_provider_snapshot(
|
return build_provider_snapshot(
|
||||||
resolve_config_env_vars(load_config(config_path)),
|
resolve_config_env_vars(
|
||||||
|
load_config(config_path),
|
||||||
|
config_path=config_path,
|
||||||
|
),
|
||||||
preset_name=preset_name,
|
preset_name=preset_name,
|
||||||
)
|
)
|
||||||
|
|||||||
@@ -1,4 +1,5 @@
|
|||||||
import asyncio
|
import asyncio
|
||||||
|
import json
|
||||||
from pathlib import Path
|
from pathlib import Path
|
||||||
from types import SimpleNamespace
|
from types import SimpleNamespace
|
||||||
from unittest.mock import MagicMock
|
from unittest.mock import MagicMock
|
||||||
@@ -8,6 +9,7 @@ import pytest
|
|||||||
from nanobot.agent.loop import AgentLoop
|
from nanobot.agent.loop import AgentLoop
|
||||||
from nanobot.bus.queue import MessageBus
|
from nanobot.bus.queue import MessageBus
|
||||||
from nanobot.bus.runtime_events import RuntimeModelChanged
|
from nanobot.bus.runtime_events import RuntimeModelChanged
|
||||||
|
from nanobot.config.errors import ConfigLoadError
|
||||||
from nanobot.config.loader import save_config
|
from nanobot.config.loader import save_config
|
||||||
from nanobot.config.schema import Config, ModelPresetConfig
|
from nanobot.config.schema import Config, ModelPresetConfig
|
||||||
from nanobot.providers.base import GenerationSettings
|
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()
|
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:
|
def test_same_snapshot_default_clears_preset_and_publishes_update(tmp_path: Path) -> None:
|
||||||
base_provider = _provider("base-model")
|
base_provider = _provider("base-model")
|
||||||
fast_provider = _provider("fast-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:
|
def _patch_webui_provider_ready(monkeypatch) -> None:
|
||||||
provider = _fake_provider()
|
monkeypatch.setattr(
|
||||||
|
"nanobot.providers.factory.validate_provider_setup",
|
||||||
def _snapshot(config: Config, **_kwargs) -> ProviderSnapshot:
|
lambda _config: None,
|
||||||
return _test_provider_snapshot(provider, config)
|
)
|
||||||
|
|
||||||
monkeypatch.setattr("nanobot.providers.factory.build_provider_snapshot", _snapshot)
|
|
||||||
|
|
||||||
|
|
||||||
def _patch_gateway_ports_free(monkeypatch) -> None:
|
def _patch_gateway_ports_free(monkeypatch) -> None:
|
||||||
@@ -1883,6 +1881,10 @@ def _patch_cli_command_runtime(
|
|||||||
"nanobot.providers.factory.load_provider_snapshot",
|
"nanobot.providers.factory.load_provider_snapshot",
|
||||||
lambda _config_path=None: _test_provider_snapshot(provider_factory(config), config),
|
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)
|
_patch_gateway_ports_free(monkeypatch)
|
||||||
|
|
||||||
if message_bus is not None:
|
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 result.exit_code == 1
|
||||||
assert missing_env in result.stdout
|
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")
|
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 result.exit_code == 1
|
||||||
assert "provider/model setup is incomplete" in result.stdout
|
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:
|
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.config.loader.load_config", lambda _path=None: config)
|
||||||
monkeypatch.setattr("nanobot.cli.commands.sync_workspace_templates", lambda _path: None)
|
monkeypatch.setattr("nanobot.cli.commands.sync_workspace_templates", lambda _path: None)
|
||||||
monkeypatch.setattr("nanobot.providers.factory.make_provider", lambda _config: provider)
|
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)
|
_patch_gateway_ports_free(monkeypatch)
|
||||||
monkeypatch.setattr(
|
monkeypatch.setattr(
|
||||||
"nanobot.providers.factory.build_provider_snapshot",
|
"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.config.loader.load_config", lambda _path=None: config)
|
||||||
monkeypatch.setattr("nanobot.cli.commands.sync_workspace_templates", lambda _path: None)
|
monkeypatch.setattr("nanobot.cli.commands.sync_workspace_templates", lambda _path: None)
|
||||||
monkeypatch.setattr("nanobot.providers.factory.make_provider", lambda _config: provider)
|
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)
|
_patch_gateway_ports_free(monkeypatch)
|
||||||
monkeypatch.setattr(
|
monkeypatch.setattr(
|
||||||
"nanobot.providers.factory.build_provider_snapshot",
|
"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"]
|
agent_kwargs = seen["agent_from_config_kwargs"]
|
||||||
kwargs = seen["local_trigger_queue_kwargs"]
|
kwargs = seen["local_trigger_queue_kwargs"]
|
||||||
assert isinstance(agent_kwargs["provider"], UnconfiguredProvider) is bool(setup_error)
|
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 "local_trigger_store" in agent_kwargs
|
||||||
assert kwargs["store"] is agent_kwargs["local_trigger_store"]
|
assert kwargs["store"] is agent_kwargs["local_trigger_store"]
|
||||||
assert "bus" not in kwargs
|
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
|
from pathlib import Path
|
||||||
|
|
||||||
|
import pytest
|
||||||
import typer
|
import typer
|
||||||
from rich.console import Console
|
from rich.console import Console
|
||||||
from typer.testing import CliRunner
|
from typer.testing import CliRunner
|
||||||
@@ -27,6 +28,7 @@ class FakeRuntime:
|
|||||||
self.restarted_options: GatewayStartOptions | None = None
|
self.restarted_options: GatewayStartOptions | None = None
|
||||||
self.stop_timeout: int | None = None
|
self.stop_timeout: int | None = None
|
||||||
self.follow_tail: int | None = None
|
self.follow_tail: int | None = None
|
||||||
|
self.validated_configs: list[Config] = []
|
||||||
|
|
||||||
def start_background(self, options: GatewayStartOptions) -> RuntimeResult:
|
def start_background(self, options: GatewayStartOptions) -> RuntimeResult:
|
||||||
self.started_options = options
|
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()
|
app = typer.Typer()
|
||||||
fake_runtime = FakeRuntime(tmp_path)
|
fake_runtime = FakeRuntime(tmp_path)
|
||||||
fake_service = FakeServiceInstaller(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]] = []
|
prepare_calls: list[tuple[Config, str]] = []
|
||||||
|
|
||||||
def load_runtime_config(_config_path: str | None, _workspace: str | None) -> Config:
|
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,
|
port: int | None = None,
|
||||||
webui_bundle_mode: str | None = None,
|
webui_bundle_mode: str | None = None,
|
||||||
|
unconfigured_provider_error: str | None = 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:
|
def prepare_webui_bundle(config: Config, mode: str) -> None:
|
||||||
prepare_calls.append((config, mode))
|
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(
|
app.add_typer(
|
||||||
create_gateway_app(
|
create_gateway_app(
|
||||||
console=Console(),
|
console=Console(),
|
||||||
log_handler_id=0,
|
log_handler_id=0,
|
||||||
load_runtime_config=load_runtime_config,
|
load_runtime_config=load_runtime_config,
|
||||||
run_gateway=run_gateway,
|
run_gateway=run_gateway,
|
||||||
|
validate_startup_config=(
|
||||||
|
validate_startup_config if startup_error is not None else None
|
||||||
|
),
|
||||||
runtime_factory=lambda **_kwargs: fake_runtime,
|
runtime_factory=lambda **_kwargs: fake_runtime,
|
||||||
service_factory=lambda: fake_service,
|
service_factory=lambda: fake_service,
|
||||||
prepare_webui_bundle=prepare_webui_bundle,
|
prepare_webui_bundle=prepare_webui_bundle,
|
||||||
@@ -131,6 +147,46 @@ def test_gateway_default_still_runs_foreground(tmp_path):
|
|||||||
assert calls[0][2] == "warn"
|
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):
|
def test_gateway_background_starts_detached_runtime(tmp_path):
|
||||||
config = Config()
|
config = Config()
|
||||||
config.gateway.port = 18792
|
config.gateway.port = 18792
|
||||||
|
|||||||
@@ -2,6 +2,7 @@ import json
|
|||||||
|
|
||||||
import pytest
|
import pytest
|
||||||
|
|
||||||
|
from nanobot.config.errors import ConfigLoadError
|
||||||
from nanobot.config.loader import load_config
|
from nanobot.config.loader import load_config
|
||||||
from nanobot.config.schema import ApiConfig
|
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
|
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:
|
def test_load_config_invalid_json_fails_fast(tmp_path) -> None:
|
||||||
config_path = tmp_path / "config.json"
|
config_path = tmp_path / "config.json"
|
||||||
config_path.write_text("{broken json", encoding="utf-8")
|
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)
|
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:
|
def test_load_config_invalid_schema_fails_fast(tmp_path) -> None:
|
||||||
config_path = tmp_path / "config.json"
|
config_path = tmp_path / "config.json"
|
||||||
@@ -27,9 +50,113 @@ def test_load_config_invalid_schema_fails_fast(tmp_path) -> None:
|
|||||||
encoding="utf-8",
|
encoding="utf-8",
|
||||||
)
|
)
|
||||||
|
|
||||||
with pytest.raises(ValueError, match="Failed to load config"):
|
with pytest.raises(ConfigLoadError) as exc_info:
|
||||||
load_config(config_path)
|
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", "::"])
|
@pytest.mark.parametrize("host", ["0.0.0.0", "::"])
|
||||||
def test_api_config_requires_key_for_wildcard_hosts(host: str) -> None:
|
def test_api_config_requires_key_for_wildcard_hosts(host: str) -> None:
|
||||||
|
|||||||
@@ -2,6 +2,7 @@ import json
|
|||||||
|
|
||||||
import pytest
|
import pytest
|
||||||
|
|
||||||
|
from nanobot.config.errors import ConfigLoadError
|
||||||
from nanobot.config.loader import (
|
from nanobot.config.loader import (
|
||||||
_resolve_env_vars,
|
_resolve_env_vars,
|
||||||
load_config,
|
load_config,
|
||||||
@@ -66,6 +67,22 @@ class TestResolveConfig:
|
|||||||
resolved = resolve_config_env_vars(raw)
|
resolved = resolve_config_env_vars(raw)
|
||||||
assert resolved.providers.groq.api_key == "resolved-key"
|
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):
|
def test_save_preserves_templates(self, tmp_path, monkeypatch):
|
||||||
monkeypatch.setenv("MY_TOKEN", "real-token")
|
monkeypatch.setenv("MY_TOKEN", "real-token")
|
||||||
config_path = tmp_path / "config.json"
|
config_path = tmp_path / "config.json"
|
||||||
|
|||||||
@@ -1,7 +1,10 @@
|
|||||||
|
import json
|
||||||
import warnings
|
import warnings
|
||||||
|
|
||||||
import pytest
|
import pytest
|
||||||
|
|
||||||
|
from nanobot.agent.model_presets import load_model_preset_catalog
|
||||||
|
from nanobot.config.errors import ConfigLoadError
|
||||||
from nanobot.config.schema import Config
|
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
|
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:
|
def test_agent_timezone_rejects_unknown_iana_name() -> None:
|
||||||
with pytest.raises(ValueError, match="unknown timezone"):
|
with pytest.raises(ValueError, match="unknown timezone"):
|
||||||
Config.model_validate({"agents": {"defaults": {"timezone": "Not/AZone"}}})
|
Config.model_validate({"agents": {"defaults": {"timezone": "Not/AZone"}}})
|
||||||
|
|||||||
@@ -74,6 +74,26 @@ def test_from_config_missing_file():
|
|||||||
Nanobot.from_config("/nonexistent/config.json")
|
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):
|
def test_from_config_creates_instance(tmp_path):
|
||||||
config_path = _write_config(tmp_path)
|
config_path = _write_config(tmp_path)
|
||||||
bot = Nanobot.from_config(config_path, workspace=tmp_path)
|
bot = Nanobot.from_config(config_path, workspace=tmp_path)
|
||||||
|
|||||||
Reference in New Issue
Block a user