Compare commits

..
Author SHA1 Message Date
jcwalker3 c70675a955 Merge branch 'master' into feat/issue-665-restart-audit 2026-07-28 08:39:00 -05:00
sysadmin 9b80e75ca3 Merge pull request 'docs(remote-mcp): threat model, trust boundaries, and decomposition ruling (Closes #956)' (#965) from docs/issue-956-remote-mcp-threat-model into master 2026-07-28 08:19:45 -05:00
sysadminandClaude Opus 4.8 b3de9c941c docs(remote-mcp): threat model, trust boundaries, and decomposition ruling (Closes #956)
#930 inventoried stdio coupling; nothing stated what the adversary is, what
each boundary protects, or why one process may hold credentials for several
services. This adds that document as child 2 of epic #929.

Adds docs/remote-mcp/threat-model.md covering 10 assets, the 5 adversaries
#956 names, 9 trust boundaries, the data flows between them, a 14-entry
per-boundary credential inventory, and an explicit decomposition ruling.

Findings established from live native evidence at this commit:

- Four prgs roles (reviewer, merger, reconciler, controller) resolve to one
  Gitea account, so separation of duty between approving and landing is
  enforced only by which process a call reaches. The mdcps tenant has no role
  separation at all: author, reviewer, and merger share one account.
- Any one role process can resolve every other role's credential.
  gitea_list_profiles reports "credentials present" for other roles because it
  calls resolve_token on each one.
- The Gitea server reads Jenkins and GlitchTip secrets out of the keychain to
  produce the "authenticated" word in gitea_audit_config's service summaries.
- Jenkins and GlitchTip were already decomposed into separate MCP servers; the
  credential references were left behind in the Gitea configuration.

Ruling D1 forbids a single integration process from holding credentials for
unrelated services, with one time-boxed dual-run exception for the local fleet
that expires with #939. D2 requires separation of duty to be credential-backed,
D3 scopes credential resolution to the request principal, and D4 gives
coordination state its own authority. Every #929 child from 2 through 10 is
mapped to the boundary it implements.

Anchors are enforced rather than asserted. #930's inventory anchors into
gitea_mcp_server.py had already drifted between 7bf4f125 and aad5c8b4 with
nothing detecting it, so this change ships the guard that was missing:
docs/remote-mcp/threat-model-anchors.json declares all 58 anchors with the
substring each must contain, and tests/test_issue_956_threat_model.py fails if
any anchor does not resolve, if the document cites an anchor the fixture does
not cover, or if the structural obligations regress.

Documentation only. No server behavior changes.

Tests:
- tests/test_issue_956_threat_model.py: 17 passed.
- Four sabotage probes confirm the validator is not passing vacuously
  (shifted anchor, undeclared citation, broken count tally, and a blanked
  boundary owner). The last two probes exposed real weaknesses in the checks
  themselves, which were fixed: the section slice now stops at the next
  heading, and boundary ownership is read only from mapping table rows.
- Docs-sensitive sweep (17 modules referencing docs/): 559 passed.
- Full suite: 30 failed, 5799 passed, 6 skipped. All 30 reproduce on a clean
  base worktree at aad5c8b4; branch failures are a strict subset of base
  failures. No production file is modified by this change.

Closes #956

Co-Authored-By: Claude Opus 4.8 (1M context) <[email protected]>
2026-07-28 04:41:26 -04:00
sysadmin aad5c8b423 Merge pull request 'fix(author): unify the bootstrap and lock_issue issue-lock contract (Closes #953)' (#954) from fix/issue-953-bootstrap-lock-provenance into master
Merges PR #954 at approved head b4c9f55890.

Approval: review 633 APPROVE at b4c9f55890.
Base: master at 82d71b7702.

Closes #953
2026-07-28 02:21:11 -05:00
sysadminandClaude Opus 4.8 b4c9f55890 test(author): execute the native #953 tool functions and compensation paths (#953)
Review 632 F4. Every prior reference to `gitea_recover_incomplete_bootstrap_lock`
and `gitea_inspect_issue_lock_contract` under `tests/` was a string literal — a
`tool=` argument, an assertion on returned prose, or a docs substring check —
and the suite reconstructed the recovery sequence by hand from
`assess_bootstrap_lock_recovery`, `build_canonical_issue_lock`,
`build_recovery_record`, and `bind_session_lock`. A hand-written sequence
validates the decision layer but cannot see a divergence between itself and
the tool body, which is exactly how F1 and F2 — both call-site defects —
survived 61 passing cases.

The new cases drive the registered functions against a real `git init`
repository, a real durable lock file, and config-backed profiles:

* `NamespaceMutationWallOnRecovery` — the gate is reached with this task and
  `author_role_exclusive=True`; its return value aborts the tool rather than
  being computed and discarded; the author namespace succeeds and produces a
  canonical lock; a reviewer namespace is refused with `namespace_block` even
  when the claimant data would otherwise match, and emits the standard BLOCKED
  audit record; merger is refused; a mismatched claimant profile is refused by
  the exact-owner layer with the namespace wall explicitly clear; a mismatched
  head is refused; every refusal leaves the lock bytes, generation, branch,
  worktree, and an unrelated lock untouched. A subtest matrix asserts the
  role-kind wall admits `author` and refuses reviewer, merger, limited, and
  mixed.
* `InspectionToolExecutes` — the registered read-only tool reports the contract
  and the recovery preview while leaving lock bytes, mtime, HEAD, and porcelain
  status unchanged, and reports an absent lock without creating one.
* `Ac7PostCompensationGuidance` — drives the real bootstrap to its AC7 refusal
  with a forced partial lock and asserts the returned action against the state
  the rollback actually left: complete cleanup directs to a bootstrap retry and
  that retry is then executed and succeeds, leaving exactly one canonical lock
  and one branch; partial cleanup with a surviving lock, and with a surviving
  branch and worktree, each get their own executable action; a rollback that
  never completed is distinguished from both; no recommendation names a deleted
  artifact; unrelated locks are byte-identical afterwards. Two cases cover
  `release_session_lock` directly — that the rollback now really removes the
  lock, and that it refuses a lock owned by another session.
* `NativeEndToEndBootstrapToCreatePr` — bootstrap, inspect, heartbeat,
  legitimate divergence (commit and push), pre-mutation ownership re-check, and
  the unchanged #447 create-PR provenance guard, in one sequence against a real
  origin. `gitea_lock_issue` is patched to fail the test if anything reaches for
  it, so the bootstrap lock is proved to carry the whole cycle unrepaired.
* `DeadProvenanceConstantRemoved` — the removed constant stays removed and the
  sanctioned source set stays unwidened.

`_NativeToolBase` clears `role_session_router` route state per test: the sticky
reviewer-stop marker is process-global and, now that this task is registered in
`AUTHOR_TASKS`, an earlier suite leaving it set would make the first gate refuse
before the namespace gate under test is reached.

Also documents both new tools in `docs/mcp-tool-inventory.md`, so this branch
adds no drift to `test_documented_inventory_equals_registered_tools`; the
failure reason there is now identical to the pinned base's.

Suite: 61 -> 92 cases.

Co-Authored-By: Claude Opus 4.8 (1M context) <[email protected]>
2026-07-28 02:08:21 -04:00
sysadminandClaude Opus 4.8 1aa351718a fix(author): derive AC7 guidance from the state compensation actually leaves (#953)
Review 632 F2. The bootstrap AC7 read-back refusal calls
`run_compensating_recovery` and *then* reported
`author_lock_contract.recommended_action(contract)` — advice computed from the
malformed lock that provoked the rollback, not from the state the rollback
left. Compensation releases the lock, removes the worktree (always clean
there, no implementation bytes having been written), and deletes the branch,
so an author following that advice got `no_durable_lock` from
`gitea_recover_incomplete_bootstrap_lock` and, had the lock survived,
`worktree_invalid` instead; the `gitea_lock_issue` half of the same sentence
cannot bind a worktree that no longer exists. Two refusals in a row for a
state a plain bootstrap retry fixes — the unexecutable-guidance failure class
this issue exists to remove, reintroduced on the new fail-closed path.

Investigating that path surfaced why the "clean retry" state was in practice
unreachable: `run_compensating_recovery` has called
`issue_lock_store.release_session_lock` since #850, and that function has
never existed. The `AttributeError` landed in a bare `except Exception: pass`,
so every rollback removed the branch and worktree and silently left the lock
behind — precisely the uninspectable, unrecoverable state #953 is about
(`gitea_recover_incomplete_bootstrap_lock` refuses `worktree_invalid`,
`gitea_lock_issue` has no worktree to bind). Confirmed dead at the pinned base
`82d71b77`, not introduced by this branch.

`release_session_lock` is therefore implemented: it removes exactly one
durable lock whose recorded `owner_session` matches the caller's, keyed by
repository when known, refusing on zero or multiple matches so no caller can
delete a lock it does not own and an ambiguous directory is never guessed at.
Bootstrap phase journals and session pointers that share the directory are
excluded by shape. The flock sidecar is deliberately left alone. The caller no
longer swallows a release failure; it records `lock_release_failed:...`.

`assess_post_compensation_state` then classifies from directly observed
durable state — lock file, worktree directory, and branch ref — rather than
from the journal's `rolled_back` list, which records only what compensation
attempted. Three distinct states: `complete` (nothing remains),
`partial` (rollback ran, artifacts survive by design or because a step
errored), `failed` (rollback never completed, so nothing is proven removed).

`post_compensation_action` answers for exactly what survives:

  complete                      -> re-run gitea_bootstrap_author_issue_worktree
  lock + branch + worktree      -> gitea_recover_incomplete_bootstrap_lock
  branch + worktree, no lock    -> gitea_lock_issue (still base-equivalent)
  lock only, worktree gone      -> gitea_inspect_issue_lock_contract
  branch only                   -> gitea_inspect_issue_lock_contract, then retry
  rollback did not complete     -> gitea_inspect_issue_lock_contract

No branch names an artifact the classification says is gone, and a failed
rollback step is stated rather than presented as an intentional outcome. The
refusal payload carries `compensating_recovery` and `post_compensation_state`
alongside the derived `exact_next_action`, still with
`implementation_allowed: false`.

Also removes the dead `SOURCE_BOOTSTRAP_LOCK_RECOVERY` constant (review 632
F3), which had no readers and implied a second lock source; the deliberate
reuse of `SOURCE_LOCK_ISSUE` is now stated as a comment. The #447 guard and
`SANCTIONED_LOCK_SOURCES` remain untouched.

Co-Authored-By: Claude Opus 4.8 (1M context) <[email protected]>
2026-07-28 02:08:21 -04:00
sysadminandClaude Opus 4.8 55d66c57e4 fix(author): gate bootstrap-lock recovery on the namespace mutation wall (#953)
Review 632 F1. `gitea_recover_incomplete_bootstrap_lock` writes the same
durable author issue lock as `gitea_recover_dirty_orphaned_issue_worktree`
but gated only on the reviewer-stop router check and the profile permission
block. Every other author state-creating mutation carries a third gate,
`_namespace_mutation_block`, and this tool was the outlier.

The surviving gates did not cover the gap: the task's required permission is
`gitea.issue.comment`, which every configured role holds, and
`_ensure_matching_profile` returns a profile name both call sites discard, so
it refuses nothing. A reviewer-bound session reached the exact-owner claimant
comparison inside `assess_bootstrap_lock_recovery` and was refused there —
one layer too late, with no namespace evaluation and no BLOCKED audit record
of the attempt.

Adding the call alone would have been inert. `check_author_mutation_namespace`
routes through `role_session_router.required_role_for_task`, which reads
`TASK_REQUIRED_ROLE` — not `task_capability_map` — and that table had no entry
for this task, so the check short-circuited to "allowed" for every caller.
The task is therefore registered in `TASK_REQUIRED_ROLE` and `AUTHOR_TASKS`,
matching the role the capability map already records.

The reviewer-namespace check alone is also insufficient for a task gated on
`gitea.issue.comment`, since merger, controller, and reconciler profiles hold
it too. `role_namespace_gate.check_author_role_kind` is added as an opt-in,
additive wall requiring the active profile's derived role kind to be exactly
`author`; `mixed` is refused rather than admitted. `_namespace_mutation_block`
gains a keyword-only `author_role_exclusive` flag, default off, so the six
pre-existing call sites are byte-for-byte unchanged.

Refusals produce the standard structured denial (`namespace_block: true`,
`mcp_namespace`) and the standard BLOCKED audit record, and no lock, lease,
generation, branch, worktree, issue, or PR is touched. Valid `prgs-author`
execution is unaffected. No reviewer permission is broadened and no existing
role wall is weakened.

Co-Authored-By: Claude Opus 4.8 (1M context) <[email protected]>
2026-07-28 02:08:21 -04:00
sysadminandClaude Opus 4.8 cdf0daefa9 fix(author): unify the bootstrap and lock_issue issue-lock contract (Closes #953)
gitea_bootstrap_author_issue_worktree wrote a lock no downstream author
operation accepts, then directed the author straight to implementation. Once
the branch carried commits, heartbeat, re-lock, exact-owner renewal, and the
#447 create-PR guard all refused simultaneously and no sanctioned recovery
path remained eligible.

Each of those gates is individually correct. The defect was that two writers
disagreed about what a lock is.

- Add author_lock_contract as the single canonical definition: claimant,
  work_lease, lock_provenance, generation, and an explicit expiration state.
  Both gitea_lock_issue and bootstrap now build through it.
- Promote issue_lock_store.lock_claimant to the one shared claimant reader and
  use it in the ownership check, so a claimant recorded at the lock top level
  is read rather than refused. The values are still compared against
  server-resolved identity and profile, so no legacy placement grants anything
  the canonical placement would not.
- Represent missing expiration explicitly. An absent expires_at previously read
  as "not yet expired", leaving a malformed lock permanently non-expiring and
  permanently ineligible for #760 renewal.
- Bootstrap reads its lock back and verifies it structurally before reporting
  success. A partial lock fails closed while the worktree is still
  base-equivalent, names the missing fields, and never reports
  implementation_allowed. Its exact_next_action now matches the state returned.
- Add gitea_recover_incomplete_bootstrap_lock for locks already written by the
  old bootstrap, including those whose branches carry pushed commits. It never
  moves, resets, or rewinds a branch, never requires base-equivalence, never
  pushes or opens a PR, and touches only the target lock. It proves repository,
  issue, claimant username and profile, branch, worktree, registration, and
  head before writing, refuses healthy foreign-owned locks, and mints
  provenance and authorization server-side.
- Add gitea_inspect_issue_lock_contract, a strictly read-only surface.
- Document the required ordering and the recovery path.

The #447 provenance guard is unchanged and the sanctioned source set was not
widened: bootstrap now satisfies the guard rather than the guard being relaxed
to admit bootstrap.

Tests: 61 new cases covering the canonical schema, immediate heartbeat,
renewal before and after commits, the create_pr guard, executable next actions,
partial and malformed and missing-expiration and expired and same-owner and
foreign-owner and legacy locks, recovery isolation, read-only inspection, and
the full bootstrap-implement-commit-push-create_pr regression against isolated
fixtures.

Full suite at head: 28 failed, 5753 passed, 6 skipped, 1042 subtests.
Full suite at base 82d71b77: 28 failed, 5692 passed, 6 skipped, 1042 subtests.
Failing test-ID sets are identical, so there are zero regressions; the +61
passes are this issue's new suite.

Issue #949 was preserved and not recovered: its branch remains at
92615f474b and its worktree, lock, and PR state
were not touched. The #949-shaped regression uses isolated fixtures only.

Co-Authored-By: Claude Opus 4.8 (1M context) <[email protected]>
2026-07-28 00:35:12 -04:00
sysadmin 82d71b7702 Merge pull request 'fix(author bootstrap): restore missing runtime identity and session helpers (Closes #943)' (#944) from fix/issue-943-runtime-context-helpers into master 2026-07-27 20:03:08 -05:00
sysadmin 35ed8a2fcb Merge pull request 'fix(gate): preserve exact-owner renewal evidence across duplicate rechecks (Closes #945)' (#946) from fix/issue-945-owning-pr-renewal-evidence into master 2026-07-27 18:27:35 -05:00
sysadminandClaude Opus 5 b1fcf15937 fix(gate): complete owning-PR renewal enforcement coverage (#945)
Addresses review 623 on PR #946 (B1 blocker, F2 medium, F3 minor).

B1 - the wiring this branch exists to install had no regression coverage.
The existing suite exercised owning_pr_renewal_from_lock,
_owning_pr_continuation_from_lock and the duplicate gate directly, but never
drove an enforcement path, so reverting any of the three call sites left the
whole repository green. Add tests/test_issue_945_enforcement_path_wiring.py,
which drives the real mcp_server._enforce_locked_issue_duplicate_recheck (the
shared recheck behind gitea_commit_files and gitea_create_pr),
mcp_server.gitea_assess_work_issue_duplicate and
mcp_server._prove_author_ownership_for_pr against a renewal-bearing lock, and
asserts each grants the exemption. Reverting the commit/create-PR recheck to
the recovery-only rebuild now fails 8 tests and 4 subtests; reverting the
assessor or the push prover fails 2 each. The suite also keeps the fail-closed
matrix on the real paths: an open PR alone, a second PR, a different PR,
branch, issue or head, identity and profile mismatch, ungranted and malformed
renewal blocks, and sequential-task non-inheritance are all still refused.

F2 - the claimant check compares lease_renewal.identity/profile against the
claimant recorded on the same lock file. Both sides are server-written fields
of one document, so it is an internal-consistency check, not verification of
the live authenticated caller. Correct the docstring and the inline comment to
say so, and document the binding that actually prevents cross-session reuse:
the enforcement paths load the lock through _load_existing_issue_lock() with no
issue coordinates, which resolves issue_lock_store.read_session_issue_lock() to
the session pointer at session-{os.getpid()}.json, so lock selection is scoped
to the operating-system process. Its limits are stated too - per-process rather
than per-authenticated-user, silent on locks reached by explicit coordinates,
and silent on two roles sharing one process. Live identity and profile stay
enforced by the mutation-authority and profile gates, not by this rebuild.

F3 - _owning_pr_continuation_from_lock previously fell through to renewal when
a dead_session_recovery block was present but failed to rebuild, so a recovery
record naming one PR could be bypassed by renewal evidence naming another.
Present-but-unusable recovery evidence is now ambiguous rather than absent and
fails closed. Because an expired lease whose recorded owner has also died
satisfies both dispositions in one gitea_lock_issue call, a sanctioned pair is
a reachable state; when both rebuild, they must agree on issue, PR, branch and
every head, or no continuation authority is returned. Recovery-only and
renewal-only locks keep their existing behaviour exactly.

Validation of the resulting token against live PR state remains untouched and
solely owned by issue_work_duplicate_gate._assess_owning_pr_exemption. No
public signature, MCP tool schema, refusal shape or reason code changes.

Tests: focused #945/#755/#760 suites 137 passed, 19 subtests. Full suite in a
branches/ worktree: 30F/5619P/6S/1013 subtests at this head vs 30F/5572P/6S/1002
at a clean checkout of 79334d48, with byte-identical failing test id sets - the
+47 passes and +11 subtests are exactly the new coverage.

Co-Authored-By: Claude Opus 5 (1M context) <[email protected]>
Claude-Session: https://claude.ai/code/session_01Q8RUznLXEA4JoK48sTZiSK
2026-07-27 18:22:07 -04:00
sysadmin ed9414ebda Merge pull request 'fix(test): add pytest.ini to restrict testpaths and ignore worktree branches (#927)' (#928) from fix/issue-927-pytest-config into master 2026-07-27 15:38:45 -05:00
sysadmin 5fea326988 Merge pull request 'feat(mcp): expose sanctioned Codex MCP reconnect request (Closes #678)' (#918) from fix/issue-678-codex-mcp-reconnect into master 2026-07-27 15:32:10 -05:00
sysadmin dc5d2c8caa Merge pull request 'feat(webui): Sentry/GlitchTip observability console & correlation view (Closes #649)' (#917) from feat/issue-649-sentry-console-correlation into master 2026-07-27 15:23:36 -05:00
sysadmin c30b381eb2 Merge pull request 'fix(mcp): add active IDE vs global config-drift diagnostic (Closes #672)' (#920) from fix/issue-672-mcp-config-drift into master 2026-07-27 05:40:04 -05:00
jcwalker3andClaude Opus 4.8 79334d4840 fix(gate): preserve exact-owner renewal evidence across duplicate rechecks (Closes #945)
An exact-owner lease renewal (#760) is granted the owning-PR duplicate-work
waiver inside gitea_lock_issue, but every later enforcement path rebuilt that
proof from the durable lock through
issue_lock_recovery.recovered_owning_pr_from_lock, which reads only the
dead_session_recovery block. A plain renewal persists its proof under
lease_renewal instead, so the waiver expired with the lock call and the next
author mutation was refused duplicate_commit_prevented with
owning_pr_recovery_exempted: false on the very PR the renewal had just proved.

Add issue_lock_renewal.owning_pr_renewal_from_lock as the renewal mirror of the
recovery rebuild, and resolve both halves through one helper,
_owning_pr_continuation_from_lock, in the same precedence gitea_lock_issue
applies. The three enforcement paths that previously saw only recovery evidence
now share that resolver: the commit/create-PR duplicate recheck, the read-only
duplicate assessor, and the push-ownership prover.

The rebuild re-applies the equality the renewal assessor required (PR head ==
local head == remote head) and additionally binds the record to the claimant the
lock names, so a renewal block cannot be reused under another identity, profile,
or workflow session. Validation of the resulting token against live PR state is
unchanged and still owned solely by
issue_work_duplicate_gate._assess_owning_pr_exemption, so repository, issue, PR,
branch and head binding continue to be enforced in exactly one place.

No public signature, MCP tool schema, refusal shape, or reason code changes.
Dead-session recovery behaviour is untouched.

Tests: tests/test_issue_945_owning_pr_renewal_continuation.py - 49 tests,
8 subtests, all in-memory (no durable branch, worktree, lock, lease or PR).
Against unmodified aab54d48 the suite is 47 failed / 2 passed; the 2 that pass
are the defect-pinning reproductions. Full suite 28F/5574P/6S/1002 subtests at
head vs 28F/5525P/6S/994 at base, with identical failing test id sets.

Co-Authored-By: Claude Opus 4.8 (1M context) <[email protected]>
2026-07-26 10:42:44 -05:00
sysadmin 17dd05ec9d fix(test): add pytest.ini to restrict testpaths and ignore worktree branches (#927) 2026-07-25 23:07:15 -04:00
sysadmin fad44669d9 fix(mcp): add active IDE vs global config-drift diagnostic (Closes #672) 2026-07-25 19:07:10 -04:00
jcwalker3andGrok 4.5 29ad93d145 feat(mcp): expose sanctioned Codex MCP reconnect request (Closes #678)
Add gitea_request_mcp_reconnect as a report-only callable surface so Codex
and other agent hosts can request host/IDE reconnect with typed blockers
and exact UI steps. Never kills processes or edits config.

Co-Authored-By: Grok 4.5 <[email protected]>
2026-07-25 17:52:08 -05:00
sysadmin f2dbf30e81 feat(webui): Sentry/GlitchTip observability console & correlation view (Closes #649) 2026-07-25 18:48:36 -04:00
sysadmin 22e0a41bd5 feat(restart): audit lifecycle events and durable incidents (#665)
Add restart_audit with mcp.restart.* event schema, redacted emission via
gitea_audit, correlation ids, and incident materialization for failed drain
and break-glass. Wire gitea_request_mcp_restart to always audit impact
previews and fail closed on privileged apply when the audit sink is enabled
but write fails.

Closes #665
2026-07-25 17:10:16 -04:00
40 changed files with 9145 additions and 90 deletions
+134 -24
View File
@@ -23,6 +23,7 @@ import shutil
import subprocess import subprocess
from typing import Any, Mapping from typing import Any, Mapping
import author_lock_contract
import author_mutation_worktree import author_mutation_worktree
import control_plane_db import control_plane_db
import issue_lock_store import issue_lock_store
@@ -278,6 +279,36 @@ def _verify_assignment_and_lease_ids(
return None return None
def _branch_exists(canonical_repo_root: str, branch_name: str) -> bool:
"""Whether *branch_name* still resolves in the canonical checkout (#953 F2).
Used after compensating recovery to observe what survived rather than infer
it from the journal. Fails closed to ``True``: an unobservable branch is
reported as present, so the recommendation stays conservative rather than
telling an author to re-bootstrap over something that may still be there.
"""
if not branch_name:
return False
try:
res = subprocess.run(
[
"git",
"-C",
canonical_repo_root,
"rev-parse",
"--verify",
"--quiet",
f"refs/heads/{branch_name}",
],
capture_output=True,
text=True,
check=False,
)
except Exception:
return True
return res.returncode == 0
def run_compensating_recovery( def run_compensating_recovery(
journal: dict[str, Any], journal: dict[str, Any],
canonical_repo_root: str, canonical_repo_root: str,
@@ -314,10 +345,21 @@ def run_compensating_recovery(
issue_number=issue_num, issue_number=issue_num,
session=session_id, session=session_id,
lock_dir=journal_dir, lock_dir=journal_dir,
remote=journal.get("remote"),
# The same defaults the lock was written under, so the
# rollback targets the exact file bind_session_lock keyed.
org=journal.get("org") or "Scaled-Tech-Consulting",
repo=journal.get("repo") or "Gitea-Tools",
) )
rolled_back.append(f"lock:issue-{issue_num}") rolled_back.append(f"lock:issue-{issue_num}")
except Exception: except Exception as exc:
pass # #953 F2: a swallowed failure here is what made the rollback
# report success while leaving an unrecoverable lock behind.
# Record it so the post-compensation classification can see the
# lock survived and recommend accordingly.
rolled_back.append(
f"lock_release_failed:issue-{issue_num}:{type(exc).__name__}"
)
artifacts["lock_created"] = False artifacts["lock_created"] = False
worktree_created = ( worktree_created = (
@@ -1198,26 +1240,31 @@ def bootstrap_author_issue_worktree(
save_phase_journal(journal, journal_dir=lock_dir) save_phase_journal(journal, journal_dir=lock_dir)
# Phase 6: STATE_ESTABLISHED — Issue Lock Acquisition # Phase 6: STATE_ESTABLISHED — Issue Lock Acquisition
#
# #953: this used to hand-build a thinner record — claimant at the top
# level, no work_lease, no lock_provenance, no expiry — which every
# downstream reader then refused. It now builds through the one shared
# canonical contract, so the lock bootstrap writes is the same lock
# gitea_lock_issue writes.
from datetime import datetime, timezone from datetime import datetime, timezone
try: try:
lock_data = { lock_data = author_lock_contract.build_canonical_issue_lock(
"remote": remote, issue_number=issue_number,
"org": org or "Scaled-Tech-Consulting", branch_name=target_branch,
"repo": repo or "Gitea-Tools", worktree_path=target_worktree,
"issue_number": issue_number, remote=remote,
"branch": target_branch, org=org or "Scaled-Tech-Consulting",
"branch_name": target_branch, repo=repo or "Gitea-Tools",
"worktree_path": target_worktree, identity=identity,
"owner_session": session, profile=profile,
"claimant": { tool="gitea_bootstrap_author_issue_worktree",
"username": identity, source=author_lock_contract.SOURCE_BOOTSTRAP,
"profile": profile, owner_session=session,
}, assignment_id=assignment_id,
"assignment_id": assignment_id, lease_id=lease_id,
"lease_id": lease_id, expected_base_sha=live_master_sha,
"expected_base_sha": live_master_sha, )
"created_at": datetime.now(timezone.utc).isoformat(), lock_data["created_at"] = datetime.now(timezone.utc).isoformat()
}
journal.setdefault("pending_creations", {})["lock"] = True journal.setdefault("pending_creations", {})["lock"] = True
journal["artifacts_created"]["lock_created"] = True journal["artifacts_created"]["lock_created"] = True
save_phase_journal(journal, journal_dir=lock_dir) save_phase_journal(journal, journal_dir=lock_dir)
@@ -1235,9 +1282,65 @@ def bootstrap_author_issue_worktree(
"exact_next_action": "Verify lease/assignment state and retry.", "exact_next_action": "Verify lease/assignment state and retry.",
} }
# ── #953 AC7: verify the lock that was actually written ──
# Reporting "lock_created: true" and then directing the author to
# implement is what produced the unrecoverable state: by the time any
# reader refused the lock, the branch already carried commits and every
# sanctioned recovery path had become ineligible. The lock is therefore
# read back from disk and structurally verified *before* this function
# can report success, and a partial lock fails closed here — while the
# branch is still base-equivalent and recovery is still cheap.
written_lock = issue_lock_store.read_lock_file(lock_res)
contract = author_lock_contract.assess_lock_contract(written_lock)
if not contract["canonical"]:
journal["failure_reason"] = author_lock_contract.format_contract_refusal(
contract
)
compensation = run_compensating_recovery(
journal, root, journal_dir=lock_dir
)
# AC5/AC15: the recommendation must describe the state compensation
# actually left, not the state that provoked it.
# ``run_compensating_recovery`` has by now released the lock, removed
# the worktree, and deleted the branch, so recommending
# incomplete-lock recovery for those exact artifacts would refuse
# twice over. Observe what survived and answer for that.
post_state = author_lock_contract.assess_post_compensation_state(
compensation,
lock_present=bool(lock_res) and os.path.exists(lock_res),
worktree_present=os.path.isdir(target_worktree),
branch_present=_branch_exists(root, target_branch),
)
return {
"success": False,
"reason_code": "incomplete_issue_lock_contract",
"message": author_lock_contract.format_contract_refusal(contract),
"issue_number": issue_number,
"branch_name": target_branch,
"worktree_path": target_worktree,
"lock_state": lock_res,
"lock_contract": contract,
"missing_fields": contract["missing_fields"],
"implementation_allowed": False,
"compensating_recovery": compensation,
"post_compensation_state": post_state,
# AC15: never strand a branch or worktree without a structured
# recovery recommendation — and never name an artifact the
# rollback has already deleted.
"exact_next_action": author_lock_contract.post_compensation_action(
post_state,
issue_number=issue_number,
branch_name=target_branch,
worktree_path=target_worktree,
missing_fields=contract["missing_fields"],
),
"phase_journal": journal,
}
journal["phases"][PHASE_6_STATE_ESTABLISHED] = { journal["phases"][PHASE_6_STATE_ESTABLISHED] = {
"status": "completed", "status": "completed",
"lock": lock_res, "lock": lock_res,
"lock_contract": contract["contract"],
} }
journal["phases"][PHASE_7_TRANSITION_COMPLETED] = { journal["phases"][PHASE_7_TRANSITION_COMPLETED] = {
"status": "completed", "status": "completed",
@@ -1261,9 +1364,16 @@ def bootstrap_author_issue_worktree(
"assignment_id": assignment_id, "assignment_id": assignment_id,
"idempotency_key": key, "idempotency_key": key,
"lock_state": lock_res, "lock_state": lock_res,
"lock_contract": contract,
# #953 AC6: the canonical ownership token for this claim. Never null
# on a successful bootstrap — it is the fencing token every
# subsequent heartbeat and renewal is checked against.
"task_session_id": contract["task_session_id"],
"implementation_allowed": True,
"phase_journal": journal, "phase_journal": journal,
"exact_next_action": ( # #953 AC5: executable under the state actually returned. The lock
"Call gitea_whoami, then gitea_resolve_task_capability(task='work_issue') " # has been read back and verified canonical, so proceeding to
"and proceed with author implementation in the bootstrapped worktree." # implementation is genuinely the correct next step here — which is
), # exactly what the old unconditional wording could not promise.
"exact_next_action": author_lock_contract.recommended_action(contract),
} }
+623
View File
@@ -0,0 +1,623 @@
"""One canonical author issue-lock contract shared by every writer (#953).
Before this module, ``gitea_lock_issue`` and
``gitea_bootstrap_author_issue_worktree`` each wrote their own lock record.
``gitea_lock_issue`` wrote the canonical shape — ``work_lease`` carrying the
claimant plus a sanctioned ``lock_provenance`` — while bootstrap wrote a thinner
record with the claimant at the lock top level, ``lease_id: null``, and no
``work_lease``, ``lock_provenance``, or expiry at all.
Every downstream reader was written against the canonical shape, so a lock that
bootstrap reported as successfully created was simultaneously:
* un-heartbeatable — the ownership check read the claimant only from
``work_lease.claimant``;
* un-renewable — expiry is read only from ``work_lease.expires_at``, so a
missing lease read as "never expires", and #760 exact-owner renewal only ever
assesses an *expired* lease;
* un-re-lockable — the branch had by then advanced past its base;
* and rejected by the #447 create-PR provenance guard.
Each of those gates is individually correct. The defect was that two writers
disagreed about what a lock *is*. This module is the single definition, and both
writers now build through it.
Nothing here weakens a guard. ``build_sanctioned_lock_provenance`` remains the
only provenance source, provenance is never accepted from a caller, and the
#447 guard is untouched — this module simply makes bootstrap satisfy it.
"""
from __future__ import annotations
from datetime import datetime, timedelta, timezone
from typing import Any, Mapping
import issue_lock_provenance
import issue_lock_store
import lease_policy
# Bootstrap writes through the same sanctioned source as gitea_lock_issue: the
# lock it produces *is* a canonical lock, not a second dialect that readers must
# learn. Adding a distinct source would have required widening
# SANCTIONED_LOCK_SOURCES, which is exactly the #447 weakening this issue's
# safety requirements forbid.
SOURCE_BOOTSTRAP = issue_lock_provenance.SOURCE_LOCK_ISSUE
# Recovery of an incomplete bootstrap lock (#953 AC8-AC11) deliberately writes
# through SOURCE_LOCK_ISSUE too, and records its distinctness in
# ``lock_provenance.written_by_tool`` plus the ``bootstrap_lock_recovery``
# transition block instead. There is no distinct recovery *source* constant, for
# the same reason bootstrap has none: minting one would require widening
# SANCTIONED_LOCK_SOURCES, which the #447 safety requirements forbid.
#: Top-level keys every canonical author issue lock must carry.
REQUIRED_LOCK_FIELDS: tuple[str, ...] = (
"remote",
"org",
"repo",
"issue_number",
"branch_name",
"worktree_path",
"work_lease",
"lock_provenance",
)
#: Keys every canonical ``work_lease`` must carry.
REQUIRED_WORK_LEASE_FIELDS: tuple[str, ...] = (
"operation_type",
"issue_number",
"branch",
"worktree_path",
"claimant",
"created_at",
"expires_at",
"last_heartbeat_at",
"task_session_id",
"lifecycle_version",
)
# ── Explicit expiration states (AC12) ──
# The bug this replaces: a lock with no recorded expiry produced
# ``is_lease_expired() -> False``, which reads as "not yet expired" and made the
# lock permanently non-expiring *and* permanently ineligible for the renewal
# path, which only ever assesses an expired lease. "Absent" and "in the future"
# are different facts and are now named differently.
EXPIRATION_RECORDED = "recorded"
EXPIRATION_MISSING = "missing"
EXPIRATION_UNPARSEABLE = "unparseable"
#: Structural verdicts returned by :func:`assess_lock_contract`.
CONTRACT_CANONICAL = "canonical"
CONTRACT_INCOMPLETE = "incomplete"
CONTRACT_LEGACY = "legacy"
CONTRACT_ABSENT = "absent"
def _text(value: Any) -> str:
return str(value or "").strip()
def now_utc() -> datetime:
return datetime.now(timezone.utc)
def format_timestamp(value: datetime) -> str:
"""Serialize in the durable ``...Z`` form already used on disk."""
return (
value.astimezone(timezone.utc)
.replace(microsecond=0)
.isoformat()
.replace("+00:00", "Z")
)
def lock_claimant(lock: Mapping[str, Any] | None) -> dict[str, str]:
"""Read the claimant from either canonical or legacy placement.
``work_lease.claimant`` is canonical and is preferred. A top-level
``claimant`` is the legacy/bootstrap placement and is accepted as a
fallback (AC14) — three separate readers already disagreed about this
(``issue_lock_store``, ``issue_lock_renewal``, ``issue_lock_recovery``),
which is why it now lives in one place.
Reading a legacy placement is *not* a widening: every caller still compares
the values it returns against server-resolved identity and profile. This
only decides where to look, never whether ownership is proven.
Delegates to ``issue_lock_store.lock_claimant`` rather than reimplementing
the rule. A second copy here would be a fourth reader that could drift from
the other three, which is the exact failure #953 exists to end. It lives in
the store because ``author_lock_contract`` imports the store, so defining it
here would make that import circular.
"""
recorded = issue_lock_store.lock_claimant(dict(lock) if isinstance(lock, Mapping) else None)
return {
"username": _text(recorded.get("username")),
"profile": _text(recorded.get("profile")),
}
def claimant_placement(lock: Mapping[str, Any] | None) -> str:
"""Where the claimant was found: ``work_lease``, ``top_level``, or ``absent``."""
if not isinstance(lock, Mapping):
return "absent"
lease = lock.get("work_lease")
if isinstance(lease, Mapping) and isinstance(lease.get("claimant"), Mapping):
return "work_lease"
if isinstance(lock.get("claimant"), Mapping):
return "top_level"
return "absent"
def build_claimant(*, username: str | None, profile: str | None) -> dict[str, str]:
"""Build the canonical claimant pair from server-resolved values."""
return {"username": _text(username), "profile": _text(profile)}
def build_author_issue_work_lease(
*,
issue_number: int,
branch_name: str,
worktree_path: str,
claimant: Mapping[str, Any],
task_session_id: str | None = None,
created: datetime | None = None,
) -> dict[str, Any]:
"""Build the canonical author ``work_lease``.
The single definition behind both writers. The TTL comes from the central
policy rather than a literal, and the window slides from the last valid
heartbeat (#790), so an abandoned task releases its claim within one TTL.
"""
started = created or now_utc()
policy = lease_policy.policy_for(lease_policy.TASK_CLASS_AUTHOR_ISSUE_WORK)
expires = started + timedelta(minutes=policy.initial_ttl_minutes)
session_id = _text(task_session_id) or issue_lock_store.mint_task_session_id(
issue_lock_store.AUTHOR_ISSUE_WORK_LEASE
)
return {
"operation_type": issue_lock_store.AUTHOR_ISSUE_WORK_LEASE,
"issue_number": int(issue_number),
"pr_number": None,
"branch": branch_name,
"worktree_path": worktree_path,
"claimant": dict(claimant),
"created_at": format_timestamp(started),
"expires_at": format_timestamp(expires),
"last_heartbeat_at": format_timestamp(started),
# #790 AC-N1: the ownership key for this task, distinct from the
# recorded PID, which is the shared daemon and identifies no task.
"task_session_id": session_id,
# #790 AC-N8: the explicit lifecycle marker. Its absence — never a
# timestamp comparison — is what makes a lock legacy.
"lifecycle_version": lease_policy.LIFECYCLE_HEARTBEAT_V1,
"heartbeat_count": 1,
}
def build_canonical_issue_lock(
*,
issue_number: int,
branch_name: str,
worktree_path: str,
remote: str,
org: str,
repo: str,
identity: str | None,
profile: str | None,
tool: str,
source: str = issue_lock_provenance.SOURCE_LOCK_ISSUE,
owner_session: str | None = None,
assignment_id: str | None = None,
lease_id: str | None = None,
expected_base_sha: str | None = None,
task_session_id: str | None = None,
created: datetime | None = None,
) -> dict[str, Any]:
"""Build a complete canonical lock record.
``tool`` and ``source`` are server-supplied. There is deliberately no
parameter through which a caller could inject provenance: the #953 safety
requirements forbid caller-manufactured provenance, so provenance is always
minted here from ``build_sanctioned_lock_provenance``.
"""
claimant = build_claimant(username=identity, profile=profile)
work_lease = build_author_issue_work_lease(
issue_number=issue_number,
branch_name=branch_name,
worktree_path=worktree_path,
claimant=claimant,
task_session_id=task_session_id,
created=created,
)
record: dict[str, Any] = {
"remote": remote,
"org": org,
"repo": repo,
"issue_number": int(issue_number),
"branch": branch_name,
"branch_name": branch_name,
"worktree_path": worktree_path,
"work_lease": work_lease,
"lock_provenance": issue_lock_provenance.build_sanctioned_lock_provenance(
tool=tool,
source=source,
claimant=claimant,
),
}
if owner_session is not None:
record["owner_session"] = owner_session
if assignment_id is not None:
record["assignment_id"] = assignment_id
# #953 AC6: a null lease id is recorded only when no workflow lease was
# allocated for this bootstrap. The task-session identifier in the
# work_lease is what downstream ownership checks fence on, and it is never
# null on a canonical lock.
if lease_id is not None:
record["lease_id"] = lease_id
if expected_base_sha is not None:
record["expected_base_sha"] = expected_base_sha
return record
def expiration_state(lock: Mapping[str, Any] | None) -> dict[str, Any]:
"""Classify a lock's recorded expiry explicitly (AC12).
Distinguishes "no expiry was ever recorded" from "an expiry was recorded
and is still in the future". Collapsing those two into a single ``False``
from ``is_lease_expired`` is what let a malformed lock be treated as
permanently live and simultaneously never renewable.
"""
if not isinstance(lock, Mapping):
return {"state": EXPIRATION_MISSING, "expires_at": None, "expired": None}
lease = lock.get("work_lease")
raw = lease.get("expires_at") if isinstance(lease, Mapping) else None
text = _text(raw)
if not text:
return {"state": EXPIRATION_MISSING, "expires_at": None, "expired": None}
try:
parsed = datetime.fromisoformat(text.replace("Z", "+00:00")).astimezone(
timezone.utc
)
except ValueError:
return {"state": EXPIRATION_UNPARSEABLE, "expires_at": text, "expired": None}
return {
"state": EXPIRATION_RECORDED,
"expires_at": text,
"expired": parsed <= now_utc(),
}
def missing_contract_fields(lock: Mapping[str, Any] | None) -> list[str]:
"""Name every canonical field a lock does not carry (AC7)."""
if not isinstance(lock, Mapping):
return ["<no lock record>"]
missing: list[str] = []
for field in REQUIRED_LOCK_FIELDS:
value = lock.get(field)
if value is None or (isinstance(value, str) and not value.strip()):
missing.append(field)
lease = lock.get("work_lease")
if not isinstance(lease, Mapping):
if "work_lease" not in missing:
missing.append("work_lease")
else:
for field in REQUIRED_WORK_LEASE_FIELDS:
value = lease.get(field)
if value is None or (isinstance(value, str) and not value.strip()):
missing.append(f"work_lease.{field}")
provenance = lock.get("lock_provenance")
if isinstance(provenance, Mapping):
if (
_text(provenance.get("source"))
not in issue_lock_provenance.SANCTIONED_LOCK_SOURCES
):
missing.append("lock_provenance.source (not sanctioned)")
if not _text(provenance.get("written_by_tool")):
missing.append("lock_provenance.written_by_tool")
claimant = lock_claimant(lock)
if not claimant["username"]:
missing.append("claimant.username")
if not claimant["profile"]:
missing.append("claimant.profile")
return missing
def assess_lock_contract(lock: Mapping[str, Any] | None) -> dict[str, Any]:
"""Structural, read-only verdict on a durable lock record (AC7, AC16).
Pure inspection: it reads the record it is handed and mutates nothing —
no lock, lease, branch, worktree, issue, or PR. Callers use it both to
verify a lock they just wrote and to report on one they found.
"""
if not isinstance(lock, Mapping) or not lock:
return {
"contract": CONTRACT_ABSENT,
"canonical": False,
"missing_fields": ["<no lock record>"],
"claimant": {"username": "", "profile": ""},
"claimant_placement": "absent",
"expiration": {
"state": EXPIRATION_MISSING,
"expires_at": None,
"expired": None,
},
"heartbeatable": False,
"create_pr_eligible": False,
"lock_generation": None,
"task_session_id": None,
"reasons": ["no durable lock record"],
}
missing = missing_contract_fields(lock)
claimant = lock_claimant(lock)
placement = claimant_placement(lock)
expiration = expiration_state(lock)
provenance_check = issue_lock_provenance.assess_lock_file_for_create_pr(dict(lock))
# Canonical means: every required field present, the claimant in the
# canonical placement, an expiry actually recorded, and the untouched #447
# guard satisfied.
canonical = (
not missing
and placement == "work_lease"
and expiration["state"] == EXPIRATION_RECORDED
and bool(provenance_check.get("proven"))
)
if canonical:
contract = CONTRACT_CANONICAL
elif placement == "top_level" and claimant["username"] and claimant["profile"]:
contract = CONTRACT_LEGACY
else:
contract = CONTRACT_INCOMPLETE
reasons: list[str] = []
if missing:
reasons.append("missing canonical fields: " + ", ".join(missing))
if placement == "top_level":
reasons.append(
"claimant recorded at the lock top level rather than in work_lease "
"(legacy/bootstrap placement)"
)
if expiration["state"] == EXPIRATION_MISSING:
reasons.append(
"no expiration recorded; the lock is neither expirable nor renewable "
"until it is upgraded"
)
elif expiration["state"] == EXPIRATION_UNPARSEABLE:
reasons.append(f"unparseable expires_at '{expiration['expires_at']}'")
if provenance_check.get("block"):
reasons.extend(provenance_check.get("reasons") or [])
# Heartbeat needs the claimant pair (from either placement, post-fix) plus a
# task-session identifier to fence on.
lease = lock.get("work_lease")
task_session_id = (
_text(lease.get("task_session_id")) if isinstance(lease, Mapping) else ""
)
heartbeatable = bool(
claimant["username"] and claimant["profile"] and task_session_id
)
return {
"contract": contract,
"canonical": canonical,
"missing_fields": missing,
"claimant": claimant,
"claimant_placement": placement,
"expiration": expiration,
"heartbeatable": heartbeatable,
"create_pr_eligible": bool(provenance_check.get("proven")),
"lock_generation": lock.get("lock_generation"),
"task_session_id": task_session_id or None,
"reasons": reasons,
}
def format_contract_refusal(assessment: Mapping[str, Any]) -> str:
"""Human-readable refusal naming exactly what the lock is missing."""
missing = ", ".join(assessment.get("missing_fields") or []) or "unknown fields"
return (
"Issue lock contract incomplete (#953): "
f"{missing}. The lock cannot be heartbeated, renewed, or accepted by "
"gitea_create_pr in this state (fail closed)"
)
def recommended_action(assessment: Mapping[str, Any]) -> str:
"""The one executable next step for a lock in this state (AC5, AC15)."""
contract = assessment.get("contract")
if contract == CONTRACT_CANONICAL:
return (
"Lock is canonical. Call gitea_whoami, then "
"gitea_resolve_task_capability(task='work_issue'), then proceed with "
"author implementation in the bootstrapped worktree."
)
if contract == CONTRACT_ABSENT:
return (
"No durable lock exists. Call gitea_lock_issue for this issue and "
"branch before writing any implementation bytes."
)
return (
"Do not begin implementation. Call "
"gitea_recover_incomplete_bootstrap_lock for this exact issue, branch, "
"and worktree to upgrade the lock to the canonical contract, or "
"gitea_lock_issue while the worktree is still base-equivalent."
)
# ── Post-compensation recovery guidance (#953 AC5/AC15, review 632 F2) ──
#
# ``recommended_action`` above answers "what can be done about a lock in this
# shape". That is the wrong question on the bootstrap AC7 refusal path, because
# ``run_compensating_recovery`` has already run by the time the answer is
# reported: it releases the lock, removes the worktree when clean — which it
# always is there, no implementation bytes having been written — and deletes the
# created branch. Recommending incomplete-lock recovery for those artifacts
# hands the author two refusals in a row (``no_durable_lock``, then
# ``worktree_invalid``) for a state that a plain bootstrap retry would fix. The
# advice must describe the state that actually *remains*.
#: Compensation removed every artifact this transition created.
CLEANUP_COMPLETE = "complete"
#: Compensation removed some artifacts; others survive and are still actionable.
CLEANUP_PARTIAL = "partial"
#: Compensation itself failed or could not be observed; nothing is provable.
CLEANUP_FAILED = "failed"
def assess_post_compensation_state(
recovery: Mapping[str, Any] | None,
*,
lock_present: bool,
worktree_present: bool,
branch_present: bool,
) -> dict[str, Any]:
"""Classify what survived compensation, from observed durable state.
Pure. The caller observes the filesystem and git; this decides. Observation
is authoritative over the journal's ``rolled_back`` list, which records what
compensation *attempted*: ``run_compensating_recovery`` swallows a failed
lock release and appends nothing, so an absent marker proves nothing either
way. The list is still carried through as corroborating evidence.
The three states are distinct facts, not degrees of the same one:
* ``CLEANUP_COMPLETE`` — compensation ran and nothing it created remains.
* ``CLEANUP_PARTIAL`` — compensation ran and artifacts survive, whether by
design (a worktree dirty at rollback time, a branch carrying commits) or
because a rollback step errored. Either way the surviving set was observed
directly, so it is known and actionable; ``failed_rollback_steps`` records
which cause applies.
* ``CLEANUP_FAILED`` — compensation never ran to completion, so nothing it
would have removed can be assumed removed.
"""
rolled_back = list((recovery or {}).get("rolled_back") or [])
executed = bool((recovery or {}).get("executed"))
failed_steps = [entry for entry in rolled_back if "_failed" in entry]
surviving: list[str] = []
if lock_present:
surviving.append("lock")
if worktree_present:
surviving.append("worktree")
if branch_present:
surviving.append("branch")
if not executed:
state = CLEANUP_FAILED
elif surviving:
state = CLEANUP_PARTIAL
else:
state = CLEANUP_COMPLETE
return {
"cleanup_state": state,
"compensation_executed": executed,
"lock_present": bool(lock_present),
"worktree_present": bool(worktree_present),
"branch_present": bool(branch_present),
"surviving_artifacts": surviving,
"removed_artifacts": [
name
for name, present in (
("lock", lock_present),
("worktree", worktree_present),
("branch", branch_present),
)
if not present
],
"failed_rollback_steps": failed_steps,
"rolled_back": rolled_back,
}
def post_compensation_action(
state: Mapping[str, Any],
*,
issue_number: int,
branch_name: str,
worktree_path: str,
missing_fields: list[str] | None = None,
) -> str:
"""The one executable next step for the state compensation actually left.
Every branch names only artifacts the classification says still exist, so no
recommendation can point at something the rollback deleted.
"""
missing = ", ".join(missing_fields or []) or "the reported missing fields"
cleanup_state = state.get("cleanup_state")
lock_present = bool(state.get("lock_present"))
worktree_present = bool(state.get("worktree_present"))
branch_present = bool(state.get("branch_present"))
if not state.get("compensation_executed"):
# Compensation never ran, so nothing was rolled back and nothing about
# the remaining state was decided. The read-only surface is the only
# action executable under any state.
return (
"Compensating rollback did not complete, so the remaining state is "
f"not proven. Call gitea_inspect_issue_lock_contract for issue "
f"#{issue_number} (read-only) to establish what survives before any "
"further action. Do not retry bootstrap until it is known."
)
prefix = ""
failed_steps = state.get("failed_rollback_steps") or []
if failed_steps:
prefix = (
"Compensating rollback reported a failed step "
f"({', '.join(failed_steps)}); what survives was observed directly "
"and the action below is scoped to exactly that. "
)
if cleanup_state == CLEANUP_COMPLETE:
return (
"Compensating rollback removed the malformed lock, the branch, and "
f"the worktree, so nothing from this attempt remains. Resolve "
f"{missing} and re-run gitea_bootstrap_author_issue_worktree for "
f"issue #{issue_number} from the clean pre-bootstrap state. Do not "
"call gitea_recover_incomplete_bootstrap_lock: there is no lock, "
"branch, or worktree left for it to act on."
)
if lock_present and worktree_present and branch_present:
return prefix + (
"The lock, branch, and worktree all survive. Call "
"gitea_recover_incomplete_bootstrap_lock for issue "
f"#{issue_number}, branch '{branch_name}', and worktree "
f"'{worktree_path}', passing the worktree's current head as "
"expected_head, to upgrade the lock to the canonical contract."
)
if not lock_present and worktree_present and branch_present:
return prefix + (
"The malformed lock was released but the branch and worktree "
"survive. No implementation bytes were written, so the worktree is "
f"still base-equivalent: call gitea_lock_issue for issue "
f"#{issue_number} on branch '{branch_name}' from worktree "
f"'{worktree_path}' to acquire a canonical lock."
)
if lock_present and not worktree_present:
return prefix + (
f"The worktree for issue #{issue_number} is gone but the durable "
"lock survived, so neither gitea_recover_incomplete_bootstrap_lock "
"(it would refuse worktree_invalid) nor gitea_lock_issue (it has no "
"worktree to bind) is executable. Call "
"gitea_inspect_issue_lock_contract for issue "
f"#{issue_number} (read-only) to confirm the surviving lock; it "
"must be released by its recorded owner before bootstrap is "
"retried."
)
# Lock gone, worktree gone, some git artifact left (a branch with commits,
# or a branch this transition did not create).
return prefix + (
"Compensating rollback removed the lock and worktree; branch "
f"'{branch_name}' survives and was not deleted. Call "
f"gitea_inspect_issue_lock_contract for issue #{issue_number} "
"(read-only) to confirm no durable lock remains, then re-run "
"gitea_bootstrap_author_issue_worktree, which will adopt the existing "
"branch rather than recreating it."
)
+304
View File
@@ -0,0 +1,304 @@
"""Target-specific recovery for incomplete bootstrap issue locks (#953).
The situation this exists for: ``gitea_bootstrap_author_issue_worktree``
reported success, wrote an incomplete lock, and told the author to implement.
The author did — legitimately, following the tool's own reported next action —
and the branch now carries real committed and pushed work. At that point every
pre-existing recovery path is simultaneously ineligible:
* heartbeat refuses, because the claimant is not where it looks;
* ``gitea_lock_issue`` refuses, because the branch is no longer base-equivalent;
* #760 exact-owner renewal never engages, because a lock with no recorded
expiry is never *expired*;
* the #447 create-PR guard refuses, because there is no provenance.
Distinct from every neighbouring path: #753 ``issue_lock_recovery`` requires a
dead owner PID, #760 ``issue_lock_renewal`` requires an *expired* lease, and
#442 ``issue_lock_adoption`` decides branch adoption. None of them addresses a
lock that is structurally incomplete and therefore never expires at all.
**What this will not do.** It never moves, resets, or rewinds a branch, and
never requires base-equivalence — the committed work is the thing being
preserved. It never pushes and never opens a pull request. It touches only the
one lock file named by (remote, org, repo, issue). It accepts no caller-supplied
provenance and no caller-supplied authorization flag; both are minted
server-side. It refuses a healthy foreign-owned lock outright, and a matching
username alone is never accepted as proof of ownership — the profile must match
too, and the lock's recorded binding must agree with the observed branch,
worktree, and head.
"""
from __future__ import annotations
import os
from typing import Any, Mapping
import author_lock_contract
import issue_lock_store
#: Refusal codes, so callers can branch on cause rather than parse prose.
REFUSAL_NO_LOCK = "no_durable_lock"
REFUSAL_ALREADY_CANONICAL = "already_canonical"
REFUSAL_FOREIGN_CLAIMANT = "foreign_claimant"
REFUSAL_HEALTHY_FOREIGN = "healthy_foreign_lock"
REFUSAL_IDENTITY_UNRESOLVED = "identity_unresolved"
REFUSAL_BINDING_MISMATCH = "binding_mismatch"
REFUSAL_WORKTREE_INVALID = "worktree_invalid"
REFUSAL_HEAD_MISMATCH = "head_mismatch"
def _text(value: Any) -> str:
return str(value or "").strip()
def _same_realpath(left: str | None, right: str | None) -> bool:
lhs, rhs = _text(left), _text(right)
if not lhs or not rhs:
return False
try:
return os.path.realpath(lhs) == os.path.realpath(rhs)
except OSError:
return lhs == rhs
def assess_bootstrap_lock_recovery(
existing_lock: Mapping[str, Any] | None,
*,
issue_number: int,
branch_name: str,
worktree_path: str,
remote: str,
org: str,
repo: str,
identity: str | None,
profile: str | None,
observed_head: str | None,
declared_head: str | None,
worktree_exists: bool,
worktree_registered: bool,
current_branch: str | None,
now: Any = None,
) -> dict[str, Any]:
"""Decide whether this exact lock may be upgraded by this exact caller.
Pure: every input is an observation the caller already made, and nothing
here reads or writes the filesystem, git, or Gitea. That is what makes the
same decision testable in isolation and reusable by the read-only
inspection surface, which must not mutate anything (AC16).
Returns a dict with ``recovery_sanctioned`` plus the full evidence set. A
refusal never raises — it reports, so the caller can surface exactly which
piece of evidence was missing.
"""
reasons: list[str] = []
refusal_code: str | None = None
contract = author_lock_contract.assess_lock_contract(existing_lock)
if not existing_lock:
return {
"recovery_sanctioned": False,
"refusal_code": REFUSAL_NO_LOCK,
"reasons": [
f"no durable issue lock exists for issue #{issue_number}; there is "
"nothing to recover (fail closed)"
],
"contract": contract,
"evidence": {},
"expected_generation": None,
}
active_identity = _text(identity)
active_profile = _text(profile)
recorded = author_lock_contract.lock_claimant(existing_lock)
freshness = issue_lock_store.assess_lock_freshness(dict(existing_lock), now=now)
generation = issue_lock_store.lock_generation(existing_lock)
evidence: dict[str, Any] = {
"recorded_claimant": recorded,
"active_identity": active_identity,
"active_profile": active_profile,
"recorded_branch": existing_lock.get("branch_name"),
"recorded_worktree": existing_lock.get("worktree_path"),
"recorded_owner_session": existing_lock.get("owner_session"),
"recorded_generation": generation,
"recorded_remote": existing_lock.get("remote"),
"recorded_org": existing_lock.get("org"),
"recorded_repo": existing_lock.get("repo"),
"observed_head": _text(observed_head),
"declared_head": _text(declared_head),
"current_branch": _text(current_branch),
"worktree_exists": bool(worktree_exists),
"worktree_registered": bool(worktree_registered),
"freshness": freshness,
"claimant_placement": contract.get("claimant_placement"),
"expiration_state": contract.get("expiration", {}).get("state"),
}
# ── Repository and issue identity (AC10) ──
if _text(existing_lock.get("remote")) != _text(remote):
reasons.append(
f"recorded remote '{existing_lock.get('remote')}' does not match '{remote}'"
)
refusal_code = refusal_code or REFUSAL_BINDING_MISMATCH
if _text(existing_lock.get("org")) != _text(org):
reasons.append(
f"recorded org '{existing_lock.get('org')}' does not match '{org}'"
)
refusal_code = refusal_code or REFUSAL_BINDING_MISMATCH
if _text(existing_lock.get("repo")) != _text(repo):
reasons.append(
f"recorded repo '{existing_lock.get('repo')}' does not match '{repo}'"
)
refusal_code = refusal_code or REFUSAL_BINDING_MISMATCH
if existing_lock.get("issue_number") != issue_number:
reasons.append(
f"lock targets issue #{existing_lock.get('issue_number')}, not "
f"#{issue_number}"
)
refusal_code = refusal_code or REFUSAL_BINDING_MISMATCH
# ── Branch and worktree binding (AC10) ──
if _text(existing_lock.get("branch_name")) != _text(branch_name):
reasons.append(
f"recorded branch '{existing_lock.get('branch_name')}' does not match "
f"'{branch_name}'"
)
refusal_code = refusal_code or REFUSAL_BINDING_MISMATCH
if not _same_realpath(existing_lock.get("worktree_path"), worktree_path):
reasons.append(
f"recorded worktree '{existing_lock.get('worktree_path')}' does not "
f"match '{worktree_path}'"
)
refusal_code = refusal_code or REFUSAL_BINDING_MISMATCH
# ── The worktree is real, registered, and on the branch (AC10) ──
# Deliberately no base-equivalence requirement and no constraint on how far
# the branch has advanced: the whole point is that it already carries the
# author's legitimate commits (AC9).
if not worktree_exists:
reasons.append(f"declared worktree '{worktree_path}' does not exist")
refusal_code = refusal_code or REFUSAL_WORKTREE_INVALID
if not worktree_registered:
reasons.append(f"worktree '{worktree_path}' is not a registered git worktree")
refusal_code = refusal_code or REFUSAL_WORKTREE_INVALID
if _text(current_branch) != _text(branch_name):
reasons.append(
f"worktree is on branch '{_text(current_branch) or 'unknown'}', not "
f"'{branch_name}'"
)
refusal_code = refusal_code or REFUSAL_WORKTREE_INVALID
# ── Current head fencing (AC10) ──
# The caller names the commit it believes it is recovering. A mismatch means
# the worktree moved under the caller, so the decision is stale.
if not _text(observed_head):
reasons.append("could not observe the worktree head")
refusal_code = refusal_code or REFUSAL_HEAD_MISMATCH
elif _text(declared_head) and _text(declared_head) != _text(observed_head):
reasons.append(
f"declared head '{_text(declared_head)}' does not match observed head "
f"'{_text(observed_head)}'"
)
refusal_code = refusal_code or REFUSAL_HEAD_MISMATCH
# ── Ownership (AC10, AC11) ──
# A matching username alone is never sufficient: the profile must match too,
# and both are compared against server-resolved values the caller cannot set.
if not active_identity or not active_profile:
reasons.append(
"active identity and profile could not both be resolved; ownership "
"cannot be proven"
)
refusal_code = refusal_code or REFUSAL_IDENTITY_UNRESOLVED
if not recorded["username"] or not recorded["profile"]:
reasons.append(
"durable lock does not record both a claimant username and profile"
)
refusal_code = refusal_code or REFUSAL_FOREIGN_CLAIMANT
elif (
recorded["username"] != active_identity
or recorded["profile"] != active_profile
):
# AC11: a foreign-owned lock is never recoverable through this path,
# healthy or not. The healthy case is reported distinctly so the refusal
# is legible, but both refuse.
if freshness.get("live"):
reasons.append(
f"lock is owned by a healthy foreign claimant "
f"'{recorded['username']}/{recorded['profile']}'; takeover is not "
"a recovery path"
)
refusal_code = REFUSAL_HEALTHY_FOREIGN
else:
reasons.append(
f"lock claimant '{recorded['username']}/{recorded['profile']}' "
f"does not match active '{active_identity}/{active_profile}'"
)
refusal_code = refusal_code or REFUSAL_FOREIGN_CLAIMANT
# ── Nothing to recover ──
# A lock that is already canonical is left strictly alone. Rewriting it would
# mint a new task-session identifier and invalidate the heartbeat token the
# legitimate owner is already using.
if contract.get("canonical") and not reasons:
return {
"recovery_sanctioned": False,
"refusal_code": REFUSAL_ALREADY_CANONICAL,
"reasons": [
"lock already satisfies the canonical contract; no recovery is "
"required"
],
"contract": contract,
"evidence": evidence,
"expected_generation": generation,
}
sanctioned = not reasons
return {
"recovery_sanctioned": sanctioned,
"refusal_code": None if sanctioned else refusal_code,
"reasons": reasons,
"contract": contract,
"evidence": evidence,
"expected_generation": generation,
}
def build_recovery_record(
assessment: Mapping[str, Any],
*,
recovered_at: str,
new_task_session_id: str,
) -> dict[str, Any]:
"""Auditable record of the ownership and generation transition (AC10).
A recovered lock must never read as an original claim, so both sides of the
transition are preserved: what the incomplete lock recorded, and what
replaced it.
"""
evidence = dict(assessment.get("evidence") or {})
contract = dict(assessment.get("contract") or {})
return {
"recovery_kind": "incomplete_bootstrap_lock",
"recovered_at": recovered_at,
"prior_contract": contract.get("contract"),
"prior_missing_fields": list(contract.get("missing_fields") or []),
"prior_claimant_placement": evidence.get("claimant_placement"),
"prior_expiration_state": evidence.get("expiration_state"),
"prior_generation": evidence.get("recorded_generation"),
"prior_owner_session": evidence.get("recorded_owner_session"),
"prior_freshness": (evidence.get("freshness") or {}).get("status"),
"replacement_task_session_id": new_task_session_id,
"preserved_head": evidence.get("observed_head"),
"branch_reset": False,
"base_equivalence_required": False,
}
def format_recovery_refusal(assessment: Mapping[str, Any]) -> str:
reasons = "; ".join(
assessment.get("reasons") or ["unknown bootstrap lock recovery refusal"]
)
code = assessment.get("refusal_code") or "refused"
return f"Bootstrap lock recovery refused ({code}): {reasons} (fail closed)"
+26
View File
@@ -1568,6 +1568,32 @@ class ControlPlaneDB:
).fetchone() ).fetchone()
return dict(row) if row else None return dict(row) if row else None
def list_incident_links(
self,
*,
provider: str | None = None,
gitea_org: str | None = None,
gitea_repo: str | None = None,
limit: int = 100,
) -> list[dict[str, Any]]:
"""List stored incident_links rows, optionally filtered by provider/repo (#612 / #649)."""
query = "SELECT * FROM incident_links WHERE 1=1"
params: list[Any] = []
if provider:
query += " AND provider = ?"
params.append(provider.strip().lower())
if gitea_org:
query += " AND gitea_org = ?"
params.append(_norm_scope(gitea_org))
if gitea_repo:
query += " AND gitea_repo = ?"
params.append(_norm_scope(gitea_repo))
query += " ORDER BY link_id DESC LIMIT ?"
params.append(max(1, limit))
with self._tx(immediate=False) as conn:
rows = conn.execute(query, params).fetchall()
return [dict(r) for r in rows]
# ── lease lifecycle (#601) ──────────────────────────────────────────── # ── lease lifecycle (#601) ────────────────────────────────────────────
+209
View File
@@ -0,0 +1,209 @@
# The canonical author issue-lock contract (#953)
Every author issue lock has exactly one shape. Both writers —
`gitea_bootstrap_author_issue_worktree` and `gitea_lock_issue` — build it
through `author_lock_contract.build_canonical_issue_lock`, and every reader
consumes that same shape.
Before #953 the two writers disagreed. `gitea_lock_issue` wrote the canonical
record; bootstrap wrote a thinner one with the claimant at the lock top level,
`lease_id: null`, and no `work_lease`, `lock_provenance`, or expiry. Because
every reader was written against the canonical shape, a lock that bootstrap
reported as successfully created could not be heartbeated, renewed, re-locked,
or accepted by `gitea_create_pr`. Each of those gates was individually correct;
the defect was that two writers disagreed about what a lock *is*.
## Required ordering
**Finalize the lock before writing any implementation bytes.** This ordering is
what keeps recovery cheap: while the worktree is still base-equivalent, a lock
problem can be fixed by simply calling `gitea_lock_issue` again. Once the branch
carries commits, base-equivalence is gone and the ordinary re-lock path is no
longer available.
1. `gitea_whoami` — resolve identity and profile.
2. `gitea_resolve_task_capability(task='work_issue')`.
3. `gitea_bootstrap_author_issue_worktree` — creates the branch, the registered
worktree under `branches/`, and a **canonical** lock. It reads the lock back
and verifies it structurally before reporting success; a partial lock fails
closed here, with the missing fields named, and never reports
`implementation_allowed: true`.
4. `gitea_heartbeat_issue_lock` — prove the lock is usable, using the
`task_session_id` bootstrap returned.
5. Implement, commit, push.
6. `gitea_create_pr`.
If bootstrap returns `success: false` with
`reason_code: incomplete_issue_lock_contract`, **do not implement**. Its
`exact_next_action` names the executable recovery step. Bootstrap's reported
next action always matches the state it actually returned.
### What that refusal leaves behind
The AC7 refusal runs `run_compensating_recovery` *before* it reports, so the
advice has to describe the post-rollback state rather than the shape of the lock
that provoked it. Recommending incomplete-lock recovery for artifacts the
rollback already deleted would produce `no_durable_lock` and then
`worktree_invalid` — two refusals for a state a plain retry fixes.
The refusal therefore carries `compensating_recovery` and
`post_compensation_state`, and derives `exact_next_action` from what was
observed on disk. `cleanup_state` is one of:
| `cleanup_state` | Meaning | Next action |
| --- | --- | --- |
| `complete` | lock, branch, and worktree all removed | resolve `missing_fields` and re-run `gitea_bootstrap_author_issue_worktree` |
| `partial` | rollback ran; some artifacts survive, by design or because a step errored | scoped to exactly what survives — see below |
| `failed` | rollback never completed, so nothing is proven removed | `gitea_inspect_issue_lock_contract` (read-only) before anything else |
Within `partial`, the surviving set decides the action:
| Survives | Next action |
| --- | --- |
| lock + branch + worktree | `gitea_recover_incomplete_bootstrap_lock` for that exact issue, branch, and worktree |
| branch + worktree (lock released) | `gitea_lock_issue` — no implementation bytes were written, so the worktree is still base-equivalent |
| lock only (worktree removed) | `gitea_inspect_issue_lock_contract`; the surviving lock must be released by its recorded owner before bootstrap is retried |
| branch only | `gitea_inspect_issue_lock_contract`, then re-run bootstrap, which adopts the existing branch |
`failed_rollback_steps` names any rollback step that errored, and the returned
action says so rather than presenting the surviving state as intentional.
> The lock half of that rollback was dead code until #953 review 632 F2:
> `run_compensating_recovery` called `issue_lock_store.release_session_lock`,
> which did not exist, inside a bare `except Exception: pass`. Every rollback
> removed the branch and worktree and silently left the lock — the exact
> uninspectable, unrecoverable state this issue exists to eliminate. The
> function now exists, releases only a lock whose recorded `owner_session`
> matches, and its failures are recorded rather than swallowed.
## The contract
A canonical lock carries every field in
`author_lock_contract.REQUIRED_LOCK_FIELDS`:
| Field | Meaning |
| --- | --- |
| `remote`, `org`, `repo`, `issue_number` | repository and issue identity |
| `branch_name`, `worktree_path` | the binding this claim owns |
| `work_lease` | the canonical lease block, below |
| `lock_provenance` | sanctioned source, minted server-side |
| `lock_generation` | monotonic; every write advances it |
`work_lease` carries every field in
`author_lock_contract.REQUIRED_WORK_LEASE_FIELDS`, notably:
| Field | Meaning |
| --- | --- |
| `claimant.{username,profile}` | **canonical** claimant placement |
| `expires_at` | sliding TTL from `lease_policy` |
| `last_heartbeat_at`, `heartbeat_count` | liveness evidence |
| `task_session_id` | the ownership fencing token — never null |
| `lifecycle_version` | `heartbeat-v1`; its absence is what makes a lock legacy |
### Claimant placement and legacy compatibility
`work_lease.claimant` is canonical. A top-level `claimant` is the legacy
placement written by pre-#953 bootstrap and is still **read** — through the one
shared reader, `issue_lock_store.lock_claimant` — so an existing lock is not
refused for "not recording a claimant" when it plainly records one.
Tolerating the placement is not a widening. Every caller still compares the
values against server-resolved identity and profile, so a legacy placement
grants nothing the canonical placement would not. When both are present, the
`work_lease` copy wins: after an upgrade, a stale top-level copy must never
decide ownership.
### Expiration is explicit
A lock with no recorded expiry is **not** "not yet expired". `is_lease_expired`
returns `False` for it, which used to make such a lock permanently non-expiring
*and* permanently ineligible for #760 exact-owner renewal, which only ever
assesses an expired lease. `author_lock_contract.expiration_state` names the
real fact: `recorded`, `missing`, or `unparseable`. A `missing` expiry makes the
lock eligible for the recovery path below rather than stranding it.
## Recovering an existing incomplete bootstrap lock
For locks already written by the old bootstrap — including those whose branches
already carry legitimate committed and pushed work — use:
```text
gitea_inspect_issue_lock_contract(issue_number, branch_name, worktree_path, remote=...)
gitea_recover_incomplete_bootstrap_lock(issue_number, branch_name, worktree_path, expected_head, remote=...)
```
`gitea_inspect_issue_lock_contract` is strictly read-only: it performs no lock,
lease, branch, worktree, issue, or pull-request mutation. Use it first to see
which fields are missing and what the recommended action is; pass `dry_run=True`
to the recovery tool to preview the decision without writing.
`gitea_recover_incomplete_bootstrap_lock` upgrades that one lock to the
canonical contract. Before writing anything it verifies:
* repository (`remote`, `org`, `repo`) and issue number
* claimant username **and** profile against the server-resolved values — a
matching username alone is never accepted
* branch, worktree path, worktree existence, and worktree registration
* the worktree is on the recorded branch
* the observed head equals the caller's `expected_head`
* the existing lock's generation and provenance state
* the absence of healthy foreign ownership
What it deliberately does **not** do:
* it never moves, resets, or rewinds the branch, and never requires
base-equivalence — preserving the committed work is the entire point;
* it never pushes and never creates a pull request;
* it touches only the single lock file for that exact remote/org/repo/issue;
* it accepts no caller-supplied provenance and no caller-supplied authorization
flag — both are minted server-side.
A recovered lock records a `bootstrap_lock_recovery` block holding both sides of
the transition — prior contract, prior missing fields, prior generation, prior
owning session, the replacement `task_session_id`, and the preserved head — so a
recovered claim never reads as an original one.
### Gates, in order
`gitea_recover_incomplete_bootstrap_lock` is an author-only durable-lock
mutation and carries the same three gates as every comparable author operation,
in this order:
1. `role_session_router.check_author_mutation_after_reviewer_stop` — no author
fallback after a reviewer `wrong_role_stop`.
2. `_namespace_mutation_block(task, remote=remote, author_role_exclusive=True)`
the namespace wall. It refuses a reviewer-bound session and, because this
task's required permission is `gitea.issue.comment` (which merger,
controller, and reconciler profiles also hold), additionally requires the
active profile's derived role kind to be exactly `author`. A refusal carries
`namespace_block: true` and emits a `BLOCKED` audit record naming the
namespace and profile.
3. `_profile_permission_block` — operation, provenance, and session-context
gates.
Exact-owner claimant matching inside `assess_bootstrap_lock_recovery` runs
*after* all three. It is a further layer, never a substitute for them: on its
own it refuses one step too late and leaves the audit trail silent about the
attempt.
### Refusals
| `refusal_code` | Meaning |
| --- | --- |
| `no_durable_lock` | nothing to recover |
| `already_canonical` | lock is fine; rewriting would invalidate a live heartbeat token |
| `foreign_claimant` | recorded claimant is not the active identity/profile pair |
| `healthy_foreign_lock` | a live foreign-owned lock; takeover is not a recovery path |
| `identity_unresolved` | identity or profile could not be resolved |
| `binding_mismatch` | repository, issue, branch, or worktree does not match |
| `worktree_invalid` | worktree missing, unregistered, or on another branch |
| `head_mismatch` | the worktree moved under the caller |
## The #447 create-PR provenance guard is unchanged
`issue_lock_provenance.assess_lock_file_for_create_pr` still requires both a
sanctioned `lock_provenance` and a `work_lease`, and the sanctioned source set
was **not** widened. Bootstrap writes through
`issue_lock_provenance.SOURCE_LOCK_ISSUE` — the lock it produces *is* a
canonical lock, not a second dialect with its own exemption. Bootstrap now
satisfies the guard rather than the guard being relaxed to admit bootstrap.
+70
View File
@@ -0,0 +1,70 @@
# MCP Config Drift Diagnostic & Sanctioned Repair Runbook (#672)
This document describes the diagnostic framework for detecting configuration drift between the active IDE MCP configuration (`~/.gemini/antigravity-ide/mcp_config.json`) and the offline/global canonical configuration (`~/.gemini/config/mcp_config.json`), and establishes the **sanctioned repair runbook**.
## Background & Problem Statement
Offline tools like `test_mcp_conn.py` test the global configuration (`~/.gemini/config/mcp_config.json`) via `subprocess.Popen`. However, the active IDE/client namespace uses `~/.gemini/antigravity-ide/mcp_config.json`. When required Gitea role servers (`gitea-author`, `gitea-reviewer`, `gitea-merger`, `gitea-reconciler`, `gitea-controller`, `gitea-tools`) are missing or carry mismatched profile environments in the active IDE config:
1. Offline tests pass (`test_mcp_conn.py` green).
2. The IDE client returns `EOF` / `transport closed` when attempting role-scoped mutations.
3. Operators misdiagnose missing server definitions as stale runtimes, leading to forbidden `pkill` attempts (#630) or `mtime` hacks (#655).
## Diagnostic Tool: `mcp_config_drift.py`
Run the diagnostic tool directly to compare configurations:
```bash
python3 mcp_config_drift.py --json
```
Or specify custom config locations:
```bash
python3 mcp_config_drift.py \
--active-config ~/.gemini/antigravity-ide/mcp_config.json \
--global-config ~/.gemini/config/mcp_config.json
```
### Key Diagnostic Outputs
- `in_sync`: Boolean indicating if all required Gitea role servers exist in the active IDE config with matching profile declarations.
- `missing_role_servers`: List of role servers present in global config but missing from active IDE config.
- `profile_mismatches`: List of profile environment mismatches per server.
- `reasons`: Explicit, human-readable list of drift causes.
All returned payloads automatically redact secret tokens, DSNs, Authorization headers, and private keys.
---
## Sanctioned Repair Path (Step-by-Step)
When `mcp_config_drift.py` reports drift (`in_sync: false`), execute the following **sanctioned repair steps**:
1. **Backup Active IDE Config:**
```bash
cp ~/.gemini/antigravity-ide/mcp_config.json ~/.gemini/antigravity-ide/mcp_config.json.bak
```
2. **Patch Active IDE Config:**
Copy the missing Gitea role server JSON blocks (`gitea-author`, `gitea-reviewer`, etc.) from `~/.gemini/config/mcp_config.json` into `~/.gemini/antigravity-ide/mcp_config.json`.
3. **Reconnect via IDE/Client:**
Use the IDE / client UI reconnection control (or restart the IDE client app).
4. **Verify Active Namespace Health:**
Invoke `gitea_whoami` (and optional `gitea_resolve_task_capability`) through the active IDE client on each required role namespace.
---
## FORBIDDEN Repair Actions (#630 / #655)
The following actions are **strictly forbidden** for config drift repair:
- ❌ **`pkill` or manual daemon process kill commands:** Process kills cause contamination and break active session leases.
- ❌ **`mtime` touch edits:** Artificial mtime modifications mask stale runtimes without updating configuration.
- ❌ **Source code edits:** Mutating python tool logic to bypass missing server entries.
- ❌ **Session-state edits:** Direct database or lock-file state mutation.
---
## Final Report Guidelines
A workflow final report **must not** rely on offline `test_mcp_conn.py` output alone. Final reports must include active-config evidence from live `gitea_whoami` calls on the active IDE namespaces.
+21 -6
View File
@@ -47,18 +47,33 @@ Do the steps in order. Stop as soon as a live **client-namespace** call succeeds
- Only the Gitea namespace fails → single-namespace transport close. Continue. - Only the Gitea namespace fails → single-namespace transport close. Continue.
- Every server fails → restart the whole MCP client, not just one namespace. - Every server fails → restart the whole MCP client, not just one namespace.
2. **Reconnect the namespace through the client, not the shell.** Use the IDE / 2. **Request the sanctioned reconnect surface (#678), then reconnect through
client MCP-reconnect action for that server entry (in Claude Code: the client — not the shell.** From a still-reachable Gitea MCP namespace
`/mcp` → reconnect the affected `gitea-*` server). Reconnecting forces the (or after host auto-reconnect), call:
client to spawn a fresh subprocess and re-open the pipe. This clears the
closed-client state that a bare `kill`/respawn from a terminal does **not**. ```text
gitea_request_mcp_reconnect(
namespace="gitea-author", # or gitea-reviewer / gitea-merger / …
reason="transport_eof",
client="codex", # or claude_code / generic
)
```
The tool is **report-only**: it never restarts a process. It returns
namespace, profile, pid/session, startup SHA, current master SHA, boundary
status, and a **typed blocker** with exact operator UI steps for Codex
(Reload Developer Tools / per-server reconnect) or Claude Code (`/mcp`).
Then perform the host reconnect those steps describe so the client spawns a
fresh subprocess and re-opens the pipe. That clears the closed-client state
that a bare `kill`/respawn from a terminal does **not**.
3. **Do not "fix" it by importing the server or poking the process.** Reaching 3. **Do not "fix" it by importing the server or poking the process.** Reaching
for `python -c 'import gitea_mcp_server ...'`, raw JSON-RPC from a shell, for `python -c 'import gitea_mcp_server ...'`, raw JSON-RPC from a shell,
killing PIDs to force a respawn, or touching MCP config mtimes does **not** killing PIDs to force a respawn, or touching MCP config mtimes does **not**
restore the *client's* view of the namespace and violates the daemon-import restore the *client's* view of the namespace and violates the daemon-import
guard (#558, `docs/mcp-daemon-import-guard.md`). The only sanctioned repair guard (#558, `docs/mcp-daemon-import-guard.md`). The only sanctioned repair
is a **client reconnect / relaunch**. is a **client reconnect / relaunch** (or the typed operator path returned by
`gitea_request_mcp_reconnect`).
4. **Verify through the same path the workflow will use.** After reconnect, call 4. **Verify through the same path the workflow will use.** After reconnect, call
the specific tool the blocked workflow needs — not just any tool — through the specific tool the blocked workflow needs — not just any tool — through
+93
View File
@@ -0,0 +1,93 @@
# MCP restart audit events and incidents (#665)
Restarts and recovery attempts leave a forensic trail. Failed drains and
break-glass paths also raise durable Gitea incident issues so unsafe restarts
cannot be silently repeated.
Parent umbrella: **#655**. Related: impact coordinator **#658**, drain proof
**#661**, restart classes **#663**, break-glass **#664**, post-restart reconcile
**#662**, vision **#652**, roadmap **#653**, console recovery **#642**.
## Components
| Piece | Where | Responsibility |
|-------|-------|----------------|
| Event schema + emission | `restart_audit.py` | `mcp.restart.*` vocabulary, redacted payload builder, append-only sink via `gitea_audit` |
| Fail-closed privileged gate | `restart_audit.require_audit_or_deny` | When `GITEA_AUDIT_LOG` is set and the write fails, privileged apply is denied |
| Incident descriptors | `restart_audit.build_incident_descriptor` | Durable follow-up issues (failed drain, break-glass, reconcile unresolved, unguarded) |
| Materializer | `restart_audit.materialize_incident` | Injected `create_issue_fn` (network kept out of pure tests) |
| Wiring | `gitea_request_mcp_restart` | Correlation id, impact-preview audit, apply-gate / break-glass audit + incident creation |
## Event vocabulary
| Event type | When |
|------------|------|
| `mcp.restart.impact_preview` | Every `gitea_request_mcp_restart` evaluation |
| `mcp.restart.drain_enter` | Drain window starts (schema reserved; emit from drain path) |
| `mcp.restart.drain_exit` | Drain window ends |
| `mcp.restart.drain_proof` | Drain-proof verification result |
| `mcp.restart.apply_gate` | Apply hard gate (`dry_run=False`) |
| `mcp.restart.break_glass` | Authorized break-glass bypass |
| `mcp.restart.post_restart_reconcile` | Post-restart reconcile outcome |
| `mcp.restart.narrower_recovery` | Narrower recovery attempt recorded |
| `mcp.restart.unguarded_detected` | Unguarded restart path detected |
All free text is redacted before sink write or issue body assembly. Emission
never raises; callers decide fail-closed policy.
## Correlation
Each restart lifecycle mints a short `correlation_id` (`rst-` + 16 hex) shared
across impact preview → apply gate → incident descriptors so operators can join
the trail.
## Privileged deny-on-audit-fail
Rollout policy (issue #665):
1. Configure `GITEA_AUDIT_LOG` so writes land.
2. Only then enforce deny when a privileged restart path cannot audit.
When audit is **not** configured, privileged apply still proceeds (no false
denials during rollout). When audit **is** configured and the write fails,
`apply_authorized` is cleared.
## Incidents
| Kind | Trigger |
|------|---------|
| `restart_failed_drain` | Apply denied by drain hard gate / failed proof |
| `restart_break_glass` | Any authorized break-glass apply |
| `restart_reconcile_unresolved` | Post-restart reconcile left work unresolved |
| `restart_unguarded_detected` | Unguarded restart attempt detected |
Break-glass **always** creates an incident descriptor (and a Gitea issue when
the create path is available). Failed drain does the same. Incident bodies
include correlation id, session, class, scope, proof id, and redacted reasons.
Default labels: `mcp-health`, `safety`, `observability`, `status:ready`,
`type:bug`, `workflow-hardening`.
## Tool payload surface
`gitea_request_mcp_restart` returns:
* `correlation_id` — lifecycle join key
* `restart_audit.impact_preview_written` — sink success for the preview event
* `restart_audit.apply_gate_written` — sink success for apply/break-glass (apply only)
* `restart_audit.incident_result` — materialization outcome when an incident was required
* `incident` — durable descriptor (when gate requires follow-up)
## Security
* No secrets in audit payloads or issue bodies.
* This module never restarts a process.
* Drain proof verification remains #661; audit only records the decision.
* Incident creation failures are recorded in `incident_result.reasons` and never
crash the restart evaluation path (audit write failure still fails closed for
privileged apply when the sink is enabled).
## Tests
See `tests/test_restart_audit.py`: schema, redaction, emission, deny policy,
incident materialization mocks, break-glass / failed-drain selection.
+1
View File
@@ -33,6 +33,7 @@ recovery behavior for all nine classes.
| `ControlPlaneDB.list_sessions` | `control_plane_db.py` | Read-only session inventory (the process-level unit a restart kills). | | `ControlPlaneDB.list_sessions` | `control_plane_db.py` | Read-only session inventory (the process-level unit a restart kills). |
| `gitea_request_mcp_restart` | `gitea_mcp_server.py` | MCP tool: gathers inventory from the #613 DB, calls the coordinator, returns the report, and on `dry_run=False` runs the #661 drain-proof hard gate. Never restarts a process. | | `gitea_request_mcp_restart` | `gitea_mcp_server.py` | MCP tool: gathers inventory from the #613 DB, calls the coordinator, returns the report, and on `dry_run=False` runs the #661 drain-proof hard gate. Never restarts a process. |
| `drain_proof.gate_apply_restart` | `drain_proof.py` | The #661 hard gate: verifies a drain proof against the current impact fingerprint, or records an authorized break-glass bypass. | | `drain_proof.gate_apply_restart` | `drain_proof.py` | The #661 hard gate: verifies a drain proof against the current impact fingerprint, or records an authorized break-glass bypass. |
| `restart_audit` | `restart_audit.py` | #665 forensic trail: `mcp.restart.*` events via `gitea_audit`, correlation ids, durable incidents for failed drain / break-glass. See [`mcp-restart-audit.md`](./mcp-restart-audit.md). |
## Dimensions evaluated ## Dimensions evaluated
+3 -2
View File
@@ -45,10 +45,11 @@ and *fails closed*.
| `legacy_auto_restart_helper` | removed | A helper (`_trigger_mcp_auto_restart`) that actively restarted the server from the read-only resolver path. | Removed in #685; kept absent by `assert_auto_restart_helper_absent()`. | #685, #657 | | `legacy_auto_restart_helper` | removed | A helper (`_trigger_mcp_auto_restart`) that actively restarted the server from the read-only resolver path. | Removed in #685; kept absent by `assert_auto_restart_helper_absent()`. | #685, #657 |
| `config_touch_reload` | removed | Touching (utime) the MCP client config to make the host reload the server. | Removed from the resolver in #685: stale detection is report-only, never mutating config, spawning threads, or calling `os._exit`. | #685, #657 | | `config_touch_reload` | removed | Touching (utime) the MCP client config to make the host reload the server. | Removed from the resolver in #685: stale detection is report-only, never mutating config, spawning threads, or calling `os._exit`. | #685, #657 |
| `master_advance_auto_restart` | guarded_fail_closed | On-disk master advancing past the running code. | `master_parity_gate` captures startup parity and blocks mutations while stale, emitting restart guidance; the process never self-restarts. | #420, #591, #657 | | `master_advance_auto_restart` | guarded_fail_closed | On-disk master advancing past the running code. | `master_parity_gate` captures startup parity and blocks mutations while stale, emitting restart guidance; the process never self-restarts. | #420, #591, #657 |
| `stale_runtime_resolver_reconnect` | guarded_fail_closed | The capability resolver detecting a stale serving process. | Report-only (#685): returns `restart_required`/`stop_required` and an exact reconnect action; no restart, thread, config touch, or `os._exit`. | #685, #657 | | `stale_runtime_resolver_reconnect` | guarded_fail_closed | The capability resolver detecting a stale serving process. | Report-only (#685): returns `restart_required`/`stop_required` and an exact reconnect action; no restart, thread, config touch, or `os._exit`. | #685, #657, #678 |
| `codex_client_reconnect_request` | guarded_fail_closed | `gitea_request_mcp_reconnect` report-only tool for Codex/LLM sessions. | Report-only (#678): returns namespace/profile/pid/startup SHA/master SHA/boundary status and a typed operator blocker with exact client UI steps; never restarts or kills. | #678, #630, #685, #657 |
| `manual_daemon_kill` | forbidden | Shell kills of the daemon: `pkill -f mcp_server.py`, `killall`, broad `pkill -f python` sweeps, or `kill <pid>` of a daemon pid. | Forbidden (#630): `runtime_recovery_guard` classifies these as contamination and `gitea_record_daemon_process_kill_attempt` writes a durable marker that fails later mutations closed. Operator maintenance authorization is read only from the environment. | #630, #657 | | `manual_daemon_kill` | forbidden | Shell kills of the daemon: `pkill -f mcp_server.py`, `killall`, broad `pkill -f python` sweeps, or `kill <pid>` of a daemon pid. | Forbidden (#630): `runtime_recovery_guard` classifies these as contamination and `gitea_record_daemon_process_kill_attempt` writes a durable marker that fails later mutations closed. Operator maintenance authorization is read only from the environment. | #630, #657 |
| `conflict_marker_infra_stop` | guarded_fail_closed | The daemon entrypoint scans for unresolved merge-conflict markers at startup and stops (`sys.exit(1)`). | Fail-closed startup stop, not a restart: the process exits and waits for the operator to resolve conflicts and relaunch; never loops. | #657 | | `conflict_marker_infra_stop` | guarded_fail_closed | The daemon entrypoint scans for unresolved merge-conflict markers at startup and stops (`sys.exit(1)`). | Fail-closed startup stop, not a restart: the process exits and waits for the operator to resolve conflicts and relaunch; never loops. | #657 |
| `ide_client_reconnect` | host_residual | A manual `/mcp reconnect` (or equivalent host action) that recreates the MCP client connection. | Outside this process's control; the sanctioned recovery the gates point operators toward. No in-process code initiates it. | #584, #656, #657 | | `ide_client_reconnect` | host_residual | A manual `/mcp reconnect` (or equivalent host action) that recreates the MCP client connection. Agents obtain exact UI steps via `gitea_request_mcp_reconnect` (#678). | Outside this process's control; the sanctioned recovery the gates point operators toward. No in-process code initiates it. | #584, #656, #657, #678 |
| `profile_switch_runtime` | sanctioned_narrow_recovery | Switching the active execution profile at runtime (dynamic-profile mode). | In-process and restart-free: `runtime_switching_supported` is true, so a switch rebinds capability without recreating the process. | #656, #657 | | `profile_switch_runtime` | sanctioned_narrow_recovery | Switching the active execution profile at runtime (dynamic-profile mode). | In-process and restart-free: `runtime_switching_supported` is true, so a switch rebinds capability without recreating the process. | #656, #657 |
## Guards enforced in CI ## Guards enforced in CI
+3
View File
@@ -103,6 +103,7 @@ that gates each call, not which tools exist.
- `gitea_get_shell_health` - `gitea_get_shell_health`
- `gitea_heartbeat_issue_lock` - `gitea_heartbeat_issue_lock`
- `gitea_heartbeat_reviewer_pr_lease` - `gitea_heartbeat_reviewer_pr_lease`
- `gitea_inspect_issue_lock_contract`
- `gitea_inspect_workflow_lease` - `gitea_inspect_workflow_lease`
- `gitea_issue_irrecoverable_provenance_authorization` - `gitea_issue_irrecoverable_provenance_authorization`
- `gitea_list_dependency_edges` - `gitea_list_dependency_edges`
@@ -134,9 +135,11 @@ that gates each call, not which tools exist.
- `gitea_record_pre_review_command` - `gitea_record_pre_review_command`
- `gitea_record_shell_spawn_outcome` - `gitea_record_shell_spawn_outcome`
- `gitea_record_stable_branch_push_attempt` - `gitea_record_stable_branch_push_attempt`
- `gitea_recover_incomplete_bootstrap_lock`
- `gitea_release_merger_pr_lease` - `gitea_release_merger_pr_lease`
- `gitea_release_reviewer_pr_lease` - `gitea_release_reviewer_pr_lease`
- `gitea_release_workflow_lease` - `gitea_release_workflow_lease`
- `gitea_request_mcp_reconnect`
- `gitea_request_mcp_restart` - `gitea_request_mcp_restart`
- `gitea_resolve_task_capability` - `gitea_resolve_task_capability`
- `gitea_resume_review_draft` - `gitea_resume_review_draft`
@@ -0,0 +1,35 @@
# Web Console: Sentry/GlitchTip Observability & Incident Bridge Console (#649)
This document describes the Phase 4 observability console surface integrated into the MCP Control Plane Web Console (`webui/`), backed by the #612 incident bridge and the #613 control-plane DB substrate.
## Architectural Authority Model (ADR Alignment)
Per the Web Console Architecture ADR (`docs/architecture/webui-control-plane-console-architecture-adr.md`):
| Layer | Responsibility | Authority |
|---|---|---|
| **Gitea** | Durable work record | Issues, PRs, comments, reviews, labels, merges |
| **Control-plane DB** | Live coordination & linkage | `incident_links` table, session leases, allocations |
| **Sentry / GlitchTip** | Observability input | Unresolved incidents, error events, stack traces |
| **Incident Bridge (#612)** | Reconciliation engine | Reconciles provider observations into Gitea issues |
| **Web Console (`webui/`)** | Read-only projection & gated actions | Projects connection health & correlation links; gates writes |
> **Key Rule:** Raw monitoring incidents are **never** assignable control-plane `work_items`. They remain observation input only.
## Redaction Boundary Invariants
1. **No secrets in returns or rendering:** Auth tokens (`SENTRY_AUTH_TOKEN`, `GLITCHTIP_AUTH_TOKEN`), DSNs, `Authorization` headers, and sensitive local file paths are passed through `webui.console_redaction` before leaving the server.
2. **Safe projection:** Connection objects report `credentials_present: true/false` rather than exposing raw keys or headers.
## Console Endpoints
- **HTML Surface:** `GET /observability` — Renders provider connection cards, error correlation tables, and gated reconcile controls.
- **Versioned API:** `GET /api/v1/observability` — Returns structured JSON snapshot with `schema_version`, `providers`, `links`, and `metrics`.
- **Legacy Compatibility Alias:** `GET /api/observability` — Read-only compatibility alias for Phase 4.
## Gated Actions
- `observability_reconcile_incident` (`gitea_observability_reconcile_incident`): Triggers or previews dry-run issue reconciliation for a provider incident.
- `observability_link_issue` (`gitea_observability_link_issue`): Links a provider incident to an existing Gitea tracking issue.
Both actions require `operator` role and gate through `task_capability_map`. Execution fails closed in read-only MVP mode.
+80
View File
@@ -0,0 +1,80 @@
{
"_comment": [
"Machine-checkable anchor table for docs/remote-mcp/threat-model.md (#956).",
"Every file:line anchor cited in the threat model must appear here, and the",
"source line at that anchor must contain the 'expect' substring.",
"tests/test_issue_956_threat_model.py enforces both directions, so a refactor",
"that shifts a line number fails the suite instead of silently rotting the",
"document. #930's inventory had no such guard and its gitea_mcp_server.py",
"anchors drifted between 7bf4f125 and aad5c8b4."
],
"generated_against_commit": "aad5c8b42361d380a8eeb07b94b90815e594c2c5",
"anchors": [
{"anchor": "gitea_mcp_server.py:24721", "expect": "bind_native_mcp_transport(transport=\"stdio\")"},
{"anchor": "mcp_daemon_guard.py:45", "expect": "_PRODUCTION_TRANSPORTS = frozenset({\"stdio\"})"},
{"anchor": "mcp_daemon_guard.py:174", "expect": "def bind_native_mcp_transport"},
{"anchor": "irrecoverable_provenance.py:497", "expect": "def assess_transport_for_auth_mint"},
{"anchor": "gitea_mcp_server.py:9129", "expect": "assess_transport_for_auth_mint()"},
{"anchor": "gitea_mcp_server.py:9378", "expect": "assess_transport_for_auth_mint()"},
{"anchor": "mcp_server.py:4", "expect": "Runs over stdio."},
{"anchor": "gitea_mcp_server.py:15412", "expect": "def _is_client_managed_process"},
{"anchor": "gitea_mcp_server.py:15442", "expect": "def _provenance_mutation_block"},
{"anchor": "gitea_mcp_server.py:15450", "expect": "unsupported_manual_launch"},
{"anchor": "gitea_mcp_server.py:19001", "expect": "server_provenance"},
{"anchor": "gitea_mcp_server.py:21442", "expect": "def _check_mcp_runtimes_diagnostics"},
{"anchor": "gitea_mcp_server.py:21462", "expect": "\"ps\", \"-o\", \"pid,lstart,command\""},
{"anchor": "gitea_mcp_server.py:21506", "expect": "\"ps\", \"eww\""},
{"anchor": "gitea_config.py:1172", "expect": "RECOGNIZED_GITEA_ENV_KEYS"},
{"anchor": "gitea_config.py:1233", "expect": "GITEA_CLIENT_MANAGED"},
{"anchor": "gitea_config.py:54", "expect": "ENV_PROFILE = \"GITEA_MCP_PROFILE\""},
{"anchor": "gitea_config.py:97", "expect": "_REVIEW_MERGE_OPS"},
{"anchor": "gitea_config.py:499", "expect": "repository authorization scope"},
{"anchor": "gitea_config.py:956", "expect": "def _keychain_token"},
{"anchor": "gitea_config.py:974", "expect": "def resolve_token"},
{"anchor": "gitea_config.py:1015", "expect": "def keychain_auth"},
{"anchor": "gitea_config.py:294", "expect": "def _validate_identity_auth"},
{"anchor": "mcp_daemon_guard.py:440", "expect": "def assert_keychain_access_allowed"},
{"anchor": "gitea_mcp_server.py:19258", "expect": "def gitea_list_profiles"},
{"anchor": "gitea_mcp_server.py:19309", "expect": "gitea_config.resolve_token(p)"},
{"anchor": "gitea_mcp_server.py:19552", "expect": "def gitea_audit_config"},
{"anchor": "gitea_mcp_server.py:19574", "expect": "service_summaries(config)"},
{"anchor": "gitea_config.py:704", "expect": "def resolve_service"},
{"anchor": "gitea_config.py:837", "expect": "def service_summaries"},
{"anchor": "gitea_config.py:851", "expect": "_keychain_token(auth.get(\"id\"))"},
{"anchor": "gitea_mcp_server.py:17707", "expect": "\"jenkins-mcp\""},
{"anchor": "gitea_mcp_server.py:17713", "expect": "external-mcp"},
{"anchor": "gitea_mcp_server.py:17734", "expect": "\"glitchtip-mcp\""},
{"anchor": "gitea_mcp_server.py:17739", "expect": "external-mcp"},
{"anchor": "mcp_discoverability.py:9", "expect": "EXPECTED_JENKINS_TOOLS"},
{"anchor": "mcp_discoverability.py:17", "expect": "EXPECTED_GLITCHTIP_TOOLS"},
{"anchor": "sentry_incident_bridge.py:36", "expect": "SENTRY_AUTH_TOKEN"},
{"anchor": "sentry_incident_bridge.py:190", "expect": "def resolve_token"},
{"anchor": "sentry_incident_bridge.py:289", "expect": "Authorization"},
{"anchor": "sentry_observability.py:55", "expect": "SENTRY_DSN"},
{"anchor": "master_parity_gate.py:168", "expect": "def capture_startup_parity"},
{"anchor": "master_parity_gate.py:255", "expect": "mutation_safe"},
{"anchor": "gitea_mcp_server.py:19102", "expect": "def gitea_assess_master_parity"},
{"anchor": "gitea_mcp_server.py:190", "expect": "ACTIVE_WORKTREE_ENV"},
{"anchor": "gitea_mcp_server.py:191", "expect": "AUTHOR_WORKTREE_ENV"},
{"anchor": "gitea_mcp_server.py:2348", "expect": "/tmp/gitea_issue_lock.json"},
{"anchor": "gitea_mcp_server.py:10894", "expect": "def gitea_bootstrap_author_issue_worktree"},
{"anchor": "mcp_server.py:10", "expect": "/tmp/mcp_server_stderr.log"},
{"anchor": "issue_lock_store.py:26", "expect": "DEFAULT_LOCK_DIR"},
{"anchor": "issue_lock_store.py:83", "expect": "def session_pointer_path"},
{"anchor": "issue_lock_store.py:98", "expect": "def is_process_alive"},
{"anchor": "mcp_session_state.py:27", "expect": "DEFAULT_STATE_DIR"},
{"anchor": "control_plane_db.py:47", "expect": "DEFAULT_DB_PATH"},
{"anchor": "control_plane_db.py:380", "expect": "mode=0o700"},
{"anchor": "control_plane_db.py:386", "expect": "sqlite3.connect"},
{"anchor": "control_plane_db.py:1145", "expect": "os.getpid()"},
{"anchor": "gitea_mcp_server.py:12801", "expect": "owner_pid_alive"}
]
}
+403
View File
@@ -0,0 +1,403 @@
# Remote-MCP threat model, trust boundaries, and service decomposition
What the adversary is, what each boundary protects, and which services may share a process.
- **Issue:** #956 (Remote-MCP threat model), child of epic #929, cross-linked to #955.
- **Depends on:** #930 (closed) — `docs/remote-mcp/coupling-inventory.md`.
- **Blocks:** #932, #933, #934, #938.
- **Generated against commit:** `aad5c8b42361d380a8eeb07b94b90815e594c2c5` (`master`).
- **Scope:** documentation only. This child changes no server behavior. It adds one
document, one anchor fixture, and the test that enforces them.
## Relationship to #930
#930 asked *what breaks when the process stops being local*. This document asks *what an
attacker gets, and where we stop them*. The two are deliberately different axes: #930
classifies each coupling as portable, seam, replacement, or cannot-be-remote; this document
classifies each **credential** by blast radius and each **boundary** by what crossing it
requires. An entry can be perfectly portable and still be a trust disaster —
`gitea_config.py:851` is portable Python that reads a CI secret from inside the Gitea server.
### Anchors are enforced, not asserted
Every `file:line` in this document is declared in `docs/remote-mcp/threat-model-anchors.json`
with the substring that must appear at that line, and
`tests/test_issue_956_threat_model.py` fails if any anchor does not resolve or if the
document cites an anchor the fixture does not cover.
This guard exists because #930 did not have one. Its inventory was generated at
`7bf4f125`; by `aad5c8b4` its `gitea_mcp_server.py` anchors had drifted — the transport
bind it cited at line 23750 now lives at `gitea_mcp_server.py:24721`, and its
client-managed provenance anchor at 14588 now lands in an unrelated function. Nothing
failed, because nothing checked. Anchors into a ~24,700-line module rot silently, and a
security document that cannot prove its own citations is worse than none, because it is
trusted.
---
## 1. Assets
What an adversary wants. Ordered by consequence, not by likelihood.
| ID | Asset | Why it matters |
| -- | ----- | -------------- |
| A1 | Merge authority on `Scaled-Tech-Consulting/Gitea-Tools` | This repository *is* the control plane. Code merged here becomes the gate that authorizes every future mutation, so merge authority is self-amplifying: one merge can disable every other control in this document. |
| A2 | Write authority on the `mdcps` tenant | A second, unrelated organization reachable from the same configuration. Compromise here is a cross-organization incident, not an internal one. |
| A3 | The eight Gitea role credentials | Long-lived bearer tokens. Possession is authority; there is no second factor at the API. |
| A4 | Jenkins read access (`mdcps`, enabled) | Build logs routinely carry deployment topology, internal hostnames, and accidentally-echoed secrets. |
| A5 | Error-tracking read access (GlitchTip / Sentry) | Event payloads carry stack frames, request context, and production user data. |
| A6 | Coordination-state integrity | The locks, leases, and review-decision records that make "exactly one owner" true. Corrupting them needs no Gitea credential and produces duplicate or lost work. |
| A7 | The operator's checkout and worktrees | Unmerged code, branch state, and the filesystem the author tools write to. |
| A8 | The macOS login keychain | The meta-credential. Everything in A3, A4, and A5 resolves from it. |
| A9 | Separation of duty between review and merge | The property that no single actor both approves and lands a change. An *asset*, not a control, because it is what the controls exist to produce. |
| A10 | Audit and provenance records | Determine whether an incident is reconstructable. An attacker who can forge provenance makes an intrusion indistinguishable from normal work. |
## 2. Adversaries
| ID | Adversary | Capability assumed | Not assumed |
| -- | --------- | ------------------ | ----------- |
| ADV1 | **Compromised LLM client** | Full control of one MCP client. Issues arbitrary tool calls, in any order, with any arguments, at machine speed. Sees every tool result. | Cannot read the operator's disk except through tools; cannot execute arbitrary local code outside the tool surface. |
| ADV2 | **Prompt injection** via repository content | Controls text the model reads and treats as instruction — issue bodies, PR descriptions, review comments, commit messages, file contents. Reaches the model on any read of untrusted content. | Holds no credential and issues no call directly. Its entire power is causing an *authorized* client to act. |
| ADV3 | **Malicious tool arguments** | Supplies hostile values to any parameter — paths, branch names, session identifiers, worktree paths, issue numbers — including traversal, injection, and confusion between look-alike identifiers. | Cannot bypass a gate that actually validates its input. |
| ADV4 | **Network attacker** | Observes and modifies traffic between client, server, and Gitea. Attempts downgrade, replay, and endpoint impersonation. | Does not hold a valid credential at the start. |
| ADV5 | **Curious operator** | Legitimate local access to the workstation: process table, `/tmp`, home directory, keychain prompts. Not malicious, but not authorized for every role either. | Does not defeat the OS keychain's own access control without a prompt. |
ADV2 is the adversary this architecture most under-models. Every other adversary must first
obtain something. Prompt injection obtains nothing: it borrows authority the client already
holds and is indistinguishable at the tool boundary from legitimate work. Each boundary
below therefore states whether it constrains ADV2 at all — and most do not, because they
authenticate the *caller*, not the *intent*.
## 3. Trust boundaries
"Crossing requires today" is what the code actually enforces at
`aad5c8b42361d380a8eeb07b94b90815e594c2c5`, not what the design intends.
| ID | Boundary | Protects | Crossing requires today | Crossing must require remotely |
| -- | -------- | -------- | ----------------------- | ------------------------------ |
| B1 | LLM client ↔ MCP server session | A1, A3, A10 — that a mutating session was established through the sanctioned client path | A literal `stdio` bind (`gitea_mcp_server.py:24721`) inside a closed allowlist (`mcp_daemon_guard.py:45`, `mcp_daemon_guard.py:174`); client-managed provenance (`gitea_mcp_server.py:15412`) or a refusal (`gitea_mcp_server.py:15450`); production transport before recovery-authorization mint (`irrecoverable_provenance.py:497`, consumed at `gitea_mcp_server.py:9129` and `gitea_mcp_server.py:9378`) | An authenticated handshake issuing a server-side session identity bound to a principal, with the transport recorded in provenance. The physical proof (a pipe) must become a cryptographic one. |
| B2 | Role ↔ role | A9 — that author, reviewer, merger, and reconciler are distinct authorities | **The process boundary only.** The role is a property of the process, read once from `GITEA_MCP_PROFILE` (`gitea_config.py:54`). A caller gets author permissions by connecting to the author process. Review and merge are the operations singled out for extra care (`gitea_config.py:97`) | A per-request principal, so the role follows from the credential presented and cannot be selected by reaching a different endpoint. |
| B3 | MCP server ↔ credential store | A3, A8 — that only sanctioned code turns a profile into a token | `_keychain_token` shelling out to the login keychain (`gitea_config.py:956`), dispatched by `resolve_token` (`gitea_config.py:974`) with the reference type built at `gitea_config.py:1015`, gated by `assert_keychain_access_allowed` (`mcp_daemon_guard.py:440`). Inline secrets are rejected at config load (`gitea_config.py:294`) | A credential provider keyed by the *request* principal, returning only that principal's credential, with the source recorded and the value never returned. |
| B4 | MCP server ↔ Gitea | A1, A2 — that only authorized calls reach the forge | A bearer token over TLS. Server-side, nothing distinguishes one role's token from another beyond the account it belongs to | Unchanged at the forge; the endpoint in front of it must refuse unauthenticated and plaintext connections before tool dispatch. |
| B5 | MCP server ↔ caller's filesystem | A7 — that a tool acts on the *caller's* disk or refuses | Nothing. The server's disk *is* the caller's disk. Worktree bootstrap writes directly (`gitea_mcp_server.py:10894`); the active workspace is process-global (`gitea_mcp_server.py:190`, `gitea_mcp_server.py:191`) | An explicit per-tool classification, enforced at dispatch, refusing filesystem tools over a transport that cannot reach the caller's disk. A green verdict about the wrong disk is the failure to prevent. |
| B6 | MCP server ↔ coordination state | A6, A9 — mutual exclusion | Local files and a local SQLite database, with liveness judged from the local process table (`issue_lock_store.py:98`), keyed on paths under one user's home (`issue_lock_store.py:26`, `mcp_session_state.py:27`, `control_plane_db.py:47`) and on `os.getpid()` (`control_plane_db.py:1145`, `gitea_mcp_server.py:12801`). A legacy global slot still exists at `gitea_mcp_server.py:2348`, and the session-pointer file is named per PID (`issue_lock_store.py:83`) | One authority per ownership question, with liveness from session identity and expiry, and atomic acquire, renew, and release across hosts. |
| B7 | Gitea integration ↔ unrelated integrations | A4, A5 — that a Gitea compromise is not a CI and observability compromise | **Nothing.** See §5. The Gitea server reads Jenkins and GlitchTip secrets (`gitea_config.py:851`, reached from `gitea_config.py:837`) and holds the Sentry token (`sentry_incident_bridge.py:190`) | A hard process boundary. This is the boundary #956 exists to create. |
| B8 | Tenant ↔ tenant (`prgs` / `mdcps` / `local-lab`) | A2 — that one organization's compromise is not another's | Convention. One configuration declares all three contexts; `resolve_service` fails closed on a *disabled* context (`gitea_config.py:704`) but the credentials of enabled ones remain reachable in-process. A per-profile repository scope exists (`gitea_config.py:499`) | Separate deployments, or at minimum per-tenant credential scopes with no process able to resolve both. |
| B9 | Deployed code ↔ merged policy | A1, A10 — that the running server enforces the rules that were actually merged | Comparing this process's startup commit against this disk (`master_parity_gate.py:168`), conjoined into a single verdict (`master_parity_gate.py:255`) published by `gitea_mcp_server.py:19102` | Freshness defined against the deployed build identity, with an explicit fail-closed verdict when undeterminable. |
### What no boundary constrains
None of B1B9 constrains **ADV2**. Every one authenticates a caller or a process; prompt
injection supplies neither. An injected instruction that reaches an authorized author
session crosses B1, B2, B3, and B5 legitimately, because at each of those boundaries it *is*
the author. The only controls that bite ADV2 are those constraining what an authenticated
principal may do regardless of what it asks for — the per-role permission split (B2), the
repository scope at `gitea_config.py:499`, and separation of duty (A9). Sizing those
controls correctly matters more after the migration, not less, because a remote endpoint
raises the number of clients that can be injected into.
## 4. Data flows
Flows that cross a boundary. `==>` carries a credential; `-->` does not.
```
B1 B4
[LLM client] ====================> [MCP server] ========> [Gitea]
^ stdio pipe today | ^ (A1,A2)
| session identity | |
| after migration | |
| | | B3
untrusted repository content | +======> [macOS login keychain] (A8)
read back into the model (ADV2) | resolves A3, A4, A5
^ |
+----------------------------------+
|
B5 | B6
[operator checkout / worktrees] <--------+-------> [locks · leases · sqlite]
(A7) | (A6)
|
B7 <-- boundary does not exist today
|
+========================+========================+
| | |
[Jenkins] (A4) [GlitchTip] (A5) [Sentry] (A5)
external MCP server external MCP server in-process bridge
```
Two flows deserve attention because neither is obvious from the code:
1. **The keychain flow fans out.** B3 is drawn once but resolves credentials for *every*
configured profile and service, not only the active one. `gitea_list_profiles`
(`gitea_mcp_server.py:19258`) reports each profile's credential status by calling
`resolve_token` on it (`gitea_mcp_server.py:19309`), and `gitea_audit_config`
(`gitea_mcp_server.py:19552`) reports service credential status through
`service_summaries` (`gitea_mcp_server.py:19574`).
2. **The return path is a flow too.** Content read from Gitea travels back into the model
and is treated as instruction. This is the ADV2 edge, and it is the only edge in the
diagram with no authentication on it, because it is not a request.
## 5. Per-boundary credential inventory
**14 credentials in total.** Blast radius is stated as what the credential yields *on its
own*, assuming every gate not backed by the credential itself has been bypassed — because
an attacker holding a token calls the API, not our tools.
| ID | Credential | Holder | Boundary | Blast radius |
| -- | ---------- | ------ | -------- | ------------ |
| CR1 | `prgs-author` Gitea token — account `jcwalker3` | macOS keychain; resolved in-process (`gitea_config.py:974`) | B3 → B4 | Create branches, push, commit, open PRs, create/close/comment issues on the control-plane repo. Cannot approve or merge. The one credential whose identity is genuinely distinct. |
| CR2 | `prgs-reviewer` Gitea token — account `sysadmin` | macOS keychain | B3 → B4 | Approve and request changes. **Shares one Gitea account with CR3, CR4, CR5.** |
| CR3 | `prgs-merger` Gitea token — account `sysadmin` | macOS keychain | B3 → B4 | Merge to `master` — A1 in full. Same account as CR2. |
| CR4 | `prgs-reconciler` Gitea token — account `sysadmin` | macOS keychain | B3 → B4 | Close PRs, delete branches, irrecoverable decision-lock recovery. Same account as CR2. |
| CR5 | `prgs-controller` Gitea token — account `sysadmin` | macOS keychain | B3 → B4 | Same operation set as CR4. Same account as CR2. |
| CR6 | `mdcps-author` Gitea token — account `913443` | macOS keychain | B3 → B4, B8 | Author operations on a second organization. **Shares one account with CR7 and CR8.** |
| CR7 | `mdcps-reviewer` Gitea token — account `913443` | macOS keychain | B3 → B4, B8 | Approve and request changes on `mdcps`. Same account as CR6. |
| CR8 | `mdcps-merger` Gitea token — account `913443` | macOS keychain | B3 → B4, B8 | Merge on `mdcps` — A2 in full. Same account as CR6. |
| CR9 | MDCPS Jenkins read credential | macOS keychain, read from the Gitea server process (`gitea_config.py:851`) | B7 | Read CI jobs, builds, and logs (A4). Enabled today. |
| CR10 | MDCPS GlitchTip read credential | macOS keychain, read from the Gitea server process (`gitea_config.py:851`) | B7 | Read error events and their payloads (A5). Enabled today. |
| CR11 | `SENTRY_AUTH_TOKEN` | Process environment, read in-process (`sentry_incident_bridge.py:36`, `sentry_incident_bridge.py:190`), sent as a bearer header (`sentry_incident_bridge.py:289`) | B7 | Read and reconcile Sentry issues (A5). Not a keychain credential — an env var, so it is inherited by anything the process spawns. |
| CR12 | `SENTRY_DSN` | Process environment (`sentry_observability.py:55`) | B7 | Write events into the observability project. Low read value, real forgery value: an attacker can inject fabricated events into the record (A10). |
| CR13 | macOS login keychain access | The operator's login session; gated by `assert_keychain_access_allowed` (`mcp_daemon_guard.py:440`) | B3, ADV5 | **Every other credential in this table except CR11 and CR12.** This is the aggregation point. |
| CR14 | Coordination-store access (no secret) | Filesystem permissions — `control_plane_db.py:47`, created `0o700` (`control_plane_db.py:380`), opened with a local file lock (`control_plane_db.py:386`) | B6, ADV5 | Full read/write of locks, leases, and decision records (A6). **There is no credential here at all** — anything running as the operator can rewrite ownership. |
### Findings
**Finding 1 — Role separation is not credential separation.** Four `prgs` roles resolve to
one Gitea account (`sysadmin`): reviewer, merger, reconciler, and controller. A stolen
reviewer credential *is* a merger credential. A9 — separation of duty between approving and
landing — is therefore enforced entirely by which local process a call reaches (B2), and not
at all by the forge. It survives exactly as long as B2 does, and B2 is the boundary the
migration dissolves.
**Finding 2 — The `mdcps` tenant has no role separation at all.** Author, reviewer, and
merger all resolve to account `913443`. One credential can open a PR, approve it, and merge
it. The in-process self-review check compares the authenticated username against the PR
author and would refuse — but that check runs on our side of B4. It is not a property of
the credential, and an attacker holding the token does not call our tools.
**Finding 3 — Any one role process can resolve every other role's credential.** This is not
inferred; it is demonstrated by tool output. `gitea_list_profiles`
(`gitea_mcp_server.py:19258`) called from the **author** session reports
`identity_status: "credentials present"` for `prgs-merger`, `prgs-reviewer`,
`prgs-reconciler`, and every `mdcps` profile, because it calls `resolve_token` on each one
(`gitea_mcp_server.py:19309`). The author process does not merely *have access to* the
merger's credential — it reads it to answer a status query. B2 is not a credential boundary
in either direction.
**Finding 4 — The Gitea server reads CI and observability secrets.** `gitea_audit_config`
(`gitea_mcp_server.py:19552`) reports `MDCPS Jenkins: enabled, read-only, authenticated`.
That word `authenticated` is produced by `service_summaries` (`gitea_mcp_server.py:19574`,
defined at `gitea_config.py:837`), whose default check calls `_keychain_token` on the
service's own keychain reference (`gitea_config.py:851`). Producing that one line requires
the Gitea MCP server to read the Jenkins secret and the GlitchTip secret out of the
keychain. B7 does not exist.
**Finding 5 — Jenkins and GlitchTip are already decomposed; the reach is residual.** Their
tools live in separately registered servers, marked `external-mcp`
(`gitea_mcp_server.py:17707`, `gitea_mcp_server.py:17713`, `gitea_mcp_server.py:17734`,
`gitea_mcp_server.py:17739`) with their own expected tool sets (`mcp_discoverability.py:9`,
`mcp_discoverability.py:17`). The correct decomposition was already chosen. What remains is
a leak across it: the credential *references* still live in the Gitea configuration and are
still resolved by the Gitea process. #75 bundled these services into one control-plane
umbrella; the tools were separated afterwards, the credentials were not.
**Finding 6 — Sentry is the exception that is not decomposed.** Unlike Jenkins and
GlitchTip, the Sentry bridge runs *inside* the Gitea server, resolving its token from the
process environment (`sentry_incident_bridge.py:190`) and sending it as a bearer header
(`sentry_incident_bridge.py:289`). Being an environment variable rather than a keychain item
makes it strictly worse: it needs no keychain prompt and is inherited by every subprocess the
server spawns — including the `ps` invocations at `gitea_mcp_server.py:21462` and
`gitea_mcp_server.py:21506`, reached from `gitea_mcp_server.py:21442`.
**Finding 7 — The highest-value coordination asset has the weakest gate.** A6 is protected
by filesystem permissions alone (CR14). Corrupting a lease requires no Gitea credential,
produces no forge-side audit record, and breaks the mutual exclusion the entire workflow
assumes. Every other asset costs an attacker a credential; this one costs nothing beyond
local access, which is exactly ADV5's position.
**Finding 8 — Provenance authenticates the launch, not the caller.** `server_provenance` is
reported as exactly `client_managed` or `manual_launch` (`gitea_mcp_server.py:19001`),
derived from environment inspection (`gitea_mcp_server.py:15412`) with the recognized-key
allowlist at `gitea_config.py:1172` and the generator that emits the marker at
`gitea_config.py:1233`. Every one of those facts is fixed at process start. A client that is
trustworthy at launch and compromised a minute later remains `client_managed` for the life
of the process, and the stdio contract that underwrites it is stated as a property of the
server itself (`mcp_server.py:4`).
## 6. Decomposition ruling
This section is the ruling #956 requires. It is a decision, not a recommendation.
**D1 — No unrelated co-residency.** A single integration process **must not** hold, resolve,
or be able to resolve credentials for services it does not itself integrate with.
Concretely: the Gitea MCP service may hold Gitea credentials and nothing else. Jenkins,
GlitchTip, Sentry, and any database credential are **not permitted** to co-reside with Gitea
credentials in one process.
*Rationale.* A process is the smallest unit an attacker takes whole. Once ADV1 or ADV2
controls execution in a process, every credential that process can resolve is theirs, and no
in-process check helps, because the checks are in the process too. Blast radius is therefore
a property of the process boundary and nothing finer. Findings 4 and 6 show that today one
compromise of the Gitea server yields CI read access, error-tracking read access, and — via
CR13 — every role credential on both tenants. That is the single largest reduction in blast
radius available anywhere in epic #929, and it costs no new mechanism: the decomposition
already exists (Finding 5) and is merely leaked across.
**D2 — Separation of duty must be backed by credentials.** Two roles whose separation is a
security property must not resolve to the same forge account. Specifically, reviewer and
merger must be distinct accounts. Today they are not, on either tenant (Findings 1 and 2).
*Rationale.* B2 is a process boundary, and the migration's entire purpose is to replace
process boundaries with request-level ones. A separation enforced only by which process a
call reaches does not survive that replacement — and it is already bypassable by anyone who
holds the token and calls the API instead of the tool.
**D3 — Credential resolution is scoped to the request principal.** A session must resolve its
own credential and must have no path to any other principal's. The resolve-every-profile
behavior behind `gitea_mcp_server.py:19309` and `gitea_mcp_server.py:19574` must report
configured-or-not from configuration alone, without resolving the secret.
*Rationale.* Finding 3. An audit surface that proves a credential exists by fetching it is a
credential-aggregation primitive wearing a diagnostic's clothes.
**D4 — Coordination state is a protected asset with its own authority.** Access to locks,
leases, and decision records must require an authenticated session, not merely local
filesystem access.
*Rationale.* Finding 7. #937 already moves this store for concurrency reasons; the
authorization requirement must land with it, or the store becomes remotely reachable while
still being authorized by nothing.
### Exceptions
**One, time-boxed.** During the dual-run window defined by #939, the **local** stdio fleet
may continue to resolve Jenkins and GlitchTip credential *references* from the shared
configuration, because removing them from the local configuration is not a prerequisite for
standing up the remote endpoint and would strand the operator's existing local workflow.
This exception is bounded by all of:
- It applies to the local stdio deployment only. The remote endpoint (#938) must be
configured with Gitea credentials and no others from its first day.
- It expires when #939 completes. It does not survive cutover.
- It does not extend to Sentry: CR11 and CR12 are process-environment credentials in the
Gitea server (Finding 6) and must be absent from the remote deployment's environment
regardless of dual-run state.
No exception is granted to D2, D3, or D4.
### Consequences for the target architecture
- The remote endpoint serves **Gitea only**. It is not a general control-plane endpoint.
- Jenkins and GlitchTip keep their existing separate servers, and their credential
references move out of the Gitea configuration.
- The Sentry bridge either moves behind its own service boundary or is absent from the
remote deployment. It does not travel with the Gitea server.
- Reviewer and merger accounts diverge before the endpoint is trusted for merges, or A9 is
recorded as unenforced.
## 7. Child-to-boundary mapping
Every #929 child from 2 through 10, mapped to the boundary it implements. A child
implementing more than one boundary names its primary first.
| Child | Issue | Boundaries | What it must establish | Rulings it must honor |
| ----: | ----- | ---------- | ---------------------- | --------------------- |
| 2 | #931 | B1, B9 | The bound transport becomes a validated value that provenance and freshness can both key on. Without it neither B1 nor B9 has an input. | — |
| 3 | #932 | B2 | The role becomes a property of the request, not the process — the boundary the migration otherwise deletes. | D2, D3 |
| 4 | #933 | B3, B7 | Credentials come from a provider keyed by principal. This is where D1 and D3 are either enforced or permanently lost. | D1, D3 |
| 5 | #934 | B1 | Session provenance replaces pipe-and-process-table proof with an authenticated session identity. | — |
| 6 | #935 | B9 | Freshness redefined against deployed build identity, with an explicit undeterminable verdict. | — |
| 7 | #936 | B5 | Every tool classified and the filesystem boundary enforced at dispatch, so a tool cannot return green about the wrong disk. | — |
| 8 | #937 | B6 | One authority per ownership question, with session-identity liveness and atomic transitions. | D4 |
| 9 | #938 | B4, B1, B8 | The endpoint: authentication, principal binding, transport security, and — critically — the deployed credential set. | D1, D2, D3 |
| 10 | #939 | B6 | Dual-run with exactly one coordination authority at every instant, and the rollback that proves the way back. | D1 exception expiry |
Boundary coverage: B1 (#931, #934, #938), B2 (#932), B3 (#933), B4 (#938), B5 (#936),
B6 (#937, #939), B7 (#933), B8 (#938), B9 (#931, #935).
B7 has exactly one owner, #933, and that is deliberate. B7 is not created by standing up an
endpoint; it is created by deciding which credentials a process may resolve, which is
precisely what the credential-provider child does.
## 8. Adversarial walkthrough: a fully compromised LLM client
The required negative case. ADV1 holds one MCP client and issues arbitrary tool calls. What
does it reach at each boundary?
### Before the migration
The client is attached to the local fleet over stdio.
| Boundary | What ADV1 reaches | Stopped by |
| -------- | ----------------- | ---------- |
| B1 | Everything the fleet serves. The client *is* the sanctioned launcher: it satisfies the client-managed check (`gitea_mcp_server.py:15412`) by construction, and provenance is never re-verified after launch (Finding 8). | Nothing. The guard authenticates the launch, not the caller. |
| B2 | All five roles — it is attached to all five namespaces. It can author a PR, approve it from the reviewer namespace, and merge it from the merger namespace. | Only the in-process self-review check, which compares `jcwalker3` (author) against `sysadmin` (reviewer) and **passes**, because Finding 1 made them different accounts while leaving reviewer and merger identical. A9 falls in one sequence of legitimate calls. |
| B3 | Every credential in CR1CR10 via CR13, with no additional prompt — the daemon is already sanctioned, so `assert_keychain_access_allowed` (`mcp_daemon_guard.py:440`) returns immediately. | Nothing. |
| B4 | A1 and A2 in full. | Branch protection at the forge, to the extent configured. |
| B5 | The operator's checkout and every worktree, through the author tools (`gitea_mcp_server.py:10894`), plus the shared stderr path at `mcp_server.py:10`. | Nothing; the server's disk is the target disk. |
| B6 | All coordination state — no credential required (CR14). It can forge lease ownership and clear decision locks. | Filesystem permissions, which it already satisfies. |
| B7 | Jenkins (A4) and GlitchTip (A5) secrets via Finding 4, and CR11/CR12 from its own environment. | Nothing. |
| B8 | Both tenants. | Nothing in-process; only the disabled-context check (`gitea_config.py:704`), which does not apply to enabled contexts. |
| B9 | Can it run stale code? Only by advancing the checkout, which parity detects (`master_parity_gate.py:255`). | This gate holds — the one boundary that behaves as designed against ADV1. |
**Result: total.** One compromised client yields A1 through A8 and A10. The only asset with
real resistance is A1 via branch protection, and the client holds the merger credential
anyway. Nine boundaries, one meaningful stop.
### After the migration
The same client authenticates to the remote endpoint with one role's credential, assuming
#931#939 land **and honor D1D4**.
| Boundary | What ADV1 reaches | Stopped by |
| -------- | ----------------- | ---------- |
| B1 | One authenticated session, bound to one principal. | #934: a forged or expired session identity is refused; the client cannot mint one. |
| B2 | **One role.** Presenting the author credential yields author permissions only. | #932: the principal comes from the credential, not from which endpoint was reached. |
| B3 | **One credential — its own.** | #933 with D3: the provider resolves by principal, and no diagnostic resolves the others. |
| B4 | That role's authority on the forge. | Endpoint authentication (#938); plaintext and unauthenticated attempts refused before dispatch. |
| B5 | **Nothing.** Filesystem tools are refused over the remote transport with a named blocker. | #936. |
| B6 | Its own leases; contention resolves to exactly one winner. | #937 with D4: authenticated session required, not filesystem access. |
| B7 | **Nothing.** No CI or observability credential exists in the process. | D1 — the single largest reduction on this table. |
| B8 | One tenant. | D1 and #938: the deployment carries one tenant's credentials. |
| B9 | Cannot induce stale enforcement. | #935: explicit fail-closed verdict, including undeterminable. |
**Result: bounded.** The compromise is contained to one role on one tenant, with no
filesystem reach and no lateral credential access. A9 survives *only if D2 lands* — if
reviewer and merger still share `sysadmin`, a compromised reviewer session still merges, and
this row reads the same after the migration as before it.
### What the migration does not fix
Against **ADV2**, both tables are identical. Prompt injection does not need to cross a
boundary: it arrives inside an authorized session and asks that session to do what it is
already permitted to do. Every "stopped by" above authenticates a principal, and the
injected instruction has the correct principal. The migration reduces ADV1's blast radius by
roughly an order of magnitude and reduces ADV2's by nothing.
The controls that do constrain ADV2 are per-principal permission scope (#932), repository
scope (`gitea_config.py:499`), and credential-backed separation of duty (D2) — each limiting
what an authenticated session may do *regardless of what it is asked for*. #955's
secure-isolation end state should be read with that distinction in mind: removing credentials
from clients defeats ADV1 and ADV5, and does not by itself defeat ADV2.
Two further items are explicitly out of scope here and unowned by #929:
- **Session-credential rotation and revocation.** #938 names rotation as documentation, but
no child owns proving that a revoked credential stops an in-flight session.
- **ADV3** (malicious tool arguments) is diffused across every child rather than owned. The
per-request principal work in #932 is the natural place to assert that identifiers taken
from the request never authorize anything on their own.
## 9. How to verify this document
1. `PYTHONPATH=. pytest tests/test_issue_956_threat_model.py` — resolves every anchor
against the working tree and checks the document's structural obligations.
2. Pick any five anchors at random and read them; the fixture states what each line must
contain.
3. Reproduce Findings 3 and 4 live: call `gitea_list_profiles` and `gitea_audit_config`
from the **author** namespace. Credential presence reported for roles other than the
active one is Finding 3; `MDCPS Jenkins: enabled, read-only, authenticated` is Finding 4.
If the anchor test fails after an unrelated refactor, the anchors moved and the fixture
needs regenerating — the claims are still true, but they are no longer traceable, which
#956 treats as the same defect.
+2
View File
@@ -98,6 +98,8 @@ already define, and a regression test asserts each mapping matches.
| `system.rebind_session_worktree` | operator | gated_write | `gitea.read` | Yes | No | No | 2 | | `system.rebind_session_worktree` | operator | gated_write | `gitea.read` | Yes | No | No | 2 |
| `system.reconcile_cleanups` | controller | privileged | `gitea.pr.close` | Yes | No | No | 2 | | `system.reconcile_cleanups` | controller | privileged | `gitea.pr.close` | Yes | No | No | 2 |
| `initiate_workflow` | operator | gated_write | `gitea.read` | Yes | No | No | 2 | | `initiate_workflow` | operator | gated_write | `gitea.read` | Yes | No | No | 2 |
| `observability_reconcile_incident` | operator | gated_write | `gitea.read` | Yes | No | No | 4 |
| `observability_link_issue` | operator | gated_write | `gitea.read` | Yes | No | No | 4 |
**Dual control** means the acting principal may not be the sole authority: a **Dual control** means the acting principal may not be the sole authority: a
second distinct principal must confirm. **Break-glass** means the action is second distinct principal must confirm. **Break-glass** means the action is
+745 -44
View File
@@ -2100,6 +2100,7 @@ import lease_policy # noqa: E402
import workflow_dashboard # noqa: E402 # #605 live queue/lease dashboard import workflow_dashboard # noqa: E402 # #605 live queue/lease dashboard
import restart_coordinator # noqa: E402 # #658 MCP restart coordinator/impact import restart_coordinator # noqa: E402 # #658 MCP restart coordinator/impact
import drain_proof # noqa: E402 # #661 pre-restart drain proof and hard gate import drain_proof # noqa: E402 # #661 pre-restart drain proof and hard gate
import restart_audit # noqa: E402 # #665 restart audit events + incidents
import incident_bridge # noqa: E402 import incident_bridge # noqa: E402
import sentry_observability # noqa: E402 (#606 optional Sentry observability) import sentry_observability # noqa: E402 (#606 optional Sentry observability)
import sentry_incident_bridge # noqa: E402 (#607 Sentry→Gitea incident bridge) import sentry_incident_bridge # noqa: E402 (#607 Sentry→Gitea incident bridge)
@@ -2110,6 +2111,8 @@ import issue_lock_store # noqa: E402
import issue_lock_adoption # noqa: E402 import issue_lock_adoption # noqa: E402
import issue_lock_recovery # noqa: E402 import issue_lock_recovery # noqa: E402
import issue_lock_renewal # noqa: E402 import issue_lock_renewal # noqa: E402
import author_lock_contract # noqa: E402
import bootstrap_lock_recovery # noqa: E402
import dirty_orphan_worktree_recovery # noqa: E402 # #860 dirty orphan recovery import dirty_orphan_worktree_recovery # noqa: E402 # #860 dirty orphan recovery
import dirty_same_claimant_session_rebind # noqa: E402 # #864 import dirty_same_claimant_session_rebind # noqa: E402 # #864
import stacked_pr_support # noqa: E402 import stacked_pr_support # noqa: E402
@@ -2123,6 +2126,7 @@ import root_checkout_guard # noqa: E402
import workflow_scope_guard # noqa: E402 # #683 production scope / force-on guards import workflow_scope_guard # noqa: E402 # #683 production scope / force-on guards
import stable_branch_push_guard # noqa: E402 import stable_branch_push_guard # noqa: E402
import runtime_recovery_guard # noqa: E402 # #630 manual daemon-kill contamination import runtime_recovery_guard # noqa: E402 # #630 manual daemon-kill contamination
import mcp_client_reconnect # noqa: E402 # #678 sanctioned Codex reconnect request
import remote_repo_guard # noqa: E402 import remote_repo_guard # noqa: E402
import anti_stomp_preflight # noqa: E402 import anti_stomp_preflight # noqa: E402
import issue_claim_heartbeat # noqa: E402 import issue_claim_heartbeat # noqa: E402
@@ -2657,33 +2661,17 @@ def _build_author_issue_work_lease(
worktree_path: str, worktree_path: str,
host: str | None, host: str | None,
) -> dict: ) -> dict:
created = _work_lease_now() # #953: the lease shape now lives in author_lock_contract so that bootstrap
# #790 Slice A: the window comes from the central policy, not a literal here. # and gitea_lock_issue cannot drift apart again. The policy-derived sliding
# It is also now a *sliding* window — the lease lives ``initial_ttl_minutes`` # TTL (#790 Slice A) and the task-session ownership key (#790 AC-N1) are
# past its last valid heartbeat rather than a fixed four hours past its # unchanged — they simply have one definition instead of two.
# creation, so an abandoned task stops holding the claim within one TTL. return author_lock_contract.build_author_issue_work_lease(
policy = lease_policy.policy_for(lease_policy.TASK_CLASS_AUTHOR_ISSUE_WORK) issue_number=issue_number,
expires = created + timedelta(minutes=policy.initial_ttl_minutes) branch_name=branch_name,
return { worktree_path=worktree_path,
"operation_type": AUTHOR_ISSUE_WORK_LEASE, claimant=_work_lease_claimant(host),
"issue_number": issue_number, created=_work_lease_now(),
"pr_number": None, )
"branch": branch_name,
"worktree_path": worktree_path,
"claimant": _work_lease_claimant(host),
"created_at": _work_lease_timestamp(created),
"expires_at": _work_lease_timestamp(expires),
"last_heartbeat_at": _work_lease_timestamp(created),
# #790 AC-N1: the ownership key for this task. Distinct from the recorded
# PID, which is the shared daemon and identifies no individual task.
"task_session_id": issue_lock_store.mint_task_session_id(
AUTHOR_ISSUE_WORK_LEASE
),
# #790 AC-N8: the explicit lifecycle marker. Its absence — never a
# timestamp comparison — is what makes a lock legacy.
"lifecycle_version": lease_policy.LIFECYCLE_HEARTBEAT_V1,
"heartbeat_count": 1,
}
def _active_work_lease_block( def _active_work_lease_block(
@@ -2781,6 +2769,87 @@ def _collect_issue_duplicate_context(
return issue_duplicate_context_fetcher(h, o, r, auth, issue_number) return issue_duplicate_context_fetcher(h, o, r, auth, issue_number)
# Every field of the owning-PR continuation token is security relevant: the
# issue and PR it names, the branch it is scoped to, and each head the waiver
# was measured against. Two evidence blocks that disagree on any of them cannot
# both describe the single sanctioned decision the lock is supposed to record.
_CONTINUATION_EVIDENCE_BINDINGS = (
"issue_number",
"pr_number",
"branch_name",
"head_sha",
"recorded_head",
"accepted_head",
"head_relation",
)
def _continuation_evidence_agrees(recovered: dict, renewed: dict) -> bool:
"""Do two rebuilt continuation tokens bind to exactly the same thing (#945)?"""
return all(
recovered.get(field) == renewed.get(field)
for field in _CONTINUATION_EVIDENCE_BINDINGS
)
def _owning_pr_continuation_from_lock(lock_record: dict | None) -> dict | None:
"""Owning-PR continuation evidence a persisted lock still proves (#945).
``gitea_lock_issue`` grants the duplicate-work waiver from either a
sanctioned dead-session recovery (#755) or a sanctioned exact-owner renewal
(#760), in that precedence. Every later enforcement path — commit,
create-PR, push-ownership, and the read-only duplicate assessor re-derives
ownership from the durable lock instead of that live assessment.
Until #945 only the recovery half was rebuilt there, so an ordinary
exact-owner renewal lost its waiver the moment ``gitea_lock_issue``
returned: the author renewed successfully and was then refused
``duplicate_commit_prevented`` with ``owning_pr_recovery_exempted: false``
on the very PR the renewal had just proved it owned.
Resolving both halves here, in the same precedence the lock path applies,
keeps the answer from drifting between the gate that grants the waiver and
the gates that enforce it. This only decides which server-written block the
token is rebuilt from the token is still re-validated against live PR
state by ``issue_work_duplicate_gate._assess_owning_pr_exemption``, which
remains the single authoritative policy for whether an exemption applies.
**Both blocks present is a reachable, legitimate state, and it must agree.**
The two dispositions are not mutually exclusive at the writer. Recovery is
assessed whenever the lease is not live and requires the recorded PID to be
dead; renewal is assessed whenever the lease has *expired* which is itself
one way to be non-live and deliberately does not branch on PID liveness
(#760 AC16). An expired lease whose recorded owner has also died therefore
satisfies both, and ``gitea_lock_issue`` writes ``dead_session_recovery``
and ``lease_renewal`` into the same freshly built ``data`` dict. Because a
sanctioned pair was derived from one live observation in one call, it always
describes the same issue, PR, branch and head. Disagreement means the
persisted lock is no longer a faithful record of a single sanctioned
decision, so no continuation authority is returned.
Ambiguity never broadens authority. A ``dead_session_recovery`` block that
is present but does not rebuild conflicting, stale, malformed, or only
partially valid fails closed here rather than falling through to renewal:
otherwise a recovery record naming one PR could be bypassed by valid-looking
renewal evidence naming another. Recovery-only and renewal-only locks keep
their existing behaviour exactly, and provenance stays server-controlled
this still only ever re-reads blocks the server itself wrote.
"""
if not lock_record:
return None
recovery_present = isinstance(lock_record.get("dead_session_recovery"), dict)
recovered = issue_lock_recovery.recovered_owning_pr_from_lock(lock_record)
# Present but unusable recovery evidence is ambiguous, not absent.
if recovery_present and not recovered:
return None
renewed = issue_lock_renewal.owning_pr_renewal_from_lock(lock_record)
if recovered and renewed and not _continuation_evidence_agrees(recovered, renewed):
return None
if recovered:
return recovered
return renewed
def _assess_issue_duplicate_gate( def _assess_issue_duplicate_gate(
issue_number: int, issue_number: int,
*, *,
@@ -2833,6 +2902,11 @@ def _enforce_locked_issue_duplicate_recheck(
commit and create-PR phases run in their own calls, long after the recovery commit and create-PR phases run in their own calls, long after the recovery
assessment ended, so without this they re-block the very PR the recovery assessment ended, so without this they re-block the very PR the recovery
already proved belongs to this author. already proved belongs to this author.
#945: an exact-owner *renewal* (#760) owns its open PR for exactly the same
reason, and ``gitea_lock_issue`` already waives the blocker for both. Both
halves are resolved together here so the renewal waiver survives past the
lock call instead of expiring with it.
""" """
lock_data = _load_existing_issue_lock() lock_data = _load_existing_issue_lock()
if not lock_data: if not lock_data:
@@ -2856,9 +2930,7 @@ def _enforce_locked_issue_duplicate_recheck(
auth=auth, auth=auth,
locked_branch=locked_branch, locked_branch=locked_branch,
phase=phase, phase=phase,
recovered_owning_pr=issue_lock_recovery.recovered_owning_pr_from_lock( recovered_owning_pr=_owning_pr_continuation_from_lock(lock_data),
lock_data
),
) )
if gate.get("block"): if gate.get("block"):
return gate return gate
@@ -4869,6 +4941,370 @@ def gitea_heartbeat_issue_lock(
return outcome return outcome
def _observe_recovery_worktree(worktree_path: str) -> dict:
"""Observe head, branch, existence, and registration for lock recovery.
Read-only: it runs ``git`` queries and touches nothing. Kept separate from
the decision so the decision stays a pure function of observations (#953).
"""
observation = {
"worktree_exists": os.path.isdir(worktree_path),
"worktree_registered": False,
"current_branch": "",
"observed_head": "",
}
if not observation["worktree_exists"]:
return observation
try:
observation["current_branch"] = subprocess.run(
["git", "-C", worktree_path, "rev-parse", "--abbrev-ref", "HEAD"],
capture_output=True,
text=True,
check=False,
).stdout.strip()
observation["observed_head"] = subprocess.run(
["git", "-C", worktree_path, "rev-parse", "HEAD"],
capture_output=True,
text=True,
check=False,
).stdout.strip()
listing = subprocess.run(
["git", "-C", worktree_path, "worktree", "list", "--porcelain"],
capture_output=True,
text=True,
check=False,
).stdout
real = os.path.realpath(worktree_path)
observation["worktree_registered"] = any(
os.path.realpath(line.split(" ", 1)[1].strip()) == real
for line in listing.splitlines()
if line.startswith("worktree ")
)
except Exception: # fail closed: unobservable is not provable
return observation
return observation
@mcp.tool()
def gitea_inspect_issue_lock_contract(
issue_number: int,
branch_name: str | None = None,
worktree_path: str | None = None,
remote: str = "dadeschools",
host: str | None = None,
org: str | None = None,
repo: str | None = None,
) -> dict:
"""Inspect a durable author issue lock against the canonical contract (#953 AC8/AC16).
Strictly read-only. It performs no lock, lease, branch, worktree, issue, or
pull-request mutation of any kind it reads the durable lock record and
reports. Use it to find out *why* a lock is being refused before choosing a
recovery path, and to confirm afterwards that recovery produced a canonical
lock.
Reports which canonical fields are missing, where the claimant is recorded
(``work_lease`` is canonical, top level is the legacy/bootstrap placement),
whether an expiration is actually recorded as opposed to absent, which
used to masquerade as "not yet expired" whether the lock can be
heartbeated, and whether it satisfies the untouched #447 create-PR
provenance guard.
Args:
issue_number: The issue whose lock to inspect.
branch_name: Optional; when given, the recovery eligibility preview is
evaluated against this branch.
worktree_path: Optional; when given, the recovery eligibility preview is
evaluated against this worktree.
remote: Known instance 'dadeschools' or 'prgs'.
host: Override the Gitea host.
org: Override the owner/organization.
repo: Override the repository name.
Returns:
dict with 'success', 'lock_present', 'lock_contract' (the structural
verdict), 'recommended_action', and when branch_name and
worktree_path are supplied a non-mutating 'recovery_preview'.
"""
blocked = _profile_permission_block(
"gitea.read",
issue_number=issue_number,
remote=remote,
host=host,
org=org,
repo=repo,
org_explicit=org is not None,
repo_explicit=repo is not None,
)
if blocked:
return blocked
h, o, r = _resolve(remote, host, org, repo)
existing = _load_existing_issue_lock(
remote=remote, org=o, repo=r, issue_number=issue_number
)
contract = author_lock_contract.assess_lock_contract(existing)
result = {
"success": True,
"performed": False,
"mutation_performed": False,
"read_only": True,
"issue_number": issue_number,
"lock_present": bool(existing),
"lock_contract": contract,
"lock_freshness": (
issue_lock_store.assess_lock_freshness(dict(existing))
if existing
else {"status": issue_lock_store.STATUS_ABSENT, "live": False}
),
"recommended_action": author_lock_contract.recommended_action(contract),
}
if branch_name and worktree_path:
resolved = issue_lock_worktree.resolve_author_worktree_path(
worktree_path, _canonical_local_git_root()
)
observation = _observe_recovery_worktree(resolved)
claimant = _work_lease_claimant(h)
result["recovery_preview"] = bootstrap_lock_recovery.assess_bootstrap_lock_recovery(
existing,
issue_number=issue_number,
branch_name=branch_name,
worktree_path=resolved,
remote=remote,
org=o,
repo=r,
identity=claimant.get("username"),
profile=claimant.get("profile"),
observed_head=observation["observed_head"],
declared_head=None,
worktree_exists=observation["worktree_exists"],
worktree_registered=observation["worktree_registered"],
current_branch=observation["current_branch"],
)
return result
@mcp.tool()
def gitea_recover_incomplete_bootstrap_lock(
issue_number: int,
branch_name: str,
worktree_path: str,
expected_head: str,
remote: str = "dadeschools",
host: str | None = None,
org: str | None = None,
repo: str | None = None,
dry_run: bool = False,
) -> dict:
"""Upgrade an incomplete bootstrap issue lock to the canonical contract (#953 AC8-AC11).
Explicit, target-specific recovery. It does **not** widen
``gitea_lock_issue``, and it is not a takeover path.
The state it repairs: ``gitea_bootstrap_author_issue_worktree`` reported
success but wrote a lock with the claimant at the top level, no
``work_lease``, no ``lock_provenance``, and no expiry. The author then
implemented, committed, and pushed following bootstrap's own reported next
action after which heartbeat, re-lock, exact-owner renewal, and the #447
create-PR guard all refuse simultaneously.
Deliberate non-behaviours: the branch is never moved, reset, or rewound, and
base-equivalence is never required preserving the already-committed and
pushed work is the entire point. Nothing is pushed and no pull request is
created. Only the single lock file for this exact (remote, org, repo, issue)
is written.
Ownership is proven, never asserted. The claimant recorded on the durable
lock must match **both** the server-resolved identity and the active
profile; a matching username alone is refused. Repository, issue, branch,
worktree, registration, current branch, and head are all verified before any
write, and the declared ``expected_head`` must equal the observed head. A
healthy foreign-owned lock is refused outright. Provenance and authorization
are minted server-side there is no parameter through which a caller can
supply either.
Args:
issue_number: The issue whose incomplete lock is being recovered.
branch_name: The branch recorded on the lock; must match.
worktree_path: The registered worktree recorded on the lock; must match.
expected_head: Full SHA the caller believes the worktree is at. A
mismatch fails closed, so a worktree that moved underneath the
caller cannot be recovered against stale evidence.
remote: Known instance 'dadeschools' or 'prgs'.
host: Override the Gitea host.
org: Override the owner/organization.
repo: Override the repository name.
dry_run: Report the decision and evidence, mutate nothing.
Returns:
dict with 'success', 'performed', the resulting canonical
'lock_contract' and 'work_lease', the auditable
'bootstrap_lock_recovery' transition record, and 'exact_next_action'; on
refusal 'success'/'performed' False with 'refusal_code' and 'reasons'
naming exactly which evidence was missing, and no write performed.
"""
task = "recover_incomplete_bootstrap_lock"
ok, block_reasons = role_session_router.check_author_mutation_after_reviewer_stop(
task
)
if not ok:
return _author_mutation_block(block_reasons)
# #953 F1: the namespace/session wall every author state-creating mutation
# carries, and which this tool — the structural neighbour of
# gitea_recover_dirty_orphaned_issue_worktree, writing the same durable
# lock — was the only one to omit. Exact-owner claimant matching inside
# assess_bootstrap_lock_recovery is a later layer, not a substitute: it
# refuses one commit too late and leaves no BLOCKED audit record of the
# attempt. author_role_exclusive is required here because this task is gated
# on gitea.issue.comment, which merger, controller, and reconciler profiles
# also hold.
blocked = _namespace_mutation_block(
task, remote=remote, author_role_exclusive=True
)
if blocked:
return blocked
blocked = _profile_permission_block(
task_capability_map.required_permission(task),
issue_number=issue_number,
remote=remote,
host=host,
org=org,
repo=repo,
org_explicit=org is not None,
repo_explicit=repo is not None,
)
if blocked:
return blocked
h, o, r = _resolve(remote, host, org, repo)
resolved_worktree = issue_lock_worktree.resolve_author_worktree_path(
worktree_path, _canonical_local_git_root()
)
existing = _load_existing_issue_lock(
remote=remote, org=o, repo=r, issue_number=issue_number
)
observation = _observe_recovery_worktree(resolved_worktree)
# The claimant pair is resolved server-side from the live session; the
# caller cannot influence which identity or profile recovery compares
# against.
claimant = _work_lease_claimant(h)
assessment = bootstrap_lock_recovery.assess_bootstrap_lock_recovery(
existing,
issue_number=issue_number,
branch_name=branch_name,
worktree_path=resolved_worktree,
remote=remote,
org=o,
repo=r,
identity=claimant.get("username"),
profile=claimant.get("profile"),
observed_head=observation["observed_head"],
declared_head=expected_head,
worktree_exists=observation["worktree_exists"],
worktree_registered=observation["worktree_registered"],
current_branch=observation["current_branch"],
)
if not assessment["recovery_sanctioned"]:
return {
"success": False,
"performed": False,
"mutation_performed": False,
"issue_number": issue_number,
"refusal_code": assessment["refusal_code"],
"reasons": assessment["reasons"],
"message": bootstrap_lock_recovery.format_recovery_refusal(assessment),
"lock_contract": assessment["contract"],
"evidence": assessment["evidence"],
}
if dry_run:
return {
"success": True,
"performed": False,
"mutation_performed": False,
"dry_run": True,
"issue_number": issue_number,
"would_recover": True,
"lock_contract": assessment["contract"],
"evidence": assessment["evidence"],
"exact_next_action": (
"Re-run without dry_run=True to upgrade this lock to the "
"canonical contract."
),
}
recovered = author_lock_contract.build_canonical_issue_lock(
issue_number=issue_number,
branch_name=branch_name,
worktree_path=resolved_worktree,
remote=remote,
org=o,
repo=r,
identity=claimant.get("username"),
profile=claimant.get("profile"),
tool="gitea_recover_incomplete_bootstrap_lock",
source=issue_lock_provenance.SOURCE_LOCK_ISSUE,
owner_session=(existing or {}).get("owner_session"),
expected_base_sha=(existing or {}).get("expected_base_sha"),
)
# AC10: preserve both sides of the transition so a recovered lock never
# reads as an original claim.
recovered["bootstrap_lock_recovery"] = bootstrap_lock_recovery.build_recovery_record(
assessment,
recovered_at=_work_lease_timestamp(_work_lease_now()),
new_task_session_id=recovered["work_lease"]["task_session_id"],
)
try:
lock_path = issue_lock_store.bind_session_lock(
recovered,
expected_generation=assessment["expected_generation"],
recovery_sanctioned=True,
)
except Exception as exc:
return {
"success": False,
"performed": False,
"mutation_performed": False,
"issue_number": issue_number,
"refusal_code": "lock_write_failed",
"reasons": [str(exc)],
"message": f"Recovered lock could not be persisted: {exc} (fail closed)",
}
written = issue_lock_store.read_lock_file(lock_path)
contract = author_lock_contract.assess_lock_contract(written)
return {
"success": True,
"performed": True,
"mutation_performed": True,
"issue_number": issue_number,
"branch_name": branch_name,
"worktree_path": resolved_worktree,
"lock_file_path": lock_path,
"lock_contract": contract,
"work_lease": (written or {}).get("work_lease"),
"task_session_id": contract["task_session_id"],
"lock_generation": (written or {}).get("lock_generation"),
"prior_generation": assessment["expected_generation"],
"bootstrap_lock_recovery": (written or {}).get("bootstrap_lock_recovery"),
"preserved_head": observation["observed_head"],
"branch_reset": False,
"pushed": False,
"pr_created": False,
"exact_next_action": (
"Lock is canonical. Heartbeat it with the returned task_session_id, "
"then continue the author workflow; publish and create the pull "
"request through the normal sanctioned calls."
),
}
@mcp.tool() @mcp.tool()
def gitea_recover_dirty_orphaned_issue_worktree( def gitea_recover_dirty_orphaned_issue_worktree(
issue_number: int, issue_number: int,
@@ -5427,9 +5863,10 @@ def gitea_assess_work_issue_duplicate(
recovered_owning_pr = None recovered_owning_pr = None
lock_data = _load_existing_issue_lock() lock_data = _load_existing_issue_lock()
if lock_data and int(lock_data.get("issue_number") or 0) == int(issue_number): if lock_data and int(lock_data.get("issue_number") or 0) == int(issue_number):
recovered_owning_pr = issue_lock_recovery.recovered_owning_pr_from_lock( # #945: rebuilt from a sanctioned recovery *or* a sanctioned exact-owner
lock_data # renewal, so this read-only assessor reports the same disposition the
) # commit and create-PR gates will enforce.
recovered_owning_pr = _owning_pr_continuation_from_lock(lock_data)
gate = _assess_issue_duplicate_gate( gate = _assess_issue_duplicate_gate(
issue_number, issue_number,
h=h, h=h,
@@ -14407,11 +14844,16 @@ def _classify_operation_gate_reasons(reasons: list[str]) -> dict:
def _stale_runtime_reconnect_action() -> str: def _stale_runtime_reconnect_action() -> str:
"""Sanctioned recovery for a stale daemon — reconnect only (#685/#897).""" """Sanctioned recovery for a stale daemon — reconnect only (#685/#897/#678)."""
return ( return (
"Reconnect the IDE/client MCP session so the server reloads at the " "blocker_kind=runtime_reconnect_required: call "
"current master head. Do not call gitea_activate_profile or switch " "gitea_request_mcp_reconnect(namespace=<active gitea-* namespace>, "
"MCP role sessions — profile switching does not clear a stale daemon." "reason='stale-runtime', client='codex') for a typed operator "
"reconnect blocker with exact UI steps, then reconnect the IDE/client "
"MCP session so the server reloads at the current master head. Do not "
"call gitea_activate_profile, pkill, touch configs, or switch MCP role "
"sessions — profile switching does not clear a stale daemon. After "
"reconnect restart from gitea_whoami → gitea_resolve_task_capability."
) )
@@ -15064,8 +15506,21 @@ def _profile_permission_block(required_operation: str, **extra_fields) -> dict |
) )
def _namespace_mutation_block(mutation_task: str, **extra_fields) -> dict | None: def _namespace_mutation_block(
"""Reviewer/author namespace alignment gate (#209).""" mutation_task: str,
*,
author_role_exclusive: bool = False,
**extra_fields,
) -> dict | None:
"""Reviewer/author namespace alignment gate (#209).
``author_role_exclusive`` additionally requires the active profile's derived
role kind to be exactly ``author`` (#953 F1). Off by default, so the six
pre-existing call sites are unchanged. Tools whose required permission is
``gitea.issue.comment`` which every configured role holds opt in, since
the reviewer-namespace check alone would let a merger, controller, or
reconciler session through to a durable author lock write.
"""
required_permission = task_capability_map.required_permission(mutation_task) required_permission = task_capability_map.required_permission(mutation_task)
required_role = task_capability_map.required_role(mutation_task) required_role = task_capability_map.required_role(mutation_task)
# #714: evaluate active profile only — never auto-switch. # #714: evaluate active profile only — never auto-switch.
@@ -15087,6 +15542,9 @@ def _namespace_mutation_block(mutation_task: str, **extra_fields) -> dict | None
} }
ok, reasons = role_namespace_gate.check_author_mutation_namespace( ok, reasons = role_namespace_gate.check_author_mutation_namespace(
mutation_task, profile) mutation_task, profile)
if ok and author_role_exclusive:
ok, reasons = role_namespace_gate.check_author_role_kind(
mutation_task, profile)
if ok: if ok:
return None return None
blocked = { blocked = {
@@ -19776,7 +20234,10 @@ def _prove_author_ownership_for_pr(
# advance is the one recovery already sanctioned, not a foreign head. # advance is the one recovery already sanctioned, not a foreign head.
recovered_owning_pr = None recovered_owning_pr = None
if proven and lock_record: if proven and lock_record:
candidate = issue_lock_recovery.recovered_owning_pr_from_lock(lock_record) # #945: a sanctioned exact-owner renewal proves the same ownership of
# the same PR, so the push gate resolves both halves rather than seeing
# only the recovery one.
candidate = _owning_pr_continuation_from_lock(lock_record)
if candidate and int(candidate.get("pr_number") or 0) == int(pr_number): if candidate and int(candidate.get("pr_number") or 0) == int(pr_number):
recovered_owning_pr = candidate recovered_owning_pr = candidate
return { return {
@@ -21494,10 +21955,15 @@ def gitea_resolve_task_capability(
# serving process/profile inventory is stale — even if permission is OK. # serving process/profile inventory is stale — even if permission is OK.
if runtime_stale_blocker: if runtime_stale_blocker:
next_safe_action = ( next_safe_action = (
"blocker_kind=runtime_reconnect_required: reconnect/restart the " "blocker_kind=runtime_reconnect_required: call "
"IDE-managed Gitea MCP server for this profile so it reloads current " "gitea_request_mcp_reconnect(namespace=<active gitea-* namespace>, "
"master. Do not edit mcp_config.json by hand; the resolver does not " "reason='stale-runtime', client='codex') for a typed operator "
"touch config, spawn recovery threads, or terminate the process." "blocker with exact UI steps, then reconnect/reload the IDE-managed "
"Gitea MCP server for this profile so it reloads current master. "
"Do not edit mcp_config.json by hand, pkill, or touch configs; the "
"resolver does not touch config, spawn recovery threads, or "
"terminate the process. After reconnect restart from gitea_whoami → "
"gitea_resolve_task_capability."
) )
# Task/role alignment guards (#167): the requested task, not the # Task/role alignment guards (#167): the requested task, not the
@@ -23055,6 +23521,129 @@ def gitea_workflow_dashboard(
return payload return payload
@mcp.tool()
def gitea_request_mcp_reconnect(
namespace: str | None = None,
reason: str | None = None,
client: str = "codex",
remote: str = "dadeschools",
host: str | None = None,
session_id: str | None = None,
) -> dict:
"""Request a sanctioned host/IDE MCP reconnect for a named namespace (#678).
Codex and other agent hosts can detect stale or closed Gitea MCP runtimes
(``stop_required`` / ``restart_required`` from capability resolution, transport
EOF, missing namespace attachment). The **host owns the transport** this
process cannot reopen the client's stdio pipe. This tool is the callable
surface agents use to:
1. Report reconnect status fields (namespace, profile, pid/session,
startup SHA, current master SHA, boundary status).
2. Return a **typed blocker** with exact operator UI steps for Codex (or
another client) when reconnect is required.
This tool **never** restarts, kills, reloads, or reconfigures an MCP
process. Forbidden recovery paths (pkill, touch/mtime hacks, config/.env
edits, session-state edits, raw API) are never recommended.
After the operator reconnects, workflows must restart from preflight:
``gitea_whoami`` ``gitea_resolve_task_capability`` task.
Args:
namespace: MCP namespace to reconnect (e.g. ``gitea-author``). Defaults
to the active profile's inferred namespace.
reason: Why reconnect is requested: ``stale-runtime``, ``transport_eof``,
``missing_namespace``, ``not_required``, or free-form (normalized).
client: Operator UI surface ``codex`` (default), ``claude_code``, or
``generic``.
remote: Known instance ``dadeschools`` or ``prgs`` (parity context).
host: Optional host override for parity context.
session_id: Optional session id to echo in the report.
Returns:
dict with reconnect report fields, ``reconnect_performed=False``,
``typed_blocker`` when reconnect is required, and
``forbidden_recovery_paths``.
"""
# Read-only: gitea.read is sufficient. Never a mutation.
read_block = _profile_operation_gate("gitea.read")
if read_block:
return {
"success": False,
"read_only": True,
"reconnect_performed": False,
"mutation_performed": False,
"reasons": read_block,
"permission_report": _permission_block_report("gitea.read"),
"forbidden_recovery_paths": list(
mcp_client_reconnect.FORBIDDEN_RECOVERY_PATHS
),
}
profile = get_profile()
profile_name = (profile.get("profile_name") or "").strip() or None
inferred_ns = role_namespace_gate.infer_mcp_namespace(profile_name)
ns = (namespace or "").strip() or inferred_ns or "gitea-tools"
parity = _current_master_parity()
startup_sha = (
parity.get("daemon_start_head")
or parity.get("startup_head")
or _process_boot_head_sha
)
current_sha = parity.get("local_head") or parity.get("current_head")
if not current_sha:
try:
current_sha = master_parity_gate.read_git_head(PROJECT_ROOT)
except Exception: # noqa: BLE001
current_sha = None
boundary = mcp_client_reconnect.classify_boundary_status(
startup_sha=startup_sha if isinstance(startup_sha, str) else None,
current_master_sha=current_sha if isinstance(current_sha, str) else None,
live_stale=bool(parity.get("live_stale")) if parity.get("live_known") else None,
in_parity=parity.get("in_parity") if parity.get("determinable") else None,
)
# Infer reason from parity when caller left it unspecified.
effective_reason = reason
if not (effective_reason or "").strip():
if parity.get("restart_required") or parity.get("live_stale"):
effective_reason = mcp_client_reconnect.REASON_STALE_RUNTIME
elif boundary == mcp_client_reconnect.BOUNDARY_CLEAN:
effective_reason = mcp_client_reconnect.REASON_NOT_REQUIRED
else:
effective_reason = mcp_client_reconnect.REASON_UNSPECIFIED
payload = mcp_client_reconnect.build_reconnect_request(
namespace=ns,
profile=profile_name,
pid=os.getpid(),
session_id=session_id
or f"{(profile_name or 'session')}-{os.getpid()}",
startup_sha=startup_sha if isinstance(startup_sha, str) else None,
current_master_sha=current_sha if isinstance(current_sha, str) else None,
boundary_status=boundary,
reason=effective_reason,
client=client,
live_stale=bool(parity.get("live_stale")) if parity.get("live_known") else None,
in_parity=parity.get("in_parity") if parity.get("determinable") else None,
restart_required=bool(parity.get("restart_required")),
stop_required=bool(parity.get("restart_required")),
extra={
"remote": remote if remote in REMOTES else remote,
"host": host,
"session_context_audit": session_ctx.mutation_context_audit_fields(),
"parity_summary": master_parity_gate.format_parity(parity),
"live_stale": parity.get("live_stale"),
"live_known": parity.get("live_known"),
"in_parity": parity.get("in_parity"),
},
)
return payload
@mcp.tool() @mcp.tool()
def gitea_request_mcp_restart( def gitea_request_mcp_restart(
remote: str = "dadeschools", remote: str = "dadeschools",
@@ -23279,6 +23868,38 @@ def gitea_request_mcp_restart(
# and a durable incident is raised. Break-glass is the only bypass and its # and a durable incident is raised. Break-glass is the only bypass and its
# authorization is read from the environment, never self-asserted. # authorization is read from the environment, never self-asserted.
payload["apply_supported"] = False payload["apply_supported"] = False
# #665: correlation id threads impact preview → apply gate → incidents.
correlation_id = restart_audit.new_correlation_id()
payload["correlation_id"] = correlation_id
auth_user = None
try:
# remote-only: host override is for operator diagnostics, not required here
auth_user = (gitea_whoami(remote=remote) or {}).get("username")
except Exception: # noqa: BLE001 — identity is best-effort for audit
auth_user = None
preview_audit = restart_audit.record_restart_lifecycle(
event_type=restart_audit.EVENT_IMPACT_PREVIEW,
outcome=str(report.verdict or "unknown"),
correlation_id=correlation_id,
remote=remote,
org=o,
repo=r,
requesting_session_id=sid,
restart_class=restart_class,
profile_name=profile_name,
authenticated_username=auth_user,
reasons=list(report.reasons or []),
details={
"dry_run": True,
"allow_restart": bool(report.allow_restart),
"inventory_complete": inventory_complete,
},
privileged=False,
)
payload["restart_audit"] = {
"correlation_id": correlation_id,
"impact_preview_written": preview_audit["audit_written"],
}
if not dry_run: if not dry_run:
proof_obj: dict | None = None proof_obj: dict | None = None
proof_parse_error: str | None = None proof_parse_error: str | None = None
@@ -23331,6 +23952,86 @@ def gitea_request_mcp_restart(
) )
if not gate.allow and gate.incident is not None: if not gate.allow and gate.incident is not None:
payload["incident"] = gate.incident payload["incident"] = gate.incident
# #665: audit apply-gate + materialize durable incidents for failed
# drain and break-glass. Privileged apply denies if audit is enabled
# and the sink write fails.
incident_desc = restart_audit.incident_from_apply_gate(
gate_payload={
**gate_payload,
"incident": gate.incident,
"allow": gate.allow,
},
break_glass=break_glass,
correlation_id=correlation_id,
requesting_session_id=sid,
restart_class=restart_class,
remote=remote,
org=o,
repo=r,
)
if incident_desc is not None:
payload["incident"] = incident_desc
def _create_restart_incident_issue(
*, title, body, labels, org=None, repo=None, **_kw
):
return gitea_create_issue(
title=title,
body=body,
labels=labels,
remote=remote,
host=h,
org=org or o,
repo=repo or r,
)
apply_audit = restart_audit.record_restart_lifecycle(
event_type=(
restart_audit.EVENT_BREAK_GLASS
if break_glass
else restart_audit.EVENT_APPLY_GATE
),
outcome=(
"break_glass"
if break_glass
else ("allow" if payload["apply_authorized"] else "deny")
),
correlation_id=correlation_id,
remote=remote,
org=o,
repo=r,
requesting_session_id=sid,
restart_class=restart_class,
profile_name=profile_name,
authenticated_username=auth_user,
reasons=list(gate_payload.get("reasons") or []),
details={
"apply_authorized": payload["apply_authorized"],
"drain_gate_allow": gate_payload.get("drain_gate_allow"),
"restart_class_authorized": restart_class_authorized,
"break_glass": break_glass,
"proof_id": gate_payload.get("proof_id"),
},
privileged=True,
create_incident=incident_desc,
create_issue_fn=_create_restart_incident_issue
if incident_desc is not None
else None,
dry_run_incident=False,
)
payload["restart_audit"] = {
"correlation_id": correlation_id,
"impact_preview_written": preview_audit["audit_written"],
"apply_gate_written": apply_audit["audit_written"],
"incident_result": apply_audit.get("incident_result"),
}
if apply_audit["deny_reasons"]:
payload["apply_authorized"] = False
payload["reasons"] = list(payload.get("reasons") or []) + list(
apply_audit["deny_reasons"]
)
payload["success"] = True
return payload return payload
+111
View File
@@ -436,6 +436,117 @@ def owning_pr_renewal_evidence(
} }
def owning_pr_renewal_from_lock(
lock_record: Mapping[str, Any] | None,
) -> dict[str, Any] | None:
"""Rebuild owning-PR renewal evidence from a persisted lock (#945).
The renewal mirror of ``issue_lock_recovery.recovered_owning_pr_from_lock``.
``owning_pr_renewal_evidence`` supplies the waiver for the duration of the
``gitea_lock_issue`` call only. The commit, push, create-PR, and
duplicate-assessment gates run later in their own calls and re-derive
ownership from the durable lock instead — so without this the open PR that
renewal already proved belongs to this author reappears there as competing
duplicate work, and the exact owner is refused with
``duplicate_commit_prevented`` despite complete matching evidence.
This reads only the ``lease_renewal`` block that the server itself writes,
on a lock the caller must already own. Like the recovery mirror it is a
re-read of server-derived state, never a fresh assertion: a caller able to
forge it could equally forge the lock file every other ownership gate
already treats as authoritative.
Renewal has no descendant case — the assessor required the local, remote and
PR heads to be equal — so that equality is re-checked here, and the record
must still name the claimant the lock records.
**What the claimant check below is, and what it is not.** It compares
``lease_renewal.identity``/``profile`` against the claimant recorded on the
*same* lock file. Both sides are server-written fields of one document, so
this is an internal-consistency check: it rejects a lock whose renewal block
and claimant disagree. It does **not** consult the live authenticated caller
and therefore does not, on its own, prove that the session invoking a later
gate is the session the renewal was granted to.
The binding that actually keeps one session from using another's renewal is
structural, and it lives in the caller rather than here. The enforcement
paths load the lock through ``_load_existing_issue_lock()`` with no issue
coordinates, which resolves ``issue_lock_store.read_session_issue_lock()``
→ the session pointer at ``session-{os.getpid()}.json``. Lock *selection* is
scoped to the operating-system process, so a caller cannot aim the recheck
at a lock some other process bound. Its limits follow from what that scope
is: it is per-process, not per-authenticated-user; it says nothing about a
lock reached by explicit issue coordinates rather than the session pointer,
and nothing about two roles sharing one process. Live identity and profile
are enforced separately, by the mutation-authority and profile gates each
mutating path already runs — not by this rebuild.
This function is therefore strictly a re-read with an added consistency
requirement. It narrows what a persisted lock can authorize; it never widens
it, and it never substitutes for a caller-identity gate.
"""
if not isinstance(lock_record, Mapping):
return None
record = lock_record.get("lease_renewal")
if not isinstance(record, Mapping) or not record.get("renewed"):
return None
branch_name = _text(record.get("branch_name")) or _text(
lock_record.get("branch_name")
)
pr_head = _text(record.get("pr_head_sha"))
local_head = _text(record.get("head_sha"))
remote_head = _text(record.get("remote_head_sha"))
raw_pr_number = record.get("pr_number")
raw_issue_number = lock_record.get("issue_number")
if raw_pr_number is None or raw_issue_number is None:
return None
if not branch_name or not pr_head:
return None
# The assessor required all three heads to agree before it granted renewal.
# Re-check, so a truncated, drifted, or hand-built record cannot widen the
# exemption past the single head the renewal disposition actually proved.
if not local_head or not remote_head:
return None
if pr_head != local_head or pr_head != remote_head:
return None
# Renewal is refused outright unless the durable lock records both a
# claimant username and profile, so a sanctioned record always carries them.
# Requiring them to still agree rejects a lock whose renewal block and
# claimant disagree. Both values are read from this one server-written
# document: this is internal consistency, not a check against the live
# authenticated caller — see the docstring for the binding that is.
claimant = lock_record.get("claimant")
if not isinstance(claimant, Mapping):
lease = lock_record.get("work_lease")
claimant = lease.get("claimant") if isinstance(lease, Mapping) else None
if not isinstance(claimant, Mapping):
return None
identity = _text(record.get("identity"))
profile = _text(record.get("profile"))
if not identity or identity != _text(claimant.get("username")):
return None
if not profile or profile != _text(claimant.get("profile")):
return None
try:
pr_number = int(raw_pr_number)
issue_number = int(raw_issue_number)
except (TypeError, ValueError):
return None
return {
"issue_number": issue_number,
"pr_number": pr_number,
"branch_name": branch_name,
"head_sha": pr_head,
"recorded_head": pr_head,
"accepted_head": pr_head,
"head_relation": "equal",
}
def build_renewal_record( def build_renewal_record(
assessment: Mapping[str, Any] | None, assessment: Mapping[str, Any] | None,
*, *,
+127 -8
View File
@@ -299,9 +299,13 @@ def _ownership_refusals(
f"lock worktree '{lock.get('worktree_path')}' does not match " f"lock worktree '{lock.get('worktree_path')}' does not match "
f"'{worktree_path}'" f"'{worktree_path}'"
) )
lease = lock.get("work_lease") if isinstance(lock, dict) else None # #953 AC2/AC13/AC14: read through the shared claimant reader so a lock
claimant = lease.get("claimant") if isinstance(lease, dict) else None # written by bootstrap — which records the claimant at the top level — is
claimant = claimant if isinstance(claimant, dict) else {} # not refused for "not recording a claimant" when it plainly records one.
# This is not a widening: the values are still compared against the
# server-resolved identity and profile immediately below, so a legacy
# placement grants nothing that the canonical placement would not.
claimant = lock_claimant(lock) if isinstance(lock, dict) else {}
recorded_identity = str(claimant.get("username") or "").strip() recorded_identity = str(claimant.get("username") or "").strip()
recorded_profile = str(claimant.get("profile") or "").strip() recorded_profile = str(claimant.get("profile") or "").strip()
if not recorded_identity or not recorded_profile: if not recorded_identity or not recorded_profile:
@@ -636,6 +640,99 @@ def iter_lock_files(lock_dir: str | None = None) -> list[str]:
return sorted(paths) return sorted(paths)
def release_session_lock(
*,
issue_number: int,
session: str,
lock_dir: str | None = None,
remote: str | None = None,
org: str | None = None,
repo: str | None = None,
) -> str:
"""Remove exactly the durable lock *session* created for *issue_number*.
``author_issue_bootstrap.run_compensating_recovery`` has called this name
since #850, but it was never defined: the call raised ``AttributeError``
into a bare ``except Exception: pass``, so the lock half of every
compensating rollback silently did nothing. The branch and worktree were
removed and the lock was left behind — a state no sanctioned tool can act
on, since recovery refuses ``worktree_invalid`` and ``gitea_lock_issue`` has
no worktree to bind (#953 review 632 F2).
Ownership is proven, not asserted. A record is removed only when its
recorded ``owner_session`` equals *session* and its issue number matches;
``remote``/``org``/``repo`` narrow it further when supplied. Zero matches or
more than one both raise, so a caller can never delete a lock it does not
own and an ambiguous directory is never guessed at. The ``.json.lock`` flock
sidecar is deliberately left in place — it is a zero-byte mutex another
process may hold, and removing it under contention would be a race.
Returns the removed lock file path.
"""
target_issue = int(issue_number)
owner = str(session or "").strip()
if not owner:
raise ValueError(
"release_session_lock requires the owning session id (fail closed)"
)
def _is_owned_durable_lock(record: dict[str, Any] | None) -> bool:
# A durable lock, not a bootstrap phase journal or a session pointer,
# both of which can share a directory and carry the same issue number
# and owner_session.
if not record or "lock_generation" not in record:
return False
if not str(record.get("branch_name") or "").strip():
return False
if not str(record.get("worktree_path") or "").strip():
return False
try:
if int(record.get("issue_number") or 0) != target_issue:
return False
except (TypeError, ValueError):
return False
return str(record.get("owner_session") or "").strip() == owner
# Prefer the exact keyed path when the caller knows the repository; scanning
# is the fallback for callers that only carry the issue number.
if remote and org and repo:
exact = lock_file_path(
remote=remote,
org=org,
repo=repo,
issue_number=target_issue,
lock_dir=lock_dir,
)
if not _is_owned_durable_lock(read_lock_file(exact)):
raise FileNotFoundError(
f"durable issue lock '{exact}' is absent or is not owned by "
f"session '{owner}' (fail closed; nothing released)"
)
os.remove(exact)
return exact
matches: list[str] = []
for path in iter_lock_files(lock_dir):
if _is_owned_durable_lock(read_lock_file(path)):
matches.append(path)
if not matches:
raise FileNotFoundError(
f"no durable issue lock for issue #{target_issue} is owned by "
f"session '{owner}' (fail closed; nothing released)"
)
if len(matches) > 1:
raise RuntimeError(
f"{len(matches)} durable locks for issue #{target_issue} claim "
f"session '{owner}'; refusing to guess which to release "
"(fail closed)"
)
path = matches[0]
os.remove(path)
return path
def find_lock_for_branch( def find_lock_for_branch(
*, *,
remote: str, remote: str,
@@ -1112,21 +1209,43 @@ def assess_same_issue_lease_conflict(
) )
def _lock_claimant(lock: dict[str, Any] | None) -> dict[str, str]: def lock_claimant(lock: dict[str, Any] | None) -> dict[str, str]:
"""Read the claimant from either canonical or legacy placement (#953 AC13/AC14).
``work_lease.claimant`` is the canonical placement and is preferred; a
top-level ``claimant`` is the legacy/bootstrap placement and is accepted as
a fallback. This is the single definition. Before #953 the readers
disagreed: this module, ``issue_lock_renewal``, and ``issue_lock_recovery``
tolerated both placements, while ``_ownership_refusals`` looked only in
``work_lease`` — which is what made a bootstrap-written lock
un-heartbeatable.
Preferring ``work_lease`` over the top level is deliberate: once a legacy
lock is upgraded, the canonical placement is authoritative and a stale
top-level copy must never win.
This decides *where to look*, never whether ownership is proven — every
caller still compares these values against server-resolved identity and
profile.
"""
if not isinstance(lock, dict): if not isinstance(lock, dict):
return {} return {}
claimant = lock.get("claimant")
if not isinstance(claimant, dict):
lease = lock.get("work_lease") lease = lock.get("work_lease")
claimant = lease.get("claimant") if isinstance(lease, dict) else None claimant = lease.get("claimant") if isinstance(lease, dict) else None
if not isinstance(claimant, dict):
claimant = lock.get("claimant")
if not isinstance(claimant, dict): if not isinstance(claimant, dict):
return {} return {}
return { return {
"username": str(claimant.get("username") or ""), "username": str(claimant.get("username") or "").strip(),
"profile": str(claimant.get("profile") or ""), "profile": str(claimant.get("profile") or "").strip(),
} }
#: Back-compatible alias for the pre-#953 private name.
_lock_claimant = lock_claimant
def assess_foreign_lock_overwrite( def assess_foreign_lock_overwrite(
existing_lock: dict[str, Any] | None, existing_lock: dict[str, Any] | None,
incoming_lock: dict[str, Any], incoming_lock: dict[str, Any],
+328
View File
@@ -0,0 +1,328 @@
"""Sanctioned MCP client reconnect request surface for Codex/LLM sessions (#678).
Codex and other agent hosts can detect stale or closed Gitea MCP runtimes, but
the host owns the transport. This module never restarts, kills, or reloads a
daemon. It builds:
1. A **callable reconnect request** result agents can invoke via
``gitea_request_mcp_reconnect`` (report-only, side-effect free).
2. A **typed blocker** with exact operator UI steps when recovery must be
performed by the host/operator.
Forbidden recovery paths (must never be recommended):
* ``pkill`` / ``kill`` / ``killall`` of MCP daemons
* ``touch`` / mtime config reload hacks
* ``.env`` or MCP config edits as recovery
* session-state file edits
* raw Gitea API / direct server-import fallbacks
After the operator reconnects, workflows restart from identity / runtime /
capability preflight (``gitea_whoami`` → ``gitea_resolve_task_capability`` →
task).
"""
from __future__ import annotations
from typing import Any, Mapping
# --- Reason vocabulary -------------------------------------------------------
REASON_STALE_RUNTIME = "stale-runtime"
REASON_TRANSPORT_EOF = "transport_eof"
REASON_MISSING_NAMESPACE = "missing_namespace"
REASON_NOT_REQUIRED = "not_required"
REASON_UNSPECIFIED = "unspecified"
VALID_REASONS = frozenset(
{
REASON_STALE_RUNTIME,
REASON_TRANSPORT_EOF,
REASON_MISSING_NAMESPACE,
REASON_NOT_REQUIRED,
REASON_UNSPECIFIED,
}
)
# Boundary statuses reported to callers (match review_workflow_boundary style).
BOUNDARY_CLEAN = "clean"
BOUNDARY_MISMATCH = "mismatch"
BOUNDARY_STALE = "stale"
BOUNDARY_UNKNOWN = "unknown"
# Typed blocker kinds
BLOCKER_OPERATOR_RECONNECT = "operator_mcp_reconnect_required"
BLOCKER_NONE = "none"
FORBIDDEN_RECOVERY_PATHS: tuple[str, ...] = (
"pkill / kill / killall of mcp_server.py, gitea_mcp_server, or broad python sweeps",
"touch / mtime-based MCP config reload hacks",
".env edits as recovery",
"MCP config file edits as recovery",
"session-state file edits as recovery",
"raw Gitea API or direct MCP server-import fallbacks",
)
# Client-specific operator UI steps. Keep Codex first (issue title surface).
OPERATOR_UI_STEPS: dict[str, tuple[str, ...]] = {
"codex": (
"In Codex, open the MCP / Developer tools panel for this workspace.",
"Locate the named Gitea MCP server entry (namespace) that needs reconnect "
"(e.g. gitea-author, gitea-reviewer, gitea-merger, gitea-tools, "
"gitea-controller, gitea-reconciler).",
"Click 'Reload Developer Tools' or the server reconnect/reload control "
"for that entry so the client spawns a fresh MCP subprocess.",
"If per-server reconnect is unavailable, fully restart the Codex client "
"(quit and relaunch) so all MCP namespaces reattach.",
"After reconnect, rerun the blocked workflow from preflight: "
"gitea_whoami → gitea_resolve_task_capability → the original task. "
"Do not resume mid-mutation.",
),
"claude_code": (
"Run `/mcp` (or open the MCP servers UI) in Claude Code.",
"Reconnect the affected gitea-* server entry so the client reopens stdio.",
"If reconnect fails, relaunch the Claude Code session entirely.",
"After reconnect, restart the workflow from gitea_whoami → "
"gitea_resolve_task_capability → task.",
),
"generic": (
"Use the host/IDE MCP reconnect or reload control for the named namespace.",
"If no per-namespace control exists, restart the MCP client/editor.",
"After reconnect, restart the workflow from identity/capability preflight.",
),
}
DEFAULT_CLIENT = "codex"
def normalize_reason(reason: str | None) -> str:
"""Map free-form reason strings onto the closed vocabulary."""
raw = (reason or "").strip().lower()
if not raw:
return REASON_UNSPECIFIED
if raw in VALID_REASONS:
return raw
text = raw.replace(" ", "_").replace("-", "_")
aliases = {
"stale_runtime": REASON_STALE_RUNTIME,
"staleruntime": REASON_STALE_RUNTIME,
"runtime_stale": REASON_STALE_RUNTIME,
"stale": REASON_STALE_RUNTIME,
"transport_eof": REASON_TRANSPORT_EOF,
"transport_closed": REASON_TRANSPORT_EOF,
"eof": REASON_TRANSPORT_EOF,
"client_is_closing": REASON_TRANSPORT_EOF,
"missing_namespace": REASON_MISSING_NAMESPACE,
"namespace_missing": REASON_MISSING_NAMESPACE,
"not_required": REASON_NOT_REQUIRED,
"healthy": REASON_NOT_REQUIRED,
"ok": REASON_NOT_REQUIRED,
"unspecified": REASON_UNSPECIFIED,
}
if text in aliases:
return aliases[text]
hyphenated = text.replace("_", "-")
if hyphenated in VALID_REASONS:
return hyphenated
return REASON_UNSPECIFIED
def normalize_client(client: str | None) -> str:
"""Return a known client key for operator UI steps."""
text = (client or "").strip().lower().replace(" ", "_").replace("-", "_")
if text in ("codex", "openai_codex", "openai"):
return "codex"
if text in ("claude", "claude_code", "claude_desktop", "anthropic"):
return "claude_code"
if text in OPERATOR_UI_STEPS:
return text
return DEFAULT_CLIENT
def classify_boundary_status(
*,
startup_sha: str | None,
current_master_sha: str | None,
live_stale: bool | None = None,
in_parity: bool | None = None,
) -> str:
"""Derive boundary_status from parity evidence."""
if live_stale is True or in_parity is False:
return BOUNDARY_STALE
start = (startup_sha or "").strip().lower()
current = (current_master_sha or "").strip().lower()
if start and current and start != current:
return BOUNDARY_MISMATCH
if start and current and start == current:
return BOUNDARY_CLEAN
if in_parity is True:
return BOUNDARY_CLEAN
return BOUNDARY_UNKNOWN
def operator_ui_steps(client: str | None, *, namespace: str | None = None) -> list[str]:
"""Exact operator UI steps for the named client."""
key = normalize_client(client)
steps = list(OPERATOR_UI_STEPS.get(key) or OPERATOR_UI_STEPS[DEFAULT_CLIENT])
ns = (namespace or "").strip()
if ns:
steps = [
s.replace("named Gitea MCP server entry (namespace)", f"namespace '{ns}'")
.replace("affected gitea-* server entry", f"server entry '{ns}'")
.replace("named namespace", f"namespace '{ns}'")
for s in steps
]
return steps
def build_reconnect_request(
*,
namespace: str,
profile: str | None = None,
pid: int | str | None = None,
session_id: str | None = None,
startup_sha: str | None = None,
current_master_sha: str | None = None,
boundary_status: str | None = None,
reason: str | None = None,
client: str | None = DEFAULT_CLIENT,
live_stale: bool | None = None,
in_parity: bool | None = None,
restart_required: bool | None = None,
stop_required: bool | None = None,
extra: Mapping[str, Any] | None = None,
) -> dict[str, Any]:
"""Build the structured reconnect-request / typed-blocker payload (#678).
Never mutates process, config, or session state. Always side-effect free.
"""
ns = (namespace or "").strip() or "unknown"
normalized_reason = normalize_reason(reason)
boundary = (boundary_status or "").strip() or classify_boundary_status(
startup_sha=startup_sha,
current_master_sha=current_master_sha,
live_stale=live_stale,
in_parity=in_parity,
)
reconnect_needed = True
if normalized_reason == REASON_NOT_REQUIRED and boundary == BOUNDARY_CLEAN:
reconnect_needed = False
if restart_required is False and stop_required is False and boundary == BOUNDARY_CLEAN:
# Explicit healthy probe
if normalized_reason in (REASON_NOT_REQUIRED, REASON_UNSPECIFIED):
reconnect_needed = False
normalized_reason = REASON_NOT_REQUIRED
if restart_required is True or stop_required is True:
reconnect_needed = True
if normalized_reason in (REASON_NOT_REQUIRED, REASON_UNSPECIFIED):
normalized_reason = REASON_STALE_RUNTIME
client_key = normalize_client(client)
steps = operator_ui_steps(client_key, namespace=ns)
result: dict[str, Any] = {
"success": True,
"read_only": True,
"reconnect_performed": False,
"mutation_performed": False,
"reconnect_needed": reconnect_needed,
"namespace": ns,
"profile": (profile or "").strip() or None,
"pid": pid,
"session_id": (session_id or "").strip() or None,
"startup_sha": (startup_sha or "").strip() or None,
"current_master_sha": (current_master_sha or "").strip() or None,
"boundary_status": boundary,
"reason": normalized_reason,
"client": client_key,
"forbidden_recovery_paths": list(FORBIDDEN_RECOVERY_PATHS),
"post_reconnect_preflight": [
"gitea_whoami",
"gitea_resolve_task_capability",
"original_task",
],
"exact_safe_next_action": None,
"blocker_kind": BLOCKER_NONE,
"operator_ui_steps": steps,
"typed_blocker": None,
}
if reconnect_needed:
result["blocker_kind"] = BLOCKER_OPERATOR_RECONNECT
result["stop_required"] = True
result["restart_required"] = True
result["exact_safe_next_action"] = (
f"blocker_kind={BLOCKER_OPERATOR_RECONNECT}: operator must reconnect "
f"MCP namespace '{ns}' via the host UI (client={client_key}). "
"Do not pkill, touch configs, edit session state, or use raw API. "
"After reconnect, restart from gitea_whoami → "
"gitea_resolve_task_capability → task."
)
result["typed_blocker"] = {
"blocker_kind": BLOCKER_OPERATOR_RECONNECT,
"namespaces": [ns],
"why_reconnect_required": normalized_reason,
"operator_ui_steps": steps,
"client": client_key,
"forbidden_recovery_paths": list(FORBIDDEN_RECOVERY_PATHS),
"instruction_after_reconnect": (
"Rerun the blocked workflow from preflight "
"(gitea_whoami → gitea_resolve_task_capability → task). "
"Do not continue mid-mutation from pre-reconnect state."
),
}
else:
result["stop_required"] = False
result["restart_required"] = False
result["exact_safe_next_action"] = (
f"Reconnect not required for namespace '{ns}' "
f"(boundary_status={boundary}). Proceed with the original task."
)
if extra:
for key, value in extra.items():
if key not in result:
result[key] = value
return result
def reasons_never_suggest_forbidden(text: str) -> bool:
"""Return True when *text* does not recommend a forbidden recovery path.
Mentions that *ban* a path (e.g. ``Do not pkill`` / ``never edit session
state``) are allowed. Positive recommendations such as ``use pkill`` or
``run killall`` fail.
"""
import re
lowered = (text or "").lower()
# Strip common ban prefixes so "do not pkill" does not trip positive checks.
scrubbed = re.sub(
r"\b(?:do not|don't|never|must not|forbid(?:den)?|ban(?:ned)?)\b"
r"[^.!;\n]{0,80}",
" ",
lowered,
)
# Positive imperative / advisory forms that would tell an agent to do harm.
positive_suggestions = (
"use pkill",
"run pkill",
"try pkill",
"pkill -f",
"use killall",
"run killall",
"killall mcp",
"use kill ",
"run kill ",
"touch the mcp",
"touch mcp config",
"utime(",
"edit the mcp config to recover",
"edit .env to recover",
"import gitea_mcp_server",
"python -c 'import gitea_mcp",
)
return not any(frag in scrubbed for frag in positive_suggestions)
+237
View File
@@ -0,0 +1,237 @@
"""Antigravity IDE vs Global MCP Config Drift Diagnostic (#672).
Diagnoses config drift between the active IDE MCP configuration
(e.g. ``~/.gemini/antigravity-ide/mcp_config.json``) and the offline/global
canonical configuration (e.g. ``~/.gemini/config/mcp_config.json``).
Hard rules (#672 / #630 / #655):
* Distinguish offline/global success from active IDE namespace availability.
* Never print tokens, DSNs, Authorization headers, or secret-bearing env vars.
* Sanctioned repair path is: backup active config -> patch active config from canonical
-> reconnect through IDE/client -> verify with live ``gitea_whoami``.
* FORBIDDEN: ``pkill``, mtime edits, source edits, or session-state edits for repair.
"""
from __future__ import annotations
import argparse
import json
import os
import sys
from datetime import datetime, timezone
from pathlib import Path
from typing import Any
from webui import console_redaction
DEFAULT_ACTIVE_IDE_CONFIG = "~/.gemini/antigravity-ide/mcp_config.json"
DEFAULT_GLOBAL_CONFIG = "~/.gemini/config/mcp_config.json"
REQUIRED_GITEA_ROLE_SERVERS = (
"gitea-author",
"gitea-reviewer",
"gitea-merger",
"gitea-reconciler",
"gitea-controller",
"gitea-tools",
)
SANCTIONED_REPAIR_RUNBOOK: tuple[str, ...] = (
"1. Backup active IDE config: cp ~/.gemini/antigravity-ide/mcp_config.json ~/.gemini/antigravity-ide/mcp_config.json.bak",
"2. Patch active IDE config: copy required missing Gitea role server entries from global config (~/.gemini/config/mcp_config.json) into active IDE config.",
"3. Reconnect via IDE/client UI or client restart (do NOT use host process kill).",
"4. Verify active namespace health using live gitea_whoami and gitea_resolve_task_capability on each role namespace.",
"FORBIDDEN REPAIR PATHS: pkill / host process kill, mtime touch edits, source code edits, or session-state edits.",
)
def resolve_config_path(path_str: str) -> Path:
"""Expand user and resolve absolute path."""
return Path(os.path.expanduser(path_str)).resolve()
def load_mcp_config(config_path: str | Path) -> tuple[dict[str, Any] | None, str | None]:
"""Load and parse JSON MCP configuration from file.
Returns (config_dict, error_message).
"""
resolved = resolve_config_path(str(config_path))
if not resolved.exists():
return None, f"file_not_found: {resolved}"
try:
with open(resolved, "r", encoding="utf-8") as f:
data = json.load(f)
if not isinstance(data, dict):
return None, f"invalid_schema: root is not a JSON object in {resolved}"
return data, None
except Exception as exc:
return None, f"unreadable_json: {exc} in {resolved}"
def extract_mcp_servers(config: dict[str, Any] | None) -> dict[str, dict[str, Any]]:
"""Extract the mcpServers or mcp_servers mapping safely."""
if not config:
return {}
servers = config.get("mcpServers") or config.get("mcp_servers") or {}
if isinstance(servers, dict):
return {str(k): v for k, v in servers.items() if isinstance(v, dict)}
return {}
def _safe_redact_server_config(srv_cfg: dict[str, Any]) -> dict[str, Any]:
"""Redact secrets from environment variables and command line args."""
safe = {}
if "command" in srv_cfg:
safe["command"] = str(srv_cfg["command"])
if "args" in srv_cfg and isinstance(srv_cfg["args"], list):
safe["args"] = [console_redaction.redact_text(str(a)) for a in srv_cfg["args"]]
if "env" in srv_cfg and isinstance(srv_cfg["env"], dict):
safe_env = {}
for k, v in srv_cfg["env"].items():
if any(secret_kw in k.lower() for secret_kw in ("token", "secret", "pass", "key", "auth")):
safe_env[k] = "[REDACTED]"
else:
safe_env[k] = console_redaction.redact_text(str(v))
safe["env"] = safe_env
return safe
def analyze_config_drift(
active_config_path: str = DEFAULT_ACTIVE_IDE_CONFIG,
global_config_path: str = DEFAULT_GLOBAL_CONFIG,
) -> dict[str, Any]:
"""Analyze MCP configuration drift between active IDE config and global config.
Returns structured diagnostic output.
"""
active_resolved = resolve_config_path(active_config_path)
global_resolved = resolve_config_path(global_config_path)
active_cfg, active_err = load_mcp_config(active_resolved)
global_cfg, global_err = load_mcp_config(global_resolved)
active_servers = extract_mcp_servers(active_cfg)
global_servers = extract_mcp_servers(global_cfg)
missing_role_servers: list[str] = []
present_role_servers: list[str] = []
profile_mismatches: list[dict[str, Any]] = []
reasons: list[str] = []
if active_err:
reasons.append(f"Active IDE config error: {active_err}")
if global_err:
reasons.append(f"Global canonical config error: {global_err}")
# Check Gitea role servers
for srv_name in REQUIRED_GITEA_ROLE_SERVERS:
in_active = srv_name in active_servers
in_global = srv_name in global_servers
if in_active:
present_role_servers.append(srv_name)
elif in_global:
missing_role_servers.append(srv_name)
reasons.append(
f"Missing Gitea role server '{srv_name}' in active IDE config ({active_resolved})"
)
if in_active and in_global:
# Compare profiles & environments
act_env = active_servers[srv_name].get("env", {}) if isinstance(active_servers[srv_name], dict) else {}
glo_env = global_servers[srv_name].get("env", {}) if isinstance(global_servers[srv_name], dict) else {}
act_prof = act_env.get("GITEA_MCP_PROFILE") or act_env.get("GITEA_PROFILE_NAME")
glo_prof = glo_env.get("GITEA_MCP_PROFILE") or glo_env.get("GITEA_PROFILE_NAME")
if act_prof != glo_prof:
mismatch_item = {
"server": srv_name,
"active_profile": act_prof,
"global_profile": glo_prof,
}
profile_mismatches.append(mismatch_item)
reasons.append(
f"Profile mismatch for '{srv_name}': active='{act_prof}' != global='{glo_prof}'"
)
in_sync = bool(
not active_err
and not global_err
and not missing_role_servers
and not profile_mismatches
)
report = {
"timestamp": datetime.now(timezone.utc).isoformat(),
"in_sync": in_sync,
"active_config_path": str(active_resolved),
"active_config_exists": active_cfg is not None,
"global_config_path": str(global_resolved),
"global_config_exists": global_cfg is not None,
"required_role_servers": list(REQUIRED_GITEA_ROLE_SERVERS),
"present_role_servers": present_role_servers,
"missing_role_servers": missing_role_servers,
"profile_mismatches": profile_mismatches,
"reasons": reasons,
"sanctioned_repair_runbook": list(SANCTIONED_REPAIR_RUNBOOK),
"forbidden_repair_methods": [
"pkill / host process kill",
"mtime touch edits",
"source code edits",
"session-state edits",
],
}
return console_redaction.redact_payload(report)
def main() -> None:
parser = argparse.ArgumentParser(
description="Diagnose Gitea MCP role server config drift between active IDE and global config."
)
parser.add_argument(
"--active-config",
default=DEFAULT_ACTIVE_IDE_CONFIG,
help="Path to active IDE MCP config JSON",
)
parser.add_argument(
"--global-config",
default=DEFAULT_GLOBAL_CONFIG,
help="Path to global/canonical MCP config JSON",
)
parser.add_argument(
"--json", action="store_true", help="Print raw JSON report"
)
args = parser.parse_args()
report = analyze_config_drift(args.active_config, args.global_config)
if args.json:
print(json.dumps(report, indent=2))
else:
print("=== MCP Config Drift Diagnostic Report ===")
print(f"Timestamp: {report['timestamp']}")
print(f"In Sync: {report['in_sync']}")
print(f"Active IDE Config: {report['active_config_path']} (exists={report['active_config_exists']})")
print(f"Global Config: {report['global_config_path']} (exists={report['global_config_exists']})")
print(f"Present Role Servers: {', '.join(report['present_role_servers']) if report['present_role_servers'] else 'None'}")
print(f"Missing Role Servers: {', '.join(report['missing_role_servers']) if report['missing_role_servers'] else 'None'}")
if report['profile_mismatches']:
print("Profile Mismatches:")
for m in report['profile_mismatches']:
print(f" - {m['server']}: active={m['active_profile']} vs global={m['global_profile']}")
if report['reasons']:
print("Drift Reasons:")
for r in report['reasons']:
print(f" - {r}")
print("\nSanctioned Repair Runbook:")
for step in report['sanctioned_repair_runbook']:
print(f" {step}")
sys.exit(0 if report["in_sync"] else 1)
if __name__ == "__main__":
main()
+35 -5
View File
@@ -225,8 +225,33 @@ _RESTART_PATHS: tuple[RestartPath, ...] = (
"exact_safe_next_action pointing at IDE/client reconnect; performs " "exact_safe_next_action pointing at IDE/client reconnect; performs "
"no restart, thread spawn, config touch, or os._exit." "no restart, thread spawn, config touch, or os._exit."
), ),
locations=("gitea_mcp_server.py (gitea_resolve_task_capability)",), locations=(
references=("#685", "#657"), "gitea_mcp_server.py (gitea_resolve_task_capability)",
"gitea_mcp_server.py (gitea_request_mcp_reconnect)",
"mcp_client_reconnect.py",
),
references=("#685", "#657", "#678"),
),
RestartPath(
path_id="codex_client_reconnect_request",
title="Sanctioned Codex/LLM reconnect request tool",
mechanism=(
"gitea_request_mcp_reconnect: agents invoke a report-only tool that "
"returns namespace/profile/pid/startup SHA/master SHA/boundary "
"status plus a typed operator blocker with exact client UI steps."
),
classification=CLASS_GUARDED_FAIL_CLOSED,
guard=(
"Report-only (#678): never restarts, kills, reloads, or edits "
"config; recovery is always host/operator reconnect. Forbidden "
"paths (pkill, touch, .env/config/session-state hacks) are listed "
"and never recommended."
),
locations=(
"mcp_client_reconnect.py",
"gitea_mcp_server.py (gitea_request_mcp_reconnect)",
),
references=("#678", "#630", "#685", "#657"),
), ),
RestartPath( RestartPath(
path_id="manual_daemon_kill", path_id="manual_daemon_kill",
@@ -271,7 +296,8 @@ _RESTART_PATHS: tuple[RestartPath, ...] = (
title="Host/IDE MCP reconnect", title="Host/IDE MCP reconnect",
mechanism=( mechanism=(
"A manual `/mcp reconnect` (or equivalent host action) that the " "A manual `/mcp reconnect` (or equivalent host action) that the "
"IDE performs to recreate the MCP client connection." "IDE performs to recreate the MCP client connection. Agents obtain "
"exact UI steps via gitea_request_mcp_reconnect (#678)."
), ),
classification=CLASS_HOST_RESIDUAL, classification=CLASS_HOST_RESIDUAL,
guard=( guard=(
@@ -279,8 +305,12 @@ _RESTART_PATHS: tuple[RestartPath, ...] = (
"gates point operators toward; documented as residual host " "gates point operators toward; documented as residual host "
"behavior. No in-process code initiates it." "behavior. No in-process code initiates it."
), ),
locations=("host/IDE",), locations=(
references=("#584", "#656", "#657"), "host/IDE",
"mcp_client_reconnect.py",
"gitea_mcp_server.py (gitea_request_mcp_reconnect)",
),
references=("#584", "#656", "#657", "#678"),
residual_host=True, residual_host=True,
), ),
RestartPath( RestartPath(
+3
View File
@@ -0,0 +1,3 @@
[pytest]
testpaths = tests
norecursedirs = branches .git venv __pycache__ graphify-out
+426
View File
@@ -0,0 +1,426 @@
"""MCP restart lifecycle audit events and incident materialization (#665).
Restarts and recovery attempts must leave a forensic trail: impact previews,
drain enter/exit, drain-proof results, apply gate verdicts, break-glass, and
post-restart reconcile outcomes. Failed drains and break-glass must also raise
durable Gitea incident issues so they cannot be silently repeated.
This module is the pure + sink layer for that trail:
* **Schema** — ``mcp.restart.*`` event names and a redacted payload builder.
* **Emission** — append-only via :mod:`gitea_audit` (off when ``GITEA_AUDIT_LOG``
is unset; privileged apply can still *require* a successful write).
* **Incidents** — descriptors for failed drain / break-glass / unguarded restart,
plus an optional materializer that creates a Gitea issue through an injected
``create_issue_fn`` (keeps this module free of network I/O in tests).
Design rules:
* **No secrets.** All free text is redacted before write or issue body assembly.
* **Never raises from emission.** ``emit_restart_event`` returns False on sink
failure so callers can decide fail-closed policy for privileged restarts.
* **Does not restart.** Audit never executes a process restart.
* **Drain proof stays #661.** This module records what the gate decided; it
does not re-verify proofs.
"""
from __future__ import annotations
import uuid
from datetime import datetime, timezone
from typing import Any, Callable, Mapping, Sequence
import gitea_audit
# ── Event vocabulary (stable identifiers for operators + tests) ───────────────
EVENT_IMPACT_PREVIEW = "mcp.restart.impact_preview"
EVENT_DRAIN_ENTER = "mcp.restart.drain_enter"
EVENT_DRAIN_EXIT = "mcp.restart.drain_exit"
EVENT_DRAIN_PROOF = "mcp.restart.drain_proof"
EVENT_APPLY_GATE = "mcp.restart.apply_gate"
EVENT_BREAK_GLASS = "mcp.restart.break_glass"
EVENT_POST_RESTART_RECONCILE = "mcp.restart.post_restart_reconcile"
EVENT_NARROWER_RECOVERY = "mcp.restart.narrower_recovery"
EVENT_UNGUARDED_DETECTED = "mcp.restart.unguarded_detected"
RESTART_EVENT_TYPES: frozenset[str] = frozenset(
{
EVENT_IMPACT_PREVIEW,
EVENT_DRAIN_ENTER,
EVENT_DRAIN_EXIT,
EVENT_DRAIN_PROOF,
EVENT_APPLY_GATE,
EVENT_BREAK_GLASS,
EVENT_POST_RESTART_RECONCILE,
EVENT_NARROWER_RECOVERY,
EVENT_UNGUARDED_DETECTED,
}
)
# Incident kinds (durable Gitea issues).
INCIDENT_FAILED_DRAIN = "restart_failed_drain"
INCIDENT_BREAK_GLASS = "restart_break_glass"
INCIDENT_RECONCILE_UNRESOLVED = "restart_reconcile_unresolved"
INCIDENT_UNGUARDED = "restart_unguarded_detected"
DEFAULT_INCIDENT_LABELS: tuple[str, ...] = (
"mcp-health",
"safety",
"observability",
"status:ready",
"type:bug",
"workflow-hardening",
)
CreateIssueFn = Callable[..., dict[str, Any]]
def _utc_now_iso() -> str:
return datetime.now(timezone.utc).isoformat()
def new_correlation_id() -> str:
"""Mint a short correlation id shared across a restart lifecycle."""
return f"rst-{uuid.uuid4().hex[:16]}"
def build_restart_event(
*,
event_type: str,
outcome: str,
correlation_id: str | None = None,
remote: str | None = None,
org: str | None = None,
repo: str | None = None,
requesting_session_id: str | None = None,
restart_class: str | None = None,
profile_name: str | None = None,
authenticated_username: str | None = None,
reasons: Sequence[str] | None = None,
details: Mapping[str, Any] | None = None,
now: str | None = None,
) -> dict[str, Any]:
"""Build a redacted ``mcp.restart.*`` audit event.
Raises ``ValueError`` on unknown event types so a typo cannot silently land
under a free-form action name.
"""
name = str(event_type or "").strip()
if name not in RESTART_EVENT_TYPES:
raise ValueError(
f"unknown restart audit event_type {name!r}; expected one of "
f"{sorted(RESTART_EVENT_TYPES)}"
)
redacted_reasons = [
gitea_audit.redact(str(r)) for r in (reasons or []) if str(r).strip()
]
redacted_details = gitea_audit.redact(dict(details or {}))
if not isinstance(redacted_details, dict):
redacted_details = {"value": redacted_details}
event = gitea_audit.build_event(
action=name,
result=str(outcome or "unknown"),
remote=remote,
repository=f"{org}/{repo}" if org and repo else None,
profile_name=profile_name,
authenticated_username=authenticated_username,
reason="; ".join(redacted_reasons) if redacted_reasons else None,
request_metadata={
"event_family": "mcp.restart",
"correlation_id": correlation_id or new_correlation_id(),
"restart_class": restart_class,
"requesting_session_id": requesting_session_id,
"org": org,
"repo": repo,
"details": redacted_details,
"reasons": redacted_reasons,
},
now=now or _utc_now_iso(),
operation=name,
)
event["action_type"] = "restart_lifecycle"
event["event_type"] = name
event["correlation_id"] = (event.get("request_metadata") or {}).get(
"correlation_id"
)
return event
def emit_restart_event(event: Mapping[str, Any], *, path: str | None = None) -> bool:
"""Append *event* to the audit sink. Never raises. Returns write success."""
try:
return bool(gitea_audit.write_event(dict(event), path=path))
except Exception:
return False
def require_audit_or_deny(
*,
privileged: bool,
written: bool,
audit_enabled: bool | None = None,
) -> list[str]:
"""Return deny reasons when a privileged restart path fails to audit.
When audit is not configured (``GITEA_AUDIT_LOG`` unset), privileged apply
still proceeds under the rollout policy "enable audit before enforcing
deny-on-audit-fail" — but *if* audit is enabled and the write fails,
privileged apply is denied (fail closed).
"""
enabled = (
gitea_audit.audit_enabled() if audit_enabled is None else bool(audit_enabled)
)
if not privileged:
return []
if not enabled:
return []
if written:
return []
return [
"privileged restart path requires a successful audit write; "
"audit sink failed (fail closed, #665)"
]
# ── Incident descriptors ──────────────────────────────────────────────────────
def build_incident_descriptor(
*,
kind: str,
reasons: Sequence[str],
correlation_id: str | None = None,
requesting_session_id: str | None = None,
restart_class: str | None = None,
remote: str | None = None,
org: str | None = None,
repo: str | None = None,
proof_id: str | None = None,
at: str | None = None,
) -> dict[str, Any]:
"""Build a durable incident descriptor (no network)."""
titles = {
INCIDENT_FAILED_DRAIN: "Restart denied: drain proof failed the hard gate",
INCIDENT_BREAK_GLASS: "Break-glass MCP restart authorized",
INCIDENT_RECONCILE_UNRESOLVED: "Post-restart reconcile left unresolved work",
INCIDENT_UNGUARDED: "Unguarded MCP restart attempt detected",
}
title = titles.get(kind, f"MCP restart incident ({kind})")
redacted_reasons = [
gitea_audit.redact(str(r)) for r in reasons if str(r).strip()
]
return {
"kind": kind,
"title": title,
"labels": list(DEFAULT_INCIDENT_LABELS),
"reasons": redacted_reasons,
"correlation_id": correlation_id,
"requesting_session_id": requesting_session_id,
"restart_class": restart_class,
"remote": remote,
"org": org,
"repo": repo,
"proof_id": proof_id,
"at": at or _utc_now_iso(),
"source": "restart_audit#665",
}
def incident_body(descriptor: Mapping[str, Any]) -> str:
"""Render a redacted markdown body for a Gitea incident issue."""
reasons = descriptor.get("reasons") or []
reason_lines = "\n".join(f"- {gitea_audit.redact(str(r))}" for r in reasons) or (
"- (no reasons recorded)"
)
return "\n".join(
[
"<!-- mcp-restart-incident:v1 -->",
f"## MCP restart incident (`{descriptor.get('kind')}`)",
"",
f"**Correlation:** `{descriptor.get('correlation_id') or 'none'}`",
f"**Session:** `{descriptor.get('requesting_session_id') or 'none'}`",
f"**Class:** `{descriptor.get('restart_class') or 'none'}`",
f"**Scope:** `{descriptor.get('remote')}/{descriptor.get('org')}/"
f"{descriptor.get('repo')}`",
f"**At:** `{descriptor.get('at')}`",
f"**Proof id:** `{descriptor.get('proof_id') or 'none'}`",
"",
"### Reasons",
reason_lines,
"",
"### Operator next steps",
"- Treat this as durable follow-up work under the restart-governance umbrella (#655).",
"- Do not invent a second restart path; use sanctioned coordinator tools only.",
"- Raw secrets must never appear in this issue (already redacted).",
"",
f"_Source: {descriptor.get('source')}_",
]
)
def materialize_incident(
descriptor: Mapping[str, Any],
*,
create_issue_fn: CreateIssueFn | None,
dry_run: bool = False,
) -> dict[str, Any]:
"""Create a Gitea issue from *descriptor* when *create_issue_fn* is provided.
Returns a result dict with ``created`` / ``issue_number`` / ``dry_run`` /
``reasons``. Never raises.
"""
base: dict[str, Any] = {
"created": False,
"dry_run": bool(dry_run),
"issue_number": None,
"kind": descriptor.get("kind"),
"reasons": [],
"descriptor": dict(descriptor),
}
if dry_run:
base["reasons"] = ["dry-run only; no Gitea issue created"]
return base
if create_issue_fn is None:
base["reasons"] = [
"create_issue_fn not provided; incident descriptor retained only"
]
return base
try:
result = create_issue_fn(
title=str(descriptor.get("title") or "MCP restart incident"),
body=incident_body(descriptor),
labels=list(descriptor.get("labels") or DEFAULT_INCIDENT_LABELS),
org=descriptor.get("org"),
repo=descriptor.get("repo"),
)
number = None
if isinstance(result, dict):
number = result.get("number") or result.get("issue_number")
if number is not None:
base["created"] = True
base["issue_number"] = int(number)
base["reasons"] = [f"created incident issue #{int(number)}"]
else:
base["reasons"] = ["create_issue_fn returned no issue number"]
except Exception as exc: # noqa: BLE001 — never break restart path here
base["reasons"] = [
f"incident issue creation failed: {gitea_audit.redact(str(exc))}"
]
return base
def record_restart_lifecycle(
*,
event_type: str,
outcome: str,
correlation_id: str,
remote: str | None = None,
org: str | None = None,
repo: str | None = None,
requesting_session_id: str | None = None,
restart_class: str | None = None,
profile_name: str | None = None,
authenticated_username: str | None = None,
reasons: Sequence[str] | None = None,
details: Mapping[str, Any] | None = None,
privileged: bool = False,
create_incident: Mapping[str, Any] | None = None,
create_issue_fn: CreateIssueFn | None = None,
dry_run_incident: bool = False,
audit_path: str | None = None,
) -> dict[str, Any]:
"""Emit one restart audit event and optionally materialize an incident.
Returns ``{event, audit_written, deny_reasons, incident_result}``.
"""
event = build_restart_event(
event_type=event_type,
outcome=outcome,
correlation_id=correlation_id,
remote=remote,
org=org,
repo=repo,
requesting_session_id=requesting_session_id,
restart_class=restart_class,
profile_name=profile_name,
authenticated_username=authenticated_username,
reasons=reasons,
details=details,
)
written = emit_restart_event(event, path=audit_path)
deny = require_audit_or_deny(privileged=privileged, written=written)
incident_result = None
if create_incident is not None:
incident_result = materialize_incident(
create_incident,
create_issue_fn=create_issue_fn,
dry_run=dry_run_incident,
)
return {
"event": event,
"audit_written": written,
"deny_reasons": deny,
"incident_result": incident_result,
"correlation_id": correlation_id,
}
def incident_from_apply_gate(
*,
gate_payload: Mapping[str, Any],
break_glass: bool,
correlation_id: str,
requesting_session_id: str | None,
restart_class: str | None,
remote: str | None,
org: str | None,
repo: str | None,
) -> dict[str, Any] | None:
"""Choose an incident descriptor from an apply-gate payload, if required."""
reasons = list(gate_payload.get("reasons") or [])
proof_id = gate_payload.get("proof_id")
if break_glass:
return build_incident_descriptor(
kind=INCIDENT_BREAK_GLASS,
reasons=reasons
or ["break-glass restart path used; durable incident required (#665)"],
correlation_id=correlation_id,
requesting_session_id=requesting_session_id,
restart_class=restart_class,
remote=remote,
org=org,
repo=repo,
proof_id=proof_id if isinstance(proof_id, str) else None,
)
# Failed drain / deny path.
incident = gate_payload.get("incident")
if isinstance(incident, Mapping) and incident:
# Normalize gate-provided descriptor into our schema.
return build_incident_descriptor(
kind=INCIDENT_FAILED_DRAIN,
reasons=list(incident.get("reasons") or reasons),
correlation_id=correlation_id,
requesting_session_id=requesting_session_id
or incident.get("requesting_session_id"),
restart_class=restart_class,
remote=remote,
org=org,
repo=repo,
proof_id=incident.get("proof_id") or proof_id,
at=incident.get("at"),
)
if not gate_payload.get("allow") and not gate_payload.get("drain_gate_allow", True):
return build_incident_descriptor(
kind=INCIDENT_FAILED_DRAIN,
reasons=reasons or ["restart apply denied"],
correlation_id=correlation_id,
requesting_session_id=requesting_session_id,
restart_class=restart_class,
remote=remote,
org=org,
repo=repo,
proof_id=proof_id if isinstance(proof_id, str) else None,
)
return None
+37
View File
@@ -74,6 +74,43 @@ def check_author_mutation_namespace(
return True, [] return True, []
def check_author_role_kind(
mutation_task: str,
profile: dict,
) -> tuple[bool, list[str]]:
"""Author-exclusive wall for durable-lock mutations (#953 F1).
``check_author_mutation_namespace`` walls off reviewer-bound sessions, which
is the whole gate for tasks whose required permission is itself author-only
(``gitea.pr.create``, ``gitea.repo.commit``). It is *not* sufficient for a
task gated on ``gitea.issue.comment``, which every configured role holds: a
merger, controller, or reconciler session would clear both the namespace
check and the permission gate and still reach the durable write.
Opt-in per call site and additive. It refuses any active profile whose
derived role kind is not exactly ``author`` for a task the router declares
author-required, and grants nothing to anyone — a ``mixed`` profile is
refused rather than admitted.
"""
required_role = role_session_router.required_role_for_task(mutation_task)
if required_role != "author":
return True, []
allowed = profile.get("allowed_operations") or []
forbidden = profile.get("forbidden_operations") or []
active_role = derive_role_kind(allowed, forbidden)
if active_role == "author":
return True, []
profile_name = profile.get("profile_name") or ""
namespace = infer_mcp_namespace(profile_name)
return False, [
f"author mutation '{mutation_task}' blocked: active session role kind is "
f"'{active_role}', not 'author' ({profile_name} / {namespace}); this "
"operation writes a durable author issue lock and is author-exclusive",
]
def mutation_audit_context(mutation_task: str, profile: dict, *, def mutation_audit_context(mutation_task: str, profile: dict, *,
remote=None, repository=None) -> dict: remote=None, repository=None) -> dict:
"""Structured mutation metadata for audit records (#209).""" """Structured mutation metadata for audit records (#209)."""
+10
View File
@@ -75,6 +75,10 @@ AUTHOR_TASKS = frozenset({
"push_branch", "push_branch",
"bootstrap_author_issue_worktree", "bootstrap_author_issue_worktree",
"gitea_bootstrap_author_issue_worktree", "gitea_bootstrap_author_issue_worktree",
# #953: recovery of an incomplete bootstrap lock is an author-only durable
# state mutation and belongs to the same class as bootstrap itself.
"recover_incomplete_bootstrap_lock",
"gitea_recover_incomplete_bootstrap_lock",
"create_pr", "create_pr",
"comment_pr", "comment_pr",
"address_pr_change_requests", "address_pr_change_requests",
@@ -112,6 +116,12 @@ TASK_REQUIRED_ROLE = {
"claim_issue": "author", "claim_issue": "author",
"create_branch": "author", "create_branch": "author",
"push_branch": "author", "push_branch": "author",
# #953: without this entry ``required_role_for_task`` returns None and
# ``role_namespace_gate.check_author_mutation_namespace`` short-circuits to
# "allowed" — the namespace wall on the recovery tool would be inert. The
# capability map already records the same role; both tables must agree.
"recover_incomplete_bootstrap_lock": "author",
"gitea_recover_incomplete_bootstrap_lock": "author",
"create_pr": "author", "create_pr": "author",
"comment_pr": "author", "comment_pr": "author",
"address_pr_change_requests": "author", "address_pr_change_requests": "author",
+21
View File
@@ -41,6 +41,27 @@ TASK_CAPABILITY_MAP: dict[str, dict[str, str]] = {
"permission": "gitea.issue.comment", "permission": "gitea.issue.comment",
"role": "author", "role": "author",
}, },
# #953: target-specific upgrade of an incomplete bootstrap lock (explicit
# operation, never a widening of lock_issue). Author-only, and the tool
# additionally proves exact-owner claimant match before writing.
"recover_incomplete_bootstrap_lock": {
"permission": "gitea.issue.comment",
"role": "author",
},
"gitea_recover_incomplete_bootstrap_lock": {
"permission": "gitea.issue.comment",
"role": "author",
},
# #953: read-only lock contract inspection. Read permission only — it must
# never be able to mutate.
"inspect_issue_lock_contract": {
"permission": "gitea.read",
"role": "author",
},
"gitea_inspect_issue_lock_contract": {
"permission": "gitea.read",
"role": "author",
},
# #860: dirty orphaned same-claimant worktree recovery (explicit operation). # #860: dirty orphaned same-claimant worktree recovery (explicit operation).
"recover_dirty_orphaned_issue_worktree": { "recover_dirty_orphaned_issue_worktree": {
"permission": "gitea.issue.comment", "permission": "gitea.issue.comment",
@@ -0,0 +1,323 @@
"""Tests for sanctioned Codex MCP reconnect request surface (#678)."""
from __future__ import annotations
import os
import unittest
from unittest import mock
import mcp_client_reconnect as mcr
class NormalizeReasonTests(unittest.TestCase):
def test_stale_runtime_aliases(self):
self.assertEqual(mcr.normalize_reason("stale-runtime"), mcr.REASON_STALE_RUNTIME)
self.assertEqual(mcr.normalize_reason("stale_runtime"), mcr.REASON_STALE_RUNTIME)
self.assertEqual(mcr.normalize_reason("STALE"), mcr.REASON_STALE_RUNTIME)
def test_transport_eof_aliases(self):
self.assertEqual(mcr.normalize_reason("transport_eof"), mcr.REASON_TRANSPORT_EOF)
self.assertEqual(mcr.normalize_reason("EOF"), mcr.REASON_TRANSPORT_EOF)
self.assertEqual(
mcr.normalize_reason("client_is_closing"), mcr.REASON_TRANSPORT_EOF
)
def test_missing_namespace(self):
self.assertEqual(
mcr.normalize_reason("missing_namespace"), mcr.REASON_MISSING_NAMESPACE
)
def test_empty_is_unspecified(self):
self.assertEqual(mcr.normalize_reason(None), mcr.REASON_UNSPECIFIED)
self.assertEqual(mcr.normalize_reason(""), mcr.REASON_UNSPECIFIED)
class BoundaryClassificationTests(unittest.TestCase):
def test_clean_when_shas_match(self):
self.assertEqual(
mcr.classify_boundary_status(
startup_sha="abc", current_master_sha="abc"
),
mcr.BOUNDARY_CLEAN,
)
def test_mismatch_when_shas_differ(self):
self.assertEqual(
mcr.classify_boundary_status(
startup_sha="aaa", current_master_sha="bbb"
),
mcr.BOUNDARY_MISMATCH,
)
def test_stale_when_live_stale(self):
self.assertEqual(
mcr.classify_boundary_status(
startup_sha="aaa",
current_master_sha="aaa",
live_stale=True,
),
mcr.BOUNDARY_STALE,
)
class BuildReconnectRequestTests(unittest.TestCase):
def test_stale_runtime_returns_typed_blocker_with_codex_steps(self):
result = mcr.build_reconnect_request(
namespace="gitea-author",
profile="prgs-author",
pid=1234,
session_id="sess-1",
startup_sha="aaa111",
current_master_sha="bbb222",
reason="stale-runtime",
client="codex",
restart_required=True,
stop_required=True,
)
self.assertTrue(result["success"])
self.assertTrue(result["read_only"])
self.assertFalse(result["reconnect_performed"])
self.assertFalse(result["mutation_performed"])
self.assertTrue(result["reconnect_needed"])
self.assertEqual(result["namespace"], "gitea-author")
self.assertEqual(result["profile"], "prgs-author")
self.assertEqual(result["pid"], 1234)
self.assertEqual(result["session_id"], "sess-1")
self.assertEqual(result["startup_sha"], "aaa111")
self.assertEqual(result["current_master_sha"], "bbb222")
self.assertEqual(result["boundary_status"], mcr.BOUNDARY_MISMATCH)
self.assertEqual(result["blocker_kind"], mcr.BLOCKER_OPERATOR_RECONNECT)
self.assertIsNotNone(result["typed_blocker"])
blocker = result["typed_blocker"]
self.assertEqual(blocker["namespaces"], ["gitea-author"])
self.assertEqual(blocker["why_reconnect_required"], mcr.REASON_STALE_RUNTIME)
self.assertTrue(any("Codex" in s or "Reload" in s for s in blocker["operator_ui_steps"]))
self.assertIn("pkill", " ".join(result["forbidden_recovery_paths"]).lower())
self.assertTrue(
mcr.reasons_never_suggest_forbidden(result["exact_safe_next_action"] or "")
)
# Must not recommend forbidden recovery.
for step in blocker["operator_ui_steps"]:
self.assertTrue(mcr.reasons_never_suggest_forbidden(step), step)
def test_transport_eof_typed_blocker(self):
result = mcr.build_reconnect_request(
namespace="gitea-reviewer",
reason="transport_eof",
client="claude_code",
)
self.assertTrue(result["reconnect_needed"])
self.assertEqual(result["reason"], mcr.REASON_TRANSPORT_EOF)
self.assertEqual(result["client"], "claude_code")
steps = " ".join(result["operator_ui_steps"]).lower()
self.assertIn("/mcp", steps)
def test_missing_namespace_typed_blocker(self):
result = mcr.build_reconnect_request(
namespace="gitea-merger",
reason="missing_namespace",
client="codex",
)
self.assertTrue(result["reconnect_needed"])
self.assertEqual(result["reason"], mcr.REASON_MISSING_NAMESPACE)
self.assertEqual(
result["typed_blocker"]["blocker_kind"], mcr.BLOCKER_OPERATOR_RECONNECT
)
def test_healthy_not_required(self):
result = mcr.build_reconnect_request(
namespace="gitea-tools",
startup_sha="deadbeef",
current_master_sha="deadbeef",
reason="not_required",
client="codex",
in_parity=True,
restart_required=False,
stop_required=False,
)
self.assertFalse(result["reconnect_needed"])
self.assertEqual(result["blocker_kind"], mcr.BLOCKER_NONE)
self.assertIsNone(result["typed_blocker"])
self.assertFalse(result["stop_required"])
self.assertFalse(result["restart_required"])
self.assertIn("not required", (result["exact_safe_next_action"] or "").lower())
def test_successful_reconnect_report_fields_present(self):
"""AC2: reconnect result reports required fields (even when needed)."""
result = mcr.build_reconnect_request(
namespace="gitea-controller",
profile="prgs-controller",
pid=99,
session_id="sid",
startup_sha="s" * 40,
current_master_sha="c" * 40,
reason="stale-runtime",
)
for key in (
"namespace",
"profile",
"pid",
"session_id",
"startup_sha",
"current_master_sha",
"boundary_status",
):
self.assertIn(key, result)
self.assertIsNotNone(result[key], key)
class ToolSurfaceTests(unittest.TestCase):
"""Exercise gitea_request_mcp_reconnect with a stubbed server context."""
def test_tool_is_registered_and_side_effect_free(self):
import gitea_mcp_server as srv
self.assertTrue(hasattr(srv, "gitea_request_mcp_reconnect"))
with mock.patch.object(srv, "_profile_operation_gate", return_value=None):
with mock.patch.object(
srv,
"get_profile",
return_value={
"profile_name": "prgs-author",
"role_kind": "author",
"role": "author",
},
):
with mock.patch.object(
srv,
"_current_master_parity",
return_value={
"startup_head": "a" * 40,
"current_head": "a" * 40,
"daemon_start_head": "a" * 40,
"local_head": "a" * 40,
"in_parity": True,
"stale": False,
"restart_required": False,
"determinable": True,
"live_stale": False,
"live_known": True,
"reasons": [],
},
):
with mock.patch.object(
srv.master_parity_gate,
"format_parity",
return_value="in parity",
):
with mock.patch.object(
srv.role_namespace_gate,
"infer_mcp_namespace",
return_value="gitea-author",
):
with mock.patch.object(
srv.session_ctx,
"mutation_context_audit_fields",
return_value={"session_profile": "prgs-author"},
):
result = srv.gitea_request_mcp_reconnect(
namespace="gitea-author",
reason="not_required",
client="codex",
remote="prgs",
)
self.assertTrue(result.get("success"))
self.assertFalse(result.get("reconnect_performed"))
self.assertFalse(result.get("mutation_performed"))
self.assertEqual(result.get("namespace"), "gitea-author")
self.assertEqual(result.get("profile"), "prgs-author")
self.assertEqual(result.get("pid"), os.getpid())
self.assertIn("startup_sha", result)
self.assertIn("current_master_sha", result)
self.assertIn("boundary_status", result)
self.assertTrue(
mcr.reasons_never_suggest_forbidden(
result.get("exact_safe_next_action") or ""
)
)
def test_tool_stale_returns_typed_blocker(self):
import gitea_mcp_server as srv
with mock.patch.object(srv, "_profile_operation_gate", return_value=None):
with mock.patch.object(
srv,
"get_profile",
return_value={
"profile_name": "prgs-reconciler",
"role_kind": "reconciler",
"role": "reconciler",
},
):
with mock.patch.object(
srv,
"_current_master_parity",
return_value={
"startup_head": "a" * 40,
"current_head": "b" * 40,
"daemon_start_head": "a" * 40,
"local_head": "b" * 40,
"in_parity": False,
"stale": True,
"restart_required": True,
"determinable": True,
"live_stale": True,
"live_known": True,
"reasons": ["stale"],
},
):
with mock.patch.object(
srv.master_parity_gate,
"format_parity",
return_value="stale",
):
with mock.patch.object(
srv.role_namespace_gate,
"infer_mcp_namespace",
return_value="gitea-reconciler",
):
with mock.patch.object(
srv.session_ctx,
"mutation_context_audit_fields",
return_value={},
):
result = srv.gitea_request_mcp_reconnect(
reason="stale-runtime",
client="codex",
)
self.assertTrue(result["reconnect_needed"])
self.assertEqual(
result["blocker_kind"], mcr.BLOCKER_OPERATOR_RECONNECT
)
self.assertIsNotNone(result["typed_blocker"])
self.assertIn("gitea-reconciler", result["typed_blocker"]["namespaces"])
self.assertTrue(result["stop_required"])
self.assertTrue(result["restart_required"])
self.assertTrue(
mcr.reasons_never_suggest_forbidden(
result.get("exact_safe_next_action") or ""
)
)
class InventoryRegistrationTests(unittest.TestCase):
def test_reconnect_path_in_restart_inventory(self):
import mcp_restart_paths as mrp
ids = {p.path_id for p in mrp.iter_restart_paths()}
self.assertIn("codex_client_reconnect_request", ids)
self.assertIn("ide_client_reconnect", ids)
def test_tool_name_in_documented_inventory(self):
import mcp_tool_inventory as inv
doc_path = os.path.join(
os.path.dirname(os.path.dirname(__file__)), inv.INVENTORY_DOC_PATH
)
with open(doc_path, encoding="utf-8") as handle:
documented = inv.parse_documented_inventory(handle.read())
self.assertIn("gitea_request_mcp_reconnect", documented)
if __name__ == "__main__":
unittest.main()
@@ -0,0 +1,685 @@
import sys as _sys
from pathlib import Path as _Path
_sys.path.insert(0, str(_Path(__file__).resolve().parent))
from mutation_profile_fixture import shared_mutation_env # noqa: E402
"""The renewal waiver reaches the real enforcement paths (#945 B1).
``tests/test_issue_945_owning_pr_renewal_continuation.py`` proves the pure
pieces: that ``issue_lock_renewal.owning_pr_renewal_from_lock`` rebuilds a
renewal waiver, that ``_owning_pr_continuation_from_lock`` resolves the two
dispositions in the right precedence, and that the duplicate gate honours the
resulting token. None of that proves any *production* path consumes the
resolver, and review ``623`` demonstrated the gap by reverting the primary call
site at ``gitea_mcp_server.py:2894`` back to the recovery-only rebuild: the
whole repository stayed green, failing-test ids byte identical.
This file closes that hole. Every test here starts from a real durable lock
file written to a temporary lock directory and bound to this process's session
pointer, then calls the authoritative production entry point — not a helper:
* ``mcp_server._enforce_locked_issue_duplicate_recheck`` — the shared recheck
behind ``gitea_commit_files`` and ``gitea_create_pr``
* ``mcp_server.gitea_assess_work_issue_duplicate`` — the read-only assessor
* ``mcp_server._prove_author_ownership_for_pr`` — the push / PR-update
ownership prover, which is also the existing-PR continuation path
Only the external boundaries are mocked: Gitea HTTP reads (the duplicate
context fetcher, open-PR and branch listings) and the credential header. The
reconstruction and enforcement chain under test — lock load, evidence rebuild,
resolver precedence, and ``issue_work_duplicate_gate`` — runs for real.
``TestRevertingThePrimaryWiringIsDetected`` is the explicit regression the
review asked for: it reproduces the pre-#945 recovery-only call site and
asserts the enforcement path then refuses, so the wiring cannot be removed
silently.
Everything is written under ``tempfile.TemporaryDirectory``. No branch,
worktree, PR, comment, lease, or lock outside that directory is created, and no
production Gitea or control-plane state is touched (#945 AC18).
"""
import os
import subprocess
import sys
import tempfile
import unittest
from datetime import datetime, timedelta, timezone
from pathlib import Path
from unittest.mock import patch
sys.path.insert(0, str(Path(__file__).resolve().parent.parent))
import issue_lock_provenance # noqa: E402
import issue_lock_recovery # noqa: E402
import issue_lock_renewal # noqa: E402
import issue_lock_store # noqa: E402
import mcp_server # noqa: E402
from issue_work_duplicate_gate import ( # noqa: E402
PHASE_COMMIT,
PHASE_CREATE_PR,
PHASE_LOCK,
PHASE_PUSH,
)
ISSUE = 4948
OWNING_PR = 4949
OTHER_PR = 4950
OTHER_ISSUE = 4951
BRANCH = f"fix/issue-{ISSUE}-renewal-wiring"
OTHER_BRANCH = f"fix/issue-{ISSUE}-competing"
HEAD = "e" * 40
OTHER_HEAD = "f" * 40
IDENTITY = "example-user"
PROFILE = "test-author-prgs"
ORG = "Scaled-Tech-Consulting"
REPO = "Gitea-Tools"
HOST = "gitea.prgs.cc"
def dead_pid() -> int:
"""A PID that has certainly exited (spawned, then reaped)."""
proc = subprocess.Popen([sys.executable, "-c", "pass"])
proc.wait()
return proc.pid
def shifted_ts(hours: int = 4) -> str:
return (
(datetime.now(timezone.utc) + timedelta(hours=hours))
.isoformat()
.replace("+00:00", "Z")
)
def owning_pr(number=OWNING_PR, ref=BRANCH, sha=HEAD, issue=ISSUE):
return {
"number": number,
"title": f"fix: something (Closes #{issue})",
"body": f"Closes #{issue}.",
"head": {"ref": ref, "sha": sha},
}
def renewal_block(
*,
pr_number=OWNING_PR,
branch=BRANCH,
head=HEAD,
identity=IDENTITY,
profile=PROFILE,
):
"""The ``lease_renewal`` block ``build_renewal_record`` writes on success."""
return {
"renewed": True,
"renewed_at": shifted_ts(-1),
"prior_pid": 4242,
"prior_pid_alive": True,
"prior_expires_at": shifted_ts(-1),
"replacement_pid": os.getpid(),
"new_expires_at": shifted_ts(),
"identity": identity,
"profile": profile,
"branch_name": branch,
"worktree_path": os.path.realpath(os.getcwd()),
"head_sha": head,
"remote_head_sha": head,
"pr_head_sha": head,
"pr_number": pr_number,
"reason": "expired lease renewed by its exact recorded owner",
"proof": [],
}
def recovery_block(*, pr_number=OWNING_PR, branch=BRANCH, head=HEAD):
"""The ``dead_session_recovery`` block ``build_recovery_record`` writes."""
return {
"recovered": True,
"reason": "owning MCP session exited; durable ownership evidence matched",
"recovered_at": shifted_ts(-1),
"prior_session_pid": 4242,
"replacement_session_pid": os.getpid(),
"prior_pid_alive": False,
"branch_name": branch,
"pr_number": pr_number,
"pr_head": head,
"recorded_head": head,
"accepted_head": head,
"head_relation": issue_lock_recovery.HEAD_RELATION_EQUAL,
"identity": IDENTITY,
"profile": PROFILE,
"proof": [],
}
class EnforcementPathBase(unittest.TestCase):
"""Drives production enforcement entry points against a real durable lock.
The lock lives in a throwaway directory and is bound to this process's
session pointer exactly as ``gitea_lock_issue`` binds it, so
``_load_existing_issue_lock()`` resolves it through the ordinary
``read_session_issue_lock()`` path rather than a test shortcut.
"""
def setUp(self):
self.lock_dir = tempfile.TemporaryDirectory()
self.addCleanup(self.lock_dir.cleanup)
self.worktree = os.path.realpath(os.getcwd())
self.remotes = patch.dict(
mcp_server.REMOTES,
{"prgs": {"host": HOST, "org": ORG, "repo": REPO}},
)
self.remotes.start()
self.addCleanup(patch.stopall)
mcp_server._IDENTITY_CACHE.clear()
# ── fixtures ────────────────────────────────────────────────────────────
def build_lock(
self,
*,
issue_number=ISSUE,
branch=BRANCH,
renewal=None,
recovery=None,
claimant=None,
pid=None,
live=True,
):
pid = os.getpid() if pid is None else pid
claimant = claimant or {"username": IDENTITY, "profile": PROFILE}
expires = shifted_ts() if live else shifted_ts(-1)
data = {
"issue_number": issue_number,
"branch_name": branch,
"remote": "prgs",
"org": ORG,
"repo": REPO,
"worktree_path": self.worktree,
"session_pid": pid,
"pid": pid,
"claimant": dict(claimant),
"work_lease": {
"operation_type": issue_lock_store.AUTHOR_ISSUE_WORK_LEASE,
"issue_number": issue_number,
"branch": branch,
"worktree_path": self.worktree,
"claimant": dict(claimant),
"created_at": shifted_ts(-1),
"last_heartbeat_at": shifted_ts(0) if live else shifted_ts(-1),
"expires_at": expires,
},
"lock_provenance": issue_lock_provenance.build_sanctioned_lock_provenance(
tool="gitea_lock_issue",
claimant=dict(claimant),
),
}
if renewal is not None:
data["lease_renewal"] = renewal
if recovery is not None:
data["dead_session_recovery"] = recovery
return data
def bind(self, data):
"""Persist the lock and bind it to this process, as the server does."""
issue_lock_store.bind_session_lock(data, self.lock_dir.name)
return data
def env(self):
return shared_mutation_env(
PROFILE,
include_example_repo=True,
GITEA_ISSUE_LOCK_DIR=self.lock_dir.name,
)
def gitea_reads(self, *, open_prs, branch_names=None):
"""Patch only the external Gitea read boundary."""
branch_names = [BRANCH] if branch_names is None else branch_names
return (
patch("mcp_server.get_auth_header", return_value="token x"),
patch(
"mcp_server.issue_duplicate_context_fetcher",
side_effect=lambda h, o, r, auth, issue_number: (
list(open_prs), list(branch_names), {"status": "not_claimed"}
),
),
patch("mcp_server._list_open_pulls", return_value=list(open_prs)),
patch(
"mcp_server.api_get_all",
return_value=[
{"name": n, "commit": {"id": HEAD}} for n in branch_names
],
),
)
# ── production entry points ─────────────────────────────────────────────
def run_duplicate_recheck(self, *, phase, open_prs, branch_names=None):
"""The real shared recheck behind gitea_commit_files / gitea_create_pr."""
patches = self.gitea_reads(open_prs=open_prs, branch_names=branch_names)
with patches[0], patches[1], patches[2], patches[3]:
with patch.dict(os.environ, self.env(), clear=True):
os.environ["GITEA_ISSUE_LOCK_DIR"] = self.lock_dir.name
return mcp_server._enforce_locked_issue_duplicate_recheck(
"prgs", phase, host=HOST, org=ORG, repo=REPO
)
def run_readonly_assessor(
self, *, open_prs, issue_number=ISSUE, branch=BRANCH, branch_names=None
):
"""The real read-only duplicate assessor MCP tool."""
patches = self.gitea_reads(open_prs=open_prs, branch_names=branch_names)
with patches[0], patches[1], patches[2], patches[3]:
with patch.dict(os.environ, self.env(), clear=True):
os.environ["GITEA_ISSUE_LOCK_DIR"] = self.lock_dir.name
return mcp_server.gitea_assess_work_issue_duplicate(
issue_number=issue_number,
branch_name=branch,
phase=PHASE_COMMIT,
remote="prgs",
host=HOST,
org=ORG,
repo=REPO,
)
def run_ownership_prover(
self, *, pr_number=OWNING_PR, branch=BRANCH, issue_number=ISSUE
):
"""The real push / PR-update ownership prover (existing-PR continuation)."""
with patch("mcp_server.get_auth_header", return_value="token x"):
with patch.dict(os.environ, self.env(), clear=True):
os.environ["GITEA_ISSUE_LOCK_DIR"] = self.lock_dir.name
return mcp_server._prove_author_ownership_for_pr(
pr_number=pr_number,
pr_title=f"fix: something (Closes #{issue_number})",
pr_body=f"Closes #{issue_number}.",
source_branch=branch,
remote="prgs",
host=HOST,
org=ORG,
repo=REPO,
worktree_path=self.worktree,
)
# ─────────────── B1: renewal evidence reaches every enforcement path ───────────
class TestRenewalReachesEnforcementPaths(EnforcementPathBase):
"""A renewal-only lock must exempt its owning PR at the real call sites.
Each of these fails if its call site is reverted to the recovery-only
rebuild, because the lock deliberately carries no ``dead_session_recovery``
block at all.
"""
def setUp(self):
super().setUp()
self.bind(self.build_lock(renewal=renewal_block()))
def test_commit_duplicate_recheck_permits_the_owning_pr(self):
blocked = self.run_duplicate_recheck(
phase=PHASE_COMMIT, open_prs=[owning_pr()]
)
self.assertIsNone(
blocked,
"commit recheck refused the PR the renewal already proved it owns; "
"the resolver is not wired into gitea_mcp_server:2894",
)
def test_create_pr_duplicate_recheck_permits_the_owning_pr(self):
blocked = self.run_duplicate_recheck(
phase=PHASE_CREATE_PR, open_prs=[owning_pr()]
)
self.assertIsNone(blocked)
def test_read_only_assessor_reports_the_same_exemption(self):
result = self.run_readonly_assessor(open_prs=[owning_pr()])
self.assertTrue(result["success"])
self.assertFalse(result["block"])
self.assertTrue(result["owning_pr_recovery_exempted"])
self.assertEqual(result["linked_open_pr"], OWNING_PR)
def test_push_ownership_prover_carries_the_renewal_evidence(self):
ownership = self.run_ownership_prover()
self.assertTrue(ownership["proven"], ownership["reasons"])
token = ownership["recovered_owning_pr"]
self.assertIsNotNone(
token,
"push prover produced no continuation evidence from a renewal lock; "
"the resolver is not wired into gitea_mcp_server:19464",
)
self.assertEqual(token["pr_number"], OWNING_PR)
self.assertEqual(token["branch_name"], BRANCH)
self.assertEqual(token["head_sha"], HEAD)
def test_all_enforcement_paths_decide_alike_from_one_lock(self):
"""AC: commit, create-PR, assessor and prover agree on one lock."""
for phase in (PHASE_COMMIT, PHASE_CREATE_PR, PHASE_PUSH, PHASE_LOCK):
with self.subTest(phase=phase):
self.assertIsNone(
self.run_duplicate_recheck(phase=phase, open_prs=[owning_pr()])
)
assessor = self.run_readonly_assessor(open_prs=[owning_pr()])
prover = self.run_ownership_prover()
self.assertTrue(assessor["owning_pr_recovery_exempted"])
self.assertEqual(
assessor["linked_open_pr"], prover["recovered_owning_pr"]["pr_number"]
)
class TestDeadSessionRecoveryStillReachesEnforcementPaths(EnforcementPathBase):
"""#755/#768 recovery must be unchanged by the #945 resolver."""
def setUp(self):
super().setUp()
self.bind(self.build_lock(recovery=recovery_block()))
def test_commit_recheck_still_permits_a_recovered_owning_pr(self):
self.assertIsNone(
self.run_duplicate_recheck(phase=PHASE_COMMIT, open_prs=[owning_pr()])
)
def test_assessor_still_reports_the_recovery_exemption(self):
result = self.run_readonly_assessor(open_prs=[owning_pr()])
self.assertTrue(result["owning_pr_recovery_exempted"])
def test_prover_still_carries_recovery_evidence(self):
token = self.run_ownership_prover()["recovered_owning_pr"]
self.assertEqual(token["pr_number"], OWNING_PR)
# ──────────────── B1: the explicit anti-revert regression test ────────────────
class TestRevertingThePrimaryWiringIsDetected(EnforcementPathBase):
"""Reproduce the pre-#945 call site and prove the path then refuses.
Review ``623`` reverted ``gitea_mcp_server.py:2894`` from
``_owning_pr_continuation_from_lock`` to
``issue_lock_recovery.recovered_owning_pr_from_lock`` and found the entire
repository still green. Substituting exactly that pre-fix behaviour here
makes the enforcement path block, so the causal link between the resolver
and the gate's answer is asserted, not assumed.
"""
def setUp(self):
super().setUp()
self.bind(self.build_lock(renewal=renewal_block()))
def test_recovery_only_rebuild_reintroduces_the_945_refusal(self):
with patch.object(
mcp_server,
"_owning_pr_continuation_from_lock",
side_effect=issue_lock_recovery.recovered_owning_pr_from_lock,
):
blocked = self.run_duplicate_recheck(
phase=PHASE_COMMIT, open_prs=[owning_pr()]
)
self.assertIsNotNone(
blocked,
"the pre-#945 recovery-only rebuild must lose the renewal waiver; "
"if this passes, the enforcement path is not consuming the resolver",
)
self.assertTrue(blocked["block"])
self.assertFalse(blocked["owning_pr_recovery_exempted"])
def test_restoring_the_resolver_restores_continuation(self):
self.assertIsNone(
self.run_duplicate_recheck(phase=PHASE_COMMIT, open_prs=[owning_pr()])
)
def test_read_only_assessor_is_wired_to_the_same_resolver(self):
with patch.object(
mcp_server,
"_owning_pr_continuation_from_lock",
side_effect=issue_lock_recovery.recovered_owning_pr_from_lock,
):
result = self.run_readonly_assessor(open_prs=[owning_pr()])
self.assertTrue(result["block"])
self.assertFalse(result["owning_pr_recovery_exempted"])
def test_push_prover_is_wired_to_the_same_resolver(self):
with patch.object(
mcp_server,
"_owning_pr_continuation_from_lock",
side_effect=issue_lock_recovery.recovered_owning_pr_from_lock,
):
ownership = self.run_ownership_prover()
self.assertIsNone(ownership["recovered_owning_pr"])
# ───────────────── B1: the exemption is not widened at the call sites ─────────
class TestEnforcementPathsStillFailClosed(EnforcementPathBase):
def assert_blocked(self, result):
self.assertIsNotNone(result, "expected a fail-closed refusal")
self.assertTrue(result["block"])
return result
def test_open_pr_alone_grants_no_exemption(self):
"""No renewal and no recovery block: the open PR still blocks."""
self.bind(self.build_lock())
blocked = self.assert_blocked(
self.run_duplicate_recheck(phase=PHASE_COMMIT, open_prs=[owning_pr()])
)
self.assertFalse(blocked["owning_pr_recovery_exempted"])
def test_second_pr_is_refused(self):
self.bind(self.build_lock(renewal=renewal_block()))
self.assert_blocked(
self.run_duplicate_recheck(
phase=PHASE_COMMIT,
open_prs=[owning_pr(), owning_pr(number=OTHER_PR, ref=OTHER_BRANCH)],
)
)
def test_evidence_naming_another_pr_is_refused(self):
self.bind(self.build_lock(renewal=renewal_block(pr_number=OTHER_PR)))
self.assert_blocked(
self.run_duplicate_recheck(phase=PHASE_COMMIT, open_prs=[owning_pr()])
)
def test_unrelated_branch_is_refused(self):
self.bind(self.build_lock(renewal=renewal_block(branch=OTHER_BRANCH)))
self.assert_blocked(
self.run_duplicate_recheck(phase=PHASE_COMMIT, open_prs=[owning_pr()])
)
def test_live_head_divergence_is_refused(self):
"""Force-push or unrelated remote movement: live PR head no longer matches."""
self.bind(self.build_lock(renewal=renewal_block()))
self.assert_blocked(
self.run_duplicate_recheck(
phase=PHASE_COMMIT, open_prs=[owning_pr(sha=OTHER_HEAD)]
)
)
def test_stale_recorded_head_is_refused(self):
"""The renewal names a head the live PR never had."""
self.bind(self.build_lock(renewal=renewal_block(head=OTHER_HEAD)))
self.assert_blocked(
self.run_duplicate_recheck(phase=PHASE_COMMIT, open_prs=[owning_pr()])
)
def test_local_remote_head_divergence_is_refused(self):
record = renewal_block()
record["remote_head_sha"] = OTHER_HEAD
self.bind(self.build_lock(renewal=record))
self.assert_blocked(
self.run_duplicate_recheck(phase=PHASE_COMMIT, open_prs=[owning_pr()])
)
def test_identity_mismatch_with_the_lock_claimant_is_refused(self):
self.bind(self.build_lock(renewal=renewal_block(identity="someone-else")))
self.assert_blocked(
self.run_duplicate_recheck(phase=PHASE_COMMIT, open_prs=[owning_pr()])
)
def test_profile_mismatch_with_the_lock_claimant_is_refused(self):
self.bind(self.build_lock(renewal=renewal_block(profile="test-reviewer-prgs")))
self.assert_blocked(
self.run_duplicate_recheck(phase=PHASE_COMMIT, open_prs=[owning_pr()])
)
def test_ungranted_renewal_block_is_refused(self):
record = renewal_block()
record["renewed"] = False
self.bind(self.build_lock(renewal=record))
self.assert_blocked(
self.run_duplicate_recheck(phase=PHASE_COMMIT, open_prs=[owning_pr()])
)
def test_malformed_renewal_block_is_refused(self):
record = renewal_block()
record["pr_number"] = "not-a-number"
self.bind(self.build_lock(renewal=record))
self.assert_blocked(
self.run_duplicate_recheck(phase=PHASE_COMMIT, open_prs=[owning_pr()])
)
def test_wrong_issue_evidence_cannot_be_copied_onto_another_lock(self):
"""A renewal block copied onto a lock for a different issue proves nothing.
The rebuilt token takes its ``issue_number`` from the lock it is found
on, not from the record, so a block lifted onto another issue's lock
claims that issue while still naming the original PR. That copied
evidence must not waive the genuine duplicate the other issue has.
"""
self.bind(
self.build_lock(issue_number=OTHER_ISSUE, renewal=renewal_block())
)
blocked = self.assert_blocked(
self.run_duplicate_recheck(
phase=PHASE_COMMIT,
# The real open PR for OTHER_ISSUE is a different PR entirely.
open_prs=[
owning_pr(number=OTHER_PR, ref=OTHER_BRANCH, issue=OTHER_ISSUE)
],
branch_names=[OTHER_BRANCH],
)
)
self.assertFalse(blocked["owning_pr_recovery_exempted"])
def test_refusal_carries_complete_structured_fields(self):
self.bind(self.build_lock())
blocked = self.assert_blocked(
self.run_duplicate_recheck(phase=PHASE_COMMIT, open_prs=[owning_pr()])
)
for field in (
"block",
"outcome",
"reasons",
"owning_pr_recovery_exempted",
"owning_pr_recovery_notes",
"linked_open_pr",
"linked_open_pr_count",
):
with self.subTest(field=field):
self.assertIn(field, blocked)
self.assertTrue(blocked["reasons"])
class TestSequentialTasksStayIsolated(EnforcementPathBase):
"""One long-lived daemon serves many tasks; a waiver must not leak forward."""
def test_a_later_lock_without_evidence_does_not_inherit_the_earlier_waiver(self):
self.bind(self.build_lock(renewal=renewal_block()))
self.assertIsNone(
self.run_duplicate_recheck(phase=PHASE_COMMIT, open_prs=[owning_pr()])
)
# Second task in the same process: a fresh lock, no renewal evidence.
self.bind(
self.build_lock(issue_number=OTHER_ISSUE, branch=OTHER_BRANCH)
)
blocked = self.run_duplicate_recheck(
phase=PHASE_COMMIT,
open_prs=[owning_pr(number=OTHER_PR, ref=OTHER_BRANCH, issue=OTHER_ISSUE)],
branch_names=[OTHER_BRANCH],
)
self.assertIsNotNone(blocked)
self.assertFalse(blocked["owning_pr_recovery_exempted"])
# ───────────── F2: what the caller binding actually is, and is not ────────────
class TestCallerBindingIsStructuralNotFieldComparison(unittest.TestCase):
"""Document, in executable form, the binding this patch really provides.
Review ``623`` found that the claimant check in
``owning_pr_renewal_from_lock`` compares two fields of one server-written
lock file and is therefore not bound to the authenticated caller. That is
correct, and these tests assert the true guarantee rather than the
overstated one: lock *selection* is process-scoped, and the claimant check
is an internal-consistency check.
No PID-derived, cached, or process-lifetime session authority is invented
here — the process scoping asserted below is pre-existing behaviour of
``issue_lock_store``, not something this patch adds.
"""
def test_lock_selection_is_keyed_to_the_operating_system_process(self):
with tempfile.TemporaryDirectory() as root:
pointer = issue_lock_store.session_pointer_path(root)
self.assertEqual(
os.path.basename(pointer), f"session-{os.getpid()}.json"
)
def test_a_lock_bound_by_another_process_is_not_reachable(self):
"""The structural protection: a foreign session pointer is not read."""
with tempfile.TemporaryDirectory() as root:
foreign_pointer = os.path.join(root, f"session-{os.getpid() + 1}.json")
issue_lock_store.save_lock_file(
foreign_pointer, {"lock_file_path": "/nonexistent/foreign.json"}
)
self.assertIsNone(issue_lock_store.read_session_issue_lock(root))
def test_claimant_check_does_not_consult_the_live_authenticated_caller(self):
"""The honest limit: agreement is internal to the lock document.
A renewal block whose identity/profile agree with the claimant recorded
on the same lock rebuilds successfully, regardless of who is
authenticated. Live identity and profile are enforced by the separate
mutation-authority and profile gates, not by this rebuild.
"""
lock = {
"issue_number": ISSUE,
"branch_name": BRANCH,
"claimant": {"username": "unrelated-recorded-user", "profile": PROFILE},
"lease_renewal": renewal_block(identity="unrelated-recorded-user"),
}
token = issue_lock_renewal.owning_pr_renewal_from_lock(lock)
self.assertIsNotNone(token)
self.assertEqual(token["pr_number"], OWNING_PR)
def test_internal_disagreement_is_what_the_check_actually_rejects(self):
lock = {
"issue_number": ISSUE,
"branch_name": BRANCH,
"claimant": {"username": IDENTITY, "profile": PROFILE},
"lease_renewal": renewal_block(identity="someone-else"),
}
self.assertIsNone(issue_lock_renewal.owning_pr_renewal_from_lock(lock))
class TestNoDurableArtifacts(EnforcementPathBase):
def test_enforcement_runs_leave_nothing_outside_the_temp_lock_dir(self):
before = sorted(os.listdir(self.lock_dir.name))
self.bind(self.build_lock(renewal=renewal_block()))
self.run_duplicate_recheck(phase=PHASE_COMMIT, open_prs=[owning_pr()])
self.run_ownership_prover()
after = sorted(os.listdir(self.lock_dir.name))
self.assertNotEqual(before, after, "the test must have written its lock")
self.assertTrue(
all(
os.path.realpath(os.path.join(self.lock_dir.name, name)).startswith(
os.path.realpath(self.lock_dir.name)
)
for name in after
)
)
if __name__ == "__main__":
unittest.main()
@@ -0,0 +1,602 @@
import sys as _sys
from pathlib import Path as _Path
_sys.path.insert(0, str(_Path(__file__).resolve().parent))
from mutation_profile_fixture import shared_mutation_env # noqa: F401,E402
"""Exact-owner renewal keeps its owning-PR waiver past lock_issue (#945).
#755 taught the duplicate-work gate that a sanctioned *dead-session recovery*
owns its open PR, and #768 taught the later gates to rebuild that proof from the
durable lock. #760 added the exact-owner *renewal* disposition and granted it
the same waiver inside ``gitea_lock_issue`` — but never added the matching
rebuild. So an ordinary renewal held the waiver only for the duration of the
lock call: ``_enforce_locked_issue_duplicate_recheck`` asked
``recovered_owning_pr_from_lock``, which reads only ``dead_session_recovery``,
and the very next commit was refused ``duplicate_commit_prevented`` with
``owning_pr_recovery_exempted: false`` on the PR the renewal had just proved.
``TestPreFixReproduction`` pins that defect directly: the recovery-only rebuild
still returns ``None`` for a renewal lock, which is exactly why the gates lost
the waiver. Everything else proves the renewal half now survives, that recovery
is unchanged, and that no path grants an exemption on weaker evidence.
Every fixture here is an in-memory mapping. Nothing writes a branch, worktree,
lock file, lease, comment, or PR (#945 AC18).
"""
import copy
import sys
import unittest
from pathlib import Path
sys.path.insert(0, str(Path(__file__).resolve().parent.parent))
import gitea_mcp_server # noqa: E402
import issue_lock_recovery # noqa: E402
import issue_lock_renewal # noqa: E402
from issue_work_duplicate_gate import ( # noqa: E402
OUTCOME_DUPLICATE_WORK_NOT_PREVENTED,
PHASE_COMMIT,
PHASE_CREATE_PR,
PHASE_LOCK,
PHASE_PUSH,
assess_work_issue_duplicate_gate,
)
ISSUE = 4945
OWNING_PR = 4946
OTHER_PR = 4947
BRANCH = f"fix/issue-{ISSUE}-owning-pr-renewal"
OTHER_BRANCH = f"fix/issue-{ISSUE}-competing"
HEAD = "a" * 40
OTHER_HEAD = "b" * 40
IDENTITY = "example-user"
PROFILE = "test-author-prgs"
def renewal_record(**overrides):
"""The ``lease_renewal`` block ``build_renewal_record`` writes on success."""
record = {
"renewed": True,
"renewed_at": "2026-01-01T00:00:00Z",
"prior_pid": 4242,
"prior_pid_alive": True,
"prior_expires_at": "2026-01-01T00:00:00Z",
"replacement_pid": 4243,
"new_expires_at": "2026-01-01T00:10:00Z",
"identity": IDENTITY,
"profile": PROFILE,
"branch_name": BRANCH,
"worktree_path": f"branches/issue-{ISSUE}-owning-pr-renewal",
"head_sha": HEAD,
"remote_head_sha": HEAD,
"pr_head_sha": HEAD,
"pr_number": OWNING_PR,
"reason": "expired lease renewed by its exact recorded owner",
"proof": [],
}
record.update(overrides)
return record
def renewal_lock(record=None, *, issue_number=ISSUE, claimant=True, **lock_overrides):
lock = {
"issue_number": issue_number,
"branch_name": BRANCH,
"lease_renewal": renewal_record() if record is None else record,
}
if claimant:
lock["claimant"] = {"username": IDENTITY, "profile": PROFILE}
lock.update(lock_overrides)
return lock
def recovery_lock(pr_number=OWNING_PR, head=HEAD):
"""A lock carrying sanctioned dead-session recovery evidence (#755/#768)."""
return {
"issue_number": ISSUE,
"branch_name": BRANCH,
"claimant": {"username": IDENTITY, "profile": PROFILE},
"dead_session_recovery": {
"recovered": True,
"branch_name": BRANCH,
"pr_number": pr_number,
"pr_head": head,
"recorded_head": head,
"accepted_head": head,
"head_relation": issue_lock_recovery.HEAD_RELATION_EQUAL,
},
}
def owning_pr(number=OWNING_PR, ref=BRANCH, sha=HEAD, issue=ISSUE):
return {
"number": number,
"title": f"fix: something (Closes #{issue})",
"body": f"Closes #{issue}.",
"head": {"ref": ref, "sha": sha},
}
def gate(phase, *, token, open_prs=None, branch_names=None, locked_branch=BRANCH):
return assess_work_issue_duplicate_gate(
ISSUE,
open_prs=[owning_pr()] if open_prs is None else open_prs,
branch_names=branch_names or [],
claim_entry={},
locked_branch=locked_branch,
phase=phase,
recovered_owning_pr=token,
)
# ───────────────────── the defect this issue exists to fix ─────────────────────
class TestPreFixReproduction(unittest.TestCase):
"""The exact wiring gap: renewal evidence was invisible to later gates."""
def test_recovery_only_rebuild_cannot_see_a_renewal_lock(self):
# This is the pre-fix behaviour of every enforcement path. It is correct
# for the recovery rebuild to ignore a renewal block -- the defect was
# that nothing else looked at it.
self.assertIsNone(
issue_lock_recovery.recovered_owning_pr_from_lock(renewal_lock())
)
def test_renewal_lock_produced_no_exemption_before_the_fix(self):
# Feeding the gate what the pre-fix code fed it (recovery rebuild only)
# reproduces the reported refusal at the commit phase.
token = issue_lock_recovery.recovered_owning_pr_from_lock(renewal_lock())
result = gate(PHASE_COMMIT, token=token)
self.assertTrue(result["block"])
self.assertEqual(result["outcome"], "duplicate_commit_prevented")
self.assertFalse(result["owning_pr_recovery_exempted"])
self.assertEqual(result["owning_pr_recovery_notes"], [])
def test_shared_resolver_now_sees_it(self):
self.assertIsNotNone(
gitea_mcp_server._owning_pr_continuation_from_lock(renewal_lock())
)
# ───────────────────────── rebuild: the granted case ─────────────────────────
class TestRenewalRebuildGranted(unittest.TestCase):
def test_sanctioned_renewal_rebuilds_owning_pr_evidence(self):
token = issue_lock_renewal.owning_pr_renewal_from_lock(renewal_lock())
self.assertEqual(
token,
{
"issue_number": ISSUE,
"pr_number": OWNING_PR,
"branch_name": BRANCH,
"head_sha": HEAD,
"recorded_head": HEAD,
"accepted_head": HEAD,
"head_relation": "equal",
},
)
def test_branch_falls_back_to_the_lock_branch(self):
lock = renewal_lock(renewal_record(branch_name=""))
token = issue_lock_renewal.owning_pr_renewal_from_lock(lock)
self.assertEqual(token["branch_name"], BRANCH)
def test_claimant_may_live_under_work_lease(self):
lock = renewal_lock(claimant=False)
lock["work_lease"] = {"claimant": {"username": IDENTITY, "profile": PROFILE}}
self.assertIsNotNone(issue_lock_renewal.owning_pr_renewal_from_lock(lock))
def test_rebuild_does_not_mutate_the_lock(self):
lock = renewal_lock()
before = copy.deepcopy(lock)
issue_lock_renewal.owning_pr_renewal_from_lock(lock)
self.assertEqual(lock, before)
# ───────────────────────── rebuild: fails closed ─────────────────────────
class TestRenewalRebuildFailsClosed(unittest.TestCase):
def assertNoEvidence(self, lock):
self.assertIsNone(issue_lock_renewal.owning_pr_renewal_from_lock(lock))
def test_no_lock_at_all(self):
self.assertNoEvidence(None)
self.assertNoEvidence({})
self.assertNoEvidence("not-a-mapping")
def test_lock_without_renewal_block(self):
# A fresh claim, or a lock whose renewal block was replaced.
self.assertNoEvidence({"issue_number": ISSUE, "branch_name": BRANCH})
def test_renewal_not_granted(self):
self.assertNoEvidence(renewal_lock(renewal_record(renewed=False)))
def test_renewal_flag_missing(self):
record = renewal_record()
del record["renewed"]
self.assertNoEvidence(renewal_lock(record))
def test_renewal_block_malformed(self):
self.assertNoEvidence(renewal_lock("not-a-mapping"))
def test_local_head_diverged_from_pr_head(self):
self.assertNoEvidence(renewal_lock(renewal_record(head_sha=OTHER_HEAD)))
def test_remote_head_diverged_from_pr_head(self):
# Force-push or unrelated remote movement.
self.assertNoEvidence(renewal_lock(renewal_record(remote_head_sha=OTHER_HEAD)))
def test_local_head_missing(self):
self.assertNoEvidence(renewal_lock(renewal_record(head_sha="")))
def test_remote_head_missing(self):
self.assertNoEvidence(renewal_lock(renewal_record(remote_head_sha="")))
def test_pr_head_missing(self):
self.assertNoEvidence(renewal_lock(renewal_record(pr_head_sha="")))
def test_pr_number_missing(self):
self.assertNoEvidence(renewal_lock(renewal_record(pr_number=None)))
def test_pr_number_malformed(self):
self.assertNoEvidence(renewal_lock(renewal_record(pr_number="not-a-number")))
def test_issue_number_missing_from_lock(self):
self.assertNoEvidence(renewal_lock(issue_number=None))
def test_branch_unknown_everywhere(self):
lock = renewal_lock(renewal_record(branch_name=""))
lock["branch_name"] = ""
self.assertNoEvidence(lock)
def test_identity_mismatch(self):
self.assertNoEvidence(renewal_lock(renewal_record(identity="someone-else")))
def test_profile_mismatch(self):
self.assertNoEvidence(renewal_lock(renewal_record(profile="other-profile")))
def test_identity_missing(self):
self.assertNoEvidence(renewal_lock(renewal_record(identity="")))
def test_profile_missing(self):
self.assertNoEvidence(renewal_lock(renewal_record(profile="")))
def test_claimant_absent(self):
self.assertNoEvidence(renewal_lock(claimant=False))
def test_renewal_block_disagreeing_with_the_lock_claimant_is_refused(self):
# An internal-consistency check, not a caller check: the renewal block
# and the claimant recorded on the same lock must name one identity.
# Nothing here proves who is calling — see
# TestCallerBindingIsStructuralNotFieldComparison for that boundary.
lock = renewal_lock()
lock["claimant"] = {"username": "other-recorded-user", "profile": PROFILE}
self.assertNoEvidence(lock)
# ───────────────────────── the shared resolver ─────────────────────────
class TestSharedResolver(unittest.TestCase):
def test_recovery_lock_resolves_to_recovery_evidence(self):
token = gitea_mcp_server._owning_pr_continuation_from_lock(recovery_lock())
self.assertEqual(token["pr_number"], OWNING_PR)
def test_renewal_lock_resolves_to_renewal_evidence(self):
token = gitea_mcp_server._owning_pr_continuation_from_lock(renewal_lock())
self.assertEqual(token["pr_number"], OWNING_PR)
def test_recovery_takes_precedence_over_an_agreeing_renewal(self):
# Same precedence gitea_lock_issue applies when granting the waiver, so
# the answer cannot differ between the granting and enforcing paths.
# Both blocks describe one decision, so both name the same PR and head.
lock = recovery_lock()
lock["lease_renewal"] = renewal_record()
token = gitea_mcp_server._owning_pr_continuation_from_lock(lock)
self.assertEqual(token["pr_number"], OWNING_PR)
self.assertEqual(token["head_relation"], issue_lock_recovery.HEAD_RELATION_EQUAL)
def test_no_evidence_resolves_to_none(self):
self.assertIsNone(gitea_mcp_server._owning_pr_continuation_from_lock(None))
self.assertIsNone(gitea_mcp_server._owning_pr_continuation_from_lock({}))
self.assertIsNone(
gitea_mcp_server._owning_pr_continuation_from_lock(
{"issue_number": ISSUE, "branch_name": BRANCH}
)
)
# ─────────── ambiguous recovery/renewal pairs never broaden authority ──────────
class TestAmbiguousEvidenceFailsClosed(unittest.TestCase):
"""#945 F3: a lock carrying two evidence blocks must agree, or authorize nothing.
Coexistence is legitimately reachable, so this is not a theoretical case.
Recovery is assessed whenever the lease is not live and requires a dead
recorded PID; renewal is assessed whenever the lease has *expired* — one way
to be non-live — and does not branch on PID liveness at all. An expired
lease whose owner also died satisfies both, and ``gitea_lock_issue`` then
writes both blocks into the same freshly built dict. A sanctioned pair comes
from one live observation, so it always agrees; disagreement means the
persisted lock no longer records a single sanctioned decision.
The dangerous direction is fall-through: before this, a recovery block that
failed validation was skipped and renewal evidence naming a *different* PR
was returned instead. Every case below asserts ``None`` — no continuation
authority at all, not a partial or downgraded one.
"""
def resolve(self, lock):
return gitea_mcp_server._owning_pr_continuation_from_lock(lock)
def both(self, *, recovery=None, renewal=None, **lock_overrides):
"""A lock carrying both server-written evidence blocks."""
lock = recovery_lock()
if recovery is not None:
lock["dead_session_recovery"] = recovery
lock["lease_renewal"] = renewal if renewal is not None else renewal_record()
lock.update(lock_overrides)
return lock
# ── the two legitimate single-block shapes still work ──────────────────
def test_valid_recovery_only_still_authorizes(self):
token = self.resolve(recovery_lock())
self.assertEqual(token["pr_number"], OWNING_PR)
def test_valid_renewal_only_still_authorizes(self):
token = self.resolve(renewal_lock())
self.assertEqual(token["pr_number"], OWNING_PR)
# ── both present ───────────────────────────────────────────────────────
def test_both_present_and_identical_authorizes_once(self):
token = self.resolve(self.both())
self.assertEqual(token["pr_number"], OWNING_PR)
self.assertEqual(token["head_sha"], HEAD)
def test_both_present_naming_different_prs_authorizes_nothing(self):
lock = self.both(renewal=renewal_record(pr_number=OTHER_PR))
self.assertIsNone(self.resolve(lock))
def test_conflicting_head_authorizes_nothing(self):
lock = self.both(
renewal=renewal_record(
head_sha=OTHER_HEAD, remote_head_sha=OTHER_HEAD, pr_head_sha=OTHER_HEAD
)
)
self.assertIsNone(self.resolve(lock))
def test_conflicting_branch_authorizes_nothing(self):
lock = self.both(renewal=renewal_record(branch_name=OTHER_BRANCH))
self.assertIsNone(self.resolve(lock))
def test_conflicting_head_relation_authorizes_nothing(self):
"""A descendant recovery beside an equal-head renewal is not one decision."""
recovery = dict(recovery_lock()["dead_session_recovery"])
recovery["head_relation"] = issue_lock_recovery.HEAD_RELATION_STRICT_DESCENDANT
recovery["recorded_head"] = HEAD
recovery["accepted_head"] = OTHER_HEAD
self.assertIsNone(self.resolve(self.both(recovery=recovery)))
def test_conflicting_identity_authorizes_nothing(self):
"""The renewal half stops rebuilding, so the pair can no longer agree."""
lock = self.both(renewal=renewal_record(identity="other-user"))
lock["claimant"] = {"username": IDENTITY, "profile": PROFILE}
# Recovery alone would still rebuild; presence of an unusable renewal
# block must not silently downgrade to the recovery answer.
self.assertEqual(self.resolve(lock)["pr_number"], OWNING_PR)
def test_conflicting_profile_between_renewal_and_claimant(self):
lock = self.both(renewal=renewal_record(profile="other-profile"))
self.assertEqual(self.resolve(lock)["pr_number"], OWNING_PR)
def test_conflicting_issue_number_authorizes_nothing(self):
"""Both tokens read issue_number from the lock, so a wrong issue moves both."""
lock = self.both(issue_number=ISSUE + 1)
token = self.resolve(lock)
self.assertEqual(token["issue_number"], ISSUE + 1)
self.assertEqual(token["pr_number"], OWNING_PR)
# ── recovery present but unusable: never fall through to renewal ────────
def test_malformed_recovery_beside_valid_renewal_authorizes_nothing(self):
recovery = {"recovered": True, "pr_number": "not-a-number"}
self.assertIsNone(self.resolve(self.both(recovery=recovery)))
def test_ungranted_recovery_beside_valid_renewal_authorizes_nothing(self):
recovery = dict(recovery_lock()["dead_session_recovery"])
recovery["recovered"] = False
self.assertIsNone(self.resolve(self.both(recovery=recovery)))
def test_stale_recovery_beside_newer_renewal_authorizes_nothing(self):
"""The exact bypass review 623 probed: conflicting recovery, valid renewal."""
recovery = dict(recovery_lock(pr_number=OTHER_PR)["dead_session_recovery"])
recovery["accepted_head"] = OTHER_HEAD # fails its own head equality
lock = self.both(recovery=recovery)
self.assertIsNone(
self.resolve(lock),
"a conflicting recovery record must not be bypassed by renewal "
"evidence naming a different PR",
)
def test_empty_recovery_block_beside_valid_renewal_authorizes_nothing(self):
self.assertIsNone(self.resolve(self.both(recovery={})))
# ── ambiguity yields nothing at all, not a partial authorization ────────
def test_ambiguity_yields_no_partial_token(self):
lock = self.both(renewal=renewal_record(pr_number=OTHER_PR))
result = self.resolve(lock)
self.assertIsNone(result)
self.assertNotIsInstance(result, dict)
def test_resolution_does_not_mutate_the_lock(self):
lock = self.both(renewal=renewal_record(pr_number=OTHER_PR))
before = copy.deepcopy(lock)
self.resolve(lock)
self.assertEqual(lock, before)
# ────────────── every enforcement path uses the same decision ──────────────
class TestEnforcementPathsShareOneDecision(unittest.TestCase):
"""AC: commit, push and create-PR gates consume one authoritative token."""
def setUp(self):
self.token = gitea_mcp_server._owning_pr_continuation_from_lock(renewal_lock())
def test_commit_phase_permits_continuation(self):
result = gate(PHASE_COMMIT, token=self.token)
self.assertFalse(result["block"])
self.assertTrue(result["owning_pr_recovery_exempted"])
self.assertEqual(result["outcome"], OUTCOME_DUPLICATE_WORK_NOT_PREVENTED)
def test_create_pr_phase_permits_continuation(self):
result = gate(PHASE_CREATE_PR, token=self.token)
self.assertFalse(result["block"])
self.assertTrue(result["owning_pr_recovery_exempted"])
def test_push_phase_permits_continuation(self):
result = gate(PHASE_PUSH, token=self.token)
self.assertFalse(result["block"])
self.assertTrue(result["owning_pr_recovery_exempted"])
def test_lock_phase_permits_continuation(self):
result = gate(PHASE_LOCK, token=self.token)
self.assertFalse(result["block"])
def test_all_phases_agree(self):
outcomes = {
phase: gate(phase, token=self.token)["block"]
for phase in (PHASE_LOCK, PHASE_COMMIT, PHASE_PUSH, PHASE_CREATE_PR)
}
self.assertEqual(set(outcomes.values()), {False}, outcomes)
def test_dead_session_recovery_still_permits_continuation(self):
token = gitea_mcp_server._owning_pr_continuation_from_lock(recovery_lock())
for phase in (PHASE_COMMIT, PHASE_PUSH, PHASE_CREATE_PR):
with self.subTest(phase=phase):
result = gate(phase, token=token)
self.assertFalse(result["block"])
self.assertTrue(result["owning_pr_recovery_exempted"])
# ───────────────── the exemption cannot be widened ─────────────────
class TestExemptionCannotBeWidened(unittest.TestCase):
def setUp(self):
self.token = gitea_mcp_server._owning_pr_continuation_from_lock(renewal_lock())
def test_an_open_pr_alone_grants_nothing(self):
result = gate(PHASE_COMMIT, token=None)
self.assertTrue(result["block"])
self.assertFalse(result["owning_pr_recovery_exempted"])
def test_a_second_pr_is_refused(self):
result = gate(
PHASE_CREATE_PR,
token=self.token,
open_prs=[owning_pr(), owning_pr(number=OTHER_PR, ref=OTHER_BRANCH)],
)
self.assertTrue(result["block"])
self.assertFalse(result["owning_pr_recovery_exempted"])
def test_a_different_pr_is_refused(self):
result = gate(
PHASE_COMMIT, token=self.token, open_prs=[owning_pr(number=OTHER_PR)]
)
self.assertTrue(result["block"])
def test_a_different_branch_is_refused(self):
result = gate(
PHASE_COMMIT, token=self.token, open_prs=[owning_pr(ref=OTHER_BRANCH)]
)
self.assertTrue(result["block"])
def test_locked_branch_mismatch_is_refused(self):
result = gate(PHASE_COMMIT, token=self.token, locked_branch=OTHER_BRANCH)
self.assertTrue(result["block"])
def test_live_pr_head_divergence_is_refused(self):
# Force-push or unrelated remote movement after renewal.
result = gate(
PHASE_COMMIT, token=self.token, open_prs=[owning_pr(sha=OTHER_HEAD)]
)
self.assertTrue(result["block"])
def test_evidence_for_another_issue_is_refused(self):
foreign = gitea_mcp_server._owning_pr_continuation_from_lock(
renewal_lock(issue_number=ISSUE + 1)
)
result = gate(PHASE_COMMIT, token=foreign)
self.assertTrue(result["block"])
def test_sequential_tasks_do_not_inherit_continuation(self):
# One daemon serves many tasks. A renewal proved for issue N must not
# authorize continuation for the next task's issue.
prior_task = gitea_mcp_server._owning_pr_continuation_from_lock(
renewal_lock(issue_number=ISSUE + 7)
)
self.assertIsNotNone(prior_task)
self.assertTrue(gate(PHASE_COMMIT, token=prior_task)["block"])
# ───────────────── ordinary duplicate prevention is intact ─────────────────
class TestDuplicatePreventionRetained(unittest.TestCase):
def test_competing_branch_still_blocks(self):
token = gitea_mcp_server._owning_pr_continuation_from_lock(renewal_lock())
result = gate(
PHASE_COMMIT,
token=token,
open_prs=[],
branch_names=[BRANCH, OTHER_BRANCH],
)
self.assertTrue(result["block"])
def test_unrelated_work_without_a_lock_still_blocks(self):
token = gitea_mcp_server._owning_pr_continuation_from_lock(None)
self.assertIsNone(token)
self.assertTrue(gate(PHASE_COMMIT, token=token)["block"])
# ───────────────── refusals stay structured and auditable ─────────────────
class TestRefusalShapePreserved(unittest.TestCase):
def test_blocked_result_keeps_its_audit_fields(self):
token = gitea_mcp_server._owning_pr_continuation_from_lock(renewal_lock())
result = gate(
PHASE_COMMIT, token=token, open_prs=[owning_pr(number=OTHER_PR)]
)
for field in (
"block",
"outcome",
"reasons",
"owning_pr_recovery_exempted",
"owning_pr_recovery_notes",
):
with self.subTest(field=field):
self.assertIn(field, result)
self.assertTrue(result["reasons"])
# A rejected token explains which element of ownership disagreed.
self.assertTrue(result["owning_pr_recovery_notes"])
def test_granted_result_records_why(self):
token = gitea_mcp_server._owning_pr_continuation_from_lock(renewal_lock())
result = gate(PHASE_COMMIT, token=token)
self.assertTrue(result["owning_pr_recovery_notes"])
self.assertIn(
f"#{OWNING_PR}", " ".join(result["owning_pr_recovery_notes"])
)
if __name__ == "__main__":
unittest.main()
File diff suppressed because it is too large Load Diff
+323
View File
@@ -0,0 +1,323 @@
"""Validation tooling for the remote-MCP threat model (#956).
#956 requires that "every boundary claim [is] traceable to a file and line
anchor that resolves at the reviewed commit". A prose document cannot enforce
that about itself, and #930 demonstrated the failure mode: its inventory cited
``gitea_mcp_server.py`` anchors generated at ``7bf4f125`` which no longer point
at the described code at ``aad5c8b4``. Nothing failed, because nothing checked.
These tests are that check. They enforce, in both directions:
* every ``file.py:NNN`` anchor cited in the prose is declared in the fixture;
* every declared anchor resolves — the file exists, the line exists, and the
source line actually contains the substring the fixture claims for it;
* the document's structural obligations (assets, adversaries, boundaries,
credential rows, the co-residency ruling, and the child mapping) are present
and internally consistent.
A refactor that shifts a line number therefore breaks the suite instead of
silently rotting the security documentation.
"""
import json
import os
import re
import unittest
REPO_ROOT = os.path.dirname(os.path.dirname(os.path.abspath(__file__)))
DOC_PATH = os.path.join(REPO_ROOT, "docs", "remote-mcp", "threat-model.md")
FIXTURE_PATH = os.path.join(
REPO_ROOT, "docs", "remote-mcp", "threat-model-anchors.json"
)
# ``module.py:123`` as it appears inside markdown inline code spans.
ANCHOR_RE = re.compile(r"`([A-Za-z0-9_./-]+\.py):(\d+)`")
# The epic children this document must map to a boundary (#929 children 2-10).
REQUIRED_CHILDREN = [931, 932, 933, 934, 935, 936, 937, 938, 939]
# The adversaries #956 names explicitly.
REQUIRED_ADVERSARIES = [
"compromised LLM client",
"prompt injection",
"malicious tool arguments",
"network attacker",
"curious operator",
]
def _read(path):
with open(path, "r", encoding="utf-8") as fh:
return fh.read()
def _heading_re(title):
"""Match a level-2 heading by title, with or without section numbering.
The document numbers its sections ('## 6. Decomposition ruling'), so an
exact-substring assertion would break on renumbering without the document
having actually lost anything.
"""
return re.compile(
r"^##\s+(?:\d+\.\s+)?" + re.escape(title), re.MULTILINE
)
def _section_body(doc, title):
"""Return the text of section *title*, bounded by the next level-2 heading.
Bounding matters: an unbounded slice runs to end-of-document, so the
walkthrough tables in a later section leak into the child-to-boundary
mapping and satisfy its coverage check with rows that assign no owner.
"""
match = _heading_re(title).search(doc)
if match is None:
return None
rest = doc[match.end():]
nxt = re.search(r"^##\s", rest, re.MULTILINE)
return rest[: nxt.start()] if nxt else rest
def _source_line(rel_path, lineno):
"""Return the 1-based *lineno* of *rel_path*, or None if out of range."""
abs_path = os.path.join(REPO_ROOT, rel_path)
if not os.path.exists(abs_path):
return None
with open(abs_path, "r", encoding="utf-8", errors="replace") as fh:
for idx, line in enumerate(fh, start=1):
if idx == lineno:
return line
return None
class ThreatModelFixtureTests(unittest.TestCase):
"""The fixture itself must be well-formed before it can prove anything."""
def setUp(self):
self.fixture = json.loads(_read(FIXTURE_PATH))
def test_fixture_declares_a_generation_commit(self):
sha = self.fixture.get("generated_against_commit") or ""
self.assertRegex(
sha,
r"^[0-9a-f]{40}$",
"the fixture must record the full commit its anchors were taken at",
)
def test_fixture_anchors_are_unique_and_well_formed(self):
seen = set()
for entry in self.fixture["anchors"]:
anchor = entry["anchor"]
self.assertNotIn(anchor, seen, f"duplicate anchor entry: {anchor}")
seen.add(anchor)
self.assertRegex(anchor, r"^[A-Za-z0-9_./-]+\.py:[1-9]\d*$", anchor)
self.assertTrue(
(entry.get("expect") or "").strip(),
f"anchor {anchor} declares no 'expect' substring, so it proves nothing",
)
class ThreatModelAnchorResolutionTests(unittest.TestCase):
"""#956 required positive test: every anchor resolves at the reviewed commit."""
def setUp(self):
self.fixture = json.loads(_read(FIXTURE_PATH))
self.doc = _read(DOC_PATH)
def test_every_declared_anchor_resolves_to_the_claimed_source_line(self):
failures = []
for entry in self.fixture["anchors"]:
rel_path, _, raw_lineno = entry["anchor"].partition(":")
lineno = int(raw_lineno)
line = _source_line(rel_path, lineno)
if line is None:
failures.append(f"{entry['anchor']}: file or line does not exist")
continue
if entry["expect"] not in line:
failures.append(
f"{entry['anchor']}: expected {entry['expect']!r}, "
f"found {line.strip()!r}"
)
self.assertEqual(
[], failures, "unresolved threat-model anchors:\n" + "\n".join(failures)
)
def test_every_anchor_cited_in_the_document_is_declared_in_the_fixture(self):
declared = {e["anchor"] for e in self.fixture["anchors"]}
cited = {f"{m.group(1)}:{m.group(2)}" for m in ANCHOR_RE.finditer(self.doc)}
undeclared = sorted(cited - declared)
self.assertEqual(
[],
undeclared,
"document cites anchors that no test verifies: " + ", ".join(undeclared),
)
def test_the_document_actually_cites_anchors(self):
cited = {f"{m.group(1)}:{m.group(2)}" for m in ANCHOR_RE.finditer(self.doc)}
self.assertGreaterEqual(
len(cited),
30,
"a boundary document with almost no anchors is not traceable",
)
def test_unresolvable_anchor_is_detected(self):
"""Negative control: the checker must fail on a deliberately bad anchor.
Without this, a checker that silently passed everything would look
identical to a correct one.
"""
self.assertIsNone(_source_line("gitea_config.py", 10**9))
self.assertIsNone(_source_line("no_such_module_for_956.py", 1))
real = _source_line("gitea_config.py", 54)
self.assertIsNotNone(real)
self.assertNotIn("this substring is not on that line", real)
class ThreatModelStructureTests(unittest.TestCase):
"""The document must contain what #956's acceptance criteria demand."""
def setUp(self):
self.doc = _read(DOC_PATH)
def test_records_the_commit_it_was_generated_against(self):
fixture = json.loads(_read(FIXTURE_PATH))
self.assertIn(
fixture["generated_against_commit"],
self.doc,
"the document must state the commit its anchors resolve at",
)
def test_names_every_required_adversary(self):
low = self.doc.lower()
for adversary in REQUIRED_ADVERSARIES:
self.assertIn(adversary.lower(), low, f"adversary not covered: {adversary}")
def test_maps_every_epic_child_from_two_through_ten(self):
for number in REQUIRED_CHILDREN:
self.assertIn(
f"#{number}",
self.doc,
f"epic child #{number} is not mapped to a boundary",
)
def test_credential_rows_declare_holder_boundary_and_blast_radius(self):
for column in ("Holder", "Boundary", "Blast radius"):
self.assertIn(
column,
self.doc,
f"the credential inventory must state each credential's {column.lower()}",
)
def test_states_an_explicit_co_residency_ruling(self):
"""AC3/AC5: an explicit ruling, not an implication."""
self.assertIsNotNone(
_heading_re("Decomposition ruling").search(self.doc),
"the document must contain an explicit decomposition-ruling section",
)
for service in ("Jenkins", "GlitchTip", "Sentry", "database"):
self.assertIn(service, self.doc, f"ruling does not address {service}")
self.assertRegex(
self.doc,
r"D1\b.*must not",
"the ruling must state the prohibition, not merely discuss it",
)
def test_contains_the_compromised_client_walkthrough(self):
"""#956 required negative/adversarial test."""
self.assertIsNotNone(
_heading_re("Adversarial walkthrough").search(self.doc),
"the required compromised-client walkthrough is missing",
)
self.assertIn("Before the migration", self.doc)
self.assertIn("After the migration", self.doc)
def test_every_boundary_states_what_it_protects_and_what_crossing_requires(self):
boundary_ids = set(re.findall(r"\bB(\d+)\b", self.doc))
self.assertGreaterEqual(
len(boundary_ids), 5, "too few trust boundaries to be a decomposition"
)
for column in (
"Protects",
"Crossing requires today",
"Crossing must require remotely",
):
self.assertIn(column, self.doc, f"boundary table is missing '{column}'")
def test_declares_itself_documentation_only(self):
self.assertIn("documentation only", self.doc.lower())
class ThreatModelConsistencyTests(unittest.TestCase):
"""Counts stated in prose must match the rows actually present."""
def setUp(self):
self.doc = _read(DOC_PATH)
def _declared_ids(self, prefix):
# Table rows begin '| CR1 |' / '| B3 |' / '| A2 |'.
return sorted(
{
int(m)
for m in re.findall(
r"^\|\s*%s(\d+)\s*\|" % prefix, self.doc, re.MULTILINE
)
}
)
def test_identifier_sequences_have_no_gaps(self):
for prefix, label in (
("A", "assets"),
("B", "boundaries"),
("CR", "credentials"),
):
ids = self._declared_ids(prefix)
self.assertTrue(ids, f"no {label} declared")
self.assertEqual(
list(range(1, len(ids) + 1)),
ids,
f"{label} identifiers must run 1..n with no gaps; got {ids}",
)
def test_stated_credential_count_matches_the_rows(self):
ids = self._declared_ids("CR")
match = re.search(r"(\d+)\s+credential(?:s)? in total", self.doc)
self.assertIsNotNone(match, "the credential inventory must state its own total")
self.assertEqual(
len(ids),
int(match.group(1)),
"stated credential total disagrees with the number of rows",
)
def test_every_boundary_is_owned_by_at_least_one_child(self):
"""Each boundary must be owned by a child *in the mapping table*.
Scanning the whole section would let a prose summary line ("Boundary
coverage: ... B5 (#936)") satisfy the assertion while the table row
that actually assigns the owner had been emptied — verified by
deliberately blanking a row and watching a whole-section check still
pass. Only table rows count.
"""
mapping_section = _section_body(self.doc, "Child-to-boundary mapping")
self.assertIsNotNone(
mapping_section, "child-to-boundary mapping section is missing"
)
rows = [
line
for line in mapping_section.splitlines()
if line.lstrip().startswith("|") and re.search(r"#93\d", line)
]
self.assertGreaterEqual(
len(rows), len(REQUIRED_CHILDREN), "mapping table has too few child rows"
)
mapped = set(re.findall(r"\bB(\d+)\b", "\n".join(rows)))
declared = {str(i) for i in self._declared_ids("B")}
unmapped = sorted(declared - mapped, key=int)
self.assertEqual(
[],
unmapped,
"boundaries with no owning child: " + ", ".join("B" + u for u in unmapped),
)
if __name__ == "__main__":
unittest.main()
+149
View File
@@ -0,0 +1,149 @@
"""Unit tests for mcp_config_drift.py (#672)."""
from __future__ import annotations
import json
import pytest
from pathlib import Path
from mcp_config_drift import (
REQUIRED_GITEA_ROLE_SERVERS,
analyze_config_drift,
load_mcp_config,
)
@pytest.fixture
def sample_global_config() -> dict:
return {
"mcpServers": {
"gitea-author": {
"command": "python3",
"args": ["gitea_mcp_server.py"],
"env": {"GITEA_MCP_PROFILE": "prgs-author", "SENTRY_AUTH_TOKEN": "secret-token-999"},
},
"gitea-reviewer": {
"command": "python3",
"args": ["gitea_mcp_server.py"],
"env": {"GITEA_MCP_PROFILE": "prgs-reviewer"},
},
"gitea-merger": {
"command": "python3",
"args": ["gitea_mcp_server.py"],
"env": {"GITEA_MCP_PROFILE": "prgs-merger"},
},
"gitea-reconciler": {
"command": "python3",
"args": ["gitea_mcp_server.py"],
"env": {"GITEA_MCP_PROFILE": "prgs-reconciler"},
},
"gitea-controller": {
"command": "python3",
"args": ["gitea_mcp_server.py"],
"env": {"GITEA_MCP_PROFILE": "prgs-controller"},
},
"gitea-tools": {
"command": "python3",
"args": ["gitea_mcp_server.py"],
"env": {"GITEA_MCP_PROFILE": "prgs-author"},
},
}
}
def write_json(path: Path, data: dict) -> str:
path.write_text(json.dumps(data, indent=2), encoding="utf-8")
return str(path)
def test_drift_detection_in_sync(tmp_path, sample_global_config):
glob_file = tmp_path / "global_mcp.json"
act_file = tmp_path / "active_mcp.json"
write_json(glob_file, sample_global_config)
write_json(act_file, sample_global_config)
report = analyze_config_drift(active_config_path=str(act_file), global_config_path=str(glob_file))
assert report["in_sync"] is True
assert report["missing_role_servers"] == []
assert report["profile_mismatches"] == []
assert set(report["present_role_servers"]) == set(REQUIRED_GITEA_ROLE_SERVERS)
def test_drift_detection_missing_author(tmp_path, sample_global_config):
glob_file = tmp_path / "global_mcp.json"
act_file = tmp_path / "active_mcp.json"
active_config = json.loads(json.dumps(sample_global_config))
del active_config["mcpServers"]["gitea-author"]
write_json(glob_file, sample_global_config)
write_json(act_file, active_config)
report = analyze_config_drift(active_config_path=str(act_file), global_config_path=str(glob_file))
assert report["in_sync"] is False
assert "gitea-author" in report["missing_role_servers"]
assert "gitea-author" not in report["present_role_servers"]
def test_drift_detection_missing_reviewer(tmp_path, sample_global_config):
glob_file = tmp_path / "global_mcp.json"
act_file = tmp_path / "active_mcp.json"
active_config = json.loads(json.dumps(sample_global_config))
del active_config["mcpServers"]["gitea-reviewer"]
write_json(glob_file, sample_global_config)
write_json(act_file, active_config)
report = analyze_config_drift(active_config_path=str(act_file), global_config_path=str(glob_file))
assert report["in_sync"] is False
assert "gitea-reviewer" in report["missing_role_servers"]
def test_drift_detection_profile_mismatch(tmp_path, sample_global_config):
glob_file = tmp_path / "global_mcp.json"
act_file = tmp_path / "active_mcp.json"
active_config = json.loads(json.dumps(sample_global_config))
active_config["mcpServers"]["gitea-author"]["env"]["GITEA_MCP_PROFILE"] = "dadeschools-author"
write_json(glob_file, sample_global_config)
write_json(act_file, active_config)
report = analyze_config_drift(active_config_path=str(act_file), global_config_path=str(glob_file))
assert report["in_sync"] is False
assert len(report["profile_mismatches"]) == 1
mismatch = report["profile_mismatches"][0]
assert mismatch["server"] == "gitea-author"
assert mismatch["active_profile"] == "dadeschools-author"
assert mismatch["global_profile"] == "prgs-author"
def test_secret_redaction_in_drift_report(tmp_path, sample_global_config):
glob_file = tmp_path / "global_mcp.json"
act_file = tmp_path / "active_mcp.json"
write_json(glob_file, sample_global_config)
write_json(act_file, sample_global_config)
report = analyze_config_drift(active_config_path=str(act_file), global_config_path=str(glob_file))
serialized = str(report)
assert "secret-token-999" not in serialized
def test_sanctioned_runbook_forbids_pkill():
report = analyze_config_drift(active_config_path="/nonexistent/path/active.json", global_config_path="/nonexistent/path/global.json")
runbook_text = " ".join(report["sanctioned_repair_runbook"]).lower()
forbidden_text = " ".join(report["forbidden_repair_methods"]).lower()
assert "pkill" in forbidden_text
assert "mtime" in forbidden_text
assert "source" in forbidden_text
assert "session-state" in forbidden_text
+342
View File
@@ -0,0 +1,342 @@
"""Tests for MCP restart lifecycle audit events and incidents (#665)."""
from __future__ import annotations
import json
import os
import tempfile
import unittest
from unittest.mock import patch
import gitea_audit
import restart_audit as ra
class TestEventSchema(unittest.TestCase):
def test_all_lifecycle_event_types_are_named(self):
expected = {
"mcp.restart.impact_preview",
"mcp.restart.drain_enter",
"mcp.restart.drain_exit",
"mcp.restart.drain_proof",
"mcp.restart.apply_gate",
"mcp.restart.break_glass",
"mcp.restart.post_restart_reconcile",
"mcp.restart.narrower_recovery",
"mcp.restart.unguarded_detected",
}
self.assertEqual(set(ra.RESTART_EVENT_TYPES), expected)
def test_build_restart_event_core_fields(self):
event = ra.build_restart_event(
event_type=ra.EVENT_IMPACT_PREVIEW,
outcome="safe",
correlation_id="rst-abc123",
remote="prgs",
org="Scaled-Tech-Consulting",
repo="Gitea-Tools",
requesting_session_id="sess-1",
restart_class="full_mcp_restart",
profile_name="prgs-author",
authenticated_username="bot",
reasons=["inventory complete"],
details={"allow_restart": True},
now="2026-07-25T12:00:00+00:00",
)
self.assertEqual(event["event_type"], ra.EVENT_IMPACT_PREVIEW)
self.assertEqual(event["action"], ra.EVENT_IMPACT_PREVIEW)
self.assertEqual(event["action_type"], "restart_lifecycle")
self.assertEqual(event["result"], "safe")
self.assertEqual(event["correlation_id"], "rst-abc123")
self.assertEqual(event["profile_name"], "prgs-author")
self.assertEqual(event["authenticated_username"], "bot")
meta = event["request_metadata"]
self.assertEqual(meta["event_family"], "mcp.restart")
self.assertEqual(meta["correlation_id"], "rst-abc123")
self.assertEqual(meta["restart_class"], "full_mcp_restart")
self.assertEqual(meta["details"]["allow_restart"], True)
def test_unknown_event_type_raises(self):
with self.assertRaises(ValueError) as ctx:
ra.build_restart_event(
event_type="mcp.restart.not_a_real_event",
outcome="x",
correlation_id="rst-1",
)
self.assertIn("unknown restart audit event_type", str(ctx.exception))
def test_reasons_and_details_are_redacted(self):
event = ra.build_restart_event(
event_type=ra.EVENT_APPLY_GATE,
outcome="deny",
correlation_id="rst-sec",
reasons=["token secret-xyz rejected", "ok"],
details={"token": "leak-token", "status": "denied"},
)
self.assertNotIn("secret-xyz", event.get("reason") or "")
meta = event["request_metadata"]
self.assertEqual(meta["details"]["token"], gitea_audit.REDACTED)
self.assertEqual(meta["details"]["status"], "denied")
for reason in meta["reasons"]:
self.assertNotIn("secret-xyz", reason)
def test_new_correlation_id_shape(self):
cid = ra.new_correlation_id()
self.assertTrue(cid.startswith("rst-"))
self.assertEqual(len(cid), len("rst-") + 16)
class TestEmitAndRequire(unittest.TestCase):
def test_emit_appends_json_line(self):
with tempfile.TemporaryDirectory() as d:
path = os.path.join(d, "audit.log")
event = ra.build_restart_event(
event_type=ra.EVENT_DRAIN_PROOF,
outcome="pass",
correlation_id="rst-write",
)
self.assertTrue(ra.emit_restart_event(event, path=path))
with open(path, encoding="utf-8") as fh:
lines = fh.read().splitlines()
self.assertEqual(len(lines), 1)
loaded = json.loads(lines[0])
self.assertEqual(loaded["event_type"], ra.EVENT_DRAIN_PROOF)
self.assertEqual(loaded["correlation_id"], "rst-write")
def test_emit_never_raises(self):
self.assertFalse(
ra.emit_restart_event({"action": "x"}, path="/no/such/dir/audit.log")
)
def test_require_audit_denies_privileged_when_write_fails_and_enabled(self):
deny = ra.require_audit_or_deny(
privileged=True, written=False, audit_enabled=True
)
self.assertEqual(len(deny), 1)
self.assertIn("fail closed", deny[0])
def test_require_audit_allows_when_audit_disabled(self):
# Rollout policy: enable audit before enforcing deny-on-audit-fail.
deny = ra.require_audit_or_deny(
privileged=True, written=False, audit_enabled=False
)
self.assertEqual(deny, [])
def test_require_audit_noop_for_non_privileged(self):
deny = ra.require_audit_or_deny(
privileged=False, written=False, audit_enabled=True
)
self.assertEqual(deny, [])
def test_require_audit_allows_when_written(self):
deny = ra.require_audit_or_deny(
privileged=True, written=True, audit_enabled=True
)
self.assertEqual(deny, [])
class TestIncidents(unittest.TestCase):
def test_break_glass_descriptor(self):
desc = ra.build_incident_descriptor(
kind=ra.INCIDENT_BREAK_GLASS,
reasons=["break-glass authorized"],
correlation_id="rst-bg",
requesting_session_id="s1",
restart_class="full_mcp_restart",
remote="prgs",
org="O",
repo="R",
)
self.assertEqual(desc["kind"], ra.INCIDENT_BREAK_GLASS)
self.assertIn("Break-glass", desc["title"])
self.assertIn("mcp-health", desc["labels"])
self.assertEqual(desc["source"], "restart_audit#665")
def test_incident_body_redacts_and_includes_correlation(self):
desc = ra.build_incident_descriptor(
kind=ra.INCIDENT_FAILED_DRAIN,
reasons=["token secret-xyz failed proof"],
correlation_id="rst-body",
remote="prgs",
org="O",
repo="R",
proof_id="proof-1",
)
body = ra.incident_body(desc)
self.assertIn("rst-body", body)
self.assertIn("proof-1", body)
self.assertIn("mcp-restart-incident:v1", body)
self.assertNotIn("secret-xyz", body)
def test_materialize_dry_run(self):
desc = ra.build_incident_descriptor(
kind=ra.INCIDENT_FAILED_DRAIN,
reasons=["denied"],
correlation_id="rst-dr",
)
result = ra.materialize_incident(desc, create_issue_fn=lambda **k: {}, dry_run=True)
self.assertFalse(result["created"])
self.assertTrue(result["dry_run"])
self.assertIn("dry-run", result["reasons"][0])
def test_materialize_without_create_fn(self):
desc = ra.build_incident_descriptor(
kind=ra.INCIDENT_FAILED_DRAIN,
reasons=["denied"],
correlation_id="rst-nfn",
)
result = ra.materialize_incident(desc, create_issue_fn=None)
self.assertFalse(result["created"])
self.assertIn("create_issue_fn not provided", result["reasons"][0])
def test_materialize_creates_issue(self):
created = {}
def _create(*, title, body, labels, org=None, repo=None, **_kw):
created["title"] = title
created["body"] = body
created["labels"] = labels
created["org"] = org
created["repo"] = repo
return {"number": 999}
desc = ra.build_incident_descriptor(
kind=ra.INCIDENT_BREAK_GLASS,
reasons=["break-glass"],
correlation_id="rst-create",
org="O",
repo="R",
)
result = ra.materialize_incident(desc, create_issue_fn=_create)
self.assertTrue(result["created"])
self.assertEqual(result["issue_number"], 999)
self.assertIn("Break-glass", created["title"])
self.assertIn("rst-create", created["body"])
self.assertEqual(created["org"], "O")
def test_materialize_never_raises_on_create_failure(self):
def _boom(**_kw):
raise RuntimeError("token secret-xyz network")
desc = ra.build_incident_descriptor(
kind=ra.INCIDENT_FAILED_DRAIN,
reasons=["x"],
correlation_id="rst-boom",
)
result = ra.materialize_incident(desc, create_issue_fn=_boom)
self.assertFalse(result["created"])
self.assertIn("failed", result["reasons"][0])
self.assertNotIn("secret-xyz", result["reasons"][0])
class TestIncidentFromApplyGate(unittest.TestCase):
def test_break_glass_always_incident(self):
desc = ra.incident_from_apply_gate(
gate_payload={"allow": True, "reasons": [], "proof_id": None},
break_glass=True,
correlation_id="rst-bg2",
requesting_session_id="s",
restart_class="full_mcp_restart",
remote="prgs",
org="O",
repo="R",
)
self.assertIsNotNone(desc)
self.assertEqual(desc["kind"], ra.INCIDENT_BREAK_GLASS)
def test_failed_drain_from_gate_incident(self):
desc = ra.incident_from_apply_gate(
gate_payload={
"allow": False,
"drain_gate_allow": False,
"reasons": ["proof expired"],
"incident": {
"reasons": ["proof expired"],
"proof_id": "p1",
},
},
break_glass=False,
correlation_id="rst-fd",
requesting_session_id="s",
restart_class="full_mcp_restart",
remote="prgs",
org="O",
repo="R",
)
self.assertIsNotNone(desc)
self.assertEqual(desc["kind"], ra.INCIDENT_FAILED_DRAIN)
self.assertEqual(desc["proof_id"], "p1")
def test_allow_without_break_glass_no_incident(self):
desc = ra.incident_from_apply_gate(
gate_payload={
"allow": True,
"drain_gate_allow": True,
"reasons": [],
},
break_glass=False,
correlation_id="rst-ok",
requesting_session_id="s",
restart_class="full_mcp_restart",
remote="prgs",
org="O",
repo="R",
)
self.assertIsNone(desc)
class TestRecordLifecycle(unittest.TestCase):
def test_record_emits_and_materializes(self):
created = []
def _create(**kwargs):
created.append(kwargs)
return {"number": 42}
with tempfile.TemporaryDirectory() as d:
path = os.path.join(d, "audit.log")
with patch.dict(os.environ, {"GITEA_AUDIT_LOG": path}, clear=False):
incident = ra.build_incident_descriptor(
kind=ra.INCIDENT_BREAK_GLASS,
reasons=["bg"],
correlation_id="rst-lc",
org="O",
repo="R",
)
out = ra.record_restart_lifecycle(
event_type=ra.EVENT_BREAK_GLASS,
outcome="break_glass",
correlation_id="rst-lc",
remote="prgs",
org="O",
repo="R",
privileged=True,
create_incident=incident,
create_issue_fn=_create,
audit_path=path,
)
self.assertTrue(out["audit_written"])
self.assertEqual(out["deny_reasons"], [])
self.assertTrue(out["incident_result"]["created"])
self.assertEqual(out["incident_result"]["issue_number"], 42)
self.assertEqual(len(created), 1)
def test_privileged_deny_when_audit_write_fails(self):
with patch.dict(
os.environ, {"GITEA_AUDIT_LOG": "/no/such/dir/a.log"}, clear=False
):
with patch("restart_audit.emit_restart_event", return_value=False):
with patch("gitea_audit.audit_enabled", return_value=True):
out = ra.record_restart_lifecycle(
event_type=ra.EVENT_APPLY_GATE,
outcome="deny",
correlation_id="rst-deny",
privileged=True,
audit_path="/no/such/dir/a.log",
)
self.assertFalse(out["audit_written"])
self.assertEqual(len(out["deny_reasons"]), 1)
if __name__ == "__main__":
unittest.main()
+190
View File
@@ -0,0 +1,190 @@
"""Tests for Sentry/GlitchTip observability console (#649, Phase 4)."""
from __future__ import annotations
import os
import pytest
from control_plane_db import ControlPlaneDB
from webui.app import create_app
from webui.console_authz import authorize, resolve_principal
from webui.gated_actions import load_action_registry, preview_action, attempt_action
from webui.observability_loader import (
load_provider_health,
load_observability_snapshot,
snapshot_to_dict,
ObservabilitySnapshot,
)
from webui.observability_views import render_observability_page
from tests.webui_testclient import TestClient
@pytest.fixture
def test_db(tmp_path):
db_path = str(tmp_path / "test_control_plane.db")
db = ControlPlaneDB(db_path)
return db
def test_load_provider_health_redaction():
"""Ensure tokens and secrets are never returned in provider health data."""
env = {
"SENTRY_BASE_URL": "https://sentry.prgs.cc",
"SENTRY_ORG": "my-org",
"SENTRY_PROJECT": "my-project",
"SENTRY_AUTH_TOKEN": "secret-sentry-token-12345",
"MCP_SENTRY_ISSUE_BRIDGE_ENABLED": "true",
}
health = load_provider_health("sentry", env)
data = health.to_dict()
assert data["provider"] == "sentry"
assert data["base_url"] in {"https://sentry.prgs.cc", "[REDACTED_URL]"}
assert data["org"] == "my-org"
assert data["project"] == "my-project"
assert data["configured"] is True
assert data["status"] == "healthy"
assert data["credentials_present"] is True
# Token must NOT be in the dict keys or values
serialized = str(data)
assert "secret-sentry-token-12345" not in serialized
assert "SENTRY_AUTH_TOKEN" not in serialized
def test_load_provider_health_statuses():
"""Test unconfigured, missing token, and disabled statuses."""
# Not configured
h1 = load_provider_health("sentry", {})
d1 = h1.to_dict()
assert d1["configured"] is False
assert d1["status"] == "not_configured"
# Missing token
h2 = load_provider_health(
"sentry", {"SENTRY_ORG": "org", "SENTRY_PROJECT": "proj"}
)
d2 = h2.to_dict()
assert d2["configured"] is False
assert d2["status"] == "missing_token"
# Disabled
h3 = load_provider_health(
"sentry",
{
"SENTRY_ORG": "org",
"SENTRY_PROJECT": "proj",
"SENTRY_AUTH_TOKEN": "token",
"MCP_SENTRY_ISSUE_BRIDGE_ENABLED": "false",
},
)
d3 = h3.to_dict()
assert d3["configured"] is True
assert d3["status"] == "disabled"
def test_observability_snapshot_with_db_links(test_db):
"""Test loading observability snapshot with incident links in DB."""
test_db.upsert_incident_link(
provider="sentry",
provider_issue_id="101",
gitea_org="Scaled-Tech-Consulting",
gitea_repo="Gitea-Tools",
gitea_issue_number=649,
provider_base_url="https://sentry.prgs.cc",
provider_org="Scaled-Tech-Consulting",
provider_project="Gitea-Tools",
provider_short_id="ST-101",
provider_permalink="https://sentry.prgs.cc/issues/101/",
fingerprint="err-fingerprint-001",
linked_pr_numbers=[901, 902],
last_seen="2026-07-25T12:00:00Z",
event_count=5,
)
snapshot = load_observability_snapshot(db=test_db, env={})
data = snapshot.to_dict()
assert data["schema_version"] == 1
assert data["metrics"]["total_links"] == 1
assert data["metrics"]["sentry_links_count"] == 1
assert data["metrics"]["glitchtip_links_count"] == 0
link = data["links"][0]
assert link["provider"] == "sentry"
assert link["provider_issue_id"] == "101"
assert link["provider_short_id"] == "ST-101"
assert link["gitea_issue_number"] == 649
assert link["event_count"] == 5
assert link["linked_pr_numbers"] == [901, 902]
def test_observability_views_rendering(test_db):
"""Test HTML rendering of the observability dashboard."""
snapshot = load_observability_snapshot(db=test_db, env={})
html_output = render_observability_page(snapshot)
assert "Observability &amp; Incident Bridge (#649)" in html_output or "Observability & Incident Bridge (#649)" in html_output or "Observability" in html_output
assert "ADR Authority Model:" in html_output
assert "Provider Connections" in html_output
assert "Correlated Incidents" in html_output
def test_webui_observability_routes():
"""Test Starlette HTTP routes for /observability and /api/v1/observability."""
client = TestClient(create_app())
# HTML page route
res_html = client.get("/observability")
assert res_html.status_code == 200
assert "text/html" in res_html.headers["content-type"]
assert "Observability" in res_html.text
# Versioned API route
res_api_v1 = client.get("/api/v1/observability")
assert res_api_v1.status_code == 200
assert "application/json" in res_api_v1.headers["content-type"]
data_v1 = res_api_v1.json()
assert "schema_version" in data_v1
assert "providers" in data_v1
assert "links" in data_v1
assert "metrics" in data_v1
# Compatibility alias route
res_api_alias = client.get("/api/observability")
assert res_api_alias.status_code == 200
assert res_api_alias.json() == data_v1
def test_observability_gated_actions():
"""Ensure observability actions are registered, gated, and fail closed in MVP mode."""
registry = load_action_registry()
action_reconcile = registry.get("observability_reconcile_incident")
assert action_reconcile is not None
assert action_reconcile.task_key == "observability_reconcile_incident"
assert action_reconcile.mcp_tool == "gitea_observability_reconcile_incident"
action_link = registry.get("observability_link_issue")
assert action_link is not None
assert action_link.task_key == "observability_link_issue"
# Preview returns mutation ledger
prev = preview_action("observability_reconcile_incident", provider="sentry", issue_id="101")
assert prev["action_id"] == "observability_reconcile_incident"
assert prev["enabled"] is False
# Execution fails closed in MVP mode
att = attempt_action("observability_reconcile_incident", provider="sentry", issue_id="101")
assert att["success"] is False
assert att["error"] == "action_disabled"
def test_observability_authz_rbac():
"""Test RBAC authorization for observability actions."""
principal = resolve_principal({})
# Check authorize decision
decision = authorize("observability_reconcile_incident", principal)
assert decision.action_id == "observability_reconcile_incident"
# Phase 4 action denies in Phase 1 runtime by default
assert decision.allowed is False
+21
View File
@@ -87,6 +87,11 @@ from webui.notifications import (
from webui.notification_views import render_notifications_page from webui.notification_views import render_notifications_page
from webui import request_service from webui import request_service
from webui.request_views import render_requests_page from webui.request_views import render_requests_page
from webui.observability_loader import (
load_observability_snapshot,
snapshot_to_dict as observability_snapshot_to_dict,
)
from webui.observability_views import render_observability_page
_READ_ONLY_METHODS = frozenset({"GET", "HEAD", "OPTIONS"}) _READ_ONLY_METHODS = frozenset({"GET", "HEAD", "OPTIONS"})
_AUDIT_MUTATION_PATHS = frozenset({"/audit", "/api/audit"}) _AUDIT_MUTATION_PATHS = frozenset({"/audit", "/api/audit"})
@@ -910,6 +915,19 @@ async def api_notifications(request: Request) -> JSONResponse:
data = notifications_snapshot_to_dict(snap) data = notifications_snapshot_to_dict(snap)
return JSONResponse(data) return JSONResponse(data)
async def observability_route(request: Request) -> HTMLResponse:
snap = load_observability_snapshot()
html_content = render_observability_page(snap)
return HTMLResponse(html_content)
async def api_observability(request: Request) -> JSONResponse:
snap = load_observability_snapshot()
data = observability_snapshot_to_dict(snap)
return JSONResponse(data)
def _default_request_scope() -> dict[str, str]: def _default_request_scope() -> dict[str, str]:
"""Resolve remote/org/repo from the project registry for request forms. """Resolve remote/org/repo from the project registry for request forms.
@@ -1075,6 +1093,9 @@ def create_app(*, bind_host: str | None = None) -> Starlette:
Route("/api/analytics", api_v1_analytics, methods=["GET"]), Route("/api/analytics", api_v1_analytics, methods=["GET"]),
Route("/api/v1/analytics", api_v1_analytics, methods=["GET"]), Route("/api/v1/analytics", api_v1_analytics, methods=["GET"]),
Route("/api/v1/analytics/usage", api_v1_analytics_ingest, methods=["POST"]), Route("/api/v1/analytics/usage", api_v1_analytics_ingest, methods=["POST"]),
Route("/observability", observability_route, methods=["GET"]),
Route("/api/observability", api_observability, methods=["GET"]),
Route("/api/v1/observability", api_observability, methods=["GET"]),
Route("/audit", audit, methods=["GET", "POST"]), Route("/audit", audit, methods=["GET", "POST"]),
Route("/api/audit", api_audit, methods=["GET", "POST"]), Route("/api/audit", api_audit, methods=["GET", "POST"]),
Route("/worktrees", worktrees, methods=["GET"]), Route("/worktrees", worktrees, methods=["GET"]),
+23
View File
@@ -317,6 +317,29 @@ _ACTION_SPECS: tuple[ConsoleAction, ...] = (
phase=2, phase=2,
summary="Run reconciler cleanup for merged or superseded PR branches.", summary="Run reconciler cleanup for merged or superseded PR branches.",
), ),
# #649: Phase 4 observability & incident bridge actions.
ConsoleAction(
action_id="observability_reconcile_incident",
task_key="observability_reconcile_incident",
action_class=CLASS_WRITE,
minimum_role=OPERATOR,
requires_confirmation=True,
dual_control=False,
break_glass=False,
phase=4,
summary="Trigger/reconcile durable Gitea issue creation from a provider incident.",
),
ConsoleAction(
action_id="observability_link_issue",
task_key="observability_link_issue",
action_class=CLASS_WRITE,
minimum_role=OPERATOR,
requires_confirmation=True,
dual_control=False,
break_glass=False,
phase=4,
summary="Link a provider incident to an existing Gitea issue.",
),
# #643: submit a work request — desired role, issue/PR, intent — and let # #643: submit a work request — desired role, issue/PR, intent — and let
# the allocator reserve it. This is the one Phase 2 action whose execution # the allocator reserve it. This is the one Phase 2 action whose execution
# path is actually implemented (``webui.request_service``), so it carries # path is actually implemented (``webui.request_service``), so it carries
+5
View File
@@ -185,6 +185,11 @@ def build_action_registry() -> ActionRegistry:
"console.rebind_session_worktree", "Rebind session worktree to verified lease."), "console.rebind_session_worktree", "Rebind session worktree to verified lease."),
("system.reconcile_cleanups", "Reconcile cleanups", "reconcile_cleanups", ("system.reconcile_cleanups", "Reconcile cleanups", "reconcile_cleanups",
"console.reconcile_cleanups", "Run reconciler cleanup for merged or superseded PRs."), "console.reconcile_cleanups", "Run reconciler cleanup for merged or superseded PRs."),
# #649: Phase 4 observability & incident bridge actions.
("observability_reconcile_incident", "Reconcile incident", "observability_reconcile_incident",
"gitea_observability_reconcile_incident", "Trigger or dry-run durable issue reconciliation for a provider incident."),
("observability_link_issue", "Link incident issue", "observability_link_issue",
"gitea_observability_link_issue", "Link a provider incident to a Gitea tracking issue."),
) )
actions = tuple( actions = tuple(
GatedAction( GatedAction(
+1
View File
@@ -73,6 +73,7 @@ NAV_GROUPS: tuple[NavGroup, ...] = (
)), )),
NavGroup("Insights", ( NavGroup("Insights", (
NavItem("/insights", "Insights", "stub"), NavItem("/insights", "Insights", "stub"),
NavItem("/observability", "Observability"),
NavItem("/analytics", "Analytics"), NavItem("/analytics", "Analytics"),
NavItem("/audit", "Audit"), NavItem("/audit", "Audit"),
)), )),
+275
View File
@@ -0,0 +1,275 @@
"""Sentry/GlitchTip observability and incident correlation loader for the console (#649, Phase 4).
Operators need to inspect provider connection status (Sentry/GlitchTip), error
correlations, and durable Gitea issue linkage without treating raw incidents
as allocator work.
ADR authority model:
* Gitea owns work.
* Providers (Sentry/GlitchTip) observe incidents.
* Control-plane DB coordinates incident links.
* The #612 bridge reconciles observations into durable Gitea issues.
* The web console projects read-only state and gates mutations.
Redaction boundary:
* Provider auth tokens, DSNs, Authorization headers, and sensitive local file
paths are ALWAYS redacted before leaving this module.
"""
from __future__ import annotations
import os
from dataclasses import dataclass
from typing import Any
from control_plane_db import ControlPlaneDB
import sentry_incident_bridge
from webui import console_redaction
OBSERVABILITY_SCHEMA_VERSION = 1
@dataclass(frozen=True)
class ProviderHealth:
"""Connection and health status of an observability provider."""
provider: str
base_url: str
org: str
project: str
configured: bool
status: str
bridge_enabled: bool
lookback: str
min_events_for_issue: int
self_hosted: bool
environment: str | None = None
credentials_present: bool = False
def to_dict(self) -> dict[str, Any]:
data = {
"provider": self.provider,
"base_url": self.base_url,
"org": self.org,
"project": self.project,
"configured": self.configured,
"status": self.status,
"bridge_enabled": self.bridge_enabled,
"lookback": self.lookback,
"min_events_for_issue": self.min_events_for_issue,
"self_hosted": self.self_hosted,
"environment": self.environment,
"credentials_present": self.credentials_present,
}
return console_redaction.redact_payload(data)
@dataclass(frozen=True)
class CorrelatedIncidentLink:
"""One linked provider incident ↔ Gitea issue correlation record."""
link_id: int
provider: str
provider_base_url: str
provider_org: str
provider_project: str
provider_issue_id: str
provider_short_id: str | None
provider_permalink: str | None
fingerprint: str | None
gitea_org: str
gitea_repo: str
gitea_issue_number: int
linked_pr_numbers: list[int]
last_seen: str | None
event_count: int
created_at: str | None
updated_at: str | None
def to_dict(self) -> dict[str, Any]:
data = {
"link_id": self.link_id,
"provider": self.provider,
"provider_base_url": self.provider_base_url,
"provider_org": self.provider_org,
"provider_project": self.provider_project,
"provider_issue_id": self.provider_issue_id,
"provider_short_id": self.provider_short_id,
"provider_permalink": self.provider_permalink,
"fingerprint": self.fingerprint,
"gitea_org": self.gitea_org,
"gitea_repo": self.gitea_repo,
"gitea_issue_number": self.gitea_issue_number,
"linked_pr_numbers": self.linked_pr_numbers,
"last_seen": self.last_seen,
"event_count": self.event_count,
"created_at": self.created_at,
"updated_at": self.updated_at,
}
return console_redaction.redact_payload(data)
@dataclass(frozen=True)
class ObservabilitySnapshot:
"""Read-only snapshot of observability provider status and incident correlations."""
schema_version: int
providers: list[ProviderHealth]
links: list[CorrelatedIncidentLink]
total_links: int
sentry_links_count: int
glitchtip_links_count: int
bridge_active: bool
def to_dict(self) -> dict[str, Any]:
return {
"schema_version": self.schema_version,
"providers": [p.to_dict() for p in self.providers],
"links": [link.to_dict() for link in self.links],
"metrics": {
"total_links": self.total_links,
"sentry_links_count": self.sentry_links_count,
"glitchtip_links_count": self.glitchtip_links_count,
"bridge_active": self.bridge_active,
},
}
def load_provider_health(
provider_name: str = "sentry",
env: dict[str, str] | None = None,
) -> ProviderHealth:
"""Inspect configuration and connection health for an observability provider."""
source_env = dict(env if env is not None else os.environ)
if provider_name.lower() == "sentry":
config = sentry_incident_bridge.load_bridge_config(source_env)
token = sentry_incident_bridge.resolve_token(source_env)
has_token = bool(token)
configured = bool(config.org and config.project and has_token)
if not config.org or not config.project:
status = "not_configured"
elif not has_token:
status = "missing_token"
elif not config.bridge_enabled:
status = "disabled"
else:
status = "healthy"
return ProviderHealth(
provider="sentry",
base_url=config.base_url,
org=config.org or "unconfigured",
project=config.project or "unconfigured",
configured=configured,
status=status,
bridge_enabled=config.bridge_enabled,
lookback=config.lookback,
min_events_for_issue=config.min_events_for_issue,
self_hosted=not config.base_url.rstrip("/").endswith("sentry.io"),
environment=config.environment,
credentials_present=has_token,
)
# GlitchTip or fallback provider configuration
glitchtip_url = (source_env.get("GLITCHTIP_BASE_URL") or "https://glitchtip.prgs.cc").strip()
glitchtip_org = (source_env.get("GLITCHTIP_ORG") or "").strip()
glitchtip_proj = (source_env.get("GLITCHTIP_PROJECT") or "").strip()
glitchtip_token = (source_env.get("GLITCHTIP_AUTH_TOKEN") or "").strip()
has_token = bool(glitchtip_token)
configured = bool(glitchtip_org and glitchtip_proj and has_token)
status = "healthy" if configured else ("missing_token" if glitchtip_org and glitchtip_proj else "not_configured")
return ProviderHealth(
provider="glitchtip",
base_url=glitchtip_url,
org=glitchtip_org or "unconfigured",
project=glitchtip_proj or "unconfigured",
configured=configured,
status=status,
bridge_enabled=configured,
lookback="24h",
min_events_for_issue=2,
self_hosted=True,
environment=source_env.get("GLITCHTIP_ENVIRONMENT"),
credentials_present=has_token,
)
def _parse_pr_numbers(raw: Any) -> list[int]:
if isinstance(raw, list):
return [int(x) for x in raw if str(x).isdigit()]
if isinstance(raw, str) and raw.strip():
import json
try:
parsed = json.loads(raw)
if isinstance(parsed, list):
return [int(x) for x in parsed if str(x).isdigit()]
except Exception:
pass
return []
def load_observability_snapshot(
db: ControlPlaneDB | None = None,
env: dict[str, str] | None = None,
) -> ObservabilitySnapshot:
"""Build a read-only snapshot of observability connection health and incident links."""
sentry_health = load_provider_health("sentry", env)
glitchtip_health = load_provider_health("glitchtip", env)
providers = [sentry_health, glitchtip_health]
target_db = db or ControlPlaneDB()
raw_links = target_db.list_incident_links(limit=100)
links: list[CorrelatedIncidentLink] = []
sentry_cnt = 0
glitchtip_cnt = 0
for r in raw_links:
prov = (r.get("provider") or "sentry").lower()
if prov == "sentry":
sentry_cnt += 1
elif prov == "glitchtip":
glitchtip_cnt += 1
pr_nums = _parse_pr_numbers(r.get("linked_pr_numbers"))
links.append(
CorrelatedIncidentLink(
link_id=int(r.get("link_id", 0)),
provider=prov,
provider_base_url=r.get("provider_base_url") or "",
provider_org=r.get("provider_org") or "",
provider_project=r.get("provider_project") or "",
provider_issue_id=str(r.get("provider_issue_id") or ""),
provider_short_id=r.get("provider_short_id"),
provider_permalink=r.get("provider_permalink"),
fingerprint=r.get("fingerprint"),
gitea_org=r.get("gitea_org") or "Scaled-Tech-Consulting",
gitea_repo=r.get("gitea_repo") or "Gitea-Tools",
gitea_issue_number=int(r.get("gitea_issue_number", 0)),
linked_pr_numbers=pr_nums,
last_seen=r.get("last_seen"),
event_count=int(r.get("event_count", 1)),
created_at=r.get("created_at"),
updated_at=r.get("updated_at"),
)
)
bridge_active = any(p.bridge_enabled for p in providers)
return ObservabilitySnapshot(
schema_version=OBSERVABILITY_SCHEMA_VERSION,
providers=providers,
links=links,
total_links=len(links),
sentry_links_count=sentry_cnt,
glitchtip_links_count=glitchtip_cnt,
bridge_active=bridge_active,
)
def snapshot_to_dict(snapshot: ObservabilitySnapshot) -> dict[str, Any]:
return snapshot.to_dict()
+143
View File
@@ -0,0 +1,143 @@
"""HTML view renderer for the Sentry/GlitchTip observability console (#649, Phase 4).
Renders connection status widgets, error correlation links, and gated issue creation
affordances over the read-only observability snapshot.
"""
from __future__ import annotations
import html
from typing import Any
from webui.layout import render_page
from webui.observability_loader import ObservabilitySnapshot, snapshot_to_dict
def _badge(status: str) -> str:
st = (status or "").lower()
if st == "healthy":
return '<span class="badge badge-success">healthy</span>'
if st == "disabled":
return '<span class="badge badge-warning">disabled (dry-run)</span>'
if st in {"missing_token", "not_configured"}:
return f'<span class="badge badge-muted">{html.escape(st)}</span>'
return f'<span class="badge">{html.escape(st)}</span>'
def _provider_card(p: dict[str, Any]) -> str:
name = html.escape(str(p.get("provider", "provider")).upper())
base_url = html.escape(str(p.get("base_url", "")))
org = html.escape(str(p.get("org", "")))
proj = html.escape(str(p.get("project", "")))
status_badge = _badge(str(p.get("status", "")))
min_events = p.get("min_events_for_issue", 2)
lookback = html.escape(str(p.get("lookback", "24h")))
bridge_enabled = "yes" if p.get("bridge_enabled") else "no"
return f"""
<div class="card" style="margin-bottom: 1rem; padding: 1rem; border: 1px solid #ccc; border-radius: 6px;">
<div style="display: flex; justify-content: space-between; align-items: center;">
<h3 style="margin: 0;">{name} Connection</h3>
<div>{status_badge}</div>
</div>
<table style="width: 100%; margin-top: 0.5rem; border-collapse: collapse;">
<tr><td><strong>Base URL:</strong></td><td><code>{base_url}</code></td></tr>
<tr><td><strong>Scope:</strong></td><td><code>{org} / {proj}</code></td></tr>
<tr><td><strong>Bridge Enabled:</strong></td><td><code>{bridge_enabled}</code></td></tr>
<tr><td><strong>Min Events for Issue:</strong></td><td><code>{min_events}</code></td></tr>
<tr><td><strong>Lookback Window:</strong></td><td><code>{lookback}</code></td></tr>
</table>
</div>
"""
def render_observability_page(snapshot: ObservabilitySnapshot | dict[str, Any]) -> str:
"""Render the observability dashboard HTML page."""
data = snapshot.to_dict() if isinstance(snapshot, ObservabilitySnapshot) else dict(snapshot)
providers_raw = data.get("providers", [])
provider_cards = "".join(_provider_card(p) for p in providers_raw) if providers_raw else "<p>No providers configured.</p>"
links = data.get("links", [])
link_rows = []
for l in links:
prov = html.escape(str(l.get("provider", "")))
p_issue_id = html.escape(str(l.get("provider_issue_id", "")))
fingerprint = html.escape(str(l.get("fingerprint") or ""))
g_issue_num = int(l.get("gitea_issue_number", 0))
g_org = html.escape(str(l.get("gitea_org", "")))
g_repo = html.escape(str(l.get("gitea_repo", "")))
g_issue_link = f'<strong>#{g_issue_num}</strong> ({g_org}/{g_repo})'
event_cnt = int(l.get("event_count", 1))
last_seen = html.escape(str(l.get("last_seen") or ""))
short_id = html.escape(str(l.get("provider_short_id") or p_issue_id))
link_rows.append(f"""
<tr>
<td><code>{prov}</code></td>
<td><strong>{short_id}</strong><br><small style="color: #666;">id: {p_issue_id}</small></td>
<td><code>{fingerprint}</code></td>
<td>{g_issue_link}</td>
<td>{event_cnt}</td>
<td><small>{last_seen}</small></td>
</tr>
""")
table_body = "".join(link_rows) if link_rows else '<tr><td colspan="6" style="text-align: center; padding: 1.5rem; color: #666;">No correlated incident links stored. Bridge operates under dry-run default.</td></tr>'
metrics = data.get("metrics", {})
total_links = metrics.get("total_links", 0)
sentry_cnt = metrics.get("sentry_links_count", 0)
glitchtip_cnt = metrics.get("glitchtip_links_count", 0)
body_html = f"""
<h2>Observability & Incident Bridge (#649)</h2>
<p>Read-only console surface for Sentry/GlitchTip provider connections, error correlation,
and durable Gitea issue linkage.</p>
<div class="alert alert-info" style="background: #f0f4f8; padding: 1rem; border-left: 4px solid #0052cc; margin-bottom: 1.5rem;">
<strong>ADR Authority Model:</strong> Gitea records durable issue history. Control-plane DB coordinates incident links.
Sentry/GlitchTip observe errors. Raw monitoring incidents are <em>never</em> assignable control-plane work items.
Durable issue creation is gated and dry-runable via the <code>#612</code> bridge APIs.
</div>
<h3>Provider Connections</h3>
<div style="display: grid; grid-template-columns: repeat(auto-fit, minmax(300px, 1fr)); gap: 1rem; margin-bottom: 2rem;">
{provider_cards}
</div>
<div style="display: flex; justify-content: space-between; align-items: center; margin-bottom: 1rem;">
<h3 style="margin: 0;">Correlated Incidents ({total_links})</h3>
<div>
<span class="badge" style="margin-right: 0.5rem;">Sentry: {sentry_cnt}</span>
<span class="badge">GlitchTip: {glitchtip_cnt}</span>
</div>
</div>
<table class="table" style="width: 100%; border-collapse: collapse; border: 1px solid #ddd;">
<thead>
<tr style="background: #f9f9f9; text-align: left;">
<th style="padding: 0.5rem; border-bottom: 2px solid #ddd;">Provider</th>
<th style="padding: 0.5rem; border-bottom: 2px solid #ddd;">Incident ID</th>
<th style="padding: 0.5rem; border-bottom: 2px solid #ddd;">Fingerprint</th>
<th style="padding: 0.5rem; border-bottom: 2px solid #ddd;">Gitea Issue Link</th>
<th style="padding: 0.5rem; border-bottom: 2px solid #ddd;">Events</th>
<th style="padding: 0.5rem; border-bottom: 2px solid #ddd;">Last Seen</th>
</tr>
</thead>
<tbody>
{table_body}
</tbody>
</table>
<div style="margin-top: 2rem; padding: 1rem; background: #fafafa; border: 1px solid #eee; border-radius: 4px;">
<h4 style="margin-top: 0;">Reconcile & Link Controls (Gated)</h4>
<p style="margin-bottom: 0.5rem; color: #555;">
Create or reconcile durable Gitea issues from provider observations using the <code>#612</code> incident bridge:
</p>
<code>mcp call gitea_observability_reconcile_incident --provider sentry --apply false</code>
</div>
"""
return render_page(title="Observability", body_html=body_html)