···7788from __future__ import annotations
991010-import json
1110import os
12111313-from pydantic import BaseModel, ConfigDict, Field
1212+from pydantic import (
1313+ AliasChoices,
1414+ BaseModel,
1515+ ConfigDict,
1616+ Field,
1717+ ValidationError,
1818+ model_validator,
1919+)
2020+from pydantic_settings import BaseSettings, SettingsConfigDict
2121+from typing_extensions import Self
14221523from tartarus.manifest import Manifest, Sampling
1624···2129# tunes its own feel via the `model.sampling` block (see agent.nix).
2230DEFAULT_MAX_TOKENS = 16384
2331DEFAULT_STATE_DIR = ".tartarus"
2424-DEFAULT_AUDIT_PATH = f"{DEFAULT_STATE_DIR}/audit.jsonl"
2525-DEFAULT_SESSIONS_DIR = f"{DEFAULT_STATE_DIR}/sessions"
3232+# Leaf names under <work_tree>/.tartarus, shared by every path-deriving call site.
3333+AUDIT_LOG_LEAF = "audit.jsonl"
3434+SESSIONS_LEAF = "sessions"
2635DEFAULT_OUTPUT_TRUNCATE_CHARS = 10_000
2736# `path:` copies the directory regardless of git tracking, which keeps local
2837# capability edits visible before they are committed.
···4352 """Raised when required configuration is missing or invalid."""
445345544646-class Config(BaseModel):
4747- """Mutable harness config built from environment variables."""
5555+class Config(BaseSettings):
5656+ """Harness config, loaded from TARTARUS_* environment variables (PLAN.md §9).
5757+5858+ Each field reads from TARTARUS_<FIELD>; a few keep legacy env names via an
5959+ explicit alias. Runtime fields (provider/base_url/model/max_tokens) stay None
6060+ when unset so the agent's `model` block can supply them (resolve_runtime); an
6161+ explicit env value still wins.
6262+ """
48634949- model_config = ConfigDict(extra="forbid", strict=True)
6464+ model_config = SettingsConfigDict(
6565+ env_prefix="TARTARUS_", extra="ignore", populate_by_name=True
6666+ )
50675151- # The runtime fields are None when their env var is unset, so the agent's
5252- # `model` block can supply the value (resolve_runtime). An explicit env var
5353- # still wins. api_key is the exception: env-only, since it is a secret.
6868+ # The one secret: env-only, accepted under either the TARTARUS_ name or the
6969+ # provider's own OPENCODE_API_KEY. Empty is allowed here so non-auth paths can
7070+ # build a Config; load_config enforces a non-empty key (fail closed).
5471 api_key: str = ""
5572 provider: str | None = None
5673 base_url: str | None = None
···6582 flake_ref: str = DEFAULT_FLAKE_REF
6683 # A realized agent bundle store path (e.g. received via `nix copy`). When set,
6784 # the harness loads it directly and never touches the flake (PLAN.md §14).
6868- bundle_path: str = ""
8585+ bundle_path: str = Field("", validation_alias=AliasChoices("TARTARUS_BUNDLE"))
6986 # Agent name under #agents.<system> to load.
7070- agent_name: str = DEFAULT_AGENT_NAME
8787+ agent_name: str = Field(
8888+ DEFAULT_AGENT_NAME, validation_alias=AliasChoices("TARTARUS_AGENT")
8989+ )
7190 # When true there is no human to approve ask-* policies, so they fail closed.
7291 headless: bool = False
7373- # Append-only JSONL audit log for brokered tool calls.
9292+ # Append-only JSONL audit log for brokered tool calls. Empty -> derived below.
7493 audit_path: str = ""
7594 # Directory holding per-conversation transcript files (<id>.jsonl).
7676- session_dir: str = ""
9595+ session_dir: str = Field("", validation_alias=AliasChoices("TARTARUS_SESSIONS_DIR"))
7796 output_truncate: int = DEFAULT_OUTPUT_TRUNCATE_CHARS
78979898+ @model_validator(mode="after")
9999+ def _derive_state_paths(self) -> Self:
100100+ # The audit log and sessions dir default under <work_tree>/.tartarus unless
101101+ # the environment supplied an explicit path.
102102+ if not self.audit_path:
103103+ self.audit_path = _default_state_path(self.work_tree, AUDIT_LOG_LEAF)
104104+ if not self.session_dir:
105105+ self.session_dir = _default_state_path(self.work_tree, SESSIONS_LEAF)
106106+ return self
107107+7910880109def session_dir_from_env() -> str:
81110 """Resolve the sessions directory from the environment, no API key required.
821118383- Shared by load_config and the CLI's read-only `--list-sessions` path.
112112+ Shared by load_config (via Config) and the CLI's read-only `--list-sessions`
113113+ path, which runs before any API key is required, so it cannot build a Config.
84114 """
8585- work_tree = _read_env("WORK_TREE", os.getcwd()) or os.getcwd()
8686- return _read_env("SESSIONS_DIR", _default_state_path(work_tree, "sessions")) or ""
8787-8888-8989-def _read_env(name: str, default: str | None = None) -> str | None:
9090- return os.environ.get(f"TARTARUS_{name}", default)
9191-9292-9393-def _read_bool_env(name: str) -> bool:
9494- return (_read_env(name, "") or "").lower() in {"1", "true", "yes"}
115115+ work_tree = os.environ.get("TARTARUS_WORK_TREE") or os.getcwd()
116116+ return os.environ.get("TARTARUS_SESSIONS_DIR") or _default_state_path(
117117+ work_tree, SESSIONS_LEAF
118118+ )
951199612097121def _default_state_path(work_tree: str, leaf: str) -> str:
98122 return os.path.join(work_tree, DEFAULT_STATE_DIR, leaf)
99123100124101101-def _read_api_key() -> str:
102102- for variable in API_KEY_ENV_VARS:
103103- value = os.environ.get(variable)
104104- if value:
105105- return value
106106- return ""
107107-108108-109109-def _read_extra_headers() -> dict[str, str] | None:
110110- raw_headers = _read_env("EXTRA_HEADERS")
111111- if not raw_headers:
112112- return None
113113- try:
114114- parsed = json.loads(raw_headers)
115115- except json.JSONDecodeError as error:
116116- raise ConfigError(f"TARTARUS_EXTRA_HEADERS must be JSON: {error}") from error
117117- if not isinstance(parsed, dict):
118118- raise ConfigError("TARTARUS_EXTRA_HEADERS must be a JSON object")
119119- return {str(key): str(value) for key, value in parsed.items()}
120120-121121-122125def load_config() -> Config:
123123- """Build a Config from the environment, applying defaults.
126126+ """Build a Config from the environment.
124127125125- Fails closed: a missing API key raises ConfigError rather than attempting an
126126- unauthenticated request.
128128+ Fails closed: a missing API key or malformed value raises ConfigError rather
129129+ than attempting an unauthenticated or misconfigured request.
127130 """
128128- api_key = _read_api_key()
129129- if not api_key:
130130- names = " or ".join(API_KEY_ENV_VARS)
131131- raise ConfigError(f"no API key found; set {names}")
131131+ try:
132132+ config = Config()
133133+ except ValidationError as error:
134134+ raise _config_error(error) from error
135135+ if not config.api_key:
136136+ # TARTARUS_API_KEY is read via env_prefix; fall back to the alternates here.
137137+ for variable in API_KEY_ENV_VARS:
138138+ value = os.environ.get(variable, "")
139139+ if value:
140140+ config.api_key = value
141141+ break
142142+ if not config.api_key:
143143+ raise ConfigError(f"no API key found; set {' or '.join(API_KEY_ENV_VARS)}")
144144+ return config
132145133133- work_tree = _read_env("WORK_TREE", os.getcwd()) or os.getcwd()
134134- default_audit_path = _default_state_path(work_tree, "audit.jsonl")
135135- audit_path = _read_env("AUDIT_PATH", default_audit_path) or default_audit_path
136136- session_dir = session_dir_from_env()
137146138138- raw_max_tokens = _read_env("MAX_TOKENS")
139139- return Config(
140140- # Left None when unset so the agent's profile can supply them; an explicit
141141- # value here still overrides the profile (resolve_runtime).
142142- provider=_read_env("PROVIDER"),
143143- base_url=_read_env("BASE_URL"),
144144- api_key=api_key,
145145- model=_read_env("MODEL"),
146146- max_tokens=int(raw_max_tokens) if raw_max_tokens else None,
147147- extra_headers=_read_extra_headers(),
148148- work_tree=work_tree,
149149- flake_ref=_read_env("FLAKE_REF", DEFAULT_FLAKE_REF) or DEFAULT_FLAKE_REF,
150150- bundle_path=_read_env("BUNDLE", "") or "",
151151- agent_name=_read_env("AGENT", DEFAULT_AGENT_NAME) or DEFAULT_AGENT_NAME,
152152- headless=_read_bool_env("HEADLESS"),
153153- audit_path=audit_path,
154154- session_dir=session_dir,
155155- output_truncate=int(
156156- _read_env("OUTPUT_TRUNCATE", str(DEFAULT_OUTPUT_TRUNCATE_CHARS))
157157- or DEFAULT_OUTPUT_TRUNCATE_CHARS
158158- ),
147147+def _config_error(error: ValidationError) -> ConfigError:
148148+ """Translate a settings ValidationError into a user-facing ConfigError."""
149149+ failed = {str(location) for entry in error.errors() for location in entry["loc"]}
150150+ if "extra_headers" in failed:
151151+ return ConfigError("TARTARUS_EXTRA_HEADERS must be a JSON object")
152152+ # loc + msg only: ValidationError's str/input fields echo the offending value,
153153+ # which would leak secrets like api_key into logs.
154154+ details = "; ".join(
155155+ f"{' -> '.join(str(loc) for loc in entry['loc'])}: {entry['msg']}"
156156+ for entry in error.errors()
159157 )
158158+ return ConfigError(f"invalid configuration: {details}")
160159161160162161class ResolvedRuntime(BaseModel):
···11+import os
22+13import pytest
2435from tartarus.config import (
66+ API_KEY_ENV_VARS,
47 DEFAULT_BASE_URL,
58 DEFAULT_MAX_TOKENS,
69 DEFAULT_MODEL,
···1013 resolve_runtime,
1114)
1215from tartarus.manifest import Manifest, ModelConfig
1616+1717+1818+@pytest.fixture(autouse=True)
1919+def _clear_harness_env(monkeypatch):
2020+ # Config now reads ambient env (BaseSettings), so clear it for hermetic tests;
2121+ # each test sets only the vars it exercises.
2222+ for key in list(os.environ):
2323+ if key.startswith("TARTARUS_") or key in API_KEY_ENV_VARS:
2424+ monkeypatch.delenv(key, raising=False)
132514261527def _manifest(**kwargs) -> Manifest: