From 5b572cc0f5677863dcfaec3629c0547e311e737b Mon Sep 17 00:00:00 2001 From: feliche93 Date: Wed, 27 May 2026 17:25:35 +0200 Subject: [PATCH 1/2] feat: add Infisical secret source --- .env.example | 13 + agent/secret_sources/__init__.py | 3 + agent/secret_sources/infisical.py | 404 ++++++++++++++++++ cli-config.yaml.example | 23 ++ hermes_cli/config.py | 33 ++ hermes_cli/env_loader.py | 117 ++++-- hermes_cli/infisical_secrets_cli.py | 414 +++++++++++++++++++ hermes_cli/main.py | 20 +- tests/test_env_loader_secret_sources.py | 119 ++++++ tests/test_infisical_secrets.py | 322 +++++++++++++++ website/docs/user-guide/secrets/index.md | 1 + website/docs/user-guide/secrets/infisical.md | 129 ++++++ 12 files changed, 1564 insertions(+), 34 deletions(-) create mode 100644 agent/secret_sources/infisical.py create mode 100644 hermes_cli/infisical_secrets_cli.py create mode 100644 tests/test_infisical_secrets.py create mode 100644 website/docs/user-guide/secrets/infisical.md diff --git a/.env.example b/.env.example index b7f3b008faf2c..7cc003c6fe0b7 100644 --- a/.env.example +++ b/.env.example @@ -14,6 +14,19 @@ # LLM_MODEL is no longer read from .env — this line is kept for reference only. # LLM_MODEL=anthropic/claude-opus-4.6 +# ============================================================================= +# EXTERNAL SECRET SOURCE (Infisical) +# ============================================================================= +# These are bootstrap credentials only. Store regular provider keys +# (OPENROUTER_API_KEY, ANTHROPIC_API_KEY, etc.) in Infisical and enable +# secrets.infisical in ~/.hermes/config.yaml or with: +# hermes secrets infisical setup +# +# INFISICAL_CLIENT_ID= +# INFISICAL_CLIENT_SECRET= +# INFISICAL_PROJECT_ID= +# INFISICAL_API_URL=https://app.infisical.com + # ============================================================================= # LLM PROVIDER (NovitaAI) # ============================================================================= diff --git a/agent/secret_sources/__init__.py b/agent/secret_sources/__init__.py index e1564058ad111..58a309c070ec5 100644 --- a/agent/secret_sources/__init__.py +++ b/agent/secret_sources/__init__.py @@ -10,4 +10,7 @@ - ``bitwarden`` — Bitwarden Secrets Manager (`bws` CLI). See ``agent.secret_sources.bitwarden`` for the integration and ``hermes_cli.secrets_cli`` for the user-facing setup wizard. + - ``infisical`` — Infisical Universal Auth. See + ``agent.secret_sources.infisical`` for the integration and + ``hermes_cli.infisical_secrets_cli`` for the user-facing setup wizard. """ diff --git a/agent/secret_sources/infisical.py b/agent/secret_sources/infisical.py new file mode 100644 index 0000000000000..aa6485ab7a3fc --- /dev/null +++ b/agent/secret_sources/infisical.py @@ -0,0 +1,404 @@ +"""Infisical Universal Auth secret source integration. + +Hermes pulls API keys from Infisical at process startup so they don't +have to live in plaintext in ``~/.hermes/.env``. + +Design summary +-------------- + +* The bootstrap credentials are a Machine Identity client ID and client + secret read from environment variables (``INFISICAL_CLIENT_ID`` and + ``INFISICAL_CLIENT_SECRET`` by default). They live in ``.env`` or the + parent shell; they are never stored in ``config.yaml``. +* Hermes exchanges those credentials for a short-lived access token via + Infisical Universal Auth, then calls the v4 secrets list endpoint for + the configured project/environment/path. +* Returned ``secretKey`` / ``secretValue`` pairs are applied to + ``os.environ`` using the same non-destructive semantics as other + secret sources: existing values win unless ``override_existing`` is + enabled. +* Failures never block Hermes startup. Missing credentials, auth + failures, network errors, and malformed responses are surfaced as a + one-line warning by the caller while Hermes continues with whatever + credentials were already present. +""" + +from __future__ import annotations + +import hashlib +import json +import os +import time +import urllib.error +import urllib.parse +import urllib.request +from dataclasses import dataclass, field +from typing import Any, Dict, List, Optional, Tuple + +DEFAULT_API_URL = "https://app.infisical.com" +_HTTP_TIMEOUT = 30 + +_CacheKey = Tuple[str, str, str, str, str, str, str, str, str, str] +_CACHE: Dict[_CacheKey, "_CachedFetch"] = {} + + +@dataclass +class _CachedFetch: + secrets: Dict[str, str] + fetched_at: float + + def is_fresh(self, ttl_seconds: float) -> bool: + if ttl_seconds <= 0: + return False + return (time.time() - self.fetched_at) < ttl_seconds + + +@dataclass +class FetchResult: + """Outcome of a single Infisical pull.""" + + secrets: Dict[str, str] = field(default_factory=dict) + applied: List[str] = field(default_factory=list) + skipped: List[str] = field(default_factory=list) + warnings: List[str] = field(default_factory=list) + error: Optional[str] = None + + @property + def ok(self) -> bool: + return self.error is None + + +def _fingerprint(value: str) -> str: + """SHA-256 prefix used only for cache keys.""" + return hashlib.sha256(value.encode("utf-8")).hexdigest()[:16] + + +def _normalize_api_url(api_url: str) -> str: + api_url = (api_url or DEFAULT_API_URL).strip().rstrip("/") + if not api_url: + return DEFAULT_API_URL + if not api_url.startswith(("http://", "https://")): + raise RuntimeError( + "secrets.infisical.api_url must start with http:// or https://" + ) + return api_url + + +def _normalize_secret_path(secret_path: str) -> str: + secret_path = (secret_path or "/").strip() + if not secret_path: + return "/" + if not secret_path.startswith("/"): + secret_path = "/" + secret_path + return secret_path + + +def _bool_param(value: bool) -> str: + return "true" if value else "false" + + +def _http_json( + method: str, + url: str, + *, + body: Optional[dict[str, Any]] = None, + token: str = "", + params: Optional[dict[str, str]] = None, +) -> dict[str, Any]: + """Make a JSON Infisical API request using stdlib urllib.""" + if params: + url = f"{url}?{urllib.parse.urlencode(params)}" + + data = None + headers = { + "Accept": "application/json", + "User-Agent": "hermes-agent", + } + if body is not None: + data = json.dumps(body).encode("utf-8") + headers["Content-Type"] = "application/json" + if token: + headers["Authorization"] = f"Bearer {token}" + + req = urllib.request.Request(url, data=data, headers=headers, method=method) + try: + with urllib.request.urlopen(req, timeout=_HTTP_TIMEOUT) as resp: # noqa: S310 + raw = resp.read().decode("utf-8", errors="replace") + except urllib.error.HTTPError as exc: + detail = exc.read().decode("utf-8", errors="replace").strip() + raise RuntimeError( + f"Infisical API returned HTTP {exc.code}: {detail[:300]}" + ) from exc + except urllib.error.URLError as exc: + raise RuntimeError(f"Infisical API request failed: {exc}") from exc + except OSError as exc: + raise RuntimeError(f"Infisical API request failed: {exc}") from exc + + if not raw.strip(): + return {} + try: + payload = json.loads(raw) + except json.JSONDecodeError as exc: + raise RuntimeError(f"Infisical API returned non-JSON output: {exc}") from exc + if not isinstance(payload, dict): + raise RuntimeError( + f"Infisical API returned unexpected shape: {type(payload).__name__}" + ) + return payload + + +def login_universal_auth( + *, + client_id: str, + client_secret: str, + api_url: str = DEFAULT_API_URL, + organization_slug: str = "", +) -> tuple[str, int]: + """Exchange Universal Auth credentials for an Infisical access token.""" + if not client_id: + raise RuntimeError("Infisical client ID is empty") + if not client_secret: + raise RuntimeError("Infisical client secret is empty") + + api_url = _normalize_api_url(api_url) + body: dict[str, Any] = { + "clientId": client_id, + "clientSecret": client_secret, + } + if organization_slug: + body["organizationSlug"] = organization_slug + + payload = _http_json( + "POST", + f"{api_url}/api/v1/auth/universal-auth/login", + body=body, + ) + token = payload.get("accessToken") + if not isinstance(token, str) or not token: + raise RuntimeError("Infisical login response did not include accessToken") + expires_in = payload.get("expiresIn") + if not isinstance(expires_in, int): + expires_in = 0 + return token, expires_in + + +def fetch_infisical_secrets( + *, + client_id: str, + client_secret: str, + project_id: str, + environment: str = "prod", + secret_path: str = "/", + api_url: str = DEFAULT_API_URL, + organization_slug: str = "", + cache_ttl_seconds: float = 300, + use_cache: bool = True, + recursive: bool = False, + include_imports: bool = True, + expand_secret_references: bool = True, +) -> tuple[Dict[str, str], List[str]]: + """Fetch secrets from Infisical for one project/environment/path.""" + if not client_id: + raise RuntimeError("Infisical client ID is empty") + if not client_secret: + raise RuntimeError("Infisical client secret is empty") + if not project_id: + raise RuntimeError("Infisical project_id is empty") + if not environment: + raise RuntimeError("Infisical environment is empty") + + api_url = _normalize_api_url(api_url) + secret_path = _normalize_secret_path(secret_path) + cache_key: _CacheKey = ( + _fingerprint(client_id), + _fingerprint(client_secret), + api_url, + organization_slug or "", + project_id, + environment, + secret_path, + _bool_param(recursive), + _bool_param(include_imports), + _bool_param(expand_secret_references), + ) + if use_cache: + cached = _CACHE.get(cache_key) + if cached and cached.is_fresh(cache_ttl_seconds): + return dict(cached.secrets), [] + + token, _expires_in = login_universal_auth( + client_id=client_id, + client_secret=client_secret, + api_url=api_url, + organization_slug=organization_slug, + ) + payload = _http_json( + "GET", + f"{api_url}/api/v4/secrets", + token=token, + params={ + "projectId": project_id, + "environment": environment, + "secretPath": secret_path, + "viewSecretValue": "true", + "expandSecretReferences": _bool_param(expand_secret_references), + "recursive": _bool_param(recursive), + "includeImports": _bool_param(include_imports), + }, + ) + secrets, warnings = _extract_secret_values(payload, include_imports=include_imports) + if use_cache: + _CACHE[cache_key] = _CachedFetch(secrets=dict(secrets), fetched_at=time.time()) + return secrets, warnings + + +def _extract_secret_values( + payload: dict[str, Any], + *, + include_imports: bool, +) -> tuple[Dict[str, str], List[str]]: + """Extract env-var shaped secrets from Infisical's v4 list response.""" + secrets: Dict[str, str] = {} + warnings: List[str] = [] + + # Imported secrets are lower precedence than secrets in the requested path. + if include_imports: + imports = payload.get("imports") + if isinstance(imports, list): + for imported in imports: + if not isinstance(imported, dict): + continue + _merge_secret_items( + imported.get("secrets"), + secrets, + warnings, + source="import", + ) + + _merge_secret_items(payload.get("secrets"), secrets, warnings, source="secret") + return secrets, warnings + + +def _merge_secret_items( + items: Any, + secrets: Dict[str, str], + warnings: List[str], + *, + source: str, +) -> None: + if not isinstance(items, list): + return + for item in items: + if not isinstance(item, dict): + continue + key = item.get("secretKey") + value = item.get("secretValue") + if not isinstance(key, str) or not isinstance(value, str): + continue + if not _is_valid_env_name(key): + warnings.append( + f"Skipping {source} {key!r}: not a valid env-var name" + ) + continue + if key in secrets: + warnings.append( + f"Duplicate secret {key!r}: later value overwrote earlier one" + ) + secrets[key] = value + + +def _is_valid_env_name(name: str) -> bool: + if not name: + return False + if not (name[0].isalpha() or name[0] == "_"): + return False + return all(c.isalnum() or c == "_" for c in name) + + +def apply_infisical_secrets( + *, + enabled: bool, + client_id_env: str = "INFISICAL_CLIENT_ID", + client_secret_env: str = "INFISICAL_CLIENT_SECRET", + project_id: str = "", + project_id_env: str = "INFISICAL_PROJECT_ID", + environment: str = "prod", + secret_path: str = "/", + api_url: str = DEFAULT_API_URL, + organization_slug: str = "", + override_existing: bool = True, + cache_ttl_seconds: float = 300, + recursive: bool = False, + include_imports: bool = True, + expand_secret_references: bool = True, +) -> FetchResult: + """Pull Infisical secrets and set them on ``os.environ``.""" + result = FetchResult() + + if not enabled: + return result + + client_id = os.environ.get(client_id_env, "").strip() + if not client_id: + result.error = ( + f"secrets.infisical.enabled is true but {client_id_env} is not set. " + "Run `hermes secrets infisical setup`." + ) + return result + + client_secret = os.environ.get(client_secret_env, "").strip() + if not client_secret: + result.error = ( + f"secrets.infisical.enabled is true but {client_secret_env} is not set. " + "Run `hermes secrets infisical setup`." + ) + return result + + resolved_project_id = (project_id or "").strip() + if not resolved_project_id: + resolved_project_id = os.environ.get(project_id_env, "").strip() + if not resolved_project_id: + result.error = ( + "secrets.infisical.project_id is empty and " + f"{project_id_env} is not set. Run `hermes secrets infisical setup`." + ) + return result + + try: + secrets, warnings = fetch_infisical_secrets( + client_id=client_id, + client_secret=client_secret, + project_id=resolved_project_id, + environment=environment, + secret_path=secret_path, + api_url=api_url, + organization_slug=organization_slug, + cache_ttl_seconds=cache_ttl_seconds, + recursive=recursive, + include_imports=include_imports, + expand_secret_references=expand_secret_references, + ) + except RuntimeError as exc: + result.error = str(exc) + return result + + result.secrets = secrets + result.warnings.extend(warnings) + + bootstrap_names = {client_id_env, client_secret_env, project_id_env} + for key, value in secrets.items(): + if key in bootstrap_names: + result.skipped.append(key) + continue + if not override_existing and os.environ.get(key): + result.skipped.append(key) + continue + os.environ[key] = value + result.applied.append(key) + + return result + + +def _reset_cache_for_tests() -> None: + """Clear the in-process fetch cache.""" + _CACHE.clear() diff --git a/cli-config.yaml.example b/cli-config.yaml.example index 355b6bb756947..4c5f3e69f81b6 100644 --- a/cli-config.yaml.example +++ b/cli-config.yaml.example @@ -141,6 +141,29 @@ model: # response_cache: true # Enable response caching (default: true) # response_cache_ttl: 300 # Cache TTL in seconds, 1-86400 (default: 300) +# ============================================================================= +# External Secret Sources +# ============================================================================= +# Pull API keys from an external secret manager at process startup. +# Bootstrap credentials stay in ~/.hermes/.env; provider keys live in the +# secret manager and are injected into os.environ before Hermes builds config. +# +# secrets: +# infisical: +# enabled: false +# api_url: "https://app.infisical.com" # or your self-hosted URL +# project_id: "" # falls back to INFISICAL_PROJECT_ID +# env: "prod" +# path: "/" +# client_id_env: "INFISICAL_CLIENT_ID" +# client_secret_env: "INFISICAL_CLIENT_SECRET" +# project_id_env: "INFISICAL_PROJECT_ID" +# override_existing: true +# cache_ttl_seconds: 300 +# recursive: false +# include_imports: true +# expand_secret_references: true + # ============================================================================= # Git Worktree Isolation # ============================================================================= diff --git a/hermes_cli/config.py b/hermes_cli/config.py index 7b381392092e5..83d905cda8cf5 100644 --- a/hermes_cli/config.py +++ b/hermes_cli/config.py @@ -176,6 +176,8 @@ def _reject_denylisted_env_var(key: str) -> None: _EXTRA_ENV_KEYS = frozenset({ "OPENAI_API_KEY", "OPENAI_BASE_URL", "ANTHROPIC_API_KEY", "ANTHROPIC_TOKEN", + "INFISICAL_CLIENT_ID", "INFISICAL_CLIENT_SECRET", + "INFISICAL_PROJECT_ID", "INFISICAL_API_URL", "DISCORD_HOME_CHANNEL", "DISCORD_HOME_CHANNEL_NAME", "TELEGRAM_HOME_CHANNEL", "TELEGRAM_HOME_CHANNEL_NAME", "SLACK_HOME_CHANNEL", "SLACK_HOME_CHANNEL_NAME", @@ -1939,6 +1941,37 @@ def _ensure_hermes_home_managed(home: Path): # `hermes secrets bitwarden setup`. "server_url": "", }, + "infisical": { + # Master switch. When false, Infisical is never contacted. + "enabled": False, + # Infisical API base URL. Use your self-hosted URL when not + # using Infisical Cloud. + "api_url": "https://app.infisical.com", + # Env var names that hold the Machine Identity Universal Auth + # bootstrap credentials. These live in ~/.hermes/.env or the + # parent shell; never in config.yaml. + "client_id_env": "INFISICAL_CLIENT_ID", + "client_secret_env": "INFISICAL_CLIENT_SECRET", + # UUID of the Infisical project to sync from. If this is empty, + # Hermes falls back to project_id_env for compatibility with + # existing infisical run / wrapper deployments. + "project_id": "", + "project_id_env": "INFISICAL_PROJECT_ID", + # Infisical environment slug and secret path to sync. + "env": "prod", + "path": "/", + # Optional organization slug for Universal Auth setups that + # require disambiguation. + "organization_slug": "", + # Seconds to cache fetched secrets in-process. 0 disables. + "cache_ttl_seconds": 300, + # When True, Infisical values overwrite existing env vars. + "override_existing": True, + # v4 list-secrets request options. + "recursive": False, + "include_imports": True, + "expand_secret_references": True, + }, }, # Paste collapse thresholds (TUI + CLI). diff --git a/hermes_cli/env_loader.py b/hermes_cli/env_loader.py index c5e95a24dbcf0..0b97e87c08b33 100644 --- a/hermes_cli/env_loader.py +++ b/hermes_cli/env_loader.py @@ -78,12 +78,21 @@ def format_secret_source_suffix(env_var: str) -> str: return "" if source == "bitwarden": return " (from Bitwarden)" + if source == "infisical": + return " (from Infisical)" # Generic fallback — future-proofing for additional secret sources # (e.g. 1Password, HashiCorp Vault) without having to update every # call site. return f" (from {source})" +def _float_config(value: object, default: float) -> float: + try: + return float(value) + except (TypeError, ValueError): + return default + + def _format_offending_chars(value: str, limit: int = 3) -> str: """Return a compact 'U+XXXX ('c'), ...' summary of non-ASCII codepoints.""" seen: list[str] = [] @@ -248,10 +257,10 @@ def load_hermes_dotenv( def _apply_external_secret_sources(home_path: Path) -> None: - """Pull secrets from external sources (currently Bitwarden) into env. + """Pull secrets from external sources into env. Runs AFTER dotenv loads so .env values are visible (we use them to - locate the access token) but BEFORE the rest of Hermes reads + locate bootstrap credentials) but BEFORE the rest of Hermes reads ``os.environ`` for credentials. Any failure here is logged and swallowed — external secret sources must never block startup. @@ -275,50 +284,100 @@ def _apply_external_secret_sources(home_path: Path) -> None: return bw_cfg = (cfg or {}).get("bitwarden") or {} - if not bw_cfg.get("enabled"): - return + if bw_cfg.get("enabled"): + try: + from agent.secret_sources.bitwarden import apply_bitwarden_secrets + except ImportError: + apply_bitwarden_secrets = None + + if apply_bitwarden_secrets is not None: + result = apply_bitwarden_secrets( + enabled=True, + access_token_env=bw_cfg.get("access_token_env", "BWS_ACCESS_TOKEN"), + project_id=bw_cfg.get("project_id", ""), + override_existing=bool(bw_cfg.get("override_existing", False)), + cache_ttl_seconds=_float_config( + bw_cfg.get("cache_ttl_seconds", 300), + 300.0, + ), + auto_install=bool(bw_cfg.get("auto_install", True)), + server_url=str(bw_cfg.get("server_url", "") or "").strip(), + home_path=home_path, + ) - try: - from agent.secret_sources.bitwarden import apply_bitwarden_secrets - except ImportError: - return + _report_secret_source_result( + label="Bitwarden Secrets Manager", + source="bitwarden", + result=result, + ) + + inf_cfg = (cfg or {}).get("infisical") or {} + if inf_cfg.get("enabled"): + try: + from agent.secret_sources.infisical import apply_infisical_secrets + except ImportError: + apply_infisical_secrets = None + + if apply_infisical_secrets is not None: + result = apply_infisical_secrets( + enabled=True, + client_id_env=inf_cfg.get("client_id_env", "INFISICAL_CLIENT_ID"), + client_secret_env=inf_cfg.get( + "client_secret_env", "INFISICAL_CLIENT_SECRET" + ), + project_id=str(inf_cfg.get("project_id", "") or "").strip(), + project_id_env=inf_cfg.get("project_id_env", "INFISICAL_PROJECT_ID"), + environment=str(inf_cfg.get("env", "prod") or "prod").strip(), + secret_path=str(inf_cfg.get("path", "/") or "/").strip(), + api_url=str( + inf_cfg.get("api_url") + or os.environ.get("INFISICAL_API_URL") + or "https://app.infisical.com" + ).strip(), + organization_slug=str( + inf_cfg.get("organization_slug", "") or "" + ).strip(), + override_existing=bool(inf_cfg.get("override_existing", True)), + cache_ttl_seconds=_float_config( + inf_cfg.get("cache_ttl_seconds", 300), + 300.0, + ), + recursive=bool(inf_cfg.get("recursive", False)), + include_imports=bool(inf_cfg.get("include_imports", True)), + expand_secret_references=bool( + inf_cfg.get("expand_secret_references", True) + ), + ) + + _report_secret_source_result( + label="Infisical", + source="infisical", + result=result, + ) - result = apply_bitwarden_secrets( - enabled=True, - access_token_env=bw_cfg.get("access_token_env", "BWS_ACCESS_TOKEN"), - project_id=bw_cfg.get("project_id", ""), - override_existing=bool(bw_cfg.get("override_existing", False)), - cache_ttl_seconds=float(bw_cfg.get("cache_ttl_seconds", 300)), - auto_install=bool(bw_cfg.get("auto_install", True)), - server_url=str(bw_cfg.get("server_url", "") or "").strip(), - home_path=home_path, - ) +def _report_secret_source_result(*, label: str, source: str, result) -> None: if result.applied: - # Re-run the ASCII sanitization pass: BSM values are user-supplied - # and might have the same copy-paste corruption as a manually - # edited .env (see #6843). + # Re-run the ASCII sanitization pass: external values are + # user-supplied and might have the same copy-paste corruption as a + # manually edited .env (see #6843). _sanitize_loaded_credentials() - # Remember where these came from so the setup / `hermes model` - # flows can label detected credentials with "(from Bitwarden)" — - # otherwise users see "credentials ✓" with no hint that the value - # came from BSM rather than .env. for name in result.applied: - _SECRET_SOURCES[name] = "bitwarden" + _SECRET_SOURCES[name] = source print( - f" Bitwarden Secrets Manager: applied {len(result.applied)} " + f" {label}: applied {len(result.applied)} " f"secret{'s' if len(result.applied) != 1 else ''} " f"({', '.join(sorted(result.applied))})", file=sys.stderr, ) if result.error: print( - f" Bitwarden Secrets Manager: {result.error}", + f" {label}: {result.error}", file=sys.stderr, ) for warn in result.warnings: print( - f" Bitwarden Secrets Manager: {warn}", + f" {label}: {warn}", file=sys.stderr, ) diff --git a/hermes_cli/infisical_secrets_cli.py b/hermes_cli/infisical_secrets_cli.py new file mode 100644 index 0000000000000..56b301cd46f07 --- /dev/null +++ b/hermes_cli/infisical_secrets_cli.py @@ -0,0 +1,414 @@ +"""CLI handlers for ``hermes secrets infisical ...``.""" + +from __future__ import annotations + +import argparse +import os + +from rich.console import Console +from rich.panel import Panel +from rich.table import Table + +from agent.secret_sources import infisical +from hermes_cli.config import ( + get_env_path, + load_config, + save_config, + save_env_value, +) +from hermes_cli.secret_prompt import masked_secret_prompt + + +def register_cli(parent_parser: argparse.ArgumentParser) -> None: + """Attach the ``infisical`` subcommand tree to a parent parser.""" + sub = parent_parser.add_subparsers(dest="secrets_infisical_command") + + setup = sub.add_parser( + "setup", + help="Interactive wizard: store Universal Auth credentials and test fetch", + ) + setup.add_argument( + "--client-id", + help="Universal Auth client ID (will be stored in .env)", + ) + setup.add_argument( + "--client-secret", + help="Universal Auth client secret (will be stored in .env)", + ) + setup.add_argument("--project-id", help="Infisical project UUID") + setup.add_argument( + "--api-url", + default="", + help="Infisical API URL (default: https://app.infisical.com)", + ) + setup.add_argument("--env", default="", help="Infisical environment slug") + setup.add_argument("--path", default="", help="Secret path to sync") + setup.add_argument( + "--organization-slug", + default="", + help="Optional organization slug for Universal Auth", + ) + setup.set_defaults(func=cmd_setup) + + status = sub.add_parser("status", help="Show config + credential presence") + status.set_defaults(func=cmd_status) + + sync = sub.add_parser("sync", help="Fetch secrets now and report what changed") + sync.add_argument( + "--apply", + action="store_true", + help="Actually export the secrets into the current process env", + ) + sync.set_defaults(func=cmd_sync) + + disable = sub.add_parser("disable", help="Turn off the Infisical integration") + disable.set_defaults(func=cmd_disable) + + +def cmd_setup(args: argparse.Namespace) -> int: + console = Console() + console.print( + Panel.fit( + "[bold]Infisical setup[/bold]\n\n" + "Create a Machine Identity with Universal Auth in Infisical, " + "grant it read access to the project/environment/path Hermes " + "should sync, then paste the client ID and client secret here.", + border_style="cyan", + ) + ) + + cfg = load_config() + secrets_cfg = cfg.setdefault("secrets", {}).setdefault("infisical", {}) + client_id_env = secrets_cfg.get("client_id_env", "INFISICAL_CLIENT_ID") + client_secret_env = secrets_cfg.get( + "client_secret_env", "INFISICAL_CLIENT_SECRET" + ) + + console.print() + console.print("[bold]Step 1[/bold] Provide Universal Auth credentials") + client_id = (args.client_id or "").strip() + if not client_id: + client_id = console.input(f" Client ID ({client_id_env}): ").strip() + if not client_id: + console.print(" [red]Empty client ID, aborting.[/red]") + return 1 + + client_secret = (args.client_secret or "").strip() + if not client_secret: + client_secret = masked_secret_prompt( + f" Client secret ({client_secret_env}): " + ).strip() + if not client_secret: + console.print(" [red]Empty client secret, aborting.[/red]") + return 1 + + save_env_value(client_id_env, client_id) + save_env_value(client_secret_env, client_secret) + os.environ[client_id_env] = client_id + os.environ[client_secret_env] = client_secret + console.print( + f" [green]✓[/green] stored bootstrap credentials in {get_env_path()}" + ) + + console.print() + console.print("[bold]Step 2[/bold] Select Infisical project and path") + project_id = (args.project_id or "").strip() + if not project_id: + project_id = str(secrets_cfg.get("project_id", "") or "").strip() + if not project_id: + project_id = console.input(" Project ID: ").strip() + if not project_id: + console.print(" [red]Empty project ID, aborting.[/red]") + return 1 + + api_url = ( + args.api_url + or secrets_cfg.get("api_url") + or os.environ.get("INFISICAL_API_URL") + or infisical.DEFAULT_API_URL + ) + api_url = str(api_url).strip() + environment = ( + args.env + or secrets_cfg.get("env") + or "prod" + ) + environment = str(environment).strip() + secret_path = ( + args.path + or secrets_cfg.get("path") + or "/" + ) + secret_path = str(secret_path).strip() + organization_slug = ( + args.organization_slug + or secrets_cfg.get("organization_slug") + or "" + ) + organization_slug = str(organization_slug).strip() + + console.print(f" API URL: [cyan]{api_url}[/cyan]") + console.print(f" Project ID: [cyan]{project_id}[/cyan]") + console.print(f" Environment: [cyan]{environment}[/cyan]") + console.print(f" Path: [cyan]{secret_path}[/cyan]") + if organization_slug: + console.print(f" Org slug: [cyan]{organization_slug}[/cyan]") + + console.print() + console.print("[bold]Step 3[/bold] Test fetch") + try: + secrets, warnings = infisical.fetch_infisical_secrets( + client_id=client_id, + client_secret=client_secret, + project_id=project_id, + environment=environment, + secret_path=secret_path, + api_url=api_url, + organization_slug=organization_slug, + use_cache=False, + ) + except Exception as exc: # noqa: BLE001 + console.print(f" [red]✗ Fetch failed: {exc}[/red]") + return 1 + + _print_secret_preview(console, secrets, warnings, bootstrap_names={ + client_id_env, + client_secret_env, + secrets_cfg.get("project_id_env", "INFISICAL_PROJECT_ID"), + }) + + secrets_cfg["enabled"] = True + secrets_cfg["api_url"] = api_url + secrets_cfg["client_id_env"] = client_id_env + secrets_cfg["client_secret_env"] = client_secret_env + secrets_cfg["project_id"] = project_id + secrets_cfg.setdefault("project_id_env", "INFISICAL_PROJECT_ID") + secrets_cfg["env"] = environment + secrets_cfg["path"] = secret_path + secrets_cfg["organization_slug"] = organization_slug + secrets_cfg.setdefault("cache_ttl_seconds", 300) + secrets_cfg.setdefault("override_existing", True) + secrets_cfg.setdefault("recursive", False) + secrets_cfg.setdefault("include_imports", True) + secrets_cfg.setdefault("expand_secret_references", True) + save_config(cfg) + + console.print() + console.print( + "[green]✓ Infisical is enabled.[/green] Secrets will be pulled at " + "the start of every Hermes process." + ) + console.print( + " Status: [cyan]hermes secrets infisical status[/cyan]\n" + " Refresh: [cyan]hermes secrets infisical sync[/cyan]\n" + " Disable: [cyan]hermes secrets infisical disable[/cyan]" + ) + return 0 + + +def cmd_status(args: argparse.Namespace) -> int: + console = Console() + cfg = load_config() + inf_cfg = (cfg.get("secrets") or {}).get("infisical") or {} + + enabled = bool(inf_cfg.get("enabled")) + client_id_env = inf_cfg.get("client_id_env", "INFISICAL_CLIENT_ID") + client_secret_env = inf_cfg.get( + "client_secret_env", "INFISICAL_CLIENT_SECRET" + ) + project_id = str(inf_cfg.get("project_id", "") or "").strip() + project_id_env = inf_cfg.get("project_id_env", "INFISICAL_PROJECT_ID") + + table = Table(show_header=False, box=None, padding=(0, 2)) + table.add_column("", style="bold") + table.add_column("") + table.add_row("Enabled", _yn(enabled)) + table.add_row("API URL", inf_cfg.get("api_url", infisical.DEFAULT_API_URL)) + table.add_row("Client ID env", client_id_env) + table.add_row("Client ID in env", _yn(bool(os.environ.get(client_id_env)))) + table.add_row("Client secret env", client_secret_env) + table.add_row( + "Client secret in env", + _yn(bool(os.environ.get(client_secret_env))), + ) + table.add_row("Project ID", project_id or f"[dim]({project_id_env})[/dim]") + table.add_row("Project ID env", project_id_env) + table.add_row("Project ID in env", _yn(bool(os.environ.get(project_id_env)))) + table.add_row("Environment", str(inf_cfg.get("env", "prod") or "prod")) + table.add_row("Path", str(inf_cfg.get("path", "/") or "/")) + table.add_row( + "Organization slug", + str(inf_cfg.get("organization_slug", "") or "") or "[dim](unset)[/dim]", + ) + table.add_row( + "Override existing", + _yn(bool(inf_cfg.get("override_existing", True))), + ) + table.add_row("Cache TTL (s)", str(inf_cfg.get("cache_ttl_seconds", 300))) + + console.print(Panel(table, title="Infisical", border_style="cyan")) + + if not enabled: + console.print("\n Run [cyan]hermes secrets infisical setup[/cyan] to enable.") + return 0 + + +def cmd_sync(args: argparse.Namespace) -> int: + console = Console() + cfg = load_config() + inf_cfg = (cfg.get("secrets") or {}).get("infisical") or {} + if not inf_cfg.get("enabled"): + console.print( + "[yellow]Infisical integration is disabled. Run " + "`hermes secrets infisical setup` first.[/yellow]" + ) + return 1 + + client_id_env = inf_cfg.get("client_id_env", "INFISICAL_CLIENT_ID") + client_secret_env = inf_cfg.get( + "client_secret_env", "INFISICAL_CLIENT_SECRET" + ) + client_id = os.environ.get(client_id_env, "").strip() + client_secret = os.environ.get(client_secret_env, "").strip() + if not client_id: + console.print(f"[red]{client_id_env} is not set.[/red]") + return 1 + if not client_secret: + console.print(f"[red]{client_secret_env} is not set.[/red]") + return 1 + + project_id_env = inf_cfg.get("project_id_env", "INFISICAL_PROJECT_ID") + project_id = str(inf_cfg.get("project_id", "") or "").strip() + if not project_id: + project_id = os.environ.get(project_id_env, "").strip() + if not project_id: + console.print(f"[red]No project_id configured and {project_id_env} is not set.[/red]") + return 1 + + try: + secrets, warnings = infisical.fetch_infisical_secrets( + client_id=client_id, + client_secret=client_secret, + project_id=project_id, + environment=str(inf_cfg.get("env", "prod") or "prod"), + secret_path=str(inf_cfg.get("path", "/") or "/"), + api_url=str( + inf_cfg.get("api_url") + or os.environ.get("INFISICAL_API_URL") + or infisical.DEFAULT_API_URL + ), + organization_slug=str(inf_cfg.get("organization_slug", "") or ""), + use_cache=False, + recursive=bool(inf_cfg.get("recursive", False)), + include_imports=bool(inf_cfg.get("include_imports", True)), + expand_secret_references=bool( + inf_cfg.get("expand_secret_references", True) + ), + ) + except Exception as exc: # noqa: BLE001 + console.print(f"[red]Fetch failed: {exc}[/red]") + return 1 + + _print_sync_actions(console, args, inf_cfg, secrets, warnings, { + client_id_env, + client_secret_env, + project_id_env, + }) + return 0 + + +def cmd_disable(args: argparse.Namespace) -> int: + console = Console() + cfg = load_config() + inf_cfg = cfg.setdefault("secrets", {}).setdefault("infisical", {}) + inf_cfg["enabled"] = False + save_config(cfg) + console.print( + "[green]Disabled.[/green] Infisical secrets will NOT be pulled on " + "the next Hermes invocation.\n" + " Bootstrap credentials are left in .env; remove or revoke them " + "manually if needed." + ) + return 0 + + +def _print_secret_preview( + console: Console, + secrets: dict[str, str], + warnings: list[str], + *, + bootstrap_names: set[str], +) -> None: + if not secrets: + console.print(" [yellow]Fetch succeeded but this path has no secrets.[/yellow]") + else: + table = Table(show_header=True, header_style="bold") + table.add_column("Name", style="cyan") + table.add_column("Status") + for key in sorted(secrets): + if key in bootstrap_names: + status = "[dim]bootstrap credential — never overrides itself[/dim]" + elif os.environ.get(key): + status = "[yellow]already set in env (will be overwritten)[/yellow]" + else: + status = "[green]new[/green]" + table.add_row(key, status) + console.print(table) + for warning in warnings: + console.print(f" [yellow]warning:[/yellow] {warning}") + + +def _print_sync_actions( + console: Console, + args: argparse.Namespace, + inf_cfg: dict, + secrets: dict[str, str], + warnings: list[str], + bootstrap_names: set[str], +) -> None: + if not secrets: + console.print("[yellow]No secrets found.[/yellow]") + return + + override = bool(inf_cfg.get("override_existing", True)) or bool(args.apply) + table = Table(show_header=True, header_style="bold") + table.add_column("Name", style="cyan") + table.add_column("Action") + applied = 0 + for key in sorted(secrets): + if key in bootstrap_names: + table.add_row(key, "[dim]skip (bootstrap credential)[/dim]") + continue + already = bool(os.environ.get(key)) + if already and not override: + table.add_row(key, "[dim]skip (already set)[/dim]") + continue + if args.apply: + os.environ[key] = secrets[key] + applied += 1 + table.add_row( + key, + "[green]exported[/green]" + (" (overrode)" if already else ""), + ) + else: + table.add_row( + key, + "[green]would export[/green]" + (" (overrides)" if already else ""), + ) + + console.print(table) + for warning in warnings: + console.print(f"[yellow]warning:[/yellow] {warning}") + + if not args.apply: + console.print( + "\n This was a dry-run — secrets are picked up automatically on the " + "next [cyan]hermes[/cyan] invocation. Re-run with [cyan]--apply[/cyan] " + "to export into the current process instead." + ) + else: + console.print(f"\n [green]Exported {applied} secret(s) into current process.[/green]") + + +def _yn(value: bool) -> str: + return "[green]yes[/green]" if value else "[dim]no[/dim]" diff --git a/hermes_cli/main.py b/hermes_cli/main.py index 8bda836623d5a..03966a7fa4c40 100644 --- a/hermes_cli/main.py +++ b/hermes_cli/main.py @@ -11219,16 +11219,16 @@ def main(): fallback_parser.set_defaults(func=cmd_fallback) # ========================================================================= - # secrets command — external secret managers (currently: Bitwarden) + # secrets command — external secret managers # ========================================================================= secrets_parser = subparsers.add_parser( "secrets", - help="Manage external secret sources (Bitwarden Secrets Manager)", + help="Manage external secret sources (Bitwarden, Infisical)", description=( "Pull API keys from an external secret manager at process startup " - "instead of storing them in ~/.hermes/.env. Currently supports " - "Bitwarden Secrets Manager. See: " - "https://hermes-agent.nousresearch.com/docs/user-guide/secrets/bitwarden" + "instead of storing them in ~/.hermes/.env. Currently supports " + "Bitwarden Secrets Manager and Infisical. See: " + "https://hermes-agent.nousresearch.com/docs/user-guide/secrets" ), ) secrets_subparsers = secrets_parser.add_subparsers(dest="secrets_command") @@ -11238,17 +11238,27 @@ def main(): aliases=["bw"], help="Bitwarden Secrets Manager integration", ) + secrets_inf = secrets_subparsers.add_parser( + "infisical", + aliases=["inf"], + help="Infisical Universal Auth integration", + ) # Lazy import — only pays for itself when this subcommand is actually used. from hermes_cli import secrets_cli as _secrets_cli + from hermes_cli import infisical_secrets_cli as _infisical_secrets_cli _secrets_cli.register_cli(secrets_bw) + _infisical_secrets_cli.register_cli(secrets_inf) def _dispatch_secrets(args): # noqa: ANN001 sub = getattr(args, "secrets_command", None) bw_sub = getattr(args, "secrets_bw_command", None) + inf_sub = getattr(args, "secrets_infisical_command", None) if sub in ("bitwarden", "bw") and bw_sub is not None: return args.func(args) + if sub in ("infisical", "inf") and inf_sub is not None: + return args.func(args) secrets_parser.print_help() return 0 diff --git a/tests/test_env_loader_secret_sources.py b/tests/test_env_loader_secret_sources.py index 91c9d4c6e4f56..2a16f3c4add65 100644 --- a/tests/test_env_loader_secret_sources.py +++ b/tests/test_env_loader_secret_sources.py @@ -53,6 +53,14 @@ def test_format_secret_source_suffix_bitwarden_uses_proper_name(): ) +def test_format_secret_source_suffix_infisical_uses_proper_name(): + env_loader._SECRET_SOURCES["ANTHROPIC_API_KEY"] = "infisical" + assert ( + env_loader.format_secret_source_suffix("ANTHROPIC_API_KEY") + == " (from Infisical)" + ) + + def test_format_secret_source_suffix_generic_label_for_future_sources(): # Future-proofing: a new secret source (e.g. "vault") should still # produce a sensible label without needing to edit every call site. @@ -121,6 +129,117 @@ def test_apply_external_secret_sources_noop_when_disabled(tmp_path, monkeypatch) assert env_loader.get_secret_source("ANTHROPIC_API_KEY") is None +def test_apply_external_secret_sources_records_infisical_origin(tmp_path, monkeypatch): + """Infisical-applied keys are tracked for source labels.""" + + monkeypatch.setenv("HERMES_HOME", str(tmp_path)) + config_path = tmp_path / "config.yaml" + config_path.write_text( + "secrets:\n" + " infisical:\n" + " enabled: true\n" + " project_id: test-project\n" + " client_id_env: INFISICAL_CLIENT_ID\n" + " client_secret_env: INFISICAL_CLIENT_SECRET\n", + encoding="utf-8", + ) + + from agent.secret_sources.infisical import FetchResult + + fake_result = FetchResult( + secrets={"ANTHROPIC_API_KEY": "sk-ant-test"}, + applied=["ANTHROPIC_API_KEY"], + ) + + def _fake_apply(**_kwargs): + return fake_result + + import agent.secret_sources.infisical as inf_module + + monkeypatch.setattr(inf_module, "apply_infisical_secrets", _fake_apply) + + env_loader._apply_external_secret_sources(tmp_path) + + assert env_loader.get_secret_source("ANTHROPIC_API_KEY") == "infisical" + assert ( + env_loader.format_secret_source_suffix("ANTHROPIC_API_KEY") + == " (from Infisical)" + ) + + +def test_apply_external_secret_sources_defaults_bad_infisical_cache_ttl( + tmp_path, + monkeypatch, +): + """A typo in cache_ttl_seconds must not break dotenv loading.""" + + monkeypatch.setenv("HERMES_HOME", str(tmp_path)) + config_path = tmp_path / "config.yaml" + config_path.write_text( + "secrets:\n" + " infisical:\n" + " enabled: true\n" + " project_id: test-project\n" + " cache_ttl_seconds: not-a-number\n", + encoding="utf-8", + ) + + from agent.secret_sources.infisical import FetchResult + + captured = {} + + def _fake_apply(**kwargs): + captured.update(kwargs) + return FetchResult() + + import agent.secret_sources.infisical as inf_module + + monkeypatch.setattr(inf_module, "apply_infisical_secrets", _fake_apply) + + env_loader._apply_external_secret_sources(tmp_path) + + assert captured["cache_ttl_seconds"] == 300.0 + + +def test_apply_external_secret_sources_does_not_return_after_bitwarden_disabled( + tmp_path, + monkeypatch, +): + """A disabled Bitwarden section must not prevent Infisical from running.""" + + monkeypatch.setenv("HERMES_HOME", str(tmp_path)) + config_path = tmp_path / "config.yaml" + config_path.write_text( + "secrets:\n" + " bitwarden:\n" + " enabled: false\n" + " infisical:\n" + " enabled: true\n" + " project_id: test-project\n", + encoding="utf-8", + ) + + from agent.secret_sources.infisical import FetchResult + + call_count = {"n": 0} + + def _fake_apply(**_kwargs): + call_count["n"] += 1 + return FetchResult( + secrets={"ANTHROPIC_API_KEY": "sk-ant-test"}, + applied=["ANTHROPIC_API_KEY"], + ) + + import agent.secret_sources.infisical as inf_module + + monkeypatch.setattr(inf_module, "apply_infisical_secrets", _fake_apply) + + env_loader._apply_external_secret_sources(tmp_path) + + assert call_count["n"] == 1 + assert env_loader.get_secret_source("ANTHROPIC_API_KEY") == "infisical" + + def test_apply_external_secret_sources_dedupes_within_process(tmp_path, monkeypatch): """``load_hermes_dotenv()`` is called at module-import time from several hot modules (cli.py, hermes_cli/main.py, run_agent.py, ...). The diff --git a/tests/test_infisical_secrets.py b/tests/test_infisical_secrets.py new file mode 100644 index 0000000000000..1ef687a838273 --- /dev/null +++ b/tests/test_infisical_secrets.py @@ -0,0 +1,322 @@ +"""Hermetic tests for the Infisical secret-source integration.""" + +from __future__ import annotations + +import os +import sys +from pathlib import Path + +import pytest + + +ROOT = Path(__file__).resolve().parents[1] +if str(ROOT) not in sys.path: + sys.path.insert(0, str(ROOT)) + +from agent.secret_sources import infisical as inf # noqa: E402 + + +@pytest.fixture(autouse=True) +def _reset_cache(monkeypatch): + for key in ( + "NEW_KEY", + "OPENAI_API_KEY", + "INFISICAL_CLIENT_ID", + "INFISICAL_CLIENT_SECRET", + "INFISICAL_PROJECT_ID", + ): + monkeypatch.delenv(key, raising=False) + inf._reset_cache_for_tests() + yield + inf._reset_cache_for_tests() + + +def test_login_universal_auth_posts_client_credentials(monkeypatch): + calls = [] + + def fake_http(method, url, **kwargs): + calls.append((method, url, kwargs)) + return {"accessToken": "access-token", "expiresIn": 3600} + + monkeypatch.setattr(inf, "_http_json", fake_http) + + token, expires_in = inf.login_universal_auth( + client_id="cid", + client_secret="csecret", + api_url="https://infisical.example.com/", + organization_slug="acme", + ) + + assert token == "access-token" + assert expires_in == 3600 + assert calls == [ + ( + "POST", + "https://infisical.example.com/api/v1/auth/universal-auth/login", + { + "body": { + "clientId": "cid", + "clientSecret": "csecret", + "organizationSlug": "acme", + }, + }, + ) + ] + + +def test_fetch_secrets_uses_v4_list_endpoint(monkeypatch): + calls = [] + + def fake_http(method, url, **kwargs): + calls.append((method, url, kwargs)) + if method == "POST": + return {"accessToken": "access-token"} + return { + "secrets": [ + {"secretKey": "OPENAI_API_KEY", "secretValue": "sk-test"}, + {"secretKey": "ANTHROPIC_API_KEY", "secretValue": "sk-ant"}, + ] + } + + monkeypatch.setattr(inf, "_http_json", fake_http) + + secrets, warnings = inf.fetch_infisical_secrets( + client_id="cid", + client_secret="csecret", + project_id="proj", + environment="dev", + secret_path="hermes", + api_url="https://infisical.example.com", + recursive=True, + include_imports=False, + expand_secret_references=False, + use_cache=False, + ) + + assert secrets == { + "OPENAI_API_KEY": "sk-test", + "ANTHROPIC_API_KEY": "sk-ant", + } + assert warnings == [] + method, url, kwargs = calls[1] + assert method == "GET" + assert url == "https://infisical.example.com/api/v4/secrets" + assert kwargs["token"] == "access-token" + assert kwargs["params"] == { + "projectId": "proj", + "environment": "dev", + "secretPath": "/hermes", + "viewSecretValue": "true", + "expandSecretReferences": "false", + "recursive": "true", + "includeImports": "false", + } + + +def test_extract_includes_imports_with_lower_precedence(): + payload = { + "imports": [ + { + "secrets": [ + {"secretKey": "SHARED_KEY", "secretValue": "imported"}, + {"secretKey": "IMPORT_ONLY", "secretValue": "yes"}, + ] + } + ], + "secrets": [ + {"secretKey": "SHARED_KEY", "secretValue": "local"}, + {"secretKey": "LOCAL_ONLY", "secretValue": "yes"}, + ], + } + + secrets, warnings = inf._extract_secret_values(payload, include_imports=True) + + assert secrets == { + "SHARED_KEY": "local", + "IMPORT_ONLY": "yes", + "LOCAL_ONLY": "yes", + } + assert any("Duplicate secret 'SHARED_KEY'" in warning for warning in warnings) + + +def test_fetch_skips_invalid_env_names(monkeypatch): + def fake_http(method, url, **kwargs): + if method == "POST": + return {"accessToken": "access-token"} + return { + "secrets": [ + {"secretKey": "VALID_KEY", "secretValue": "v1"}, + {"secretKey": "1BAD", "secretValue": "v2"}, + {"secretKey": "HAS-DASH", "secretValue": "v3"}, + ] + } + + monkeypatch.setattr(inf, "_http_json", fake_http) + + secrets, warnings = inf.fetch_infisical_secrets( + client_id="cid", + client_secret="csecret", + project_id="proj", + use_cache=False, + ) + + assert secrets == {"VALID_KEY": "v1"} + assert len(warnings) == 2 + + +def test_fetch_cache_hits(monkeypatch): + call_count = {"n": 0} + + def fake_http(method, url, **kwargs): + if method == "POST": + return {"accessToken": "access-token"} + call_count["n"] += 1 + return {"secrets": [{"secretKey": "KEY", "secretValue": "value"}]} + + monkeypatch.setattr(inf, "_http_json", fake_http) + + inf.fetch_infisical_secrets( + client_id="cid", + client_secret="csecret", + project_id="proj", + cache_ttl_seconds=60, + ) + inf.fetch_infisical_secrets( + client_id="cid", + client_secret="csecret", + project_id="proj", + cache_ttl_seconds=60, + ) + + assert call_count["n"] == 1 + + +def test_fetch_cache_returns_defensive_copy(monkeypatch): + def fake_http(method, url, **kwargs): + if method == "POST": + return {"accessToken": "access-token"} + return {"secrets": [{"secretKey": "KEY", "secretValue": "value"}]} + + monkeypatch.setattr(inf, "_http_json", fake_http) + + secrets, _warnings = inf.fetch_infisical_secrets( + client_id="cid", + client_secret="csecret", + project_id="proj", + cache_ttl_seconds=60, + ) + secrets["KEY"] = "mutated" + + cached, _warnings = inf.fetch_infisical_secrets( + client_id="cid", + client_secret="csecret", + project_id="proj", + cache_ttl_seconds=60, + ) + + assert cached == {"KEY": "value"} + + +def test_http_json_wraps_os_errors(monkeypatch): + def fake_urlopen(*_args, **_kwargs): + raise TimeoutError("timed out") + + monkeypatch.setattr(inf.urllib.request, "urlopen", fake_urlopen) + + with pytest.raises(RuntimeError, match="Infisical API request failed"): + inf._http_json("GET", "https://infisical.example.com/api") + + +def test_apply_missing_bootstrap_credentials(monkeypatch): + monkeypatch.delenv("INFISICAL_CLIENT_ID", raising=False) + monkeypatch.delenv("INFISICAL_CLIENT_SECRET", raising=False) + + result = inf.apply_infisical_secrets(enabled=True, project_id="proj") + + assert not result.ok + assert "INFISICAL_CLIENT_ID" in result.error + + +def test_apply_project_id_env_fallback(monkeypatch): + monkeypatch.setenv("INFISICAL_CLIENT_ID", "cid") + monkeypatch.setenv("INFISICAL_CLIENT_SECRET", "csecret") + monkeypatch.setenv("INFISICAL_PROJECT_ID", "proj-from-env") + captured = {} + + def fake_fetch(**kwargs): + captured.update(kwargs) + return {"NEW_KEY": "fresh"}, [] + + monkeypatch.setattr(inf, "fetch_infisical_secrets", fake_fetch) + + result = inf.apply_infisical_secrets(enabled=True, project_id="") + + assert result.ok + assert captured["project_id"] == "proj-from-env" + assert os.environ["NEW_KEY"] == "fresh" + assert "NEW_KEY" in result.applied + + +def test_apply_does_not_override_existing(monkeypatch): + monkeypatch.setenv("INFISICAL_CLIENT_ID", "cid") + monkeypatch.setenv("INFISICAL_CLIENT_SECRET", "csecret") + monkeypatch.setenv("OPENAI_API_KEY", "existing") + + monkeypatch.setattr( + inf, + "fetch_infisical_secrets", + lambda **_kwargs: ( + {"OPENAI_API_KEY": "fresh", "NEW_KEY": "new"}, + [], + ), + ) + + result = inf.apply_infisical_secrets( + enabled=True, + project_id="proj", + override_existing=False, + ) + + assert result.ok + assert os.environ["OPENAI_API_KEY"] == "existing" + assert os.environ["NEW_KEY"] == "new" + assert "OPENAI_API_KEY" in result.skipped + assert "NEW_KEY" in result.applied + + +def test_apply_never_overrides_bootstrap_credentials(monkeypatch): + monkeypatch.setenv("INFISICAL_CLIENT_ID", "cid") + monkeypatch.setenv("INFISICAL_CLIENT_SECRET", "original-secret") + + monkeypatch.setattr( + inf, + "fetch_infisical_secrets", + lambda **_kwargs: ( + {"INFISICAL_CLIENT_SECRET": "malicious-replacement"}, + [], + ), + ) + + result = inf.apply_infisical_secrets( + enabled=True, + project_id="proj", + override_existing=True, + ) + + assert os.environ["INFISICAL_CLIENT_SECRET"] == "original-secret" + assert "INFISICAL_CLIENT_SECRET" in result.skipped + + +def test_apply_swallows_fetch_errors(monkeypatch): + monkeypatch.setenv("INFISICAL_CLIENT_ID", "cid") + monkeypatch.setenv("INFISICAL_CLIENT_SECRET", "csecret") + + def fake_fetch(**_kwargs): + raise RuntimeError("bad auth") + + monkeypatch.setattr(inf, "fetch_infisical_secrets", fake_fetch) + + result = inf.apply_infisical_secrets(enabled=True, project_id="proj") + + assert not result.ok + assert result.error == "bad auth" diff --git a/website/docs/user-guide/secrets/index.md b/website/docs/user-guide/secrets/index.md index bf8d85cfed69d..5469f9a9fc36d 100644 --- a/website/docs/user-guide/secrets/index.md +++ b/website/docs/user-guide/secrets/index.md @@ -5,5 +5,6 @@ Hermes can pull API keys from external secret managers at process startup instea Supported: - [Bitwarden Secrets Manager](./bitwarden) — `bws` CLI, lazy-installed, free tier works. +- [Infisical](./infisical) — Universal Auth, Cloud or self-hosted. More backends (Vault, AWS Secrets Manager, 1Password CLI) are easy to add behind the same interface — the lift is one module in `agent/secret_sources/` and one CLI handler. File a request if you have a specific one in mind. diff --git a/website/docs/user-guide/secrets/infisical.md b/website/docs/user-guide/secrets/infisical.md new file mode 100644 index 0000000000000..de2629e96ed8c --- /dev/null +++ b/website/docs/user-guide/secrets/infisical.md @@ -0,0 +1,129 @@ +# Infisical + +Pull API keys from [Infisical](https://infisical.com) at process startup instead of storing them in plaintext inside `~/.hermes/.env`. Hermes uses Infisical Universal Auth for Machine Identities, then reads secrets from one project/environment/path and exports valid environment-variable names into `os.environ`. + +## How it works + +1. You create an Infisical **Machine Identity** with Universal Auth and grant it read access to a project. +2. Hermes stores only the bootstrap credentials in `~/.hermes/.env` as `INFISICAL_CLIENT_ID` and `INFISICAL_CLIENT_SECRET`. +3. Every time `hermes` starts, after `.env` has loaded, Hermes logs in through Universal Auth and calls the Infisical v4 secrets API. +4. Returned `secretKey` / `secretValue` pairs are written into `os.environ` before provider and gateway config is built. + +By default Hermes overwrites existing env vars with Infisical values so rotating a secret in Infisical takes effect on the next Hermes start. Set `override_existing: false` if local `.env` or shell exports should win. + +## Setup + +### 1. Create a Machine Identity + +In Infisical: + +1. Create or pick a project. +2. Add provider keys as secrets. The secret key becomes the environment variable name, e.g. `OPENROUTER_API_KEY`, `ANTHROPIC_API_KEY`, or `SLACK_BOT_TOKEN`. +3. Create a Machine Identity with Universal Auth. +4. Grant that identity read access to the target project, environment, and path. +5. Copy the Universal Auth client ID and client secret. + +### 2. Run the wizard + +```bash +hermes secrets infisical setup +``` + +The wizard stores the bootstrap credentials in `.env`, asks for the project ID, environment, and secret path, test-fetches secrets, then enables `secrets.infisical.enabled: true`. + +Non-interactive setup is also supported: + +```bash +hermes secrets infisical setup \ + --client-id "$INFISICAL_CLIENT_ID" \ + --client-secret "$INFISICAL_CLIENT_SECRET" \ + --project-id \ + --api-url https://app.infisical.com \ + --env prod \ + --path / +``` + +For self-hosted Infisical, set `--api-url` to your instance URL. + +### 3. Confirm + +```bash +hermes secrets infisical status +``` + +From now on, every `hermes` invocation pulls secrets at startup. You'll see a one-line summary on stderr the first time secrets are applied in a process. + +## CLI + +| Command | What it does | +|---|---| +| `hermes secrets infisical setup` | Store Universal Auth credentials, configure project/path, test fetch | +| `hermes secrets infisical status` | Show config and bootstrap credential presence | +| `hermes secrets infisical sync` | Dry-run: pull secrets now and show what would be applied | +| `hermes secrets infisical sync --apply` | Pull and export into the current process environment | +| `hermes secrets infisical disable` | Flip `enabled: false`; leaves bootstrap credentials in place | + +## Configuration + +Defaults in `~/.hermes/config.yaml`: + +```yaml +secrets: + infisical: + enabled: false + api_url: https://app.infisical.com + project_id: "" + project_id_env: INFISICAL_PROJECT_ID + env: prod + path: / + client_id_env: INFISICAL_CLIENT_ID + client_secret_env: INFISICAL_CLIENT_SECRET + organization_slug: "" + override_existing: true + cache_ttl_seconds: 300 + recursive: false + include_imports: true + expand_secret_references: true +``` + +| Key | Default | What it does | +|---|---|---| +| `enabled` | `false` | Master switch. When false, Infisical is never contacted. | +| `api_url` | `https://app.infisical.com` | Infisical API base URL. Use your self-hosted URL when applicable. | +| `project_id` | `""` | UUID of the project to sync from. If empty, Hermes falls back to `project_id_env`. | +| `project_id_env` | `INFISICAL_PROJECT_ID` | Env var fallback for project ID, useful for existing `infisical run` deployments. | +| `env` | `prod` | Infisical environment slug. | +| `path` | `/` | Secret path to sync. | +| `client_id_env` | `INFISICAL_CLIENT_ID` | Env var holding the Machine Identity client ID. | +| `client_secret_env` | `INFISICAL_CLIENT_SECRET` | Env var holding the Machine Identity client secret. | +| `organization_slug` | `""` | Optional organization slug for Universal Auth setups that require it. | +| `override_existing` | `true` | When true, Infisical values overwrite existing env vars. | +| `cache_ttl_seconds` | `300` | How long an in-process fetch result is reused. Set to `0` to disable caching. | +| `recursive` | `false` | Passed to Infisical's list-secrets API. | +| `include_imports` | `true` | Include imported secrets when the API returns them. | +| `expand_secret_references` | `true` | Ask Infisical to expand secret references in returned values. | + +## Failure modes + +Infisical never blocks Hermes startup. If anything goes wrong, Hermes prints a warning and continues with credentials from `.env` or the shell. + +| Symptom | Cause | Fix | +|---|---|---| +| `INFISICAL_CLIENT_ID is not set` | Enabled in config but bootstrap ID is missing | Re-run setup or add it to `.env` | +| `INFISICAL_CLIENT_SECRET is not set` | Enabled in config but bootstrap secret is missing | Re-run setup or add it to `.env` | +| `project_id is empty` | No project ID in config or `INFISICAL_PROJECT_ID` | Set `secrets.infisical.project_id` or `INFISICAL_PROJECT_ID` | +| `HTTP 401` / `HTTP 403` | Machine Identity credentials revoked or missing access | Regenerate credentials or fix project permissions | +| `not a valid env-var name` | A secret key contains spaces, dashes, or starts with a digit | Rename the secret key to an env-var-safe name | + +## Security notes + +- The Universal Auth client secret is sensitive. Anyone with the client ID and client secret can read every secret allowed by that Machine Identity. +- Hermes refuses to let Infisical overwrite the bootstrap credential env vars themselves, even with `override_existing: true`. +- Secret values are cached only in process memory for `cache_ttl_seconds`; this backend does not write fetched Infisical secrets to disk. +- This integration helps secrets consumed by Hermes itself. Sibling containers or services that need secrets before Hermes starts still need their own Infisical integration, platform env injection, or wrapper. + +## When NOT to use this + +- Single-machine personal setups where `~/.hermes/.env` is enough. +- Air-gapped deployments that cannot reach your Infisical API. +- Services outside the Hermes process that need secrets at container startup. From 88ffae8abae724a2ea202978fb1374c747202eff Mon Sep 17 00:00:00 2001 From: feliche93 Date: Sun, 14 Jun 2026 21:09:35 +0200 Subject: [PATCH 2/2] fix: polish Infisical secret source --- .env.example | 2 +- agent/secret_sources/infisical.py | 10 ++++-- cli-config.yaml.example | 2 +- hermes_cli/config.py | 6 ++-- hermes_cli/env_loader.py | 24 +++++++------ hermes_cli/infisical_secrets_cli.py | 4 +-- tests/test_env_loader_secret_sources.py | 38 ++++++++++++++++++++ tests/test_infisical_secrets.py | 23 ++++++++++++ tests/test_infisical_secrets_cli.py | 35 ++++++++++++++++++ website/docs/user-guide/secrets/infisical.md | 10 +++--- website/sidebars.ts | 1 + 11 files changed, 130 insertions(+), 25 deletions(-) create mode 100644 tests/test_infisical_secrets_cli.py diff --git a/.env.example b/.env.example index 7cc003c6fe0b7..d3ad58b424bfe 100644 --- a/.env.example +++ b/.env.example @@ -25,7 +25,7 @@ # INFISICAL_CLIENT_ID= # INFISICAL_CLIENT_SECRET= # INFISICAL_PROJECT_ID= -# INFISICAL_API_URL=https://app.infisical.com +# INFISICAL_API_URL=https://us.infisical.com # ============================================================================= # LLM PROVIDER (NovitaAI) diff --git a/agent/secret_sources/infisical.py b/agent/secret_sources/infisical.py index aa6485ab7a3fc..0efa488828555 100644 --- a/agent/secret_sources/infisical.py +++ b/agent/secret_sources/infisical.py @@ -35,7 +35,11 @@ from dataclasses import dataclass, field from typing import Any, Dict, List, Optional, Tuple -DEFAULT_API_URL = "https://app.infisical.com" +# Infisical's latest documented endpoint versions are not uniform: +# Universal Auth login is v1, while the secrets list API is v4. +DEFAULT_API_URL = "https://us.infisical.com" +UNIVERSAL_AUTH_LOGIN_PATH = "/api/v1/auth/universal-auth/login" +SECRETS_LIST_PATH = "/api/v4/secrets" _HTTP_TIMEOUT = 30 _CacheKey = Tuple[str, str, str, str, str, str, str, str, str, str] @@ -170,7 +174,7 @@ def login_universal_auth( payload = _http_json( "POST", - f"{api_url}/api/v1/auth/universal-auth/login", + f"{api_url}{UNIVERSAL_AUTH_LOGIN_PATH}", body=body, ) token = payload.get("accessToken") @@ -234,7 +238,7 @@ def fetch_infisical_secrets( ) payload = _http_json( "GET", - f"{api_url}/api/v4/secrets", + f"{api_url}{SECRETS_LIST_PATH}", token=token, params={ "projectId": project_id, diff --git a/cli-config.yaml.example b/cli-config.yaml.example index 4c5f3e69f81b6..e56096db9c285 100644 --- a/cli-config.yaml.example +++ b/cli-config.yaml.example @@ -151,7 +151,7 @@ model: # secrets: # infisical: # enabled: false -# api_url: "https://app.infisical.com" # or your self-hosted URL +# api_url: "https://us.infisical.com" # or https://eu.infisical.com / self-hosted # project_id: "" # falls back to INFISICAL_PROJECT_ID # env: "prod" # path: "/" diff --git a/hermes_cli/config.py b/hermes_cli/config.py index 83d905cda8cf5..b49ed39971b1e 100644 --- a/hermes_cli/config.py +++ b/hermes_cli/config.py @@ -1944,9 +1944,9 @@ def _ensure_hermes_home_managed(home: Path): "infisical": { # Master switch. When false, Infisical is never contacted. "enabled": False, - # Infisical API base URL. Use your self-hosted URL when not - # using Infisical Cloud. - "api_url": "https://app.infisical.com", + # Infisical API base URL. Use https://eu.infisical.com for EU + # Cloud or your self-hosted URL when not using US Cloud. + "api_url": "https://us.infisical.com", # Env var names that hold the Machine Identity Universal Auth # bootstrap credentials. These live in ~/.hermes/.env or the # parent shell; never in config.yaml. diff --git a/hermes_cli/env_loader.py b/hermes_cli/env_loader.py index 0b97e87c08b33..50d7ed65f5381 100644 --- a/hermes_cli/env_loader.py +++ b/hermes_cli/env_loader.py @@ -87,6 +87,8 @@ def format_secret_source_suffix(env_var: str) -> str: def _float_config(value: object, default: float) -> float: + if not isinstance(value, (str, int, float)): + return default try: return float(value) except (TypeError, ValueError): @@ -188,9 +190,8 @@ def _sanitize_env_file_if_needed(path: Path) -> None: except ImportError: return # early bootstrap — config module not available yet - read_kw = {"encoding": "utf-8-sig", "errors": "replace"} try: - with open(path, **read_kw) as f: + with open(path, encoding="utf-8-sig", errors="replace") as f: original = f.readlines() # Strip null bytes before _sanitize_env_lines so they never # reach python-dotenv (which passes them to os.environ and @@ -288,9 +289,8 @@ def _apply_external_secret_sources(home_path: Path) -> None: try: from agent.secret_sources.bitwarden import apply_bitwarden_secrets except ImportError: - apply_bitwarden_secrets = None - - if apply_bitwarden_secrets is not None: + pass + else: result = apply_bitwarden_secrets( enabled=True, access_token_env=bw_cfg.get("access_token_env", "BWS_ACCESS_TOKEN"), @@ -314,11 +314,13 @@ def _apply_external_secret_sources(home_path: Path) -> None: inf_cfg = (cfg or {}).get("infisical") or {} if inf_cfg.get("enabled"): try: - from agent.secret_sources.infisical import apply_infisical_secrets + from agent.secret_sources.infisical import ( + DEFAULT_API_URL as infisical_default_api_url, + apply_infisical_secrets, + ) except ImportError: - apply_infisical_secrets = None - - if apply_infisical_secrets is not None: + pass + else: result = apply_infisical_secrets( enabled=True, client_id_env=inf_cfg.get("client_id_env", "INFISICAL_CLIENT_ID"), @@ -332,7 +334,7 @@ def _apply_external_secret_sources(home_path: Path) -> None: api_url=str( inf_cfg.get("api_url") or os.environ.get("INFISICAL_API_URL") - or "https://app.infisical.com" + or infisical_default_api_url ).strip(), organization_slug=str( inf_cfg.get("organization_slug", "") or "" @@ -392,7 +394,7 @@ def _load_secrets_config(home_path: Path) -> dict: if not config_path.exists(): return {} try: - import yaml # type: ignore + import yaml except ImportError: return {} try: diff --git a/hermes_cli/infisical_secrets_cli.py b/hermes_cli/infisical_secrets_cli.py index 56b301cd46f07..9913000a9aeba 100644 --- a/hermes_cli/infisical_secrets_cli.py +++ b/hermes_cli/infisical_secrets_cli.py @@ -39,7 +39,7 @@ def register_cli(parent_parser: argparse.ArgumentParser) -> None: setup.add_argument( "--api-url", default="", - help="Infisical API URL (default: https://app.infisical.com)", + help=f"Infisical API URL (default: {infisical.DEFAULT_API_URL})", ) setup.add_argument("--env", default="", help="Infisical environment slug") setup.add_argument("--path", default="", help="Secret path to sync") @@ -370,7 +370,7 @@ def _print_sync_actions( console.print("[yellow]No secrets found.[/yellow]") return - override = bool(inf_cfg.get("override_existing", True)) or bool(args.apply) + override = bool(inf_cfg.get("override_existing", True)) table = Table(show_header=True, header_style="bold") table.add_column("Name", style="cyan") table.add_column("Action") diff --git a/tests/test_env_loader_secret_sources.py b/tests/test_env_loader_secret_sources.py index 2a16f3c4add65..99876c3c56de6 100644 --- a/tests/test_env_loader_secret_sources.py +++ b/tests/test_env_loader_secret_sources.py @@ -201,6 +201,44 @@ def _fake_apply(**kwargs): assert captured["cache_ttl_seconds"] == 300.0 +def test_apply_external_secret_sources_passes_self_hosted_infisical_url( + tmp_path, + monkeypatch, +): + """Configured self-hosted Infisical URLs must flow into the backend.""" + + monkeypatch.setenv("HERMES_HOME", str(tmp_path)) + config_path = tmp_path / "config.yaml" + config_path.write_text( + "secrets:\n" + " infisical:\n" + " enabled: true\n" + " api_url: https://infisical.internal:8080\n" + " project_id: test-project\n" + " env: staging\n" + " path: /hermes\n", + encoding="utf-8", + ) + + from agent.secret_sources.infisical import FetchResult + + captured = {} + + def _fake_apply(**kwargs): + captured.update(kwargs) + return FetchResult() + + import agent.secret_sources.infisical as inf_module + + monkeypatch.setattr(inf_module, "apply_infisical_secrets", _fake_apply) + + env_loader._apply_external_secret_sources(tmp_path) + + assert captured["api_url"] == "https://infisical.internal:8080" + assert captured["environment"] == "staging" + assert captured["secret_path"] == "/hermes" + + def test_apply_external_secret_sources_does_not_return_after_bitwarden_disabled( tmp_path, monkeypatch, diff --git a/tests/test_infisical_secrets.py b/tests/test_infisical_secrets.py index 1ef687a838273..9843eafb31f80 100644 --- a/tests/test_infisical_secrets.py +++ b/tests/test_infisical_secrets.py @@ -113,6 +113,28 @@ def fake_http(method, url, **kwargs): } +def test_default_api_url_uses_current_us_cloud_host(monkeypatch): + calls = [] + + def fake_http(method, url, **kwargs): + calls.append((method, url, kwargs)) + if method == "POST": + return {"accessToken": "access-token"} + return {"secrets": []} + + monkeypatch.setattr(inf, "_http_json", fake_http) + + inf.fetch_infisical_secrets( + client_id="cid", + client_secret="csecret", + project_id="proj", + use_cache=False, + ) + + assert calls[0][1] == "https://us.infisical.com/api/v1/auth/universal-auth/login" + assert calls[1][1] == "https://us.infisical.com/api/v4/secrets" + + def test_extract_includes_imports_with_lower_precedence(): payload = { "imports": [ @@ -234,6 +256,7 @@ def test_apply_missing_bootstrap_credentials(monkeypatch): result = inf.apply_infisical_secrets(enabled=True, project_id="proj") assert not result.ok + assert result.error is not None assert "INFISICAL_CLIENT_ID" in result.error diff --git a/tests/test_infisical_secrets_cli.py b/tests/test_infisical_secrets_cli.py new file mode 100644 index 0000000000000..cec61265728f1 --- /dev/null +++ b/tests/test_infisical_secrets_cli.py @@ -0,0 +1,35 @@ +"""Tests for the Infisical secrets CLI helpers.""" + +from __future__ import annotations + +import os +import sys +from argparse import Namespace +from io import StringIO +from pathlib import Path + +from rich.console import Console + +ROOT = Path(__file__).resolve().parents[1] +if str(ROOT) not in sys.path: + sys.path.insert(0, str(ROOT)) + +from hermes_cli import infisical_secrets_cli as cli # noqa: E402 + + +def test_sync_apply_respects_override_existing_false(monkeypatch): + monkeypatch.setenv("OPENAI_API_KEY", "existing") + monkeypatch.delenv("NEW_KEY", raising=False) + console = Console(file=StringIO(), force_terminal=False, color_system=None) + + cli._print_sync_actions( + console, + Namespace(apply=True), + {"override_existing": False}, + {"OPENAI_API_KEY": "fresh", "NEW_KEY": "new"}, + [], + set(), + ) + + assert os.environ["OPENAI_API_KEY"] == "existing" + assert os.environ["NEW_KEY"] == "new" diff --git a/website/docs/user-guide/secrets/infisical.md b/website/docs/user-guide/secrets/infisical.md index de2629e96ed8c..8fbb9382fa5f8 100644 --- a/website/docs/user-guide/secrets/infisical.md +++ b/website/docs/user-guide/secrets/infisical.md @@ -9,6 +9,8 @@ Pull API keys from [Infisical](https://infisical.com) at process startup instead 3. Every time `hermes` starts, after `.env` has loaded, Hermes logs in through Universal Auth and calls the Infisical v4 secrets API. 4. Returned `secretKey` / `secretValue` pairs are written into `os.environ` before provider and gateway config is built. +Infisical's current API versions differ by surface: Universal Auth login is documented at `/api/v1/auth/universal-auth/login`, while secret listing is documented at `/api/v4/secrets`. + By default Hermes overwrites existing env vars with Infisical values so rotating a secret in Infisical takes effect on the next Hermes start. Set `override_existing: false` if local `.env` or shell exports should win. ## Setup @@ -38,12 +40,12 @@ hermes secrets infisical setup \ --client-id "$INFISICAL_CLIENT_ID" \ --client-secret "$INFISICAL_CLIENT_SECRET" \ --project-id \ - --api-url https://app.infisical.com \ + --api-url https://us.infisical.com \ --env prod \ --path / ``` -For self-hosted Infisical, set `--api-url` to your instance URL. +For Infisical EU Cloud, use `https://eu.infisical.com`. For self-hosted Infisical, set `--api-url` to your instance URL. ### 3. Confirm @@ -71,7 +73,7 @@ Defaults in `~/.hermes/config.yaml`: secrets: infisical: enabled: false - api_url: https://app.infisical.com + api_url: https://us.infisical.com project_id: "" project_id_env: INFISICAL_PROJECT_ID env: prod @@ -89,7 +91,7 @@ secrets: | Key | Default | What it does | |---|---|---| | `enabled` | `false` | Master switch. When false, Infisical is never contacted. | -| `api_url` | `https://app.infisical.com` | Infisical API base URL. Use your self-hosted URL when applicable. | +| `api_url` | `https://us.infisical.com` | Infisical API base URL. Use `https://eu.infisical.com` for EU Cloud, or your self-hosted URL when applicable. | | `project_id` | `""` | UUID of the project to sync from. If empty, Hermes falls back to `project_id_env`. | | `project_id_env` | `INFISICAL_PROJECT_ID` | Env var fallback for project ID, useful for existing `infisical run` deployments. | | `env` | `prod` | Infisical environment slug. | diff --git a/website/sidebars.ts b/website/sidebars.ts index a994e4e7fee8b..c45f04337f679 100644 --- a/website/sidebars.ts +++ b/website/sidebars.ts @@ -34,6 +34,7 @@ const sidebars: SidebarsConfig = { items: [ 'user-guide/secrets/index', 'user-guide/secrets/bitwarden', + 'user-guide/secrets/infisical', ], }, 'user-guide/sessions',