Files
Gitea-Tools/gitea_config.py
T
sysadmin b88ca0c929 feat: canonical shared runtime-profiles config with typed auth refs (#19)
Rework the JSON runtime-profile config from the earlier ad-hoc schema
(profiles + token_env) to the canonical single-file model in #19, so every LLM
launcher can reference one shared Gitea profiles file instead of duplicating
GITEA_USER_*/GITEA_PASS_* blocks or embedding tokens.

Canonical schema (gitea_config.py):
- top-level "version" (1) + "profiles" map.
- each profile: base_url, username, default_owner, execution_profile, and a
  typed auth reference:
    { "type": "keychain", "id": "..." }   -> macOS keychain (security(1))
    { "type": "env",      "name": "..." } -> named environment variable
- inline "token"/"password" keys are rejected (never accepted or echoed).
- select via GITEA_MCP_CONFIG (path) + GITEA_MCP_PROFILE (name).

gitea_auth integration:
- get_profile() overlays env over the selected profile (env wins; JSON fills
  the rest); profile_name <- execution_profile; token_source_name <- the
  non-secret auth reference name (env var name or "keychain:<id>"); now also
  surfaces username + default_owner.
- get_auth_header() resolves the profile's auth reference (env/keychain) as a
  token fallback after explicit env tokens; a ConfigError there fails closed.

Security / safety:
- Secrets referenced only (keychain id / env name); token values never stored
  in or returned as metadata. Errors never print file contents, tokens, or
  passwords (JSONDecodeError context suppressed).
- Missing file / invalid JSON / unsupported version / unknown-or-unset profile
  / unresolvable secret reference all raise a clear, safe ConfigError.
- No network calls during config parsing; keychain lookup is on-demand and
  injectable for tests.
- Backwards compatible: GITEA_MCP_CONFIG unset => legacy env-only mode
  (existing get_profile/get_auth_header tests unchanged).

Docs: README canonical-profile + thin-launcher (Claude/Gemini/Codex) sections
and a migration note away from duplicated GITEA_PASS_* blocks; .env.example and
gitea-mcp.example.json updated to the canonical shape (safe placeholders only).

Tests: tests/test_config.py (31 cases) — legacy env-only, JSON selection,
multiple profiles, missing/unset profile, invalid JSON, unsupported version,
env-override precedence, keychain + env auth-reference parsing and resolution,
missing-secret errors, inline token/password redaction, and no-network parse.

Refs #10. Completes the closed #19 (env-based profiles) by adding the canonical
shared-file model. Supersedes this PR's earlier simpler JSON schema.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-07-01 23:04:03 -04:00

237 lines
8.7 KiB
Python

"""Canonical JSON runtime-profile configuration for Gitea MCP (issue #19).
One canonical config file defines every Gitea runtime profile. Each LLM MCP
launcher (Claude / Gemini / Codex) stays a *thin* launcher that only points at
that file and names a profile — no duplicated ``GITEA_USER_*`` / ``GITEA_PASS_*``
blocks, no raw tokens in client configs:
GITEA_MCP_CONFIG=/path/to/profiles.json # the canonical file
GITEA_MCP_PROFILE=prgs # which named profile to use
File shape (see ``gitea-mcp.example.json``)::
{
"version": 1,
"profiles": {
"prgs": {
"base_url": "https://gitea.prgs.cc",
"username": "jcwalker3",
"auth": {"type": "keychain", "id": "prgs-gitea-token"},
"default_owner": "Scaled-Tech-Consulting",
"execution_profile": "personal-prgs"
}
}
}
Auth is referenced *indirectly*, never inline:
- ``{"type": "keychain", "id": "prgs-gitea-token"}`` — resolved from the macOS
keychain at token-resolution time.
- ``{"type": "env", "name": "GITEA_TOKEN_PRGS"}`` — read from that env var.
Design constraints:
- **Backwards compatible.** With ``GITEA_MCP_CONFIG`` unset, every entry point
returns ``None`` and callers fall back to pure environment behaviour.
- **No inline secrets.** A raw ``token``/``password`` key is rejected. Token
values are resolved only via an auth *reference* and are never stored in, or
returned as, profile metadata.
- **No network.** Parsing only reads and decodes a local file. Token resolution
(keychain/env) happens separately, on demand.
- **Fail safely.** A missing file, invalid JSON, unsupported version, unknown/
unset selected profile, or an unresolvable secret reference raises
:class:`ConfigError` with a message that never includes file contents,
tokens, or passwords.
"""
import os
import json
import subprocess
ENV_CONFIG_PATH = "GITEA_MCP_CONFIG"
ENV_PROFILE = "GITEA_MCP_PROFILE"
SUPPORTED_VERSION = 1
_AUTH_TYPES = ("keychain", "env")
class ConfigError(Exception):
"""Raised for a bad config file, profile selection, or secret reference.
Messages are safe to surface: they never include file contents, tokens, or
passwords — only non-secret names/ids/positions.
"""
def config_path():
"""Return the configured JSON path, or None when the JSON layer is off."""
return (os.environ.get(ENV_CONFIG_PATH) or "").strip() or None
def selected_profile_name():
"""Return the selected profile name from the environment, or None."""
return (os.environ.get(ENV_PROFILE) or "").strip() or None
def load_config(path=None):
"""Load and minimally validate the canonical JSON config.
Returns the parsed dict, or None when no config path is configured (so
env-only usage is never broken). Raises :class:`ConfigError` when a path is
configured but the file is missing, unreadable, not valid JSON, declares an
unsupported ``version``, or lacks a ``profiles`` object.
"""
path = path or config_path()
if not path:
return None
if not os.path.isfile(path):
raise ConfigError(f"{ENV_CONFIG_PATH} points to a missing file: {path}")
try:
with open(path, encoding="utf-8") as fh:
data = json.load(fh)
except json.JSONDecodeError as exc:
# Report position only (msg/line/col). `from None` suppresses the
# original exception, whose `.doc` holds the raw file text — which could
# contain a token — so it never reaches a traceback.
raise ConfigError(
f"invalid JSON in {path} (line {exc.lineno}, column {exc.colno}): {exc.msg}"
) from None
except OSError as exc:
raise ConfigError(f"could not read {path}: {exc.strerror}") from None
if not isinstance(data, dict) or not isinstance(data.get("profiles"), dict):
raise ConfigError(f"{path} must be a JSON object with a 'profiles' object")
version = data.get("version", SUPPORTED_VERSION)
if version != SUPPORTED_VERSION:
raise ConfigError(
f"{path} has unsupported version {version!r}; expected {SUPPORTED_VERSION}"
)
return data
def _validate_auth(name, auth):
"""Validate a profile's optional ``auth`` reference. Never echoes secrets."""
if auth is None:
return
if not isinstance(auth, dict):
raise ConfigError(f"profile '{name}' has a non-object 'auth'")
atype = auth.get("type")
if atype not in _AUTH_TYPES:
raise ConfigError(
f"profile '{name}' has invalid auth type {atype!r}; "
f"expected one of {list(_AUTH_TYPES)}"
)
if atype == "keychain" and not auth.get("id"):
raise ConfigError(f"profile '{name}' keychain auth requires an 'id'")
if atype == "env" and not auth.get("name"):
raise ConfigError(f"profile '{name}' env auth requires a 'name'")
def select_profile(config, name=None):
"""Return the selected profile dict from a loaded *config*.
Returns None when *config* is None. Raises :class:`ConfigError` when no
profile is selected, the selected profile is unknown or not an object, the
profile embeds a raw ``token``/``password``, or its ``auth`` reference is
malformed.
"""
if config is None:
return None
profiles = config.get("profiles", {})
name = name or selected_profile_name()
available = sorted(profiles)
if not name:
raise ConfigError(
f"{ENV_CONFIG_PATH} is set but {ENV_PROFILE} is not; "
f"available profiles: {available}"
)
if name not in profiles:
raise ConfigError(
f"profile '{name}' not found in config; available profiles: {available}"
)
profile = profiles[name]
if not isinstance(profile, dict):
raise ConfigError(f"profile '{name}' must be a JSON object")
for secret_key in ("token", "password"):
if secret_key in profile:
# Never accept (or echo) an inline secret; require an auth reference.
raise ConfigError(
f"profile '{name}' must not contain an inline '{secret_key}'; "
"use an 'auth' reference ({\"type\": \"keychain\"|\"env\", ...}) instead"
)
_validate_auth(name, profile.get("auth"))
return profile
def resolve_profile(path=None, name=None):
"""Load the config and return the selected profile dict, or None if off."""
return select_profile(load_config(path), name)
def auth_source_name(profile):
"""Return a *non-secret* name for a profile's token source, or None.
For env auth this is the env var name; for keychain auth, ``keychain:<id>``.
Safe to surface in profile metadata (never the token value).
"""
if not profile:
return None
auth = profile.get("auth")
if not isinstance(auth, dict):
return None
if auth.get("type") == "env":
return auth.get("name")
if auth.get("type") == "keychain":
return f"keychain:{auth.get('id')}"
return None
def _keychain_token(item_id):
"""Read a token from the macOS keychain by service *item_id*.
Returns the secret string, or None if it cannot be found. Never logs the
value; failures are swallowed so the caller can raise a safe error.
"""
try:
proc = subprocess.run(
["security", "find-generic-password", "-s", item_id, "-w"],
capture_output=True, text=True,
)
except (OSError, subprocess.SubprocessError):
return None
if proc.returncode != 0:
return None
return proc.stdout.strip() or None
def resolve_token(profile, keychain_lookup=_keychain_token):
"""Resolve the token for *profile* via its ``auth`` reference.
Returns None when *profile* is None or has no ``auth``. Raises
:class:`ConfigError` when the reference cannot be resolved (env var unset or
keychain item missing). Never accepts an inline token and never logs, echoes,
or includes the secret *value* in any error. *keychain_lookup* is injectable
for testing.
"""
if not profile:
return None
auth = profile.get("auth")
if not isinstance(auth, dict):
return None
atype = auth.get("type")
if atype == "env":
env_name = auth.get("name")
value = os.environ.get(env_name) if env_name else None
if not value:
raise ConfigError(
f"auth env var '{env_name}' referenced by the profile is not set"
)
return value
if atype == "keychain":
item_id = auth.get("id")
value = keychain_lookup(item_id) if item_id else None
if not value:
raise ConfigError(
f"keychain item '{item_id}' referenced by the profile was not found"
)
return value
raise ConfigError(f"unsupported auth type {atype!r} in the selected profile")