mirror of
https://github.com/HKUDS/nanobot.git
synced 2026-05-05 09:15:58 +00:00
feat: support ${VAR} env var interpolation in config secrets
Allow config.json to reference environment variables via ${VAR_NAME}
syntax. Variables are resolved at runtime by resolve_config_env_vars(),
keeping the raw templates in the Pydantic model so save_config()
preserves them. This lets secrets live in a separate env file
(e.g. loaded by systemd EnvironmentFile=) instead of plain text
in config.json.
This commit is contained in:
parent
7ffd93f48d
commit
202938ae73
35
README.md
35
README.md
@ -861,6 +861,41 @@ Config file: `~/.nanobot/config.json`
|
|||||||
> run `nanobot onboard`, then answer `N` when asked whether to overwrite the config.
|
> run `nanobot onboard`, then answer `N` when asked whether to overwrite the config.
|
||||||
> nanobot will merge in missing default fields and keep your current settings.
|
> nanobot will merge in missing default fields and keep your current settings.
|
||||||
|
|
||||||
|
### Environment Variables for Secrets
|
||||||
|
|
||||||
|
Instead of storing secrets directly in `config.json`, you can use `${VAR_NAME}` references that are resolved from environment variables at startup:
|
||||||
|
|
||||||
|
```json
|
||||||
|
{
|
||||||
|
"channels": {
|
||||||
|
"telegram": { "token": "${TELEGRAM_TOKEN}" },
|
||||||
|
"email": {
|
||||||
|
"imapPassword": "${IMAP_PASSWORD}",
|
||||||
|
"smtpPassword": "${SMTP_PASSWORD}"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"providers": {
|
||||||
|
"groq": { "apiKey": "${GROQ_API_KEY}" }
|
||||||
|
}
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
For **systemd** deployments, use `EnvironmentFile=` in the service unit to load variables from a file that only the deploying user can read:
|
||||||
|
|
||||||
|
```ini
|
||||||
|
# /etc/systemd/system/nanobot.service (excerpt)
|
||||||
|
[Service]
|
||||||
|
EnvironmentFile=/home/youruser/nanobot_secrets.env
|
||||||
|
User=nanobot
|
||||||
|
ExecStart=...
|
||||||
|
```
|
||||||
|
|
||||||
|
```bash
|
||||||
|
# /home/youruser/nanobot_secrets.env (mode 600, owned by youruser)
|
||||||
|
TELEGRAM_TOKEN=your-token-here
|
||||||
|
IMAP_PASSWORD=your-password-here
|
||||||
|
```
|
||||||
|
|
||||||
### Providers
|
### Providers
|
||||||
|
|
||||||
> [!TIP]
|
> [!TIP]
|
||||||
|
|||||||
@ -453,7 +453,7 @@ def _make_provider(config: Config):
|
|||||||
|
|
||||||
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, set_config_path
|
from nanobot.config.loader import load_config, resolve_config_env_vars, set_config_path
|
||||||
|
|
||||||
config_path = None
|
config_path = None
|
||||||
if config:
|
if config:
|
||||||
@ -464,7 +464,11 @@ 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]")
|
||||||
|
|
||||||
loaded = load_config(config_path)
|
try:
|
||||||
|
loaded = resolve_config_env_vars(load_config(config_path))
|
||||||
|
except ValueError as e:
|
||||||
|
console.print(f"[red]Error: {e}[/red]")
|
||||||
|
raise typer.Exit(1)
|
||||||
_warn_deprecated_config_keys(config_path)
|
_warn_deprecated_config_keys(config_path)
|
||||||
if workspace:
|
if workspace:
|
||||||
loaded.agents.defaults.workspace = workspace
|
loaded.agents.defaults.workspace = workspace
|
||||||
|
|||||||
@ -1,6 +1,8 @@
|
|||||||
"""Configuration loading utilities."""
|
"""Configuration loading utilities."""
|
||||||
|
|
||||||
import json
|
import json
|
||||||
|
import os
|
||||||
|
import re
|
||||||
from pathlib import Path
|
from pathlib import Path
|
||||||
|
|
||||||
import pydantic
|
import pydantic
|
||||||
@ -76,6 +78,38 @@ def save_config(config: Config, config_path: Path | None = None) -> None:
|
|||||||
json.dump(data, f, indent=2, ensure_ascii=False)
|
json.dump(data, f, indent=2, ensure_ascii=False)
|
||||||
|
|
||||||
|
|
||||||
|
def resolve_config_env_vars(config: Config) -> Config:
|
||||||
|
"""Return a copy of *config* with ``${VAR}`` env-var references resolved.
|
||||||
|
|
||||||
|
Only string values are affected; other types pass through unchanged.
|
||||||
|
Raises :class:`ValueError` if a referenced variable is not set.
|
||||||
|
"""
|
||||||
|
data = config.model_dump(mode="json", by_alias=True)
|
||||||
|
data = _resolve_env_vars(data)
|
||||||
|
return Config.model_validate(data)
|
||||||
|
|
||||||
|
|
||||||
|
def _resolve_env_vars(obj: object) -> object:
|
||||||
|
"""Recursively resolve ``${VAR}`` patterns in string values."""
|
||||||
|
if isinstance(obj, str):
|
||||||
|
return re.sub(r"\$\{([A-Za-z_][A-Za-z0-9_]*)\}", _env_replace, obj)
|
||||||
|
if isinstance(obj, dict):
|
||||||
|
return {k: _resolve_env_vars(v) for k, v in obj.items()}
|
||||||
|
if isinstance(obj, list):
|
||||||
|
return [_resolve_env_vars(v) for v in obj]
|
||||||
|
return obj
|
||||||
|
|
||||||
|
|
||||||
|
def _env_replace(match: re.Match[str]) -> str:
|
||||||
|
name = match.group(1)
|
||||||
|
value = os.environ.get(name)
|
||||||
|
if value is None:
|
||||||
|
raise ValueError(
|
||||||
|
f"Environment variable '{name}' referenced in config is not set"
|
||||||
|
)
|
||||||
|
return value
|
||||||
|
|
||||||
|
|
||||||
def _migrate_config(data: dict) -> dict:
|
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
|
||||||
|
|||||||
@ -47,7 +47,7 @@ class Nanobot:
|
|||||||
``~/.nanobot/config.json``.
|
``~/.nanobot/config.json``.
|
||||||
workspace: Override the workspace directory from config.
|
workspace: Override the workspace directory from config.
|
||||||
"""
|
"""
|
||||||
from nanobot.config.loader import load_config
|
from nanobot.config.loader import load_config, resolve_config_env_vars
|
||||||
from nanobot.config.schema import Config
|
from nanobot.config.schema import Config
|
||||||
|
|
||||||
resolved: Path | None = None
|
resolved: Path | None = None
|
||||||
@ -56,7 +56,7 @@ 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 = load_config(resolved)
|
config: Config = resolve_config_env_vars(load_config(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()
|
||||||
|
|||||||
@ -425,6 +425,7 @@ def mock_agent_runtime(tmp_path):
|
|||||||
config.agents.defaults.workspace = str(tmp_path / "default-workspace")
|
config.agents.defaults.workspace = str(tmp_path / "default-workspace")
|
||||||
|
|
||||||
with patch("nanobot.config.loader.load_config", return_value=config) as mock_load_config, \
|
with patch("nanobot.config.loader.load_config", return_value=config) as mock_load_config, \
|
||||||
|
patch("nanobot.config.loader.resolve_config_env_vars", side_effect=lambda c: c), \
|
||||||
patch("nanobot.cli.commands.sync_workspace_templates") as mock_sync_templates, \
|
patch("nanobot.cli.commands.sync_workspace_templates") as mock_sync_templates, \
|
||||||
patch("nanobot.cli.commands._make_provider", return_value=object()), \
|
patch("nanobot.cli.commands._make_provider", return_value=object()), \
|
||||||
patch("nanobot.cli.commands._print_agent_response") as mock_print_response, \
|
patch("nanobot.cli.commands._print_agent_response") as mock_print_response, \
|
||||||
@ -739,6 +740,7 @@ def _patch_cli_command_runtime(
|
|||||||
set_config_path or (lambda _path: None),
|
set_config_path or (lambda _path: None),
|
||||||
)
|
)
|
||||||
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.config.loader.resolve_config_env_vars", lambda c: c)
|
||||||
monkeypatch.setattr(
|
monkeypatch.setattr(
|
||||||
"nanobot.cli.commands.sync_workspace_templates",
|
"nanobot.cli.commands.sync_workspace_templates",
|
||||||
sync_templates or (lambda _path: None),
|
sync_templates or (lambda _path: None),
|
||||||
|
|||||||
82
tests/config/test_env_interpolation.py
Normal file
82
tests/config/test_env_interpolation.py
Normal file
@ -0,0 +1,82 @@
|
|||||||
|
import json
|
||||||
|
|
||||||
|
import pytest
|
||||||
|
|
||||||
|
from nanobot.config.loader import (
|
||||||
|
_resolve_env_vars,
|
||||||
|
load_config,
|
||||||
|
resolve_config_env_vars,
|
||||||
|
save_config,
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
class TestResolveEnvVars:
|
||||||
|
def test_replaces_string_value(self, monkeypatch):
|
||||||
|
monkeypatch.setenv("MY_SECRET", "hunter2")
|
||||||
|
assert _resolve_env_vars("${MY_SECRET}") == "hunter2"
|
||||||
|
|
||||||
|
def test_partial_replacement(self, monkeypatch):
|
||||||
|
monkeypatch.setenv("HOST", "example.com")
|
||||||
|
assert _resolve_env_vars("https://${HOST}/api") == "https://example.com/api"
|
||||||
|
|
||||||
|
def test_multiple_vars_in_one_string(self, monkeypatch):
|
||||||
|
monkeypatch.setenv("USER", "alice")
|
||||||
|
monkeypatch.setenv("PASS", "secret")
|
||||||
|
assert _resolve_env_vars("${USER}:${PASS}") == "alice:secret"
|
||||||
|
|
||||||
|
def test_nested_dicts(self, monkeypatch):
|
||||||
|
monkeypatch.setenv("TOKEN", "abc123")
|
||||||
|
data = {"channels": {"telegram": {"token": "${TOKEN}"}}}
|
||||||
|
result = _resolve_env_vars(data)
|
||||||
|
assert result["channels"]["telegram"]["token"] == "abc123"
|
||||||
|
|
||||||
|
def test_lists(self, monkeypatch):
|
||||||
|
monkeypatch.setenv("VAL", "x")
|
||||||
|
assert _resolve_env_vars(["${VAL}", "plain"]) == ["x", "plain"]
|
||||||
|
|
||||||
|
def test_ignores_non_strings(self):
|
||||||
|
assert _resolve_env_vars(42) == 42
|
||||||
|
assert _resolve_env_vars(True) is True
|
||||||
|
assert _resolve_env_vars(None) is None
|
||||||
|
assert _resolve_env_vars(3.14) == 3.14
|
||||||
|
|
||||||
|
def test_plain_strings_unchanged(self):
|
||||||
|
assert _resolve_env_vars("no vars here") == "no vars here"
|
||||||
|
|
||||||
|
def test_missing_var_raises(self):
|
||||||
|
with pytest.raises(ValueError, match="DOES_NOT_EXIST"):
|
||||||
|
_resolve_env_vars("${DOES_NOT_EXIST}")
|
||||||
|
|
||||||
|
|
||||||
|
class TestResolveConfig:
|
||||||
|
def test_resolves_env_vars_in_config(self, tmp_path, monkeypatch):
|
||||||
|
monkeypatch.setenv("TEST_API_KEY", "resolved-key")
|
||||||
|
config_path = tmp_path / "config.json"
|
||||||
|
config_path.write_text(
|
||||||
|
json.dumps(
|
||||||
|
{"providers": {"groq": {"apiKey": "${TEST_API_KEY}"}}}
|
||||||
|
),
|
||||||
|
encoding="utf-8",
|
||||||
|
)
|
||||||
|
|
||||||
|
raw = load_config(config_path)
|
||||||
|
assert raw.providers.groq.api_key == "${TEST_API_KEY}"
|
||||||
|
|
||||||
|
resolved = resolve_config_env_vars(raw)
|
||||||
|
assert resolved.providers.groq.api_key == "resolved-key"
|
||||||
|
|
||||||
|
def test_save_preserves_templates(self, tmp_path, monkeypatch):
|
||||||
|
monkeypatch.setenv("MY_TOKEN", "real-token")
|
||||||
|
config_path = tmp_path / "config.json"
|
||||||
|
config_path.write_text(
|
||||||
|
json.dumps(
|
||||||
|
{"channels": {"telegram": {"token": "${MY_TOKEN}"}}}
|
||||||
|
),
|
||||||
|
encoding="utf-8",
|
||||||
|
)
|
||||||
|
|
||||||
|
raw = load_config(config_path)
|
||||||
|
save_config(raw, config_path)
|
||||||
|
|
||||||
|
saved = json.loads(config_path.read_text(encoding="utf-8"))
|
||||||
|
assert saved["channels"]["telegram"]["token"] == "${MY_TOKEN}"
|
||||||
Loading…
x
Reference in New Issue
Block a user