Compare commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
f34ec86b90 | ||
|
|
f65069d22d | ||
|
|
9617b64a77 | ||
|
|
c780ded653 | ||
|
|
67e4a2b5e9 | ||
|
|
ba7915452e | ||
|
|
293808b42d | ||
|
|
970e68bddb | ||
|
|
78cc37a977 |
@@ -39,6 +39,27 @@ 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 self-hosted Sentry observability (#606) ────────────────────────
|
||||
# Emits runtime errors, fail-closed workflow blockers, lease/terminal-lock/
|
||||
# stale-runtime collisions, and watchdog cron check-ins to a SELF-HOSTED Sentry
|
||||
# (https://sentry.prgs.cc/) — never Sentry Cloud. Gitea stays the source of
|
||||
# truth; Sentry is observe-only. OFF by default: with MCP_SENTRY_ENABLED unset
|
||||
# or SENTRY_DSN empty, nothing is initialised and no events are sent.
|
||||
#
|
||||
# Master gate. Truthy = 1/true/yes/on. Both this AND SENTRY_DSN are required.
|
||||
MCP_SENTRY_ENABLED=0
|
||||
# DSN for the self-hosted project (create a `gitea-tools-mcp` project in
|
||||
# https://sentry.prgs.cc/ and copy its DSN). Never commit a real DSN.
|
||||
SENTRY_DSN=
|
||||
# Deployment environment tag (local/dev/prod). Defaults to "development".
|
||||
SENTRY_ENVIRONMENT=development
|
||||
# Optional release identifier (e.g. a git SHA or version string).
|
||||
SENTRY_RELEASE=
|
||||
# Performance-trace sample rate, 0.0–1.0 (clamped). Default 0.0 (traces off).
|
||||
MCP_SENTRY_TRACES_SAMPLE_RATE=0.0
|
||||
# Set to 1 to forward Python logs to Sentry as structured logs. Default off.
|
||||
MCP_SENTRY_ENABLE_LOGS=0
|
||||
|
||||
# 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
|
||||
|
||||
@@ -0,0 +1,198 @@
|
||||
# ADR: Stable control runtime vs dev runtime (Gitea MCP)
|
||||
|
||||
- **Status:** Accepted (policy effective immediately for LLM sessions; tooling may lag)
|
||||
- **Date:** 2026-07-09
|
||||
- **Tracking issue:** [#615](https://gitea.prgs.cc/Scaled-Tech-Consulting/Gitea-Tools/issues/615)
|
||||
- **Related:**
|
||||
- [#543](https://gitea.prgs.cc/Scaled-Tech-Consulting/Gitea-Tools/issues/543) / `docs/mcp-namespace-health.md` — client-namespace health
|
||||
- `docs/mcp-namespace-eof-recovery.md` — reconnect-only EOF recovery (no PID kill)
|
||||
- [#558](https://gitea.prgs.cc/Scaled-Tech-Consulting/Gitea-Tools/issues/558) / `docs/mcp-daemon-import-guard.md` — sanctioned daemon
|
||||
- [#557](https://gitea.prgs.cc/Scaled-Tech-Consulting/Gitea-Tools/issues/557) / `docs/bootstrap-review-path.md` — controller bootstrap for self-hosted fixes
|
||||
- Allocator / control-plane ADR: `docs/architecture/mcp-allocator-control-plane-observability-adr.md` (#613 / PR #614)
|
||||
|
||||
## 1. Context
|
||||
|
||||
The Gitea MCP server is the **control plane** for real issue/PR mutations (create, comment, lock, review, merge, etc.). When author/reviewer/merger/reconciler sessions kill or restart that process, relaunch it from a feature worktree, or edit the checkout that process loads, operators observe:
|
||||
|
||||
- Mid-session identity/preflight resets
|
||||
- Stale-runtime vs master parity failures
|
||||
- IDE transport EOF / “tool not found” while code on disk has changed
|
||||
- Accidental production mutations from experimental code
|
||||
|
||||
This ADR separates **stable control runtime** from **dev/test runtime** and defines promotion proof.
|
||||
|
||||
## 2. Decision
|
||||
|
||||
### 2.1 Stable control runtime
|
||||
|
||||
The Gitea MCP server used for **real workflow mutations** is the **stable control runtime**.
|
||||
|
||||
Characteristics:
|
||||
|
||||
- Loads a known, promoted revision of Gitea-Tools (or the packaged release layout operators designate)
|
||||
- Registered in the IDE/client as the production namespaces (`gitea-tools`, `gitea-reviewer`, `gitea-merger`, `gitea-reconciler`, etc.)
|
||||
- Holds production profile credentials via sanctioned keychain/env paths only
|
||||
|
||||
### 2.2 Dev / test runtime
|
||||
|
||||
MCP **server code** development and testing:
|
||||
|
||||
- Happens in isolated **`branches/`** worktrees (or other non-stable checkouts)
|
||||
- May use a **separate** dev/test MCP runtime/process when process-level testing is required
|
||||
- **Must not** be used for real Gitea mutations on production issues/PRs
|
||||
|
||||
### 2.3 Forbidden actions (normal sessions)
|
||||
|
||||
Normal **author, reviewer, merger, and reconciler** LLM sessions **must not**:
|
||||
|
||||
| Forbidden | Why |
|
||||
|-----------|-----|
|
||||
| Kill the running MCP server process | Drops all concurrent sessions; loses preflight state |
|
||||
| Restart / relaunch the MCP server process | Same as kill; causes stale/identity churn mid-workflow |
|
||||
| Relaunch MCP from a development worktree | Runs unpromoted code against production mutations |
|
||||
| Edit files in the stable runtime checkout | Hot-mutates control plane under concurrent users |
|
||||
| Use experimental/dev MCP for real Gitea mutations | Bypasses promotion proof and audit expectations |
|
||||
| Bypass or self-reset a stale master-parity gate | The gate is fail-closed; only an operator reload restores parity |
|
||||
|
||||
**LLM-allowed vs operator-owned (authoritative split):**
|
||||
|
||||
| Actor | May do | Must not do |
|
||||
|-------|--------|-------------|
|
||||
| **LLM session** | Call tools on the already-running stable namespaces; **client reconnect** after transport EOF (no process kill); pass `worktree_path` / role worktree args; report blockers and stop mutations when unhealthy/stale | Kill, restart, or relaunch any MCP process; bump config mtimes to force reload; edit the stable checkout; switch to a dev MCP for production mutations |
|
||||
| **Operator / release-manager** | Supervised restart/reload of the **stable** control runtime; dual-namespace client configuration; §2.4 promotions; incident recovery | — |
|
||||
|
||||
EOF / transport recovery for LLM sessions: **client reconnect only** (see `docs/mcp-namespace-eof-recovery.md`). Do not “fix” health by killing PIDs or bumping MCP config mtimes as a normal session procedure.
|
||||
|
||||
This ADR **supersedes** any older runbook wording that told the LLM to relaunch or restart the client/MCP as a self-service step. Where `docs/llm-workflow-runbooks.md` (or wiki runbooks) discuss dual-namespace setup or workspace rebind, **process restart/relaunch is operator-owned**; the LLM stops, reports, and waits.
|
||||
|
||||
### 2.4 Promotion (operator / release-manager only)
|
||||
|
||||
Promotion of a new revision into the stable control runtime is an **explicit operator/release-manager action**, not an LLM self-service step.
|
||||
|
||||
A promotion **must record** (issue comment, release note, or promotion ledger):
|
||||
|
||||
| Field | Description |
|
||||
|-------|-------------|
|
||||
| **previous runtime SHA** | Commit previously loaded by stable runtime |
|
||||
| **promoted runtime SHA** | Commit after promotion |
|
||||
| **source branch/PR** | Where the change was reviewed |
|
||||
| **restart/reload method** | How the process was cycled (e.g. supervised restart, client reload) |
|
||||
| **health check proof** | Client-namespace probe success (`gitea_whoami` / namespace health) |
|
||||
| **identity/profile proof** | Expected profile(s) and username(s) after reload |
|
||||
| **workspace/root proof** | Stable checkout path / root matches intended layout |
|
||||
| **mutation capability proof** | Required permissions for the target role present; forbidden ops still forbidden |
|
||||
| **rollback instructions** | How to restore previous SHA and re-verify health |
|
||||
|
||||
Suggested durable marker:
|
||||
|
||||
```text
|
||||
## MCP STABLE RUNTIME PROMOTION (#615)
|
||||
|
||||
Status: COMPLETED | ROLLED_BACK | ABORTED
|
||||
Previous-SHA: <full sha>
|
||||
Promoted-SHA: <full sha>
|
||||
Source-PR: <number>
|
||||
Source-Branch: <name>
|
||||
Reload-Method: <text>
|
||||
Health-Proof: client_namespace whoami OK / assess_mcp_namespace_health OK
|
||||
Identity-Proof: profile=<name> user=<name>
|
||||
Workspace-Proof: root=<path>
|
||||
Mutation-Proof: allowed_ops include <…>; forbidden include <…>
|
||||
Rollback: checkout <previous sha>; reload method <…>; re-run health/identity proofs
|
||||
Operator: <username>
|
||||
Timestamp: <ISO-8601>
|
||||
```
|
||||
|
||||
### 2.5 Unhealthy stable runtime → stop work
|
||||
|
||||
If the stable MCP runtime is **unhealthy**, including any of:
|
||||
|
||||
- client-namespace probes fail
|
||||
- wrong identity / wrong profile
|
||||
- wrong workspace root
|
||||
- missing mutation capability for the intended role
|
||||
- persistent EOF after **client reconnect**
|
||||
- **master parity is stale** (`startup_head` behind on-disk `master` / `restart_required` from the parity gate)
|
||||
|
||||
then:
|
||||
|
||||
1. **Normal PR / review / merge / issue-mutation work must stop immediately.**
|
||||
2. The session **reports** the unhealthy/stale state (tool error, CTH, or operator handoff) with startup vs current head when known.
|
||||
3. Do **not** improvise: no LLM process kill/restart, no dev-worktree MCP for production mutations, no env escape hatches, no manual gate bypass.
|
||||
4. Resume only after:
|
||||
- an **operator** restores the runtime (see §2.6 for routine post-merge parity reload), **or**
|
||||
- a **controlled** promotion/rollback completes with the §2.4 promotion record, **or**
|
||||
- a controller invokes the narrow **bootstrap review path** (#557) when the defect is self-hosted and documented,
|
||||
- **and** the session re-verifies health (and master parity when applicable) before the next mutation.
|
||||
|
||||
### 2.6 Routine post-merge master-parity staleness (operator reload, not promotion)
|
||||
|
||||
**Symptom:** After merges land on `master`, a long-lived stable MCP process still runs the pre-merge `startup_head`. The master-parity gate marks the server **stale** / `restart_required` and **blocks mutations**.
|
||||
|
||||
**Sanctioned response (authoritative):**
|
||||
|
||||
| Step | Actor | Action |
|
||||
|------|-------|--------|
|
||||
| 1 | LLM session | Mutations stop immediately when the gate reports stale. |
|
||||
| 2 | LLM session | Report the stale state (startup head, current master head, that operator reload is required). Do not retry mutations. |
|
||||
| 3 | **Operator** | Reload/restart the **stable** control MCP so it loads current `master` (supervised client/daemon reload). |
|
||||
| 4 | LLM session | Resume only after startup/current-head parity is verified (e.g. `gitea_get_runtime_context` / parity assessment shows in parity). |
|
||||
|
||||
**Not a §2.4 promotion:** Catching the already-designated stable control checkout up to a newly advanced `master` is a **routine operator reload** of the stable runtime. It does **not** require the nine-field promotion ledger. Use §2.4 only when **changing which unpromoted/dev revision** becomes the stable control runtime (new source branch/PR into the stable designation).
|
||||
|
||||
**Code note:** `master_parity_gate.py` may still say “restart the server” in machine-facing reason strings. That string names the **operator recovery action**, not an LLM self-service instruction. This ADR and the runbooks define the actor split.
|
||||
|
||||
## 3. Relationship to other controls
|
||||
|
||||
| Doc / mechanism | Interaction |
|
||||
|-----------------|-------------|
|
||||
| Namespace health (#543) | Proves IDE client can call tools; does not authorize restart |
|
||||
| EOF recovery | Reconnect only; no process kill |
|
||||
| Daemon import guard (#558) | Mutations require sanctioned daemon; not a bare shell import |
|
||||
| Bootstrap path (#557) | Only controller-authorized exception when live runtime cannot review its own fix |
|
||||
| Allocator / control-plane ADR | Coordination DB is separate; still depends on a healthy MCP surface for Gitea writes |
|
||||
|
||||
## 4. Consequences
|
||||
|
||||
### Positive
|
||||
|
||||
- Predictable control plane for concurrent LLMs
|
||||
- Clear operator-only promotion gate with rollback
|
||||
- Aligns session behavior with health/EOF docs already landed
|
||||
|
||||
### Costs
|
||||
|
||||
- LLM sessions must wait when runtime is sick (no DIY restart)
|
||||
- Operators must maintain promotion discipline and dual-runtime config if they use a dev MCP
|
||||
|
||||
### Non-goals
|
||||
|
||||
- Does not ban operator-supervised restarts during incidents
|
||||
- Does not replace CI or code review for MCP changes
|
||||
- Does not authorize editing stable checkout “because tests need a quick fix”
|
||||
|
||||
## 5. Implementation follow-ups (optional tooling)
|
||||
|
||||
These may land in later issues; the **policy binds sessions now**:
|
||||
|
||||
1. Session preflight that refuses mutations if workspace root equals a `branches/` feature worktree configured as “dev only.”
|
||||
2. Explicit `runtime_kind=stable|dev` in MCP config and `gitea_whoami` profile metadata.
|
||||
3. Promotion checklist script that emits the durable promotion marker fields.
|
||||
|
||||
**Not optional (issue #615 acceptance criterion 2):** operator guide and runbooks **must** cross-link this ADR (see §6). Cross-links are documentation acceptance, not deferred tooling.
|
||||
|
||||
## 6. Acceptance for this ADR
|
||||
|
||||
1. Document merged under `docs/architecture/mcp-stable-control-runtime-policy-adr.md`.
|
||||
2. **Operator guide / runbooks cross-link this ADR** (`docs/wiki/Operator-Guide.md`, `docs/wiki/Runbooks.md`, `docs/llm-workflow-runbooks.md`).
|
||||
3. Issue #615 references this path.
|
||||
4. LLM/operator runbooks treat kill/restart/relaunch-from-worktree as **LLM violations**; process restart is **operator-owned**.
|
||||
5. Unhealthy runtime (including **stale master parity**) stops normal mutation work until operator restore/reload, promotion/rollback, or #557 bootstrap — then re-verify parity before mutating.
|
||||
6. Routine post-merge parity reload is documented as operator reload (§2.6), not an LLM self-restart and not a full §2.4 promotion.
|
||||
|
||||
## 7. Document history
|
||||
|
||||
| Date | Change |
|
||||
|------|--------|
|
||||
| 2026-07-09 | Initial ADR: stable vs dev runtime, forbidden session actions, promotion proof fields, stop-work rule |
|
||||
| 2026-07-16 | Review 443 remediation (#615 / PR #616): mandatory cross-links; LLM vs operator restart split; routine post-merge parity staleness (§2.6); stale parity in §2.5 unhealthy triggers |
|
||||
@@ -195,7 +195,7 @@ To avoid the bottleneck of relaunching/restarting the MCP server to switch betwe
|
||||
`gitea_reconcile_already_landed_pr` after ancestry proof — not for normal
|
||||
review or author workflows.
|
||||
|
||||
* **Fallback:** If the dual-profile MCP launcher pattern is not supported or configured in the client, the LLM must relaunch or restart the client/MCP with the correct profile environment variable before claiming or working on any tasks.
|
||||
* **Fallback (operator-owned):** If the dual-profile MCP launcher pattern is not supported or configured in the client, **do not** have the LLM relaunch or restart the client/MCP. The LLM **stops** role-switching work, reports that the correct static namespace is missing, and waits for an **operator** to configure dual namespaces or reload the client with the correct `GITEA_MCP_PROFILE` for that role. Process restart/relaunch is operator-owned under the [stable control runtime ADR](architecture/mcp-stable-control-runtime-policy-adr.md) (#615).
|
||||
|
||||
## Setup runbook — interactive menu
|
||||
|
||||
@@ -1200,10 +1200,13 @@ When a mutation blocks on workspace binding:
|
||||
|
||||
1. Read the error — it names the **resolved workspace path**, **role
|
||||
namespace**, and **binding source** (tool arg, env var, or process root).
|
||||
2. Reconnect or relaunch the correct namespace MCP server from the intended
|
||||
workspace (or set the role-specific env var before launch).
|
||||
3. Pass `worktree_path` on reviewer/merger mutation tools when the active
|
||||
branches/ worktree differs from the MCP process root.
|
||||
2. **LLM-allowed:** pass `worktree_path` on reviewer/merger mutation tools when
|
||||
the active `branches/` worktree differs from the MCP process root; **client
|
||||
reconnect** after transport EOF only (no process kill).
|
||||
3. **Operator-owned:** if the wrong namespace process was launched, or a role-
|
||||
specific `GITEA_*_WORKTREE` must be set at process start, an **operator**
|
||||
reloads/relaunches the correct static namespace MCP. LLM sessions must not
|
||||
kill or restart MCP processes (see [stable control runtime ADR](architecture/mcp-stable-control-runtime-policy-adr.md)).
|
||||
4. **Do not** clean, reset, or discard foreign role worktrees to unblock your
|
||||
own namespace — that destroys another agent's WIP.
|
||||
|
||||
@@ -1213,9 +1216,10 @@ When posting a Canonical Thread Handoff after a binding blocker:
|
||||
|
||||
- State which namespace was active (author / reviewer / merger / reconciler).
|
||||
- Quote the resolved workspace path and binding source from the error.
|
||||
- Name the safe reconnect action (relaunch MCP from `branches/...`, set
|
||||
`GITEA_*_WORKTREE`, or pass `worktree_path`).
|
||||
- Explicitly note that foreign worktrees must not be cleaned to unblock.
|
||||
- Name the safe next action: pass `worktree_path` if that unblocks the tool, or
|
||||
request an **operator** reload of the correct namespace MCP / env binding.
|
||||
- Explicitly note that foreign worktrees must not be cleaned to unblock, and
|
||||
that the LLM must not self-restart the MCP process.
|
||||
|
||||
## Safety notes
|
||||
|
||||
@@ -1226,6 +1230,7 @@ When posting a Canonical Thread Handoff after a binding blocker:
|
||||
|
||||
## Related documents
|
||||
|
||||
- [`architecture/mcp-stable-control-runtime-policy-adr.md`](architecture/mcp-stable-control-runtime-policy-adr.md) — stable control runtime vs dev runtime; LLM must not kill/restart MCP; operator-owned reload and promotions; routine post-merge parity staleness (#615).
|
||||
- [`reviewer-handoff-consistency.md`](reviewer-handoff-consistency.md) — reject contradictory reviewer handoffs (#501).
|
||||
- [`issue-acceptance-gate.md`](issue-acceptance-gate.md) — controller issue-acceptance audit after PR merge (#500).
|
||||
- [`../skills/llm-project-workflow/SKILL.md`](../skills/llm-project-workflow/SKILL.md) — portable cross-project LLM workflow skill.
|
||||
|
||||
@@ -0,0 +1,138 @@
|
||||
# Self-hosted Sentry observability for the Gitea MCP server (#606)
|
||||
|
||||
Optional, **off-by-default** instrumentation that reports MCP runtime errors,
|
||||
fail-closed workflow blockers, lease / terminal-lock / stale-runtime
|
||||
collisions, and recurring watchdog check-ins to a **self-hosted** Sentry at
|
||||
`https://sentry.prgs.cc/`.
|
||||
|
||||
> **Gitea remains the source of truth.** Sentry is observe-only. It never
|
||||
> approves, merges, closes, or otherwise mutates Gitea workflow state, and it
|
||||
> never bypasses leases, #332, workflow roles, or the MCP gates. Sentry alerts
|
||||
> may only feed the *sanctioned* Gitea issue/comment path via the #612 incident
|
||||
> bridge — never a direct write.
|
||||
|
||||
Implemented by [`sentry_observability.py`](../../sentry_observability.py).
|
||||
|
||||
---
|
||||
|
||||
## 1. Create the Sentry project
|
||||
|
||||
1. Sign in to the self-hosted Sentry at **`https://sentry.prgs.cc/`** (this is
|
||||
**not** Sentry Cloud — do not use `*.ingest.sentry.io`).
|
||||
2. Create a new **Python** project named **`gitea-tools-mcp`**.
|
||||
3. Open **Settings → Projects → gitea-tools-mcp → Client Keys (DSN)** and copy
|
||||
the DSN. It looks like `https://<publickey>@sentry.prgs.cc/<project-id>`.
|
||||
4. **Never commit the DSN.** It is a runtime secret supplied via env var only.
|
||||
|
||||
## 2. Configure the environment
|
||||
|
||||
All configuration is env-var driven (see [`.env.example`](../../.env.example)):
|
||||
|
||||
| Variable | Purpose | Default |
|
||||
|----------|---------|---------|
|
||||
| `MCP_SENTRY_ENABLED` | Master gate (`1/true/yes/on`). Required. | off |
|
||||
| `SENTRY_DSN` | Self-hosted DSN. Required. | *(empty)* |
|
||||
| `SENTRY_ENVIRONMENT` | `local` / `dev` / `prod` tag. | `development` |
|
||||
| `SENTRY_RELEASE` | Release id (git SHA or version). | *(none)* |
|
||||
| `MCP_SENTRY_TRACES_SAMPLE_RATE` | Perf-trace sample rate `0.0–1.0` (clamped). | `0.0` |
|
||||
| `MCP_SENTRY_ENABLE_LOGS` | Forward Python logs as structured logs. | off |
|
||||
|
||||
**The feature stays completely off unless `MCP_SENTRY_ENABLED` is truthy *and*
|
||||
`SENTRY_DSN` is non-empty.** With either missing, `init_sentry()` is a no-op,
|
||||
the SDK is never initialised, and no events are sent — existing tool behaviour
|
||||
and API-call patterns are unchanged.
|
||||
|
||||
### Per-environment examples
|
||||
|
||||
```bash
|
||||
# local (quiet: capture errors/blockers, no traces)
|
||||
export MCP_SENTRY_ENABLED=1
|
||||
export SENTRY_DSN="https://<key>@sentry.prgs.cc/<id>"
|
||||
export SENTRY_ENVIRONMENT=local
|
||||
|
||||
# dev (light tracing + logs)
|
||||
export MCP_SENTRY_ENABLED=1
|
||||
export SENTRY_DSN="https://<key>@sentry.prgs.cc/<id>"
|
||||
export SENTRY_ENVIRONMENT=dev
|
||||
export MCP_SENTRY_TRACES_SAMPLE_RATE=0.2
|
||||
export MCP_SENTRY_ENABLE_LOGS=1
|
||||
|
||||
# prod (errors/blockers + low-rate tracing, release-tagged)
|
||||
export MCP_SENTRY_ENABLED=1
|
||||
export SENTRY_DSN="https://<key>@sentry.prgs.cc/<id>"
|
||||
export SENTRY_ENVIRONMENT=prod
|
||||
export SENTRY_RELEASE="$(git rev-parse --short HEAD)"
|
||||
export MCP_SENTRY_TRACES_SAMPLE_RATE=0.05
|
||||
```
|
||||
|
||||
The optional SDK is pinned in [`requirements.txt`](../../requirements.txt)
|
||||
(`sentry-sdk==2.20.0`). It is imported lazily: if the package is absent, the
|
||||
module still imports and every entry point is a safe no-op.
|
||||
|
||||
## 3. What is instrumented
|
||||
|
||||
| Signal | Where | Notes |
|
||||
|--------|-------|-------|
|
||||
| Startup init | `gitea_mcp_server.py` `__main__`, before `mcp.run` | Prints a redaction-safe status line to stderr. |
|
||||
| Failing mutations (exceptions) | `_audited(...)` context manager | `capture_exception` with scrubbed tags. |
|
||||
| Fail-closed blockers / failed mutations | `_audit_pr_result(...)` (BLOCKED/FAILED) | Structured `capture_workflow_blocker` event incl. the canonical next action when available (criterion 7). |
|
||||
| Allocator watchdog check-ins | `gitea_allocate_next_work` tool | `allocator_health`, `stale_lease_scan`, `terminal_lock_scan`. |
|
||||
| Namespace-health check-in | `gitea_assess_mcp_namespace_health` tool | `namespace_health`. |
|
||||
|
||||
All capture paths are **best-effort / fail open**: a Sentry outage or capture
|
||||
error never breaks an MCP tool success path.
|
||||
|
||||
## 4. Cron / watchdog monitors
|
||||
|
||||
`sentry_observability.MONITOR_SLUGS` defines stable check-in slugs:
|
||||
|
||||
| Registry key | Sentry monitor slug | Wired at |
|
||||
|--------------|--------------------|----------|
|
||||
| `stale_lease_scan` | `gitea-mcp-stale-lease-scan` | allocator run (global lease expiry) |
|
||||
| `terminal_lock_scan` | `gitea-mcp-terminal-lock-scan` | allocator run (terminal-lock lookup) |
|
||||
| `allocator_health` | `gitea-mcp-allocator-health` | allocator run |
|
||||
| `namespace_health` | `gitea-mcp-namespace-health` | namespace-health probe |
|
||||
| `dashboard_freshness` | `gitea-mcp-dashboard-freshness` | call `monitor_checkin("dashboard_freshness", ...)` from the dashboard refresh job (#605) |
|
||||
| `reconciler_cleanup` | `gitea-mcp-reconciler-cleanup` | call `monitor_checkin("reconciler_cleanup", ...)` from the reconciler cleanup entrypoint |
|
||||
|
||||
Create matching Cron monitors in Sentry with those slugs. Emit an
|
||||
`in_progress` check-in at job start and `ok`/`error` at completion via
|
||||
`sentry_observability.monitor_checkin(slug_key, status)`.
|
||||
|
||||
## 5. Redaction guarantees (fail closed)
|
||||
|
||||
Redaction fails *closed*: if a field cannot be proven safe it is dropped rather
|
||||
than sent. The `before_send` (and `before_send_log`) hook `scrub_event`
|
||||
recursively redacts every outgoing event; on any error it drops the event
|
||||
entirely. Guarantees, proven by `tests/test_sentry_observability.py`:
|
||||
|
||||
- **No** tokens, passwords, keychain IDs, DSNs, cookies, or `user:pass@host`.
|
||||
- **No** raw session-state or full prompt/comment bodies — `session_id` is only
|
||||
ever surfaced as a 12-char `session_id_hash`.
|
||||
- **No** private config contents or raw credential headers.
|
||||
- **No** full local filesystem paths — a worktree path collapses to a coarse
|
||||
`worktree_category` (`author` / `reviewer` / `merger` / `reconciler` /
|
||||
`branches` / `root` / `other`).
|
||||
- Only the allowlisted tag keys in `ALLOWED_TAG_KEYS` are ever attached.
|
||||
|
||||
## 6. Coexistence with GlitchTip / the #612 incident bridge
|
||||
|
||||
This is the **outbound** path (MCP → Sentry SDK). It complements — it does not
|
||||
replace — the **inbound** [`incident_bridge.py`](../../incident_bridge.py)
|
||||
(#612), which turns Sentry/GlitchTip *observations* into durable Gitea issues
|
||||
and `incident_links` rows.
|
||||
|
||||
- Prefer **one** observability path per environment. Point the MCP server's
|
||||
`SENTRY_DSN` at the same self-hosted `gitea-tools-mcp` project that the #612
|
||||
bridge reconciles from, so an MCP-reported error and its Gitea issue line up.
|
||||
- GlitchTip is Sentry-protocol compatible; if an existing GlitchTip DSN is in
|
||||
use, either migrate it to `https://sentry.prgs.cc/` or document the split
|
||||
(MCP → Sentry, legacy → GlitchTip) explicitly for operators.
|
||||
- The bridge remains the **only** sanctioned route from an alert back into
|
||||
Gitea workflow state.
|
||||
|
||||
## 7. Non-goals
|
||||
|
||||
- Sentry must **not** become the workflow source of truth.
|
||||
- Sentry must **not** approve, merge, close, or mutate Gitea workflow state.
|
||||
- Sentry must **not** bypass leases, #332, workflow roles, or the MCP gates.
|
||||
@@ -14,6 +14,7 @@ Handbook for LLM operators and human developers using the Gitea-Tools MCP server
|
||||
4. **No self-review / no self-merge** — The authenticated Gitea user must not approve or merge a PR they authored.
|
||||
5. **Follow the gates** — Prompts express intent; MCP tools enforce safety. Never bypass gates via prompt instructions.
|
||||
6. **Global LLM Worktree Rule** — Main checkout stays on `master`/`main`/`dev`; all mutations happen under `branches/`. Prove project root, `cwd`, branch, stable main-checkout branch, and session worktree path before editing. No exceptions.
|
||||
7. **Stable control runtime** — Real Gitea mutations use only the **stable** MCP control runtime. LLM sessions must not kill, restart, or relaunch MCP processes, edit the stable checkout, or use a dev MCP for production mutations. See the policy ADR: [mcp-stable-control-runtime-policy-adr.md](../architecture/mcp-stable-control-runtime-policy-adr.md) (#615).
|
||||
|
||||
## Supported Gitea instances
|
||||
|
||||
@@ -29,4 +30,5 @@ Always pass `remote` explicitly on tool calls. The server default is `dadeschool
|
||||
1. `gitea_whoami` — confirm authenticated user and profile.
|
||||
2. `gitea_get_runtime_context` — allowed/forbidden operations for this session.
|
||||
3. `gitea_resolve_task_capability` — prove the session may perform the planned task.
|
||||
4. For reviewer work: dry-run validation (`gitea_dry_run_pr_review`) before live review mutations.
|
||||
4. For reviewer work: dry-run validation (`gitea_dry_run_pr_review`) before live review mutations.
|
||||
5. Confirm master parity / runtime health when tools report stale or unhealthy control runtime — stop mutations and request an **operator** reload of the stable MCP (see [stable control runtime ADR](../architecture/mcp-stable-control-runtime-policy-adr.md)).
|
||||
@@ -12,6 +12,17 @@
|
||||
4. `gitea_mark_final_review_decision` → approve via `gitea_review_pr`.
|
||||
5. `gitea_merge_pr` with pinned head SHA and `confirmation="MERGE PR <n>"`.
|
||||
|
||||
## Stable MCP control runtime (#615)
|
||||
|
||||
Policy ADR: [mcp-stable-control-runtime-policy-adr.md](../architecture/mcp-stable-control-runtime-policy-adr.md).
|
||||
|
||||
| Situation | Who acts | What to do |
|
||||
|-----------|----------|------------|
|
||||
| Transport EOF / missing tools | LLM | **Client reconnect only** — do not kill PIDs |
|
||||
| Wrong profile / dual-namespace missing | Operator | Configure or reload the correct static namespace(s) |
|
||||
| Master parity stale after merge | LLM stops + reports; **operator** reloads stable MCP | Resume only after parity re-verified |
|
||||
| Promote unpromoted MCP code to stable | Operator / release-manager only | Full §2.4 promotion record |
|
||||
|
||||
## Gitea Wiki sync
|
||||
|
||||
The Gitea Wiki mirrors `docs/wiki/` (source of truth). After merging wiki changes:
|
||||
|
||||
+100
-46
@@ -1104,6 +1104,7 @@ import allocator_service # noqa: E402
|
||||
import control_plane_db # noqa: E402
|
||||
import lease_lifecycle # noqa: E402
|
||||
import incident_bridge # noqa: E402
|
||||
import sentry_observability # noqa: E402 (#606 optional Sentry observability)
|
||||
import agent_temp_artifacts
|
||||
import issue_lock_worktree # noqa: E402
|
||||
import issue_lock_provenance # noqa: E402
|
||||
@@ -2005,6 +2006,18 @@ def _audited(action: str, *, host, remote, org=None, repo=None,
|
||||
result=gitea_audit.FAILED, reason=_redact(str(exc)),
|
||||
request_metadata=request_metadata, issue_number=issue_number,
|
||||
pr_number=pr_number, target_branch=target_branch)
|
||||
# #606: best-effort Sentry capture of the failing mutation (fail open).
|
||||
sentry_observability.capture_exception(
|
||||
exc,
|
||||
tags={
|
||||
"mutation_tool": action,
|
||||
"remote": remote,
|
||||
"repo": repo,
|
||||
"org": org,
|
||||
"issue_number": issue_number,
|
||||
"pr_number": pr_number,
|
||||
},
|
||||
)
|
||||
raise
|
||||
_audit(action, host=host, remote=remote, org=org, repo=repo,
|
||||
result=gitea_audit.SUCCEEDED, request_metadata=request_metadata,
|
||||
@@ -2051,6 +2064,20 @@ def _audit_pr_result(action: str):
|
||||
"merge_method": result.get("merge_method"),
|
||||
},
|
||||
)
|
||||
# #606: surface fail-closed blockers / failed mutations to
|
||||
# Sentry as structured events (best-effort, fail open).
|
||||
if status in (gitea_audit.BLOCKED, gitea_audit.FAILED):
|
||||
sentry_observability.capture_workflow_blocker(
|
||||
action,
|
||||
message="; ".join(reasons) or action,
|
||||
next_action=result.get("safe_next_action"),
|
||||
level="error" if status == gitea_audit.FAILED else "warning",
|
||||
tags={
|
||||
"mutation_tool": action,
|
||||
"pr_number": result.get("pr_number"),
|
||||
"current_head_sha": result.get("head_sha"),
|
||||
},
|
||||
)
|
||||
except Exception:
|
||||
pass # best-effort; never break the tool
|
||||
return result
|
||||
@@ -12730,6 +12757,11 @@ def gitea_assess_mcp_namespace_health(
|
||||
probe_source=probe_source,
|
||||
)
|
||||
_record_live_namespace_health(result)
|
||||
# #606: namespace-health watchdog check-in (best-effort, fail open).
|
||||
sentry_observability.monitor_checkin(
|
||||
"namespace_health",
|
||||
"ok" if result.get("healthy", result.get("callable", True)) else "error",
|
||||
)
|
||||
return result
|
||||
|
||||
|
||||
@@ -13659,36 +13691,18 @@ def gitea_route_task_session(
|
||||
)
|
||||
|
||||
|
||||
_restart_triggered = False
|
||||
|
||||
|
||||
def _trigger_mcp_auto_restart():
|
||||
global _restart_triggered
|
||||
if _restart_triggered or _preflight_in_test_mode():
|
||||
return
|
||||
_restart_triggered = True
|
||||
|
||||
config_path = os.environ.get(
|
||||
"MCP_CONFIG_PATH",
|
||||
os.path.expanduser("~/.gemini/config/mcp_config.json")
|
||||
)
|
||||
|
||||
try:
|
||||
if os.path.exists(config_path):
|
||||
os.utime(config_path, None)
|
||||
except Exception:
|
||||
pass
|
||||
|
||||
import threading
|
||||
import time
|
||||
def delayed_exit():
|
||||
time.sleep(1.0)
|
||||
os._exit(0)
|
||||
threading.Thread(target=delayed_exit, daemon=True).start()
|
||||
# #685: resolver stale-runtime detection is report-only. Config touch / os._exit
|
||||
# self-recovery was removed from the read-only path (was _trigger_mcp_auto_restart).
|
||||
# Recovery is owned exclusively by the IDE/client reconnect path.
|
||||
|
||||
|
||||
def _check_mcp_runtimes_diagnostics(task: str, matching_profiles: list[str]) -> list[str]:
|
||||
"""Check running runtimes and return errors if they are missing or stale."""
|
||||
"""Read-only: report missing or stale MCP runtimes (no config or process mutation).
|
||||
|
||||
#685: Never touches MCP client config, never spawns recovery threads, never
|
||||
calls ``os._exit``. Stale detection remains fail-closed via returned reasons
|
||||
only; the IDE/client owns reconnect/reload.
|
||||
"""
|
||||
import subprocess
|
||||
import re
|
||||
from datetime import datetime
|
||||
@@ -13768,11 +13782,13 @@ def _check_mcp_runtimes_diagnostics(task: str, matching_profiles: list[str]) ->
|
||||
}
|
||||
|
||||
if self_stale:
|
||||
_trigger_mcp_auto_restart()
|
||||
# #685: report-only — no config utime, no thread, no os._exit.
|
||||
reasons.append(
|
||||
"stale-runtime: The active Gitea MCP server process is stale (running code from before changes were merged). "
|
||||
"Auto-restart has been triggered: touched mcp_config.json to reload the daemon. "
|
||||
"The current process will cleanly exit shortly."
|
||||
"stale-runtime: The active Gitea MCP server process is stale "
|
||||
"(running code from before changes were merged). "
|
||||
"Reconnect the IDE/client-managed MCP namespace for this profile "
|
||||
"so it reloads current master. The resolver does not touch "
|
||||
"mcp_config.json, spawn recovery threads, or terminate this process."
|
||||
)
|
||||
|
||||
if matching_profiles:
|
||||
@@ -13804,10 +13820,15 @@ def gitea_resolve_task_capability(
|
||||
remote: str = "dadeschools",
|
||||
host: str | None = None,
|
||||
) -> dict:
|
||||
"""Read-only: Resolve which capability, profile, and namespace is required for a Gitea task.
|
||||
"""Read-only / side-effect free: resolve capability, profile, and namespace for a task.
|
||||
|
||||
Helps the client or LLM determine the correct namespace or profile before acting,
|
||||
and returns exact next action instructions if the current session is not authorized.
|
||||
Does **not** mutate MCP client configuration, spawn recovery threads, kill
|
||||
processes, or trigger daemon reloads (#685). Stale-runtime detection remains
|
||||
fail-closed: when the serving process is stale the result includes
|
||||
``blocker_kind=runtime_reconnect_required``, ``restart_required=true``,
|
||||
``stop_required=true``, and ``mutation_performed=false`` with a precise
|
||||
``exact_safe_next_action`` pointing at IDE/client reconnect. Recovery is
|
||||
owned by the client reconnect path — never by this resolver.
|
||||
|
||||
Args:
|
||||
task: The task/action to check (e.g. review_pr, create_issue).
|
||||
@@ -13992,31 +14013,32 @@ def gitea_resolve_task_capability(
|
||||
|
||||
configured = len(matching_profiles) > 0
|
||||
available_in_session = allowed_in_current_session
|
||||
runtime_stale_blocker = False
|
||||
|
||||
if "PYTEST_CURRENT_TEST" not in os.environ or "GITEA_FORCE_MCP_RUNTIME_CHECK" in os.environ:
|
||||
runtime_reasons = _check_mcp_runtimes_diagnostics(task, matching_profiles)
|
||||
if runtime_reasons:
|
||||
restart_required = True
|
||||
runtime_stale_blocker = True
|
||||
reason_msg = "; ".join(runtime_reasons)
|
||||
next_safe_action = (
|
||||
"stale-runtime: Gitea MCP runtime conflict or missing process detected. "
|
||||
"Please fully restart the Gitea MCP server and retry."
|
||||
)
|
||||
|
||||
if not allowed_in_current_session:
|
||||
if configured and switching:
|
||||
restart_required = True
|
||||
available_in_session = False
|
||||
reason_msg = (
|
||||
f"{required_role.capitalize()} profile exists but MCP server "
|
||||
"was added after session startup and is not attached."
|
||||
)
|
||||
if not reason_msg:
|
||||
reason_msg = (
|
||||
f"{required_role.capitalize()} profile exists but MCP server "
|
||||
"was added after session startup and is not attached."
|
||||
)
|
||||
elif not configured:
|
||||
reason_msg = (
|
||||
f"No profile configured with permission '{required_permission}'."
|
||||
)
|
||||
if not reason_msg:
|
||||
reason_msg = (
|
||||
f"No profile configured with permission '{required_permission}'."
|
||||
)
|
||||
elif role_mismatch_reason:
|
||||
reason_msg = role_mismatch_reason
|
||||
if not reason_msg:
|
||||
reason_msg = role_mismatch_reason
|
||||
different_namespace_required = False
|
||||
next_safe_action = "None; ready for operations."
|
||||
|
||||
@@ -14042,6 +14064,16 @@ def gitea_resolve_task_capability(
|
||||
"or use the corresponding MCP namespace."
|
||||
)
|
||||
|
||||
# #685: stale-runtime typed remediation wins for exact_next_action when the
|
||||
# serving process/profile inventory is stale — even if permission is OK.
|
||||
if runtime_stale_blocker:
|
||||
next_safe_action = (
|
||||
"blocker_kind=runtime_reconnect_required: reconnect/restart the "
|
||||
"IDE-managed Gitea MCP server for this profile so it reloads current "
|
||||
"master. Do not edit mcp_config.json by hand; the resolver does not "
|
||||
"touch config, spawn recovery threads, or terminate the process."
|
||||
)
|
||||
|
||||
# Task/role alignment guards (#167): the requested task, not the
|
||||
# available credential, decides what the session may do. A review/merge
|
||||
# task under a non-reviewer profile must stop — not silently degrade
|
||||
@@ -14103,12 +14135,16 @@ def gitea_resolve_task_capability(
|
||||
"configured": configured,
|
||||
"restart_required": restart_required,
|
||||
"stop_required": stop_required or restart_required,
|
||||
# #685: resolver is always side-effect free; never claims mutations.
|
||||
"mutation_performed": False,
|
||||
"task_role_guidance": task_role_guidance,
|
||||
"matching_configured_profile": matching_profiles,
|
||||
"runtime_switching_supported": switching,
|
||||
"different_mcp_namespace_required": different_namespace_required,
|
||||
"exact_safe_next_action": next_safe_action,
|
||||
}
|
||||
if runtime_stale_blocker:
|
||||
result["blocker_kind"] = "runtime_reconnect_required"
|
||||
if reason_msg:
|
||||
result["reason"] = reason_msg
|
||||
if task in ("review_pr", "merge_pr"):
|
||||
@@ -14660,6 +14696,18 @@ def gitea_allocate_next_work(
|
||||
result["inventory_source"] = (
|
||||
"candidates_json" if candidates_json else "gitea_live"
|
||||
)
|
||||
# #606: watchdog check-ins for the recurring jobs this allocator run
|
||||
# performs — global stale-lease expiry, terminal-lock lookup, and the
|
||||
# allocator itself. Best-effort; a failed selection reports "error".
|
||||
_alloc_ok = bool(result.get("success"))
|
||||
sentry_observability.monitor_checkin(
|
||||
"allocator_health", "ok" if _alloc_ok else "error"
|
||||
)
|
||||
if _alloc_ok:
|
||||
# These two scans complete inside allocate_next_work before selection;
|
||||
# a successful result proves both ran.
|
||||
sentry_observability.monitor_checkin("stale_lease_scan", "ok")
|
||||
sentry_observability.monitor_checkin("terminal_lock_scan", "ok")
|
||||
return result
|
||||
|
||||
|
||||
@@ -15221,4 +15269,10 @@ if __name__ == "__main__":
|
||||
# processes (e.g. review_pr.py) can detect and refuse profile
|
||||
# side-channel overrides (#199).
|
||||
_export_session_profile_lock()
|
||||
# #606: optional self-hosted Sentry observability. No-op unless
|
||||
# MCP_SENTRY_ENABLED is truthy and SENTRY_DSN is set; never blocks startup.
|
||||
_sentry_status = sentry_observability.init_sentry()
|
||||
sys.stderr.write(
|
||||
f"--- Sentry observability: {_sentry_status.get('reason')} ---\n"
|
||||
)
|
||||
mcp.run(transport="stdio")
|
||||
|
||||
@@ -31,6 +31,7 @@ python-multipart==0.0.32
|
||||
referencing==0.37.0
|
||||
rich==15.0.0
|
||||
rpds-py==2026.5.1
|
||||
sentry-sdk==2.20.0
|
||||
shellingham==1.5.4
|
||||
sse-starlette==3.4.5
|
||||
starlette==1.3.1
|
||||
|
||||
@@ -0,0 +1,535 @@
|
||||
"""Optional self-hosted Sentry observability for the Gitea MCP server (#606).
|
||||
|
||||
Adds env-var-gated Sentry SDK instrumentation so runtime errors, fail-closed
|
||||
workflow blockers, lease/terminal-lock/stale-runtime collisions, and recurring
|
||||
watchdog check-ins are visible in a *self-hosted* Sentry at
|
||||
``https://sentry.prgs.cc/`` — never Sentry Cloud, and never as the workflow
|
||||
source of truth (Gitea stays canonical).
|
||||
|
||||
Design constraints (mirror ``gitea_audit`` and the #612 incident bridge):
|
||||
|
||||
- **Off by default.** With ``MCP_SENTRY_ENABLED`` false/unset *or* ``SENTRY_DSN``
|
||||
empty, ``init_sentry`` is a no-op and no events are ever sent — existing tool
|
||||
behaviour and API-call patterns are unchanged (acceptance criterion 1).
|
||||
- **Fail *open* for observability.** A Sentry outage, a missing ``sentry_sdk``
|
||||
package, or any capture error must never break an MCP tool success path. Every
|
||||
public entry point swallows its own exceptions.
|
||||
- **Fail *closed* for redaction.** If a field cannot be proven safe it is dropped
|
||||
rather than sent. Tokens, passwords, keychain IDs, DSNs, private config, raw
|
||||
session-state, full prompt bodies, and full filesystem paths never leave here.
|
||||
- **No hard dependency.** ``sentry_sdk`` is imported lazily; the module is fully
|
||||
importable and testable without it installed.
|
||||
|
||||
Sentry is observe-only: it must not approve, merge, close, or otherwise mutate
|
||||
Gitea workflow state, nor bypass leases, #332, or MCP gates. Alerts may only feed
|
||||
the sanctioned Gitea issue/comment path via the #612 incident bridge.
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import hashlib
|
||||
import os
|
||||
import re
|
||||
from dataclasses import dataclass
|
||||
from typing import Any
|
||||
|
||||
# Reuse the most comprehensive existing scrubber so redaction stays consistent
|
||||
# with the #612 incident bridge (tokens, DSNs, cookies, bearer/basic, keychain
|
||||
# ids, session ids, user:pass@host).
|
||||
from incident_bridge import redact_text as _redact_text
|
||||
|
||||
# Second, complementary scrubber: catches bare ``token <value>`` /
|
||||
# ``Bearer <value>`` / ``Basic <value>`` prefixes and raw URLs that the
|
||||
# incident-bridge delimiter patterns miss.
|
||||
from gitea_audit import _redact_str as _redact_prefixes
|
||||
|
||||
# ── Optional SDK (lazy, never a hard dependency) ────────────────────────────
|
||||
try: # pragma: no cover - trivial import guard
|
||||
import sentry_sdk # type: ignore
|
||||
except Exception: # pragma: no cover - absence is a supported state
|
||||
sentry_sdk = None # type: ignore
|
||||
|
||||
|
||||
# ── Env var names (single source of truth) ──────────────────────────────────
|
||||
ENV_ENABLED = "MCP_SENTRY_ENABLED"
|
||||
ENV_DSN = "SENTRY_DSN"
|
||||
ENV_ENVIRONMENT = "SENTRY_ENVIRONMENT"
|
||||
ENV_RELEASE = "SENTRY_RELEASE"
|
||||
ENV_TRACES_SAMPLE_RATE = "MCP_SENTRY_TRACES_SAMPLE_RATE"
|
||||
ENV_ENABLE_LOGS = "MCP_SENTRY_ENABLE_LOGS"
|
||||
|
||||
_TRUTHY = frozenset({"1", "true", "yes", "on"})
|
||||
|
||||
REDACTED = "[REDACTED]"
|
||||
REDACTED_PATH = "[REDACTED_PATH]"
|
||||
|
||||
|
||||
# ── Cron / watchdog monitor slugs (acceptance criterion 6) ──────────────────
|
||||
# Stable slugs for the recurring/watchdog jobs #606 wants check-ins for. The
|
||||
# slug is the durable monitor identity in Sentry; the wiring call sites pass one
|
||||
# of these keys (or an explicit slug) to ``monitor_checkin``.
|
||||
MONITOR_SLUGS: dict[str, str] = {
|
||||
"stale_lease_scan": "gitea-mcp-stale-lease-scan",
|
||||
"terminal_lock_scan": "gitea-mcp-terminal-lock-scan",
|
||||
"allocator_health": "gitea-mcp-allocator-health",
|
||||
"namespace_health": "gitea-mcp-namespace-health",
|
||||
"dashboard_freshness": "gitea-mcp-dashboard-freshness",
|
||||
"reconciler_cleanup": "gitea-mcp-reconciler-cleanup",
|
||||
}
|
||||
|
||||
_CHECKIN_STATUSES = frozenset({"in_progress", "ok", "error"})
|
||||
|
||||
|
||||
# ── Tag allowlist (issue "Suggested Sentry tags/context") ───────────────────
|
||||
# Only these keys are ever attached as Sentry tags. Anything else is dropped so
|
||||
# a caller cannot accidentally leak a sensitive value through a tag.
|
||||
ALLOWED_TAG_KEYS = frozenset({
|
||||
"role",
|
||||
"profile",
|
||||
"namespace",
|
||||
"repo",
|
||||
"org",
|
||||
"issue_number",
|
||||
"pr_number",
|
||||
"blocker_type",
|
||||
"workflow_hash",
|
||||
"session_id_hash", # hash only — never the raw session id
|
||||
"pid",
|
||||
"worktree_category", # category, never the full sensitive path
|
||||
"lease_comment_id",
|
||||
"expected_head_sha",
|
||||
"current_head_sha",
|
||||
"terminal_lock_state",
|
||||
"capability",
|
||||
"mutation_tool",
|
||||
})
|
||||
|
||||
# Absolute-path shapes that must never be sent verbatim (macOS/Linux + temp).
|
||||
_PATH_RE = re.compile(r"(?:/private)?/(?:Users|home|tmp|var|opt|Volumes)/[^\s\"']*")
|
||||
|
||||
# ``extra`` keys whose *full* contents are forbidden by the redaction rules
|
||||
# (raw session-state, full prompt/comment bodies, private config blobs, raw
|
||||
# headers).
|
||||
_FORBIDDEN_EXTRA_KEYS = frozenset({
|
||||
"prompt",
|
||||
"prompt_body",
|
||||
"next_prompt",
|
||||
"body",
|
||||
"raw_body",
|
||||
"session_state",
|
||||
"session_state_contents",
|
||||
"config",
|
||||
"config_contents",
|
||||
"private_config",
|
||||
"headers",
|
||||
"authorization",
|
||||
})
|
||||
|
||||
|
||||
# ── Configuration ───────────────────────────────────────────────────────────
|
||||
@dataclass(frozen=True)
|
||||
class SentryConfig:
|
||||
"""Immutable snapshot of the Sentry env configuration."""
|
||||
|
||||
enabled: bool = False
|
||||
dsn: str | None = None
|
||||
environment: str = "development"
|
||||
release: str | None = None
|
||||
traces_sample_rate: float = 0.0
|
||||
enable_logs: bool = False
|
||||
|
||||
@property
|
||||
def active(self) -> bool:
|
||||
"""True only when the operator both opted in *and* supplied a DSN.
|
||||
|
||||
This is the single gate that keeps the feature off by default: enabling
|
||||
the flag without a DSN (or vice versa) sends nothing.
|
||||
"""
|
||||
return bool(self.enabled and self.dsn)
|
||||
|
||||
def safe_summary(self) -> dict[str, Any]:
|
||||
"""Operator-facing status with **no** DSN value (only presence)."""
|
||||
return {
|
||||
"enabled": self.enabled,
|
||||
"dsn_present": bool(self.dsn),
|
||||
"environment": self.environment,
|
||||
"release": self.release,
|
||||
"traces_sample_rate": self.traces_sample_rate,
|
||||
"enable_logs": self.enable_logs,
|
||||
"active": self.active,
|
||||
}
|
||||
|
||||
|
||||
def _env_bool(name: str, env: dict[str, str]) -> bool:
|
||||
return (env.get(name) or "").strip().lower() in _TRUTHY
|
||||
|
||||
|
||||
def _env_float(name: str, default: float, env: dict[str, str]) -> float:
|
||||
raw = (env.get(name) or "").strip()
|
||||
if not raw:
|
||||
return default
|
||||
try:
|
||||
val = float(raw)
|
||||
except (TypeError, ValueError):
|
||||
return default
|
||||
# Clamp to Sentry's valid [0.0, 1.0] sample-rate range.
|
||||
if val < 0.0:
|
||||
return 0.0
|
||||
if val > 1.0:
|
||||
return 1.0
|
||||
return val
|
||||
|
||||
|
||||
def load_config(env: dict[str, str] | None = None) -> SentryConfig:
|
||||
"""Build a :class:`SentryConfig` from the environment (read at call time)."""
|
||||
env = dict(os.environ if env is None else env)
|
||||
dsn = (env.get(ENV_DSN) or "").strip() or None
|
||||
return SentryConfig(
|
||||
enabled=_env_bool(ENV_ENABLED, env),
|
||||
dsn=dsn,
|
||||
environment=(env.get(ENV_ENVIRONMENT) or "").strip() or "development",
|
||||
release=(env.get(ENV_RELEASE) or "").strip() or None,
|
||||
traces_sample_rate=_env_float(ENV_TRACES_SAMPLE_RATE, 0.0, env),
|
||||
enable_logs=_env_bool(ENV_ENABLE_LOGS, env),
|
||||
)
|
||||
|
||||
|
||||
def sdk_available() -> bool:
|
||||
"""True when the optional ``sentry_sdk`` package is importable."""
|
||||
return sentry_sdk is not None
|
||||
|
||||
|
||||
# ── Redaction (fail closed) ─────────────────────────────────────────────────
|
||||
def sanitize_path(value: Any) -> str:
|
||||
"""Reduce a filesystem path to a non-sensitive *category* token.
|
||||
|
||||
Full local paths must never be sent. We keep only a coarse worktree
|
||||
category derived from the path shape (author/reviewer/merger/reconciler/
|
||||
branches/root/other).
|
||||
"""
|
||||
text = "" if value is None else str(value)
|
||||
low = text.lower()
|
||||
if not text:
|
||||
return "unknown"
|
||||
# Order matters: more specific role markers before the generic "branches".
|
||||
if "reconcile" in low:
|
||||
return "reconciler"
|
||||
if "review" in low:
|
||||
return "reviewer"
|
||||
if "merge" in low or "merger" in low:
|
||||
return "merger"
|
||||
if "author" in low or re.search(r"/branches/(?:feat|fix|docs|chore|issue)", low):
|
||||
return "author"
|
||||
if "/branches/" in low:
|
||||
return "branches"
|
||||
if low.rstrip("/").endswith("gitea-tools"):
|
||||
return "root"
|
||||
return "other"
|
||||
|
||||
|
||||
def redact_value(value: Any) -> Any:
|
||||
"""Recursively redact a JSON-able value: secret text, absolute paths, and
|
||||
known-sensitive dict keys are removed. Fail closed — any error drops the
|
||||
value entirely rather than risk leaking it."""
|
||||
try:
|
||||
if isinstance(value, dict):
|
||||
out: dict[str, Any] = {}
|
||||
for k, v in value.items():
|
||||
key = str(k)
|
||||
low = key.lower()
|
||||
if low in _FORBIDDEN_EXTRA_KEYS or any(
|
||||
s in low
|
||||
for s in ("token", "secret", "password", "cookie", "auth", "dsn", "keychain")
|
||||
):
|
||||
out[key] = REDACTED
|
||||
continue
|
||||
out[key] = redact_value(v)
|
||||
return out
|
||||
if isinstance(value, (list, tuple)):
|
||||
return [redact_value(v) for v in value]
|
||||
if isinstance(value, str):
|
||||
scrubbed = _redact_text(value)
|
||||
scrubbed = _redact_prefixes(scrubbed)
|
||||
scrubbed = _PATH_RE.sub(REDACTED_PATH, scrubbed)
|
||||
return scrubbed
|
||||
return value
|
||||
except Exception:
|
||||
return REDACTED
|
||||
|
||||
|
||||
def hash_session_id(session_id: Any) -> str:
|
||||
"""Short, stable, non-reversible fingerprint of a session id."""
|
||||
digest = hashlib.sha256(str(session_id).encode("utf-8", "replace")).hexdigest()
|
||||
return digest[:12]
|
||||
|
||||
|
||||
def build_tags(**kwargs: Any) -> dict[str, str]:
|
||||
"""Return a scrubbed, allowlisted tag dict.
|
||||
|
||||
``session_id`` is accepted but only ever surfaced as ``session_id_hash``.
|
||||
``worktree_path`` collapses to ``worktree_category``. Any non-allowlisted
|
||||
key, or a value that still contains redacted material after scrubbing, is
|
||||
dropped.
|
||||
"""
|
||||
raw: dict[str, Any] = dict(kwargs)
|
||||
|
||||
# Hash the session id — never emit it raw.
|
||||
session_id = raw.pop("session_id", None)
|
||||
if session_id and "session_id_hash" not in raw:
|
||||
raw["session_id_hash"] = hash_session_id(session_id)
|
||||
|
||||
# A full worktree path collapses to a category tag.
|
||||
wt = raw.pop("worktree_path", None)
|
||||
if wt and "worktree_category" not in raw:
|
||||
raw["worktree_category"] = sanitize_path(wt)
|
||||
|
||||
out: dict[str, str] = {}
|
||||
for key, val in raw.items():
|
||||
if key not in ALLOWED_TAG_KEYS:
|
||||
continue
|
||||
if val is None:
|
||||
continue
|
||||
scrubbed = redact_value(val)
|
||||
text = str(scrubbed)
|
||||
if not text or REDACTED in text or REDACTED_PATH in text:
|
||||
continue
|
||||
if len(text) > 200:
|
||||
text = text[:200] + "…"
|
||||
out[key] = text
|
||||
return out
|
||||
|
||||
|
||||
def scrub_event(event: Any, hint: Any = None) -> dict[str, Any] | None:
|
||||
"""Sentry ``before_send`` / ``before_send_log`` hook.
|
||||
|
||||
Recursively redacts the outgoing event. On *any* failure it returns ``None``
|
||||
so the event is dropped rather than sent unscrubbed (fail closed for
|
||||
redaction).
|
||||
"""
|
||||
try:
|
||||
if not isinstance(event, dict):
|
||||
return None
|
||||
scrubbed = redact_value(event)
|
||||
# Drop server_name if it leaked a hostname/path; PID is kept via tags.
|
||||
scrubbed.pop("server_name", None)
|
||||
return scrubbed
|
||||
except Exception:
|
||||
return None
|
||||
|
||||
|
||||
# ── Event builders (pure, independently testable) ───────────────────────────
|
||||
def build_blocker_event(
|
||||
blocker_type: str,
|
||||
*,
|
||||
message: str | None = None,
|
||||
next_action: str | None = None,
|
||||
level: str = "warning",
|
||||
tags: dict[str, Any] | None = None,
|
||||
extra: dict[str, Any] | None = None,
|
||||
) -> dict[str, Any]:
|
||||
"""Build a redacted, structured Sentry event for a workflow blocker.
|
||||
|
||||
``next_action`` maps the issue's "canonical next action when available"
|
||||
requirement (acceptance criterion 7).
|
||||
"""
|
||||
merged_tags = dict(tags or {})
|
||||
merged_tags.setdefault("blocker_type", blocker_type)
|
||||
safe_tags = build_tags(**merged_tags)
|
||||
|
||||
safe_extra = redact_value(dict(extra or {}))
|
||||
if next_action:
|
||||
# A short canonical next action is allowed (it is not a full prompt).
|
||||
safe_extra["canonical_next_action"] = redact_value(str(next_action)[:500])
|
||||
|
||||
event: dict[str, Any] = {
|
||||
"message": redact_value(message or blocker_type),
|
||||
"level": level if level in ("debug", "info", "warning", "error", "fatal") else "warning",
|
||||
"logger": "gitea-mcp.workflow",
|
||||
"tags": safe_tags,
|
||||
"extra": safe_extra,
|
||||
"fingerprint": ["workflow-blocker", blocker_type],
|
||||
}
|
||||
return event
|
||||
|
||||
|
||||
def build_checkin_payload(
|
||||
monitor: str,
|
||||
status: str,
|
||||
*,
|
||||
check_in_id: str | None = None,
|
||||
duration: float | None = None,
|
||||
) -> dict[str, Any]:
|
||||
"""Build a Sentry cron check-in payload for one of :data:`MONITOR_SLUGS`.
|
||||
|
||||
``monitor`` may be a registry key (e.g. ``"stale_lease_scan"``) or an
|
||||
explicit slug. Raises ``ValueError`` on an unknown status so callers cannot
|
||||
silently send a malformed check-in.
|
||||
"""
|
||||
if status not in _CHECKIN_STATUSES:
|
||||
raise ValueError(
|
||||
f"invalid check-in status {status!r}; expected one of {sorted(_CHECKIN_STATUSES)}"
|
||||
)
|
||||
slug = MONITOR_SLUGS.get(monitor, monitor)
|
||||
payload: dict[str, Any] = {"monitor_slug": slug, "status": status}
|
||||
if check_in_id:
|
||||
payload["check_in_id"] = str(check_in_id)
|
||||
if duration is not None:
|
||||
try:
|
||||
payload["duration"] = float(duration)
|
||||
except (TypeError, ValueError):
|
||||
pass
|
||||
return payload
|
||||
|
||||
|
||||
# ── Runtime init + capture (fail open) ──────────────────────────────────────
|
||||
_STATE: dict[str, Any] = {"initialized": False, "config": None}
|
||||
|
||||
|
||||
def is_initialized() -> bool:
|
||||
return bool(_STATE.get("initialized"))
|
||||
|
||||
|
||||
def active_config() -> SentryConfig | None:
|
||||
return _STATE.get("config")
|
||||
|
||||
|
||||
def reset_for_tests() -> None:
|
||||
"""Clear module init state. Test-only helper (never called in production)."""
|
||||
_STATE["initialized"] = False
|
||||
_STATE["config"] = None
|
||||
|
||||
|
||||
def init_sentry(config: SentryConfig | None = None) -> dict[str, Any]:
|
||||
"""Initialise the Sentry SDK if (and only if) enabled + DSN + SDK present.
|
||||
|
||||
Idempotent and never raises. Returns an operator-safe status dict (no DSN
|
||||
value). Behaviour is unchanged when the feature is off.
|
||||
"""
|
||||
cfg = config or load_config()
|
||||
status: dict[str, Any] = {"initialized": False, **cfg.safe_summary()}
|
||||
try:
|
||||
if not cfg.active:
|
||||
status["reason"] = "disabled (MCP_SENTRY_ENABLED false or SENTRY_DSN empty)"
|
||||
_STATE["config"] = cfg
|
||||
return status
|
||||
if not sdk_available():
|
||||
status["reason"] = "sentry_sdk not installed"
|
||||
_STATE["config"] = cfg
|
||||
return status
|
||||
|
||||
init_kwargs: dict[str, Any] = {
|
||||
"dsn": cfg.dsn,
|
||||
"environment": cfg.environment,
|
||||
"release": cfg.release,
|
||||
"traces_sample_rate": cfg.traces_sample_rate,
|
||||
"before_send": scrub_event,
|
||||
"send_default_pii": False,
|
||||
}
|
||||
if cfg.enable_logs:
|
||||
# sentry-sdk 2.x captures Python logs as structured logs when the
|
||||
# experimental logs feature is enabled; scrub those too.
|
||||
init_kwargs["_experiments"] = {
|
||||
"enable_logs": True,
|
||||
"before_send_log": scrub_event,
|
||||
}
|
||||
sentry_sdk.init(**init_kwargs) # type: ignore[union-attr]
|
||||
_STATE["initialized"] = True
|
||||
_STATE["config"] = cfg
|
||||
status["initialized"] = True
|
||||
status["reason"] = "sentry initialised"
|
||||
except Exception as exc: # fail open: observability must not block startup
|
||||
status["reason"] = f"init failed (ignored): {type(exc).__name__}"
|
||||
_STATE["initialized"] = False
|
||||
return status
|
||||
|
||||
|
||||
def _set_scope_tags(scope: Any, tags: dict[str, str]) -> None:
|
||||
for key, val in tags.items():
|
||||
try:
|
||||
scope.set_tag(key, val)
|
||||
except Exception:
|
||||
pass
|
||||
|
||||
|
||||
def capture_workflow_blocker(
|
||||
blocker_type: str,
|
||||
*,
|
||||
message: str | None = None,
|
||||
next_action: str | None = None,
|
||||
level: str = "warning",
|
||||
tags: dict[str, Any] | None = None,
|
||||
extra: dict[str, Any] | None = None,
|
||||
) -> dict[str, Any]:
|
||||
"""Capture a fail-closed workflow blocker as a structured Sentry event.
|
||||
|
||||
Always returns the redacted event dict (so callers/tests can inspect it),
|
||||
and sends it to Sentry only when initialised. Fail open.
|
||||
"""
|
||||
event = build_blocker_event(
|
||||
blocker_type,
|
||||
message=message,
|
||||
next_action=next_action,
|
||||
level=level,
|
||||
tags=tags,
|
||||
extra=extra,
|
||||
)
|
||||
try:
|
||||
if is_initialized() and sdk_available():
|
||||
sentry_sdk.capture_event(event) # type: ignore[union-attr]
|
||||
except Exception:
|
||||
pass
|
||||
return event
|
||||
|
||||
|
||||
def capture_exception(
|
||||
exc: BaseException,
|
||||
*,
|
||||
tags: dict[str, Any] | None = None,
|
||||
extra: dict[str, Any] | None = None,
|
||||
) -> bool:
|
||||
"""Capture a runtime exception with scrubbed tags. Fail open.
|
||||
|
||||
Returns True only when the event was handed to an initialised SDK.
|
||||
"""
|
||||
try:
|
||||
if not (is_initialized() and sdk_available()):
|
||||
return False
|
||||
safe_tags = build_tags(**(tags or {}))
|
||||
safe_extra = redact_value(dict(extra or {}))
|
||||
with sentry_sdk.push_scope() as scope: # type: ignore[union-attr]
|
||||
_set_scope_tags(scope, safe_tags)
|
||||
for key, val in safe_extra.items():
|
||||
try:
|
||||
scope.set_extra(key, val)
|
||||
except Exception:
|
||||
pass
|
||||
sentry_sdk.capture_exception(exc) # type: ignore[union-attr]
|
||||
return True
|
||||
except Exception:
|
||||
return False
|
||||
|
||||
|
||||
def monitor_checkin(
|
||||
monitor: str,
|
||||
status: str,
|
||||
*,
|
||||
check_in_id: str | None = None,
|
||||
duration: float | None = None,
|
||||
) -> dict[str, Any] | None:
|
||||
"""Send a Sentry cron check-in for a watchdog job. Fail open.
|
||||
|
||||
Returns the payload (for inspection/tests), or ``None`` if the status was
|
||||
invalid. Only transmits when initialised.
|
||||
"""
|
||||
try:
|
||||
payload = build_checkin_payload(
|
||||
monitor, status, check_in_id=check_in_id, duration=duration
|
||||
)
|
||||
except ValueError:
|
||||
return None
|
||||
try:
|
||||
if is_initialized() and sdk_available() and hasattr(sentry_sdk, "capture_checkin"):
|
||||
sentry_sdk.capture_checkin(**payload) # type: ignore[union-attr]
|
||||
except Exception:
|
||||
pass
|
||||
return payload
|
||||
@@ -84,7 +84,7 @@ TASK_CAPABILITY_MAP: dict[str, dict[str, str]] = {
|
||||
},
|
||||
"adopt_merger_pr_lease": {
|
||||
"permission": "gitea.pr.comment",
|
||||
"role": "reviewer",
|
||||
"role": "merger",
|
||||
},
|
||||
# #691: guarded non-owner cleanup of obsolete comment-backed reviewer leases.
|
||||
# Apply path posts lease release + audit comments (gitea.pr.comment).
|
||||
|
||||
@@ -0,0 +1,326 @@
|
||||
"""#685: gitea_resolve_task_capability must be side-effect free.
|
||||
|
||||
Stale-runtime detection remains fail-closed, but the resolver must never:
|
||||
* touch mcp_config.json (or any MCP client config)
|
||||
* spawn recovery threads
|
||||
* call os._exit / terminate the serving process
|
||||
* claim that an auto-restart was triggered
|
||||
|
||||
Recovery is owned by the IDE/client reconnect path only.
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import os
|
||||
import tempfile
|
||||
import threading
|
||||
import unittest
|
||||
from datetime import datetime
|
||||
from pathlib import Path
|
||||
from unittest.mock import MagicMock, patch
|
||||
|
||||
import sys
|
||||
|
||||
ROOT = str(Path(__file__).resolve().parent.parent)
|
||||
if ROOT not in sys.path:
|
||||
sys.path.insert(0, ROOT)
|
||||
|
||||
import gitea_mcp_server as mcp_server
|
||||
|
||||
|
||||
ROLE_PROFILES = (
|
||||
("create_issue", "prgs-author", "author"),
|
||||
("review_pr", "prgs-reviewer", "reviewer"),
|
||||
("merge_pr", "prgs-merger", "merger"),
|
||||
("reconciliation_cleanup", "prgs-reconciler", "reconciler"),
|
||||
)
|
||||
|
||||
|
||||
def _stale_self_ps_mocks(profile: str = "prgs-author"):
|
||||
"""Build subprocess mocks: self PID is stale vs code mtime."""
|
||||
mock_getpid = MagicMock(return_value=12345)
|
||||
mock_exists = MagicMock(return_value=True)
|
||||
code_time = datetime(2026, 7, 8, 14, 0, 0)
|
||||
mock_getmtime = MagicMock(return_value=code_time.timestamp())
|
||||
|
||||
ps_output = (
|
||||
" PID LSTART COMMAND\n"
|
||||
"12345 Wed Jul 8 13:00:00 2026 /path/to/python mcp_server.py\n"
|
||||
)
|
||||
mock_run_ps = MagicMock()
|
||||
mock_run_ps.stdout = ps_output
|
||||
|
||||
mock_run_env = MagicMock()
|
||||
mock_run_env.stdout = f"GITEA_MCP_PROFILE={profile}"
|
||||
|
||||
mock_run_git = MagicMock()
|
||||
mock_run_git.stdout = "SAME"
|
||||
|
||||
def side_effect(args, **kwargs):
|
||||
if args[0] == "ps" and "eww" in args:
|
||||
return mock_run_env
|
||||
if args[0] == "ps":
|
||||
return mock_run_ps
|
||||
if args[0] == "git":
|
||||
return mock_run_git
|
||||
raise ValueError(f"Unexpected subprocess args: {args}")
|
||||
|
||||
mock_run = MagicMock(side_effect=side_effect)
|
||||
return mock_getpid, mock_exists, mock_getmtime, mock_run
|
||||
|
||||
|
||||
class TestIssue685DiagnosticsNoSideEffects(unittest.TestCase):
|
||||
def setUp(self):
|
||||
mcp_server._process_boot_head_sha = None
|
||||
|
||||
def tearDown(self):
|
||||
mcp_server._process_boot_head_sha = None
|
||||
|
||||
@patch.dict(os.environ, {"GITEA_FORCE_MCP_RUNTIME_CHECK": "1"}, clear=False)
|
||||
@patch("subprocess.run")
|
||||
@patch("os.path.getmtime")
|
||||
@patch("os.path.exists")
|
||||
@patch("os.getpid")
|
||||
@patch("os.utime")
|
||||
@patch("threading.Thread")
|
||||
@patch("os._exit")
|
||||
def test_stale_self_does_not_touch_config_or_exit(
|
||||
self,
|
||||
mock_exit,
|
||||
mock_thread,
|
||||
mock_utime,
|
||||
mock_getpid,
|
||||
mock_exists,
|
||||
mock_getmtime,
|
||||
mock_run,
|
||||
):
|
||||
mock_getpid.return_value = 12345
|
||||
mock_exists.return_value = True
|
||||
mock_getmtime.return_value = datetime(2026, 7, 8, 14, 0, 0).timestamp()
|
||||
mock_run.side_effect = _stale_self_ps_mocks("prgs-author")[3].side_effect
|
||||
|
||||
before_threads = threading.active_count()
|
||||
reasons = mcp_server._check_mcp_runtimes_diagnostics(
|
||||
"create_issue", ["prgs-author"]
|
||||
)
|
||||
after_threads = threading.active_count()
|
||||
|
||||
self.assertTrue(
|
||||
any("stale-runtime" in r and "active Gitea MCP server process is stale" in r
|
||||
for r in reasons),
|
||||
reasons,
|
||||
)
|
||||
# Must not claim auto-restart / config touch
|
||||
blob = " ".join(reasons)
|
||||
self.assertNotIn("Auto-restart has been triggered", blob)
|
||||
self.assertNotIn("touched mcp_config", blob)
|
||||
self.assertNotIn("will cleanly exit", blob)
|
||||
|
||||
mock_utime.assert_not_called()
|
||||
mock_thread.assert_not_called()
|
||||
mock_exit.assert_not_called()
|
||||
self.assertEqual(before_threads, after_threads)
|
||||
|
||||
@patch.dict(os.environ, {"GITEA_FORCE_MCP_RUNTIME_CHECK": "1"}, clear=False)
|
||||
@patch("subprocess.run")
|
||||
@patch("os.path.getmtime")
|
||||
@patch("os.path.exists")
|
||||
@patch("os.getpid")
|
||||
@patch("os.utime")
|
||||
def test_repeated_stale_calls_do_not_trigger_restart_loop(
|
||||
self, mock_utime, mock_getpid, mock_exists, mock_getmtime, mock_run
|
||||
):
|
||||
mock_getpid.return_value = 12345
|
||||
mock_exists.return_value = True
|
||||
mock_getmtime.return_value = datetime(2026, 7, 8, 14, 0, 0).timestamp()
|
||||
mock_run.side_effect = _stale_self_ps_mocks("prgs-author")[3].side_effect
|
||||
|
||||
for _ in range(5):
|
||||
reasons = mcp_server._check_mcp_runtimes_diagnostics(
|
||||
"create_issue", ["prgs-author"]
|
||||
)
|
||||
self.assertTrue(any("stale-runtime" in r for r in reasons))
|
||||
|
||||
mock_utime.assert_not_called()
|
||||
|
||||
def test_trigger_mcp_auto_restart_removed(self):
|
||||
"""#685 AC: auto-restart helper is removed (unreachable from read-only)."""
|
||||
self.assertFalse(hasattr(mcp_server, "_trigger_mcp_auto_restart"))
|
||||
self.assertFalse(hasattr(mcp_server, "_restart_triggered"))
|
||||
|
||||
|
||||
class TestIssue685ResolverTypedBlocker(unittest.TestCase):
|
||||
def setUp(self):
|
||||
mcp_server._process_boot_head_sha = None
|
||||
|
||||
def tearDown(self):
|
||||
mcp_server._process_boot_head_sha = None
|
||||
if hasattr(mcp_server, "capability_stop_terminal"):
|
||||
mcp_server.capability_stop_terminal.clear()
|
||||
|
||||
def _resolve_with_stale_runtime(self, task: str, profile_name: str, role: str):
|
||||
allowed = [
|
||||
"gitea.read",
|
||||
"gitea.issue.create",
|
||||
"gitea.issue.comment",
|
||||
"gitea.issue.close",
|
||||
"gitea.branch.create",
|
||||
"gitea.branch.push",
|
||||
"gitea.branch.delete",
|
||||
"gitea.pr.create",
|
||||
"gitea.pr.comment",
|
||||
"gitea.pr.review",
|
||||
"gitea.pr.approve",
|
||||
"gitea.pr.request_changes",
|
||||
"gitea.pr.merge",
|
||||
"gitea.pr.close",
|
||||
"gitea.repo.commit",
|
||||
]
|
||||
profile = {
|
||||
"profile_name": profile_name,
|
||||
"role": role,
|
||||
"allowed_operations": allowed,
|
||||
"forbidden_operations": [],
|
||||
}
|
||||
config = {
|
||||
"profiles": {
|
||||
profile_name: {
|
||||
"role": role,
|
||||
"allowed_operations": allowed,
|
||||
"forbidden_operations": [],
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
mock_getpid, mock_exists, mock_getmtime, mock_run = _stale_self_ps_mocks(
|
||||
profile_name
|
||||
)
|
||||
|
||||
with patch.dict(
|
||||
os.environ,
|
||||
{
|
||||
"GITEA_FORCE_MCP_RUNTIME_CHECK": "1",
|
||||
"GITEA_MCP_PROFILE": profile_name,
|
||||
},
|
||||
clear=False,
|
||||
), patch.object(mcp_server, "get_profile", return_value=profile), patch.object(
|
||||
mcp_server.gitea_config, "load_config", return_value=config
|
||||
), patch.object(
|
||||
mcp_server, "_authenticated_username", return_value="test-user"
|
||||
), patch.object(
|
||||
mcp_server, "_ensure_matching_profile", return_value=None
|
||||
), patch.object(
|
||||
mcp_server, "record_preflight_check", return_value=None
|
||||
), patch.object(
|
||||
mcp_server, "record_mutation_authority", return_value=None
|
||||
), patch.object(
|
||||
mcp_server, "init_review_decision_lock", return_value=None
|
||||
), patch(
|
||||
"subprocess.run", mock_run
|
||||
), patch(
|
||||
"os.path.getmtime", mock_getmtime
|
||||
), patch(
|
||||
"os.path.exists", mock_exists
|
||||
), patch(
|
||||
"os.getpid", mock_getpid
|
||||
), patch(
|
||||
"os.utime"
|
||||
) as mock_utime, patch(
|
||||
"threading.Thread"
|
||||
) as mock_thread, patch(
|
||||
"os._exit"
|
||||
) as mock_exit:
|
||||
result = mcp_server.gitea_resolve_task_capability(task=task, remote="prgs")
|
||||
return result, mock_utime, mock_thread, mock_exit
|
||||
|
||||
def test_stale_returns_typed_blocker_fields(self):
|
||||
result, mock_utime, mock_thread, mock_exit = self._resolve_with_stale_runtime(
|
||||
"create_issue", "prgs-author", "author"
|
||||
)
|
||||
self.assertTrue(result.get("restart_required"), result)
|
||||
self.assertTrue(result.get("stop_required"), result)
|
||||
self.assertEqual(result.get("blocker_kind"), "runtime_reconnect_required")
|
||||
self.assertIs(result.get("mutation_performed"), False)
|
||||
action = result.get("exact_safe_next_action") or ""
|
||||
self.assertIn("reconnect", action.lower())
|
||||
self.assertNotIn("None; ready for operations", action)
|
||||
reason = result.get("reason") or ""
|
||||
self.assertIn("stale-runtime", reason)
|
||||
self.assertNotIn("Auto-restart has been triggered", reason)
|
||||
mock_utime.assert_not_called()
|
||||
mock_thread.assert_not_called()
|
||||
mock_exit.assert_not_called()
|
||||
|
||||
def test_all_four_role_profiles_get_same_side_effect_free_contract(self):
|
||||
for task, profile, role in ROLE_PROFILES:
|
||||
with self.subTest(task=task, profile=profile):
|
||||
# Skip tasks that may be unknown on this branch
|
||||
try:
|
||||
import task_capability_map as tcm
|
||||
|
||||
tcm.required_permission(task)
|
||||
except Exception:
|
||||
self.skipTest(f"task {task} not in capability map")
|
||||
|
||||
result, mock_utime, mock_thread, mock_exit = (
|
||||
self._resolve_with_stale_runtime(task, profile, role)
|
||||
)
|
||||
self.assertTrue(
|
||||
result.get("restart_required") or result.get("stop_required"),
|
||||
result,
|
||||
)
|
||||
self.assertEqual(
|
||||
result.get("blocker_kind"), "runtime_reconnect_required", result
|
||||
)
|
||||
self.assertIs(result.get("mutation_performed"), False, result)
|
||||
mock_utime.assert_not_called()
|
||||
mock_thread.assert_not_called()
|
||||
mock_exit.assert_not_called()
|
||||
|
||||
def test_config_mtime_and_contents_unchanged(self):
|
||||
with tempfile.TemporaryDirectory() as tmp:
|
||||
cfg = os.path.join(tmp, "mcp_config.json")
|
||||
original = '{"servers": {"gitea-author": {}}}'
|
||||
with open(cfg, "w", encoding="utf-8") as fh:
|
||||
fh.write(original)
|
||||
mtime_before = os.path.getmtime(cfg)
|
||||
|
||||
result, mock_utime, mock_thread, mock_exit = self._resolve_with_stale_runtime(
|
||||
"create_issue", "prgs-author", "author"
|
||||
)
|
||||
# Force-path also must not use real utime when diagnostics runs
|
||||
with open(cfg, encoding="utf-8") as fh:
|
||||
after = fh.read()
|
||||
self.assertEqual(after, original)
|
||||
self.assertEqual(os.path.getmtime(cfg), mtime_before)
|
||||
mock_utime.assert_not_called()
|
||||
self.assertTrue(result.get("restart_required"), result)
|
||||
|
||||
|
||||
class TestIssue685MutationGatesStillFailClosed(unittest.TestCase):
|
||||
def test_parity_stale_still_reports_restart_required(self):
|
||||
"""Mutation-facing parity gate remains fail-closed when heads differ."""
|
||||
import master_parity_gate as mpg
|
||||
|
||||
out = mpg.assess_master_parity(
|
||||
{"startup_head": "aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa"},
|
||||
"bbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbb",
|
||||
)
|
||||
self.assertFalse(out.get("in_parity"))
|
||||
self.assertTrue(out.get("restart_required") or out.get("stale"))
|
||||
|
||||
|
||||
class TestIssue685DocstringReadOnlyContract(unittest.TestCase):
|
||||
def test_resolve_docstring_declares_side_effect_free(self):
|
||||
doc = mcp_server.gitea_resolve_task_capability.__doc__ or ""
|
||||
lower = doc.lower()
|
||||
self.assertTrue(
|
||||
"side-effect" in lower or "read-only" in lower or "does not mutate" in lower,
|
||||
doc,
|
||||
)
|
||||
self.assertNotIn("auto-restart", lower)
|
||||
self.assertNotIn("os._exit", lower)
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
unittest.main()
|
||||
@@ -111,21 +111,13 @@ class TestMcpStaleRuntime(unittest.TestCase):
|
||||
reasons = gitea_mcp_server._check_mcp_runtimes_diagnostics("create_issue", ["prgs-author"])
|
||||
self.assertTrue(any("stale-runtime: The active Gitea MCP server process is stale" in r for r in reasons))
|
||||
|
||||
@patch("threading.Thread")
|
||||
@patch("os.utime")
|
||||
@patch("os.path.exists")
|
||||
@patch.dict("os.environ", {"MCP_CONFIG_PATH": "/tmp/mcp_config.json"})
|
||||
def test_auto_restart_trigger_touches_and_spawns(self, mock_exists, mock_utime, mock_thread):
|
||||
mock_exists.return_value = True
|
||||
gitea_mcp_server._restart_triggered = False
|
||||
|
||||
# Ensure we are not skipped in test mode for testing purposes
|
||||
with patch("gitea_mcp_server._preflight_in_test_mode", return_value=False):
|
||||
gitea_mcp_server._trigger_mcp_auto_restart()
|
||||
|
||||
mock_utime.assert_called_once_with("/tmp/mcp_config.json", None)
|
||||
mock_thread.assert_called_once()
|
||||
self.assertTrue(gitea_mcp_server._restart_triggered)
|
||||
def test_auto_restart_helper_removed_from_read_only_path(self):
|
||||
"""#685: config-touch / os._exit self-recovery is no longer on the server."""
|
||||
self.assertFalse(
|
||||
hasattr(gitea_mcp_server, "_trigger_mcp_auto_restart"),
|
||||
"_trigger_mcp_auto_restart must not remain (side-effect-free resolver)",
|
||||
)
|
||||
self.assertFalse(hasattr(gitea_mcp_server, "_restart_triggered"))
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
|
||||
@@ -0,0 +1,412 @@
|
||||
"""Tests for optional self-hosted Sentry observability (#606).
|
||||
|
||||
Covers the pure module (config, redaction, event/check-in builders) and the
|
||||
runtime capture paths using a fake ``sentry_sdk``, so nothing ever touches the
|
||||
network. Critically proves the feature is a no-op when disabled or DSN-less
|
||||
(acceptance criterion 1) and that secrets/paths/session-state are never sent
|
||||
(criterion 5).
|
||||
"""
|
||||
|
||||
import sys
|
||||
import contextlib
|
||||
from pathlib import Path
|
||||
|
||||
import pytest
|
||||
|
||||
sys.path.insert(0, str(Path(__file__).resolve().parent.parent))
|
||||
|
||||
import sentry_observability as so # noqa: E402
|
||||
|
||||
|
||||
# ── Fake SDK ────────────────────────────────────────────────────────────────
|
||||
class _FakeScope:
|
||||
def __init__(self):
|
||||
self.tags = {}
|
||||
self.extras = {}
|
||||
|
||||
def set_tag(self, k, v):
|
||||
self.tags[k] = v
|
||||
|
||||
def set_extra(self, k, v):
|
||||
self.extras[k] = v
|
||||
|
||||
|
||||
class FakeSentrySDK:
|
||||
"""Minimal stand-in exposing the SDK surface sentry_observability uses."""
|
||||
|
||||
def __init__(self):
|
||||
self.init_kwargs = None
|
||||
self.events = []
|
||||
self.exceptions = []
|
||||
self.checkins = []
|
||||
self.last_scope = None
|
||||
|
||||
def init(self, **kwargs):
|
||||
self.init_kwargs = kwargs
|
||||
|
||||
def capture_event(self, event):
|
||||
self.events.append(event)
|
||||
|
||||
def capture_exception(self, exc):
|
||||
self.exceptions.append(exc)
|
||||
|
||||
def capture_checkin(self, **payload):
|
||||
self.checkins.append(payload)
|
||||
|
||||
@contextlib.contextmanager
|
||||
def push_scope(self):
|
||||
self.last_scope = _FakeScope()
|
||||
yield self.last_scope
|
||||
|
||||
|
||||
@pytest.fixture(autouse=True)
|
||||
def _reset_state():
|
||||
"""Isolate module init state between tests."""
|
||||
so.reset_for_tests()
|
||||
yield
|
||||
so.reset_for_tests()
|
||||
|
||||
|
||||
@pytest.fixture
|
||||
def fake_sdk(monkeypatch):
|
||||
sdk = FakeSentrySDK()
|
||||
monkeypatch.setattr(so, "sentry_sdk", sdk)
|
||||
return sdk
|
||||
|
||||
|
||||
# ── Config / gating ─────────────────────────────────────────────────────────
|
||||
def test_disabled_by_default_empty_env():
|
||||
cfg = so.load_config(env={})
|
||||
assert cfg.enabled is False
|
||||
assert cfg.active is False
|
||||
|
||||
|
||||
def test_enabled_flag_without_dsn_is_not_active():
|
||||
cfg = so.load_config(env={"MCP_SENTRY_ENABLED": "1"})
|
||||
assert cfg.enabled is True
|
||||
assert cfg.dsn is None
|
||||
assert cfg.active is False # DSN required
|
||||
|
||||
|
||||
def test_dsn_without_enabled_flag_is_not_active():
|
||||
cfg = so.load_config(env={"SENTRY_DSN": "https://[email protected]/1"})
|
||||
assert cfg.active is False
|
||||
|
||||
|
||||
def test_active_requires_enabled_and_dsn():
|
||||
cfg = so.load_config(
|
||||
env={"MCP_SENTRY_ENABLED": "true", "SENTRY_DSN": "https://[email protected]/1"}
|
||||
)
|
||||
assert cfg.active is True
|
||||
|
||||
|
||||
def test_truthy_variants():
|
||||
for val in ("1", "true", "YES", "On"):
|
||||
cfg = so.load_config(env={"MCP_SENTRY_ENABLED": val})
|
||||
assert cfg.enabled is True
|
||||
for val in ("0", "false", "no", "", "off"):
|
||||
cfg = so.load_config(env={"MCP_SENTRY_ENABLED": val})
|
||||
assert cfg.enabled is False
|
||||
|
||||
|
||||
def test_traces_sample_rate_parsed_and_clamped():
|
||||
assert so.load_config(env={"MCP_SENTRY_TRACES_SAMPLE_RATE": "0.25"}).traces_sample_rate == 0.25
|
||||
assert so.load_config(env={"MCP_SENTRY_TRACES_SAMPLE_RATE": "5"}).traces_sample_rate == 1.0
|
||||
assert so.load_config(env={"MCP_SENTRY_TRACES_SAMPLE_RATE": "-1"}).traces_sample_rate == 0.0
|
||||
assert so.load_config(env={"MCP_SENTRY_TRACES_SAMPLE_RATE": "junk"}).traces_sample_rate == 0.0
|
||||
|
||||
|
||||
def test_safe_summary_has_no_dsn_value():
|
||||
cfg = so.load_config(
|
||||
env={"MCP_SENTRY_ENABLED": "1", "SENTRY_DSN": "https://[email protected]/1"}
|
||||
)
|
||||
summary = cfg.safe_summary()
|
||||
assert summary["dsn_present"] is True
|
||||
assert "secret" not in repr(summary)
|
||||
assert "dsn" not in summary # only presence, never the value
|
||||
|
||||
|
||||
# ── init_sentry ─────────────────────────────────────────────────────────────
|
||||
def test_init_noop_when_disabled(fake_sdk):
|
||||
status = so.init_sentry(so.load_config(env={}))
|
||||
assert status["initialized"] is False
|
||||
assert fake_sdk.init_kwargs is None # no SDK init
|
||||
assert so.is_initialized() is False
|
||||
|
||||
|
||||
def test_init_noop_when_enabled_but_missing_dsn(fake_sdk):
|
||||
status = so.init_sentry(so.load_config(env={"MCP_SENTRY_ENABLED": "1"}))
|
||||
assert status["initialized"] is False
|
||||
assert fake_sdk.init_kwargs is None
|
||||
|
||||
|
||||
def test_init_reports_missing_sdk(monkeypatch):
|
||||
monkeypatch.setattr(so, "sentry_sdk", None)
|
||||
status = so.init_sentry(
|
||||
so.load_config(
|
||||
env={"MCP_SENTRY_ENABLED": "1", "SENTRY_DSN": "https://[email protected]/1"}
|
||||
)
|
||||
)
|
||||
assert status["initialized"] is False
|
||||
assert "not installed" in status["reason"]
|
||||
|
||||
|
||||
def test_init_configures_sdk_with_scrubber(fake_sdk):
|
||||
status = so.init_sentry(
|
||||
so.load_config(
|
||||
env={
|
||||
"MCP_SENTRY_ENABLED": "1",
|
||||
"SENTRY_DSN": "https://[email protected]/1",
|
||||
"SENTRY_ENVIRONMENT": "prod",
|
||||
"MCP_SENTRY_TRACES_SAMPLE_RATE": "0.1",
|
||||
}
|
||||
)
|
||||
)
|
||||
assert status["initialized"] is True
|
||||
assert so.is_initialized() is True
|
||||
kw = fake_sdk.init_kwargs
|
||||
assert kw["dsn"] == "https://[email protected]/1"
|
||||
assert kw["environment"] == "prod"
|
||||
assert kw["traces_sample_rate"] == 0.1
|
||||
assert kw["before_send"] is so.scrub_event
|
||||
assert kw["send_default_pii"] is False
|
||||
|
||||
|
||||
def test_init_enable_logs_wires_log_scrubber(fake_sdk):
|
||||
so.init_sentry(
|
||||
so.load_config(
|
||||
env={
|
||||
"MCP_SENTRY_ENABLED": "1",
|
||||
"SENTRY_DSN": "https://[email protected]/1",
|
||||
"MCP_SENTRY_ENABLE_LOGS": "1",
|
||||
}
|
||||
)
|
||||
)
|
||||
exp = fake_sdk.init_kwargs["_experiments"]
|
||||
assert exp["enable_logs"] is True
|
||||
assert exp["before_send_log"] is so.scrub_event
|
||||
|
||||
|
||||
def test_init_never_raises_on_sdk_failure(monkeypatch):
|
||||
class Boom:
|
||||
def init(self, **kwargs):
|
||||
raise RuntimeError("sentry down")
|
||||
|
||||
monkeypatch.setattr(so, "sentry_sdk", Boom())
|
||||
status = so.init_sentry(
|
||||
so.load_config(
|
||||
env={"MCP_SENTRY_ENABLED": "1", "SENTRY_DSN": "https://[email protected]/1"}
|
||||
)
|
||||
)
|
||||
assert status["initialized"] is False
|
||||
assert "init failed" in status["reason"]
|
||||
|
||||
|
||||
# ── Redaction (fail closed) ─────────────────────────────────────────────────
|
||||
def test_build_tags_allowlist_only():
|
||||
tags = so.build_tags(role="author", secret_thing="leak", pid=123)
|
||||
assert tags["role"] == "author"
|
||||
assert tags["pid"] == "123"
|
||||
assert "secret_thing" not in tags
|
||||
|
||||
|
||||
def test_build_tags_hashes_session_id():
|
||||
tags = so.build_tags(session_id="prgs-author-20479-cf9ac178")
|
||||
assert "session_id" not in tags
|
||||
assert "session_id_hash" in tags
|
||||
assert tags["session_id_hash"] != "prgs-author-20479-cf9ac178"
|
||||
assert len(tags["session_id_hash"]) == 12
|
||||
|
||||
|
||||
def test_build_tags_collapses_worktree_path():
|
||||
tags = so.build_tags(
|
||||
worktree_path="/Users/x/Development/Gitea-Tools/branches/issue-606-sentry-observability"
|
||||
)
|
||||
assert "worktree_path" not in tags
|
||||
assert tags["worktree_category"] == "author"
|
||||
|
||||
|
||||
def test_build_tags_drops_value_that_scrubs_to_redacted():
|
||||
# A tag value that is itself a token gets scrubbed then dropped.
|
||||
tags = so.build_tags(capability="token abcdef1234567890")
|
||||
assert "capability" not in tags
|
||||
|
||||
|
||||
def test_sanitize_path_categories():
|
||||
assert so.sanitize_path("/repo/branches/review-pr-654") == "reviewer"
|
||||
assert so.sanitize_path("/repo/branches/merge-pr-1") == "merger"
|
||||
assert so.sanitize_path("/repo/branches/reconcile-pr-1") == "reconciler"
|
||||
assert so.sanitize_path("/repo/branches/feat-issue-606") == "author"
|
||||
assert so.sanitize_path("/x/y/Gitea-Tools") == "root"
|
||||
|
||||
|
||||
def test_redact_value_scrubs_secrets_and_paths():
|
||||
out = so.redact_value(
|
||||
{
|
||||
"token": "abc123",
|
||||
"note": "Authorization: Bearer sk_live_abcdefgh12345",
|
||||
"path": "/Users/jasonwalker/Development/Gitea-Tools/secret",
|
||||
"dsn": "https://[email protected]/1",
|
||||
"safe": "hello",
|
||||
}
|
||||
)
|
||||
assert out["token"] == so.REDACTED
|
||||
assert out["dsn"] == so.REDACTED
|
||||
assert "sk_live" not in out["note"]
|
||||
assert so.REDACTED_PATH in out["path"]
|
||||
assert "/Users/" not in out["path"]
|
||||
assert out["safe"] == "hello"
|
||||
|
||||
|
||||
def test_redact_value_forbidden_prompt_and_session_state():
|
||||
out = so.redact_value(
|
||||
{"prompt": "full body", "session_state": "{...}", "keep": "ok"}
|
||||
)
|
||||
assert out["prompt"] == so.REDACTED
|
||||
assert out["session_state"] == so.REDACTED
|
||||
assert out["keep"] == "ok"
|
||||
|
||||
|
||||
def test_scrub_event_redacts_nested_and_drops_server_name():
|
||||
event = {
|
||||
"server_name": "some-host",
|
||||
"message": "boom",
|
||||
"extra": {"token": "leak", "ok": "1"},
|
||||
}
|
||||
scrubbed = so.scrub_event(event)
|
||||
assert "server_name" not in scrubbed
|
||||
assert scrubbed["extra"]["token"] == so.REDACTED
|
||||
assert scrubbed["extra"]["ok"] == "1"
|
||||
|
||||
|
||||
def test_scrub_event_drops_non_dict():
|
||||
assert so.scrub_event("not a dict") is None
|
||||
assert so.scrub_event(None) is None
|
||||
|
||||
|
||||
# ── Event builders ──────────────────────────────────────────────────────────
|
||||
def test_build_blocker_event_structure_and_next_action():
|
||||
event = so.build_blocker_event(
|
||||
"active_foreign_lease",
|
||||
message="blocked by foreign lease",
|
||||
next_action="wait or adopt via allocator",
|
||||
level="warning",
|
||||
tags={"pr_number": 606, "session_id": "s-123"},
|
||||
)
|
||||
assert event["tags"]["blocker_type"] == "active_foreign_lease"
|
||||
assert event["tags"]["pr_number"] == "606"
|
||||
assert "session_id" not in event["tags"]
|
||||
assert event["tags"]["session_id_hash"]
|
||||
assert event["extra"]["canonical_next_action"] == "wait or adopt via allocator"
|
||||
assert event["fingerprint"] == ["workflow-blocker", "active_foreign_lease"]
|
||||
|
||||
|
||||
def test_build_blocker_event_invalid_level_defaults_warning():
|
||||
event = so.build_blocker_event("x", level="nonsense")
|
||||
assert event["level"] == "warning"
|
||||
|
||||
|
||||
def test_build_checkin_payload_maps_slugs():
|
||||
for key, slug in so.MONITOR_SLUGS.items():
|
||||
payload = so.build_checkin_payload(key, "ok")
|
||||
assert payload["monitor_slug"] == slug
|
||||
assert payload["status"] == "ok"
|
||||
|
||||
|
||||
def test_build_checkin_payload_explicit_slug_passthrough():
|
||||
payload = so.build_checkin_payload("custom-slug", "in_progress", duration=1.5)
|
||||
assert payload["monitor_slug"] == "custom-slug"
|
||||
assert payload["duration"] == 1.5
|
||||
|
||||
|
||||
def test_build_checkin_payload_rejects_bad_status():
|
||||
with pytest.raises(ValueError):
|
||||
so.build_checkin_payload("allocator_health", "bogus")
|
||||
|
||||
|
||||
def test_all_six_monitors_registered():
|
||||
assert set(so.MONITOR_SLUGS) == {
|
||||
"stale_lease_scan",
|
||||
"terminal_lock_scan",
|
||||
"allocator_health",
|
||||
"namespace_health",
|
||||
"dashboard_freshness",
|
||||
"reconciler_cleanup",
|
||||
}
|
||||
|
||||
|
||||
# ── Capture paths (fail open) ───────────────────────────────────────────────
|
||||
def test_capture_workflow_blocker_noop_when_disabled(fake_sdk):
|
||||
# not initialised
|
||||
event = so.capture_workflow_blocker("some_blocker", message="x")
|
||||
assert isinstance(event, dict) # still returns redacted event
|
||||
assert fake_sdk.events == [] # but nothing sent
|
||||
|
||||
|
||||
def test_capture_workflow_blocker_sends_when_initialised(fake_sdk):
|
||||
so.init_sentry(
|
||||
so.load_config(
|
||||
env={"MCP_SENTRY_ENABLED": "1", "SENTRY_DSN": "https://[email protected]/1"}
|
||||
)
|
||||
)
|
||||
so.capture_workflow_blocker("terminal_lock_occupied", message="held")
|
||||
assert len(fake_sdk.events) == 1
|
||||
assert fake_sdk.events[0]["tags"]["blocker_type"] == "terminal_lock_occupied"
|
||||
|
||||
|
||||
def test_capture_exception_noop_when_disabled(fake_sdk):
|
||||
assert so.capture_exception(ValueError("x")) is False
|
||||
assert fake_sdk.exceptions == []
|
||||
|
||||
|
||||
def test_capture_exception_sends_scrubbed_tags(fake_sdk):
|
||||
so.init_sentry(
|
||||
so.load_config(
|
||||
env={"MCP_SENTRY_ENABLED": "1", "SENTRY_DSN": "https://[email protected]/1"}
|
||||
)
|
||||
)
|
||||
ok = so.capture_exception(
|
||||
RuntimeError("bad"), tags={"mutation_tool": "gitea_merge_pr", "leaky": "x"}
|
||||
)
|
||||
assert ok is True
|
||||
assert len(fake_sdk.exceptions) == 1
|
||||
assert fake_sdk.last_scope.tags["mutation_tool"] == "gitea_merge_pr"
|
||||
assert "leaky" not in fake_sdk.last_scope.tags
|
||||
|
||||
|
||||
def test_capture_exception_never_raises(monkeypatch, fake_sdk):
|
||||
so.init_sentry(
|
||||
so.load_config(
|
||||
env={"MCP_SENTRY_ENABLED": "1", "SENTRY_DSN": "https://[email protected]/1"}
|
||||
)
|
||||
)
|
||||
|
||||
def boom(exc):
|
||||
raise RuntimeError("sdk exploded")
|
||||
|
||||
monkeypatch.setattr(fake_sdk, "capture_exception", boom)
|
||||
# Must swallow the SDK failure (fail open).
|
||||
assert so.capture_exception(ValueError("y")) is False
|
||||
|
||||
|
||||
def test_monitor_checkin_noop_when_disabled(fake_sdk):
|
||||
payload = so.monitor_checkin("allocator_health", "ok")
|
||||
assert payload["monitor_slug"] == "gitea-mcp-allocator-health"
|
||||
assert fake_sdk.checkins == [] # not sent while disabled
|
||||
|
||||
|
||||
def test_monitor_checkin_sends_when_initialised(fake_sdk):
|
||||
so.init_sentry(
|
||||
so.load_config(
|
||||
env={"MCP_SENTRY_ENABLED": "1", "SENTRY_DSN": "https://[email protected]/1"}
|
||||
)
|
||||
)
|
||||
so.monitor_checkin("stale_lease_scan", "ok")
|
||||
assert fake_sdk.checkins == [
|
||||
{"monitor_slug": "gitea-mcp-stale-lease-scan", "status": "ok"}
|
||||
]
|
||||
|
||||
|
||||
def test_monitor_checkin_invalid_status_returns_none(fake_sdk):
|
||||
assert so.monitor_checkin("allocator_health", "bogus") is None
|
||||
assert fake_sdk.checkins == []
|
||||
@@ -0,0 +1,139 @@
|
||||
"""Documentation acceptance for the stable control runtime ADR (#615 / PR #616).
|
||||
|
||||
Enforces review 443 remediation:
|
||||
|
||||
* F1 — operator guide / runbooks cross-link the ADR (issue #615 AC2).
|
||||
* F2 — LLM sessions are not instructed to kill/restart/relaunch MCP; process
|
||||
restart is operator-owned; ADR is the authoritative split.
|
||||
* F3 — routine post-merge master-parity staleness has a sanctioned response
|
||||
(stop → report → operator reload → re-verify parity) and is not a full
|
||||
promotion ledger requirement.
|
||||
"""
|
||||
from pathlib import Path
|
||||
|
||||
REPO_ROOT = Path(__file__).resolve().parent.parent
|
||||
ADR = (
|
||||
REPO_ROOT
|
||||
/ "docs"
|
||||
/ "architecture"
|
||||
/ "mcp-stable-control-runtime-policy-adr.md"
|
||||
)
|
||||
ADR_REL = "architecture/mcp-stable-control-runtime-policy-adr.md"
|
||||
ADR_BASENAME = "mcp-stable-control-runtime-policy-adr.md"
|
||||
|
||||
CROSS_LINK_DOCS = (
|
||||
REPO_ROOT / "docs" / "wiki" / "Operator-Guide.md",
|
||||
REPO_ROOT / "docs" / "wiki" / "Runbooks.md",
|
||||
REPO_ROOT / "docs" / "llm-workflow-runbooks.md",
|
||||
)
|
||||
|
||||
|
||||
def _read(path: Path) -> str:
|
||||
assert path.is_file(), f"missing {path.relative_to(REPO_ROOT)}"
|
||||
return path.read_text(encoding="utf-8")
|
||||
|
||||
|
||||
def test_adr_exists_with_policy_core():
|
||||
text = _read(ADR)
|
||||
assert text.lstrip().startswith("#"), "ADR lacks a title"
|
||||
assert "#615" in text
|
||||
assert "stable control runtime" in text.lower()
|
||||
assert "2.3" in text and "2.4" in text and "2.5" in text and "2.6" in text
|
||||
|
||||
|
||||
def test_f1_operator_docs_cross_link_adr():
|
||||
for path in CROSS_LINK_DOCS:
|
||||
text = _read(path)
|
||||
assert ADR_BASENAME in text, (
|
||||
f"{path.relative_to(REPO_ROOT)} must cross-link {ADR_BASENAME} "
|
||||
f"(issue #615 acceptance criterion 2)"
|
||||
)
|
||||
|
||||
|
||||
def test_f1_adr_lists_cross_links_as_acceptance_not_optional_tooling():
|
||||
text = _read(ADR)
|
||||
# Acceptance section must require guide/runbook cross-links.
|
||||
assert "Operator guide / runbooks cross-link" in text or (
|
||||
"operator guide" in text.lower() and "cross-link" in text.lower()
|
||||
and "Acceptance" in text
|
||||
)
|
||||
# Cross-link must not remain only as optional tooling item #4.
|
||||
optional = text.split("## 5. Implementation follow-ups", 1)[-1].split(
|
||||
"## 6. Acceptance", 1
|
||||
)[0]
|
||||
assert "Operator Guide wiki cross-link to this ADR" not in optional, (
|
||||
"ADR §5 must not list operator-guide cross-link as optional tooling"
|
||||
)
|
||||
assert "Not optional" in text or "must** cross-link" in text.lower() or (
|
||||
"must cross-link" in text.lower()
|
||||
)
|
||||
|
||||
|
||||
def test_f2_runbook_fallback_is_operator_owned_not_llm_restart():
|
||||
runbooks = _read(REPO_ROOT / "docs" / "llm-workflow-runbooks.md")
|
||||
# Forbidden historical self-service instruction.
|
||||
forbidden = (
|
||||
"the LLM must relaunch or restart the client/MCP with the correct "
|
||||
"profile environment variable before claiming or working on any tasks"
|
||||
)
|
||||
assert forbidden not in runbooks, (
|
||||
"llm-workflow-runbooks must not instruct the LLM to relaunch/restart MCP"
|
||||
)
|
||||
assert "operator-owned" in runbooks.lower() or "Operator-owned" in runbooks
|
||||
assert ADR_BASENAME in runbooks
|
||||
|
||||
|
||||
def test_f2_workspace_rebind_does_not_require_llm_process_relaunch():
|
||||
runbooks = _read(REPO_ROOT / "docs" / "llm-workflow-runbooks.md")
|
||||
section = runbooks.split("### Safe reconnect / rebind procedure", 1)[-1]
|
||||
section = section.split("## Safety notes", 1)[0]
|
||||
# Must not tell the LLM alone to relaunch the MCP process as step 2.
|
||||
assert "Reconnect or relaunch the correct namespace MCP server from the intended" not in section
|
||||
assert "Operator-owned" in section or "operator" in section.lower()
|
||||
assert "worktree_path" in section
|
||||
collapsed = " ".join(section.lower().split())
|
||||
assert "client reconnect" in collapsed
|
||||
|
||||
|
||||
def test_f2_adr_forbids_llm_restart_and_supersedes_self_service_relaunch():
|
||||
text = _read(ADR)
|
||||
lower = text.lower()
|
||||
assert "must not" in lower and "restart" in lower
|
||||
assert "operator" in lower and "reload" in lower
|
||||
assert "supersedes" in lower
|
||||
assert "llm" in lower
|
||||
|
||||
|
||||
def test_f3_adr_defines_post_merge_parity_staleness_response():
|
||||
text = _read(ADR)
|
||||
lower = text.lower()
|
||||
assert "2.6" in text
|
||||
assert "post-merge" in lower or "post merge" in lower
|
||||
assert "parity" in lower and "stale" in lower
|
||||
# Sanctioned steps: stop, report, operator reload, re-verify.
|
||||
assert "stop" in lower
|
||||
assert "report" in lower
|
||||
assert "operator" in lower
|
||||
assert "parity is verified" in lower or "re-verif" in lower or (
|
||||
"startup/current-head" in lower
|
||||
)
|
||||
# Not a full promotion ledger for routine reload.
|
||||
assert "not a §2.4 promotion" in lower or "not a section 2.4 promotion" in lower or (
|
||||
"not a §2.4" in text or "Not a §2.4 promotion" in text
|
||||
)
|
||||
# Stale parity listed among unhealthy triggers.
|
||||
assert "master parity is stale" in lower or "stale master parity" in lower
|
||||
|
||||
|
||||
def test_f3_adr_forbids_llm_bypass_of_parity_gate():
|
||||
text = _read(ADR)
|
||||
lower = text.lower()
|
||||
assert "bypass" in lower or "self-reset" in lower or "self-service" in lower
|
||||
assert "parity" in lower
|
||||
|
||||
|
||||
def test_cross_links_do_not_embed_secrets_or_raw_hosts_in_wiki_snippets():
|
||||
for path in CROSS_LINK_DOCS:
|
||||
text = _read(path)
|
||||
for marker in ("ghp_", "BEGIN PRIVATE KEY", "Authorization: Bearer"):
|
||||
assert marker not in text, f"{path} contains {marker!r}"
|
||||
@@ -0,0 +1,205 @@
|
||||
"""Invariant tests pinning task_capability_map role assignments (#722/#723).
|
||||
|
||||
Added with the operator-authorized break-glass repair for incident #722:
|
||||
commit 970e68b remapped ten reviewer tasks to ``role="merger"`` while every
|
||||
configured merger profile forbids the review permissions, so no configured
|
||||
profile could resolve any formal review task (``matching_configured_profile``
|
||||
was empty repository-wide). These tests fail loudly if that class of
|
||||
regression recurs:
|
||||
|
||||
- every role-exclusive formal-review task must be satisfiable by at least one
|
||||
canonical role profile (permission AND role together);
|
||||
- the capability map must agree with ``role_session_router`` task sets;
|
||||
- ``adopt_merger_pr_lease`` stays merger-only (the legitimate hunk of
|
||||
970e68b, preserved by the repair);
|
||||
- merger profiles must not be able to resolve review_pr/approve_pr.
|
||||
"""
|
||||
|
||||
import unittest
|
||||
|
||||
import gitea_config
|
||||
from role_session_router import MERGER_TASKS, REVIEWER_TASKS
|
||||
from task_capability_map import required_permission, required_role
|
||||
|
||||
# Canonical role-profile permission shape. Mirrors the configured
|
||||
# author/reviewer/merger/reconciler profiles (profiles.json v2 role split):
|
||||
# reviewers review/approve/request changes but never merge; mergers merge but
|
||||
# never review/approve/request changes.
|
||||
CANONICAL_ROLE_PROFILES = {
|
||||
"author": {
|
||||
"allowed": [
|
||||
"gitea.read",
|
||||
"gitea.branch.create",
|
||||
"gitea.branch.push",
|
||||
"gitea.repo.commit",
|
||||
"gitea.pr.create",
|
||||
"gitea.pr.comment",
|
||||
"gitea.issue.create",
|
||||
"gitea.issue.comment",
|
||||
"gitea.issue.close",
|
||||
],
|
||||
"forbidden": [
|
||||
"gitea.pr.approve",
|
||||
"gitea.pr.request_changes",
|
||||
"gitea.pr.merge",
|
||||
],
|
||||
},
|
||||
"reviewer": {
|
||||
"allowed": [
|
||||
"gitea.read",
|
||||
"gitea.pr.review",
|
||||
"gitea.pr.approve",
|
||||
"gitea.pr.request_changes",
|
||||
"gitea.pr.comment",
|
||||
"gitea.issue.comment",
|
||||
],
|
||||
"forbidden": [
|
||||
"gitea.branch.create",
|
||||
"gitea.branch.push",
|
||||
"gitea.repo.commit",
|
||||
"gitea.pr.create",
|
||||
"gitea.pr.merge",
|
||||
],
|
||||
},
|
||||
"merger": {
|
||||
"allowed": [
|
||||
"gitea.read",
|
||||
"gitea.pr.merge",
|
||||
"gitea.pr.comment",
|
||||
"gitea.issue.comment",
|
||||
],
|
||||
"forbidden": [
|
||||
"gitea.branch.create",
|
||||
"gitea.branch.push",
|
||||
"gitea.repo.commit",
|
||||
"gitea.pr.create",
|
||||
"gitea.pr.approve",
|
||||
"gitea.pr.review",
|
||||
"gitea.pr.request_changes",
|
||||
],
|
||||
},
|
||||
"reconciler": {
|
||||
"allowed": [
|
||||
"gitea.read",
|
||||
"gitea.pr.close",
|
||||
"gitea.pr.comment",
|
||||
"gitea.issue.comment",
|
||||
"gitea.branch.delete",
|
||||
"gitea.decision_lock.irrecoverable_recovery",
|
||||
],
|
||||
"forbidden": [
|
||||
"gitea.pr.approve",
|
||||
"gitea.pr.merge",
|
||||
"gitea.pr.review",
|
||||
"gitea.pr.request_changes",
|
||||
"gitea.pr.create",
|
||||
"gitea.branch.create",
|
||||
"gitea.branch.push",
|
||||
"gitea.repo.commit",
|
||||
],
|
||||
},
|
||||
}
|
||||
|
||||
# Role-exclusive formal-review tasks (mirrors the resolver's role-exclusive
|
||||
# handling for review work): permission alone is not enough — the profile's
|
||||
# role kind must also match, so both dimensions are pinned here.
|
||||
FORMAL_REVIEW_TASKS = (
|
||||
"review_pr",
|
||||
"approve_pr",
|
||||
"request_changes_pr",
|
||||
"blind_pr_queue_review",
|
||||
"pr_queue_cleanup",
|
||||
"pr-queue-cleanup",
|
||||
)
|
||||
|
||||
|
||||
def _profile_satisfies(role_name, task):
|
||||
"""True when the canonical *role_name* profile can perform *task*."""
|
||||
profile = CANONICAL_ROLE_PROFILES[role_name]
|
||||
ok, _reason = gitea_config.check_operation(
|
||||
required_permission(task), profile["allowed"], profile["forbidden"]
|
||||
)
|
||||
return ok and role_name == required_role(task)
|
||||
|
||||
|
||||
class TestFormalReviewProfileCoverage(unittest.TestCase):
|
||||
"""#722: some configured profile must be able to formally review."""
|
||||
|
||||
def test_every_formal_review_task_has_a_satisfying_role_profile(self):
|
||||
for task in FORMAL_REVIEW_TASKS:
|
||||
with self.subTest(task=task):
|
||||
satisfying = [
|
||||
role
|
||||
for role in CANONICAL_ROLE_PROFILES
|
||||
if _profile_satisfies(role, task)
|
||||
]
|
||||
self.assertTrue(
|
||||
satisfying,
|
||||
f"no canonical role profile satisfies both permission "
|
||||
f"{required_permission(task)!r} and role "
|
||||
f"{required_role(task)!r} for task {task!r} — formal "
|
||||
f"review would be impossible for every configured "
|
||||
f"profile (incident #722)",
|
||||
)
|
||||
|
||||
def test_formal_review_tasks_are_reviewer_role(self):
|
||||
for task in FORMAL_REVIEW_TASKS:
|
||||
with self.subTest(task=task):
|
||||
self.assertEqual(required_role(task), "reviewer")
|
||||
|
||||
|
||||
class TestMapRouterAgreement(unittest.TestCase):
|
||||
"""#723 AC2: the map and the role session router must not drift."""
|
||||
|
||||
def test_reviewer_tasks_map_to_reviewer_role(self):
|
||||
for task in sorted(REVIEWER_TASKS):
|
||||
with self.subTest(task=task):
|
||||
self.assertEqual(
|
||||
required_role(task),
|
||||
"reviewer",
|
||||
f"router classifies {task!r} as a reviewer task but the "
|
||||
f"capability map assigns role {required_role(task)!r}",
|
||||
)
|
||||
|
||||
def test_merger_tasks_map_to_merger_role(self):
|
||||
for task in sorted(MERGER_TASKS):
|
||||
with self.subTest(task=task):
|
||||
self.assertEqual(
|
||||
required_role(task),
|
||||
"merger",
|
||||
f"router classifies {task!r} as a merger task but the "
|
||||
f"capability map assigns role {required_role(task)!r}",
|
||||
)
|
||||
|
||||
|
||||
class TestMergerBoundary(unittest.TestCase):
|
||||
"""Preserve the legitimate hunk of 970e68b and the merger fence."""
|
||||
|
||||
def test_adopt_merger_pr_lease_requires_merger_role(self):
|
||||
self.assertEqual(required_role("adopt_merger_pr_lease"), "merger")
|
||||
self.assertEqual(
|
||||
required_permission("adopt_merger_pr_lease"), "gitea.pr.comment"
|
||||
)
|
||||
|
||||
def test_merge_pr_requires_merger_role(self):
|
||||
self.assertEqual(required_role("merge_pr"), "merger")
|
||||
self.assertEqual(required_permission("merge_pr"), "gitea.pr.merge")
|
||||
|
||||
def test_merger_profile_cannot_resolve_formal_review_tasks(self):
|
||||
merger = CANONICAL_ROLE_PROFILES["merger"]
|
||||
for task in ("review_pr", "approve_pr", "request_changes_pr"):
|
||||
with self.subTest(task=task):
|
||||
ok, reason = gitea_config.check_operation(
|
||||
required_permission(task),
|
||||
merger["allowed"],
|
||||
merger["forbidden"],
|
||||
)
|
||||
self.assertFalse(
|
||||
ok,
|
||||
f"merger profile must not hold {task!r} permission "
|
||||
f"(got reason {reason!r})",
|
||||
)
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
unittest.main()
|
||||
Reference in New Issue
Block a user