seren-observatory 0.0.0.dev0__py3-none-any.whl

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -0,0 +1,23 @@
1
+ """
2
+ seren-observatory - per-Jetson management plane.
3
+
4
+ HTTP API exposing manifest-driven discovery and lifecycle of locally-installed
5
+ Seren services (llama, kokoro, comfy, whisper, coral). Consumed by:
6
+ - SerenRuntimeHost (C#) for chat-app workflows
7
+ - SerenCommandCenter (SCC, future) for cluster orchestration
8
+ - The NUC dashboard for monitoring
9
+
10
+ Routes are versioned under /api/v1/. See service_routes.py and system_routes.py.
11
+ """
12
+ from __future__ import annotations
13
+
14
+ from importlib.metadata import PackageNotFoundError, version
15
+
16
+ try:
17
+ __version__: str = version("seren-observatory")
18
+ except PackageNotFoundError:
19
+ # Running from a source checkout without an editable install.
20
+ # Set SETUPTOOLS_SCM_PRETEND_VERSION=0.0.0 and run:
21
+ # pip install -e ".[dev]"
22
+ # to resolve this.
23
+ __version__ = "0.0.0.dev"
@@ -0,0 +1,37 @@
1
+ """Entry point for `python -m seren_observatory`.
2
+
3
+ Accepts --config / -c to match the SerenMemory convention (Memory leads, the
4
+ rest follow). Host/port come from the resolved config (which itself layers
5
+ defaults < yaml < env). The bearer token is NOT a config concern - it's loaded
6
+ separately from ~/.seren/secrets.json by auth.load_token(). See config.py.
7
+ """
8
+ from __future__ import annotations
9
+
10
+ import argparse
11
+
12
+ import uvicorn
13
+
14
+ from .app import create_app
15
+ from .config import load_config
16
+
17
+
18
+ def main() -> None:
19
+ parser = argparse.ArgumentParser(
20
+ prog="seren_observatory",
21
+ description="seren-observatory - per-node management plane.")
22
+ parser.add_argument(
23
+ "--config", "-c", default=None,
24
+ help="Path to seren-observatory.yaml (default: $SEREN_AGENT_CONFIG, then "
25
+ "~/seren-observatory/seren-observatory.yaml, falling back to built-in "
26
+ "defaults of 0.0.0.0:7777).")
27
+ args = parser.parse_args()
28
+
29
+ cfg = load_config(args.config)
30
+ app = create_app(cfg)
31
+
32
+ print(f"[seren-observatory] listening on {cfg.host}:{cfg.port}")
33
+ uvicorn.run(app, host=cfg.host, port=cfg.port, log_level="info")
34
+
35
+
36
+ if __name__ == "__main__":
37
+ main()
@@ -0,0 +1,24 @@
1
+ # file generated by vcs-versioning
2
+ # don't change, don't track in version control
3
+ from __future__ import annotations
4
+
5
+ __all__ = [
6
+ "__version__",
7
+ "__version_tuple__",
8
+ "version",
9
+ "version_tuple",
10
+ "__commit_id__",
11
+ "commit_id",
12
+ ]
13
+
14
+ version: str
15
+ __version__: str
16
+ __version_tuple__: tuple[int | str, ...]
17
+ version_tuple: tuple[int | str, ...]
18
+ commit_id: str | None
19
+ __commit_id__: str | None
20
+
21
+ __version__ = version = '0.0.0.dev0'
22
+ __version_tuple__ = version_tuple = (0, 0, 0, 'dev0')
23
+
24
+ __commit_id__ = commit_id = None
@@ -0,0 +1,172 @@
1
+ """
2
+ seren-observatory - main FastAPI app.
3
+
4
+ Run with:
5
+ seren-observatory # via the installed console script
6
+ python -m seren_observatory # config-aware entry (--config/-c)
7
+ python -m seren_observatory.app # directly from the source tree
8
+ uvicorn seren_observatory.app:app # ASGI server pointing at module app
9
+
10
+ or via systemd / a launcher. Listens on 0.0.0.0:7777 by default.
11
+
12
+ Config: host/port resolve via config.load_config() (defaults < yaml's server:
13
+ block < env vars). The bearer token is loaded SEPARATELY from
14
+ ~/.seren/secrets.json by auth.load_token() - it's a safety interlock, not a
15
+ config field. See config.py for why.
16
+ """
17
+ from __future__ import annotations
18
+
19
+ import os
20
+
21
+ from pathlib import Path
22
+
23
+ from fastapi import FastAPI
24
+ from fastapi.responses import HTMLResponse
25
+
26
+ from . import __version__, manifests
27
+ from .auth import BearerAuthMiddleware, load_token
28
+ from .config import ObservatoryConfig, load_config
29
+ from .request_log import RequestLoggingMiddleware
30
+ from .service_routes import register_all_services
31
+ from .system_routes import router as system_router
32
+
33
+ from seren_meninges import get_version
34
+ from seren_meninges.viewer import render_from_dir
35
+
36
+ # Version via the shared SerenMeninges helper: the installed wheel's
37
+ # setuptools-scm metadata, falling back to the package __version__ for an
38
+ # editable/dev checkout where dist metadata may be absent. get_version never
39
+ # raises - the same one-liner the rest of the family uses, replacing the old
40
+ # hand-rolled importlib.metadata block that drifted from the others.
41
+ APP_VERSION = get_version("seren-observatory", fallback=__version__)
42
+
43
+
44
+ def create_app(cfg: ObservatoryConfig | None = None) -> FastAPI:
45
+ # cfg is accepted so the config-aware entry points (python -m seren_observatory)
46
+ # can pass a resolved config. When called with no arg (e.g. the
47
+ # module-level `app` below, or `uvicorn seren_observatory.app:app`), fall back to
48
+ # load_config() so behaviour is identical either way. cfg currently carries
49
+ # host/port; those are consumed by the caller that runs uvicorn, so the app
50
+ # body doesn't need them - but we resolve it anyway so a future need (e.g.
51
+ # surfacing the bind in the root page) has it on hand without another load.
52
+ cfg = cfg or load_config()
53
+
54
+ app = FastAPI(
55
+ title="seren-observatory",
56
+ version=APP_VERSION,
57
+ description="Per-Jetson management plane. Manifest-driven service "
58
+ "lifecycle, status, and orchestration. Bearer token auth "
59
+ "on everything except /api/v1/system/{ping,version}.",
60
+ )
61
+
62
+ # Request logging - wraps every request, captures timing + status +
63
+ # 500 tracebacks. Logs go to BOTH stderr (journalctl) AND a rotating
64
+ # file at ~/seren-logs/observatory-requests.log (no sudo needed to read).
65
+ #
66
+ # MUST be added BEFORE auth so we log auth-rejected requests too -
67
+ # that's actually one of the most useful debug signals ("dashboard
68
+ # is failing - is the bearer token wrong, or is the route 500ing?")
69
+ #
70
+ # Starlette/FastAPI middleware order: LIFO at request time. So adding
71
+ # RequestLoggingMiddleware FIRST means it runs LAST on the way in
72
+ # (closest to the route), and FIRST on the way out - wrapping the
73
+ # entire chain. Adding auth SECOND means auth runs BEFORE logging on
74
+ # request, which is wrong. We want logging OUTERMOST.
75
+ #
76
+ # Correct stack (request flow top→bottom):
77
+ # RequestLoggingMiddleware (logs everything, including 401s)
78
+ # BearerAuthMiddleware (rejects unauthed, logging sees the rejection)
79
+ # <route handler>
80
+ #
81
+ # FastAPI add_middleware adds in REVERSE order at runtime, so we add
82
+ # auth FIRST (will run inner) and logging SECOND (will run outer).
83
+ token = load_token()
84
+ app.add_middleware(BearerAuthMiddleware, expected_token=token)
85
+ app.add_middleware(RequestLoggingMiddleware)
86
+
87
+ # Root info page - no service data, just links + auth status indicator
88
+ @app.get("/", response_class=HTMLResponse)
89
+ async def root() -> str:
90
+ node = manifests.load_node()
91
+ host = (node or {}).get("hostname", "unknown")
92
+ auth_state = "configured" if token else "DISABLED (no token in ~/.seren/secrets.json)"
93
+ return f"""<!doctype html>
94
+ <html><head><title>seren-observatory - {host}</title></head>
95
+ <body style="font-family: system-ui; max-width: 720px; margin: 2rem auto; padding: 0 1rem;">
96
+ <h1>seren-observatory</h1>
97
+ <p>Per-Jetson management plane for the Seren cluster.</p>
98
+ <dl>
99
+ <dt>Hostname:</dt> <dd>{host}</dd>
100
+ <dt>Observatory version:</dt> <dd>{APP_VERSION}</dd>
101
+ <dt>Auth:</dt> <dd>{auth_state}</dd>
102
+ </dl>
103
+ <h2>Endpoints</h2>
104
+ <ul>
105
+ <li><a href="/docs">/docs</a> - interactive API docs (Swagger)</li>
106
+ <li><a href="/api/v1/system/ping">/api/v1/system/ping</a> - public liveness</li>
107
+ <li><a href="/api/v1/system/version">/api/v1/system/version</a> - public version</li>
108
+ <li>/api/v1/system/{{node, services, health, reclaim}} - auth required</li>
109
+ <li>/api/v1/service/{{name}}/{{start, stop, restart, health, status, logs, manifest}} - auth required</li>
110
+ </ul>
111
+ <p>Source of truth: ~/.seren/services/*.json + ~/.seren/node.json</p>
112
+ </body></html>"""
113
+
114
+ # The Observatory glance - on the shared SerenMeninges baseplate.
115
+ @app.get("/viewer", response_class=HTMLResponse)
116
+ async def viewer() -> str:
117
+ # Node vitals, thermals, health rollup, and the service roster with
118
+ # lifecycle controls. PUBLIC route (the HTML shell needs no auth - see
119
+ # auth.PUBLIC_PATHS); its /api/v1/* fetches carry the token from the
120
+ # shell's key modal. With no token provisioned the read-only glance
121
+ # still works (safe GETs stay open); the action buttons fail closed
122
+ # (503) until ~/.seren/secrets.json exists - the deliberate interlock,
123
+ # surfaced in the UI instead of hidden.
124
+ return render_from_dir(
125
+ Path(__file__).resolve().parent / "viewer" / "ui",
126
+ title="seren-observatory",
127
+ brand="Seren<b>Observatory</b>",
128
+ subtitle=f"v{APP_VERSION} · per-node watch plane",
129
+ accent="#6eff70",
130
+ )
131
+
132
+ # System routes (ping, version, node, services, health, reclaim)
133
+ app.include_router(system_router)
134
+
135
+ # Per-service routes - one router per installed service
136
+ mounted = register_all_services(app)
137
+ print(f"[seren-observatory] mounted services: {mounted}")
138
+
139
+ return app
140
+
141
+
142
+ # Module-level app for `uvicorn seren_observatory.app:app`
143
+ app = create_app()
144
+
145
+
146
+ if __name__ == "__main__":
147
+ import uvicorn
148
+
149
+ # Direct `python -m seren_observatory.app` path. Resolve config the same way the
150
+ # console script does so host/port behave identically. (`python -m
151
+ # seren_observatory` -> __main__.py is the preferred, --config-aware entry.)
152
+ _cfg = load_config()
153
+ uvicorn.run(app, host=_cfg.host, port=_cfg.port, log_level="info")
154
+
155
+
156
+ def main() -> None:
157
+ """Console-script entry point: `seren-observatory` (declared in pyproject.toml).
158
+
159
+ Resolves config (defaults < yaml < env) and runs uvicorn. The CLI --config
160
+ flag is handled by __main__.py (`python -m seren_observatory`); the bare
161
+ console-script reads $SEREN_AGENT_CONFIG / the conventional path / defaults.
162
+ """
163
+ import uvicorn
164
+
165
+ cfg = load_config()
166
+ uvicorn.run(
167
+ "seren_observatory.app:app",
168
+ host=cfg.host,
169
+ port=cfg.port,
170
+ log_level="info",
171
+ reload=False,
172
+ )
@@ -0,0 +1,134 @@
1
+ """
2
+ Bearer-token auth middleware.
3
+
4
+ Token lives at ~/.seren/secrets.json with {"observatory_token": "..."}, chmod 600.
5
+ Generated by `seren-secrets.sh` which is invoked by foundation Phase 1
6
+ (or manually if missing).
7
+
8
+ Skipped paths:
9
+ / - root info page (links only, no service data)
10
+ /api/v1/system/ping - liveness probe
11
+ /api/v1/system/version - observatory version (no sensitive info)
12
+
13
+ Everything else requires `Authorization: Bearer <token>`.
14
+
15
+ When NO token is configured (fresh install before seren-secrets.sh runs),
16
+ the observatory stays reachable for safe, read-only requests (GET/HEAD/OPTIONS) so
17
+ monitoring and bootstrap work - but it FAILS CLOSED on any state-changing
18
+ method (POST/PUT/PATCH/DELETE). This plane can restart services and trigger a
19
+ sudoers-backed reboot; an unprovisioned observatory on 0.0.0.0 must never be an open
20
+ remote-reboot button. Provision the token to unlock mutating endpoints.
21
+
22
+ Threat model: this is a Jetson on your home LAN. The token protects against
23
+ casual LAN-mate snooping and prevents drive-by RCE if you ever expose the
24
+ observatory port outside your trusted network. It is NOT designed for multi-user
25
+ or untrusted-attacker scenarios. Use a VPN or firewall if those apply.
26
+ """
27
+ from __future__ import annotations
28
+
29
+ import hmac
30
+ import json
31
+ import os
32
+ from pathlib import Path
33
+
34
+ from fastapi import Request, Response
35
+ from fastapi.responses import JSONResponse
36
+ from starlette.middleware.base import BaseHTTPMiddleware
37
+ from starlette.types import ASGIApp
38
+
39
+ SECRETS_PATH = Path(os.path.expanduser("~")) / ".seren" / "secrets.json"
40
+
41
+ # HTTP methods that don't change state. When no token is configured these
42
+ # stay open (read-only introspection for monitoring/bootstrap); everything
43
+ # else is refused until a token exists.
44
+ _SAFE_METHODS = frozenset({"GET", "HEAD", "OPTIONS"})
45
+
46
+ # Paths that bypass auth. Keep this list MINIMAL - every entry here is an
47
+ # information disclosure or attack-surface concern.
48
+ PUBLIC_PATHS = frozenset({
49
+ "/",
50
+ "/viewer", # the glance HTML shell - public like /, but
51
+ # its /api/v1/* fetches still carry the token,
52
+ # and mutations still fail closed without one.
53
+ "/api/v1/system/ping",
54
+ "/api/v1/system/version",
55
+ })
56
+
57
+
58
+ def load_token() -> str | None:
59
+ """Load the observatory token from ~/.seren/secrets.json, or None if missing.
60
+
61
+ None means "auth is disabled" - the observatory will accept all requests. This
62
+ is meant as a fallback for fresh installs before seren-secrets.sh runs;
63
+ in production all installs should have a token.
64
+ """
65
+ if not SECRETS_PATH.is_file():
66
+ return None
67
+ try:
68
+ with open(SECRETS_PATH) as f:
69
+ data = json.load(f)
70
+ token = data.get("observatory_token")
71
+ if isinstance(token, str) and token:
72
+ return token
73
+ except (json.JSONDecodeError, OSError):
74
+ pass
75
+ return None
76
+
77
+
78
+ class BearerAuthMiddleware(BaseHTTPMiddleware):
79
+ """ASGI middleware that requires `Authorization: Bearer <token>` on
80
+ every request EXCEPT those listed in PUBLIC_PATHS."""
81
+
82
+ def __init__(self, app: ASGIApp, *, expected_token: str | None) -> None:
83
+ super().__init__(app)
84
+ self._expected = expected_token
85
+
86
+ async def dispatch(self, request: Request, call_next) -> Response:
87
+ path = request.url.path
88
+
89
+ # If no token configured, stay reachable for safe reads but refuse
90
+ # anything that changes state. The fresh-install convenience must not
91
+ # extend to remote service restarts or a sudoers-backed reboot.
92
+ if self._expected is None:
93
+ if request.method not in _SAFE_METHODS:
94
+ return JSONResponse(
95
+ {"error": "unauthorized",
96
+ "detail": "no observatory token configured; service-management "
97
+ "endpoints are disabled until ~/.seren/secrets.json "
98
+ "exists (run seren-secrets.sh)"},
99
+ status_code=503,
100
+ )
101
+ response = await call_next(request)
102
+ response.headers["X-Seren-Auth"] = "disabled-no-token-configured"
103
+ return response
104
+
105
+ if path in PUBLIC_PATHS:
106
+ return await call_next(request)
107
+
108
+ auth_header = request.headers.get("authorization", "")
109
+ if not auth_header.startswith("Bearer "):
110
+ return JSONResponse(
111
+ {"error": "unauthorized", "detail": "missing bearer token"},
112
+ status_code=401,
113
+ )
114
+
115
+ provided = auth_header[len("Bearer "):].strip()
116
+ # Constant-time compare to avoid timing leaks on token prefix
117
+ if not _constant_time_eq(provided, self._expected):
118
+ return JSONResponse(
119
+ {"error": "unauthorized", "detail": "invalid token"},
120
+ status_code=401,
121
+ )
122
+
123
+ return await call_next(request)
124
+
125
+
126
+ def _constant_time_eq(a: str, b: str) -> bool:
127
+ """Constant-time comparison via the stdlib's audited hmac.compare_digest.
128
+
129
+ Encodes to bytes so non-ASCII input can't raise (compare_digest rejects
130
+ non-ASCII str). Different-length inputs return False without raising, so
131
+ this is a drop-in for the previous hand-rolled version - and it means we
132
+ don't pull in the `cryptography` package just to compare two tokens.
133
+ """
134
+ return hmac.compare_digest(a.encode("utf-8"), b.encode("utf-8"))
@@ -0,0 +1,153 @@
1
+ """Config for seren-observatory.
2
+
3
+ Follows the SerenMemory convention (Memory leads, the rest follow) so a buddy
4
+ who set up one service already knows how to set up this one:
5
+
6
+ * network settings live under a ``server:`` block (host/port)
7
+ * config resolves: --config -> $SEREN_AGENT_CONFIG ->
8
+ ~/seren-observatory/seren-observatory.yaml -> built-in defaults
9
+ * the file is named seren-observatory.yaml
10
+
11
+ DELIBERATE EXCEPTION - the bearer token is NOT here. Unlike SerenMemory, the
12
+ observatory's token is a SAFETY INTERLOCK, not a config knob: this plane can restart
13
+ services and trigger a sudoers-backed reboot, so the token lives in
14
+ ~/.seren/secrets.json (chmod 600, written by seren-secrets.sh) and is loaded
15
+ by auth.load_token(). The observatory fails CLOSED on mutating methods when no token
16
+ exists. Putting the token in a yaml field would add a second, lower-security
17
+ path (yaml may be 644, may be committed) next to the deliberate secrets.json
18
+ one - a security regression dressed as consistency. Follow-the-leader on
19
+ STRUCTURE (--config, server: block, resolution order); NOT on collapsing auth
20
+ into config. (Same spirit as Margin keeping 127.0.0.1 instead of inheriting
21
+ Memory's 0.0.0.0.)
22
+
23
+ Precedence (highest wins):
24
+ 1. Env vars (AGENT_HOST/AGENT_PORT, and the SEREN_AGENT_* aliases)
25
+ 2. YAML file (operator's standing config)
26
+ 3. Defaults (0.0.0.0:7777 - the Seren cluster convention)
27
+
28
+ Lenient parse (Postel-as-kindness): missing file or malformed YAML -> log and
29
+ fall back to defaults; a bad single value -> that key falls back, others apply.
30
+ """
31
+ from __future__ import annotations
32
+
33
+ import os
34
+ from pathlib import Path
35
+ from typing import Any, Optional
36
+
37
+ from pydantic import BaseModel
38
+
39
+ try:
40
+ import yaml # type: ignore[import-untyped]
41
+ _HAS_YAML = True
42
+ except ImportError: # pragma: no cover - pyyaml is a hard dep, but be lenient
43
+ _HAS_YAML = False
44
+
45
+
46
+ class ObservatoryConfig(BaseModel):
47
+ """seren-observatory network config. Defaults match the cluster convention:
48
+ bind all interfaces (trusted LAN) on 7777.
49
+
50
+ NOTE: no token field here, on purpose - see module docstring.
51
+ """
52
+
53
+ # Bind all interfaces by default - the observatory is a cluster plane meant to be
54
+ # reached from the NUC/RuntimeHost across the trusted LAN. (Contrast Margin,
55
+ # which is private and binds 127.0.0.1.) The auth interlock, not the bind
56
+ # address, is what protects the mutating endpoints.
57
+ host: str = "0.0.0.0"
58
+ port: int = 7777
59
+
60
+
61
+ _DEFAULT_CONFIG_PATH = Path.home() / "seren-observatory" / "seren-observatory.yaml"
62
+
63
+
64
+ def _resolve_config_path(explicit_path: Optional[str] = None) -> Optional[Path]:
65
+ """--config -> $SEREN_AGENT_CONFIG -> ~/seren-observatory/seren-observatory.yaml -> None."""
66
+ if explicit_path:
67
+ return Path(explicit_path).expanduser()
68
+ env = os.getenv("SEREN_AGENT_CONFIG")
69
+ if env:
70
+ return Path(env).expanduser()
71
+ if _DEFAULT_CONFIG_PATH.exists():
72
+ return _DEFAULT_CONFIG_PATH
73
+ return None
74
+
75
+
76
+ def _load_yaml_lenient(path: Path) -> dict[str, Any]:
77
+ if not path.exists():
78
+ return {}
79
+ if not _HAS_YAML:
80
+ print(f"[seren-observatory] config: pyyaml not installed; ignoring {path}")
81
+ return {}
82
+ try:
83
+ with open(path, "r", encoding="utf-8") as f:
84
+ data = yaml.safe_load(f)
85
+ except Exception as e:
86
+ print(f"[seren-observatory] config: failed to parse {path}: {e} (using defaults)")
87
+ return {}
88
+ if data is None:
89
+ return {}
90
+ if not isinstance(data, dict):
91
+ print(f"[seren-observatory] config: {path} top-level must be a mapping; got "
92
+ f"{type(data).__name__} (using defaults)")
93
+ return {}
94
+ return data
95
+
96
+
97
+ def _apply_server_overrides(cfg: ObservatoryConfig, server: dict[str, Any], *, source: str) -> None:
98
+ """Apply per-key overrides; each key try/except'd so one bad value doesn't
99
+ sink the others. Only host/port are known - anything else is ignored with
100
+ a note (notably 'bearer_token', which is intentionally NOT honored here)."""
101
+ known = {"host", "port"}
102
+ for key, raw in server.items():
103
+ if key == "bearer_token":
104
+ # Loud, specific note: the token is not a config field by design.
105
+ print("[seren-observatory] config: 'bearer_token' in the yaml is ignored "
106
+ "by design - the observatory token lives in ~/.seren/secrets.json "
107
+ "(run seren-secrets.sh). See config.py for why.")
108
+ continue
109
+ if key not in known:
110
+ print(f"[seren-observatory] config: ignoring unknown server key '{key}' from {source}")
111
+ continue
112
+ try:
113
+ current = cfg.model_dump()
114
+ current[key] = raw
115
+ cfg.__dict__.update(ObservatoryConfig.model_validate(current).__dict__)
116
+ except Exception as e:
117
+ print(f"[seren-observatory] config: ignored bad value for '{key}' from {source}: {e}")
118
+
119
+
120
+ def load_config(path: Optional[str] = None) -> ObservatoryConfig:
121
+ """Defaults -> YAML (server: block) -> env vars. Never raises on bad input.
122
+
123
+ ``path`` is the --config flag value (highest-priority config location).
124
+ """
125
+ cfg = ObservatoryConfig()
126
+
127
+ # Layer 2: YAML
128
+ yaml_path = _resolve_config_path(path)
129
+ if yaml_path is not None:
130
+ data = _load_yaml_lenient(yaml_path)
131
+ server = data.get("server")
132
+ if isinstance(server, dict):
133
+ _apply_server_overrides(cfg, server, source=str(yaml_path))
134
+ elif server is not None:
135
+ print(f"[seren-observatory] config: 'server' in {yaml_path} must be a mapping; ignoring")
136
+
137
+ # Layer 3: env vars (highest precedence). Honor BOTH the original
138
+ # AGENT_HOST/AGENT_PORT (what app.py historically read, and what the old
139
+ # launcher exported) AND the SEREN_AGENT_* aliases the yaml sample
140
+ # documents. The SEREN_AGENT_* form wins if both are somehow set, since
141
+ # it's the documented, namespaced one.
142
+ env_overrides: dict[str, Any] = {}
143
+ for env_key, attr in (("AGENT_HOST", "host"),
144
+ ("AGENT_PORT", "port"),
145
+ ("SEREN_AGENT_HOST", "host"),
146
+ ("SEREN_AGENT_PORT", "port")):
147
+ v = os.getenv(env_key)
148
+ if v is not None:
149
+ env_overrides[attr] = v
150
+ if env_overrides:
151
+ _apply_server_overrides(cfg, env_overrides, source="environment")
152
+
153
+ return cfg