From 9b1299458d07754c8644079ee466441d43c67a69 Mon Sep 17 00:00:00 2001 From: anti Date: Sun, 19 Apr 2026 02:43:25 -0400 Subject: [PATCH] fix(env): resolve DECNET_JWT_SECRET lazily so agent/updater subcommands don't need it The module-level _require_env('DECNET_JWT_SECRET') call blocked `decnet agent` and `decnet updater` from starting on workers that legitimately have no business knowing the master's JWT signing key. Move the resolution into a module `__getattr__`: only consumers that actually read `decnet.env.DECNET_JWT_SECRET` trigger the validation, which in practice means only decnet.web.auth (master-side). Adds tests/test_env_lazy_jwt.py covering both the in-process lazy path and an out-of-process `decnet agent --help` subprocess check with a fully sanitized environment. --- decnet/env.py | 11 ++++++- tests/test_env_lazy_jwt.py | 60 ++++++++++++++++++++++++++++++++++++++ 2 files changed, 70 insertions(+), 1 deletion(-) create mode 100644 tests/test_env_lazy_jwt.py diff --git a/decnet/env.py b/decnet/env.py index cb64caa..08556ca 100644 --- a/decnet/env.py +++ b/decnet/env.py @@ -79,7 +79,9 @@ DECNET_PROFILE_DIR: str = os.environ.get("DECNET_PROFILE_DIR", "profiles") # API Options DECNET_API_HOST: str = os.environ.get("DECNET_API_HOST", "127.0.0.1") DECNET_API_PORT: int = _port("DECNET_API_PORT", 8000) -DECNET_JWT_SECRET: str = _require_env("DECNET_JWT_SECRET") +# DECNET_JWT_SECRET is resolved lazily via module __getattr__ so that agent / +# updater / swarmctl subcommands (which never touch auth) can start without +# the master's JWT secret being present in the environment. DECNET_INGEST_LOG_FILE: str | None = os.environ.get("DECNET_INGEST_LOG_FILE", "/var/log/decnet/decnet.log") # SWARM log pipeline — RFC 5425 syslog-over-TLS between worker forwarders @@ -124,3 +126,10 @@ _web_hostname: str = "localhost" if DECNET_WEB_HOST in _WILDCARD_ADDRS else DECN _cors_default: str = f"http://{_web_hostname}:{DECNET_WEB_PORT}" _cors_raw: str = os.environ.get("DECNET_CORS_ORIGINS", _cors_default) DECNET_CORS_ORIGINS: list[str] = [o.strip() for o in _cors_raw.split(",") if o.strip()] + + +def __getattr__(name: str) -> str: + """Lazy resolution for secrets only the master web/api process needs.""" + if name == "DECNET_JWT_SECRET": + return _require_env("DECNET_JWT_SECRET") + raise AttributeError(f"module 'decnet.env' has no attribute {name!r}") diff --git a/tests/test_env_lazy_jwt.py b/tests/test_env_lazy_jwt.py new file mode 100644 index 0000000..d4eab6e --- /dev/null +++ b/tests/test_env_lazy_jwt.py @@ -0,0 +1,60 @@ +"""The JWT secret must be lazy: agent/updater subcommands should import +`decnet.env` without DECNET_JWT_SECRET being set.""" +from __future__ import annotations + +import importlib +import os +import sys + +import pytest + + +def _reimport_env(monkeypatch): + monkeypatch.delenv("DECNET_JWT_SECRET", raising=False) + for mod in list(sys.modules): + if mod == "decnet.env" or mod.startswith("decnet.env."): + sys.modules.pop(mod) + return importlib.import_module("decnet.env") + + +def test_env_imports_without_jwt_secret(monkeypatch): + env = _reimport_env(monkeypatch) + assert hasattr(env, "DECNET_API_PORT") + + +def test_jwt_secret_access_returns_value_when_set(monkeypatch): + monkeypatch.setenv("DECNET_JWT_SECRET", "x" * 32) + env = _reimport_env(monkeypatch) + monkeypatch.setenv("DECNET_JWT_SECRET", "x" * 32) + assert env.DECNET_JWT_SECRET == "x" * 32 + + +def test_agent_cli_imports_without_jwt_secret(monkeypatch, tmp_path): + """Subprocess check: `decnet agent --help` must succeed with no + DECNET_JWT_SECRET in the environment and no .env file in cwd.""" + import subprocess + import pathlib + clean_env = { + k: v for k, v in os.environ.items() + if not k.startswith("DECNET_") and not k.startswith("PYTEST") + } + clean_env["PATH"] = os.environ["PATH"] + clean_env["HOME"] = str(tmp_path) + repo = pathlib.Path(__file__).resolve().parent.parent + binary = repo / ".venv" / "bin" / "decnet" + result = subprocess.run( + [str(binary), "agent", "--help"], + cwd=str(tmp_path), + env=clean_env, + capture_output=True, + text=True, + timeout=15, + ) + assert result.returncode == 0, result.stderr + assert "worker agent" in result.stdout.lower() + + +def test_unknown_attr_still_raises(monkeypatch): + env = _reimport_env(monkeypatch) + with pytest.raises(AttributeError): + _ = env.DOES_NOT_EXIST