Files
wehub-resource-sync 4b6817381b
CI (OpenClaw E2E) / openclaw test (push) Has been cancelled
CI / coverage-report (push) Has been cancelled
CI / test-kubernetes (push) Has been cancelled
CI / should-run-thorough (push) Has been cancelled
CI / test-thorough (cloudwatch-demo) (push) Has been cancelled
CI / test-thorough (flink-ecs) (push) Has been cancelled
CI / test-thorough (upstream-lambda) (push) Has been cancelled
CI / test-thorough (prefect-ecs-fargate) (push) Has been cancelled
Release / build-binaries (zip, opensre.exe, onefile, windows-latest, windows-x64) (push) Has been cancelled
Benchmark image — build + push to ECR (any adapter) / build + push (push) Has been cancelled
CI / quality (ubuntu-latest) (push) Has been cancelled
CI / test (tools-runtime) (push) Has been cancelled
CI / test (e2e-general) (push) Has been cancelled
CI / test (cli-runtime) (push) Has been cancelled
CI / test (e2e-provider-and-openclaw) (push) Has been cancelled
CI / test (integrations-and-misc) (push) Has been cancelled
Release / verify (push) Has been cancelled
Release / build-python-dist (push) Has been cancelled
Release / build-binaries (tar.gz, opensre, onedir, macos-15-intel, darwin-x64) (push) Has been cancelled
Release / build-binaries (tar.gz, opensre, onedir, macos-latest, darwin-arm64) (push) Has been cancelled
Release / build-binaries (tar.gz, opensre, onedir, ubuntu-22.04, linux-x64) (push) Has been cancelled
Release / publish-release (push) Has been cancelled
Release / publish-main-release (push) Has been cancelled
Interactive Shell Live (PR + post-merge) / turn-checks (no-LLM) (push) Has been cancelled
CodeQL / Analyze (python) (push) Has been cancelled
Interactive Shell Live (PR + post-merge) / turn-live shard ${{ matrix.shard_index }} (push) Has been cancelled
Release / prepare (push) Has been cancelled
Release / build-binaries (tar.gz, opensre, onedir, ubuntu-22.04-arm, linux-arm64) (push) Has been cancelled
Synthetic Deterministic Tests / Synthetic offline (deterministic) (push) Has been cancelled
chore: import upstream snapshot with attribution
2026-07-13 13:10:45 +08:00

387 lines
13 KiB
Python

"""Helpers to sync wizard choices into the project .env file."""
from __future__ import annotations
import os
import re
from contextlib import suppress
from dataclasses import dataclass
from pathlib import Path
from config.llm_auth.auth_method import LLM_AUTH_METHOD_ENV
from config.llm_auth.credentials import delete as delete_provider_auth
from config.llm_auth.credentials import save_api_key
from config.llm_auth.provider_catalog import API_KEY_PROVIDER_ENVS
from config.llm_credentials import delete_llm_api_key, save_llm_api_key
from surfaces.cli.wizard.config import PROJECT_ENV_PATH, ProviderOption
_ENV_ASSIGNMENT = re.compile(r"^\s*([A-Za-z_][A-Za-z0-9_]*)\s*=")
_NON_SECRET_ENV_KEYS: frozenset[str] = frozenset({"DISCORD_PUBLIC_KEY"})
# Underscore-separated terminal tokens that mark an env var as sensitive.
# Matching the terminal component (rather than a substring or a fixed suffix
# like ``_token``) catches both ``GITLAB_ACCESS_TOKEN`` and a bare ``TOKEN``
# while leaving ``OPENAI_TOKEN_LIMIT`` (terminal ``limit``) alone.
_SENSITIVE_TERMINAL_TOKENS: frozenset[str] = frozenset(
{
"token",
"secret",
"password",
"passwd",
"key",
"apikey",
"credential",
"credentials",
}
)
_SENSITIVE_SUBSTRINGS: tuple[str, ...] = ("connection_string",)
@dataclass(frozen=True)
class _PublicEnvLines:
"""Validated `.env` content that contains no sensitive assignments."""
lines: tuple[str, ...]
@classmethod
def from_lines(cls, lines: list[str]) -> _PublicEnvLines:
public_lines = _strip_sensitive_env_lines(lines)
_ensure_no_sensitive_env_lines(public_lines)
return cls(tuple(public_lines))
def write_to(self, target_path: Path) -> None:
with target_path.open("w", encoding="utf-8", newline="") as env_file:
env_file.writelines(self.lines)
def _is_sensitive_env_key(key: str) -> bool:
"""True when an env var should be stored in the keyring, not plain .env."""
if key in _NON_SECRET_ENV_KEYS:
return False
lowered = key.lower()
terminal = lowered.rsplit("_", 1)[-1]
if terminal in _SENSITIVE_TERMINAL_TOKENS:
return True
return any(needle in lowered for needle in _SENSITIVE_SUBSTRINGS)
def _strip_sensitive_env_lines(lines: list[str]) -> list[str]:
"""Remove secret assignments so .env only carries non-sensitive config."""
stripped: list[str] = []
for line in lines:
match = _ENV_ASSIGNMENT.match(line)
if match and _is_sensitive_env_key(match.group(1)):
continue
stripped.append(line)
return stripped
def _strip_keyring_backed_secret_lines(lines: list[str]) -> list[str]:
"""Drop sensitive assignments so `.env` writes never persist secrets."""
kept: list[str] = []
for line in lines:
match = _ENV_ASSIGNMENT.match(line)
if match and _is_sensitive_env_key(match.group(1)):
continue
kept.append(line)
return kept
def _persist_env_secret(key: str, value: str) -> bool:
"""Store a secret in the keyring. Returns False when keyring is unavailable."""
normalized = value.strip()
provider = next(
(name for name, env_var in API_KEY_PROVIDER_ENVS.items() if env_var == key),
"",
)
if not normalized:
if provider:
delete_provider_auth(provider)
else:
delete_llm_api_key(key)
return True
try:
if provider:
save_api_key(provider, normalized)
else:
save_llm_api_key(key, normalized)
except RuntimeError:
return False
return True
def _set_env_value(lines: list[str], key: str, value: str) -> list[str]:
if _is_sensitive_env_key(key):
raise RuntimeError(
f"Refusing to write sensitive env key {key!r} to .env; use sync_env_secret()."
)
updated: list[str] = []
replaced = False
for line in lines:
match = _ENV_ASSIGNMENT.match(line)
if not match or match.group(1) != key:
updated.append(line)
continue
if not replaced:
updated.append(f"{key}={value}\n")
replaced = True
if not replaced:
if updated and not updated[-1].endswith("\n"):
updated[-1] = updated[-1] + "\n"
updated.append(f"{key}={value}\n")
return updated
def _ensure_no_sensitive_env_lines(lines: list[str]) -> None:
"""Fail closed when a sensitive assignment would be written to disk."""
for line in lines:
match = _ENV_ASSIGNMENT.match(line)
if match and _is_sensitive_env_key(match.group(1)):
raise RuntimeError(
f"Refusing to write sensitive env key {match.group(1)!r} to .env; use the system keyring."
)
def _write_env(target_path: Path, lines: list[str]) -> None:
"""Write non-sensitive .env lines with owner-only permissions when possible."""
public_lines = _PublicEnvLines.from_lines(lines)
try:
target_path.parent.mkdir(parents=True, exist_ok=True)
public_lines.write_to(target_path)
except PermissionError as exc:
raise PermissionError(
f"Cannot write to {target_path}: permission denied. "
"Ensure you have write access to this file, or run the command as the file owner."
) from exc
if os.name != "nt":
with suppress(OSError):
target_path.chmod(0o600)
def _write_env_lines(target_path: Path, lines: list[str]) -> None:
"""Write merged env lines after rejecting sensitive assignments."""
_write_env(target_path, lines)
def sync_env_secret(key: str, value: str) -> None:
"""Persist a sensitive env value in the system keyring, not in ``.env``."""
if not _is_sensitive_env_key(key):
raise ValueError(f"{key!r} is not classified as sensitive; use sync_env_values instead.")
_persist_env_secret(key, value)
def sync_env_values(
values: dict[str, str],
*,
env_path: Path | None = None,
) -> Path:
"""Write multiple non-sensitive environment values into the target .env file.
Sensitive keys must be persisted with :func:`sync_env_secret` instead.
Existing sensitive assignments are removed from ``.env`` whenever this file
is rewritten so secrets do not remain in clear text.
"""
sensitive_keys = [key for key in values if _is_sensitive_env_key(key)]
if sensitive_keys:
joined = ", ".join(repr(key) for key in sensitive_keys)
raise ValueError(f"Refusing to sync sensitive env keys {joined}; use sync_env_secret().")
target_path = env_path or PROJECT_ENV_PATH
existing = (
target_path.read_text(encoding="utf-8").splitlines(keepends=True)
if target_path.exists()
else []
)
lines = _strip_keyring_backed_secret_lines(list(existing))
for key, value in values.items():
lines = _set_env_value(lines, key, value)
_write_env_lines(target_path, lines)
return target_path
def sync_reasoning_model_env(
*,
provider: ProviderOption,
model: str,
env_path: Path | None = None,
) -> Path:
"""Write reasoning model env vars to ``.env``, update runtime env, and sync wizard store."""
values: dict[str, str] = {provider.model_env: model}
if provider.legacy_model_env:
values[provider.legacy_model_env] = model
target_path = sync_env_values(values, env_path=env_path)
os.environ.update(values)
_sync_llm_selection_to_store(provider=provider, model=model)
return target_path
def _sync_llm_selection_to_store(
*,
provider: ProviderOption,
model: str,
model_provider: ProviderOption | None = None,
auth_method: str | None = None,
) -> None:
from surfaces.cli.wizard.store import update_local_llm_selection
resolved_model_provider = model_provider or provider
update_local_llm_selection(
provider=provider.value,
model=model,
api_key_env=provider.api_key_env or "",
model_env=resolved_model_provider.model_env,
auth_method=auth_method,
)
def _classification_model_env(p: ProviderOption) -> str | None:
if p.classification_model_env:
return p.classification_model_env
if p.model_env.endswith("_REASONING_MODEL"):
return p.model_env.replace("_REASONING_MODEL", "_CLASSIFICATION_MODEL")
return None
def _provider_specific_keys(p: ProviderOption) -> set[str]:
"""Return all env keys owned by a provider (api key + model keys)."""
keys: set[str] = {p.model_env}
if p.api_key_env:
keys.add(p.api_key_env)
if p.legacy_model_env:
keys.add(p.legacy_model_env)
if p.toolcall_model_env:
keys.add(p.toolcall_model_env)
if p.endpoint_env:
keys.add(p.endpoint_env)
if p.api_version_env:
keys.add(p.api_version_env)
classification_env = _classification_model_env(p)
if classification_env:
keys.add(classification_env)
return keys
def _env_value_from_lines(lines: list[str], key: str) -> str | None:
for line in lines:
match = _ENV_ASSIGNMENT.match(line)
if match and match.group(1) == key:
_, _, rhs = line.partition("=")
return rhs.strip().strip("\"'") or None
return None
def _remove_keys(lines: list[str], keys_to_remove: set[str]) -> list[str]:
"""Drop lines whose env key is in *keys_to_remove*."""
result: list[str] = []
for line in lines:
match = _ENV_ASSIGNMENT.match(line)
if match and match.group(1) in keys_to_remove:
continue
result.append(line)
return result
def sync_provider_env(
*,
provider: ProviderOption,
model: str,
toolcall_model: str | None = None,
model_provider: ProviderOption | None = None,
auth_method: str | None = None,
extra_env: dict[str, str] | None = None,
env_path: Path | None = None,
) -> Path:
"""Write non-secret provider settings into the project .env.
Removes stale keys from other providers and every API-key line. Secrets are
stored in the system keyring, not in ``.env``.
"""
from surfaces.cli.wizard.config import SUPPORTED_PROVIDERS
resolved_model_provider = model_provider or provider
target_path = env_path or PROJECT_ENV_PATH
existing = (
target_path.read_text(encoding="utf-8").splitlines(keepends=True)
if target_path.exists()
else []
)
# Strip every provider's API key and every provider's model keys except the
# active provider's model slots (secrets are stored in the system keyring).
keys_to_remove: set[str] = set()
for p in SUPPORTED_PROVIDERS:
keys_to_remove |= _provider_specific_keys(p)
keys_to_remove.add(LLM_AUTH_METHOD_ENV)
from core.llm.transport_mode import LLM_TRANSPORT_ENV
keys_to_remove.add(LLM_TRANSPORT_ENV)
# Keep the active provider's model keys but always remove API key entries
# (API keys are persisted via the system keyring, not .env).
active_non_secret: set[str] = {resolved_model_provider.model_env}
if resolved_model_provider.legacy_model_env:
active_non_secret.add(resolved_model_provider.legacy_model_env)
if resolved_model_provider.toolcall_model_env:
active_non_secret.add(resolved_model_provider.toolcall_model_env)
classification_env = _classification_model_env(resolved_model_provider)
if classification_env:
active_non_secret.add(classification_env)
if provider.value == "azure-openai":
if provider.endpoint_env:
active_non_secret.add(provider.endpoint_env)
if provider.api_version_env:
active_non_secret.add(provider.api_version_env)
keys_to_remove -= active_non_secret
lines = _remove_keys(existing, keys_to_remove)
values: dict[str, str] = {
"LLM_PROVIDER": provider.value,
resolved_model_provider.model_env: model,
}
if auth_method:
values[LLM_AUTH_METHOD_ENV] = auth_method
if resolved_model_provider.legacy_model_env:
values[resolved_model_provider.legacy_model_env] = model
if toolcall_model and resolved_model_provider.toolcall_model_env:
values[resolved_model_provider.toolcall_model_env] = toolcall_model
if provider.value == "azure-openai":
values[LLM_TRANSPORT_ENV] = "litellm"
if provider.api_version_env:
from core.llm.providers.azure_openai import resolve_azure_openai_api_version
values[provider.api_version_env] = resolve_azure_openai_api_version()
if provider.endpoint_env:
preserved_base = (
_env_value_from_lines(lines, provider.endpoint_env)
or os.getenv(provider.endpoint_env, "").strip()
)
if preserved_base:
values[provider.endpoint_env] = preserved_base
if extra_env:
values.update(extra_env)
for key, value in values.items():
lines = _set_env_value(lines, key, value)
_write_env_lines(target_path, lines)
for key in keys_to_remove:
os.environ.pop(key, None)
for key in active_non_secret:
preserved = _env_value_from_lines(lines, key)
if preserved is not None:
values[key] = preserved
os.environ.update(values)
_sync_llm_selection_to_store(
provider=provider,
model=model,
model_provider=resolved_model_provider,
auth_method=auth_method,
)
return target_path