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:
2026-07-01 23:04:03 -04:00
parent 3aaba73127
commit b88ca0c929
6 changed files with 390 additions and 189 deletions
+7 -6
View File
@@ -39,9 +39,10 @@ GITEA_AUDIT_LOG=/path/to/gitea-mcp-audit.log
# only — never the token value. Surfaced by gitea_get_profile. # only — never the token value. Surfaced by gitea_get_profile.
GITEA_TOKEN_SOURCE=GITEA_TOKEN GITEA_TOKEN_SOURCE=GITEA_TOKEN
# Optional JSON runtime-profile config (roadmap #10). Instead of the fields # Optional canonical runtime-profile config (#19). Instead of the fields above,
# above, point at a JSON file with multiple named profiles and select one. # point every LLM launcher at ONE JSON file of named profiles and select one.
# See gitea-mcp.example.json. Explicit env vars above still override the # Secrets are referenced (keychain id / env var name), never inlined. See
# selected profile's values. Leave unset for pure env-based configuration. # gitea-mcp.example.json. Explicit env vars above still override the selected
GITEA_MCP_CONFIG=/path/to/gitea-mcp.json # profile's values. Leave unset for pure env-based configuration.
GITEA_MCP_PROFILE=dev GITEA_MCP_CONFIG=/Users/jasonwalker/.config/gitea-tools/profiles.json
GITEA_MCP_PROFILE=prgs
+51 -23
View File
@@ -190,40 +190,68 @@ Notes:
is off by default and never adds API calls or breaks the action when off. is off by default and never adds API calls or breaks the action when off.
See [`gitea_audit.py`](gitea_audit.py). See [`gitea_audit.py`](gitea_audit.py).
**JSON runtime profiles (roadmap #10).** Instead of juggling many `.env` files, **Canonical runtime profiles (#19).** Define every Gitea profile **once**, in a
one server can pick a named profile from a JSON file (see canonical JSON file, and keep each LLM launcher (Claude / Gemini / Codex) a
[`gitea-mcp.example.json`](gitea-mcp.example.json), loaded by *thin* pointer at it — no duplicated `GITEA_USER_*` / `GITEA_PASS_*` blocks and
[`gitea_config.py`](gitea_config.py)): no raw tokens in client configs. See [`gitea-mcp.example.json`](gitea-mcp.example.json),
loaded by [`gitea_config.py`](gitea_config.py).
```bash Canonical profile file (e.g. `~/.config/gitea-tools/profiles.json`):
export GITEA_MCP_CONFIG=/path/to/gitea-mcp.json
export GITEA_MCP_PROFILE=dev
```
```json ```json
{ {
"version": 1,
"profiles": { "profiles": {
"dev": { "prgs": {
"base_url": "https://gitea.dev.example", "base_url": "https://gitea.prgs.cc",
"profile_name": "gitea-author", "username": "jcwalker3",
"token_env": "GITEA_TOKEN_DEV", "auth": { "type": "keychain", "id": "prgs-gitea-token" },
"allowed_operations": ["read", "pr.create"], "default_owner": "Scaled-Tech-Consulting",
"audit_label": "dev-author" "execution_profile": "personal-prgs"
},
"mdcps": {
"base_url": "https://gitea.dadeschools.net",
"username": "913443",
"auth": { "type": "env", "name": "GITEA_TOKEN_MDCPS" },
"execution_profile": "mdcps"
} }
} }
} }
``` ```
- **Token by reference only:** a profile names the env var holding its token Thin LLM launcher (Claude / Gemini / Codex) — only two env vars, no secrets:
(`token_env`); an inline `token` is rejected. The value is read from that env
var and never stored in or returned as profile metadata. ```json
- **Precedence:** explicit env vars (`GITEA_PROFILE_NAME`, `GITEA_BASE_URL`, "gitea-tools": {
`GITEA_ALLOWED_OPERATIONS`, `GITEA_TOKEN`, …) **override** the JSON profile; "command": "/Users/jasonwalker/Development/Gitea-Tools/venv/bin/python3",
the JSON profile only fills what the environment leaves unset. "args": ["/Users/jasonwalker/Development/Gitea-Tools/mcp_server.py"],
"env": {
"GITEA_MCP_CONFIG": "/Users/jasonwalker/.config/gitea-tools/profiles.json",
"GITEA_MCP_PROFILE": "prgs"
}
}
```
- **Secrets by reference only:** a profile's `auth` names *where* the token
lives — `{ "type": "keychain", "id": "..." }` (macOS keychain) or
`{ "type": "env", "name": "..." }` (env var). Inline `token`/`password` keys
are rejected. The value is resolved on demand and never stored in, returned
by, or logged as profile metadata.
- **Precedence:** explicit process env vars (`GITEA_PROFILE_NAME`,
`GITEA_BASE_URL`, `GITEA_TOKEN`, …) **override** the JSON profile; the JSON
profile only fills what the environment leaves unset.
- **Backwards compatible / fail-safe:** with `GITEA_MCP_CONFIG` unset, behaviour - **Backwards compatible / fail-safe:** with `GITEA_MCP_CONFIG` unset, behaviour
is exactly env-only. A missing file, invalid JSON, or unknown/unset selected is exactly the legacy env-only mode. A missing file, invalid JSON, unsupported
profile raises a clear startup error that never prints file contents or `version`, unknown/unset selected profile, or unresolvable secret reference
tokens. Parsing makes no network calls. raises a clear startup error that never prints file contents, tokens, or
passwords. Parsing makes no network calls.
**Migrating from duplicated `GITEA_PASS_*` blocks.** Move each instance's
credentials into one canonical profile entry (referencing a keychain id or env
var for the secret), then delete the `GITEA_USER_*` / `GITEA_PASS_*` /
`GITEA_SITE_*` blocks from every LLM `mcp_config.json`, leaving only
`GITEA_MCP_CONFIG` + `GITEA_MCP_PROFILE`. Existing env-only setups keep working
unchanged until migrated.
</details> </details>
<details> <details>
+29 -18
View File
@@ -1,24 +1,35 @@
{ {
"version": 1,
"profiles": { "profiles": {
"dev": { "prgs": {
"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"
},
"prod-readonly": {
"base_url": "https://gitea.prgs.cc", "base_url": "https://gitea.prgs.cc",
"profile_name": "gitea-readonly", "username": "jcwalker3",
"token_env": "GITEA_TOKEN_PROD", "auth": {
"owner": "Scaled-Tech-Consulting", "type": "keychain",
"repo": "Gitea-Tools", "id": "prgs-gitea-token"
"allowed_operations": ["read"], },
"forbidden_operations": ["merge", "approve", "request_changes"], "default_owner": "Scaled-Tech-Consulting",
"audit_label": "prod-ro" "execution_profile": "personal-prgs"
},
"mdcps": {
"base_url": "https://gitea.dadeschools.net",
"username": "913443",
"auth": {
"type": "keychain",
"id": "mdcps-gitea-token"
},
"default_owner": "Contractor",
"execution_profile": "mdcps"
},
"prgs-env": {
"base_url": "https://gitea.prgs.cc",
"username": "jcwalker3",
"auth": {
"type": "env",
"name": "GITEA_TOKEN_PRGS"
},
"default_owner": "Scaled-Tech-Consulting",
"execution_profile": "personal-prgs"
} }
} }
} }
+9 -5
View File
@@ -320,7 +320,9 @@ def get_profile():
Returns: Returns:
dict with 'profile_name', 'allowed_operations' (list), dict with 'profile_name', 'allowed_operations' (list),
'forbidden_operations' (list), 'audit_label', 'token_source_name', 'forbidden_operations' (list), 'audit_label', 'token_source_name',
and 'base_url'. 'base_url', 'username', and 'default_owner'. ``profile_name`` maps to a
JSON profile's ``execution_profile``; ``token_source_name`` is the
non-secret auth reference name (env var name or ``keychain:<id>``).
""" """
# JSON layer (base). None when GITEA_MCP_CONFIG is unset; raises ConfigError # JSON layer (base). None when GITEA_MCP_CONFIG is unset; raises ConfigError
# on a misconfigured file/profile so the problem surfaces clearly at startup. # on a misconfigured file/profile so the problem surfaces clearly at startup.
@@ -336,9 +338,9 @@ def get_profile():
val = jp.get(key) val = jp.get(key)
return list(val) if isinstance(val, (list, tuple)) else [] return list(val) if isinstance(val, (list, tuple)) else []
# profile_name: env > JSON > default. # profile_name: env > JSON execution_profile > default.
name = (os.environ.get("GITEA_PROFILE_NAME") name = (os.environ.get("GITEA_PROFILE_NAME")
or jp.get("profile_name") or "gitea-default") or jp.get("execution_profile") or "gitea-default")
name = str(name).strip() or "gitea-default" name = str(name).strip() or "gitea-default"
ops = _env_csv("GITEA_ALLOWED_OPERATIONS") ops = _env_csv("GITEA_ALLOWED_OPERATIONS")
@@ -350,9 +352,9 @@ def get_profile():
audit_label = (os.environ.get("GITEA_AUDIT_LABEL") or "").strip() \ audit_label = (os.environ.get("GITEA_AUDIT_LABEL") or "").strip() \
or (jp.get("audit_label") or None) or (jp.get("audit_label") or None)
# A *name* of the token source (env var name / JSON token_env), never a value. # A *name* of the token source (env var name / keychain id), never a value.
token_source = (os.environ.get("GITEA_TOKEN_SOURCE") or "").strip() \ token_source = (os.environ.get("GITEA_TOKEN_SOURCE") or "").strip() \
or (jp.get("token_env") or None) or gitea_config.auth_source_name(jp)
base_url = os.environ.get("GITEA_BASE_URL") or jp.get("base_url") or None base_url = os.environ.get("GITEA_BASE_URL") or jp.get("base_url") or None
return { return {
"profile_name": name, "profile_name": name,
@@ -361,4 +363,6 @@ def get_profile():
"audit_label": audit_label, "audit_label": audit_label,
"token_source_name": token_source, "token_source_name": token_source,
"base_url": base_url, "base_url": base_url,
"username": jp.get("username") or None,
"default_owner": jp.get("default_owner") or None,
} }
+136 -43
View File
@@ -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 One canonical config file defines every Gitea runtime profile. Each LLM MCP
JSON file, instead of editing code or juggling many ``.env`` files: 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_CONFIG=/path/to/profiles.json # the canonical file
GITEA_MCP_PROFILE=dev # which named profile to use GITEA_MCP_PROFILE=prgs # which named profile to use
File shape (see ``gitea-mcp.example.json``):: File shape (see ``gitea-mcp.example.json``)::
{ {
"version": 1,
"profiles": { "profiles": {
"dev": { "prgs": {
"base_url": "https://gitea.dev.example", "base_url": "https://gitea.prgs.cc",
"profile_name": "gitea-author", "username": "jcwalker3",
"token_env": "GITEA_TOKEN_DEV", "auth": {"type": "keychain", "id": "prgs-gitea-token"},
"owner": "Scaled-Tech-Consulting", "default_owner": "Scaled-Tech-Consulting",
"repo": "Gitea-Tools", "execution_profile": "personal-prgs"
"allowed_operations": ["read", "pr.create"],
"forbidden_operations": ["merge"],
"audit_label": "dev-author"
} }
} }
} }
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: Design constraints:
- **Backwards compatible.** With ``GITEA_MCP_CONFIG`` unset, every entry point - **Backwards compatible.** With ``GITEA_MCP_CONFIG`` unset, every entry point
returns ``None`` and callers fall back to pure environment behaviour. returns ``None`` and callers fall back to pure environment behaviour.
- **No inline secrets.** A profile references its token by *env var name* - **No inline secrets.** A raw ``token``/``password`` key is rejected. Token
(``token_env``); a raw ``token`` key is rejected. The token value is resolved values are resolved only via an auth *reference* and are never stored in, or
by reading that env var and is never stored in, or returned as, profile returned as, profile metadata.
metadata. - **No network.** Parsing only reads and decodes a local file. Token resolution
- **No network.** Parsing only reads and decodes a local file. (keychain/env) happens separately, on demand.
- **Fail safely.** A missing file, invalid JSON, or missing/unknown selected - **Fail safely.** A missing file, invalid JSON, unsupported version, unknown/
profile raises :class:`ConfigError` with a clear message that never includes unset selected profile, or an unresolvable secret reference raises
file contents or credential values. :class:`ConfigError` with a message that never includes file contents,
tokens, or passwords.
""" """
import os import os
import json import json
import subprocess
ENV_CONFIG_PATH = "GITEA_MCP_CONFIG" ENV_CONFIG_PATH = "GITEA_MCP_CONFIG"
ENV_PROFILE = "GITEA_MCP_PROFILE" ENV_PROFILE = "GITEA_MCP_PROFILE"
SUPPORTED_VERSION = 1
_AUTH_TYPES = ("keychain", "env")
class ConfigError(Exception): 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): 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 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 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 configured but the file is missing, unreadable, not valid JSON, declares an
``profiles`` object. unsupported ``version``, or lacks a ``profiles`` object.
""" """
path = path or config_path() path = path or config_path()
if not path: if not path:
@@ -87,15 +99,39 @@ def load_config(path=None):
raise ConfigError(f"could not read {path}: {exc.strerror}") from None raise ConfigError(f"could not read {path}: {exc.strerror}") from None
if not isinstance(data, dict) or not isinstance(data.get("profiles"), dict): 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") 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 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): def select_profile(config, name=None):
"""Return the selected profile dict from a loaded *config*. """Return the selected profile dict from a loaded *config*.
Returns None when *config* is None. Raises :class:`ConfigError` when no Returns None when *config* is None. Raises :class:`ConfigError` when no
profile is selected, the selected profile is unknown, the profile is not an profile is selected, the selected profile is unknown or not an object, the
object, or the profile embeds a raw ``token``. profile embeds a raw ``token``/``password``, or its ``auth`` reference is
malformed.
""" """
if config is None: if config is None:
return None return None
@@ -114,12 +150,14 @@ def select_profile(config, name=None):
profile = profiles[name] profile = profiles[name]
if not isinstance(profile, dict): if not isinstance(profile, dict):
raise ConfigError(f"profile '{name}' must be a JSON object") raise ConfigError(f"profile '{name}' must be a JSON object")
if "token" in profile: for secret_key in ("token", "password"):
# Never accept (or echo) an inline secret; require an env var reference. if secret_key in profile:
raise ConfigError( # Never accept (or echo) an inline secret; require an auth reference.
f"profile '{name}' must not contain an inline 'token'; " raise ConfigError(
"use 'token_env' (the NAME of an environment variable) instead" 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 return profile
@@ -128,16 +166,71 @@ def resolve_profile(path=None, name=None):
return select_profile(load_config(path), name) return select_profile(load_config(path), name)
def resolve_token(profile): def auth_source_name(profile):
"""Resolve the token for *profile* via its ``token_env`` name. """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, For env auth this is the env var name; for keychain auth, ``keychain:<id>``.
has no ``token_env``, or the variable is unset. Never accepts an inline Safe to surface in profile metadata (never the token value).
token and never logs the value.
""" """
if not profile: if not profile:
return None return None
env_name = profile.get("token_env") auth = profile.get("auth")
if not env_name: if not isinstance(auth, dict):
return None 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")
+158 -94
View File
@@ -1,9 +1,10 @@
"""Tests for JSON runtime-profile configuration (gitea_config) and its """Tests for canonical JSON runtime-profile configuration (gitea_config) and
integration into gitea_auth.get_profile / get_auth_header. its integration into gitea_auth.get_profile / get_auth_header.
Covers: env-only still works, JSON selection, multiple profiles, env-override Covers: legacy env-only, JSON profile config, profile selection, multiple
precedence, missing file, invalid JSON, missing/unset profile, inline-token profiles, missing profile, invalid JSON, env-override precedence, keychain and
rejection + redaction, and that config parsing performs no network calls. env auth-reference parsing/resolution, token/password redaction in errors, and
no network calls during config load.
""" """
import os import os
import sys import sys
@@ -18,32 +19,30 @@ import gitea_config # noqa: E402
import gitea_auth # noqa: E402 import gitea_auth # noqa: E402
CONFIG = { CONFIG = {
"version": 1,
"profiles": { "profiles": {
"dev": { "prgs": {
"base_url": "https://gitea.dev.example", "base_url": "https://gitea.prgs.cc",
"profile_name": "gitea-author", "username": "jcwalker3",
"token_env": "GITEA_TOKEN_DEV", "auth": {"type": "keychain", "id": "prgs-gitea-token"},
"owner": "Org", "default_owner": "Scaled-Tech-Consulting",
"repo": "Repo", "execution_profile": "personal-prgs",
"allowed_operations": ["read", "pr.create"],
"forbidden_operations": ["merge"],
"audit_label": "dev-author",
}, },
"prod-readonly": { "mdcps-env": {
"base_url": "https://gitea.prod.example", "base_url": "https://gitea.dadeschools.net",
"profile_name": "gitea-readonly", "username": "913443",
"token_env": "GITEA_TOKEN_PROD", "auth": {"type": "env", "name": "GITEA_TOKEN_MDCPS"},
"allowed_operations": ["read"], "default_owner": "Contractor",
"audit_label": "prod-ro", "execution_profile": "mdcps",
}, },
} },
} }
class _ConfigBase(unittest.TestCase): class _ConfigBase(unittest.TestCase):
def setUp(self): def setUp(self):
self._dir = tempfile.TemporaryDirectory() self._dir = tempfile.TemporaryDirectory()
self.path = os.path.join(self._dir.name, "gitea-mcp.json") self.path = os.path.join(self._dir.name, "profiles.json")
self._write(CONFIG) self._write(CONFIG)
def tearDown(self): def tearDown(self):
@@ -51,59 +50,55 @@ class _ConfigBase(unittest.TestCase):
def _write(self, obj): def _write(self, obj):
with open(self.path, "w", encoding="utf-8") as fh: with open(self.path, "w", encoding="utf-8") as fh:
if isinstance(obj, str): fh.write(obj if isinstance(obj, str) else json.dumps(obj))
fh.write(obj)
else:
json.dump(obj, fh)
def _env(self, profile="dev", **extra): def _env(self, profile="prgs", **extra):
env = {"GITEA_MCP_CONFIG": self.path, "GITEA_MCP_PROFILE": profile} env = {"GITEA_MCP_CONFIG": self.path, "GITEA_MCP_PROFILE": profile}
env.update(extra) env.update(extra)
return env return env
# --------------------------------------------------------------------------- # ---------------------------------------------------------------------------
# gitea_config: loading / selection # Loading / selection
# --------------------------------------------------------------------------- # ---------------------------------------------------------------------------
class TestLoadSelect(_ConfigBase): class TestLoadSelect(_ConfigBase):
def test_env_only_returns_none(self): def test_legacy_env_only_returns_none(self):
# No GITEA_MCP_CONFIG -> JSON layer off, no error.
with patch.dict(os.environ, {}, clear=True): with patch.dict(os.environ, {}, clear=True):
self.assertIsNone(gitea_config.load_config()) self.assertIsNone(gitea_config.load_config())
self.assertIsNone(gitea_config.resolve_profile()) self.assertIsNone(gitea_config.resolve_profile())
def test_selected_profile_loads(self): def test_selected_profile_loads(self):
with patch.dict(os.environ, self._env("dev"), clear=True): with patch.dict(os.environ, self._env("prgs"), clear=True):
p = gitea_config.resolve_profile() p = gitea_config.resolve_profile()
self.assertEqual(p["profile_name"], "gitea-author") self.assertEqual(p["base_url"], "https://gitea.prgs.cc")
self.assertEqual(p["base_url"], "https://gitea.dev.example") self.assertEqual(p["execution_profile"], "personal-prgs")
def test_multiple_profiles_select_correctly(self): def test_multiple_profiles_select_correctly(self):
with patch.dict(os.environ, self._env("prod-readonly"), clear=True): with patch.dict(os.environ, self._env("mdcps-env"), clear=True):
p = gitea_config.resolve_profile() p = gitea_config.resolve_profile()
self.assertEqual(p["profile_name"], "gitea-readonly") self.assertEqual(p["username"], "913443")
self.assertEqual(p["allowed_operations"], ["read"]) self.assertEqual(p["auth"]["type"], "env")
def test_missing_file_when_unset_is_noop(self): def test_missing_file_unset_is_noop(self):
with patch.dict(os.environ, {}, clear=True): with patch.dict(os.environ, {}, clear=True):
self.assertIsNone(gitea_config.resolve_profile()) self.assertIsNone(gitea_config.resolve_profile())
def test_missing_file_when_set_raises(self): def test_missing_file_set_raises(self):
env = {"GITEA_MCP_CONFIG": self.path + ".nope", "GITEA_MCP_PROFILE": "dev"} env = {"GITEA_MCP_CONFIG": self.path + ".nope", "GITEA_MCP_PROFILE": "prgs"}
with patch.dict(os.environ, env, clear=True): with patch.dict(os.environ, env, clear=True):
with self.assertRaises(gitea_config.ConfigError) as ctx: with self.assertRaises(gitea_config.ConfigError) as ctx:
gitea_config.resolve_profile() gitea_config.resolve_profile()
self.assertIn("missing file", str(ctx.exception)) self.assertIn("missing file", str(ctx.exception))
def test_invalid_json_raises_without_leaking_content(self): def test_invalid_json_raises_without_leaking_content(self):
self._write('{"profiles": {"dev": {"token": "super-secret-token" BROKEN}}}') self._write('{"version":1,"profiles":{"prgs":{"auth":{"type":"env" BROKEN')
with patch.dict(os.environ, self._env("dev"), clear=True): with patch.dict(os.environ, self._env("prgs"), clear=True):
with self.assertRaises(gitea_config.ConfigError) as ctx: with self.assertRaises(gitea_config.ConfigError) as ctx:
gitea_config.resolve_profile() gitea_config.resolve_profile()
msg = str(ctx.exception) msg = str(ctx.exception)
self.assertIn("invalid JSON", msg) self.assertIn("invalid JSON", msg)
self.assertNotIn("super-secret-token", msg) # no file content leaked self.assertNotIn("BROKEN", msg) # no file content leaked
def test_missing_profile_raises_with_available(self): def test_missing_profile_raises_with_available(self):
with patch.dict(os.environ, self._env("ghost"), clear=True): with patch.dict(os.environ, self._env("ghost"), clear=True):
@@ -111,7 +106,7 @@ class TestLoadSelect(_ConfigBase):
gitea_config.resolve_profile() gitea_config.resolve_profile()
msg = str(ctx.exception) msg = str(ctx.exception)
self.assertIn("ghost", msg) self.assertIn("ghost", msg)
self.assertIn("dev", msg) # lists available profiles self.assertIn("prgs", msg)
def test_config_set_but_profile_unset_raises(self): def test_config_set_but_profile_unset_raises(self):
with patch.dict(os.environ, {"GITEA_MCP_CONFIG": self.path}, clear=True): with patch.dict(os.environ, {"GITEA_MCP_CONFIG": self.path}, clear=True):
@@ -120,54 +115,127 @@ class TestLoadSelect(_ConfigBase):
self.assertIn("GITEA_MCP_PROFILE", str(ctx.exception)) self.assertIn("GITEA_MCP_PROFILE", str(ctx.exception))
def test_bad_top_level_shape_raises(self): def test_bad_top_level_shape_raises(self):
self._write({"nope": 1}) self._write({"version": 1, "nope": 1})
with patch.dict(os.environ, self._env("dev"), clear=True): with patch.dict(os.environ, self._env("prgs"), clear=True):
with self.assertRaises(gitea_config.ConfigError):
gitea_config.resolve_profile()
def test_unsupported_version_raises(self):
self._write({"version": 2, "profiles": {"prgs": {}}})
with patch.dict(os.environ, self._env("prgs"), clear=True):
with self.assertRaises(gitea_config.ConfigError) as ctx:
gitea_config.resolve_profile()
self.assertIn("version", str(ctx.exception))
def test_missing_version_defaults_ok(self):
self._write({"profiles": {"prgs": {"base_url": "https://x"}}})
with patch.dict(os.environ, self._env("prgs"), clear=True):
self.assertEqual(
gitea_config.resolve_profile()["base_url"], "https://x")
# ---------------------------------------------------------------------------
# Auth reference parsing + resolution
# ---------------------------------------------------------------------------
class TestAuthReferences(_ConfigBase):
def test_keychain_reference_parses(self):
with patch.dict(os.environ, self._env("prgs"), clear=True):
p = gitea_config.resolve_profile()
self.assertEqual(p["auth"], {"type": "keychain", "id": "prgs-gitea-token"})
self.assertEqual(gitea_config.auth_source_name(p), "keychain:prgs-gitea-token")
def test_env_reference_parses(self):
with patch.dict(os.environ, self._env("mdcps-env"), clear=True):
p = gitea_config.resolve_profile()
self.assertEqual(gitea_config.auth_source_name(p), "GITEA_TOKEN_MDCPS")
def test_keychain_token_resolved_via_injected_lookup(self):
with patch.dict(os.environ, self._env("prgs"), clear=True):
p = gitea_config.resolve_profile()
token = gitea_config.resolve_token(
p, keychain_lookup=lambda i: "kc-token" if i == "prgs-gitea-token" else None)
self.assertEqual(token, "kc-token")
def test_env_token_resolved_from_env(self):
env = self._env("mdcps-env", GITEA_TOKEN_MDCPS="mdcps-token-value")
with patch.dict(os.environ, env, clear=True):
p = gitea_config.resolve_profile()
self.assertEqual(gitea_config.resolve_token(p), "mdcps-token-value")
def test_keychain_lookup_uses_security_binary(self):
# _keychain_token shells out to `security find-generic-password`.
class _Proc:
returncode = 0
stdout = "secret-from-keychain\n"
with patch("gitea_config.subprocess.run", return_value=_Proc()) as run:
self.assertEqual(gitea_config._keychain_token("some-id"), "secret-from-keychain")
args = run.call_args[0][0]
self.assertEqual(args[0], "security")
self.assertIn("some-id", args)
def test_missing_env_secret_raises_clearly(self):
with patch.dict(os.environ, self._env("mdcps-env"), clear=True):
p = gitea_config.resolve_profile()
with self.assertRaises(gitea_config.ConfigError) as ctx:
gitea_config.resolve_token(p)
self.assertIn("GITEA_TOKEN_MDCPS", str(ctx.exception))
def test_missing_keychain_secret_raises_clearly(self):
with patch.dict(os.environ, self._env("prgs"), clear=True):
p = gitea_config.resolve_profile()
with self.assertRaises(gitea_config.ConfigError) as ctx:
gitea_config.resolve_token(p, keychain_lookup=lambda i: None)
self.assertIn("prgs-gitea-token", str(ctx.exception))
def test_invalid_auth_type_rejected(self):
self._write({"version": 1, "profiles": {"p": {"auth": {"type": "vault"}}}})
with patch.dict(os.environ, self._env("p"), clear=True):
with self.assertRaises(gitea_config.ConfigError) as ctx:
gitea_config.resolve_profile()
self.assertIn("invalid auth type", str(ctx.exception))
def test_keychain_without_id_rejected(self):
self._write({"version": 1, "profiles": {"p": {"auth": {"type": "keychain"}}}})
with patch.dict(os.environ, self._env("p"), clear=True):
with self.assertRaises(gitea_config.ConfigError): with self.assertRaises(gitea_config.ConfigError):
gitea_config.resolve_profile() gitea_config.resolve_profile()
# --------------------------------------------------------------------------- # ---------------------------------------------------------------------------
# Token handling / redaction # Secret redaction
# --------------------------------------------------------------------------- # ---------------------------------------------------------------------------
class TestTokenHandling(_ConfigBase): class TestRedaction(_ConfigBase):
def test_inline_token_rejected_without_echo(self): def test_inline_token_rejected_without_echo(self):
self._write({"profiles": {"dev": {"token": "super-secret-token"}}}) self._write({"version": 1, "profiles": {"prgs": {"token": "super-secret-token"}}})
with patch.dict(os.environ, self._env("dev"), clear=True): with patch.dict(os.environ, self._env("prgs"), clear=True):
with self.assertRaises(gitea_config.ConfigError) as ctx: with self.assertRaises(gitea_config.ConfigError) as ctx:
gitea_config.resolve_profile() gitea_config.resolve_profile()
msg = str(ctx.exception) msg = str(ctx.exception)
self.assertIn("token_env", msg) self.assertIn("auth", msg)
self.assertNotIn("super-secret-token", msg) # value never echoed self.assertNotIn("super-secret-token", msg)
def test_resolve_token_reads_env_by_name(self): def test_inline_password_rejected_without_echo(self):
env = self._env("dev", GITEA_TOKEN_DEV="dev-token-value") self._write({"version": 1, "profiles": {"prgs": {"password": "hunter2secret"}}})
with patch.dict(os.environ, self._env("prgs"), clear=True):
with self.assertRaises(gitea_config.ConfigError) as ctx:
gitea_config.resolve_profile()
self.assertNotIn("hunter2secret", str(ctx.exception))
def test_profile_metadata_has_no_token_value(self):
env = self._env("mdcps-env", GITEA_TOKEN_MDCPS="mdcps-token-value")
with patch.dict(os.environ, env, clear=True): with patch.dict(os.environ, env, clear=True):
p = gitea_config.resolve_profile() p = gitea_config.resolve_profile()
self.assertEqual(gitea_config.resolve_token(p), "dev-token-value") self.assertNotIn("mdcps-token-value", json.dumps(p))
def test_resolve_token_none_when_env_unset(self):
with patch.dict(os.environ, self._env("dev"), clear=True):
p = gitea_config.resolve_profile()
self.assertIsNone(gitea_config.resolve_token(p))
def test_resolve_token_none_for_none_profile(self):
self.assertIsNone(gitea_config.resolve_token(None))
def test_profile_metadata_never_contains_token_value(self):
env = self._env("dev", GITEA_TOKEN_DEV="dev-token-value")
with patch.dict(os.environ, env, clear=True):
p = gitea_config.resolve_profile()
self.assertNotIn("dev-token-value", json.dumps(p))
# --------------------------------------------------------------------------- # ---------------------------------------------------------------------------
# gitea_auth integration: get_profile precedence + get_auth_header fallback # gitea_auth integration
# --------------------------------------------------------------------------- # ---------------------------------------------------------------------------
class TestAuthIntegration(_ConfigBase): class TestAuthIntegration(_ConfigBase):
def test_env_only_profile_unchanged(self): def test_legacy_env_only_profile_unchanged(self):
# No JSON config: get_profile is exactly the env-only behaviour.
with patch.dict(os.environ, {"GITEA_PROFILE_NAME": "gitea-reviewer", with patch.dict(os.environ, {"GITEA_PROFILE_NAME": "gitea-reviewer",
"GITEA_ALLOWED_OPERATIONS": "read,review"}, "GITEA_ALLOWED_OPERATIONS": "read,review"},
clear=True): clear=True):
@@ -177,25 +245,21 @@ class TestAuthIntegration(_ConfigBase):
self.assertIsNone(p["base_url"]) self.assertIsNone(p["base_url"])
def test_json_fills_profile_when_env_absent(self): def test_json_fills_profile_when_env_absent(self):
with patch.dict(os.environ, self._env("dev"), clear=True): with patch.dict(os.environ, self._env("prgs"), clear=True):
p = gitea_auth.get_profile() p = gitea_auth.get_profile()
self.assertEqual(p["profile_name"], "gitea-author") self.assertEqual(p["profile_name"], "personal-prgs") # from execution_profile
self.assertEqual(p["allowed_operations"], ["read", "pr.create"]) self.assertEqual(p["base_url"], "https://gitea.prgs.cc")
self.assertEqual(p["forbidden_operations"], ["merge"]) self.assertEqual(p["username"], "jcwalker3")
self.assertEqual(p["audit_label"], "dev-author") self.assertEqual(p["default_owner"], "Scaled-Tech-Consulting")
self.assertEqual(p["base_url"], "https://gitea.dev.example") self.assertEqual(p["token_source_name"], "keychain:prgs-gitea-token")
# token_env surfaces as the (non-secret) token *source name*.
self.assertEqual(p["token_source_name"], "GITEA_TOKEN_DEV")
def test_env_overrides_json(self): def test_env_overrides_json(self):
env = self._env("dev", env = self._env("prgs",
GITEA_PROFILE_NAME="env-name", GITEA_PROFILE_NAME="env-name",
GITEA_ALLOWED_OPERATIONS="read",
GITEA_BASE_URL="https://env.example") GITEA_BASE_URL="https://env.example")
with patch.dict(os.environ, env, clear=True): with patch.dict(os.environ, env, clear=True):
p = gitea_auth.get_profile() p = gitea_auth.get_profile()
self.assertEqual(p["profile_name"], "env-name") self.assertEqual(p["profile_name"], "env-name")
self.assertEqual(p["allowed_operations"], ["read"])
self.assertEqual(p["base_url"], "https://env.example") self.assertEqual(p["base_url"], "https://env.example")
def test_get_profile_propagates_config_error(self): def test_get_profile_propagates_config_error(self):
@@ -203,22 +267,23 @@ class TestAuthIntegration(_ConfigBase):
with self.assertRaises(gitea_config.ConfigError): with self.assertRaises(gitea_config.ConfigError):
gitea_auth.get_profile() gitea_auth.get_profile()
def test_auth_header_uses_json_token_env(self): def test_auth_header_uses_json_env_token(self):
env = self._env("dev", GITEA_TOKEN_DEV="json-token") env = self._env("mdcps-env", GITEA_TOKEN_MDCPS="json-env-token")
with patch.dict(os.environ, env, clear=True): with patch.dict(os.environ, env, clear=True):
header = gitea_auth.get_auth_header("gitea.example.com") header = gitea_auth.get_auth_header("gitea.example.com")
self.assertEqual(header, "token json-token") self.assertEqual(header, "token json-env-token")
def test_explicit_env_token_overrides_json(self): def test_explicit_env_token_overrides_json(self):
env = self._env("dev", GITEA_TOKEN_DEV="json-token", GITEA_TOKEN="env-token") env = self._env("mdcps-env", GITEA_TOKEN_MDCPS="json-env-token",
GITEA_TOKEN="process-token")
with patch.dict(os.environ, env, clear=True): with patch.dict(os.environ, env, clear=True):
header = gitea_auth.get_auth_header("gitea.example.com") header = gitea_auth.get_auth_header("gitea.example.com")
self.assertEqual(header, "token env-token") self.assertEqual(header, "token process-token")
def test_auth_header_config_error_fails_closed(self): def test_auth_header_unresolvable_ref_fails_closed(self):
# A broken selection must not crash auth — it falls back to no token. # env token ref points at an unset var -> ConfigError inside resolve is
env = {"GITEA_MCP_CONFIG": self.path, "GITEA_MCP_PROFILE": "ghost"} # swallowed to "no token"; auth falls through to (mocked-empty) basic.
with patch.dict(os.environ, env, clear=True): with patch.dict(os.environ, self._env("mdcps-env"), clear=True):
with patch("gitea_auth.get_credentials", return_value=("", "")): with patch("gitea_auth.get_credentials", return_value=("", "")):
self.assertIsNone(gitea_auth.get_auth_header("gitea.example.com")) self.assertIsNone(gitea_auth.get_auth_header("gitea.example.com"))
@@ -230,12 +295,11 @@ class TestNoNetwork(_ConfigBase):
def test_config_load_makes_no_network_calls(self): def test_config_load_makes_no_network_calls(self):
boom = AssertionError("config parsing must not touch the network") boom = AssertionError("config parsing must not touch the network")
with patch.dict(os.environ, self._env("dev"), clear=True): with patch.dict(os.environ, self._env("prgs"), clear=True):
with patch("gitea_auth.urllib.request.urlopen", side_effect=boom): with patch("gitea_auth.urllib.request.urlopen", side_effect=boom):
self.assertIsNotNone(gitea_config.resolve_profile()) self.assertIsNotNone(gitea_config.resolve_profile())
# get_profile only reads config + env; no HTTP.
self.assertEqual( self.assertEqual(
gitea_auth.get_profile()["profile_name"], "gitea-author") gitea_auth.get_profile()["profile_name"], "personal-prgs")
if __name__ == "__main__": if __name__ == "__main__":