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
+51 -23
View File
@@ -190,40 +190,68 @@ Notes:
is off by default and never adds API calls or breaks the action when off.
See [`gitea_audit.py`](gitea_audit.py).
**JSON runtime profiles (roadmap #10).** Instead of juggling many `.env` files,
one server can pick a named profile from a JSON file (see
[`gitea-mcp.example.json`](gitea-mcp.example.json), loaded by
[`gitea_config.py`](gitea_config.py)):
**Canonical runtime profiles (#19).** Define every Gitea profile **once**, in a
canonical JSON file, and keep each LLM launcher (Claude / Gemini / Codex) a
*thin* pointer at it — no duplicated `GITEA_USER_*` / `GITEA_PASS_*` blocks and
no raw tokens in client configs. See [`gitea-mcp.example.json`](gitea-mcp.example.json),
loaded by [`gitea_config.py`](gitea_config.py).
```bash
export GITEA_MCP_CONFIG=/path/to/gitea-mcp.json
export GITEA_MCP_PROFILE=dev
```
Canonical profile file (e.g. `~/.config/gitea-tools/profiles.json`):
```json
{
"version": 1,
"profiles": {
"dev": {
"base_url": "https://gitea.dev.example",
"profile_name": "gitea-author",
"token_env": "GITEA_TOKEN_DEV",
"allowed_operations": ["read", "pr.create"],
"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"
},
"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
(`token_env`); an inline `token` is rejected. The value is read from that env
var and never stored in or returned as profile metadata.
- **Precedence:** explicit env vars (`GITEA_PROFILE_NAME`, `GITEA_BASE_URL`,
`GITEA_ALLOWED_OPERATIONS`, `GITEA_TOKEN`, …) **override** the JSON profile;
the JSON profile only fills what the environment leaves unset.
Thin LLM launcher (Claude / Gemini / Codex) — only two env vars, no secrets:
```json
"gitea-tools": {
"command": "/Users/jasonwalker/Development/Gitea-Tools/venv/bin/python3",
"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
is exactly env-only. A missing file, invalid JSON, or unknown/unset selected
profile raises a clear startup error that never prints file contents or
tokens. Parsing makes no network calls.
is exactly the legacy env-only mode. A missing file, invalid JSON, unsupported
`version`, unknown/unset selected profile, or unresolvable secret reference
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>