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>
This commit is contained in:
+136
-43
@@ -1,52 +1,64 @@
|
||||
"""JSON runtime-profile configuration for Gitea MCP.
|
||||
"""Canonical JSON runtime-profile configuration for Gitea MCP (issue #19).
|
||||
|
||||
Lets one MCP server select among multiple named runtime profiles defined in a
|
||||
JSON file, instead of editing code or juggling many ``.env`` files:
|
||||
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/gitea-mcp.json # the file
|
||||
GITEA_MCP_PROFILE=dev # which named profile to use
|
||||
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": {
|
||||
"dev": {
|
||||
"base_url": "https://gitea.dev.example",
|
||||
"profile_name": "gitea-author",
|
||||
"token_env": "GITEA_TOKEN_DEV",
|
||||
"owner": "Scaled-Tech-Consulting",
|
||||
"repo": "Gitea-Tools",
|
||||
"allowed_operations": ["read", "pr.create"],
|
||||
"forbidden_operations": ["merge"],
|
||||
"audit_label": "dev-author"
|
||||
"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 profile references its token by *env var name*
|
||||
(``token_env``); a raw ``token`` key is rejected. The token value is resolved
|
||||
by reading that env var and is never stored in, or returned as, profile
|
||||
metadata.
|
||||
- **No network.** Parsing only reads and decodes a local file.
|
||||
- **Fail safely.** A missing file, invalid JSON, or missing/unknown selected
|
||||
profile raises :class:`ConfigError` with a clear message that never includes
|
||||
file contents or credential values.
|
||||
- **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 missing/invalid config file or a bad profile selection.
|
||||
"""Raised for a bad config file, profile selection, or secret reference.
|
||||
|
||||
Messages are safe to surface: they never include file contents or tokens.
|
||||
Messages are safe to surface: they never include file contents, tokens, or
|
||||
passwords — only non-secret names/ids/positions.
|
||||
"""
|
||||
|
||||
|
||||
@@ -61,12 +73,12 @@ def selected_profile_name():
|
||||
|
||||
|
||||
def load_config(path=None):
|
||||
"""Load and minimally validate the JSON config.
|
||||
"""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, or lacks a
|
||||
``profiles`` object.
|
||||
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:
|
||||
@@ -87,15 +99,39 @@ def load_config(path=None):
|
||||
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, the profile is not an
|
||||
object, or the profile embeds a raw ``token``.
|
||||
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
|
||||
@@ -114,12 +150,14 @@ def select_profile(config, name=None):
|
||||
profile = profiles[name]
|
||||
if not isinstance(profile, dict):
|
||||
raise ConfigError(f"profile '{name}' must be a JSON object")
|
||||
if "token" in profile:
|
||||
# Never accept (or echo) an inline secret; require an env var reference.
|
||||
raise ConfigError(
|
||||
f"profile '{name}' must not contain an inline 'token'; "
|
||||
"use 'token_env' (the NAME of an environment variable) instead"
|
||||
)
|
||||
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
|
||||
|
||||
|
||||
@@ -128,16 +166,71 @@ def resolve_profile(path=None, name=None):
|
||||
return select_profile(load_config(path), name)
|
||||
|
||||
|
||||
def resolve_token(profile):
|
||||
"""Resolve the token for *profile* via its ``token_env`` name.
|
||||
def auth_source_name(profile):
|
||||
"""Return a *non-secret* name for a profile's token source, or None.
|
||||
|
||||
Reads the named environment variable; returns None if the profile is None,
|
||||
has no ``token_env``, or the variable is unset. Never accepts an inline
|
||||
token and never logs the value.
|
||||
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
|
||||
env_name = profile.get("token_env")
|
||||
if not env_name:
|
||||
auth = profile.get("auth")
|
||||
if not isinstance(auth, dict):
|
||||
return None
|
||||
return os.environ.get(env_name) or 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")
|
||||
|
||||
Reference in New Issue
Block a user