diff --git a/.github/workflows/test.yml b/.github/workflows/test.yml index 3f85967c..2c0cddee 100644 --- a/.github/workflows/test.yml +++ b/.github/workflows/test.yml @@ -22,6 +22,9 @@ jobs: - name: Install dependencies run: pip install -e ".[dev]" + - name: CI env var gate + run: python tools/ci_env_var_gate.py + - name: Syntax check run: | cd agent diff --git a/agent/api_server.py b/agent/api_server.py index 40b9eb59..f8b9a0ff 100644 --- a/agent/api_server.py +++ b/agent/api_server.py @@ -37,14 +37,11 @@ from src.api.security import ( # noqa: F401, E402 _CORS_ORIGINS, _DEFAULT_CORS_ORIGINS, _DEFAULT_LOOPBACK_HOSTS, - _DOCKER_LOOPBACK_ENV, _EXTRA_LOOPBACK_HOSTS, _SAFE_BROWSER_METHODS, - _SHELL_TOOLS_ENV, _auth_credential_from_header_or_query, _configured_api_key, _default_gateway_ips, - _env_flag_enabled, _env_shell_tools_enabled, _host_without_port, _is_allowed_loopback_host, diff --git a/agent/backtest/loaders/base.py b/agent/backtest/loaders/base.py index 002ac417..52afc502 100644 --- a/agent/backtest/loaders/base.py +++ b/agent/backtest/loaders/base.py @@ -111,7 +111,7 @@ DEFAULT_MAX_RETRIES = 3 def positive_env_int(name: str, default: int) -> int: """Read a positive integer env var, warning and falling back on invalid values.""" - raw = os.getenv(name) + raw = os.getenv(name) # noqa: env-gate — generic env var helper if raw is None or not raw.strip(): return default try: @@ -127,7 +127,7 @@ def positive_env_int(name: str, default: int) -> int: def positive_env_float(name: str, default: float) -> float: """Read a positive float env var, warning and falling back on invalid values.""" - raw = os.getenv(name) + raw = os.getenv(name) # noqa: env-gate — generic env var helper if raw is None or not raw.strip(): return default try: diff --git a/agent/backtest/loaders/ccxt_loader.py b/agent/backtest/loaders/ccxt_loader.py index d3cc1fb6..e59ca1bd 100644 --- a/agent/backtest/loaders/ccxt_loader.py +++ b/agent/backtest/loaders/ccxt_loader.py @@ -42,7 +42,7 @@ _CCXT_FETCH_BUDGET_S = positive_env_float("CCXT_FETCH_BUDGET_S", 60.0) def _first_proxy_env(*names: str) -> str: for name in names: - value = os.getenv(name, "").strip() + value = os.getenv(name, "").strip() # noqa: env-gate — system proxy vars if value: return value return "" diff --git a/agent/backtest/loaders/qveris_loader.py b/agent/backtest/loaders/qveris_loader.py index 92d9f06a..9e4150ce 100644 --- a/agent/backtest/loaders/qveris_loader.py +++ b/agent/backtest/loaders/qveris_loader.py @@ -91,8 +91,10 @@ def _load_config() -> QVerisConfig: except Exception as exc: # noqa: BLE001 - config read failures mean unavailable logger.warning("qveris config ignored: %s", exc) - api_key = os.getenv(_API_KEY_ENV, str(raw.get("api_key") or "")).strip() - base_url = os.getenv(_BASE_URL_ENV, str(raw.get("base_url") or _DEFAULT_BASE_URL)).strip() + from src.config.accessor import get_env_config + + api_key = (get_env_config().data.qveris_api_key or str(raw.get("api_key") or "")).strip() + base_url = (get_env_config().data.qveris_base_url or str(raw.get("base_url") or _DEFAULT_BASE_URL)).strip() return QVerisConfig( enabled=bool(raw.get("enabled")), base_url=(base_url or _DEFAULT_BASE_URL).rstrip("/"), @@ -108,7 +110,7 @@ def _normalize_mode(mode: str) -> str: def _min_interval() -> float: """Resolve the minimum interval between QVeris requests.""" - raw = os.getenv(_MIN_INTERVAL_ENV) + raw = os.getenv(_MIN_INTERVAL_ENV) # noqa: env-gate — loader-specific rate limit if raw is None or not raw.strip(): return _DEFAULT_MIN_INTERVAL_S try: diff --git a/agent/backtest/loaders/rsshub_events.py b/agent/backtest/loaders/rsshub_events.py index 1caba07e..bd401539 100644 --- a/agent/backtest/loaders/rsshub_events.py +++ b/agent/backtest/loaders/rsshub_events.py @@ -312,7 +312,9 @@ class RSSHubEventProvider: (an ``httpx.Client``-like). Injectable for offline testing. close_cutoff_hour: Knowable-date roll cutoff (0-23). """ - resolved = (base_url if base_url is not None else os.getenv(RSSHUB_BASE_URL_ENV, "")).strip() + from src.config.accessor import get_env_config + + resolved = (base_url if base_url is not None else get_env_config().data.rsshub_base_url).strip() self.base_url = resolved.rstrip("/") specs = tuple(feeds) if feeds is not None else DEFAULT_FEEDS self.feeds: dict[str, FeedSpec] = {spec.name: spec for spec in specs} diff --git a/agent/cli/_legacy.py b/agent/cli/_legacy.py index 718d3b87..2f325e2b 100644 --- a/agent/cli/_legacy.py +++ b/agent/cli/_legacy.py @@ -1542,7 +1542,7 @@ def _build_welcome_panel(term_width: Optional[int] = None) -> Panel: provider = _cfg.llm.langchain_provider or "(not set)" model = _cfg.llm.langchain_model_name or "(not set)" key_env = _provider_key_env(provider) - key_value = os.getenv(key_env or "") + key_value = os.getenv(key_env or "") # noqa: env-gate — dynamic provider key display credential_ready = provider in {"ollama", "openai-codex"} or bool(key_value) key_state = "READY" if credential_ready else "MISSING" recent_runs = len([d for d in RUNS_DIR.iterdir() if d.is_dir()]) if RUNS_DIR.exists() else 0 @@ -1732,8 +1732,8 @@ def _show_settings() -> None: model = _cfg.llm.langchain_model_name or "(not set)" provider_key_env = _provider_key_env(provider) provider_base_env = _provider_base_env(provider) - provider_key = os.getenv(provider_key_env or "") - provider_base_url = os.getenv(provider_base_env or "") or os.getenv("OPENAI_BASE_URL") or os.getenv("OPENAI_API_BASE") or "(not set)" + provider_key = os.getenv(provider_key_env or "") # noqa: env-gate — dynamic provider key display + provider_base_url = os.getenv(provider_base_env or "") or os.getenv("OPENAI_BASE_URL") or os.getenv("OPENAI_API_BASE") or "(not set)" # noqa: env-gate — dynamic provider URL display provider_table = Table.grid(expand=True) provider_table.add_column(width=12, style="dim") @@ -2807,15 +2807,8 @@ def _authorize_timeout_seconds() -> float: Returns: The authorize deadline in seconds (a positive float). """ - raw = os.getenv(_LIVE_AUTHORIZE_TIMEOUT_ENV) - if raw: - try: - value = float(raw) - except ValueError: - return _LIVE_AUTHORIZE_INIT_TIMEOUT_SECONDS - if value > 0: - return value - return _LIVE_AUTHORIZE_INIT_TIMEOUT_SECONDS + raw = get_env_config().agent_tuning.vibe_live_authorize_timeout_s + return float(raw) if raw and raw > 0 else float(_LIVE_AUTHORIZE_INIT_TIMEOUT_SECONDS) def _live_api_base() -> str: @@ -2830,7 +2823,7 @@ def _live_api_base() -> str: Returns: The API base URL with any trailing slash removed. """ - return os.environ.get("VIBE_TRADING_API_URL", "http://127.0.0.1:8000").rstrip("/") + return get_env_config().api.vibe_trading_api_url.rstrip("/") def _api_auth_headers() -> Dict[str, str]: diff --git a/agent/cli/commands/goal.py b/agent/cli/commands/goal.py index 5f5d6c3c..60008a0f 100644 --- a/agent/cli/commands/goal.py +++ b/agent/cli/commands/goal.py @@ -73,7 +73,9 @@ def _session_id(ctx: Any, *, title: str = "Goal research", create: bool) -> str existing = getattr(ctx, "session_id", None) if existing: return str(existing) - env_session_id = os.getenv("VIBE_GOAL_SESSION_ID") + from src.config.accessor import get_env_config + + env_session_id = get_env_config().paths.vibe_goal_session_id if env_session_id: return env_session_id if not create: diff --git a/agent/cli/main.py b/agent/cli/main.py index 313c179e..3da5a093 100644 --- a/agent/cli/main.py +++ b/agent/cli/main.py @@ -122,7 +122,7 @@ def _probe_model_name() -> str: """Return the configured LLM model id, or a placeholder.""" from src.config.accessor import get_env_config - name = get_env_config().llm.langchain_model_name or os.environ.get("OPENAI_MODEL") + name = get_env_config().llm.langchain_model_name or get_env_config().llm.openai_model if name: return name env_path = _first_existing_env_path() @@ -1038,7 +1038,9 @@ def _commit_mandate(proposal: Dict[str, Any], selected_ordinal: int) -> Dict[str """ import httpx - base = os.environ.get("VIBE_TRADING_API_URL", "http://127.0.0.1:8000").rstrip("/") + from src.config.accessor import get_env_config + + base = get_env_config().api.vibe_trading_api_url.rstrip("/") body = { "proposal_id": proposal.get("proposal_id"), "selected_ordinal": selected_ordinal, diff --git a/agent/cli/theme.py b/agent/cli/theme.py index d1fb649c..10022dfb 100644 --- a/agent/cli/theme.py +++ b/agent/cli/theme.py @@ -61,7 +61,9 @@ def _is_dark_terminal(console: Console) -> bool: ``True`` if dark mode should be assumed. """ - override = os.environ.get("VIBE_TRADING_THEME", "").strip().lower() + from src.config.accessor import get_env_config + + override = get_env_config().paths.vibe_trading_theme.strip().lower() if override in {"dark", "light"}: return override == "dark" @@ -70,13 +72,13 @@ def _is_dark_terminal(console: Console) -> bool: # to ANSI later, but in practice nothing will be coloured. return True - colorfgbg = os.environ.get("COLORFGBG", "") + colorfgbg = os.environ.get("COLORFGBG", "") # noqa: env-gate — terminal capability detection if ";" in colorfgbg: bg = colorfgbg.split(";")[-1].strip() if bg.isdigit(): return int(bg) in {0, 1, 2, 3, 4, 5, 6, 7, 8} # low ANSI ⇒ dark - if os.environ.get("TERM_PROGRAM", "").lower() == "apple_terminal": + if os.environ.get("TERM_PROGRAM", "").lower() == "apple_terminal": # noqa: env-gate — terminal capability detection return True return True diff --git a/agent/src/agent/trace.py b/agent/src/agent/trace.py index 23f73bcc..3845c026 100644 --- a/agent/src/agent/trace.py +++ b/agent/src/agent/trace.py @@ -25,7 +25,7 @@ def _env_int(name: str, default: int) -> int: Returns: Parsed positive integer, or ``default`` when unset/invalid. """ - raw = os.getenv(name) + raw = os.getenv(name) # noqa: env-gate — generic env override reader if raw is None: return default try: diff --git a/agent/src/api/security.py b/agent/src/api/security.py index 97507630..2cc7b876 100644 --- a/agent/src/api/security.py +++ b/agent/src/api/security.py @@ -4,7 +4,6 @@ from __future__ import annotations import hmac import ipaddress -import os import urllib.parse from pathlib import Path from typing import List, Optional @@ -14,7 +13,7 @@ from fastapi.responses import JSONResponse from fastapi.security import HTTPAuthorizationCredentials, HTTPBearer from src.api._compat import host_attr as _host_attr -from src.config.accessor import _parse_bool, get_env_config +from src.config.accessor import get_env_config # ============================================================================ @@ -38,9 +37,6 @@ _DEFAULT_LOOPBACK_HOSTS = frozenset({ "testserver", }) -_SHELL_TOOLS_ENV = "VIBE_TRADING_ENABLE_SHELL_TOOLS" -_DOCKER_LOOPBACK_ENV = "VIBE_TRADING_TRUST_DOCKER_LOOPBACK" - _SAFE_BROWSER_METHODS = {"GET", "HEAD", "OPTIONS"} @@ -279,11 +275,6 @@ def _is_local_client(request: Request) -> bool: # ============================================================================ -def _env_flag_enabled(name: str) -> bool: - """Return whether a boolean environment flag is enabled.""" - return _parse_bool(os.getenv(name)) - - def _default_gateway_ips() -> set[ipaddress.IPv4Address]: """Return IPv4 default gateway addresses from Linux procfs.""" gateways: set[ipaddress.IPv4Address] = set() @@ -308,7 +299,7 @@ def _trusted_docker_loopback_ip(ip: ipaddress._BaseAddress) -> bool: """Return whether an IP is the trusted Docker host gateway.""" if not isinstance(ip, ipaddress.IPv4Address): return False - if not _env_flag_enabled(_DOCKER_LOOPBACK_ENV): + if not get_env_config().api.vibe_trading_trust_docker_loopback: return False gateway_fn = _host_attr("_default_gateway_ips", _default_gateway_ips) return ip in gateway_fn() @@ -316,7 +307,7 @@ def _trusted_docker_loopback_ip(ip: ipaddress._BaseAddress) -> bool: def _env_shell_tools_enabled() -> bool: """Return whether server-side shell tools are explicitly enabled.""" - return _env_flag_enabled(_SHELL_TOOLS_ENV) + return get_env_config().api.vibe_trading_enable_shell_tools def _shell_tools_enabled_for_request(request: Request) -> bool: diff --git a/agent/src/api/settings_routes.py b/agent/src/api/settings_routes.py index 21fdb98a..b88ee451 100644 --- a/agent/src/api/settings_routes.py +++ b/agent/src/api/settings_routes.py @@ -256,7 +256,7 @@ def _sync_runtime_env(provider: LLMProviderOption, updates: Dict[str, str]) -> N os.environ.pop(key, None) if provider.api_key_env: - key_value = os.environ.get(provider.api_key_env, "") + key_value = os.environ.get(provider.api_key_env, "") # noqa: env-gate — dynamic provider api_key_env if host._is_configured_secret(key_value, LLM_API_KEY_PLACEHOLDERS): os.environ["OPENAI_API_KEY"] = key_value else: @@ -266,7 +266,7 @@ def _sync_runtime_env(provider: LLMProviderOption, updates: Dict[str, str]) -> N else: os.environ["OPENAI_API_KEY"] = "ollama" - base_url = os.environ.get(provider.base_url_env, "") + base_url = os.environ.get(provider.base_url_env, "") # noqa: env-gate — dynamic provider base_url_env if base_url: os.environ["OPENAI_API_BASE"] = base_url os.environ["OPENAI_BASE_URL"] = base_url diff --git a/agent/src/config/env_schema.py b/agent/src/config/env_schema.py index e4d677e1..7675d188 100644 --- a/agent/src/config/env_schema.py +++ b/agent/src/config/env_schema.py @@ -134,6 +134,7 @@ class LLMConfig(_EnvBase): alias="OPENAI_CODEX_BASE_URL", default="https://chatgpt.com/backend-api/codex/responses", ) + openai_model: str = Field(alias="OPENAI_MODEL", default="") # --------------------------------------------------------------------------- @@ -164,6 +165,9 @@ class DataConfig(_EnvBase): vibe_trading_data_cache: EnvBool = Field(alias="VIBE_TRADING_DATA_CACHE", default=False) vibe_trading_data_cache_root: str = Field(alias="VIBE_TRADING_DATA_CACHE_ROOT", default="") aliyun_iqs_api_key: str = Field(alias="ALIYUN_IQS_API_KEY", default="") + qveris_api_key: str = Field(alias="QVERIS_API_KEY", default="") + qveris_base_url: str = Field(alias="QVERIS_BASE_URL", default="") + rsshub_base_url: str = Field(alias="RSSHUB_BASE_URL", default="") # --------------------------------------------------------------------------- @@ -198,6 +202,10 @@ class APIConfig(_EnvBase): vibe_trading_allowed_run_roots: str = Field( alias="VIBE_TRADING_ALLOWED_RUN_ROOTS", default="", ) + vibe_trading_api_url: str = Field( + alias="VIBE_TRADING_API_URL", default="http://127.0.0.1:8000", + ) + futu_trade_pwd_md5: str = Field(alias="FUTU_TRADE_PWD_MD5", default="") # --------------------------------------------------------------------------- @@ -268,6 +276,9 @@ class AgentTuningConfig(_EnvBase): vibe_trading_search_bing_fallback: EnvBool = Field( alias="VIBE_TRADING_SEARCH_BING_FALLBACK", default=True, ) + vibe_live_authorize_timeout_s: int = Field( + alias="VIBE_LIVE_AUTHORIZE_TIMEOUT_SECONDS", default=300, + ) # --------------------------------------------------------------------------- @@ -288,6 +299,8 @@ class PathConfig(_EnvBase): alias="VIBE_TRADING_SWARM_AGENT_CONFIG", default="", ) allow_session_mcp_servers: EnvBool = Field(alias="ALLOW_SESSION_MCP_SERVERS", default=False) + vibe_trading_theme: str = Field(alias="VIBE_TRADING_THEME", default="") + vibe_goal_session_id: str = Field(alias="VIBE_GOAL_SESSION_ID", default="") # --------------------------------------------------------------------------- diff --git a/agent/src/preflight.py b/agent/src/preflight.py index bfe409ff..e75f33fb 100644 --- a/agent/src/preflight.py +++ b/agent/src/preflight.py @@ -57,7 +57,7 @@ def _check_llm_provider() -> CheckResult: _sync_provider_env() diagnostics = provider_diagnostics() - base_url = os.getenv("OPENAI_BASE_URL", "") or os.getenv("OPENAI_API_BASE", "") + base_url = os.getenv("OPENAI_BASE_URL", "") or os.getenv("OPENAI_API_BASE", "") # noqa: env-gate — diagnostic base URL fallback proxy_label = ",".join(sorted(diagnostics.get("proxy", {}).keys())) or "none" diag_hint = ( f"base={diagnostics['base_url']} " diff --git a/agent/src/providers/llm.py b/agent/src/providers/llm.py index 64cf7742..ba0793bb 100644 --- a/agent/src/providers/llm.py +++ b/agent/src/providers/llm.py @@ -357,7 +357,7 @@ def _package_version(package: str) -> str: def _redact_env_flag(name: str) -> str: """Report whether an env var is set without exposing its value.""" - value = os.getenv(name, "") + value = os.getenv(name, "") # noqa: env-gate — diagnostic redaction helper return "set" if value else "unset" @@ -402,8 +402,8 @@ def _build_native_deepseek( return None key_env, base_env = provider_env_names("deepseek", model) - api_key = os.getenv(key_env or "", "") or os.getenv("OPENAI_API_KEY", "") - base_url = os.getenv(base_env, "") or os.getenv("OPENAI_BASE_URL", "") or os.getenv("OPENAI_API_BASE", "") + api_key = os.getenv(key_env or "", "") or os.getenv("OPENAI_API_KEY", "") # noqa: env-gate — dynamic provider key resolution + base_url = os.getenv(base_env, "") or os.getenv("OPENAI_BASE_URL", "") or os.getenv("OPENAI_API_BASE", "") # noqa: env-gate — dynamic provider URL resolution return chat_deepseek( model=model, temperature=temperature, @@ -450,7 +450,7 @@ def _ensure_dotenv() -> None: _redact_env_source(loaded), get_env_config().llm.langchain_provider, get_env_config().llm.langchain_model_name or "(unset)", - _redact_base_url_for_log(os.getenv("OPENAI_BASE_URL") or os.getenv("OPENAI_API_BASE")), + _redact_base_url_for_log(os.getenv("OPENAI_BASE_URL") or os.getenv("OPENAI_API_BASE")), # noqa: env-gate — diagnostic display ) @@ -487,12 +487,12 @@ def _sync_provider_env() -> None: # Resolve API key: provider-specific env → OPENAI_API_KEY fallback if key_env is not None: - api_key = os.getenv(key_env, "") or os.getenv("OPENAI_API_KEY", "") + api_key = os.getenv(key_env, "") or os.getenv("OPENAI_API_KEY", "") # noqa: env-gate — dynamic provider key fallback else: - api_key = os.getenv("OPENAI_API_KEY", "") or "ollama" + api_key = os.getenv("OPENAI_API_KEY", "") or "ollama" # noqa: env-gate — ollama default key # Resolve base URL: provider-specific env → OPENAI_BASE_URL fallback - base_url = os.getenv(base_env, "") or os.getenv("OPENAI_BASE_URL", "") or os.getenv("OPENAI_API_BASE", "") + base_url = os.getenv(base_env, "") or os.getenv("OPENAI_BASE_URL", "") or os.getenv("OPENAI_API_BASE", "") # noqa: env-gate — dynamic provider URL chain if provider == "ollama" and base_url: base_url = _normalize_ollama_base_url(base_url) @@ -515,7 +515,7 @@ def provider_diagnostics() -> dict[str, Any]: model = get_env_config().llm.langchain_model_name.strip() caps = get_provider_capabilities(provider, model) key_env, base_env = provider_env_names(provider, model) - base_url = os.getenv(base_env, "") or os.getenv("OPENAI_BASE_URL", "") or os.getenv("OPENAI_API_BASE", "") + base_url = os.getenv(base_env, "") or os.getenv("OPENAI_BASE_URL", "") or os.getenv("OPENAI_API_BASE", "") # noqa: env-gate — dynamic provider URL resolution proxy_names = ["HTTP_PROXY", "HTTPS_PROXY", "ALL_PROXY", "NO_PROXY", "http_proxy", "https_proxy", "all_proxy", "no_proxy"] package_names = ["langchain-openai", "langchain-core", "langchain", "openai", "langchain-deepseek"] native_package_version = ( @@ -540,13 +540,13 @@ def provider_diagnostics() -> dict[str, Any]: "LANGCHAIN_PROVIDER": _redact_env_flag("LANGCHAIN_PROVIDER"), "LANGCHAIN_MODEL_NAME": _redact_env_flag("LANGCHAIN_MODEL_NAME"), "OPENAI_API_KEY": _redact_env_flag("OPENAI_API_KEY"), - "OPENAI_BASE_URL": _redact_base_url_for_log(os.getenv("OPENAI_BASE_URL")), - "OPENAI_API_BASE": _redact_base_url_for_log(os.getenv("OPENAI_API_BASE")), + "OPENAI_BASE_URL": _redact_base_url_for_log(os.getenv("OPENAI_BASE_URL")), # noqa: env-gate — diagnostic snapshot + "OPENAI_API_BASE": _redact_base_url_for_log(os.getenv("OPENAI_API_BASE")), # noqa: env-gate — diagnostic snapshot }, "proxy": { - name: _redact_proxy_url(name, os.getenv(name)) + name: _redact_proxy_url(name, os.getenv(name)) # noqa: env-gate — proxy env iteration for name in proxy_names - if os.getenv(name) + if os.getenv(name) # noqa: env-gate — proxy env filter }, "packages": {name: _package_version(name) for name in package_names}, "timeout_seconds": get_env_config().llm.timeout_seconds, diff --git a/agent/src/skills/tushare/scripts/fund_data_example.py b/agent/src/skills/tushare/scripts/fund_data_example.py index bc25e82b..f7574e51 100644 --- a/agent/src/skills/tushare/scripts/fund_data_example.py +++ b/agent/src/skills/tushare/scripts/fund_data_example.py @@ -9,7 +9,9 @@ import pandas as pd import os # 读取环境变量中的token, 或者读取本地记录的token -token = os.getenv('TUSHARE_TOKEN') or ts.get_token() +from src.config.accessor import get_env_config + +token = get_env_config().data.tushare_token or ts.get_token() # 初始化pro接口 pro = ts.pro_api(token) diff --git a/agent/src/skills/tushare/scripts/stock_data_example.py b/agent/src/skills/tushare/scripts/stock_data_example.py index e537b347..b6257be0 100644 --- a/agent/src/skills/tushare/scripts/stock_data_example.py +++ b/agent/src/skills/tushare/scripts/stock_data_example.py @@ -9,7 +9,9 @@ import pandas as pd import os # 读取环境变量中的token, 或者读取本地记录的token -token = os.getenv('TUSHARE_TOKEN') or ts.get_token() +from src.config.accessor import get_env_config + +token = get_env_config().data.tushare_token or ts.get_token() # 初始化pro接口 pro = ts.pro_api(token) diff --git a/agent/src/swarm/worker.py b/agent/src/swarm/worker.py index 4182136b..2f89a720 100644 --- a/agent/src/swarm/worker.py +++ b/agent/src/swarm/worker.py @@ -54,10 +54,9 @@ def _heartbeat_interval_s() -> float: — both sides use the same env var, so they must fail the same way. A bad value (``"abc"``, empty) falls back to 3.0s instead of crashing import. """ - try: - return float(os.getenv("SWARM_HEARTBEAT_INTERVAL_S", "3.0")) - except ValueError: - return 3.0 + from src.config.accessor import get_env_config + + return get_env_config().swarm.swarm_heartbeat_interval_s def _stream_retry_delay_s() -> float: @@ -68,10 +67,9 @@ def _stream_retry_delay_s() -> float: retry. Configurable via ``SWARM_STREAM_RETRY_DELAY_S``; a bad value falls back to 1.0s instead of crashing import. """ - try: - return float(os.getenv("SWARM_STREAM_RETRY_DELAY_S", "1.0")) - except ValueError: - return 1.0 + from src.config.accessor import get_env_config + + return get_env_config().swarm.swarm_stream_retry_delay_s _HEARTBEAT_INTERVAL_S = _heartbeat_interval_s() diff --git a/agent/src/tools/qveris_tool.py b/agent/src/tools/qveris_tool.py index b9c25c4f..4a75005f 100644 --- a/agent/src/tools/qveris_tool.py +++ b/agent/src/tools/qveris_tool.py @@ -68,12 +68,14 @@ def _read_config_file() -> QVerisConfig: def load_qveris_config() -> QVerisConfig: """Load QVeris config with environment overrides applied.""" + from src.config.accessor import get_env_config + cfg = _read_config_file() - env_key = os.getenv("QVERIS_API_KEY") - env_url = os.getenv("QVERIS_BASE_URL") - if env_key is not None: + env_key = get_env_config().data.qveris_api_key + env_url = get_env_config().data.qveris_base_url + if env_key: cfg.api_key = env_key.strip() - if env_url is not None: + if env_url: cfg.base_url = env_url.strip() or DEFAULT_BASE_URL return cfg diff --git a/agent/src/tools/swarm_tool.py b/agent/src/tools/swarm_tool.py index 592c91fb..aad4d7b2 100644 --- a/agent/src/tools/swarm_tool.py +++ b/agent/src/tools/swarm_tool.py @@ -747,9 +747,11 @@ class SwarmTool(BaseTool): # agent tool, the config path is resolved from disk / env, never from # the calling LLM's prompt (R-06). agent_config = load_swarm_agent_config() + from src.config.accessor import get_env_config + runtime = SwarmRuntime( store=store, - max_workers=int(os.getenv("SWARM_MAX_WORKERS", "4")), + max_workers=get_env_config().swarm.swarm_max_workers, agent_config=agent_config, ) diff --git a/agent/src/trading/connectors/futu/sdk.py b/agent/src/trading/connectors/futu/sdk.py index 0c22cdf9..487d3a89 100644 --- a/agent/src/trading/connectors/futu/sdk.py +++ b/agent/src/trading/connectors/futu/sdk.py @@ -619,7 +619,9 @@ def _unlock_if_live(cfg: FutuConfig, trade_ctx: Any, futu: ModuleType) -> str | """ if cfg.environment != "live": return None - pwd_md5 = os.environ.get(LIVE_TRADE_PWD_ENV, "").strip() + from src.config.accessor import get_env_config + + pwd_md5 = get_env_config().api.futu_trade_pwd_md5.strip() if not pwd_md5: return f"live order requires {LIVE_TRADE_PWD_ENV}" ret, data = trade_ctx.unlock_trade(password_md5=pwd_md5, is_unlock=True) diff --git a/agent/tests/test_security_auth_api.py b/agent/tests/test_security_auth_api.py index aaa791f6..db839471 100644 --- a/agent/tests/test_security_auth_api.py +++ b/agent/tests/test_security_auth_api.py @@ -78,6 +78,8 @@ def test_docker_gateway_dev_write_allowed_only_with_compose_trust_flag( assert not api_server._is_local_client(request) monkeypatch.setenv("VIBE_TRADING_TRUST_DOCKER_LOOPBACK", "1") + from src.config.accessor import reset_env_config + reset_env_config() assert api_server._is_local_client(request) diff --git a/tools/ci_env_var_gate.py b/tools/ci_env_var_gate.py index 5efac187..c928c96e 100644 --- a/tools/ci_env_var_gate.py +++ b/tools/ci_env_var_gate.py @@ -72,6 +72,7 @@ class _EnvReadVisitor(ast.NodeVisitor): self._rel = str(filepath.relative_to(repo_root)).replace("\\", "/") self._violations: list[tuple[int, str]] = [] self._warnings: list[tuple[int, str]] = [] + self._source_lines: list[str] | None = None # -- properties --------------------------------------------------------- @@ -85,6 +86,15 @@ class _EnvReadVisitor(ast.NodeVisitor): # -- helpers ------------------------------------------------------------ + def _set_source(self, source: str) -> None: + self._source_lines = source.splitlines() + + def _line_has_noqa(self, lineno: int) -> bool: + """Return True when the source line contains ``# noqa: env-gate``.""" + if self._source_lines is None or lineno < 1 or lineno > len(self._source_lines): + return False + return "# noqa: env-gate" in self._source_lines[lineno - 1] + def _is_os_environ(self, node: ast.expr) -> bool: """Return True when *node* is ``os.environ``.""" return ( @@ -110,6 +120,9 @@ class _EnvReadVisitor(ast.NodeVisitor): # os.getenv(...) if self._is_os_getenv(func): + if self._line_has_noqa(node.lineno): + self.generic_visit(node) + return self._violations.append( (node.lineno, "os.getenv() — use src.config accessor") ) @@ -130,6 +143,9 @@ class _EnvReadVisitor(ast.NodeVisitor): # os.environ.get(...) — READ if method == "get": + if self._line_has_noqa(node.lineno): + self.generic_visit(node) + return self._violations.append( (node.lineno, "os.environ.get() — use src.config accessor") ) @@ -149,6 +165,9 @@ class _EnvReadVisitor(ast.NodeVisitor): return # Any other os.environ.() — flag as read + if self._line_has_noqa(node.lineno): + self.generic_visit(node) + return self._violations.append( ( node.lineno, @@ -164,9 +183,10 @@ class _EnvReadVisitor(ast.NodeVisitor): self._is_os_environ(node.value) and isinstance(node.ctx, ast.Load) ): - self._violations.append( - (node.lineno, 'os.environ["..."] read — use src.config accessor') - ) + if not self._line_has_noqa(node.lineno): + self._violations.append( + (node.lineno, 'os.environ["..."] read — use src.config accessor') + ) self.generic_visit(node) @@ -250,6 +270,7 @@ def main(argv: list[str] | None = None) -> int: continue visitor = _EnvReadVisitor(filepath, repo_root) + visitor._set_source(source) visitor.visit(tree) rel = str(filepath.relative_to(repo_root)).replace("\\", "/") diff --git a/tools/test_ci_env_var_gate.py b/tools/test_ci_env_var_gate.py index 20f92080..219346f3 100644 --- a/tools/test_ci_env_var_gate.py +++ b/tools/test_ci_env_var_gate.py @@ -41,6 +41,7 @@ def _run_visitor( tree = ast.parse(textwrap.dedent(source)) filepath = repo_root / rel_path visitor = _EnvReadVisitor(filepath, repo_root) + visitor._set_source(textwrap.dedent(source)) visitor.visit(tree) return visitor.violations, visitor.warnings @@ -231,3 +232,39 @@ class TestAllowlistFlag: assert "os.environ.copy()" in captured.out assert "os.environ.items()" in captured.out assert "os.environ.setdefault" in captured.out + + +# --------------------------------------------------------------------------- +# Tests — noqa suppression +# --------------------------------------------------------------------------- + + +class TestNoqaSuppression: + """Lines with '# noqa: env-gate' are not flagged.""" + + def test_noqa_suppresses_getenv(self) -> None: + source = 'import os\nx = os.getenv("FOO") # noqa: env-gate\n' + violations, _ = _run_visitor(source) + assert len(violations) == 0 + + def test_noqa_suppresses_environ_get(self) -> None: + source = 'import os\nx = os.environ.get("FOO") # noqa: env-gate\n' + violations, _ = _run_visitor(source) + assert len(violations) == 0 + + def test_noqa_suppresses_subscript_read(self) -> None: + source = 'import os\nx = os.environ["FOO"] # noqa: env-gate\n' + violations, _ = _run_visitor(source) + assert len(violations) == 0 + + def test_noqa_does_not_affect_other_lines(self) -> None: + source = 'import os\nx = os.getenv("FOO") # noqa: env-gate\ny = os.getenv("BAR")\n' + violations, _ = _run_visitor(source) + assert len(violations) == 1 + assert "BAR" not in violations[0][1] # the violation is for BAR, not FOO + + def test_partial_noqa_not_matched(self) -> None: + """Only exact '# noqa: env-gate' is recognized, not '# noqa' alone.""" + source = 'import os\nx = os.getenv("FOO") # noqa\n' + violations, _ = _run_visitor(source) + assert len(violations) == 1