Commit Graph
16 Commits
Author SHA1 Message Date
sysadminandClaude Opus 4.8 b0868be6b3 fix(runtime): recognize heartbeat interval env and wire production lifecycle tests
Address REQUEST_CHANGES review #652 on PR #976 (issue #975).

Blocker 1: name GITEA_WORKER_HEARTBEAT_INTERVAL_SECONDS individually in
RECOGNIZED_GITEA_ENV_KEYS so the production-consumed operator override is no
longer classified as unsupported-env / runtime_reconnect_required. No prefix
broadening; unknown overrides remain fail-closed.

Blocker 2: add focused production-path tests that call _active_worker_identity
and _start_worker_heartbeat, proving supervisor attachment, identity/session/
generation/pid/epoch agreement, no duplicates, failed/fenced paths, orderly
shutdown, configured interval, and off-loop sqlite heartbeats.

Co-Authored-By: Claude Opus 4.8 (1M context) <[email protected]>
2026-07-29 23:41:37 -04:00
jcwalker3andClaude Opus 4.8 1a38ef95e3 fix(runtime): recognize client identity environment and refresh worker registrations
Two defects left behind by #948 made sanctioned multi-client operation
impossible. They are inseparable: fixing either alone still leaves the
multi-client canary unable to run.

1. Client-identity environment keys were not recognized.

   gitea_mcp_server reads GITEA_MCP_CLIENT, GITEA_MCP_CLIENT_INSTANCE and
   GITEA_MCP_CLIENT_SESSION as the authoritative client-identity inputs for
   worker registration, but none of the three appeared in
   RECOGNIZED_GITEA_ENV_KEYS or matched a recognized prefix. The runtime
   diagnostic scans every peer mcp_server.py process environment and classifies
   any unlisted GITEA_* key as an unsupported override, which is raised as a
   runtime blocker, so gitea_resolve_task_capability returned
   blocker_kind=runtime_reconnect_required with stop_required=true. Because that
   resolver is the mandatory preflight for every author, reviewer and merger
   mutation, setting the very variable #948 requires closed the mutation gate
   for the whole fleet, and reconnecting could not clear it: the variable is
   re-exported from the client's server definition on every launch.

   The three keys are now named individually in the recognized-key set. No
   prefix is added, so an unrecognized GITEA_* override is still refused
   exactly as before.

   A related inconsistency in the same path is also fixed. The diagnostic
   reasons are raised as one RuntimeError, but the preflight re-raise
   recognized only "stale-runtime:", so an "unsupported-env:" reason was
   silently swallowed there while still failing the resolver. Both reason
   families now live in RUNTIME_DIAGNOSTIC_HARD_PREFIXES beside the function
   that produces them, and both propagate identically. This only widens what is
   refused, never what is permitted.

2. WorkerRegistry.heartbeat() had no production caller.

   #948 delivered heartbeat() but only tests called it. The single production
   writer registers once per process behind an attempted-once flag, and
   register() stamps the same timestamp into both started_at and
   last_heartbeat_at. Nothing advanced it afterwards: no lifespan hook, no
   background task, no atexit handler in a process that blocks in mcp.run().
   Since liveness is age against heartbeat_ttl_seconds, that TTL was not a
   liveness window at all but a hard cap on how long any client could stay
   attached; at 900 seconds a healthy, connected, client-managed process became
   session_ownership=unowned with blocker_kind=session_attachment_missing.

   WorkerHeartbeatSupervisor in mcp_worker_identity is the missing caller,
   started from _active_worker_identity() at the moment register() succeeds,
   because that is the only point where identity and fencing_epoch are both
   known. It is a daemon thread rather than an asyncio task or a request-driven
   refresh because renewal must survive an idle session, and because the
   registry performs blocking BEGIN IMMEDIATE sqlite writes that must not run on
   the server's event loop. daemon=True is deliberate: a hard kill takes the
   thread with it, so a dead worker still goes stale on the normal TTL.

   heartbeat_interval_for() returns one third of the TTL, hard-capped at one
   half, so two consecutive beats can be lost without the row expiring and no
   override can produce an interval that outlives the registration it renews.
   heartbeat() gains optional keyword-only expectations (session, generation,
   client name, pid); each supplied one must match the recorded row or the
   renewal is refused with the existing BLOCKER_FENCED literal rather than a new
   blocker_kind, since consumers switch on that value. Omitting them preserves
   the pre-existing behavior exactly. Client names are compared normalized, so
   several namespaces of one application stay one client while separate
   applications stay distinct.

   A terminal refusal stops the supervisor permanently and records why, so a
   fenced session can never beat its way back into ownership. A transient
   failure is counted and beating continues. An atexit hook stops it on orderly
   shutdown. status() is surfaced read-only as worker_heartbeat on
   gitea_get_runtime_context so a stopped heartbeat is diagnosable before the
   TTL turns it into session_attachment_missing; it grants nothing.

   claim_generation() still has no production caller. It bumps fencing_epoch,
   which would fence the supervisor's cached epoch, and the strict refusal is
   left in place deliberately: auto-re-adopting a bumped epoch would defeat
   fencing.

No lock or lease TTL is changed, including the author issue-lock TTL, and no
mutation refused today becomes permitted.

Tests: tests/test_issue_975_client_identity_heartbeat.py adds 40 focused tests
covering all 13 acceptance criteria. Every TTL assertion uses an injected
clock; no test waits for a real TTL. The thread-loop tests use a
millisecond-scale interval with bounded polling.

Focused: 40 passed, 15 subtests passed.
Full suite from inside the branches worktree: 28 failed, 6105 passed, 6 skipped,
1105 subtests — an identical failure set to the 324a0c8a baseline measured in a
sibling branches worktree (28 failed, 6065 passed, 1090 subtests). Zero new
failures; the delta is exactly the added tests.

Closes #975

Co-Authored-By: Claude Opus 4.8 (1M context) <[email protected]>
2026-07-29 20:06:10 -05:00
sysadmin 6a53308473 fix(runtime): support validated cross-repository canonical roots (#973) 2026-07-29 13:19:29 -04:00
sysadmin dfe8d7c28d fix(mcp): detect and reject manually launched duplicate MCP role servers (Closes #686) 2026-07-25 19:25:23 -04:00
sysadminandClaude Opus 4.8 61c3a57df5 fix(mcp): consume canonical target root in cross-repository operations (Closes #741)
#706 introduced the immutable `canonical_repository_root` and #739/#740 routed
three consumption paths through it. The paths #740 left behind all shared one
shape: the *filesystem* guards had been migrated to the canonical root, but
*repository identity* still bottomed out in `_local_git_remote_url`, which ran
`git remote get-url` with `cwd=PROJECT_ROOT` — always the Gitea-Tools install
checkout. The two halves of a single assessment therefore described two
different repositories.

For a namespace bound to another repository this inverts the guards rather than
merely weakening them: an operation naming the genuinely bound target is
rejected, while one naming Gitea-Tools is accepted.

Central helper
--------------
Adds `_canonical_local_git_root()` as the single place a target-repository root
is resolved: the configured canonical root when one is declared, else
`PROJECT_ROOT`. A configured-but-invalid root is never silently replaced by the
install checkout. Single-repository behaviour is byte-for-byte unchanged.

Defects fixed
-------------
* `_local_git_remote_url` now runs in the canonical target root, which corrects
  every downstream identity consumer at once (`_resolve`, the #530 remote/repo
  guard, the anti-stomp org/repo fill, `_workspace_repository_slug`).
* `_verify_role_mutation_workspace` omitted `configured_canonical_root`, so the
  #274 branches-only / worktree-membership guards validated
  `Gitea-Tools/branches/` instead of the bound target. It now threads the root
  exactly as `_resolve_namespace_mutation_context` does.
* `_resolve` derived omitted coordinates by looking a remote up *by name*. A
  target checkout commonly names its remote `origin` rather than `prgs`, so the
  lookup returned None and the coordinates fell through to the remote-wide
  default target — an unrelated repository. It now prefers
  `_canonical_repository_slug`, which probes candidate remote names.
* Explicit `org`/`repo` short-circuit the #530 match check, so caller
  coordinates bypassed validation entirely. They may now only *confirm* a
  canonical binding, never override it, and fail closed in both directions.
* Reconciler ancestry, fetch, worktree-inventory and cleanup call sites, the
  author worktree derivation in `gitea_lock_issue`/`gitea_create_pr`, and the
  `control_clean` porcelain probe now use the canonical target root.
* `gitea_config`: v2-`environments` silently *dropped* `canonical_repository_root`
  during flattening, so such a namespace fell back to the install root — a
  fail-open. It is now validated and propagated. The v1 path validates it too,
  so all three loaders behave identically.

Preserved as installation-scoped
--------------------------------
Server parity (`master_parity_gate`), workflow/schema/skill loading, self-code
hashing and stale-runtime detection, and `mirror_refs.sh` lookup remain anchored
to `PROJECT_ROOT`. The `server_implementation` vs `target_repository` parity
dimensions from #740 stay separately labelled, and only the server dimension
gates mutations.

Tests
-----
`tests/test_issue_741_canonical_root_consumers.py` (51 tests, 52 subtests) drives
a role-by-operation matrix over author/reviewer/merger/reconciler against:
correct cross-repository target, wrong repository, wrong worktree, explicit
matching and mismatched coordinates, missing/invalid canonical root, immutable
first-bind, and request-override attempts — plus both cross-repository
directions and all three config loaders. Real git repositories, no network, no
branch deletion, no merge.

The module reinstalls the genuine `_local_git_remote_url` per test: an autouse
conftest fixture permanently reassigns it to a working-directory-independent
stub, which is precisely the behaviour under test.

Full suite: 3408 passed, 6 skipped, 2 failed. Both failures
(`test_issue_702_review_findings_f1_f6`, `test_reconciler_supersession_close`)
reproduce identically on clean master c908ed6050 and are pre-existing.

Co-Authored-By: Claude Opus 4.8 (1M context) <[email protected]>
Claude-Session: https://claude.ai/code/session_01AYGdWAwuA6UNDc9c3CGipU
2026-07-18 12:02:31 -04:00
sysadminandClaude Opus 4.8 0d8a2c2b1d feat(mcp): immutable canonical_repository_root for cross-repo namespaces (Closes #706)
The MCP server derived the canonical repository root from the install
checkout the server script lives in (PROJECT_ROOT), so any namespace that
ran the server against an external repository (e.g. eagenda-author, or the
mcp-control-plane reviewer/merger namespaces) failed every mutation: the
branches-only / worktree-membership guards (#274) compared the task
workspace against the Gitea-Tools .git directory it can never belong to.

Separate the two concepts:
- PROJECT_ROOT stays the immutable code/install root.
- A new, optional, namespace-scoped canonical_repository_root binds the
  session to the working root of the target repository.

Implementation:
- canonical_repository_root.py: resolve the binding from profile/env
  (GITEA_CANONICAL_REPOSITORY_ROOT overrides the profile field), validate
  existence + git identity, fail closed on missing/conflicting/forged
  bindings. Preserves the single-repo default when unconfigured.
- session_context_binding.py: pin canonical_repository_root into the
  immutable #714 session context; drift fails closed.
- namespace_workspace_binding.py: route the configured target root through
  resolve_namespace_mutation_context so #274 / membership guards evaluate
  against the target repository.
- gitea_mcp_server.py: seed + enforce the binding in the mutation preflight
  (both purity-order and FORCE_PRODUCTION_GUARDS paths); validate repository
  identity and git common-directory membership.
- gitea_config.py: validate the optional absolute-path profile field.
- gitea_auth.py: surface the config-sourced field through get_profile.

No weakening of root-checkout, remote/repository, or anti-stomp guards; the
single-repo Gitea-Tools path is unchanged.

Tests: two distinct real git repositories/worktrees prove cross-repository
bindings resolve, enforce membership, and fail closed on forged/conflicting
identity and simultaneous prgs/mdcps isolation.

Co-Authored-By: Claude Opus 4.8 (1M context) <[email protected]>
2026-07-17 23:31:39 -04:00
sysadminandClaude Opus 4.8 943d40270e fix(session): bind workspace-verified repository scope at first bind (#714)
The independent review reproduced a real hole: the production first-bind path
never pinned repository/org, so the drift checks it feeds were skipped.

Root cause
----------
gitea_whoami, gitea_get_runtime_context, gitea_resolve_task_capability and
gitea_activate_profile all seeded the session context without repository or
org. First-bind-wins then made the mutation gate's later seed a no-op, and
assess_session_context only compares repository/org when the bound fields are
non-null. Result, reproduced in production order with the real REMOTES:

  after whoami:               repository=None org=None
  same-host repo=Other-Tools  -> NOT BLOCKED
  same-host org=Other-Org     -> NOT BLOCKED
  cross-host                  -> blocked (this part always worked)

Binding REMOTES defaults would not fix it: REMOTES['prgs'].repo is 'Timesheet'
(a default *target*, not an authorization scope, see #530), so pinning it fails
closed on every legitimate Gitea-Tools mutation. There was no trusted source
for repository scope, so this adds one.

Design
------
- New optional profile field `allowed_repositories`: canonical owner/repository
  slugs. An authorization boundary, never the binding itself. Config-only —
  no env var can widen or forge it.
- The session repository is derived from the verified, workspace-aligned git
  remote, never from caller-supplied org/repo, and validated against that
  allowlist. The session binds immutably to exactly one canonical slug; org is
  derived from it. A profile authorizing several repositories still binds to
  the single verified one.
- Every first-bind entry point now pins the same complete context. Activation
  rejects an unauthorized/unverifiable workspace. Mutations fail closed when
  repository/org is unverified, and a tool-level org/repo override that
  disagrees with the binding is rejected before the write.
- A mutation request can no longer establish, complete, or replace the binding
  (it previously seeded from `org or REMOTES[...]`, i.e. caller-controlled).
- Enforcement is opt-in per profile: a profile without the field keeps prior
  behaviour, so existing static-profile namespaces stay functional.

First-bind-wins, immutability, the RLock, profile isolation, host/identity
validation and the private pytest-only reset are unchanged.

gitea_auth.get_profile() built an explicit whitelist dict and silently dropped
`allowed_repositories`; the new integration tests caught that the scope was
never enforced through the real path.

Tests
-----
New tests/test_issue_714_production_first_bind.py drives the real entry points
in production order rather than constructing _SessionContext directly. Covers
complete first bind via each entry point, same-host repo/org drift, Timesheet
and other-repo/other-org rejection, unverified and unauthorized workspaces,
caller values unable to establish the binding, concurrent init selecting one
repository, and the PR #715 author path staying functional. Mutation-verified:
disabling trusted derivation, override validation, the scope check, or the
get_profile passthrough each fails these tests (9/8/4/5 failures).

No security assertion was weakened, removed, skipped, or rewritten.

Focused: 26 passed. Issue #714 total: 46 passed. Prior failing modules: 240
passed. Config/profile/remote modules: 130 passed. Full suite: 2739 passed,
6 skipped, 1 pre-existing warning, 161 subtests in 25.80s.

Co-Authored-By: Claude Opus 4.8 (1M context) <[email protected]>
2026-07-15 16:50:41 -04:00
sysadmin 75ed0632b4 feat(mcp): implement operation-scoped role selection and automatic dispatch switching (#228) 2026-07-06 12:37:30 -04:00
sysadmin 3953b6bdd2 feat: allow gitea.issue.comment for author and reviewer profiles (#137)
- Add aliases 'issue.comment' and 'issue_comment' to GITEA_OPERATION_ALIASES so short names normalize to gitea.issue.comment
- Grant 'issue.comment' (and short) in v2 example profiles for example-author and example-reviewer
- Grant in gitea-mcp.example.json mdcps-reviewer
- Update docs example profiles
- Add test coverage for new aliases and normalization

This enables gitea_create_issue_comment and list for profiles that include it, while preserving fail-closed for others and separation of duties (issue.comment does not imply pr.review etc).

Closes #137
2026-07-05 02:10:18 -04:00
sysadmin a0e7d3360e feat: implement profile activation and runtime identity clarity (#131) 2026-07-04 02:04:07 -04:00
sysadminandClaude Fable 5 e0861bcb03 feat: operation-name normalization table with fail-closed enforcement (#106)
Promote the #103 minimal alias map to the documented public table
GITEA_OPERATION_ALIASES and add the #106 enforcement layer:

- normalize_operation(op, service): canonical namespaced names; legacy
  spellings accepted only via the explicit table; unknown, ambiguous,
  and cross-service names fail closed.
- check_operation(op, allowed, forbidden, service): normalizes BOTH the
  requested operation and the profile lists before any membership
  check; forbidden always overrides allowed; unnormalizable allowed
  entries grant nothing and unnormalizable forbidden entries deny the
  request, so normalization can never silently widen permissions;
  empty/missing allowed list denies everything.
- gitea_check_pr_eligibility now routes its capability check through
  check_operation, fixing the mismatch where canonical namespaced
  profile ops (gitea.pr.merge) never matched the raw action (merge)
  and namespaced forbidden entries were never enforced.
- Document the normalization table and enforcement rules in
  docs/gitea-execution-profiles.md, replacing the stale 'enforcement
  out of scope' caveat.
- tests/test_op_normalization.py: full #106 matrix (27 tests) —
  qualified/legacy allowed and forbidden, unknown, ambiguous, service
  mismatch, forbidden-overrides-allowed, empty/missing allowed,
  duplicates after normalization, no permission widening, and
  eligibility integration proving normalization happens before
  enforcement. Existing v1/env unqualified behaviour stays compatible.

Closes #106

Co-Authored-By: Claude Fable 5 <[email protected]>
2026-07-03 03:35:03 -04:00
sysadminandClaude Fable 5 ff920a6496 feat: load profiles.json v2 contexts shape with enabled enforcement and LLM-safe output (#120)
Support the canonical contexts-shape version 2 config (contexts / profiles /
projects / rules) alongside the existing environments shape and v1:

- Require a boolean 'enabled' on every context, profile, service, and
  project. Disabled entries are surfaced in audits but fail closed at
  selection/resolution — never a silent fallback to another profile,
  service, or credential source.
- Resolve the active identity from GITEA_MCP_PROFILE via the existing
  select_profile path; profile base_url falls back to the context's enabled
  gitea block.
- Add resolve_service() and project_for_path() for context service and
  project-to-context resolution (internal use; fail closed on disabled).
- get_auth_header now propagates ConfigError when GITEA_MCP_CONFIG is set
  instead of silently degrading to Basic auth.
- Hide endpoint URLs and keychain ids from normal LLM-facing output:
  gitea_whoami / gitea_get_profile report logical names and auth status
  only; new gitea_audit_config tool reports enabled/disabled state and safe
  one-line service summaries. The GITEA_MCP_REVEAL_ENDPOINTS opt-in (and
  'python3 gitea_config.py audit --reveal-endpoints' locally) restores
  endpoints and auth source names for admin diagnostics; token values are
  never printed on any path.
- Ship gitea-mcp.v2-contexts.example.json (synthetic values) and validate
  it in tests.

Implements #120

Co-Authored-By: Claude Fable 5 <[email protected]>
2026-07-03 02:19:39 -04:00
sysadminandClaude Fable 5 65ea7514d2 feat: profiles.json v2 parser with validation invariants (#103)
Add version-2 support to gitea_config: environment -> service -> identity
hierarchy flattened at load into v1-shaped profiles keyed by the canonical
dotted address {env}.{service}.{identity}, with aliases for legacy names
(mdcps, prgs-author, prgs-reviewer) and service-level defaults inherited by
identities.

Fail-closed validation: missing required version (v1 files must now declare
version: 1), unknown versions, malformed environment/service/identity
structure, dotted segment names, missing base_url, missing auth reference,
inline secrets in identities or auth entries, alias/address selector
conflicts, aliases to unknown targets, and unqualified operations that
cannot be normalized safely. TBD-* usernames fail closed at selection
without blocking other identities in the file.

Reviewer-identity deadlock rule enforced at load: any identity allowed
gitea.pr.approve or gitea.pr.merge must forbid gitea.pr.create and
gitea.branch.push (prevents the PR #102-style self-authored-PR deadlock).

Selector resolution is strict: exact alias -> exact dotted address -> fail
closed; no fuzzy matching. Minimal operation normalization only (the known
v1 unqualified Gitea ops and single-word non-Gitea ops); the full table and
enforcement matrix remain issue #106.

Tests: new tests/test_config_v2.py (29 cases) covering the acceptance
criteria; test_config.py missing-version case flipped to fail-closed per
the issue. resolve_token/auth_source_name proven against flattened v2
profiles.

Refs #100. Closes #103.

Co-Authored-By: Claude Fable 5 <[email protected]>
2026-07-02 18:49:30 -04:00
sysadminandClaude Opus 4.8 835fbbf324 feat: interactive setup menu for canonical Gitea MCP profiles (#31)
Add an interactive utility so users create/edit/validate canonical runtime
profiles and generate safe LLM launcher snippets without hand-editing JSON or
pasting tokens into Claude/Gemini/Codex configs.

Run: `python gitea_config.py menu` (or `python gitea_config_menu.py`).

gitea_config.py — pure, testable authoring helpers:
- is_valid_profile_name, build_profile, keychain_auth/env_auth, empty_config
- validate_config (reports missing base_url/auth, inline token/password — never
  echoing the secret value)
- add_profile (preserves existing, rejects dup/invalid name/missing base_url),
  upsert_profile, remove_profile
- save_config: mkdir parents + atomic temp-then-os.replace, pretty JSON
- launcher_entry: thin MCP entry (command/args + GITEA_MCP_CONFIG/PROFILE only)
- keychain_set: store a token via `security add-generic-password` (token passed
  as an arg, never returned/printed/logged; injectable runner)
- `menu` __main__ dispatch

gitea_config_menu.py — interactive loop with fully injectable IO/secret/HTTP/
keychain so it is testable without a real terminal, keychain, or network:
- list / add / edit / remove / validate profiles
- test authentication + show authenticated user (calls /user only on request)
- reviewer-eligibility helper (authenticated user vs PR author, open state) —
  read-only, never approves/merges
- launcher snippets for Claude / Gemini / Codex (no secrets)

Security: tokens are never written to profiles.json, launcher snippets, logs,
or errors — only keychain ids / env var names are stored. Backwards compatible:
menu is optional; env-only mode and MCP server startup are unchanged.

Tests: tests/test_config_menu.py (21 cases) — name validation, preserve-on-add,
dup/invalid/missing-field rejection, atomic write (+ replace-failure leaves the
original intact, no temp debris), keychain_set stores-without-printing, launcher
snippets secret-free, eligibility eligible/self-author/closed, and a full menu
add→list→quit flow proving the token value never reaches disk or stdout.

Stacked on #30 (canonical profiles); base branch feat/json-runtime-profiles.
Refs #10, #19. Closes #31.

Co-Authored-By: Claude Opus 4.8 (1M context) <[email protected]>
2026-07-01 23:32:24 -04:00
sysadminandClaude Opus 4.8 b88ca0c929 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) <[email protected]>
2026-07-01 23:04:03 -04:00
sysadminandClaude Opus 4.8 3aaba73127 feat: JSON multi-profile runtime config for Gitea MCP (roadmap #10)
Let one MCP server select among named Gitea runtime profiles from a JSON file
instead of editing code or juggling many .env files:

    GITEA_MCP_CONFIG=/path/to/gitea-mcp.json
    GITEA_MCP_PROFILE=dev

- New gitea_config.py: load/validate the JSON, select the named profile, and
  resolve its token by env-var reference. Profiles supply base_url,
  profile_name, token_env, owner/repo, allowed/forbidden operations, and audit
  label.
- gitea_auth.get_profile() now overlays env over the selected JSON profile:
  explicit env vars win, the JSON profile fills only what env leaves unset.
- gitea_auth.get_auth_header() gains a JSON token_env fallback after explicit
  env tokens (env still wins).

Security / safety:
- Tokens are referenced by env-var NAME (token_env); an inline "token" is
  rejected and never echoed. The value is never stored in or returned as
  profile metadata.
- Fail-safe errors: missing file / invalid JSON / unknown or unset selected
  profile raise a clear ConfigError that never prints file contents or tokens
  (JSONDecodeError context is suppressed so the raw file text can't surface).
- No network calls during config parsing.
- Real config files are gitignored (gitea-mcp*.json), example kept.

Backwards compatible: with GITEA_MCP_CONFIG unset, behaviour is exactly the
prior env-only behaviour (all existing get_profile/get_auth_header tests pass
unchanged).

Docs: README JSON-profiles section + env table rows, .env.example placeholders,
gitea-mcp.example.json.
Tests: tests/test_config.py (22 cases) — env-only, selection, multiple
profiles, env-override precedence, missing file, invalid JSON, missing/unset
profile, inline-token rejection + redaction, and no-network-during-parse.

Refs #10. Note: issue #19 (env-based profiles) was already implemented and
closed; this JSON-file capability is adjacent new scope tracked under the
roadmap rather than reopening #19.

Co-Authored-By: Claude Opus 4.8 (1M context) <[email protected]>
2026-07-01 22:44:31 -04:00