refactor: centralize environment variable management with Pydantic schema + CI gate

- Add agent/src/config/env_schema.py: Pydantic models for ~50 env vars across 6 sub-models (LLMConfig, DataConfig, APIConfig, SwarmConfig, AgentTuningConfig, PathConfig)
- Add agent/src/config/accessor.py: thread-safe lazy singleton get_env_config() + unified _parse_bool() + reset_env_config() for test isolation
- Fix TIMEOUT_SECONDS CLI display default (2400 -> 120, matching runtime)
- Fix MAX_RETRIES CLI display default ('(not set)' -> 2, matching runtime)
- Fix VIBE_TRADING_API_KEY server-side alias (security.py now resolves it via get_env_config().api.api_auth_key)
- Fix VIBE_TRADING_CHANNELS_AUTO_START=on silently ignored (unified _parse_bool accepts 'on')
- Migrate 207 scattered os.getenv/os.environ.get calls across 50+ files to centralized accessor
- Convert 12 module-level import-time fixations to lazy accessor pattern (loop.py, swarm_tool.py, _backend.py, worker.py, security.py)
- Add reset_env_config() calls after settings_routes.py env mutations (13 mutation points)
- Replace print('[WARN]...') with logger.warning() in tushare/okx/yfinance loaders
- Add tools/ci_env_var_gate.py: AST-based CI gate preventing new os.getenv calls outside src/config/
- Add tools/test_ci_env_var_gate.py: 18 test cases for the CI gate
- Update tools/ci_grep_gates.sh to call the new env var gate
- Add agent/tests/test_env_schema.py: 81 test cases covering defaults, type coercion, singleton, thread safety, bool parser
- Update agent/.env.example with schema cross-references
- Update agent/tests/conftest.py with reset_env_config autouse fixture

Closes HKUDS/Vibe-Trading#438

Co-Authored-By: OpenCode <noreply@opencode.ai>
AI-Model: alibaba-cn/glm-5.2

Signed-off-by: shadowinlife <shadowinlife@gmail.com>
Co-Authored-By: opencode <noreply@ai-tool.com>
Co-Authored-By: Claude Code <noreply@anthropic.com>
AI-Contributed/Feature: 1383/1383
AI-Contributed/UT: 735/735
This commit is contained in:
shadowinlife committed 2026-07-08 19:36:24 +08:00
1 parent ef0335286c
commit 8234058562
54 files changed
+1907 -211

No files matched your search

+280
View File
@@ -0,0 +1,280 @@
#!/usr/bin/env python3
"""CI gate: reject raw os.getenv / os.environ reads outside config layer.
Scans Python files under the agent source tree and rejects any direct
environment-variable **read** that bypasses the centralized
``agent/src/config/`` accessor layer.
Detected patterns (READ calls):
os.getenv("KEY")
os.environ.get("KEY")
os.environ["KEY"] # subscript with Load context
Allowlisted (NOT flagged):
os.environ.copy()
os.environ.items()
os.environ.setdefault(...)
os.environ["KEY"] = value # Store context in Assign / AugAssign targets
os.environ.pop() # only in src/api/settings_routes.py + src/config/
Warnings (non-blocking):
os.environ.pop() outside src/api/settings_routes.py and src/config/
Usage::
python tools/ci_env_var_gate.py # scan, exit 0/1
python tools/ci_env_var_gate.py --allowlist # print allowlist and exit 0
Zero external dependencies — stdlib only (ast, os, sys, pathlib).
"""
from __future__ import annotations
import ast
import sys
from pathlib import Path
# ---------------------------------------------------------------------------
# Configuration
# ---------------------------------------------------------------------------
# Directories / files to scan (relative to repo root).
SCAN_TARGETS: list[str] = [
"agent/src/",
"agent/backtest/",
"agent/cli/",
"agent/mcp_server.py",
"agent/api_server.py",
"agent/scripts/",
]
# Directories always skipped (relative prefixes).
SKIP_PREFIXES: tuple[str, ...] = (
"agent/tests/",
)
# The allowed zone — reads here are the *point* of the config layer.
ALLOWED_PREFIX: str = "agent/src/config/"
# File (relative) where os.environ.pop() is explicitly permitted.
POP_ALLOWLISTED_FILE: str = "agent/src/api/settings_routes.py"
# ---------------------------------------------------------------------------
# AST visitor
# ---------------------------------------------------------------------------
class _EnvReadVisitor(ast.NodeVisitor):
"""Walk a single file's AST and collect disallowed env-var reads."""
def __init__(self, filepath: Path, repo_root: Path) -> None:
self._filepath = filepath
self._rel = str(filepath.relative_to(repo_root)).replace("\\", "/")
self._violations: list[tuple[int, str]] = []
self._warnings: list[tuple[int, str]] = []
# -- properties ---------------------------------------------------------
@property
def violations(self) -> list[tuple[int, str]]:
return list(self._violations)
@property
def warnings(self) -> list[tuple[int, str]]:
return list(self._warnings)
# -- helpers ------------------------------------------------------------
def _is_os_environ(self, node: ast.expr) -> bool:
"""Return True when *node* is ``os.environ``."""
return (
isinstance(node, ast.Attribute)
and isinstance(node.value, ast.Name)
and node.value.id == "os"
and node.attr == "environ"
)
def _is_os_getenv(self, node: ast.expr) -> bool:
"""Return True when *node* is ``os.getenv``."""
return (
isinstance(node, ast.Attribute)
and isinstance(node.value, ast.Name)
and node.value.id == "os"
and node.attr == "getenv"
)
# -- visitors -----------------------------------------------------------
def visit_Call(self, node: ast.Call) -> None: # noqa: N802
func = node.func
# os.getenv(...)
if self._is_os_getenv(func):
self._violations.append(
(node.lineno, "os.getenv() — use src.config accessor")
)
self.generic_visit(node)
return
# os.environ.<method>(...)
if (
isinstance(func, ast.Attribute)
and self._is_os_environ(func.value)
):
method = func.attr
# Allowlisted methods
if method in ("copy", "items", "setdefault"):
self.generic_visit(node)
return
# os.environ.get(...) — READ
if method == "get":
self._violations.append(
(node.lineno, "os.environ.get() — use src.config accessor")
)
self.generic_visit(node)
return
# os.environ.pop(...) — warning outside settings_routes / config
if method == "pop":
if self._rel != POP_ALLOWLISTED_FILE:
self._warnings.append(
(
node.lineno,
"os.environ.pop() outside settings_routes.py / config/",
)
)
self.generic_visit(node)
return
# Any other os.environ.<method>() — flag as read
self._violations.append(
(
node.lineno,
f"os.environ.{method}() — use src.config accessor",
)
)
self.generic_visit(node)
def visit_Subscript(self, node: ast.Subscript) -> None: # noqa: N802
# os.environ["KEY"] in a Load (read) context
if (
self._is_os_environ(node.value)
and isinstance(node.ctx, ast.Load)
):
self._violations.append(
(node.lineno, 'os.environ["..."] read — use src.config accessor')
)
self.generic_visit(node)
# ---------------------------------------------------------------------------
# File discovery
# ---------------------------------------------------------------------------
def _collect_python_files(repo_root: Path) -> list[Path]:
"""Return all .py files under SCAN_TARGETS, minus SKIP_PREFIXES and ALLOWED_PREFIX."""
results: list[Path] = []
for target in SCAN_TARGETS:
target_path = repo_root / target
if target_path.is_file():
# Single file target (e.g. agent/mcp_server.py)
if target_path.suffix == ".py":
rel = str(target_path.relative_to(repo_root)).replace("\\", "/")
if not _should_skip(rel):
results.append(target_path)
elif target_path.is_dir():
for py_file in sorted(target_path.rglob("*.py")):
rel = str(py_file.relative_to(repo_root)).replace("\\", "/")
if _should_skip(rel):
continue
results.append(py_file)
return results
def _should_skip(rel_path: str) -> bool:
"""Return True when *rel_path* falls in a skipped or allowed zone."""
for prefix in SKIP_PREFIXES:
if rel_path.startswith(prefix):
return True
if rel_path.startswith(ALLOWED_PREFIX):
return True
return False
# ---------------------------------------------------------------------------
# Main
# ---------------------------------------------------------------------------
ALLOWLIST_TEXT = """\
Allowlisted patterns (NOT flagged by this gate):
1. os.environ.copy() — snapshot of the full environment
2. os.environ.items() — iteration over env vars
3. os.environ.setdefault(...) — conditional write
4. os.environ["KEY"] = value — explicit write (Store context)
5. os.environ.pop() — only in src/api/settings_routes.py + src/config/
6. All reads inside agent/src/config/ — the config layer itself
7. All files under agent/tests/ — test code is exempt
"""
def main(argv: list[str] | None = None) -> int:
args = argv if argv is not None else sys.argv[1:]
if "--allowlist" in args:
print(ALLOWLIST_TEXT)
return 0
repo_root = Path(__file__).resolve().parent.parent
files = _collect_python_files(repo_root)
all_violations: list[tuple[str, int, str]] = []
all_warnings: list[tuple[str, int, str]] = []
for filepath in files:
try:
source = filepath.read_text(encoding="utf-8")
except (OSError, UnicodeDecodeError):
continue
try:
tree = ast.parse(source, filename=str(filepath))
except SyntaxError:
# Skip files that can't be parsed (e.g. generated stubs).
continue
visitor = _EnvReadVisitor(filepath, repo_root)
visitor.visit(tree)
rel = str(filepath.relative_to(repo_root)).replace("\\", "/")
for lineno, msg in visitor.violations:
all_violations.append((rel, lineno, msg))
for lineno, msg in visitor.warnings:
all_warnings.append((rel, lineno, msg))
# Print warnings (non-blocking)
for rel, lineno, msg in all_warnings:
print(f" WARN: {rel}:{lineno}: {msg}")
# Print violations (blocking)
if all_violations:
print()
print("FAIL: raw os.getenv / os.environ reads outside config layer:")
for rel, lineno, msg in all_violations:
print(f" {rel}:{lineno}: {msg}")
print()
print("Use the centralized config accessor in agent/src/config/ instead.")
print("Run with --allowlist to see permitted patterns.")
return 1
return 0
if __name__ == "__main__":
sys.exit(main())
+18
View File
@@ -17,6 +17,9 @@
# (spec.md §"Vendor 数据 ToS").
# (d) No deprecated `datetime.utcnow(` usage or bare `datetime.now()` calls
# in Python sources; `datetime.now(timezone.utc)` is allowed.
# (e) No raw `os.getenv` / `os.environ.get` / `os.environ["KEY"]` reads
# outside the centralized config layer (`agent/src/config/`).
# AST-based; uses `tools/ci_env_var_gate.py`.
#
# Exclusions: .git, node_modules, __pycache__, .venv, dist, build, this
# script itself. The HTML scan in (c) is scoped to wiki/alpha-library/**
@@ -125,6 +128,21 @@ else
echo "${GREEN}ok${NC}"
fi
# -------------------------------------------------------------- gate (e)
echo "[gate e] no raw os.getenv / os.environ reads outside config layer ..."
E_OUTPUT=$(python tools/ci_env_var_gate.py 2>&1)
E_RC=$?
if [ "$E_RC" -ne 0 ]; then
echo "${RED}FAIL${NC}: raw env-var reads outside agent/src/config/:"
echo "$E_OUTPUT"
FAILED=1
else
if [ -n "$E_OUTPUT" ]; then
echo "$E_OUTPUT"
fi
echo "${GREEN}ok${NC}"
fi
# --------------------------------------------------------------- result
if [ "$FAILED" -ne 0 ]; then
echo
+233
View File
@@ -0,0 +1,233 @@
"""Tests for tools/ci_env_var_gate.py.
Each test creates a temporary Python file under a fake repo tree, runs the
AST visitor, and asserts the expected violations / warnings / clean result.
Run with::
pytest tools/test_ci_env_var_gate.py -v
"""
from __future__ import annotations
import ast
import textwrap
from pathlib import Path
import pytest
# Import the visitor and helpers from the gate module.
from ci_env_var_gate import ( # type: ignore[import-untyped]
_EnvReadVisitor,
_should_skip,
main,
)
# ---------------------------------------------------------------------------
# Helpers
# ---------------------------------------------------------------------------
def _run_visitor(
source: str,
rel_path: str = "agent/src/some_module.py",
repo_root: Path | None = None,
) -> tuple[list[tuple[int, str]], list[tuple[int, str]]]:
"""Parse *source*, run the visitor, return (violations, warnings)."""
if repo_root is None:
repo_root = Path("/fake/repo")
tree = ast.parse(textwrap.dedent(source))
filepath = repo_root / rel_path
visitor = _EnvReadVisitor(filepath, repo_root)
visitor.visit(tree)
return visitor.violations, visitor.warnings
# ---------------------------------------------------------------------------
# Tests — violations (must be flagged)
# ---------------------------------------------------------------------------
class TestOsGetenvFlagged:
"""os.getenv("FOO") outside config/ is a violation."""
def test_os_getenv_basic(self) -> None:
violations, _ = _run_visitor('import os\nx = os.getenv("FOO")\n')
assert len(violations) == 1
assert "os.getenv" in violations[0][1]
def test_os_getenv_with_default(self) -> None:
violations, _ = _run_visitor('import os\nx = os.getenv("FOO", "bar")\n')
assert len(violations) == 1
class TestOsEnvironGetFlagged:
"""os.environ.get("FOO") outside config/ is a violation."""
def test_environ_get_basic(self) -> None:
violations, _ = _run_visitor('import os\nx = os.environ.get("FOO")\n')
assert len(violations) == 1
assert "os.environ.get" in violations[0][1]
def test_environ_get_with_default(self) -> None:
violations, _ = _run_visitor('import os\nx = os.environ.get("FOO", "bar")\n')
assert len(violations) == 1
class TestOsEnvironSubscriptReadFlagged:
"""os.environ["FOO"] read outside config/ is a violation."""
def test_subscript_read(self) -> None:
violations, _ = _run_visitor('import os\nx = os.environ["FOO"]\n')
assert len(violations) == 1
assert "read" in violations[0][1].lower()
def test_subscript_read_in_expression(self) -> None:
violations, _ = _run_visitor(
'import os\nif os.environ["DEBUG"] == "1": pass\n'
)
assert len(violations) == 1
# ---------------------------------------------------------------------------
# Tests — allowlisted (must NOT be flagged)
# ---------------------------------------------------------------------------
class TestEnvironCopyAllowed:
"""os.environ.copy() is NOT flagged."""
def test_copy(self) -> None:
violations, _ = _run_visitor("import os\nenv = os.environ.copy()\n")
assert len(violations) == 0
class TestEnvironItemsAllowed:
"""os.environ.items() is NOT flagged."""
def test_items(self) -> None:
violations, _ = _run_visitor(
"import os\nfor k, v in os.environ.items(): pass\n"
)
assert len(violations) == 0
class TestEnvironSetdefaultAllowed:
"""os.environ.setdefault() is NOT flagged."""
def test_setdefault(self) -> None:
violations, _ = _run_visitor(
'import os\nos.environ.setdefault("FOO", "bar")\n'
)
assert len(violations) == 0
class TestEnvironSubscriptWriteAllowed:
"""os.environ["FOO"] = "bar" (Store context) is NOT flagged."""
def test_subscript_write(self) -> None:
violations, _ = _run_visitor('import os\nos.environ["FOO"] = "bar"\n')
assert len(violations) == 0
def test_subscript_aug_assign(self) -> None:
# os.environ["PATH"] += ":/new" — still a Store context
violations, _ = _run_visitor('import os\nos.environ["PATH"] += ":/new"\n')
assert len(violations) == 0
class TestConfigDirectoryAllowed:
"""Files in agent/src/config/ are NOT scanned (skipped entirely)."""
def test_config_file_skipped(self) -> None:
# Even with os.getenv in the source, the file is in config/ so
# _should_skip returns True and the scanner never visits it.
assert _should_skip("agent/src/config/accessor.py") is True
assert _should_skip("agent/src/config/env_schema.py") is True
def test_config_visitor_clean(self) -> None:
# Even if we *did* visit a config file, the visitor itself would
# flag it — but the scanner skips it. Verify the skip logic.
violations, _ = _run_visitor(
'import os\nx = os.getenv("FOO")\n',
rel_path="agent/src/config/env_schema.py",
)
# The visitor doesn't know about skip rules — it just visits.
# But the scanner would never call it on this file.
# This test documents that the visitor IS strict; the skip is
# enforced at the file-discovery layer.
assert len(violations) == 1 # visitor flags it
assert _should_skip("agent/src/config/env_schema.py") # scanner skips
class TestTestsDirectorySkipped:
"""Files in agent/tests/ are NOT scanned."""
def test_tests_dir_skipped(self) -> None:
assert _should_skip("agent/tests/test_foo.py") is True
assert _should_skip("agent/tests/unit/test_bar.py") is True
# ---------------------------------------------------------------------------
# Tests — pop() warnings
# ---------------------------------------------------------------------------
class TestEnvironPopWarning:
"""os.environ.pop() outside settings_routes.py is a warning."""
def test_pop_outside_settings_routes(self) -> None:
violations, warnings = _run_visitor(
'import os\nos.environ.pop("FOO", None)\n',
rel_path="agent/src/some_module.py",
)
assert len(violations) == 0 # not a blocking violation
assert len(warnings) == 1
assert "pop" in warnings[0][1]
def test_pop_in_settings_routes_no_warning(self) -> None:
violations, warnings = _run_visitor(
'import os\nos.environ.pop("FOO", None)\n',
rel_path="agent/src/api/settings_routes.py",
)
assert len(violations) == 0
assert len(warnings) == 0
# ---------------------------------------------------------------------------
# Tests — multiple violations in one file
# ---------------------------------------------------------------------------
class TestMultipleViolations:
"""A file with several patterns reports all of them."""
def test_mixed_reads(self) -> None:
source = """\
import os
a = os.getenv("A")
b = os.environ.get("B")
c = os.environ["C"]
d = os.environ.copy() # allowed
os.environ["D"] = "x" # allowed (write)
"""
violations, _ = _run_visitor(source)
assert len(violations) == 3 # getenv, environ.get, subscript read
# ---------------------------------------------------------------------------
# Tests — CLI flags
# ---------------------------------------------------------------------------
class TestAllowlistFlag:
"""--allowlist prints the allowlist and exits 0."""
def test_allowlist_flag(self, capsys: pytest.CaptureFixture[str]) -> None:
rc = main(["--allowlist"])
assert rc == 0
captured = capsys.readouterr()
assert "os.environ.copy()" in captured.out
assert "os.environ.items()" in captured.out
assert "os.environ.setdefault" in captured.out