diff --git a/.env.example b/.env.example index 522f4e2..5463787 100644 --- a/.env.example +++ b/.env.example @@ -39,9 +39,10 @@ GITEA_AUDIT_LOG=/path/to/gitea-mcp-audit.log # only — never the token value. Surfaced by gitea_get_profile. GITEA_TOKEN_SOURCE=GITEA_TOKEN -# Optional JSON runtime-profile config (roadmap #10). Instead of the fields -# above, point at a JSON file with multiple named profiles and select one. -# See gitea-mcp.example.json. Explicit env vars above still override the -# selected profile's values. Leave unset for pure env-based configuration. -GITEA_MCP_CONFIG=/path/to/gitea-mcp.json -GITEA_MCP_PROFILE=dev +# Optional canonical runtime-profile config (#19). Instead of the fields above, +# point every LLM launcher at ONE JSON file of named profiles and select one. +# Secrets are referenced (keychain id / env var name), never inlined. See +# gitea-mcp.example.json. Explicit env vars above still override the selected +# profile's values. Leave unset for pure env-based configuration. +GITEA_MCP_CONFIG=/Users/jasonwalker/.config/gitea-tools/profiles.json +GITEA_MCP_PROFILE=prgs diff --git a/README.md b/README.md index 8dcb451..093fbb5 100644 --- a/README.md +++ b/README.md @@ -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.
diff --git a/gitea-mcp.example.json b/gitea-mcp.example.json index dee02d8..1a9371f 100644 --- a/gitea-mcp.example.json +++ b/gitea-mcp.example.json @@ -1,24 +1,35 @@ { + "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" - }, - "prod-readonly": { + "prgs": { "base_url": "https://gitea.prgs.cc", - "profile_name": "gitea-readonly", - "token_env": "GITEA_TOKEN_PROD", - "owner": "Scaled-Tech-Consulting", - "repo": "Gitea-Tools", - "allowed_operations": ["read"], - "forbidden_operations": ["merge", "approve", "request_changes"], - "audit_label": "prod-ro" + "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": "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" } } } diff --git a/gitea_auth.py b/gitea_auth.py index 4eb6ed6..244e998 100644 --- a/gitea_auth.py +++ b/gitea_auth.py @@ -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:``). """ # 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, } diff --git a/gitea_config.py b/gitea_config.py index fcd06fc..0478cca 100644 --- a/gitea_config.py +++ b/gitea_config.py @@ -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:``. + 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") diff --git a/tests/test_config.py b/tests/test_config.py index 8721c88..30dc6e3 100644 --- a/tests/test_config.py +++ b/tests/test_config.py @@ -1,9 +1,10 @@ -"""Tests for JSON runtime-profile configuration (gitea_config) and its -integration into gitea_auth.get_profile / get_auth_header. +"""Tests for canonical JSON runtime-profile configuration (gitea_config) and +its integration into gitea_auth.get_profile / get_auth_header. -Covers: env-only still works, JSON selection, multiple profiles, env-override -precedence, missing file, invalid JSON, missing/unset profile, inline-token -rejection + redaction, and that config parsing performs no network calls. +Covers: legacy env-only, JSON profile config, profile selection, multiple +profiles, missing profile, invalid JSON, env-override precedence, keychain and +env auth-reference parsing/resolution, token/password redaction in errors, and +no network calls during config load. """ import os import sys @@ -18,32 +19,30 @@ import gitea_config # noqa: E402 import gitea_auth # noqa: E402 CONFIG = { + "version": 1, "profiles": { - "dev": { - "base_url": "https://gitea.dev.example", - "profile_name": "gitea-author", - "token_env": "GITEA_TOKEN_DEV", - "owner": "Org", - "repo": "Repo", - "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", }, - "prod-readonly": { - "base_url": "https://gitea.prod.example", - "profile_name": "gitea-readonly", - "token_env": "GITEA_TOKEN_PROD", - "allowed_operations": ["read"], - "audit_label": "prod-ro", + "mdcps-env": { + "base_url": "https://gitea.dadeschools.net", + "username": "913443", + "auth": {"type": "env", "name": "GITEA_TOKEN_MDCPS"}, + "default_owner": "Contractor", + "execution_profile": "mdcps", }, - } + }, } class _ConfigBase(unittest.TestCase): def setUp(self): 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) def tearDown(self): @@ -51,59 +50,55 @@ class _ConfigBase(unittest.TestCase): def _write(self, obj): with open(self.path, "w", encoding="utf-8") as fh: - if isinstance(obj, str): - fh.write(obj) - else: - json.dump(obj, fh) + fh.write(obj if isinstance(obj, str) else json.dumps(obj)) - def _env(self, profile="dev", **extra): + def _env(self, profile="prgs", **extra): env = {"GITEA_MCP_CONFIG": self.path, "GITEA_MCP_PROFILE": profile} env.update(extra) return env # --------------------------------------------------------------------------- -# gitea_config: loading / selection +# Loading / selection # --------------------------------------------------------------------------- class TestLoadSelect(_ConfigBase): - def test_env_only_returns_none(self): - # No GITEA_MCP_CONFIG -> JSON layer off, no error. + def test_legacy_env_only_returns_none(self): with patch.dict(os.environ, {}, clear=True): self.assertIsNone(gitea_config.load_config()) self.assertIsNone(gitea_config.resolve_profile()) 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() - self.assertEqual(p["profile_name"], "gitea-author") - self.assertEqual(p["base_url"], "https://gitea.dev.example") + self.assertEqual(p["base_url"], "https://gitea.prgs.cc") + self.assertEqual(p["execution_profile"], "personal-prgs") 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() - self.assertEqual(p["profile_name"], "gitea-readonly") - self.assertEqual(p["allowed_operations"], ["read"]) + self.assertEqual(p["username"], "913443") + 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): self.assertIsNone(gitea_config.resolve_profile()) - def test_missing_file_when_set_raises(self): - env = {"GITEA_MCP_CONFIG": self.path + ".nope", "GITEA_MCP_PROFILE": "dev"} + def test_missing_file_set_raises(self): + env = {"GITEA_MCP_CONFIG": self.path + ".nope", "GITEA_MCP_PROFILE": "prgs"} with patch.dict(os.environ, env, clear=True): with self.assertRaises(gitea_config.ConfigError) as ctx: gitea_config.resolve_profile() self.assertIn("missing file", str(ctx.exception)) def test_invalid_json_raises_without_leaking_content(self): - self._write('{"profiles": {"dev": {"token": "super-secret-token" BROKEN}}}') - with patch.dict(os.environ, self._env("dev"), clear=True): + self._write('{"version":1,"profiles":{"prgs":{"auth":{"type":"env" BROKEN') + with patch.dict(os.environ, self._env("prgs"), clear=True): with self.assertRaises(gitea_config.ConfigError) as ctx: gitea_config.resolve_profile() msg = str(ctx.exception) 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): with patch.dict(os.environ, self._env("ghost"), clear=True): @@ -111,7 +106,7 @@ class TestLoadSelect(_ConfigBase): gitea_config.resolve_profile() msg = str(ctx.exception) self.assertIn("ghost", msg) - self.assertIn("dev", msg) # lists available profiles + self.assertIn("prgs", msg) def test_config_set_but_profile_unset_raises(self): 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)) def test_bad_top_level_shape_raises(self): - self._write({"nope": 1}) - with patch.dict(os.environ, self._env("dev"), clear=True): + self._write({"version": 1, "nope": 1}) + 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): gitea_config.resolve_profile() # --------------------------------------------------------------------------- -# Token handling / redaction +# Secret redaction # --------------------------------------------------------------------------- -class TestTokenHandling(_ConfigBase): +class TestRedaction(_ConfigBase): def test_inline_token_rejected_without_echo(self): - self._write({"profiles": {"dev": {"token": "super-secret-token"}}}) - with patch.dict(os.environ, self._env("dev"), clear=True): + self._write({"version": 1, "profiles": {"prgs": {"token": "super-secret-token"}}}) + with patch.dict(os.environ, self._env("prgs"), clear=True): with self.assertRaises(gitea_config.ConfigError) as ctx: gitea_config.resolve_profile() msg = str(ctx.exception) - self.assertIn("token_env", msg) - self.assertNotIn("super-secret-token", msg) # value never echoed + self.assertIn("auth", msg) + self.assertNotIn("super-secret-token", msg) - def test_resolve_token_reads_env_by_name(self): - env = self._env("dev", GITEA_TOKEN_DEV="dev-token-value") + def test_inline_password_rejected_without_echo(self): + 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): p = gitea_config.resolve_profile() - self.assertEqual(gitea_config.resolve_token(p), "dev-token-value") - - 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)) + self.assertNotIn("mdcps-token-value", json.dumps(p)) # --------------------------------------------------------------------------- -# gitea_auth integration: get_profile precedence + get_auth_header fallback +# gitea_auth integration # --------------------------------------------------------------------------- class TestAuthIntegration(_ConfigBase): - def test_env_only_profile_unchanged(self): - # No JSON config: get_profile is exactly the env-only behaviour. + def test_legacy_env_only_profile_unchanged(self): with patch.dict(os.environ, {"GITEA_PROFILE_NAME": "gitea-reviewer", "GITEA_ALLOWED_OPERATIONS": "read,review"}, clear=True): @@ -177,25 +245,21 @@ class TestAuthIntegration(_ConfigBase): self.assertIsNone(p["base_url"]) 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() - self.assertEqual(p["profile_name"], "gitea-author") - self.assertEqual(p["allowed_operations"], ["read", "pr.create"]) - self.assertEqual(p["forbidden_operations"], ["merge"]) - self.assertEqual(p["audit_label"], "dev-author") - self.assertEqual(p["base_url"], "https://gitea.dev.example") - # token_env surfaces as the (non-secret) token *source name*. - self.assertEqual(p["token_source_name"], "GITEA_TOKEN_DEV") + self.assertEqual(p["profile_name"], "personal-prgs") # from execution_profile + self.assertEqual(p["base_url"], "https://gitea.prgs.cc") + self.assertEqual(p["username"], "jcwalker3") + self.assertEqual(p["default_owner"], "Scaled-Tech-Consulting") + self.assertEqual(p["token_source_name"], "keychain:prgs-gitea-token") def test_env_overrides_json(self): - env = self._env("dev", + env = self._env("prgs", GITEA_PROFILE_NAME="env-name", - GITEA_ALLOWED_OPERATIONS="read", GITEA_BASE_URL="https://env.example") with patch.dict(os.environ, env, clear=True): p = gitea_auth.get_profile() self.assertEqual(p["profile_name"], "env-name") - self.assertEqual(p["allowed_operations"], ["read"]) self.assertEqual(p["base_url"], "https://env.example") def test_get_profile_propagates_config_error(self): @@ -203,22 +267,23 @@ class TestAuthIntegration(_ConfigBase): with self.assertRaises(gitea_config.ConfigError): gitea_auth.get_profile() - def test_auth_header_uses_json_token_env(self): - env = self._env("dev", GITEA_TOKEN_DEV="json-token") + def test_auth_header_uses_json_env_token(self): + env = self._env("mdcps-env", GITEA_TOKEN_MDCPS="json-env-token") with patch.dict(os.environ, env, clear=True): 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): - 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): 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): - # A broken selection must not crash auth — it falls back to no token. - env = {"GITEA_MCP_CONFIG": self.path, "GITEA_MCP_PROFILE": "ghost"} - with patch.dict(os.environ, env, clear=True): + def test_auth_header_unresolvable_ref_fails_closed(self): + # env token ref points at an unset var -> ConfigError inside resolve is + # swallowed to "no token"; auth falls through to (mocked-empty) basic. + with patch.dict(os.environ, self._env("mdcps-env"), clear=True): with patch("gitea_auth.get_credentials", return_value=("", "")): 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): 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): self.assertIsNotNone(gitea_config.resolve_profile()) - # get_profile only reads config + env; no HTTP. self.assertEqual( - gitea_auth.get_profile()["profile_name"], "gitea-author") + gitea_auth.get_profile()["profile_name"], "personal-prgs") if __name__ == "__main__":