diff --git a/pyproject.toml b/pyproject.toml index fcf630a..598c3a3 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -11,6 +11,7 @@ requires-python = ">=3.13" dependencies = [ "httpx>=0.28.1", "pydantic>=2.13.4", + "pydantic-settings>=2.14.2", ] [project.scripts] diff --git a/tartarus/config.py b/tartarus/config.py index c0da8ce..115d7b4 100644 --- a/tartarus/config.py +++ b/tartarus/config.py @@ -7,10 +7,18 @@ Zen so a single env var (OPENCODE_API_KEY) yields a working setup. from __future__ import annotations -import json import os -from pydantic import BaseModel, ConfigDict, Field +from pydantic import ( + AliasChoices, + BaseModel, + ConfigDict, + Field, + ValidationError, + model_validator, +) +from pydantic_settings import BaseSettings, SettingsConfigDict +from typing_extensions import Self from tartarus.manifest import Manifest, Sampling @@ -21,8 +29,9 @@ DEFAULT_MODEL = "glm-5.2" # tunes its own feel via the `model.sampling` block (see agent.nix). DEFAULT_MAX_TOKENS = 16384 DEFAULT_STATE_DIR = ".tartarus" -DEFAULT_AUDIT_PATH = f"{DEFAULT_STATE_DIR}/audit.jsonl" -DEFAULT_SESSIONS_DIR = f"{DEFAULT_STATE_DIR}/sessions" +# Leaf names under /.tartarus, shared by every path-deriving call site. +AUDIT_LOG_LEAF = "audit.jsonl" +SESSIONS_LEAF = "sessions" DEFAULT_OUTPUT_TRUNCATE_CHARS = 10_000 # `path:` copies the directory regardless of git tracking, which keeps local # capability edits visible before they are committed. @@ -43,14 +52,22 @@ class ConfigError(Exception): """Raised when required configuration is missing or invalid.""" -class Config(BaseModel): - """Mutable harness config built from environment variables.""" +class Config(BaseSettings): + """Harness config, loaded from TARTARUS_* environment variables (PLAN.md §9). - model_config = ConfigDict(extra="forbid", strict=True) + Each field reads from TARTARUS_; a few keep legacy env names via an + explicit alias. Runtime fields (provider/base_url/model/max_tokens) stay None + when unset so the agent's `model` block can supply them (resolve_runtime); an + explicit env value still wins. + """ - # The runtime fields are None when their env var is unset, so the agent's - # `model` block can supply the value (resolve_runtime). An explicit env var - # still wins. api_key is the exception: env-only, since it is a secret. + model_config = SettingsConfigDict( + env_prefix="TARTARUS_", extra="ignore", populate_by_name=True + ) + + # The one secret: env-only, accepted under either the TARTARUS_ name or the + # provider's own OPENCODE_API_KEY. Empty is allowed here so non-auth paths can + # build a Config; load_config enforces a non-empty key (fail closed). api_key: str = "" provider: str | None = None base_url: str | None = None @@ -65,98 +82,80 @@ class Config(BaseModel): flake_ref: str = DEFAULT_FLAKE_REF # A realized agent bundle store path (e.g. received via `nix copy`). When set, # the harness loads it directly and never touches the flake (PLAN.md §14). - bundle_path: str = "" + bundle_path: str = Field("", validation_alias=AliasChoices("TARTARUS_BUNDLE")) # Agent name under #agents. to load. - agent_name: str = DEFAULT_AGENT_NAME + agent_name: str = Field( + DEFAULT_AGENT_NAME, validation_alias=AliasChoices("TARTARUS_AGENT") + ) # When true there is no human to approve ask-* policies, so they fail closed. headless: bool = False - # Append-only JSONL audit log for brokered tool calls. + # Append-only JSONL audit log for brokered tool calls. Empty -> derived below. audit_path: str = "" # Directory holding per-conversation transcript files (.jsonl). - session_dir: str = "" + session_dir: str = Field("", validation_alias=AliasChoices("TARTARUS_SESSIONS_DIR")) output_truncate: int = DEFAULT_OUTPUT_TRUNCATE_CHARS + @model_validator(mode="after") + def _derive_state_paths(self) -> Self: + # The audit log and sessions dir default under /.tartarus unless + # the environment supplied an explicit path. + if not self.audit_path: + self.audit_path = _default_state_path(self.work_tree, AUDIT_LOG_LEAF) + if not self.session_dir: + self.session_dir = _default_state_path(self.work_tree, SESSIONS_LEAF) + return self + def session_dir_from_env() -> str: """Resolve the sessions directory from the environment, no API key required. - Shared by load_config and the CLI's read-only `--list-sessions` path. + Shared by load_config (via Config) and the CLI's read-only `--list-sessions` + path, which runs before any API key is required, so it cannot build a Config. """ - work_tree = _read_env("WORK_TREE", os.getcwd()) or os.getcwd() - return _read_env("SESSIONS_DIR", _default_state_path(work_tree, "sessions")) or "" - - -def _read_env(name: str, default: str | None = None) -> str | None: - return os.environ.get(f"TARTARUS_{name}", default) - - -def _read_bool_env(name: str) -> bool: - return (_read_env(name, "") or "").lower() in {"1", "true", "yes"} + work_tree = os.environ.get("TARTARUS_WORK_TREE") or os.getcwd() + return os.environ.get("TARTARUS_SESSIONS_DIR") or _default_state_path( + work_tree, SESSIONS_LEAF + ) def _default_state_path(work_tree: str, leaf: str) -> str: return os.path.join(work_tree, DEFAULT_STATE_DIR, leaf) -def _read_api_key() -> str: - for variable in API_KEY_ENV_VARS: - value = os.environ.get(variable) - if value: - return value - return "" - - -def _read_extra_headers() -> dict[str, str] | None: - raw_headers = _read_env("EXTRA_HEADERS") - if not raw_headers: - return None - try: - parsed = json.loads(raw_headers) - except json.JSONDecodeError as error: - raise ConfigError(f"TARTARUS_EXTRA_HEADERS must be JSON: {error}") from error - if not isinstance(parsed, dict): - raise ConfigError("TARTARUS_EXTRA_HEADERS must be a JSON object") - return {str(key): str(value) for key, value in parsed.items()} - - def load_config() -> Config: - """Build a Config from the environment, applying defaults. + """Build a Config from the environment. - Fails closed: a missing API key raises ConfigError rather than attempting an - unauthenticated request. + Fails closed: a missing API key or malformed value raises ConfigError rather + than attempting an unauthenticated or misconfigured request. """ - api_key = _read_api_key() - if not api_key: - names = " or ".join(API_KEY_ENV_VARS) - raise ConfigError(f"no API key found; set {names}") - - work_tree = _read_env("WORK_TREE", os.getcwd()) or os.getcwd() - default_audit_path = _default_state_path(work_tree, "audit.jsonl") - audit_path = _read_env("AUDIT_PATH", default_audit_path) or default_audit_path - session_dir = session_dir_from_env() - - raw_max_tokens = _read_env("MAX_TOKENS") - return Config( - # Left None when unset so the agent's profile can supply them; an explicit - # value here still overrides the profile (resolve_runtime). - provider=_read_env("PROVIDER"), - base_url=_read_env("BASE_URL"), - api_key=api_key, - model=_read_env("MODEL"), - max_tokens=int(raw_max_tokens) if raw_max_tokens else None, - extra_headers=_read_extra_headers(), - work_tree=work_tree, - flake_ref=_read_env("FLAKE_REF", DEFAULT_FLAKE_REF) or DEFAULT_FLAKE_REF, - bundle_path=_read_env("BUNDLE", "") or "", - agent_name=_read_env("AGENT", DEFAULT_AGENT_NAME) or DEFAULT_AGENT_NAME, - headless=_read_bool_env("HEADLESS"), - audit_path=audit_path, - session_dir=session_dir, - output_truncate=int( - _read_env("OUTPUT_TRUNCATE", str(DEFAULT_OUTPUT_TRUNCATE_CHARS)) - or DEFAULT_OUTPUT_TRUNCATE_CHARS - ), + try: + config = Config() + except ValidationError as error: + raise _config_error(error) from error + if not config.api_key: + # TARTARUS_API_KEY is read via env_prefix; fall back to the alternates here. + for variable in API_KEY_ENV_VARS: + value = os.environ.get(variable, "") + if value: + config.api_key = value + break + if not config.api_key: + raise ConfigError(f"no API key found; set {' or '.join(API_KEY_ENV_VARS)}") + return config + + +def _config_error(error: ValidationError) -> ConfigError: + """Translate a settings ValidationError into a user-facing ConfigError.""" + failed = {str(location) for entry in error.errors() for location in entry["loc"]} + if "extra_headers" in failed: + return ConfigError("TARTARUS_EXTRA_HEADERS must be a JSON object") + # loc + msg only: ValidationError's str/input fields echo the offending value, + # which would leak secrets like api_key into logs. + details = "; ".join( + f"{' -> '.join(str(loc) for loc in entry['loc'])}: {entry['msg']}" + for entry in error.errors() ) + return ConfigError(f"invalid configuration: {details}") class ResolvedRuntime(BaseModel): diff --git a/tests/test_config.py b/tests/test_config.py index 559214d..52bb795 100644 --- a/tests/test_config.py +++ b/tests/test_config.py @@ -1,6 +1,9 @@ +import os + import pytest from tartarus.config import ( + API_KEY_ENV_VARS, DEFAULT_BASE_URL, DEFAULT_MAX_TOKENS, DEFAULT_MODEL, @@ -12,6 +15,15 @@ from tartarus.config import ( from tartarus.manifest import Manifest, ModelConfig +@pytest.fixture(autouse=True) +def _clear_harness_env(monkeypatch): + # Config now reads ambient env (BaseSettings), so clear it for hermetic tests; + # each test sets only the vars it exercises. + for key in list(os.environ): + if key.startswith("TARTARUS_") or key in API_KEY_ENV_VARS: + monkeypatch.delenv(key, raising=False) + + def _manifest(**kwargs) -> Manifest: return Manifest(tools=[], capabilities={}, **kwargs) diff --git a/uv.lock b/uv.lock index 5966b88..837527f 100644 --- a/uv.lock +++ b/uv.lock @@ -185,6 +185,20 @@ wheels = [ { url = "https://files.pythonhosted.org/packages/f6/d2/42dd53d0a85c27606f316d3aa5d2869c4e8470a5ed6dec30e4a1abe19192/pydantic_core-2.46.4-cp314-cp314t-win_arm64.whl", hash = "sha256:4fcbe087dbc2068af7eda3aa87634eba216dbda64d1ae73c8684b621d33f6596", size = 2017325, upload-time = "2026-05-06T13:40:52.723Z" }, ] +[[package]] +name = "pydantic-settings" +version = "2.14.2" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "pydantic" }, + { name = "python-dotenv" }, + { name = "typing-inspection" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/5c/b5/8f48e906c3e0205276e8bd8cb7512217a87b2685304d64be27cad5b3019f/pydantic_settings-2.14.2.tar.gz", hash = "sha256:c19dd64b19097f1de80184f0cc7b0272a13ae6e170cbf240a3e27e381ed14a5f", size = 237700, upload-time = "2026-06-19T13:44:56.324Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/77/c1/6e422f34e569cf8e18df68d1939c81c099d2b61e4f7d9621c8a77560799c/pydantic_settings-2.14.2-py3-none-any.whl", hash = "sha256:a20c97b37910b6550d5ea50fbcc2d4187defe58cd57070b73863d069419c9440", size = 61715, upload-time = "2026-06-19T13:44:55.02Z" }, +] + [[package]] name = "pygments" version = "2.20.0" @@ -210,6 +224,15 @@ wheels = [ { url = "https://files.pythonhosted.org/packages/24/25/1de2678b631f5a49215c6c96fff41ba892b0a34df68d6d80292b1b48aa7f/pytest-9.1.1-py3-none-any.whl", hash = "sha256:37a86b45efb9a47a61a36449063e8e18d0cab3161329fc099eb21783169c4f0c", size = 386536, upload-time = "2026-06-19T10:58:31.347Z" }, ] +[[package]] +name = "python-dotenv" +version = "1.2.2" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/82/ed/0301aeeac3e5353ef3d94b6ec08bbcabd04a72018415dcb29e588514bba8/python_dotenv-1.2.2.tar.gz", hash = "sha256:2c371a91fbd7ba082c2c1dc1f8bf89ca22564a087c2c287cd9b662adde799cf3", size = 50135, upload-time = "2026-03-01T16:00:26.196Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/0b/d7/1959b9648791274998a9c3526f6d0ec8fd2233e4d4acce81bbae76b44b2a/python_dotenv-1.2.2-py3-none-any.whl", hash = "sha256:1d8214789a24de455a8b8bd8ae6fe3c6b69a5e3d64aa8a8e5d68e694bbcb285a", size = 22101, upload-time = "2026-03-01T16:00:25.09Z" }, +] + [[package]] name = "ruff" version = "0.15.20" @@ -242,6 +265,7 @@ source = { editable = "." } dependencies = [ { name = "httpx" }, { name = "pydantic" }, + { name = "pydantic-settings" }, ] [package.dev-dependencies] @@ -255,6 +279,7 @@ dev = [ requires-dist = [ { name = "httpx", specifier = ">=0.28.1" }, { name = "pydantic", specifier = ">=2.13.4" }, + { name = "pydantic-settings", specifier = ">=2.14.2" }, ] [package.metadata.requires-dev]