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
+9 -5
View File
@@ -320,7 +320,9 @@ def get_profile():
Returns:
dict with 'profile_name', 'allowed_operations' (list),
'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
# on a misconfigured file/profile so the problem surfaces clearly at startup.
@@ -336,9 +338,9 @@ def get_profile():
val = jp.get(key)
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")
or jp.get("profile_name") or "gitea-default")
or jp.get("execution_profile") or "gitea-default")
name = str(name).strip() or "gitea-default"
ops = _env_csv("GITEA_ALLOWED_OPERATIONS")
@@ -350,9 +352,9 @@ def get_profile():
audit_label = (os.environ.get("GITEA_AUDIT_LABEL") or "").strip() \
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() \
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
return {
"profile_name": name,
@@ -361,4 +363,6 @@ def get_profile():
"audit_label": audit_label,
"token_source_name": token_source,
"base_url": base_url,
"username": jp.get("username") or None,
"default_owner": jp.get("default_owner") or None,
}