diff --git a/allocator_service.py b/allocator_service.py index 9f32fd9..a085d25 100644 --- a/allocator_service.py +++ b/allocator_service.py @@ -23,6 +23,7 @@ import json import os import uuid from dataclasses import dataclass, field +from datetime import datetime, timezone from typing import Any, Mapping, Sequence from control_plane_db import ( @@ -738,6 +739,46 @@ def normalize_exclude_issue_numbers( return sorted(out) +def _claim_expires_at(claim: Any) -> datetime | None: + """Parse a claim's ``expires_at``, or ``None`` when it is absent/malformed.""" + if not isinstance(claim, Mapping): + return None + text = str(claim.get("expires_at") or "").strip() + if not text: + return None + if text.endswith("Z"): + text = text[:-1] + "+00:00" + try: + parsed = datetime.fromisoformat(text) + except ValueError: + return None + if parsed.tzinfo is None: + parsed = parsed.replace(tzinfo=timezone.utc) + return parsed.astimezone(timezone.utc) + + +def _drop_expired_claims( + claims: Mapping[tuple[str, int], dict[str, Any]], + *, + now: datetime | None = None, +) -> dict[tuple[str, int], dict[str, Any]]: + """Claims minus those whose lease has already expired (#643). + + The read-only mirror of ``expire_stale_leases``: the sweep marks such rows + ``expired`` so they stop being returned as claims, and this reaches the same + view without writing. A claim with no parseable ``expires_at`` is **kept** — + an unreadable expiry is not evidence that work is free. + """ + moment = now or datetime.now(timezone.utc) + kept: dict[tuple[str, int], dict[str, Any]] = {} + for key, claim in (claims or {}).items(): + expires_at = _claim_expires_at(claim) + if expires_at is not None and expires_at <= moment: + continue + kept[key] = claim + return kept + + def candidate_set_fingerprint( candidates: Sequence[WorkCandidate], *, @@ -826,12 +867,22 @@ def allocate_next_work( exclude_issue_numbers: Sequence[int] | None = None, expected_candidate_set_fingerprint: str | None = None, allocation_mode: str | None = None, + side_effect_free: bool = False, ) -> dict[str, Any]: """Select and optionally reserve the next work unit via control-plane DB. *apply=False* (default): dry-run selection only — no lease/assignment. *apply=True*: atomic ``assign_and_lease`` for the selected candidate. + *side_effect_free* (#643): a dry run that writes **nothing** to the + control-plane DB. A plain ``apply=False`` still registered a session row and + swept stale leases globally, so a caller advertising a read-only preview was + mutating on every call. Under this flag both writes are suppressed and stale + leases are instead filtered out of the claim map in memory, which yields the + same selection the sweep would have produced without persisting anything. + Incompatible with *apply* — the combination fails closed rather than + silently reserving. + *allocation_mode* (#840): ``cross_role`` (default for controller) inspects the complete queue and returns one authoritative selection naming the required downstream role/profile/action. ``role_scoped`` keeps prior @@ -885,40 +936,57 @@ def allocate_next_work( "allocation_mode": (allocation_mode or "").strip() or None, } - session_id = (session_id or "").strip() or f"alloc-{uuid.uuid4().hex[:12]}" - try: - db.upsert_session( - session_id=session_id, - role=role_norm, - profile=profile_name, - pid=os.getpid(), - controller_instance_id=controller_instance_id, - ) - except Exception as exc: # noqa: BLE001 — surface structured + # A side-effect-free run may never reserve: reserving is a write, and the + # flag is the caller's assertion that this call writes nothing (#643). + if side_effect_free and apply: return { "success": False, "outcome": OUTCOME_NO_SAFE, + "apply": True, "reasons": [ - f"failed to register session in control-plane DB: {exc} " - "(fail closed, #613)" + "side_effect_free is incompatible with apply=True; an " + "assignment is a write (fail closed, #643)" ], "skipped": [], "assignment": None, "substrate": "control_plane_db", } - # Expire stale leases globally before selection. - try: - db.expire_stale_leases() - except Exception as exc: # noqa: BLE001 - return { - "success": False, - "outcome": OUTCOME_NO_SAFE, - "reasons": [f"lease expiry failed: {exc} (fail closed)"], - "skipped": [], - "assignment": None, - "substrate": "control_plane_db", - } + session_id = (session_id or "").strip() or f"alloc-{uuid.uuid4().hex[:12]}" + if not side_effect_free: + try: + db.upsert_session( + session_id=session_id, + role=role_norm, + profile=profile_name, + pid=os.getpid(), + controller_instance_id=controller_instance_id, + ) + except Exception as exc: # noqa: BLE001 — surface structured + return { + "success": False, + "outcome": OUTCOME_NO_SAFE, + "reasons": [ + f"failed to register session in control-plane DB: {exc} " + "(fail closed, #613)" + ], + "skipped": [], + "assignment": None, + "substrate": "control_plane_db", + } + + # Expire stale leases globally before selection. + try: + db.expire_stale_leases() + except Exception as exc: # noqa: BLE001 + return { + "success": False, + "outcome": OUTCOME_NO_SAFE, + "reasons": [f"lease expiry failed: {exc} (fail closed)"], + "skipped": [], + "assignment": None, + "substrate": "control_plane_db", + } terminal = None try: @@ -953,6 +1021,12 @@ def allocate_next_work( "assignment": None, "substrate": "control_plane_db", } + if side_effect_free: + # ``list_active_claims`` filters on status alone, so without the + # global sweep an already-expired lease would still read as a live + # claim and the preview would report work as taken that is free. + # Drop those in memory: same view the sweep produces, no write. + claims = _drop_expired_claims(claims) try: exclude_nums = normalize_exclude_issue_numbers(exclude_issue_numbers) diff --git a/author_issue_bootstrap.py b/author_issue_bootstrap.py index c7784fd..10834f1 100644 --- a/author_issue_bootstrap.py +++ b/author_issue_bootstrap.py @@ -23,6 +23,7 @@ import shutil import subprocess from typing import Any, Mapping +import author_lock_contract import author_mutation_worktree import control_plane_db import issue_lock_store @@ -196,6 +197,58 @@ def _verify_assignment_and_lease_ids( # Some lease rows may not yet have an assignment join; still require # the lease itself to exist and bind to the claimed session/issue. pass + # #943 review 622 B2: a lease that is no longer live confers no ownership. + # Existence alone previously satisfied this gate, so a released or expired + # lease could still authorize a bootstrap for a claim its session had given + # up. Checked before the session comparison so the reason names the real + # problem rather than reporting a mismatch. + from datetime import datetime, timezone + + lease_status = str(lease.get("status") or "").strip().lower() + if lease_status and lease_status != "active": + return { + "success": False, + "reason_code": "lease_not_live", + "message": ( + f"lease_id '{lid}' is '{lease_status}', not active; a lease that " + "is not live confers no ownership (fail closed)." + ), + "exact_next_action": ( + "Re-allocate the work item and pass the live assignment/lease pair." + ), + } + expires_raw = str(lease.get("expires_at") or "").strip() + if expires_raw: + try: + expires_at = datetime.fromisoformat(expires_raw.replace("Z", "+00:00")) + except ValueError: + return { + "success": False, + "reason_code": "lease_not_live", + "message": ( + f"lease_id '{lid}' records an unparseable expiry " + f"'{expires_raw}' (fail closed)." + ), + "exact_next_action": ( + "Re-allocate the work item and pass the live " + "assignment/lease pair." + ), + } + if expires_at.tzinfo is None: + expires_at = expires_at.replace(tzinfo=timezone.utc) + if expires_at <= datetime.now(timezone.utc): + return { + "success": False, + "reason_code": "lease_not_live", + "message": ( + f"lease_id '{lid}' expired at {expires_raw}; an expired lease " + "confers no ownership (fail closed)." + ), + "exact_next_action": ( + "Reclaim or re-allocate the lease, then retry with the live pair." + ), + } + lease_session = str(lease.get("session_id") or "").strip() if lease_session and lease_session != owner_session: return { @@ -226,6 +279,36 @@ def _verify_assignment_and_lease_ids( 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( journal: dict[str, Any], canonical_repo_root: str, @@ -262,10 +345,21 @@ def run_compensating_recovery( issue_number=issue_num, session=session_id, 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}") - except Exception: - pass + except Exception as exc: + # #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 worktree_created = ( @@ -386,6 +480,68 @@ def run_compensating_recovery( return recovery_info +def _normalize_sha(value: str | None) -> str | None: + """Normalize a Git object id for comparison, or ``None`` when unknown.""" + normalized = (value or "").strip().lower() + return normalized or None + + +def _author_bootstrap_assessment( + *, + not_applicable: bool, + allowed: bool, + block: bool, + reasons: list[str], + workspace: str, + root: str, + branch: str | None, + dirty: list[str], + under_branches: bool, + bootstrap_path: str | None = None, + local_head_sha: str | None = None, + remote_master_sha: str | None = None, + exact_next_action: str | None = None, +) -> dict[str, Any]: + """Structured author-bootstrap assessment consumable by bootstrap_permits (#892). + + Field shape mirrors :func:`create_issue_bootstrap._result` so the shared + ``bootstrap_permits_control_checkout`` predicate can prove control-checkout + eligibility for ``gitea_bootstrap_author_issue_worktree`` the same way it + does for ``create_issue``. Allowed control assessments must use empty + ``reasons`` — narrative belongs in other fields, not the refusal list. + """ + local_tip = _normalize_sha(local_head_sha) + remote_tip = _normalize_sha(remote_master_sha) + base_tips_verified = bool(local_tip and remote_tip and local_tip == remote_tip) + return { + "not_applicable": not_applicable, + "allowed": allowed, + "block": block, + "proven": bool(allowed and not block and not not_applicable), + "reasons": list(reasons), + "workspace_path": workspace, + "canonical_repo_root": root, + "current_branch": branch, + "dirty_files": list(dirty), + "under_branches": under_branches, + "exact_next_action": exact_next_action, + "bootstrap_path": bootstrap_path, + "task_scope": "author_issue_bootstrap", + "local_head_sha": local_tip, + "remote_master_sha": remote_tip, + "base_tips_verified": base_tips_verified, + } + + +EXACT_NEXT_ACTION_AUTHOR_BOOTSTRAP = ( + "Restore the canonical control checkout to a clean accepted base branch " + "(master/main/dev) that matches live master, with no tracked local edits. " + "Re-resolve bootstrap_author_issue_worktree, then re-run " + "gitea_bootstrap_author_issue_worktree from that clean control checkout. " + "Do not use shell git worktree add as the primary path once bootstrap is healthy." +) + + def assess_author_issue_bootstrap( *, workspace_path: str, @@ -397,7 +553,13 @@ def assess_author_issue_bootstrap( remote_master_sha_error: str | None = None, task: str | None = None, ) -> dict[str, Any]: - """Assess whether author issue worktree bootstrap may proceed from control or worktree root.""" + """Assess whether author issue worktree bootstrap may proceed from control or worktree root. + + #892: control-checkout successes emit the full field set required by + ``create_issue_bootstrap.bootstrap_permits_control_checkout`` (empty reasons, + task_scope, base tip proof, binding paths) so the #274/#604 guards can + waive control-checkout for this one sanctioned bootstrap task. + """ root = os.path.realpath(canonical_repo_root or "") workspace = os.path.realpath(workspace_path or root or ".") branch = (current_branch or "").strip() @@ -407,34 +569,50 @@ def assess_author_issue_bootstrap( if root else False ) + local_tip = _normalize_sha(head_sha) + remote_tip = _normalize_sha(remote_master_sha) if not is_author_issue_bootstrap_task(task): - return { - "not_applicable": True, - "allowed": False, - "block": False, - "proven": False, - "reasons": ["task is not author_issue_bootstrap"], - } + return _author_bootstrap_assessment( + not_applicable=True, + allowed=False, + block=False, + reasons=["task is not author_issue_bootstrap"], + workspace=workspace, + root=root, + branch=branch or None, + dirty=dirty, + under_branches=under_branches, + ) + # Already under branches/: ordinary #274 path applies; not a control waiver. if under_branches: - return { - "not_applicable": False, - "allowed": True, - "block": False, - "proven": True, - "bootstrap_path": "existing_branches_worktree", - "reasons": [ - "workspace is already a registered worktree under branches/" - ], - } + return _author_bootstrap_assessment( + not_applicable=True, + allowed=False, + block=False, + reasons=["workspace is under branches/; ordinary #274 path applies"], + workspace=workspace, + root=root, + branch=branch or None, + dirty=dirty, + under_branches=True, + bootstrap_path="existing_branches_worktree", + local_head_sha=local_tip, + remote_master_sha=remote_tip, + ) reasons: list[str] = [] - if workspace != root: + if not root or workspace != root: reasons.append( "bootstrap requires workspace to be canonical control checkout or branches/ worktree" ) - if branch not in author_mutation_worktree.BASE_BRANCHES: + if not branch: + reasons.append( + "control checkout is detached HEAD; expected an accepted base branch " + f"({', '.join(sorted(author_mutation_worktree.BASE_BRANCHES))})" + ) + elif branch not in author_mutation_worktree.BASE_BRANCHES: reasons.append( f"control checkout branch '{branch}' is not an accepted base branch " f"({', '.join(sorted(author_mutation_worktree.BASE_BRANCHES))})" @@ -444,37 +622,64 @@ def assess_author_issue_bootstrap( f"control checkout has tracked local edits: {', '.join(dirty[:5])}" ) - if remote_master_sha_error: + # Fail closed on missing tip proof (same bar as create_issue bootstrap #757). + if not local_tip: reasons.append( - f"could not verify live master tip: {remote_master_sha_error}" + "control checkout HEAD SHA is unknown; base equivalence to live " + "master cannot be proven (fail closed)" + ) + resolver_error = (remote_master_sha_error or "").strip() or None + if resolver_error: + reasons.append( + f"live master tip could not be resolved ({resolver_error}); " + "base equivalence cannot be proven (fail closed)" + ) + elif not remote_tip: + reasons.append( + "live master tip is unknown; base equivalence cannot be proven " + "(fail closed)" + ) + elif local_tip and remote_tip and local_tip != remote_tip: + reasons.append( + f"control checkout HEAD ({local_tip[:12]}) != live master tip " + f"({remote_tip[:12]})" ) - elif remote_master_sha and head_sha: - h = head_sha.strip().lower() - rm = remote_master_sha.strip().lower() - if h != rm: - reasons.append( - f"control checkout HEAD ({h[:12]}) != live master tip ({rm[:12]})" - ) if reasons: - return { - "not_applicable": False, - "allowed": False, - "block": True, - "proven": False, - "reasons": reasons, - } + return _author_bootstrap_assessment( + not_applicable=False, + allowed=False, + block=True, + reasons=reasons, + workspace=workspace, + root=root, + branch=branch or None, + dirty=dirty, + under_branches=False, + local_head_sha=local_tip, + remote_master_sha=remote_tip, + exact_next_action=EXACT_NEXT_ACTION_AUTHOR_BOOTSTRAP, + ) - return { - "not_applicable": False, - "allowed": True, - "block": False, - "proven": True, - "bootstrap_path": "clean_canonical_control_checkout", - "reasons": [ - "control checkout is clean on accepted base branch matching live master" - ], - } + # Allowed: empty reasons so bootstrap_permits_control_checkout can pass. + return _author_bootstrap_assessment( + not_applicable=False, + allowed=True, + block=False, + reasons=[], + workspace=workspace, + root=root, + branch=branch or None, + dirty=dirty, + under_branches=False, + bootstrap_path="clean_canonical_control_checkout", + local_head_sha=local_tip, + remote_master_sha=remote_tip, + exact_next_action=( + "Call gitea_bootstrap_author_issue_worktree with the allocated " + "issue/lease pins; it will create the branches/ worktree and lock." + ), + ) import fcntl @@ -1035,26 +1240,31 @@ def bootstrap_author_issue_worktree( save_phase_journal(journal, journal_dir=lock_dir) # 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 try: - lock_data = { - "remote": remote, - "org": org or "Scaled-Tech-Consulting", - "repo": repo or "Gitea-Tools", - "issue_number": issue_number, - "branch": target_branch, - "branch_name": target_branch, - "worktree_path": target_worktree, - "owner_session": session, - "claimant": { - "username": identity, - "profile": profile, - }, - "assignment_id": assignment_id, - "lease_id": lease_id, - "expected_base_sha": live_master_sha, - "created_at": datetime.now(timezone.utc).isoformat(), - } + lock_data = author_lock_contract.build_canonical_issue_lock( + issue_number=issue_number, + branch_name=target_branch, + worktree_path=target_worktree, + remote=remote, + org=org or "Scaled-Tech-Consulting", + repo=repo or "Gitea-Tools", + identity=identity, + profile=profile, + tool="gitea_bootstrap_author_issue_worktree", + source=author_lock_contract.SOURCE_BOOTSTRAP, + owner_session=session, + assignment_id=assignment_id, + lease_id=lease_id, + expected_base_sha=live_master_sha, + ) + lock_data["created_at"] = datetime.now(timezone.utc).isoformat() journal.setdefault("pending_creations", {})["lock"] = True journal["artifacts_created"]["lock_created"] = True save_phase_journal(journal, journal_dir=lock_dir) @@ -1072,9 +1282,65 @@ def bootstrap_author_issue_worktree( "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] = { "status": "completed", "lock": lock_res, + "lock_contract": contract["contract"], } journal["phases"][PHASE_7_TRANSITION_COMPLETED] = { "status": "completed", @@ -1098,9 +1364,16 @@ def bootstrap_author_issue_worktree( "assignment_id": assignment_id, "idempotency_key": key, "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, - "exact_next_action": ( - "Call gitea_whoami, then gitea_resolve_task_capability(task='work_issue') " - "and proceed with author implementation in the bootstrapped worktree." - ), + # #953 AC5: executable under the state actually returned. The lock + # has been read back and verified canonical, so proceeding to + # 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), } diff --git a/author_lock_contract.py b/author_lock_contract.py new file mode 100644 index 0000000..47db9e2 --- /dev/null +++ b/author_lock_contract.py @@ -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 [""] + 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": [""], + "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." + ) diff --git a/bootstrap_lock_recovery.py b/bootstrap_lock_recovery.py new file mode 100644 index 0000000..a451c91 --- /dev/null +++ b/bootstrap_lock_recovery.py @@ -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)" diff --git a/control_plane_db.py b/control_plane_db.py index a1f90d2..422003c 100644 --- a/control_plane_db.py +++ b/control_plane_db.py @@ -1568,6 +1568,32 @@ class ControlPlaneDB: ).fetchone() 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) ──────────────────────────────────────────── diff --git a/create_issue_bootstrap.py b/create_issue_bootstrap.py index 25a59dd..d03c624 100644 --- a/create_issue_bootstrap.py +++ b/create_issue_bootstrap.py @@ -241,9 +241,14 @@ def bootstrap_permits_control_checkout( caller's ordinary block in force. ``assessment`` is server-derived only: it is produced by - :func:`assess_create_issue_bootstrap` from inspected repository state. It is - never accepted from an MCP tool argument, so no caller can assert - eligibility it has not proven. + :func:`assess_create_issue_bootstrap` or + :func:`author_issue_bootstrap.assess_author_issue_bootstrap` from inspected + repository state. It is never accepted from an MCP tool argument, so no + caller can assert eligibility it has not proven. + + #892: author issue worktree bootstrap uses the same predicate with + ``task_scope='author_issue_bootstrap'`` so a clean control checkout can + create the first ``branches/`` worktree without the lock↔worktree cycle. """ if not isinstance(assessment, dict): return False @@ -264,9 +269,16 @@ def bootstrap_permits_control_checkout( if assessment.get("reasons"): return False - # Scope proof: only the create_issue bootstrap, only via the clean - # canonical control checkout path. - if assessment.get("task_scope") != "create_issue_only": + # Scope proof: create_issue (#749) or author issue bootstrap (#850/#892), + # only via the clean canonical control checkout path. + task_scope = assessment.get("task_scope") + if is_create_issue_task(task): + if task_scope != "create_issue_only": + return False + elif author_issue_bootstrap.is_author_issue_bootstrap_task(task): + if task_scope != "author_issue_bootstrap": + return False + else: return False if assessment.get("bootstrap_path") != "clean_canonical_control_checkout": return False diff --git a/docs/architecture/mcp-ha-rolling-restart.md b/docs/architecture/mcp-ha-rolling-restart.md new file mode 100644 index 0000000..fc82f99 --- /dev/null +++ b/docs/architecture/mcp-ha-rolling-restart.md @@ -0,0 +1,167 @@ +# ADR: High-availability and rolling-restart architecture for Gitea MCP control plane + +- **Status:** Proposed (Design ADR under [#668](https://gitea.prgs.cc/Scaled-Tech-Consulting/Gitea-Tools/issues/668)) +- **Date:** 2026-07-25 +- **Tracking Issue:** [#668](https://gitea.prgs.cc/Scaled-Tech-Consulting/Gitea-Tools/issues/668) +- **Policy Version:** `mcp-ha-rolling-restart/v1` +- **Related:** + - Parent: [#655](https://gitea.prgs.cc/Scaled-Tech-Consulting/Gitea-Tools/issues/655) — Governed MCP restart coordination and zero-disruption recovery + - Governance Policy: [#656](https://gitea.prgs.cc/Scaled-Tech-Consulting/Gitea-Tools/issues/656) / `docs/architecture/mcp-restart-governance.md` + - Control-Plane DB Substrate: [#613](https://gitea.prgs.cc/Scaled-Tech-Consulting/Gitea-Tools/issues/613) / `docs/architecture/control-plane-db-substrate.md` + - Runtime Policy: [#615](https://gitea.prgs.cc/Scaled-Tech-Consulting/Gitea-Tools/issues/615) / `docs/architecture/mcp-stable-control-runtime-policy-adr.md` + - Product Vision: [#652](https://gitea.prgs.cc/Scaled-Tech-Consulting/Gitea-Tools/issues/652) (Phase 5 Maturity) + - Delivery Roadmap: [#653](https://gitea.prgs.cc/Scaled-Tech-Consulting/Gitea-Tools/issues/653) + +--- + +## 1. Context & Problem Statement + +The Gitea MCP server operates as the authoritative **control plane** for managing issues, Pull Requests, code mutations, formal reviews, and workflow reconciliations. Under single-process governance ([#656](https://gitea.prgs.cc/Scaled-Tech-Consulting/Gitea-Tools/issues/656)), process restarts are strictly controlled using pre-flight checks, drain phases, and operator approvals. + +However, a single-instance control plane inherently presents fundamental constraints: + +1. **Downtime during updates:** Even a perfectly executed single-process drain requires a window where incoming client requests must be paused or rejected while the server binary or python environment reloads. +2. **Single point of failure:** Infrastructure issues, process crashes, or unhandled host-level terminations immediately disconnect active LLM sessions and leave transient workflows incomplete. +3. **Multi-agent concurrency bottlenecks:** High volumes of concurrent multi-LLM tasks put all lock management, lease allocation, and Gitea API interactions through a single process event loop. + +To achieve true zero-disruption operation and seamless rolling deployments without stopping active work, the system requires a high-availability (HA), multi-instance MCP architecture. + +--- + +## 2. Architectural Principles & Non-Goals + +### 2.1 Core Architectural Principles +* **Gitea as Canonical Work SoT:** Gitea remains the ultimate System of Record (SoT) for issue states, pull requests, labels, and audit comments. The MCP control plane does not duplicate domain entities. +* **Control-Plane DB as Multi-Instance State Substrate:** The control-plane SQLite/durable database ([#613](https://gitea.prgs.cc/Scaled-Tech-Consulting/Gitea-Tools/issues/613)) acts as the single source of truth for workflow leases, session tokens, assignment records, and lock fences across all MCP nodes. +* **Stateless Worker Nodes:** MCP role server processes (`gitea-author`, `gitea-reviewer`, `gitea-merger`, `gitea-reconciler`, `gitea-controller`) maintain no unique in-memory state; any node can handle any request given a valid session resume token. +* **Fail-Closed Split-Brain Defense:** In any network partition or quorum loss scenario, nodes must fail closed rather than risk double-mutations or conflicting Gitea states. + +### 2.2 Non-Goals +* **Replacing Gitea:** We do not replace Gitea issue/PR tracking with an independent database. +* **Immediate Multi-Node Cluster Execution in v1:** This ADR defines the target architecture and phased roadmap; immediate implementation occurs incrementally post-[#655] v1. + +--- + +## 3. High-Availability & Rolling-Restart Architecture + +### 3.1 Architecture Overview + +``` + +----------------------------+ + | LLM Clients / IDE Sessions | + +--------------+-------------+ + | + v + +----------------------------+ + | HA Proxy / Router | + | (Health-based & Affinity) | + +------+--------------+------+ + | | + +--------------+ +--------------+ + v v + +--------------------+ +--------------------+ + | MCP Instance Node A| | MCP Instance Node B| + | (Version N) | | (Version N+1) | + +---------+----------+ +---------+----------+ + | | + +----------------------+----------------------+ + | + v + +----------------------------+ + | Control-Plane DB Substrate| + | (Shared Lease & Locks) | + +--------------+-------------+ + | + v + +----------------------------+ + | Gitea API | + +----------------------------+ +``` + +--- + +### 3.2 Key System Components + +#### A. Multiple MCP Instance Cohorts +* The control plane runs across $N \ge 2$ redundant process nodes. +* Dual-namespace deployment allows running the old version (Node A) alongside a updated version (Node B) during rolling upgrades. + +#### B. Shared Durable Session Storage & Resume Tokens +* Session context, preflight verification proofs, and capability resolution states are stored in the shared control-plane database. +* Client requests carry an explicit `session_id` and `resume_token`. If an MCP instance restarts or a request routes to a different instance, the target node validates the token against the database without requiring full session re-initialization. + +#### C. Shared Lease Authority & Fencing Counters +* Workflow leases (`gitea_allocate_next_work`, `gitea_adopt_workflow_lease`) use monotonic fencing tokens (`lease_generation_id`). +* When Node B acquires or renews a lease, it increments the generation counter. Any delayed or out-of-order write attempt from Node A using an older generation token is rejected by database constraints. + +#### D. Leader Election & Coordinated Drain +* Node clusters elect a primary coordinator node for administrative background tasks (such as stale lease cleanup or incident Watchdogs). +* During a rolling deployment: + 1. Node B (new version) is launched and registers as healthy. + 2. Router directs new session creations to Node B. + 3. Node A enters `MAINTENANCE_DRAIN` status ([#659](https://gitea.prgs.cc/Scaled-Tech-Consulting/Gitea-Tools/issues/659)), completing in-flight mutations while refusing new tasks. + 4. Once all active sessions migrate or complete, Node A shuts down cleanly. + +#### E. Idempotent Mutations & Failover Safety +* All state-changing tool executions (PR creation, review submission, merge operations, label changes) carry a deterministic `idempotency_key`. +* If a network connection flaps or a node fails mid-mutation, the re-issued request with the same `idempotency_key` is recognized by the control-plane substrate, returning the existing recorded result without repeating side effects on Gitea. + +#### F. Schema Version Compatibility +* Database migrations follow non-breaking additive patterns. +* During rolling upgrades where Node A (Version $N$) and Node B (Version $N+1$) run concurrently, both versions operate against the shared schema without structural conflicts. + +--- + +## 4. Split-Brain & Failure Behavior + +### 4.1 Split-Brain Risk Scenarios & Mitigation + +| Scenario | Risk | Mitigation Strategy | +|---|---|---| +| **Network Partition between Nodes** | Both Node A and Node B attempt to process operations for the same issue/PR. | **Generation Fencing:** Lease renewal requires updating the DB generation counter. The node isolated from the DB fails closed immediately. | +| **Stale Node Recovery** | Node A recovers after a long pause and executes a queued mutation. | **Lease Expiry & TTL Fencing:** Transactions verify that `expires_at > NOW()` within the atomic SQLite transaction boundaries. | +| **Database Connection Loss** | Node loses access to shared control-plane DB substrate. | **Strict Fail-Closed:** The node immediately marks all task capabilities as `blocked` and rejects mutation tools until DB connectivity is re-established. | + +--- + +## 5. Phased Implementation Milestones + +```mermaid +flowchart TD + M1[Milestone 1: Shared Control-Plane DB Schema & Resume Tokens] --> M2[Milestone 2: Idempotent Mutation Layer] + M2 --> M3[Milestone 3: Health Routing & Standby Failover] + M3 --> M4[Milestone 4: Active-Active Rolling Deployment & Auto-Drain] +``` + +### Milestone 1: Shared Control-Plane DB Schema & Resume Tokens (Post-#655) +* Extend [#613] Control-Plane DB schema to store multi-instance node heartbeat records and session resume tokens. +* Enable session lookup across instances via `session_id`. + +### Milestone 2: Idempotent Mutation Layer & Lease Fencing +* Add mandatory `idempotency_key` tracking to all Gitea mutation tools. +* Implement monotonic lease fencing counters in `gitea_allocate_next_work` and `gitea_adopt_workflow_lease`. + +### Milestone 3: Health-Based Routing & Active-Passive Standby +* Introduce lightweight proxy/router capable of checking node health endpoints. +* Implement active-standby failover where standby node automatically assumes work if active node fails health checks. + +### Milestone 4: Active-Active Horizontal Deployment & Rolling Upgrade Automation +* Enable true active-active multi-instance execution. +* Integrate automated zero-downtime rolling upgrades coordinated with `gitea_request_mcp_restart` maintenance drain. + +--- + +## 6. Observability & Audit Requirements + +High-availability control plane operations must expose clear telemetry and audit trails: + +* **Node Registry Telemetry:** Active nodes, version numbers, uptime, and heartbeat timestamps reported via `gitea_get_runtime_context`. +* **Lease Fencing Metrics:** Tracking lease acquire latency, fence rejection counts, and lease handoff durations. +* **Failover & Re-route Audit Logs:** Durable logging of session migrations between nodes, drain initiation, and process retirement events. + +--- + +## 7. Tradeoffs & Accepted Risks + +* **Increased Architectural Complexity:** Moving from a single process to a multi-instance control plane requires robust DB locking, proxy routing, and migration governance. +* **Database Dependency:** The control-plane database substrate becomes a critical shared dependency for multi-node deployments. High availability for the underlying SQLite file system / DB must be guaranteed. diff --git a/docs/author-issue-lock-contract.md b/docs/author-issue-lock-contract.md new file mode 100644 index 0000000..0fb51c6 --- /dev/null +++ b/docs/author-issue-lock-contract.md @@ -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. diff --git a/docs/incidents/670-direct-master-commit-2fa97c26.md b/docs/incidents/670-direct-master-commit-2fa97c26.md new file mode 100644 index 0000000..b0b7ca3 --- /dev/null +++ b/docs/incidents/670-direct-master-commit-2fa97c26.md @@ -0,0 +1,83 @@ +# Incident #670: bare direct-to-master commit `2fa97c26` (retroactive audit) + +Status: verified; disposition recommendation: **accept as-is, no revert** (final +disposition owned by controller per issue #670). + +## Summary + +Commit `2fa97c26fbda555a1a83930ca5fdcea9d8e47b50` +(`fix(mcp): load dotenv relative to project root`) landed on `prgs/master` +as a single-parent commit with no PR wrapper and no review record, bypassing +the sanctioned issue → branch → PR → review → merge workflow. It was +discovered during the PR #654 post-merge audit. PR #654 itself merged +cleanly via the Gitea API and did **not** introduce this commit. + +## Verification evidence (acceptance criteria 1–3) + +- **AC1 — present on `prgs/master`: yes.** + `git merge-base --is-ancestor 2fa97c26fbda555a1a83930ca5fdcea9d8e47b50 prgs/master` → true. +- **AC2 — no PR or review record: confirmed.** + The commit is a single-parent, non-merge commit sitting directly on + first-parent master between the #629 merge (`5ab5fe85`) and the #654 + merge (`ec903b0d`). A PR landing on master produces a merge commit (or a + PR-linked head); neither exists here. The controller audit at issue-create + time also found no PR wrapper and no review record for this SHA. +- **AC3 — changed files and diff summary: confirmed.** + `gitea_auth.py | 5 +++--` (+3/−2). Single parent + `5ab5fe8583c07134d55dadf09381aecb67df246e`. The change moves + `PROJECT_ROOT` derivation above `load_dotenv()` and loads + `.env` relative to the project root instead of the process CWD. + +## AC4 — why no immediate revert + +- The dotenv fix is intentional and required for correct runtime behavior: + without it, `load_dotenv()` resolves `.env` against the process working + directory, which breaks MCP server launches whose CWD is not the project + root. +- The change is small (+3/−2), self-contained in `gitea_auth.py`, and has + been running on master without incident since 2026-07-10. +- Reverting would re-introduce a real bug to remove a provenance defect — + the wrong trade. Provenance is repaired retroactively by this document, + issue #670, and the hardening landed under #671. +- If the controller later judges the change unsafe, a separate + revert/repair issue is the sanctioned path (issue #670, recommended + disposition option 4). + +## AC5 — workflow-hardening linkage + +Prevention already landed: **issue #671** (closed) +*“Block direct pushes to stable branches from MCP workflow sessions”*, +implemented by commit `5933d87647656643a67a50331c4c7b06ea751dad` +(`feat(guard): block direct stable-branch pushes from MCP workflow sessions`). + +Shipped guardrails include: + +- `gitea_record_stable_branch_push_attempt` — classifies proposed commands + for direct stable-branch push intent (`git push master`, + refspecs, `HEAD:master`, `--force`, dry-run intent, `:master` delete), + plus root/control-checkout local commits not carried by an issue branch, + and writes a durable `stable_branch_contamination` marker. +- `gitea_audit_stable_branch_contamination` — reconciler-only audit/clear + path; a contaminated worker session cannot self-clear. +- Review/merge/close/completion mutations fail closed while a + contamination marker is active. + +## AC6 — PR #654 was not the source + +- `2fa97c26` is the **first parent** of the #654 merge commit + `ec903b0d619e7a27d24aed272a890f4e5d381411`; it predates the #654 merge. +- First-parent history `5ab5fe8..ec903b0`: + `2fa97c2 fix(mcp): load dotenv relative to project root` followed by + `ec903b0 Merge pull request 'feat: lifecycle role/hazard labels ... (#603)' (#654)`. +- The #654 merger audit confirmed `ec903b0d` was a valid Gitea-API merge, + the `git push prgs master` attempt during that run was a no-op, and the + net change `2fa97c2..ec903b0` contained only the reviewed #603 + lifecycle-label files. +- Conclusion: #654 merged reviewed content only; the unauthorized-path + defect is solely the earlier bare commit `2fa97c26`. + +## Explicit non-actions (unchanged by this audit) + +- No revert of `2fa97c26`. +- No force-push or history rewrite. +- No master mutation from the audit session. diff --git a/docs/mcp-config-drift-runbook.md b/docs/mcp-config-drift-runbook.md new file mode 100644 index 0000000..93001a3 --- /dev/null +++ b/docs/mcp-config-drift-runbook.md @@ -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. diff --git a/docs/mcp-namespace-eof-recovery.md b/docs/mcp-namespace-eof-recovery.md index 7fd8ef2..aa854c7 100644 --- a/docs/mcp-namespace-eof-recovery.md +++ b/docs/mcp-namespace-eof-recovery.md @@ -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. - 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 / - client MCP-reconnect action for that server entry (in Claude Code: - `/mcp` → reconnect the affected `gitea-*` server). Reconnecting forces the - 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**. +2. **Request the sanctioned reconnect surface (#678), then reconnect through + the client — not the shell.** From a still-reachable Gitea MCP namespace + (or after host auto-reconnect), call: + + ```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 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** 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 - 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 the specific tool the blocked workflow needs — not just any tool — through @@ -153,7 +168,19 @@ not a tool argument: a session must never be able to authorize itself. ## Related - #630 — manual daemon killing as contaminated recovery (this contrast, enforced). +- #657 — restart-path inventory and daemon classification. +- #686 — manual server launch detection & fail-closed provenance gate. - #531 / #544 — stale-runtime detection (`ps`-based); sibling failure mode. - #558 / `docs/mcp-daemon-import-guard.md` — why shell imports are not a repair. - `docs/mcp-client-registration.md` — per-server registration contract. - `docs/mcp-namespace-health.md` — probe sources and mutation enforcement. + +## Sanctioned reconnect vs forbidden manual launch (#686) + +In addition to manual process killing (#630), manually launching a duplicate role server from an ad hoc shell (`python3 mcp_server.py`) is forbidden and fail-closed: + +- **Why manual launches are unsupported:** A terminal-launched `mcp_server.py` holds its own stdio transport; it can never bind to the IDE client's stdio pipes. It cannot restore a dropped IDE namespace, and a manual duplicate process masks stale client-managed runtimes for that profile, defeating stale-runtime gates. +- **Sanctioned path:** Supported recovery is IDE/client-managed reconnect only (`/mcp reconnect`, IDE restart, or sanctioned reconnect exposure). +- **Fail-closed enforcement (#686):** Mutating tools on a server lacking client-managed launch provenance (`GITEA_CLIENT_MANAGED=1`) refuse execution fail-closed with typed blocker `unsupported_manual_launch` and an exact next action. Unsupported `GITEA_*` env overrides (e.g. `GITEA_DUMMY`) are surfaced in diagnostics rather than silently ignored. +- **Inventory & staleness:** Staleness diagnostics ignore non-client-managed duplicates when evaluating runtime freshness and inventory duplicate processes per profile (#657, #686). + diff --git a/docs/mcp-recovery-playbook.md b/docs/mcp-recovery-playbook.md new file mode 100644 index 0000000..b589b56 --- /dev/null +++ b/docs/mcp-recovery-playbook.md @@ -0,0 +1,94 @@ +# MCP scoped recovery playbook (#669) + +**Parent:** [#655](https://gitea.prgs.cc/Scaled-Tech-Consulting/Gitea-Tools/issues/655) +**Vision / roadmap:** [#652](https://gitea.prgs.cc/Scaled-Tech-Consulting/Gitea-Tools/issues/652) · [#653](https://gitea.prgs.cc/Scaled-Tech-Consulting/Gitea-Tools/issues/653) +**Class matrix:** [#663](https://gitea.prgs.cc/Scaled-Tech-Consulting/Gitea-Tools/issues/663) · `docs/mcp-restart-classes.md` +**Coordinator:** [#658](https://gitea.prgs.cc/Scaled-Tech-Consulting/Gitea-Tools/issues/658) · `restart_coordinator.py` +**Audit lineage:** [#665](https://gitea.prgs.cc/Scaled-Tech-Consulting/Gitea-Tools/issues/665) + +## Decision + +Full-server MCP reset is a **last resort**. Prefer the narrowest recovery that +can clear the symptom. The coordinator **refuses** `rolling_mcp_restart`, +`full_mcp_restart`, and `host_restart` unless: + +1. The inventory carries a prior **attempt log** of at least one *insufficient* + narrower recovery, **or** +2. **Break-glass** is authorized + (`request_break_glass` + `GITEA_BREAKGLASS_RESTART_AUTHORIZATION`). + +Break-glass still never bypasses the #663 class matrix (role/permission). + +## Ladder (narrow → broad) + +| Rank | Action | Self-service | Implementation / delegation | +|---:|---|---|---| +| 0 | `client_reconnect` | yes | Host auto-reconnect / client reconnect · [#584](https://gitea.prgs.cc/Scaled-Tech-Consulting/Gitea-Tools/issues/584) · `docs/mcp-namespace-eof-recovery.md` | +| 1 | `capability_refresh` | yes | `gitea_resolve_task_capability` + `gitea_whoami` · [#610](https://gitea.prgs.cc/Scaled-Tech-Consulting/Gitea-Tools/issues/610) · [#685](https://gitea.prgs.cc/Scaled-Tech-Consulting/Gitea-Tools/issues/685) | +| 2 | `session_reconnect` | yes | Runtime rebind + explicit `worktree_path` · [#543](https://gitea.prgs.cc/Scaled-Tech-Consulting/Gitea-Tools/issues/543) · [#618](https://gitea.prgs.cc/Scaled-Tech-Consulting/Gitea-Tools/issues/618) | +| 3 | `configuration_reload` | no | Class `configuration_reload` · console reload · [#642](https://gitea.prgs.cc/Scaled-Tech-Consulting/Gitea-Tools/issues/642) | +| 4 | `lease_recovery` | no | Lock/lease recovery paths · [#702](https://gitea.prgs.cc/Scaled-Tech-Consulting/Gitea-Tools/issues/702) · [#753](https://gitea.prgs.cc/Scaled-Tech-Consulting/Gitea-Tools/issues/753) · [#790](https://gitea.prgs.cc/Scaled-Tech-Consulting/Gitea-Tools/issues/790) | +| 5 | `worker_restart` | no | Class `worker_restart` · #663 | +| 6 | `role_runtime_restart` | no | Class `role_runtime_restart` · console restart · #642/#663 | +| 7 | `connector_restart` | no | Class `connector_restart` · #663 | +| 8 | `rolling_mcp_restart` | no | Class `rolling_mcp_restart` · design [#668](https://gitea.prgs.cc/Scaled-Tech-Consulting/Gitea-Tools/issues/668) · **attempt log required** | +| 9 | `full_mcp_restart` | no | Class `full_mcp_restart` · **attempt log required** | +| 10 | `host_restart` | no | Class `host_restart` · **attempt log required** | + +Machine-readable source of truth: `recovery_playbook.RECOVERY_LADDER` and +`recovery_playbook.ladder_document()`. + +## Attempt log shape + +Each prior attempt is a mapping: + +```json +{ + "action": "client_reconnect", + "outcome": "insufficient", + "reason": "transport still closed after IDE reconnect", + "actor": "prgs-controller-12345", + "recorded_at": "2026-07-25T21:00:00+00:00" +} +``` + +Outcomes that count toward escalation: `failed`, `insufficient`, `denied`, +`unresolved`, `timeout`, `error`. + +Pass attempts into the coordinator via inventory +`prior_recovery_attempts` or the MCP tool argument +`prior_recovery_attempts_json` on `gitea_request_mcp_restart`. + +Helper: `recovery_playbook.build_attempt_record(...)`. + +## Symptom → first rung + +`recovery_playbook.recommend_actions(symptoms=[...])` maps symptoms such as +`transport_eof`, `stale_capability`, `stale_lease`, `daemon_corrupt` to the +narrowest recommended action, then walks the ladder. Soft recommendations +never replace the hard gate on broad restarts. + +## Enforcement points + +1. **`recovery_playbook.assess_escalation`** — pure gate. +2. **`restart_coordinator.evaluate_restart_impact`** — when `restart_class` is + set (policy-enforced path), broad classes require the gate; report fields + `attempt_log_satisfied`, `playbook_escalation`, `break_glass`. +3. **`gitea_request_mcp_restart`** — accepts attempt JSON and env-authorized + break-glass; never restarts a process. + +## Metrics + +`recovery_playbook.recovery_metrics(attempts)` reports the fraction of +successful recoveries that avoided full/host restart +(`fraction_avoided_full_restart`). + +## Non-goals + +* HA multi-instance execution (#668 design only here). +* Normalizing `pkill` (#630 contamination stays forbidden). +* Silent mutation of leases or processes from the playbook itself. + +## Manual process kills + +Remain forbidden and contaminating (#630). The playbook never recommends them. diff --git a/docs/mcp-restart-coordinator.md b/docs/mcp-restart-coordinator.md index 335f78b..921c4af 100644 --- a/docs/mcp-restart-coordinator.md +++ b/docs/mcp-restart-coordinator.md @@ -96,12 +96,18 @@ gitea_request_mcp_restart(remote, host, org, repo, target_session_id=None, target_role=None, target_connector=None, drain_proof_json=None, - request_break_glass=False) + request_break_glass=False, + prior_recovery_attempts_json=None) ``` It **never restarts anything**: `apply_supported` is always `false` and `restart_performed` is always `false`. +`prior_recovery_attempts_json` (#669) is an optional JSON array of prior +narrow recovery attempts. Rolling / full / host classes require at least one +*insufficient* narrower attempt (or authorized break-glass). See +`docs/mcp-recovery-playbook.md`. + ### Dry-run versus apply | Call | Behavior | @@ -113,9 +119,10 @@ It **never restarts anything**: `apply_supported` is always `false` and An apply requires **both** authorizations, and they are independent: -1. **Restart-class authorization** (#663) — the requester's role and permissions - must allow the requested class, the class's approval requirement must be - satisfied, and any target-scoped class must name its target. Failing any of +1. **Restart-class authorization** (#663 / #669) — the requester's role and + permissions must allow the requested class, the class's approval requirement + must be satisfied, any target-scoped class must name its target, and broad + classes must satisfy the recovery-playbook attempt-log gate. Failing any of these makes `allow_restart` `false`. 2. **Drain-proof gate** (#661) — a valid, unexpired, clean proof bound to the current impact fingerprint, or an authorized break-glass. @@ -128,7 +135,10 @@ the authorization that produced it. ### Break-glass -Break-glass bypasses the **drain proof only** — never the restart-class matrix. +Break-glass bypasses the **drain proof only** — never the restart-class matrix +(role/permission). Separately, authorized break-glass also satisfies the #669 +attempt-log requirement for broad restarts (rolling/full/host), because that +gate is not a class-matrix permission check. It is honoured solely when `request_break_glass` is set *and* the environment carries `GITEA_BREAKGLASS_RESTART_AUTHORIZATION`; like operator override, the tool argument expresses caller intent and cannot be self-asserted by a worker diff --git a/docs/mcp-restart-path-inventory.md b/docs/mcp-restart-path-inventory.md index c880269..f88b81c 100644 --- a/docs/mcp-restart-path-inventory.md +++ b/docs/mcp-restart-path-inventory.md @@ -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 | | `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 | -| `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 ` 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 | -| `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 | ## Guards enforced in CI diff --git a/docs/mcp-tool-inventory.md b/docs/mcp-tool-inventory.md index 66094ec..e93d359 100644 --- a/docs/mcp-tool-inventory.md +++ b/docs/mcp-tool-inventory.md @@ -103,6 +103,7 @@ that gates each call, not which tools exist. - `gitea_get_shell_health` - `gitea_heartbeat_issue_lock` - `gitea_heartbeat_reviewer_pr_lease` +- `gitea_inspect_issue_lock_contract` - `gitea_inspect_workflow_lease` - `gitea_issue_irrecoverable_provenance_authorization` - `gitea_list_dependency_edges` @@ -134,9 +135,11 @@ that gates each call, not which tools exist. - `gitea_record_pre_review_command` - `gitea_record_shell_spawn_outcome` - `gitea_record_stable_branch_push_attempt` +- `gitea_recover_incomplete_bootstrap_lock` - `gitea_release_merger_pr_lease` - `gitea_release_reviewer_pr_lease` - `gitea_release_workflow_lease` +- `gitea_request_mcp_reconnect` - `gitea_request_mcp_restart` - `gitea_resolve_task_capability` - `gitea_resume_review_draft` diff --git a/docs/observability/webui-observability-console.md b/docs/observability/webui-observability-console.md new file mode 100644 index 0000000..84ace13 --- /dev/null +++ b/docs/observability/webui-observability-console.md @@ -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. diff --git a/docs/remote-mcp/coupling-inventory.md b/docs/remote-mcp/coupling-inventory.md new file mode 100644 index 0000000..6f09a45 --- /dev/null +++ b/docs/remote-mcp/coupling-inventory.md @@ -0,0 +1,230 @@ +# Remote-MCP coupling inventory + +Every place the Gitea MCP server depends on being a local, client-spawned, stdio-attached +process on the operator's machine. + +- **Issue:** #930 (Remote-MCP 01), child 1 of epic #929. +- **Generated against commit:** `7bf4f1258451823a55b36d2157e74f8457165088` (`master`). +- **Anchors:** every `file:line` below resolves at the commit above and at the commit that + adds this document. This change adds one new file and edits no existing file, so no + existing line number shifts between the two. +- **Scope:** documentation only. No server behavior changes in this child. + +## How to read an entry + +| Field | Meaning | +| ----- | ------- | +| **Anchor** | `file:line` at the commit under review. | +| **Assumes today** | What the code takes for granted while running as a local stdio process. | +| **Observes remotely** | What the same code would actually see on a shared remote host. | +| **Class** | One of: *portable as written*, *needs a seam*, *needs a replacement*, *cannot be remote*. | +| **Owner** | Exactly one epic child (#931–#939) responsible for the fix. | + +Classification meanings: + +- **portable as written** — the code is already transport-, host-, and principal-neutral; it + moves unchanged once its inputs are supplied by a remote-aware caller. +- **needs a seam** — the logic is correct but is wired to a hard-coded local source. It needs + an injection point, not new semantics. +- **needs a replacement** — the semantics themselves are local-only. A remote deployment + needs a differently-defined mechanism, not the same mechanism relocated. +- **cannot be remote** — the operation is inherently about the operator's own machine + (its process table, its keychain, its checkout). It must either stay local behind an + explicit boundary or be deleted from the remote surface. + +--- + +## 1. Transport bind + +The transport is bound literally, once, at process start, and the bound value is the root of +the mutation-authorization chain. + +| ID | Anchor | Assumes today | Observes remotely | Class | Owner | +| -- | ------ | ------------- | ----------------- | ----- | ----- | +| T1 | `gitea_mcp_server.py:23750` | The single production bind call passes the literal `transport="stdio"` immediately before the server loop. | The literal is wrong for any non-stdio deployment; there is no parameter to change it. | needs a seam | #931 | +| T2 | `mcp_daemon_guard.py:45` | `_PRODUCTION_TRANSPORTS = frozenset({"stdio"})` is the closed allowlist of production transports. | A remote transport name is rejected by the allowlist before any other check runs. | needs a seam | #931 | +| T3 | `mcp_daemon_guard.py:174` | `bind_native_mcp_transport` raises `UnsanctionedRuntimeError` for any transport outside `_PRODUCTION_TRANSPORTS` (raise at `mcp_daemon_guard.py:187`). | The remote server fails to start rather than degrading; the failure is correct, but the allowlist is the only thing that must change. | needs a seam | #931 | +| T4 | `mcp_daemon_guard.py:328` | `is_native_mcp_transport()` asserts a process-local runtime record whose `pid` matches `os.getpid()` and whose phase is `transport_bound`. The predicate itself names no transport. | Unchanged semantics: one server process that bound one transport. It stays true on a remote host. | portable as written | #931 | +| T5 | `mcp_daemon_guard.py:349` | `is_production_native_mcp_transport()` adds only a `mode == production` check on top of T4. | Unchanged. | portable as written | #931 | +| T6 | `irrecoverable_provenance.py:497` | `assess_transport_for_auth_mint()` requires production native transport before minting non-forgeable recovery authorization (#709 F1). | The gate is transport-agnostic in form, but its guarantee — "an ordinary Python process cannot reach this" — is currently underwritten by the stdio bind. Under a remote transport the guarantee must be re-derived from the authenticated session, not from the bind. | needs a seam | #931 | +| T7 | `gitea_mcp_server.py:8375` | Consumer: refuses to proceed unless `assess_transport_for_auth_mint()` allows. | Unchanged given a corrected T6. | portable as written | #931 | +| T8 | `gitea_mcp_server.py:8624` | Second consumer of the same gate on the confirmation path. | Unchanged given a corrected T6. | portable as written | #931 | +| T9 | `mcp_server.py:4` | Module docstring asserts "Runs over stdio." as a property of the server. | The stated contract becomes false on the remote deployment and is load-bearing documentation for operators. | needs a replacement | #931 | + +## 2. Launch provenance + +Mutations fail closed unless the process can prove a client launched it with real stdio pipes +and `GITEA_CLIENT_MANAGED` provenance. Every proof in this section is a statement about the +local operating system. + +| ID | Anchor | Assumes today | Observes remotely | Class | Owner | +| -- | ------ | ------------- | ----------------- | ----- | ----- | +| P1 | `gitea_mcp_server.py:14588` | `_is_client_managed_process()` derives provenance from `GITEA_CLIENT_MANAGED` / `GITEA_MCP_CLIENT_MANAGED` / `GITEA_SERVER_PROVENANCE` / `GITEA_FORCE_CLIENT_MANAGED` on this process's own environment. | A long-lived remote process has one environment for all callers, so a per-process env var can no longer say anything about the caller that issued a request. | needs a replacement | #934 | +| P2 | `gitea_mcp_server.py:14606` | Falls back to `sys.stdin.isatty()`: an active TTY on stdin means a human launched it from a terminal, so refuse. | A remote server has no meaningful stdin. The signal is absent, not merely different. | cannot be remote | #934 | +| P3 | `gitea_mcp_server.py:14618` | `_provenance_mutation_block()` emits `blocker_kind: "unsupported_manual_launch"` and a "reconnect the IDE/client-managed MCP namespace" remediation. | The block shape is reusable; its predicate and its remediation text are both stdio-specific. | needs a seam | #934 | +| P4 | `gitea_mcp_server.py:20599` | `_check_mcp_runtimes_diagnostics()` shells `ps -o pid,lstart,command -ax` and greps for `mcp_server.py` to find peer role servers. | On a shared host the process table lists unrelated tenants' processes, or none at all under a container. Peer discovery by `ps` has no remote meaning. | cannot be remote | #934 | +| P5 | `gitea_mcp_server.py:20702` | More than one process per `GITEA_MCP_PROFILE` in the local process table is reported as a duplicate-launch fault. | A remote endpoint is expected to serve many concurrent sessions per role. "Two processes for one role" becomes the normal case, so the check inverts from a safety net into a false wall. | cannot be remote | #934 | +| P6 | `gitea_mcp_server.py:20715` | Processes lacking client-managed provenance are ignored for runtime freshness and reported as manual launches. | Same defect as P5: correctness depends on enumerating local peers. | cannot be remote | #934 | +| P7 | `gitea_config.py:1172` | `RECOGNIZED_GITEA_ENV_KEYS` is the allowlist of `GITEA_*` env vars a legitimately launched server may carry; anything else is contamination. | Configuration on a remote host arrives from deployment tooling, not from a client-authored env block. The allowlist keeps working mechanically but stops proving anything about provenance. | needs a replacement | #934 | +| P8 | `gitea_mcp_server.py:20683` | The unsupported-env scan applies `RECOGNIZED_GITEA_ENV_KEYS` to *other* processes' environments harvested via `ps eww `. | Reading another process's environment is unavailable or prohibited across tenants, and is not exposed in this form outside macOS/BSD `ps`. | cannot be remote | #934 | +| P9 | `mcp_daemon_guard.py:126` | `mark_sanctioned_daemon()` requires the claiming stack frame's resolved absolute path to be the canonical `mcp_server.py` / `gitea_mcp_server.py` next to the guard module; basename spoofing is rejected. | Entrypoint-path identity still exists on a remote host, but it authenticates the *deployment*, not the *caller*. It must be kept and demoted from "authorizes mutations" to "authorizes the process". | needs a seam | #934 | +| P10 | `gitea_config.py:1233` | The client-config generator emits `"GITEA_CLIENT_MANAGED": "1"` into each generated MCP client entry, alongside `GITEA_MCP_CONFIG` / `GITEA_MCP_PROFILE`. | A remote endpoint is addressed by URL and credential, not by a spawn command with an env block. This generator produces the wrong artifact entirely. | needs a replacement | #938 | +| P11 | `mcp_namespace_health.py:232` | Namespace health classifies a namespace as `client_managed` or `manual_launch` from the reported env summary. | During dual-run, local and remote namespaces coexist and must both be classifiable; a two-valued local/manual axis cannot express "remote endpoint, authenticated session". | needs a replacement | #939 | +| P12 | `gitea_mcp_server.py:18161` | The diagnostics payload reports `server_provenance` as exactly `"client_managed"` or `"manual_launch"`. | This is the field a cutover operator reads to confirm which deployment served a call. It must gain a remote value before dual-run parity can be validated. | needs a replacement | #939 | + +## 3. Role binding + +Role separation is currently enforced by *which process a call reaches*. The process is pinned +to one role for its lifetime by an environment variable. + +| ID | Anchor | Assumes today | Observes remotely | Class | Owner | +| -- | ------ | ------------- | ----------------- | ----- | ----- | +| R1 | `gitea_config.py:54` | `ENV_PROFILE = "GITEA_MCP_PROFILE"` is the single source of the active profile, read from the process environment. | One shared process serves several principals; a process-wide profile cannot answer "who is calling now". This is the root of the coupling. | needs a replacement | #932 | +| R2 | `review_workflow_load.py:95` | Reads `GITEA_MCP_PROFILE` directly to decide the reviewer workflow binding. | Reads the deployment's profile, not the caller's, silently granting or denying the wrong role. | needs a replacement | #932 | +| R3 | `mcp_discoverability.py:152` | Reads `GITEA_MCP_PROFILE` to describe the namespace to the client. | Correct logic, wrong input source; it needs the request principal injected. | needs a seam | #932 | +| R4 | `webui/deployment_boundary.py:115` | Reads `GITEA_MCP_PROFILE` to classify the deployment boundary for the console. | Same as R3. | needs a seam | #932 | +| R5 | `gitea_mcp_server.py:21106` | Remediation text instructs the operator to "Relaunch the server with `GITEA_MCP_PROFILE` set to a profile that has the required permission". | Relaunching a shared remote endpoint to change one caller's role is not a valid instruction; it would re-role every other session. | needs a replacement | #932 | +| R6 | `native_mcp_preference.py:93` | Detects shell commands that override `GITEA_MCP_PROFILE` away from the session (`native_mcp_preference.py:223`) and flags them as CLI auth divergence. | The divergence check is genuinely useful and survives, but its notion of "the session's profile" must come from the request principal. | needs a seam | #932 | +| R7 | `gitea_mcp_server.py:20671` | Recovers a peer server's role by regexing `GITEA_MCP_PROFILE=` out of that process's environment. | Depends on P4/P8 process-table access; role discovery by peer-env scraping has no remote analogue. | cannot be remote | #932 | + +## 4. Credentials + +Every token resolves, directly or indirectly, from one human's macOS keychain. + +| ID | Anchor | Assumes today | Observes remotely | Class | Owner | +| -- | ------ | ------------- | ----------------- | ----- | ----- | +| C1 | `gitea_config.py:956` | `_keychain_token()` shells `security find-generic-password -s -w`. | `security(1)` is a macOS binary reading the calling user's login keychain. It does not exist on a Linux host and would be the wrong identity even on a shared Mac. | cannot be remote | #933 | +| C2 | `gitea_config.py:974` | `resolve_token(profile, keychain_lookup=_keychain_token)` dispatches on `auth.type` of `env` or `keychain`, defaulting the lookup to C1. | The injectable `keychain_lookup` parameter is the existing seam; a remote credential provider plugs in here without changing the dispatch. | needs a seam | #933 | +| C3 | `gitea_config.py:1015` | `keychain_auth(item_id)` constructs the `{"type": "keychain", "id": ...}` reference stored in profiles. | The reference type itself encodes "macOS keychain" into persisted config. A remote provider needs a new auth reference type, not a new value of this one. | needs a replacement | #933 | +| C4 | `mcp_daemon_guard.py:440` | `assert_keychain_access_allowed()` fails closed for git-credential keychain fill outside a sanctioned daemon, with an operator opt-out env var. | The gate protects a mechanism that will not exist remotely. Its replacement must gate the *credential provider* call, not the keychain call, or the protection silently lapses. | needs a replacement | #933 | +| C5 | `sentry_incident_bridge.py:190` | `resolve_token(env)` resolves the Sentry token from an injected env mapping with no keychain path. | Already host-neutral; it is the shape the Gitea credential path should converge on. | portable as written | #933 | +| C6 | `gitea_mcp_server.py:18469` | The profile-audit tool calls `gitea_config.resolve_token(p)` for every configured profile to report "credentials present" without networking. | On a remote host this would materialize every principal's credential inside one process — an audit surface that becomes a credential-aggregation risk. | needs a seam | #933 | + +## 5. Runtime freshness + +The mutation gate is defined as "the commit this process started at matches the checkout on +this disk, and both match live master". Two of those three terms are local-disk facts. + +| ID | Anchor | Assumes today | Observes remotely | Class | Owner | +| -- | ------ | ------------- | ----------------- | ----- | ----- | +| F1 | `master_parity_gate.py:168` | `capture_startup_parity(root)` reads git `HEAD` from the server's own root once at startup and returns it as the baseline. | A remote host carries a deployed artifact, not the operator's checkout. Its `HEAD` says nothing about the operator's working tree, which is the thing the gate exists to protect. | cannot be remote | #935 | +| F2 | `master_parity_gate.py:255` | `mutation_safe = determinable and in_parity and live_known and not live_stale` — a conjunction of two local-HEAD comparisons and one live-remote comparison. | Two of the three conjuncts lose meaning, so the whole verdict does. A remote deployment needs a redefined, testable freshness predicate rather than this one relocated. | needs a replacement | #935 | +| F3 | `master_parity_gate.py:164` | The live-remote head is probed and cached per `(root, remote, branch)`, keyed on the local root. | The live-remote probe is the one conjunct that survives; it needs a key that is not the operator's filesystem path. | needs a seam | #935 | +| F4 | `gitea_mcp_server.py:18262` | `gitea_assess_master_parity` publishes `startup_head` / `local_head` / `live_remote_head` / `mutation_safe` as the authoritative mutation-safety verdict. | The tool's contract is consumed by every mutation caller and by the operator; it must keep its shape while its semantics are redefined, or every consumer breaks at once. | needs a replacement | #935 | +| F5 | `gitea_mcp_server.py:23054` | Falls back to `_process_boot_head_sha` — the commit this process booted at — when the parity payload has no `startup_head`. | Same defect as F1, in a fallback path that is easy to miss when F1 is fixed. | needs a seam | #935 | +| F6 | `gitea_mcp_server.py:20615` | Staleness is also inferred from `os.path.getmtime()` of `gitea_mcp_server.py` under `PROJECT_ROOT` (`gitea_mcp_server.py:20611`), compared against peer process start times. | File mtime on a deployed artifact tracks the deploy, not the operator's edits, and the peer start times it is compared against come from the unavailable process table (P4). | cannot be remote | #935 | + +## 6. Local filesystem + +Author and reviewer tools act directly on the operator's checkout. + +| ID | Anchor | Assumes today | Observes remotely | Class | Owner | +| -- | ------ | ------------- | ----------------- | ----- | ----- | +| L1 | `gitea_mcp_server.py:10122` | `gitea_bootstrap_author_issue_worktree` creates and binds a git worktree on the server's own disk. | The remote host has no operator checkout to add a worktree to. Executing this remotely would act on the wrong disk while reporting success. | cannot be remote | #936 | +| L2 | `gitea_mcp_server.py:190` | `ACTIVE_WORKTREE_ENV = "GITEA_ACTIVE_WORKTREE"` and `AUTHOR_WORKTREE_ENV` (`gitea_mcp_server.py:191`) carry the active workspace as process-wide environment. | Process-wide workspace state cannot represent per-session workspaces on a shared endpoint. | needs a replacement | #936 | +| L3 | `gitea_mcp_server.py:9801` | Binding a worktree writes `os.environ["GITEA_AUTHOR_WORKTREE"]` and `os.environ["GITEA_ACTIVE_WORKTREE"]` (`gitea_mcp_server.py:9802`), mutating global process state. | One session's bind would silently retarget every other concurrent session in the same process. This is a correctness bug the moment concurrency is real. | needs a replacement | #936 | +| L4 | `reviewer_inventory_worktree.py:48` | `_BRANCHES_WORKTREE_RE = re.compile(r"\bbranches/", re.I)` requires review worktree paths to sit under `branches/`. | A path convention on the operator's machine, asserted as a validation rule. It needs to become a property of a declared workspace, not a substring test. | needs a seam | #936 | +| L5 | `stable_control_runtime.py:54` | `DEV_WORKTREE_SEGMENT = "branches"` classifies a process root as a development worktree by path segment. | Same class of assumption as L4, on the runtime-classification side. | needs a seam | #936 | +| L6 | `mcp_server.py:42` | `check_conflict_markers()` runs at import and `os.walk`s the install directory for unresolved conflict markers, `sys.exit(1)` on a hit. | On a remote host it scans a deployed artifact, which by construction never has conflict markers — so the guard passes trivially and stops protecting the thing it was written to protect. | needs a replacement | #936 | +| L7 | `role_session_router.py:487` | `check_mid_merge()` reports infra-stop from `.git/MERGE_HEAD`, `rebase-merge`, `rebase-apply` and a source conflict scan under the server's project root. | Same inversion as L6: it would report the deployment's git state, not the operator's. | needs a replacement | #936 | +| L8 | `author_issue_bootstrap.py:996` | Enumerates worktrees with `git -C worktree list --porcelain`. | Requires a real local clone with real worktrees; there is nothing equivalent to enumerate remotely. | cannot be remote | #936 | +| L9 | `mcp_server.py:10` | Redirects `sys.stderr` to the fixed path `/tmp/mcp_server_stderr.log` outside pytest. | A single fixed `/tmp` path is shared by every concurrent server on a host and is not a deployment's logging surface. | needs a replacement | #938 | +| L10 | `gitea_mcp_server.py:2314` | `ISSUE_LOCK_FILE = "/tmp/gitea_issue_lock.json"` — the legacy single global lock slot. | One global `/tmp` slot per host cannot represent concurrent remote sessions and is world-visible on a shared machine. | needs a replacement | #937 | +| L11 | `issue_lock_provenance.py:14` | `ISSUE_LOCK_FILE = os.environ.get("GITEA_ISSUE_LOCK_FILE", "/tmp/gitea_issue_lock.json")` keeps the same `/tmp` default in the provenance path. | Same as L10; the env override is a local escape hatch, not a remote design. | needs a replacement | #937 | + +## 7. Durable state + +Locks, leases, session state, and the control-plane database live in the operator's home +directory and are keyed on local PIDs. + +| ID | Anchor | Assumes today | Observes remotely | Class | Owner | +| -- | ------ | ------------- | ----------------- | ----- | ----- | +| S1 | `issue_lock_store.py:26` | `DEFAULT_LOCK_DIR = ~/.cache/gitea-tools/issue-locks` — per-issue lock files under one user's home. | A shared endpoint has no single operator home; per-user paths make locks invisible across sessions and hosts. | needs a replacement | #937 | +| S2 | `issue_lock_store.py:83` | `session_pointer_path()` names the session pointer file `session-.json`. | Many sessions share one PID on a remote server, so the pointer collapses to a single slot and sessions overwrite each other. | cannot be remote | #937 | +| S3 | `issue_lock_store.py:98` | `is_process_alive(pid)` decides lock liveness by probing the local process table. | A PID recorded by one host is meaningless on another, and may coincidentally match a live unrelated process. | cannot be remote | #937 | +| S4 | `issue_lock_store.py:213` | Lock records stamp `session_pid` and `pid` from `os.getpid()`. | The recorded identity no longer distinguishes sessions; ownership checks silently pass for the wrong caller. | needs a replacement | #937 | +| S5 | `mcp_session_state.py:27` | `DEFAULT_STATE_DIR = ~/.cache/gitea-tools/session-state`, mode `0o700`. | Same home-directory coupling as S1, for review decision locks and workflow proofs. | needs a replacement | #937 | +| S6 | `mcp_session_state.py:559` | Session bodies stamp `session_pid` and `writer_pid` from `os.getpid()` (`mcp_session_state.py:560`). | Writer attribution collapses across concurrent sessions in one process. | needs a replacement | #937 | +| S7 | `control_plane_db.py:47` | `DEFAULT_DB_PATH = ~/.cache/gitea-tools/control-plane/control_plane.sqlite3`. | A per-user SQLite file is not reachable by, or safe for, multiple remote sessions or multiple hosts. | needs a replacement | #937 | +| S8 | `control_plane_db.py:386` | `sqlite3.connect(self.db_path, timeout=30)` — single-writer file locking tuned for one local process. | SQLite's write lock does not extend across hosts and degrades sharply under real concurrency; the store needs a concurrency-safe backend. | needs a replacement | #937 | +| S9 | `control_plane_db.py:1145` | Lease rows record `owner_pid` defaulting to `os.getpid()` (also `control_plane_db.py:2039`). | PID-keyed lease ownership is unusable across hosts and ambiguous within one shared process. | cannot be remote | #937 | +| S10 | `mcp_daemon_guard.py:53` | `_DEFAULT_SESSION_STATE_DIR` is pinned once at transport bind so a later `GITEA_MCP_SESSION_STATE_DIR` change cannot manufacture a second authority domain (#695 AC2). | The single-authority-domain invariant is exactly right and must be preserved; only its backing location needs to move. | needs a seam | #937 | +| S11 | `gitea_mcp_server.py:11875` | Reviewer-lease reclaim reads `owner_pid_alive` from the lease freshness record to decide whether an owner is dead. | Consumes S3/S9; a false "owner alive" or "owner dead" here reclaims or refuses a live lease. This is the highest-consequence consumer of PID liveness. | cannot be remote | #937 | + +--- + +## Summary + +### Entries per category + +| Category | Entries | +| -------- | ------: | +| 1. Transport bind | 9 | +| 2. Launch provenance | 12 | +| 3. Role binding | 7 | +| 4. Credentials | 6 | +| 5. Runtime freshness | 6 | +| 6. Local filesystem | 11 | +| 7. Durable state | 11 | +| **Total** | **62** | + +No category is empty, so no "this category has no coupling" justification is required. + +### Entries per classification + +| Classification | Entries | +| -------------- | ------: | +| portable as written | 5 | +| needs a seam | 16 | +| needs a replacement | 26 | +| cannot be remote | 15 | +| **Total** | **62** | + +### Category × classification + +| Category | portable | seam | replacement | cannot | Total | +| -------- | -------: | ---: | ----------: | -----: | ----: | +| 1. Transport bind | 4 | 4 | 1 | 0 | 9 | +| 2. Launch provenance | 0 | 2 | 5 | 5 | 12 | +| 3. Role binding | 0 | 3 | 3 | 1 | 7 | +| 4. Credentials | 1 | 2 | 2 | 1 | 6 | +| 5. Runtime freshness | 0 | 2 | 2 | 2 | 6 | +| 6. Local filesystem | 0 | 2 | 7 | 2 | 11 | +| 7. Durable state | 0 | 1 | 6 | 4 | 11 | +| **Total** | **5** | **16** | **26** | **15** | **62** | + +### Entries per epic child + +Every child from 2 through 10 is named by at least one entry, and every entry names exactly +one child. + +| Child | Issue | Title | Entries | IDs | +| ----: | ----- | ----- | ------: | --- | +| 2 | #931 | Transport-neutral bind seam | 9 | T1–T9 | +| 3 | #932 | Per-request principal resolution | 7 | R1–R7 | +| 4 | #933 | Server-side credential provider | 6 | C1–C6 | +| 5 | #934 | Remote-session provenance | 9 | P1–P9 | +| 6 | #935 | Redefined master-parity gate | 6 | F1–F6 | +| 7 | #936 | Local-filesystem vs remotable tool split | 8 | L1–L8 | +| 8 | #937 | Concurrency-safe session, lock, and lease state | 13 | L10, L11, S1–S11 | +| 9 | #938 | Authenticated remote MCP endpoint | 2 | P10, L9 | +| 10 | #939 | Dual-run cutover and rollback | 2 | P11, P12 | +| | | **Total** | **62** | | + +## Notes for downstream children + +- **The three highest-risk entries are P5, F2, and S11.** Each is a guard that does not + merely stop working remotely — it inverts. P5 turns concurrency into a reported fault, + F2 returns a verdict computed from terms that no longer mean anything, and S11 reclaims + or refuses leases on a PID-liveness answer that is wrong rather than unknown. A gate that + fails open while still reporting green is worse than one that fails to start. +- **T4, T5, T7, T8, and C5 are the portable core.** They show the target shape: predicates + over injected inputs, with no reference to the host, the process table, or the operator's + disk. +- **The keychain seam already exists** at C2 (`resolve_token`'s injectable `keychain_lookup`). + #933 should widen that seam rather than introduce a parallel path, and must remember C4 — + the guard protecting the old mechanism has to be re-pointed, or the protection lapses + silently when the mechanism is replaced. +- **`branches/` appears as a validation rule in at least two independent places** (L4, L5). + Path-substring conventions tend to have more copies than expected; #936 should re-grep + rather than trust this list to be exhaustive for that specific pattern. diff --git a/docs/remote-mcp/threat-model-anchors.json b/docs/remote-mcp/threat-model-anchors.json new file mode 100644 index 0000000..670420f --- /dev/null +++ b/docs/remote-mcp/threat-model-anchors.json @@ -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"} + ] +} diff --git a/docs/remote-mcp/threat-model.md b/docs/remote-mcp/threat-model.md new file mode 100644 index 0000000..28c8038 --- /dev/null +++ b/docs/remote-mcp/threat-model.md @@ -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 B1–B9 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 CR1–CR10 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 D1–D4**. + +| 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. diff --git a/docs/sanctioned-recovery-playbooks.md b/docs/sanctioned-recovery-playbooks.md new file mode 100644 index 0000000..6b5731f --- /dev/null +++ b/docs/sanctioned-recovery-playbooks.md @@ -0,0 +1,64 @@ +# Sanctioned Recovery Playbooks & Controls (Phase 2 #644) + +## Overview + +Stale runtimes, worktree binding mismatches, and un-reconciled merged branches previously required expert manual shell recovery. Manual process kills (`pkill -f mcp_server.py`) are strictly forbidden and classified as runtime contamination ([#630](sanctioned-restart-controls.md)). + +Phase 2 introduces **sanctioned recovery playbooks and controls** into the Web Console: +- **Diagnose**: Surface stale runtimes, worktree binding errors, contamination markers, and worktree anomalies via health & inventory APIs. +- **Preview**: Render mutation ledgers and exact confirmation phrases for recovery playbooks. +- **Confirm & Apply**: Execute sanctioned recovery actions through gated, audited paths. +- **Verify**: Revalidate control-plane state post-recovery before claiming clean status. + +--- + +## Recovery Playbook Taxonomy + +| Playbook ID | Action ID | Minimum Role | Target / Scope | Description | +|---|---|---|---|---| +| `clear_stale_binding` | `system.clear_stale_binding` | Operator | Active worktree binding | Clear provably missing or superseded `GITEA_ACTIVE_WORKTREE` binding ([#702](../stale_binding_recovery.py)). | +| `rebind_session_worktree` | `system.rebind_session_worktree` | Operator | Session worktree | Rebind or synchronize session worktree to verified lease worktree ([#864](../dirty_same_claimant_session_rebind.py)). | +| `reconcile_cleanups` | `system.reconcile_cleanups` | Controller | Worktree hygiene | Execute reconciler cleanup preview and apply for merged/superseded PR branches. | +| `sanctioned_restart` | `system.restart_namespace` | Admin | MCP Namespace | Restart MCP daemon gracefully via host supervisor ([#642](sanctioned-restart-controls.md)). | + +--- + +## Wizard Workflow (Diagnose → Preview → Confirm → Verify) + +### 1. Diagnose (`GET /api/v1/system/recovery/diagnose`) +Runs control-plane diagnostics: +- **Stale Runtime**: Mismatch between running daemon HEAD, local checkout HEAD, and remote-tracking HEAD. +- **Worktree Binding**: Missing path (`provably_stale_missing_path`), unverified inherited binding (`unverified_inherited`), or superseded binding (`superseded_by_session_lease`). +- **Contamination**: Checks for live contamination markers from unmanaged process kills. +- **Worktree Anomalies**: Scans `branches/` directory for un-reconciled cleanups or missing preserved worktrees. + +Returns `RecoveryDiagnosis` with eligible playbooks. + +### 2. Preview (`POST /api/v1/system/recovery/preview`) +Takes `playbook_id` and optional `target`/`params`. +Returns: +- **Mutation Ledger**: Step-by-step sequence of actions. +- **Confirmation Phrase**: Exact phrase required to authorize execution (e.g., `confirm clear_stale_binding`). +- **Authorization Decision**: RBAC check against the operator's principal. + +### 3. Apply (`POST /api/v1/system/recovery/apply`) +Requires `playbook_id` and matching `confirmation` phrase. Gates run in this order, and each fails closed before anything is mutated: + +1. **RBAC and execution phase** (`console_authz.authorize(..., for_execution=True)`). The phase branch only applies when `for_execution` is set. While `ACTIVE_PHASE` is `1`, every phase-2 recovery action is refused with `phase_not_active`, so no recovery playbook writes yet. Preview reports the same decision under `execution_authorization` / `execution_blocked_reason`. +2. **Confirmation phrase** (`confirmation_matches`). +3. **Contamination rules** ([#630](sanctioned-restart-controls.md)): the live marker is read from the session inventory and assessed under the gated task key `console_recovery_apply`. A contaminated runtime must be cleared through the reconciler cleanup playbook, which is the one playbook exempted from this gate because it is the designated remedy. The marker is also forwarded to `sanctioned_restart.execute_restart`, so a restart cannot launder a contaminated runtime. + +Apply then executes the sanctioned recovery logic against the **live** process environment — not a copy — and records an audit entry in `console_audit`. A playbook that leaves the binding unchanged reports `performed: false`; `binding_before`, `binding_after`, and `binding_changed` are returned so a no-op cannot read as success. + +Apply does **not** enforce master parity. Parity is reported by Diagnose ([#610](../master_parity_gate.py)) as evidence for the operator; it is not a precondition of this endpoint. + +### 4. Verify (`POST /api/v1/system/recovery/verify`) +Re-evaluates control-plane diagnostics post-recovery and **reports** `clean`, `stale_runtime_clean`, `binding_clean`, `binding_classification`, and `contamination_clean`. It reports; it does not assert or block. State is read fresh rather than from the mapping a mutation just wrote. An `unverified_inherited` binding is reported as not clean, because unproven is not clean. + +--- + +## Safety & Governance Principles + +1. **No Manual `pkill`**: Direct process killing remains forbidden and is recorded as contamination. +2. **Auditability**: Every recovery preview and execution is logged in the console audit trail. +3. **Master Parity & Dual Control**: High-privilege recovery actions require controller/admin roles and explicit confirmation phrases. diff --git a/docs/webui-authz-audit.md b/docs/webui-authz-audit.md index 2dcffac..be7e4ee 100644 --- a/docs/webui-authz-audit.md +++ b/docs/webui-authz-audit.md @@ -94,6 +94,12 @@ already define, and a regression test asserts each mapping matches. | `record_analytics_usage` | operator | gated_write | `runtime.record_analytics_usage` | Yes | No | No | 2 | | `system.reload_namespace` | controller | privileged | `runtime.reload_namespace` | Yes | No | No | 2 | | `system.restart_namespace` | admin | destructive | `runtime.restart_namespace` | Yes | **Yes** | **Yes** | 2 | +| `system.clear_stale_binding` | 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 | +| `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 second distinct principal must confirm. **Break-glass** means the action is @@ -112,6 +118,12 @@ by the console — both hand off to a host supervisor, and neither exposes a raw process kill. See [`sanctioned-restart-controls.md`](sanctioned-restart-controls.md) (#642). +`initiate_workflow` (#643) is operator-class because its outcome is a *claim*, +not a Gitea verdict. Requesting reviewer or merger work reserves that work +through the allocator; it does not grant the right to approve or merge, which +stays with the MCP role profile and its own capability gates. See +[`webui-requests.md`](webui-requests.md). + ### Authorization decision `authorize(action_id, principal, for_execution=False)` returns a decision @@ -126,9 +138,24 @@ record and **denies by default**. The deny reasons are closed and enumerated: | `phase_not_active` | Execution requested for an action whose phase is not open. | | `allowed_preview_only` | Authorized — preview only, execution still disabled. | -There is no implicit allow branch. Even the allow result reports -`execution_enabled: false` while the console is in Phase 1, so no caller can -read an allow as permission to mutate. +There is no implicit allow branch. + +`execution_enabled` on the decision reports whether the action has a live +execution path at all, and is computed by `execution_wired(action)`. There are +exactly two ways to be wired: + +1. the action's `phase` is at or below `ACTIVE_PHASE`; or +2. the action declares an `execution_env_flag` **and** that variable is set. + +Every action that declares no flag therefore reports `execution_enabled: false` +while the console is in Phase 1, so no caller can read an allow as permission +to mutate. The per-action flag exists because raising `ACTIVE_PHASE` would +enable execution for every action of that phase at once, including ones whose +execution path is not implemented. One implemented action goes live on its own +flag instead of dragging its unimplemented phase-mates with it. + +`initiate_workflow` is the only action that currently declares a flag +(`WEBUI_REQUESTS_EXECUTION`), and it stays denied until an operator sets it. ## Secret redaction @@ -235,13 +262,22 @@ second one. The integration points are already wired and observable: instead of adding a parallel check. - **`GET /api/console/security-model`** publishes the RBAC matrix, redaction policy, and audit policy as JSON for operators and tests. +- **`POST /api/v1/requests/preview` and `.../apply`** (#643) are the first + actions to use this model for a real execution path. Preview always returns a + decision and an audited `previewed` record; apply requires `confirm=true`, + emits `succeeded` or `denied`, and reserves work only through the allocator. + See [`webui-requests.md`](webui-requests.md). -To open Phase 2, a child issue must: raise `ACTIVE_PHASE`, implement the -confirmation and dual-control flow the matrix already declares, emit a -`succeeded` or `failed` record alongside the `gitea_audit` mutation record, and -keep `viewer` unable to reach any of it. Turning on execution without the -confirmation flow contradicts a declared requirement and is a review failure, -not a shortcut. +A Phase 2 action must: use `execution_wired` rather than a private enable flag, +implement the confirmation and dual-control flow the matrix already declares, +emit a `succeeded` or `failed` record alongside the `gitea_audit` mutation +record, and keep `viewer` unable to reach any of it. Turning on execution +without the confirmation flow contradicts a declared requirement and is a +review failure, not a shortcut. + +Raising `ACTIVE_PHASE` remains the way to open a whole phase at once, and is +deliberately *not* what #643 did: an action-scoped opt-in cannot enable an +action whose execution path nobody wrote. ## Local-dev mode @@ -294,6 +330,7 @@ Until Phase 2 wires it, probe protection rests on network placement alone, as | `WEBUI_ROLE_MAP` | unset | JSON subject → role map | | `WEBUI_REQUIRE_PROBE_AUTH` | unset | Require auth for non-public probes | | `WEBUI_CONSOLE_AUDIT_LOG` | unset | Append-only audit sink path | +| `WEBUI_REQUESTS_EXECUTION` | unset | Opt in to `initiate_workflow` execution (#643) | All are read server-side only. None is ever rendered into a page or returned by an API. diff --git a/docs/webui-local-dev.md b/docs/webui-local-dev.md index 3e01401..5e987ae 100644 --- a/docs/webui-local-dev.md +++ b/docs/webui-local-dev.md @@ -80,6 +80,8 @@ status, onboarding checklist state, and the fail-closed error payloads (#635). | `/sessions` | Runtime and session view (#641) — health + inventory sessions/namespaces/worktrees | | `/api/sessions` | JSON export for the runtime/session view | | `/api/v1/sessions` | Versioned alias of `/api/sessions` | +| `/gitea` | Gitea issue↔PR linkage console (#645) — both directions, with the evidence for each edge | +| `/api/v1/gitea/linkage` | JSON linkage export; `502` when the read could not be answered | | `/inventory` | Phase 1 shell stub — unified inventory (backed by #636) | | `/timeline` | Phase 1 shell stub — workflow event timeline | | `/policy` | Phase 1 shell stub — capability/role policy placeholder | @@ -327,6 +329,68 @@ Honesty rules specific to this view: The write-time redactor is a narrow denylist and is not relied on. The field itself is kept — it is the `#630` evidence naming which daemon was killed. +## Gitea issue/PR linkage (#645) + +`/gitea` is the Phase 3 read-only linkage console: which PR carries which issue, +which issues are claimed by more than one PR, and what the latest Canonical +Thread Handoff on a thread said. Gitea remains the source of truth — this +surface reads it and never writes to it. There is no issue/PR editor, no review, +and no merge control. + +Query parameters (all optional): + +| Parameter | Meaning | +|-----------|---------| +| `project` | Registry project id to scope the read (default: first registry entry) | +| `state` | `open` (default) or `all`; `all` widens the window to merged/closed items, where a landed edge lives | +| `issue=N` / `pr=N` | Focus one thread and load *its* latest canonical handoff | + +`GET /api/v1/gitea/linkage` returns the same model as JSON +(`schema_version: 1`). It answers `502` when the read could not be answered, so +an automated consumer cannot mistake a fail-closed payload for "no links exist". +The HTML page always answers `200` and renders the reason instead — an operator +view must show why a read failed rather than withhold the page. + +### How an edge is found + +Each edge carries the evidence that produced it, strongest first: + +| Evidence | Meaning | +|----------|---------| +| `closes_keyword` | The PR title or body declares `closes/fixes/resolves #N`. Gitea itself acts on this keyword. | +| `branch_marker` | The PR head branch carries the canonical `(fix\|feat\|docs\|chore)/issue-N-…` marker minted by the issue lock. | +| `body_reference` | The PR body mentions `#N` with no closing keyword. A mention is not a claim to close. | + +Only closing and branch-marker edges populate the **issue → PR** direction: a +bare mention is a cross-link, and counting it as ownership would invent +contested issues out of ordinary references. The mention stays visible on the +**PR → issue** side, labelled as such. A PR whose two strongest edges tie is +flagged `ambiguous`; an issue claimed by two PRs is flagged `contested`. + +### Honesty rules specific to this view + +* **A partial read never reads as an absence.** Linkage is a claim about the + loaded window only. When pagination did not complete, every empty edge cell + renders `none found (partial inventory)` rather than `none`, and the JSON + carries `inventory_complete: false` plus per-row `links_authoritative: false`. +* **A failed read renders no table at all.** Missing credentials, an unknown + project, or a fetch error produce `ok: false` with a reason. An empty linkage + table would assert that no issue is linked to any PR, which such a read is not + in a position to claim. +* **Handoffs are loaded, never assumed.** CTH comments are thread-scoped, so + only the focused issue or PR has its comments fetched. Every other row reports + `not_loaded` with the reason; a thread whose comments *were* loaded and carried + no CTH says exactly that. A comment-source failure degrades the handoff alone — + the linkage tables still render. +* **Unrecognised handoff headings are reported, not republished.** A `## CTH:` + heading outside `CTH_TYPES` renders as `unrecognized`. +* **Redaction precedes display.** Titles, labels, handoff fields, and error + reasons pass through `webui.console_redaction` before serialization, and the + page HTML-escapes everything it renders. +* **Deep links are opt-in.** A link out to the Gitea web UI appears only when + `GITEA_MCP_REVEAL_ENDPOINTS=1` is set server-side, matching how the MCP tools + gate URL exposure. Item numbers stay usable without it. + ## System-health dashboard (#639) `/system-health` renders the same snapshot the `/api/v1/system/health` API diff --git a/docs/webui-notifications.md b/docs/webui-notifications.md new file mode 100644 index 0000000..7ef2e05 --- /dev/null +++ b/docs/webui-notifications.md @@ -0,0 +1,81 @@ +# Web Console: Notifications & Human-Attention Routing (#648) + +- **Status:** Phase 3 Live +- **Tracking Issue:** [#648](https://gitea.prgs.cc/Scaled-Tech-Consulting/Gitea-Tools/issues/648) +- **Parent Epic:** [#631](https://gitea.prgs.cc/Scaled-Tech-Consulting/Gitea-Tools/issues/631) +- **Attention Boundary Reference:** [#628](https://gitea.prgs.cc/Scaled-Tech-Consulting/Gitea-Tools/issues/628) + +--- + +## 1. Overview + +The **Notifications & Human-Attention Console** (`/notifications`, `/api/v1/notifications`) provides intelligent event classification and human-attention routing for autonomous workflow operations. + +To prevent alert fatigue while ensuring critical escalation boundaries are never missed, events are classified into three distinct **Attention Classes**: + +1. **`human-required`** (Urgent Escalation Boundary): + - Items requiring immediate human intervention or business decisions. + - Triggers: Auth failures, hard stops, irrecoverable state, decision locks, failed report validations, critical probe errors. + - Display: Highlighted in red (`badge-blocked`) with a `HUMAN REQUIRED` badge. + +2. **`operator`** (Operational Inbox): + - Items requiring controller or operator review/triage during routine execution. + - Triggers: Blocked PRs (merge conflicts), stale leases, duplicate PRs on issues, unassigned ready work. + - Display: Displayed in orange/yellow (`badge-claimed`). + +3. **`routine`** (Background Workflow Transitions): + - Normal, healthy workflow transitions and state progressions. + - Triggers: Active PRs/issues in standard state, clean branch creation, routine heartbeats. + - Display: Filtered out of default inbox views to eliminate notification spam; viewable on demand via the "Routine" or "All" tab. + +--- + +## 2. API Endpoints + +### `GET /api/v1/notifications` +*Compatibility Alias:* `GET /api/notifications` + +#### Query Parameters: +- `project_id` (optional): Filter notifications by project ID. +- `attention_class` (optional): `inbox` (default: human-required + operator), `human-required`, `operator`, `routine`, `all`. + +#### Example JSON Response: +```json +{ + "project_id": "gitea-tools", + "repo_label": "Scaled-Tech-Consulting/Gitea-Tools", + "human_required_count": 0, + "operator_count": 2, + "routine_count": 5, + "total_count": 7, + "fetch_error": null, + "inbox_items": [ + { + "id": "notif-pr-block-742", + "attention_class": "operator", + "category": "blocker", + "title": "Blocked PR #742", + "summary": "PR #742 requires merge conflict resolution.", + "work_kind": "pr", + "work_number": 742, + "project_id": "gitea-tools", + "repo_label": "Scaled-Tech-Consulting/Gitea-Tools", + "created_at": "2026-07-25T16:39:47Z", + "deep_link": "/traffic", + "requires_human": false, + "extra": {} + } + ], + "all_items": [...] +} +``` + +--- + +## 3. UI Navigation + +- Access via the **Traffic** navigation menu: **Traffic → Notifications**. +- The main view displays: + - **Metrics Summary Bar**: Highlighting counts for Human Required, Operator Inbox, and Routine items. + - **Attention Filter Tabs**: Toggle between Inbox (Human + Operator), Human Required, Operator, Routine, and All. + - **Structured Event Table**: Displays category, title, summary, work item links, and timestamps. diff --git a/docs/webui-requests.md b/docs/webui-requests.md new file mode 100644 index 0000000..b8fbdb2 --- /dev/null +++ b/docs/webui-requests.md @@ -0,0 +1,160 @@ +# Web console requests: intent preview and workflow initiation (#643) + +**Phase 2. Preview is always live and always read-only. Initiation is wired but +denied until an operator opts in.** + +Before this surface, starting role work meant pasting a prompt into a terminal +and trusting the operator to have checked the allocator first. Nothing enforced +that check, so two sessions could reach for the same issue and each believe it +was theirs. This page replaces the paste with a *request*: a desired role, an +issue or PR, and a stated intent, answered by an authorization decision and — +on confirmation — an exclusive assignment from the allocator. + +| Concern | Module | +|---------|--------| +| Request model, preview, initiation | `webui/request_service.py` | +| Form and preview rendering | `webui/request_views.py` | +| Authorization | `webui/console_authz.py` (`initiate_workflow`) | +| Audit | `webui/console_audit.py` | +| Ownership substrate | `allocator_service.py` + `control_plane_db.py` | + +## Surfaces + +| Path | Method | Purpose | +|------|--------|---------| +| `/requests` | GET | Request form | +| `/requests` | POST | Render an intent preview. **Never assigns.** | +| `/api/v1/requests/preview` | POST | Intent preview as JSON | +| `/api/v1/requests/apply` | POST | Initiate — confirmed, audited, allocator-owned | + +The HTML form has no initiate button on purpose. Initiating requires a +confirmed POST to `/api/v1/requests/apply`, so a stray form submission cannot +reserve work as a side effect. + +## The request + +```json +{ + "desired_role": "author", + "work_kind": "issue", + "work_number": 643, + "intent_summary": "implement request preview and initiation", + "remote": "prgs", + "org": "Scaled-Tech-Consulting", + "repo": "Gitea-Tools", + "expected_head_sha": null +} +``` + +`desired_role` is one of `author`, `reviewer`, `merger`, `reconciler`, +`controller`. `work_kind` is `issue` or `pr`. `remote`/`org`/`repo` default to +the first project in the registry when omitted; when neither the request nor +the registry resolves them, the request is rejected rather than pointed at some +other repository. `intent_summary` is required — it is what the audit record +states as the reason — and is truncated to 500 characters. + +Parsing rejects rather than corrects. An unknown role, an unknown work kind, a +non-positive number, or a missing intent each return `400` with a `reason_code` +and the offending `field`. + +## Preview + +Five checks, each with its own verdict, reason code, and detail: + +| Check | Passes when | +|-------|-------------| +| `authorization` | The console principal holds `operator` or above | +| `capability` | The desired role maps to a declared profile and MCP namespace | +| `lease_availability` | No active claim holds the work unit | +| `next_safe_action` | The allocator would independently select this exact work unit | +| `head_pin` | PR work resolves to a head SHA, and a supplied SHA still matches | + +A preview also returns the role's `allowed_actions` and `prohibited_actions` +(from `allocator_service.ROLE_ACTIONS`), the `required_profile` and +`required_namespace` the work must run under, and a `correlation_id` that ties +the preview to its audit record and to any assignment that follows. + +Preview is read-only in the strict sense: it calls the allocator with +`apply=false` and writes nothing but an audit line. An unauthorized principal +never reaches the allocator or the control-plane DB at all, so a denial cannot +be used to enumerate the queue. + +## Initiation + +`POST /api/v1/requests/apply` refuses in this order, and every refusal returns +before any assignment is attempted: + +| Condition | Outcome | Status | +|-----------|---------|--------| +| Unparseable request | `invalid_request` | 400 | +| Not authorized, or execution not wired | `denied` | 403 | +| `confirm` not set | `denied` / `confirmation_required` | 409 | +| Work unit already claimed | `blocked` / `duplicate_assignment` | 409 | +| Allocator would select other work | `wait` / `not_next_safe_work` | 409 | +| Allocator declines on apply | `blocked` or `wait` | 409 | +| Evidence unavailable | `wait` / `evidence_unavailable` | 503 | +| Assigned | `assigned_work` | 201 | + +A success returns the assignment plus a `handoff` block naming the profile, the +namespace, and the actions that stay forbidden — enough for the operator to +continue in the right MCP namespace without guessing. + +### Why apply runs the allocator twice + +The allocator is the only source of exclusive ownership (#600 / #613), and it +selects work; it does not take orders. So `apply` runs a dry-run first and +proceeds only when the allocator would independently pick the requested work +unit. If it would not, the request reports `wait` and mutates nothing. + +A request is therefore a *confirmation* of the allocator's decision, never an +override of it. The apply call carries the dry-run's +`candidate_set_fingerprint` as a CAS pin (#776), so a queue that changed +between the two calls fails closed rather than assigning against a stale view. +The result is checked again on the way out: an assignment naming a different +work unit is not read as success. + +### Fail-closed defaults + +- An unreadable control-plane DB denies. It is never treated as "nothing holds + this work unit". +- An incomplete queue inventory denies (#758). Ranking a partial candidate set + can select the wrong work. +- An allocator that raises denies. +- PR work with no resolvable head SHA denies; a supplied SHA that no longer + matches denies with `head_moved`. + +## Enabling initiation + +Execution is wired off. Set `WEBUI_REQUESTS_EXECUTION=1` to enable it for the +`initiate_workflow` action only — see +[`webui-authz-audit.md`](webui-authz-audit.md) for why this is an +action-scoped flag rather than a phase bump. With the variable unset, `apply` +returns `403` with `reason_code: unauthorized` no matter who asks. + +Enabling execution does **not** enable approvals or merges. Those are phase 3 +console actions and remain forbidden in every path here; the console reserves +work and hands off, and the MCP role profile enforces what that role may then +do. + +## Audit + +Every preview and every apply emits a console audit record (schema in +[`webui-authz-audit.md`](webui-authz-audit.md)): + +| Event | `result` | +|-------|----------| +| Preview | `previewed` | +| Refusal at any stage | `denied` | +| Assignment created | `succeeded` | + +`correlation.request_id` carries the request's `correlation_id`, and a +successful record's `metadata` carries `assignment_id` and `lease_id`, so an +assignment can be traced back to the intent that produced it. The operator's +`intent_summary` travels in `metadata` and passes through the standard +redaction pass before persistence like every other field. + +## Non-goals + +- No browser-initiated approve or merge, in this phase or any other. +- No bypass of allocator exclusive ownership; no self-selection of work. +- No auto-start from raw monitoring incidents (#612 stays downstream). diff --git a/docs/webui-restart-console.md b/docs/webui-restart-console.md new file mode 100644 index 0000000..12dd427 --- /dev/null +++ b/docs/webui-restart-console.md @@ -0,0 +1,102 @@ +# Web Console: restart status, impact preview, and approval state (#667) + +Phase 1 of the console restart surface. It consumes the #655 coordinator +substrate and displays it. It performs no restart, reload, drain, approval, or +process action, and it registers no write endpoint. + +Issue #667's rollout is explicit — *status views first, write approval after the +backend gates are green* — and this change delivers only the status half. + +## Surfaces + +| Path | Method | Purpose | +|------|--------|---------| +| `/runtime/restart` | GET | Restart status page | +| `/api/v1/system/restart/status` | GET | Same snapshot as JSON | + +Both accept an optional `restart_class` query parameter (default +`full_mcp_restart`). An unrecognised class is not an error: the coordinator +resolves it as unknown and fails closed, and the page shows the resulting deny. + +Neither path accepts `POST`; a write attempt returns `405`, and a test asserts +it. + +## What it shows + +* **Impact preview (#658)** — verdict, blast radius, affected sessions, leases, + critical sections, mutations, and the counts behind them, evaluated + `dry_run=True` against live control-plane state. +* **Drain proof (#661)** — verification of a supplied proof: valid, clean, + expired, tampered, and the reasons behind a refusal. +* **Post-restart reconcile (#662)** — the most recent completion proof, its + overall status, and which dimensions still require follow-up. +* **Restart classes (#663)** — the least-privilege matrix, with *you may + request* and *you may execute* computed for the viewing role rather than for a + generic operator. +* **Approval controls (#633)** — the authorization state of + `system.restart_namespace` and `system.reload_namespace`. +* **Break-glass (#664)** — declared and marked unavailable; see below. + +## Three rules this surface holds itself to + +A status page that is wrong is worse than one that is missing, because an +operator acts on it. Three properties are enforced by tests, and each was +verified by reverting the guard and watching a test fail. + +### An unreadable source reports unavailable, never green + +Every source carries its own `SourceStatus`. Nothing substitutes a default, +placeholder, or self-comparison for a reading that failed. An unreadable +control-plane database yields `inventory_complete: false`, which the coordinator +itself turns into a fail-closed verdict, and the page says the blast radius is +unknown rather than showing an empty affected-sessions table. + +An absent drain proof is reported as absent — not as a pass. The #661 gate +authorizes a restart only against a valid, unexpired, clean proof, so no proof +is precisely the state that gate denies on. + +### Authorization is asked the way execution would ask it + +Every probe passes `for_execution=True`. + +Asked without it, an admin is `allowed` for `system.restart_namespace`. On a +control surface that reads as a live button. Asked the way an execution attempt +would ask, the same principal is refused `phase_not_active`, because the console +is in Phase 1 and the action is Phase 2. This surface reports the second answer. + +`execution_enabled` is therefore `false` for every action and every role today, +and a test asserts that across the whole role matrix. + +### The control-plane database is opened read-only + +`ControlPlaneDB()` creates directories and runs migrations on construction — a +write. This surface never constructs one. It opens the sqlite file with +`mode=ro`, exactly as `webui/inventory.py` does, and treats a missing file as +missing authority rather than as an empty inventory. + +The test that protects this points at a path inside a directory that already +exists, so a read-write `connect` would really create the file. A nested +missing-directory path would have passed for the wrong reason. + +## Break-glass is declared, not offered + +The break-glass workflow (#664) is not available on this branch's base. The +panel is rendered to operator-class roles as **unavailable**, naming the issue +that tracks it. It is not silently omitted, because an operator who has been +told a governance path exists needs to see that it is not wired here; and it is +not rendered as a control, because there is nothing behind it. + +Unprivileged viewers see only a note that the surface is operator-class. + +## Redaction and escaping + +Every interpolated value passes through `_esc` (`html.escape(..., quote=True)`). +Free-form text and anything that can carry a filesystem path additionally passes +through `webui.inventory.scrub_text`, which redacts credential-shaped tokens +inside a string rather than only at its start. The impact payload is passed +through `webui.inventory.scrub` before rendering. + +## Linkage + +Parent #655 · extends #642 · consumes #658, #661, #662, #663 · RBAC #633 · +console #631 · vision #652 · roadmap #653 · break-glass #664. diff --git a/gitea_config.py b/gitea_config.py index 53e6a05..c69553c 100644 --- a/gitea_config.py +++ b/gitea_config.py @@ -1169,10 +1169,57 @@ def server_command(): return python, [os.path.join(root, "mcp_server.py")] +RECOGNIZED_GITEA_ENV_KEYS = frozenset({ + "GITEA_MCP_CONFIG", + "GITEA_MCP_PROFILE", + "GITEA_PROFILE_NAME", + "GITEA_SERVICE", + "GITEA_EXECUTION_ROLE", + "GITEA_CLIENT_MANAGED", + "GITEA_MCP_CLIENT_MANAGED", + "GITEA_SERVER_PROVENANCE", + "GITEA_AUTHOR_WORKTREE", + "GITEA_ACTIVE_WORKTREE", + "GITEA_DISABLE_KEYCHAIN", + "GITEA_CONTROL_PLANE_DB", + "GITEA_DB_PATH", + "GITEA_LOG_LEVEL", + "GITEA_DEBUG", + "GITEA_HMAC_SECRET", + "GITEA_IRRECOVERABLE_HMAC_SECRET", + "GITEA_FORCE_MCP_RUNTIME_CHECK", + "GITEA_FORCE_CLIENT_MANAGED", +}) + +RECOGNIZED_GITEA_ENV_PREFIXES = ( + "GITEA_TOKEN_", + "GITEA_PASS_", + "GITEA_USER_", + "GITEA_URL_", + "GITEA_HOST_", + "GITEA_REMOTE_", + "GITEA_HTTP_HEADER_", +) + + +def get_unconsumed_gitea_env_overrides(env=None) -> dict[str, str]: + """Find unsupported GITEA_* env vars present in *env* (defaults to os.environ).""" + target = os.environ if env is None else env + unconsumed = {} + for key, value in target.items(): + if key.startswith("GITEA_"): + if key in RECOGNIZED_GITEA_ENV_KEYS: + continue + if any(key.startswith(p) for p in RECOGNIZED_GITEA_ENV_PREFIXES): + continue + unconsumed[key] = str(value) + return unconsumed + + def launcher_entry(profile_name, config_path=None): """Return a thin MCP launcher entry for *profile_name*. - Contains only command/args and the two GITEA_MCP_* env vars — never a token + Contains command/args and the GITEA_MCP_* / GITEA_CLIENT_MANAGED env vars — never a token or password. Suitable for Claude / Gemini / Codex ``mcpServers`` blocks. """ command, args = server_command() @@ -1183,11 +1230,13 @@ def launcher_entry(profile_name, config_path=None): "env": { "GITEA_MCP_CONFIG": config_path or DEFAULT_CONFIG_PATH, "GITEA_MCP_PROFILE": profile_name, + "GITEA_CLIENT_MANAGED": "1", }, } } + def keychain_set(item_id, token, account=None, runner=subprocess.run): """Store *token* in the macOS keychain under service *item_id*. diff --git a/gitea_mcp_server.py b/gitea_mcp_server.py index 1e2498c..6ecd1a3 100644 --- a/gitea_mcp_server.py +++ b/gitea_mcp_server.py @@ -1546,6 +1546,7 @@ def verify_preflight_purity( task=task, target_issue_number=target_issue_number, require_author_lock=require_author_lock, + bootstrap_assessment=bootstrap_assessment, ) # #604: common anti-stomp preflight after legacy + #683 enforcers. _run_anti_stomp_preflight( @@ -1585,6 +1586,7 @@ def verify_preflight_purity( task=task, target_issue_number=target_issue_number, require_author_lock=require_author_lock, + bootstrap_assessment=bootstrap_assessment, ) if force_anti_stomp: _run_anti_stomp_preflight( @@ -1652,8 +1654,15 @@ def _enforce_issue_scope_guard( task: str | None = None, target_issue_number: int | None = None, require_author_lock: bool = False, + bootstrap_assessment: object = _BOOTSTRAP_UNSET, ) -> None: - """#683: fail closed on missing/out-of-scope issue ownership for mutations.""" + """#683: fail closed on missing/out-of-scope issue ownership for mutations. + + #941: the shared bootstrap assessment is threaded in so this guard judges + the author issue-worktree bootstrap on the same server-derived evidence as + the #274 branches-only and #604 anti-stomp guards. Callers that supply + none fall back to computing it here, which preserves behaviour. + """ ctx = _resolve_namespace_mutation_context(worktree_path) workspace = ctx["workspace_path"] git_state = issue_lock_worktree.read_worktree_git_state(workspace) @@ -1703,11 +1712,32 @@ def _enforce_issue_scope_guard( import create_issue_bootstrap as _cib is_create_issue = _cib.is_create_issue_task(task) + # #941: consume the caller-computed bootstrap assessment when one was + # threaded in, so this guard and the #274/#604 guards judge identical + # evidence. Falling back preserves behaviour for callers that supply none. + bootstrap = ( + _create_issue_bootstrap_assessment(task, worktree_path) + if bootstrap_assessment is _BOOTSTRAP_UNSET + else bootstrap_assessment + ) + # #941: the author issue-worktree bootstrap is pre-ownership for the same + # reason create_issue is — it exists to break the lock<->worktree cycle, so + # no owning lock can exist yet. The exemption is granted by the canonical + # shared decision over server-derived evidence, never by a task-name list, + # and fails closed on missing, malformed, cross-scope, dirty, drifted, or + # wrongly bound evidence. + bootstrap_waives_ownership = _cib.bootstrap_permits_control_checkout( + bootstrap, + task=task, + workspace_path=workspace, + canonical_repo_root=ctx["canonical_repo_root"], + ) require_lock = bool(require_author_lock) or ( authorish and workflow_scope_guard.production_guards_forced() and role == "author" and not is_create_issue + and not bootstrap_waives_ownership ) assessment = workflow_scope_guard.assess_production_mutation_guards( workspace_path=workspace, @@ -1720,6 +1750,7 @@ def _enforce_issue_scope_guard( require_author_lock=require_lock, in_test_mode=_preflight_in_test_mode(), mutation_task=task, + bootstrap_assessment=bootstrap, ) workflow_scope_guard.raise_if_blocked(assessment) @@ -2080,6 +2111,8 @@ import issue_lock_store # noqa: E402 import issue_lock_adoption # noqa: E402 import issue_lock_recovery # 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_same_claimant_session_rebind # noqa: E402 # #864 import stacked_pr_support # noqa: E402 @@ -2093,6 +2126,7 @@ import root_checkout_guard # noqa: E402 import workflow_scope_guard # noqa: E402 # #683 production scope / force-on guards import stable_branch_push_guard # noqa: E402 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 anti_stomp_preflight # noqa: E402 import issue_claim_heartbeat # noqa: E402 @@ -2627,33 +2661,17 @@ def _build_author_issue_work_lease( worktree_path: str, host: str | None, ) -> dict: - created = _work_lease_now() - # #790 Slice A: the window comes from the central policy, not a literal here. - # It is also now a *sliding* window — the lease lives ``initial_ttl_minutes`` - # past its last valid heartbeat rather than a fixed four hours past its - # creation, so an abandoned task stops holding the claim within one TTL. - policy = lease_policy.policy_for(lease_policy.TASK_CLASS_AUTHOR_ISSUE_WORK) - expires = created + timedelta(minutes=policy.initial_ttl_minutes) - return { - "operation_type": AUTHOR_ISSUE_WORK_LEASE, - "issue_number": issue_number, - "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, - } + # #953: the lease shape now lives in author_lock_contract so that bootstrap + # and gitea_lock_issue cannot drift apart again. The policy-derived sliding + # TTL (#790 Slice A) and the task-session ownership key (#790 AC-N1) are + # unchanged — they simply have one definition instead of two. + return author_lock_contract.build_author_issue_work_lease( + issue_number=issue_number, + branch_name=branch_name, + worktree_path=worktree_path, + claimant=_work_lease_claimant(host), + created=_work_lease_now(), + ) def _active_work_lease_block( @@ -2751,6 +2769,87 @@ def _collect_issue_duplicate_context( 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( issue_number: int, *, @@ -2803,6 +2902,11 @@ def _enforce_locked_issue_duplicate_recheck( 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 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() if not lock_data: @@ -2826,9 +2930,7 @@ def _enforce_locked_issue_duplicate_recheck( auth=auth, locked_branch=locked_branch, phase=phase, - recovered_owning_pr=issue_lock_recovery.recovered_owning_pr_from_lock( - lock_data - ), + recovered_owning_pr=_owning_pr_continuation_from_lock(lock_data), ) if gate.get("block"): return gate @@ -3550,6 +3652,293 @@ def _authenticated_username(host: str): return user +# #943 review 622 F3/F4: one coherent authority snapshot per mutation claim. +# +# The reviewed implementation read the identity from the pinned #714 session +# context and the profile from the live ``get_profile()``, so a sanctioned +# rebind could produce a claimant pair whose two halves came from different +# snapshots — and that pair is written durably into the issue lock. The +# canonical pairing is ``record_mutation_authority``: profile from +# ``get_profile()``, identity from ``_authenticated_username(host)``. This +# resolver reproduces that pairing, and uses the pinned session context only for +# drift detection, never as a value source. +_AUTHORITY_PROFILE_UNRESOLVED = "authority_profile_unresolved" +_AUTHORITY_IDENTITY_UNRESOLVED = "authority_identity_unresolved" +_AUTHORITY_IDENTITY_DRIFT = "authority_identity_drift" +_AUTHORITY_PROFILE_DRIFT = "authority_profile_drift" + + +def _active_mutation_authority(host: str | None) -> dict: + """Resolve one coherent (identity, profile) authority pair, or a refusal. + + Both halves come from the same live snapshot. The immutable #714 session + context is consulted only to detect drift: when it disagrees with the live + snapshot the result is a fail-closed refusal, never a silently blended pair. + + Returns a dict with ``ok`` True plus ``identity``/``profile_name``, or ``ok`` + False plus ``reason_code``, ``reasons`` and ``expected``/``actual`` when a + drift or resolution failure is detected. Never raises for an unresolved + profile: the caller converts the refusal into a structured author block so + reason code, retryability and transport survival are preserved (F4). + """ + try: + profile = get_profile() or {} + except (RuntimeError, ValueError, TypeError, KeyError, OSError) as exc: + # Narrow, and never a silent fallback to a previously cached name: an + # unresolvable profile is a fail-closed condition, matching + # record_mutation_authority's "active profile unresolved (fail closed)". + return { + "ok": False, + "reason_code": _AUTHORITY_PROFILE_UNRESOLVED, + "reasons": [ + "active profile could not be resolved: " + f"{_redact(str(exc))} (fail closed)" + ], + } + profile_name = (profile.get("profile_name") or "").strip() + if not profile_name: + return { + "ok": False, + "reason_code": _AUTHORITY_PROFILE_UNRESOLVED, + "reasons": [ + "active profile carries no profile_name (fail closed)" + ], + } + + identity = "" + if host: + identity = (_authenticated_username(host) or "").strip() + if not identity: + # A profile's expected_username is configuration, not proof of an + # authenticated actor, so it is never substituted here. + return { + "ok": False, + "reason_code": _AUTHORITY_IDENTITY_UNRESOLVED, + "reasons": [ + "authenticated identity could not be resolved for the active " + "host (fail closed); call gitea_whoami and retry" + ], + } + + ctx = session_ctx.get_session_context() or {} + pinned_identity = (ctx.get("identity") or "").strip() + pinned_profile = (ctx.get("profile_name") or "").strip() + if pinned_identity and pinned_identity != identity: + return { + "ok": False, + "reason_code": _AUTHORITY_IDENTITY_DRIFT, + "reasons": [ + "live authenticated identity disagrees with the bound session " + "context identity (fail closed)" + ], + "expected": pinned_identity, + "actual": identity, + } + if pinned_profile and pinned_profile != profile_name: + return { + "ok": False, + "reason_code": _AUTHORITY_PROFILE_DRIFT, + "reasons": [ + "live active profile disagrees with the bound session context " + "profile (fail closed)" + ], + "expected": pinned_profile, + "actual": profile_name, + } + return { + "ok": True, + "identity": identity, + "profile_name": profile_name, + "session_context_bound": bool(pinned_identity or pinned_profile), + } + + +def _active_username(host: str | None = None) -> str | None: + """Authenticated identity for the active mutation authority, else None. + + Refuses unbound, blank and whitespace-only identities, and never substitutes + a profile's ``expected_username`` for an authenticated one. + """ + authority = _active_mutation_authority(host) + return authority.get("identity") if authority.get("ok") else None + + +def _active_profile_name(host: str | None = None) -> str | None: + """Active profile name for the mutation authority, else None. + + Shares the single snapshot with :func:`_active_username`, so the two can + never describe different authority states. + """ + authority = _active_mutation_authority(host) + return authority.get("profile_name") if authority.get("ok") else None + + +# #943 review 622 B1: the workflow session that owns the work — never a +# process-lifetime or PID-derived value. +# +# The reviewed implementation minted "--" once per process. +# The MCP daemon outlives every task it serves, so that identifier conflates +# sequential author tasks and can never equal the control-plane session that +# owns an allocator-created lease; ``_verify_assignment_and_lease_ids`` therefore +# refused with lease_session_mismatch on the canonical allocated path. +# ``issue_lock_store.mint_task_session_id`` states the rule directly: an +# ownership key "deliberately contains no process identifier", because the PID +# "cannot identify *which* task holds a claim". +_SESSION_UNVERIFIED = "workflow_session_unverified" +_SESSION_REQUIRED = "workflow_session_required_for_allocated_work" +_SESSION_LOCK_OWNER_MISMATCH = "issue_lock_owner_mismatch" + + +def _resolve_owner_workflow_session( + *, + issue_number: int, + assignment_id: str | None, + lease_id: str | None, + session_id: str | None, + identity: str, + profile_name: str, + remote: str, + org: str | None, + repo: str | None, +) -> dict: + """Resolve the authoritative workflow session that owns this work. + + Precedence, each step fail-closed: + + 1. An explicit ``session_id`` is verified against the control-plane + ``sessions`` table: it must exist, be active, and match this role and + profile. An unverifiable identifier is refused, never trusted. + 2. Otherwise an existing issue lock for this issue supplies its per-task + ``task_session_id``, but only when the lock's recorded claimant matches + the resolved authority pair. + 3. Otherwise, when allocator identifiers are supplied, the request is + refused: ownership of an allocated assignment cannot be established + without its owning session, and the presence of the identifiers is not + itself evidence of ownership. + 4. Otherwise — no allocator identifiers and no existing lock — a fresh + per-task key is minted through the canonical + ``issue_lock_store.mint_task_session_id``. It carries no process + identifier and is never memoised, so sequential tasks on one daemon never + share an owner. + + Whether the resolved session actually owns a supplied lease stays the + decision of ``author_issue_bootstrap._verify_assignment_and_lease_ids``; + this function never pre-empts, duplicates or bypasses that gate. + """ + declared = (session_id or "").strip() + if declared: + db, errs = _control_plane_db_or_error() + if db is None: + return { + "ok": False, + "reason_code": _SESSION_UNVERIFIED, + "reasons": errs, + } + try: + rows = db.list_sessions(statuses=("active",)) + except Exception as exc: # noqa: BLE001 — surface structured + return { + "ok": False, + "reason_code": _SESSION_UNVERIFIED, + "reasons": [ + "could not read control-plane sessions to verify " + f"session_id: {_redact(str(exc))} (fail closed)" + ], + } + match = next( + (r for r in rows if str(r.get("session_id") or "") == declared), None + ) + if match is None: + return { + "ok": False, + "reason_code": _SESSION_UNVERIFIED, + "reasons": [ + f"session_id '{declared}' is not an active control-plane " + "session (fail closed)" + ], + } + row_role = (match.get("role") or "").strip().lower() + row_profile = (match.get("profile") or "").strip() + if row_role and row_role != "author": + return { + "ok": False, + "reason_code": _SESSION_UNVERIFIED, + "reasons": [ + f"session_id '{declared}' is recorded for role " + f"'{row_role}', not author (fail closed)" + ], + "expected": "author", + "actual": row_role, + } + if row_profile and row_profile != profile_name: + return { + "ok": False, + "reason_code": _SESSION_UNVERIFIED, + "reasons": [ + f"session_id '{declared}' is recorded for profile " + f"'{row_profile}', not '{profile_name}' (fail closed)" + ], + "expected": row_profile, + "actual": profile_name, + } + return {"ok": True, "session_id": declared, "session_source": "declared"} + + lock = None + try: + lock = issue_lock_store.load_issue_lock( + remote=remote, + org=org or "", + repo=repo or "", + issue_number=int(issue_number), + ) + except Exception: # noqa: BLE001 — absent/unreadable lock is not fatal here + lock = None + lock_session = issue_lock_store.lease_task_session_id(lock) if lock else "" + if lock_session: + lease = (lock or {}).get("work_lease") or {} + claimant = lease.get("claimant") or {} + lock_identity = (claimant.get("username") or "").strip() + lock_profile = (claimant.get("profile") or "").strip() + if (lock_identity and lock_identity != identity) or ( + lock_profile and lock_profile != profile_name + ): + return { + "ok": False, + "reason_code": _SESSION_LOCK_OWNER_MISMATCH, + "reasons": [ + f"issue #{int(issue_number)} is locked by " + f"'{lock_identity or 'unknown'}' ({lock_profile or 'unknown'}), " + f"not '{identity}' ({profile_name}) (fail closed)" + ], + "expected": f"{lock_identity}/{lock_profile}", + "actual": f"{identity}/{profile_name}", + } + return { + "ok": True, + "session_id": lock_session, + "session_source": "issue_lock", + } + + if (assignment_id or "").strip() or (lease_id or "").strip(): + return { + "ok": False, + "reason_code": _SESSION_REQUIRED, + "reasons": [ + "assignment_id/lease_id were supplied but no owning workflow " + "session could be established (fail closed); pass the " + "session_id that holds the lease, or bind the issue lock first" + ], + } + + return { + "ok": True, + "session_id": issue_lock_store.mint_task_session_id( + issue_lock_store.AUTHOR_ISSUE_WORK_LEASE + ), + "session_source": "minted_task_key", + } + + def _authenticated_actor(host: str) -> dict: """Resolve the authenticated actor's stable identity (#709 F7 review 438). @@ -4552,6 +4941,370 @@ def gitea_heartbeat_issue_lock( 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() def gitea_recover_dirty_orphaned_issue_worktree( issue_number: int, @@ -5110,9 +5863,10 @@ def gitea_assess_work_issue_duplicate( recovered_owning_pr = None lock_data = _load_existing_issue_lock() 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( - lock_data - ) + # #945: rebuilt from a sanctioned recovery *or* a sanctioned exact-owner + # 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( issue_number, h=h, @@ -9872,6 +10626,24 @@ def gitea_commit_files( } +def _author_mutation_block(reasons: list[str], **extra) -> dict: + """Uniform fail-closed shape for an author mutation refused after a reviewer stop. + + #943: referenced by ``gitea_bootstrap_author_issue_worktree`` and never + defined, so the reviewer-stop refusal path raised ``NameError`` instead of + returning its refusal. Mirrors the inline shape the other author mutations + return for the same ``check_author_mutation_after_reviewer_stop`` block. + """ + payload = { + "success": False, + "performed": False, + "outcome": "REFUSED", + "reasons": reasons, + } + payload.update(extra) + return payload + + def _publication_block(reasons: list[str], **extra) -> dict: """Uniform fail-closed shape for publication refusals (#812 AC20).""" payload = { @@ -10128,6 +10900,7 @@ def gitea_bootstrap_author_issue_worktree( branch_name: str | None = None, worktree_path: str | None = None, idempotency_key: str | None = None, + session_id: str | None = None, remote: str = "dadeschools", host: str | None = None, org: str | None = None, @@ -10149,6 +10922,14 @@ def gitea_bootstrap_author_issue_worktree( branch_name: Optional custom branch name (must match issue- pattern). worktree_path: Optional custom worktree path under branches/. idempotency_key: Optional key for idempotent replay/resume. + session_id: Optional workflow session that owns the assignment/lease. + Required when assignment_id/lease_id are supplied, because ownership + of an allocated lease is compared by session identifier and cannot + be derived from the daemon process (#943 review 622 B1). Verified + against the control-plane sessions table; an unverifiable value is + refused rather than trusted. Omit for an unallocated bootstrap: the + owning session is then taken from an existing issue lock, or a fresh + per-task key is minted. remote: Known instance — 'dadeschools' or 'prgs'. host: Override Gitea host. org: Override Org. @@ -10187,6 +10968,44 @@ def gitea_bootstrap_author_issue_worktree( h, o, r = _resolve(remote, host, org, repo) canonical_root = _canonical_local_git_root() + # One authority snapshot supplies both halves of the claimant pair (F3), and + # an unresolvable profile becomes a structured refusal rather than a silent + # fallback (F4). + authority = _active_mutation_authority(h) + if not authority.get("ok"): + return _author_mutation_block( + authority.get("reasons") or ["mutation authority unresolved"], + reason_code=authority.get("reason_code"), + retryable=False, + transport_survives=True, + expected=authority.get("expected"), + actual=authority.get("actual"), + issue_number=int(issue_number), + ) + + # The owning workflow session — never process- or PID-derived (B1). + session = _resolve_owner_workflow_session( + issue_number=issue_number, + assignment_id=assignment_id, + lease_id=lease_id, + session_id=session_id, + identity=authority["identity"], + profile_name=authority["profile_name"], + remote=remote, + org=o, + repo=r, + ) + if not session.get("ok"): + return _author_mutation_block( + session.get("reasons") or ["owning workflow session unresolved"], + reason_code=session.get("reason_code"), + retryable=False, + transport_survives=True, + expected=session.get("expected"), + actual=session.get("actual"), + issue_number=int(issue_number), + ) + import author_issue_bootstrap return author_issue_bootstrap.bootstrap_author_issue_worktree( @@ -10202,9 +11021,9 @@ def gitea_bootstrap_author_issue_worktree( host=h, org=o, repo=r, - active_identity=_active_username(), - active_profile=_active_profile_name(), - owner_session=_current_session_id(), + active_identity=authority["identity"], + active_profile=authority["profile_name"], + owner_session=session["session_id"], dry_run=dry_run, ) @@ -14025,11 +14844,16 @@ def _classify_operation_gate_reasons(reasons: list[str]) -> dict: 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 ( - "Reconnect the IDE/client MCP session so the server reloads at the " - "current master head. Do not call gitea_activate_profile or switch " - "MCP role sessions — profile switching does not clear a stale daemon." + "blocker_kind=runtime_reconnect_required: call " + "gitea_request_mcp_reconnect(namespace=, " + "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." ) @@ -14586,6 +15410,56 @@ def _session_context_mutation_block( return blocked +def _is_client_managed_process() -> bool: + """Check whether the current MCP server process has client-managed launch provenance (#686).""" + val = ( + os.environ.get("GITEA_CLIENT_MANAGED") + or os.environ.get("GITEA_MCP_CLIENT_MANAGED") + or os.environ.get("GITEA_SERVER_PROVENANCE") + or os.environ.get("GITEA_FORCE_CLIENT_MANAGED") + or "" + ).strip().lower() + + if val in ("0", "false", "no", "manual", "manual_launch"): + return False + + if val in ("1", "true", "yes", "client_managed"): + return True + + # A terminal launch has an active TTY on stdin + try: + if sys.stdin and sys.stdin.isatty(): + return False + except Exception: + pass + + # Standard client launch or test runner with stdio pipe and profile env + if "GITEA_MCP_CONFIG" in os.environ or "GITEA_MCP_PROFILE" in os.environ or "GITEA_PROFILE_NAME" in os.environ: + return True + + return False + + +def _provenance_mutation_block(**extra_fields) -> dict | None: + """Refuse mutating tool calls on processes lacking client-managed launch provenance (#686).""" + if _is_client_managed_process(): + return None + unconsumed = gitea_config.get_unconsumed_gitea_env_overrides() + blocked = { + "success": False, + "performed": False, + "blocker_kind": "unsupported_manual_launch", + "reasons": [ + "mutation denied: server process was launched manually from a terminal without client-managed provenance (fail closed). Manually launched mcp_server.py processes cannot receive IDE stdio or serve workflow mutations." + ], + "exact_next_action": "BLOCKED + RECONNECT: Reconnect the IDE/client-managed MCP server namespace instead of an ad hoc terminal launch. Hand-launched processes and mcp_config.json hand-edits are classified as workflow contamination.", + "provenance": "manual_launch", + "unconsumed_gitea_env": unconsumed, + } + blocked.update(extra_fields) + return blocked + + def _profile_permission_block(required_operation: str, **extra_fields) -> dict | None: """Structured operation-gate denial for gated tools (#69, #142, #897). @@ -14602,6 +15476,10 @@ def _profile_permission_block(required_operation: str, **extra_fields) -> dict | # #714: evaluate active profile only — never auto-switch. _ensure_matching_profile(required_operation, req_role, extra_fields.get("remote")) + prov_block = _provenance_mutation_block(**extra_fields) + if prov_block is not None: + return prov_block + reasons = _profile_operation_gate(required_operation) if reasons: return _build_operation_gate_refusal( @@ -14628,13 +15506,30 @@ def _profile_permission_block(required_operation: str, **extra_fields) -> dict | ) -def _namespace_mutation_block(mutation_task: str, **extra_fields) -> dict | None: - """Reviewer/author namespace alignment gate (#209).""" +def _namespace_mutation_block( + 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_role = task_capability_map.required_role(mutation_task) # #714: evaluate active profile only — never auto-switch. _ensure_matching_profile(required_permission, required_role, extra_fields.get("remote")) + prov_block = _provenance_mutation_block(**extra_fields) + if prov_block is not None: + return prov_block + try: profile = get_profile() except Exception as exc: @@ -14647,6 +15542,9 @@ def _namespace_mutation_block(mutation_task: str, **extra_fields) -> dict | None } ok, reasons = role_namespace_gate.check_author_mutation_namespace( mutation_task, profile) + if ok and author_role_exclusive: + ok, reasons = role_namespace_gate.check_author_role_kind( + mutation_task, profile) if ok: return None blocked = { @@ -18082,6 +18980,9 @@ def gitea_get_runtime_context( source="gitea_get_runtime_context", ) + is_client_managed = _is_client_managed_process() + unconsumed_env = gitea_config.get_unconsumed_gitea_env_overrides() + result = { "active_profile": profile["profile_name"], "authenticated_username": username, @@ -18098,6 +18999,9 @@ def gitea_get_runtime_context( "review_merge_blocked_reasons": blocked_reasons, "suggested_fix": suggested_fix, "safe_next_action": safe_next_action, + "server_provenance": "client_managed" if is_client_managed else "manual_launch", + "is_client_managed": is_client_managed, + "unconsumed_gitea_env": unconsumed_env, "preflight_ready": preflight["preflight_ready"], "preflight_block_reasons": preflight["preflight_block_reasons"], "preflight_workspace": preflight.get("preflight_workspace"), @@ -18111,6 +19015,13 @@ def gitea_get_runtime_context( PROJECT_ROOT), } + if not is_client_managed: + result["safe_next_action"] = ( + "BLOCKED + RECONNECT: Serving process lacks client-managed launch provenance (manual launch). " + "Reconnect the IDE/client-managed MCP server namespace instead of an ad hoc terminal launch." + ) + + # #702: read-only visibility into the inherited GITEA_ACTIVE_WORKTREE # binding; recovery itself runs during capability resolution. try: @@ -19323,7 +20234,10 @@ def _prove_author_ownership_for_pr( # advance is the one recovery already sanctioned, not a foreign head. recovered_owning_pr = None 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): recovered_owning_pr = candidate return { @@ -20568,7 +21482,9 @@ def _check_mcp_runtimes_diagnostics(task: str, matching_profiles: list[str]) -> self_pid = os.getpid() self_stale = False - running_profiles = {} + all_profile_procs: dict[str, list[dict]] = {} + unsupported_env_found = set() + for line in proc.stdout.splitlines()[1:]: line = line.strip() if not line or "mcp_server.py" not in line: @@ -20600,16 +21516,55 @@ def _check_mcp_runtimes_diagnostics(task: str, matching_profiles: list[str]) -> if match: profile = match.group(1) + is_client_managed = bool( + re.search(r'\bGITEA_CLIENT_MANAGED=(1|true|yes|client_managed)\b', env_out, re.IGNORECASE) + or re.search(r'\bGITEA_MCP_CLIENT_MANAGED=(1|true|yes|client_managed)\b', env_out, re.IGNORECASE) + or re.search(r'\bGITEA_SERVER_PROVENANCE=client_managed\b', env_out, re.IGNORECASE) + ) + + for env_match in re.finditer(r'\b(GITEA_[A-Z0-9_]+)=([^\s]+)', env_out): + k, v = env_match.group(1), env_match.group(2) + if k not in gitea_config.RECOGNIZED_GITEA_ENV_KEYS and not any(k.startswith(p) for p in gitea_config.RECOGNIZED_GITEA_ENV_PREFIXES): + unsupported_env_found.add(f"{k}={v}") + is_stale = (start_time < code_mtime) or git_stale if pid == self_pid and is_stale: self_stale = True - if profile not in running_profiles or start_time > running_profiles[profile]["start_time"]: - running_profiles[profile] = { - "pid": pid, - "start_time": start_time, - "is_stale": is_stale - } + proc_info = { + "pid": pid, + "start_time": start_time, + "is_stale": is_stale, + "is_client_managed": is_client_managed, + } + if profile not in all_profile_procs: + all_profile_procs[profile] = [] + all_profile_procs[profile].append(proc_info) + + running_profiles = {} + for profile, procs in all_profile_procs.items(): + if len(procs) > 1: + pids_str = ", ".join(str(p["pid"]) for p in procs) + reasons.append( + f"stale-runtime: Duplicate MCP server process(es) detected for profile '{profile}' (PIDs: {pids_str}). " + "Manual or duplicate launches defeat staleness detection and cannot receive client stdio." + ) + client_procs = [p for p in procs if p["is_client_managed"]] + if client_procs: + client_procs.sort(key=lambda p: p["start_time"], reverse=True) + running_profiles[profile] = client_procs[0] + else: + pids_str = ", ".join(str(p["pid"]) for p in procs) + reasons.append( + f"stale-runtime: Manually launched MCP process(es) detected without client-managed provenance for profile '{profile}' (PIDs: {pids_str}). " + "Manual launches cannot serve client stdio and are ignored for runtime freshness." + ) + + if unsupported_env_found: + reasons.append( + f"unsupported-env: Unsupported GITEA_* environment variable override(s) detected: {', '.join(sorted(unsupported_env_found))}. " + "Unknown env overrides are unsupported." + ) if self_stale: # #685: report-only — no config utime, no thread, no os._exit. @@ -20644,6 +21599,7 @@ def _check_mcp_runtimes_diagnostics(task: str, matching_profiles: list[str]) -> return reasons + @mcp.tool() def gitea_resolve_task_capability( task: str, @@ -20999,10 +21955,15 @@ def gitea_resolve_task_capability( # serving process/profile inventory is stale — even if permission is OK. if runtime_stale_blocker: next_safe_action = ( - "blocker_kind=runtime_reconnect_required: reconnect/restart the " - "IDE-managed Gitea MCP server for this profile so it reloads current " - "master. Do not edit mcp_config.json by hand; the resolver does not " - "touch config, spawn recovery threads, or terminate the process." + "blocker_kind=runtime_reconnect_required: call " + "gitea_request_mcp_reconnect(namespace=, " + "reason='stale-runtime', client='codex') for a typed operator " + "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 @@ -22560,6 +23521,129 @@ def gitea_workflow_dashboard( 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() def gitea_request_mcp_restart( remote: str = "dadeschools", @@ -22576,8 +23660,9 @@ def gitea_request_mcp_restart( target_connector: str | None = None, drain_proof_json: str | None = None, request_break_glass: bool = False, + prior_recovery_attempts_json: str | None = None, ) -> dict: - """Evaluate a proposed MCP restart and return an impact preview (#658). + """Evaluate a proposed MCP restart and return an impact preview (#658/#669). Central restart coordinator: resolves the requested restart class, gathers live control-plane state (sessions, @@ -22601,10 +23686,16 @@ def gitea_request_mcp_restart( independent — the drain gate proves the blast radius was drained and knows nothing about whether this requester may request this class — so a class the matrix denied never reports an authorized apply. Break-glass bypasses the - drain proof only; it never bypasses the class matrix. ``apply_gate`` carries + drain proof and, when env-authorized, the #669 attempt-log requirement for + broad restarts; it never bypasses the class matrix. ``apply_gate`` carries ``drain_gate_allow`` and ``restart_class_authorized`` so a denial is attributable to the authorization that produced it. + ``prior_recovery_attempts_json`` (#669) is an optional JSON array of prior + narrow recovery attempts ``{action, outcome, reason, ...}``. Rolling / full + / host restart classes require at least one *insufficient* narrower attempt + unless break-glass is authorized. + Operator override authority is read from the process environment (``GITEA_OPERATOR_RESTART_OVERRIDE_AUTHORIZATION``), never self-asserted by the requesting session: ``request_override`` only expresses caller intent @@ -22710,12 +23801,37 @@ def gitea_request_mcp_restart( requester_role ) + prior_recovery_attempts: list[dict] = [] + if prior_recovery_attempts_json: + try: + parsed_attempts = json.loads(prior_recovery_attempts_json) + if isinstance(parsed_attempts, list): + prior_recovery_attempts = [ + dict(a) for a in parsed_attempts if isinstance(a, dict) + ] + else: + incomplete_reasons.append( + "prior_recovery_attempts_json must be a JSON array (#669)" + ) + inventory_complete = False + except (ValueError, TypeError) as exc: + incomplete_reasons.append( + f"invalid prior_recovery_attempts_json: {_redact(str(exc))}" + ) + inventory_complete = False + + break_glass_authorized = bool( + (os.environ.get("GITEA_BREAKGLASS_RESTART_AUTHORIZATION") or "").strip() + ) + break_glass = bool(request_break_glass and break_glass_authorized) + inventory = { "sessions": sessions, "leases": leases, "terminal_lock": terminal_lock, "inventory_complete": inventory_complete, "incomplete_reasons": incomplete_reasons, + "prior_recovery_attempts": prior_recovery_attempts, } report = restart_coordinator.evaluate_restart_impact( @@ -22731,6 +23847,7 @@ def gitea_request_mcp_restart( target_session_id=target_session_id, target_role=target_role, target_connector=target_connector, + break_glass=break_glass, ) payload = report.as_dict() @@ -22795,13 +23912,6 @@ def gitea_request_mcp_restart( except (ValueError, TypeError) as exc: proof_parse_error = f"invalid drain_proof_json: {_redact(str(exc))}" - break_glass_authorized = bool( - ( - os.environ.get("GITEA_BREAKGLASS_RESTART_AUTHORIZATION") or "" - ).strip() - ) - break_glass = bool(request_break_glass and break_glass_authorized) - expected_fp = drain_proof.impact_fingerprint(report.as_dict()) gate = drain_proof.gate_apply_restart( proof=proof_obj, diff --git a/issue_lock_renewal.py b/issue_lock_renewal.py index ec8a436..4ab0be9 100644 --- a/issue_lock_renewal.py +++ b/issue_lock_renewal.py @@ -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( assessment: Mapping[str, Any] | None, *, diff --git a/issue_lock_store.py b/issue_lock_store.py index f216656..8237305 100644 --- a/issue_lock_store.py +++ b/issue_lock_store.py @@ -299,9 +299,13 @@ def _ownership_refusals( f"lock worktree '{lock.get('worktree_path')}' does not match " f"'{worktree_path}'" ) - lease = lock.get("work_lease") if isinstance(lock, dict) else None - claimant = lease.get("claimant") if isinstance(lease, dict) else None - claimant = claimant if isinstance(claimant, dict) else {} + # #953 AC2/AC13/AC14: read through the shared claimant reader so a lock + # written by bootstrap — which records the claimant at the top level — is + # 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_profile = str(claimant.get("profile") or "").strip() 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) +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( *, 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): return {} - claimant = lock.get("claimant") + lease = lock.get("work_lease") + claimant = lease.get("claimant") if isinstance(lease, dict) else None if not isinstance(claimant, dict): - lease = lock.get("work_lease") - claimant = lease.get("claimant") if isinstance(lease, dict) else None + claimant = lock.get("claimant") if not isinstance(claimant, dict): return {} return { - "username": str(claimant.get("username") or ""), - "profile": str(claimant.get("profile") or ""), + "username": str(claimant.get("username") or "").strip(), + "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( existing_lock: dict[str, Any] | None, incoming_lock: dict[str, Any], diff --git a/mcp_client_reconnect.py b/mcp_client_reconnect.py new file mode 100644 index 0000000..2283d0a --- /dev/null +++ b/mcp_client_reconnect.py @@ -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) diff --git a/mcp_config_drift.py b/mcp_config_drift.py new file mode 100644 index 0000000..b772873 --- /dev/null +++ b/mcp_config_drift.py @@ -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() diff --git a/mcp_namespace_health.py b/mcp_namespace_health.py index a3c2da1..4934e21 100644 --- a/mcp_namespace_health.py +++ b/mcp_namespace_health.py @@ -225,6 +225,16 @@ def classify_namespace_probe( # on bad data without treating success as IDE proof). blocks = namespace_health_blocks_task("merge_pr", healthy) + import gitea_config + raw_env = process.get("env") if isinstance(process, dict) else None + unconsumed_env = gitea_config.get_unconsumed_gitea_env_overrides(raw_env) + is_client_managed = bool( + env_summary.get("GITEA_CLIENT_MANAGED") in ("1", "true", "yes", "client_managed") + or env_summary.get("GITEA_MCP_CLIENT_MANAGED") in ("1", "true", "yes", "client_managed") + or env_summary.get("GITEA_SERVER_PROVENANCE") == "client_managed" + ) + provenance = "client_managed" if is_client_managed else "manual_launch" + return { "success": healthy, "healthy": healthy, @@ -240,6 +250,9 @@ def classify_namespace_probe( "error_message": error_message or None, "reasons": reasons, "remediation": remediation, + "provenance": provenance, + "is_client_managed": is_client_managed, + "unconsumed_gitea_env": unconsumed_env, "diagnostics": { "namespace": ns, "required_tool": tool, @@ -248,6 +261,9 @@ def classify_namespace_probe( "env": env_summary, "config_path": config_path, "probe_source": source, + "provenance": provenance, + "is_client_managed": is_client_managed, + "unconsumed_gitea_env": unconsumed_env, }, "blocks_merge_workflow": blocks, } diff --git a/mcp_restart_paths.py b/mcp_restart_paths.py index 087fe72..817131d 100644 --- a/mcp_restart_paths.py +++ b/mcp_restart_paths.py @@ -225,8 +225,33 @@ _RESTART_PATHS: tuple[RestartPath, ...] = ( "exact_safe_next_action pointing at IDE/client reconnect; performs " "no restart, thread spawn, config touch, or os._exit." ), - locations=("gitea_mcp_server.py (gitea_resolve_task_capability)",), - references=("#685", "#657"), + locations=( + "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( path_id="manual_daemon_kill", @@ -271,7 +296,8 @@ _RESTART_PATHS: tuple[RestartPath, ...] = ( title="Host/IDE MCP reconnect", mechanism=( "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, guard=( @@ -279,8 +305,12 @@ _RESTART_PATHS: tuple[RestartPath, ...] = ( "gates point operators toward; documented as residual host " "behavior. No in-process code initiates it." ), - locations=("host/IDE",), - references=("#584", "#656", "#657"), + locations=( + "host/IDE", + "mcp_client_reconnect.py", + "gitea_mcp_server.py (gitea_request_mcp_reconnect)", + ), + references=("#584", "#656", "#657", "#678"), residual_host=True, ), RestartPath( diff --git a/pytest.ini b/pytest.ini new file mode 100644 index 0000000..531871f --- /dev/null +++ b/pytest.ini @@ -0,0 +1,3 @@ +[pytest] +testpaths = tests +norecursedirs = branches .git venv __pycache__ graphify-out diff --git a/recovery_playbook.py b/recovery_playbook.py new file mode 100644 index 0000000..268a3be --- /dev/null +++ b/recovery_playbook.py @@ -0,0 +1,583 @@ +"""Scoped MCP recovery playbook (#669). + +Operational recovery must prefer the *narrowest* action that can fix the +symptom. Full MCP / host restarts are last-resort rungs on a documented +ladder; the coordinator refuses those rungs unless a prior attempt log +shows narrower recoveries already failed (or break-glass is authorized). + +This module is pure classification and recommendation: + +* No network, filesystem, or process I/O. +* Never restarts anything. +* Narrow recovery *execution* is delegated to existing tools/docs (linked + per rung) — the playbook records which rung to try next and whether + escalation to a broad restart is allowed. + +Design lineage: umbrella #655, class matrix #663, coordinator #658, +auto-reconnect #584, stale-runtime #610, contamination #630, audit #665. +Vision #652 / roadmap #653. +""" + +from __future__ import annotations + +from dataclasses import dataclass, field +from datetime import datetime, timezone +from enum import Enum +from typing import Any, Mapping, Sequence + +PLAYBOOK_VERSION = "1.0.0-issue-669" + +# Attempt outcomes that count as "tried and insufficient" for escalation. +INSUFFICIENT_OUTCOMES = frozenset( + { + "failed", + "insufficient", + "denied", + "unresolved", + "timeout", + "error", + } +) + +# Break-glass / operator override still records that the ladder was skipped. +OUTCOME_BREAK_GLASS = "break_glass" +OUTCOME_SUCCESS = "success" +OUTCOME_SKIPPED = "skipped" + + +class RecoveryAction(str, Enum): + """Ordered recovery ladder (narrow → broad).""" + + CLIENT_RECONNECT = "client_reconnect" + CAPABILITY_REFRESH = "capability_refresh" + SESSION_RECONNECT = "session_reconnect" + CONFIGURATION_RELOAD = "configuration_reload" + LEASE_RECOVERY = "lease_recovery" + WORKER_RESTART = "worker_restart" + ROLE_RUNTIME_RESTART = "role_runtime_restart" + CONNECTOR_RESTART = "connector_restart" + ROLLING_MCP_RESTART = "rolling_mcp_restart" + FULL_MCP_RESTART = "full_mcp_restart" + HOST_RESTART = "host_restart" + + +# Classes that require a prior narrow-attempt log (unless break-glass). +BROAD_RESTART_ACTIONS: frozenset[RecoveryAction] = frozenset( + { + RecoveryAction.ROLLING_MCP_RESTART, + RecoveryAction.FULL_MCP_RESTART, + RecoveryAction.HOST_RESTART, + } +) + +# Map #663 restart_class strings onto playbook actions. +RESTART_CLASS_TO_ACTION: dict[str, RecoveryAction] = { + "client_reconnect": RecoveryAction.CLIENT_RECONNECT, + "session_reconnect": RecoveryAction.SESSION_RECONNECT, + "configuration_reload": RecoveryAction.CONFIGURATION_RELOAD, + "worker_restart": RecoveryAction.WORKER_RESTART, + "role_runtime_restart": RecoveryAction.ROLE_RUNTIME_RESTART, + "connector_restart": RecoveryAction.CONNECTOR_RESTART, + "rolling_mcp_restart": RecoveryAction.ROLLING_MCP_RESTART, + "full_mcp_restart": RecoveryAction.FULL_MCP_RESTART, + "host_restart": RecoveryAction.HOST_RESTART, +} + + +@dataclass(frozen=True) +class RecoveryRung: + """One rung on the recovery ladder.""" + + action: RecoveryAction + rank: int + summary: str + # Existing implementation or explicit delegation target. + implementation: str + issue_links: tuple[str, ...] + self_service: bool + # Restart-class permission when this rung is requested via coordinator. + restart_class: str | None = None + + def as_dict(self) -> dict[str, Any]: + return { + "action": self.action.value, + "rank": self.rank, + "summary": self.summary, + "implementation": self.implementation, + "issue_links": list(self.issue_links), + "self_service": self.self_service, + "restart_class": self.restart_class, + } + + +# Canonical ladder. Rank 0 is narrowest. +RECOVERY_LADDER: tuple[RecoveryRung, ...] = ( + RecoveryRung( + RecoveryAction.CLIENT_RECONNECT, + 0, + "Reconnect the IDE/client MCP transport (EOF / transport flap).", + "Host auto-reconnect or explicit client reconnect; " + "docs/mcp-namespace-eof-recovery.md", + ("#584", "#655"), + True, + "client_reconnect", + ), + RecoveryRung( + RecoveryAction.CAPABILITY_REFRESH, + 1, + "Re-resolve task capability and clear stale permission context.", + "Delegated: gitea_resolve_task_capability + gitea_whoami " + "(no process change).", + ("#610", "#685", "#655"), + True, + None, + ), + RecoveryRung( + RecoveryAction.SESSION_RECONNECT, + 2, + "Rebind identity, workspace, and namespace for one session.", + "Delegated: gitea_get_runtime_context + explicit worktree_path " + "rebind (#618); docs/mcp-namespace-health.md", + ("#543", "#618", "#655"), + True, + "session_reconnect", + ), + RecoveryRung( + RecoveryAction.CONFIGURATION_RELOAD, + 3, + "Gracefully reload configuration without replacing the daemon.", + "restart_coordinator class configuration_reload; console " + "system.reload_namespace (#642).", + ("#642", "#663", "#655"), + False, + "configuration_reload", + ), + RecoveryRung( + RecoveryAction.LEASE_RECOVERY, + 4, + "Recover or rebind stale leases/locks without a process restart.", + "Delegated: issue lock recovery / lease lifecycle paths " + "(#702, #753, #790).", + ("#702", "#753", "#790", "#655"), + False, + None, + ), + RecoveryRung( + RecoveryAction.WORKER_RESTART, + 5, + "Restart one worker after its own lease and mutation scope drains.", + "restart_coordinator class worker_restart (#663).", + ("#663", "#655"), + False, + "worker_restart", + ), + RecoveryRung( + RecoveryAction.ROLE_RUNTIME_RESTART, + 6, + "Restart one role runtime and re-probe that namespace only.", + "restart_coordinator class role_runtime_restart; console " + "system.restart_namespace (#642).", + ("#642", "#663", "#655"), + False, + "role_runtime_restart", + ), + RecoveryRung( + RecoveryAction.CONNECTOR_RESTART, + 7, + "Restart one connector while unrelated runtimes stay available.", + "restart_coordinator class connector_restart (#663).", + ("#663", "#655"), + False, + "connector_restart", + ), + RecoveryRung( + RecoveryAction.ROLLING_MCP_RESTART, + 8, + "Drain/restart/verify one instance at a time (HA path).", + "restart_coordinator class rolling_mcp_restart; design #668.", + ("#668", "#663", "#655"), + False, + "rolling_mcp_restart", + ), + RecoveryRung( + RecoveryAction.FULL_MCP_RESTART, + 9, + "Full stable-control MCP process restart after verified full drain.", + "restart_coordinator class full_mcp_restart; requires attempt log " + "unless break-glass (#669).", + ("#658", "#661", "#663", "#669", "#655"), + False, + "full_mcp_restart", + ), + RecoveryRung( + RecoveryAction.HOST_RESTART, + 10, + "Host/infrastructure restart — broadest last-resort action.", + "restart_coordinator class host_restart; operator-owned.", + ("#663", "#669", "#655"), + False, + "host_restart", + ), +) + +_LADDER_BY_ACTION: dict[RecoveryAction, RecoveryRung] = { + rung.action: rung for rung in RECOVERY_LADDER +} + +# Symptom tokens → preferred first rung (decision tree, #663 lineage). +SYMPTOM_TO_FIRST_ACTION: dict[str, RecoveryAction] = { + "transport_eof": RecoveryAction.CLIENT_RECONNECT, + "client_closing_eof": RecoveryAction.CLIENT_RECONNECT, + "transport_flap": RecoveryAction.CLIENT_RECONNECT, + "namespace_disconnected": RecoveryAction.CLIENT_RECONNECT, + "stale_capability": RecoveryAction.CAPABILITY_REFRESH, + "permission_stale": RecoveryAction.CAPABILITY_REFRESH, + "runtime_reconnect_required": RecoveryAction.CAPABILITY_REFRESH, + "stale_runtime": RecoveryAction.SESSION_RECONNECT, + "worktree_unbound": RecoveryAction.SESSION_RECONNECT, + "namespace_unhealthy": RecoveryAction.SESSION_RECONNECT, + "config_drift": RecoveryAction.CONFIGURATION_RELOAD, + "profile_misbound": RecoveryAction.CONFIGURATION_RELOAD, + "stale_lease": RecoveryAction.LEASE_RECOVERY, + "dead_pid_lock": RecoveryAction.LEASE_RECOVERY, + "orphan_worktree": RecoveryAction.LEASE_RECOVERY, + "single_worker_stuck": RecoveryAction.WORKER_RESTART, + "role_runtime_dead": RecoveryAction.ROLE_RUNTIME_RESTART, + "connector_dead": RecoveryAction.CONNECTOR_RESTART, + "ha_instance_unhealthy": RecoveryAction.ROLLING_MCP_RESTART, + "daemon_corrupt": RecoveryAction.FULL_MCP_RESTART, + "full_process_deadlock": RecoveryAction.FULL_MCP_RESTART, + "host_unresponsive": RecoveryAction.HOST_RESTART, +} + + +def _utc_now() -> datetime: + return datetime.now(timezone.utc) + + +def resolve_action(value: RecoveryAction | str) -> RecoveryAction: + """Resolve a recovery action or fail closed for unknown values.""" + if isinstance(value, RecoveryAction): + return value + text = str(value or "").strip() + # Accept #663 restart_class aliases. + if text in RESTART_CLASS_TO_ACTION: + return RESTART_CLASS_TO_ACTION[text] + try: + return RecoveryAction(text) + except ValueError as exc: + raise ValueError( + f"unknown recovery action {value!r}; deny (fail closed, #669)" + ) from exc + + +def ladder_rank(action: RecoveryAction | str) -> int: + resolved = resolve_action(action) + return _LADDER_BY_ACTION[resolved].rank + + +def rung_for(action: RecoveryAction | str) -> RecoveryRung: + return _LADDER_BY_ACTION[resolve_action(action)] + + +def normalize_attempt(raw: Mapping[str, Any]) -> dict[str, Any] | None: + """Normalize one prior-recovery attempt record; return None if unusable.""" + if not isinstance(raw, Mapping): + return None + action_raw = raw.get("action") or raw.get("recovery_action") or raw.get( + "restart_class" + ) + if not action_raw: + return None + try: + action = resolve_action(str(action_raw)) + except ValueError: + return None + outcome = str( + raw.get("outcome") or raw.get("status") or raw.get("result") or "" + ).strip().lower() + if not outcome: + return None + recorded_at = raw.get("recorded_at") or raw.get("at") or raw.get("timestamp") + reason = str(raw.get("reason") or raw.get("detail") or "").strip() + actor = str(raw.get("actor") or raw.get("session_id") or "").strip() + return { + "action": action.value, + "outcome": outcome, + "reason": reason, + "actor": actor, + "recorded_at": recorded_at, + "rank": ladder_rank(action), + "raw": dict(raw), + } + + +def normalize_attempt_log( + attempts: Sequence[Mapping[str, Any]] | None, +) -> list[dict[str, Any]]: + """Return usable attempt records in ladder order.""" + out: list[dict[str, Any]] = [] + for raw in attempts or (): + norm = normalize_attempt(raw) + if norm is not None: + out.append(norm) + out.sort(key=lambda a: (a["rank"], str(a.get("recorded_at") or ""))) + return out + + +def narrower_insufficient_attempts( + attempts: Sequence[Mapping[str, Any]] | None, + *, + requested: RecoveryAction | str, +) -> list[dict[str, Any]]: + """Return prior attempts narrower than *requested* that were insufficient.""" + target_rank = ladder_rank(requested) + usable = [] + for attempt in normalize_attempt_log(attempts): + if attempt["rank"] >= target_rank: + continue + if attempt["outcome"] in INSUFFICIENT_OUTCOMES: + usable.append(attempt) + return usable + + +@dataclass(frozen=True) +class EscalationAssessment: + """Whether a requested broad recovery may proceed given the attempt log.""" + + requested_action: str + allowed: bool + require_attempt_log: bool + break_glass: bool + reasons: list[str] = field(default_factory=list) + qualifying_attempts: list[dict[str, Any]] = field(default_factory=list) + recommended_next: list[dict[str, Any]] = field(default_factory=list) + playbook_version: str = PLAYBOOK_VERSION + + def as_dict(self) -> dict[str, Any]: + return { + "playbook_version": self.playbook_version, + "requested_action": self.requested_action, + "allowed": self.allowed, + "require_attempt_log": self.require_attempt_log, + "break_glass": self.break_glass, + "reasons": list(self.reasons), + "qualifying_attempts": list(self.qualifying_attempts), + "recommended_next": list(self.recommended_next), + } + + +def assess_escalation( + requested: RecoveryAction | str, + *, + prior_recovery_attempts: Sequence[Mapping[str, Any]] | None = None, + break_glass: bool = False, +) -> EscalationAssessment: + """Gate broad restarts on a prior narrow-attempt log (#669 AC3). + + Narrow / mid-ladder actions do not require a prior attempt log. + ``full_mcp_restart``, ``host_restart``, and ``rolling_mcp_restart`` + require at least one *insufficient* narrower attempt unless + ``break_glass`` is true. + """ + action = resolve_action(requested) + require_log = action in BROAD_RESTART_ACTIONS + reasons: list[str] = [] + qualifying = narrower_insufficient_attempts( + prior_recovery_attempts, requested=action + ) + + if not require_log: + return EscalationAssessment( + requested_action=action.value, + allowed=True, + require_attempt_log=False, + break_glass=bool(break_glass), + reasons=["narrow recovery; attempt log not required"], + qualifying_attempts=qualifying, + recommended_next=[], + ) + + if break_glass: + return EscalationAssessment( + requested_action=action.value, + allowed=True, + require_attempt_log=True, + break_glass=True, + reasons=[ + "break-glass authorized; broad restart permitted without " + "narrow-attempt log (#669)" + ], + qualifying_attempts=qualifying, + recommended_next=[], + ) + + if qualifying: + return EscalationAssessment( + requested_action=action.value, + allowed=True, + require_attempt_log=True, + break_glass=False, + reasons=[ + f"{len(qualifying)} narrower recovery attempt(s) recorded as " + "insufficient; escalation permitted" + ], + qualifying_attempts=qualifying, + recommended_next=[], + ) + + # Deny: recommend the next untried narrow rung(s). + recommended = recommend_actions( + symptoms=(), + prior_recovery_attempts=prior_recovery_attempts, + max_actions=3, + ) + reasons.append( + f"{action.value} requires a prior attempt log of insufficient " + "narrower recoveries (or break-glass); none found — deny (fail " + "closed, #669)" + ) + return EscalationAssessment( + requested_action=action.value, + allowed=False, + require_attempt_log=True, + break_glass=False, + reasons=reasons, + qualifying_attempts=[], + recommended_next=recommended.get("recommended_actions") or [], + ) + + +def recommend_actions( + *, + symptoms: Sequence[str] = (), + prior_recovery_attempts: Sequence[Mapping[str, Any]] | None = None, + max_actions: int = 5, +) -> dict[str, Any]: + """Return ordered recommended recovery actions for the given symptoms. + + Soft mode (rollout): recommendations only — callers decide whether to + hard-gate. Hard mode for broad restarts is :func:`assess_escalation`. + """ + attempted_success = { + a["action"] + for a in normalize_attempt_log(prior_recovery_attempts) + if a["outcome"] == OUTCOME_SUCCESS + } + attempted_any = { + a["action"] for a in normalize_attempt_log(prior_recovery_attempts) + } + + first_actions: list[RecoveryAction] = [] + for symptom in symptoms: + key = str(symptom or "").strip().lower().replace(" ", "_").replace("-", "_") + mapped = SYMPTOM_TO_FIRST_ACTION.get(key) + if mapped is not None and mapped not in first_actions: + first_actions.append(mapped) + + # Default entry: client reconnect then walk the ladder. + if not first_actions: + first_actions = [RecoveryAction.CLIENT_RECONNECT] + + recommended: list[dict[str, Any]] = [] + seen: set[str] = set() + min_rank = min(ladder_rank(a) for a in first_actions) + + for rung in RECOVERY_LADDER: + if rung.rank < min_rank: + continue + if rung.action.value in attempted_success: + continue + if rung.action.value in seen: + continue + # Prefer rungs not yet attempted; still list previously-failed ones + # only if nothing else remains. + entry = rung.as_dict() + entry["already_attempted"] = rung.action.value in attempted_any + recommended.append(entry) + seen.add(rung.action.value) + if len(recommended) >= max(1, int(max_actions)): + break + + return { + "playbook_version": PLAYBOOK_VERSION, + "symptoms": [str(s) for s in symptoms], + "recommended_actions": recommended, + "ladder": [r.as_dict() for r in RECOVERY_LADDER], + "read_only": True, + "hard_gate_note": ( + "Broad restarts (rolling/full/host) still require " + "assess_escalation / coordinator attempt-log enforcement." + ), + } + + +def build_attempt_record( + action: RecoveryAction | str, + *, + outcome: str, + reason: str = "", + actor: str = "", + recorded_at: str | None = None, + extra: Mapping[str, Any] | None = None, +) -> dict[str, Any]: + """Build a durable-shaped attempt log entry for inventory/audit (#665).""" + resolved = resolve_action(action) + record = { + "action": resolved.value, + "outcome": str(outcome or "").strip().lower(), + "reason": str(reason or "").strip(), + "actor": str(actor or "").strip(), + "recorded_at": recorded_at or _utc_now().isoformat(), + "rank": ladder_rank(resolved), + "playbook_version": PLAYBOOK_VERSION, + } + if extra: + record["extra"] = dict(extra) + return record + + +def recovery_metrics( + attempts: Sequence[Mapping[str, Any]] | None, +) -> dict[str, Any]: + """Compute the fraction of recoveries that avoided full/host restart. + + A recovery *episode* is approximated as one attempt with + ``outcome=success``. Successes on non-broad rungs count as avoided full + restart; successes on full/host count as full-restart recoveries. + """ + norms = normalize_attempt_log(attempts) + successes = [a for a in norms if a["outcome"] == OUTCOME_SUCCESS] + broad_success = [ + a + for a in successes + if resolve_action(a["action"]) + in {RecoveryAction.FULL_MCP_RESTART, RecoveryAction.HOST_RESTART} + ] + avoided = [a for a in successes if a not in broad_success] + total = len(successes) + fraction_avoided = (len(avoided) / total) if total else None + return { + "playbook_version": PLAYBOOK_VERSION, + "attempts_total": len(norms), + "successes_total": total, + "successes_avoided_full_restart": len(avoided), + "successes_full_or_host_restart": len(broad_success), + "fraction_avoided_full_restart": fraction_avoided, + "insufficient_attempts": sum( + 1 for a in norms if a["outcome"] in INSUFFICIENT_OUTCOMES + ), + } + + +def ladder_document() -> dict[str, Any]: + """Machine-readable ladder for docs/tools inventory.""" + return { + "playbook_version": PLAYBOOK_VERSION, + "parent_issues": ["#655", "#652", "#653"], + "enforcement_issue": "#669", + "ladder": [r.as_dict() for r in RECOVERY_LADDER], + "broad_restart_actions": [a.value for a in sorted(BROAD_RESTART_ACTIONS, key=lambda x: x.value)], + "insufficient_outcomes": sorted(INSUFFICIENT_OUTCOMES), + "symptom_map": {k: v.value for k, v in sorted(SYMPTOM_TO_FIRST_ACTION.items())}, + } diff --git a/restart_coordinator.py b/restart_coordinator.py index 5452ad0..8134b44 100644 --- a/restart_coordinator.py +++ b/restart_coordinator.py @@ -1,4 +1,4 @@ -"""MCP restart coordinator and impact analysis (#658). +"""MCP restart coordinator and impact analysis (#658 / #669). Before any sanctioned MCP restart, a central coordinator must evaluate the live control-plane state — active sessions, leases/locks, in-flight issue/PR @@ -16,6 +16,9 @@ Design rules (mirrors the read-only posture of ``workflow_dashboard`` / a mutative apply path is a later child gated by a drain proof (non-goal here). * **Fail closed.** If the inventory is not explicitly complete, the verdict is ``unsafe`` / deny — an incomplete evaluation must never green-light a restart. +* **Narrow-first (#669).** Broad classes (rolling / full / host) require a + prior attempt log of insufficient narrower recoveries unless break-glass is + authorized. See :mod:`recovery_playbook`. * **No secrets.** Session ids, pids, and profiles are operational metadata, not credentials; nothing secret flows through this module. @@ -32,8 +35,9 @@ from enum import Enum from typing import Any, Mapping, Sequence import lease_lifecycle +import recovery_playbook -COORDINATOR_VERSION = "1.1.0-issue-663" +COORDINATOR_VERSION = "1.2.0-issue-669" # Restart verdicts. Exactly the three the acceptance criteria name. VERDICT_SAFE = "safe" @@ -349,6 +353,10 @@ class RestartImpactReport: counts: dict[str, int] audit_record: dict[str, Any] incomplete_reasons: list[str] = field(default_factory=list) + # #669 playbook escalation gate (attempt-log enforcement). + playbook_escalation: dict[str, Any] = field(default_factory=dict) + attempt_log_satisfied: bool = True + break_glass: bool = False def as_dict(self) -> dict[str, Any]: return { @@ -382,6 +390,9 @@ class RestartImpactReport: "prior_recovery_attempts": list(self.prior_recovery_attempts), "counts": dict(self.counts), "audit_record": dict(self.audit_record), + "playbook_escalation": dict(self.playbook_escalation), + "attempt_log_satisfied": self.attempt_log_satisfied, + "break_glass": self.break_glass, } @@ -496,6 +507,7 @@ def evaluate_restart_impact( target_session_id: str | None = None, target_role: str | None = None, target_connector: str | None = None, + break_glass: bool = False, ) -> RestartImpactReport: """Evaluate a proposed MCP restart and return an impact preview. @@ -584,6 +596,30 @@ def evaluate_restart_impact( dict(a) for a in (inventory.get("prior_recovery_attempts") or []) ] + # #669: broad restarts require a prior narrow-attempt log unless break-glass. + playbook_escalation: dict[str, Any] = {} + attempt_log_satisfied = True + if policy_enforced and resolved_class is not None: + try: + escalation = recovery_playbook.assess_escalation( + resolved_class.value, + prior_recovery_attempts=prior_recovery_attempts, + break_glass=bool(break_glass), + ) + playbook_escalation = escalation.as_dict() + attempt_log_satisfied = bool(escalation.allowed) + if not attempt_log_satisfied: + authorization_reasons.extend(list(escalation.reasons)) + except ValueError as exc: + # Unknown mapping should never happen for enum values; fail closed. + attempt_log_satisfied = False + playbook_escalation = { + "allowed": False, + "reasons": [str(exc)], + "playbook_version": recovery_playbook.PLAYBOOK_VERSION, + } + authorization_reasons.append(str(exc)) + session_impacts = [ _classify_session( s, @@ -682,6 +718,7 @@ def evaluate_restart_impact( and role_authorized and approval_satisfied and target_complete + and attempt_log_satisfied ) if policy_enforced and not authorization_ok: @@ -745,6 +782,7 @@ def evaluate_restart_impact( "affected_issues": len(affected_issues), "affected_prs": len(affected_prs), "prior_recovery_attempts": len(prior_recovery_attempts), + "attempt_log_satisfied": attempt_log_satisfied, } audit_record = { @@ -765,6 +803,9 @@ def evaluate_restart_impact( "allow_restart": allow_restart, "blast_radius": blast_radius, "counts": counts, + "attempt_log_satisfied": attempt_log_satisfied, + "break_glass": bool(break_glass), + "playbook_version": recovery_playbook.PLAYBOOK_VERSION, } return RestartImpactReport( @@ -804,4 +845,7 @@ def evaluate_restart_impact( counts=counts, audit_record=audit_record, incomplete_reasons=incomplete_reasons, + playbook_escalation=playbook_escalation, + attempt_log_satisfied=attempt_log_satisfied, + break_glass=bool(break_glass), ) diff --git a/role_namespace_gate.py b/role_namespace_gate.py index 7ed1073..a5e3b25 100644 --- a/role_namespace_gate.py +++ b/role_namespace_gate.py @@ -74,6 +74,43 @@ def check_author_mutation_namespace( 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, *, remote=None, repository=None) -> dict: """Structured mutation metadata for audit records (#209).""" diff --git a/role_session_router.py b/role_session_router.py index e557266..6b0a6de 100644 --- a/role_session_router.py +++ b/role_session_router.py @@ -75,6 +75,10 @@ AUTHOR_TASKS = frozenset({ "push_branch", "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", "comment_pr", "address_pr_change_requests", @@ -112,6 +116,12 @@ TASK_REQUIRED_ROLE = { "claim_issue": "author", "create_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", "comment_pr": "author", "address_pr_change_requests": "author", diff --git a/stable_branch_push_guard.py b/stable_branch_push_guard.py index aa15b83..8d0c667 100644 --- a/stable_branch_push_guard.py +++ b/stable_branch_push_guard.py @@ -63,6 +63,11 @@ CONTAMINATION_GATED_TASKS = frozenset({ "merge_pr", "delete_branch", "complete_issue", + # Web console recovery playbooks that write (#644). These mutate runtime + # binding and process state, so a live contamination marker must block them + # exactly as it blocks the Gitea-side mutations above. The reconciler + # cleanup playbook is the designated remedy and is exempted by its caller. + "console_recovery_apply", }) CONTAMINATION_KIND = "stable_branch_push" diff --git a/task_capability_map.py b/task_capability_map.py index 878cf0a..393ce98 100644 --- a/task_capability_map.py +++ b/task_capability_map.py @@ -41,6 +41,27 @@ TASK_CAPABILITY_MAP: dict[str, dict[str, str]] = { "permission": "gitea.issue.comment", "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). "recover_dirty_orphaned_issue_worktree": { "permission": "gitea.issue.comment", @@ -142,6 +163,23 @@ TASK_CAPABILITY_MAP: dict[str, dict[str, str]] = { "permission": "gitea.read", "role": "author", }, + # #644: Phase 2 Web Console recovery tasks. + "clear_stale_binding": { + "permission": "gitea.read", + "role": "author", + }, + "rebind_session_worktree": { + "permission": "gitea.read", + "role": "author", + }, + # The console playbook orchestrates gitea_reconcile_merged_cleanups, whose + # own gate is gitea.read (matching the existing reconcile_merged_cleanups + # entry). Declaring a stricter permission here stated a second, conflicting + # authority for one operation. + "reconcile_cleanups": { + "permission": "gitea.read", + "role": "reconciler", + }, # PR synchronization lifecycle: assess is read-only (any role with gitea.read); # update-by-merge is author-only and mutates the PR head via Gitea API. "assess_pr_sync_status": { diff --git a/tests/conftest.py b/tests/conftest.py index 4276e32..57e0565 100644 --- a/tests/conftest.py +++ b/tests/conftest.py @@ -44,6 +44,8 @@ def _reset_mutation_authority(monkeypatch): ]: monkeypatch.delenv(env_key, raising=False) + monkeypatch.setenv("GITEA_CLIENT_MANAGED", "1") + # Isolate durable session-state files so tests never share host cache (#559). import tempfile diff --git a/tests/test_allocator_service.py b/tests/test_allocator_service.py index 3e252a1..5c37d0d 100644 --- a/tests/test_allocator_service.py +++ b/tests/test_allocator_service.py @@ -7,6 +7,7 @@ import tempfile import threading import unittest from concurrent.futures import ThreadPoolExecutor, as_completed +from datetime import datetime, timezone from allocator_service import ( OUTCOME_ASSIGNED, @@ -15,6 +16,7 @@ from allocator_service import ( OUTCOME_PREVIEW, OUTCOME_WAIT, WorkCandidate, + _drop_expired_claims, allocate_next_work, candidate_from_dict, classify_skip, @@ -362,5 +364,161 @@ class AllocatorServiceTest(unittest.TestCase): self.assertIn("unavailable", res["reasons"][0].lower()) +class SideEffectFreeAllocationTest(unittest.TestCase): + """``side_effect_free`` dry runs write nothing to the control plane (#643). + + A plain ``apply=False`` still called ``upsert_session`` and + ``expire_stale_leases`` before the apply branch was consulted, so a caller + advertising a read-only preview mutated on every call — one unreferenced + session row per preview, plus a global lease sweep. + """ + + def setUp(self) -> None: + self._tmp = tempfile.TemporaryDirectory() + self.db = ControlPlaneDB(os.path.join(self._tmp.name, "cp.sqlite3")) + + def tearDown(self) -> None: + self._tmp.cleanup() + + def _alloc(self, **kwargs): + defaults = dict( + db=self.db, + session_id="s-preview", + role="author", + remote="prgs", + org="org", + repo="repo", + candidates=[ + WorkCandidate(kind="issue", number=643, labels=("status:ready",)) + ], + apply=False, + profile_name="prgs-author", + username="jcwalker3", + ) + defaults.update(kwargs) + return allocate_next_work(**defaults) + + def _session_ids(self) -> set[str]: + return {str(r.get("session_id")) for r in self.db.list_sessions()} + + def test_side_effect_free_preview_writes_no_session_row(self): + before = self._session_ids() + result = self._alloc(side_effect_free=True) + self.assertEqual(result["outcome"], OUTCOME_PREVIEW) + self.assertEqual(self._session_ids(), before) + self.assertNotIn("s-preview", self._session_ids()) + + def test_plain_dry_run_still_registers_a_session(self): + # The default is unchanged for every existing caller. + self._alloc() + self.assertIn("s-preview", self._session_ids()) + + def test_repeated_previews_do_not_accumulate_rows(self): + for index in range(5): + self._alloc(side_effect_free=True, session_id=f"s-{index}") + self.assertEqual(self._session_ids(), set()) + + def test_side_effect_free_does_not_sweep_stale_leases(self): + self.db.upsert_session(session_id="owner", role="author", pid=1) + assigned = self.db.assign_and_lease( + session_id="owner", + role="author", + remote="prgs", + org="org", + repo="repo", + kind="issue", + number=999, + lease_ttl_seconds=-60, # already expired + ) + self.assertEqual(assigned.outcome, "assigned") + + self._alloc(side_effect_free=True) + + # The expired row is still 'active' in the DB: nothing swept it. + statuses = { + r["lease_id"]: r["status"] + for r in self.db.list_leases( + remote="prgs", org="org", repo="repo", + statuses=("active", "expired"), + ) + } + self.assertEqual(statuses.get(assigned.lease_id), "active") + + def test_expired_claims_are_filtered_in_memory_so_work_stays_selectable(self): + """The read-only mirror of the sweep: expired claims must not block.""" + self.db.upsert_session(session_id="owner", role="author", pid=1) + self.db.assign_and_lease( + session_id="owner", + role="author", + remote="prgs", + org="org", + repo="repo", + kind="issue", + number=643, + lease_ttl_seconds=-60, # expired: must not withhold #643 + ) + result = self._alloc(side_effect_free=True) + self.assertEqual(result["outcome"], OUTCOME_PREVIEW) + self.assertEqual(result["selected"]["number"], 643) + + def test_a_live_claim_still_withholds_the_work(self): + self.db.upsert_session(session_id="owner", role="author", pid=1) + self.db.assign_and_lease( + session_id="owner", + role="author", + remote="prgs", + org="org", + repo="repo", + kind="issue", + number=643, + lease_ttl_seconds=3600, + ) + result = self._alloc(side_effect_free=True) + self.assertNotEqual(result["outcome"], OUTCOME_ASSIGNED) + self.assertNotEqual((result.get("selected") or {}).get("number"), 643) + + def test_side_effect_free_with_apply_fails_closed(self): + result = self._alloc(side_effect_free=True, apply=True) + self.assertFalse(result["success"]) + self.assertEqual(result["outcome"], OUTCOME_NO_SAFE) + self.assertIsNone(result["assignment"]) + self.assertIn("incompatible with apply", result["reasons"][0]) + # And it reserved nothing. + self.assertEqual( + self.db.list_leases(remote="prgs", org="org", repo="repo"), [] + ) + + +class DropExpiredClaimsTest(unittest.TestCase): + """The in-memory expiry filter behind side-effect-free previews (#643).""" + + def test_unparseable_expiry_is_kept_rather_than_assumed_free(self): + claims = { + ("issue", 1): {"lease_id": "l1", "expires_at": "not-a-date"}, + ("issue", 2): {"lease_id": "l2"}, + ("issue", 3): {"lease_id": "l3", "expires_at": None}, + } + self.assertEqual(_drop_expired_claims(claims), claims) + + def test_expired_dropped_and_future_kept(self): + now = datetime(2026, 7, 25, 12, 0, tzinfo=timezone.utc) + claims = { + ("issue", 1): {"expires_at": "2026-07-25T11:59:59+00:00"}, + ("issue", 2): {"expires_at": "2026-07-25T12:00:01+00:00"}, + ("issue", 3): {"expires_at": "2026-07-25T12:00:00+00:00"}, # boundary + } + kept = _drop_expired_claims(claims, now=now) + self.assertEqual(set(kept), {("issue", 2)}) + + def test_naive_and_zulu_timestamps_are_treated_as_utc(self): + now = datetime(2026, 7, 25, 12, 0, tzinfo=timezone.utc) + claims = { + ("issue", 1): {"expires_at": "2026-07-25T11:00:00"}, # naive, past + ("issue", 2): {"expires_at": "2026-07-25T13:00:00Z"}, # zulu, future + } + kept = _drop_expired_claims(claims, now=now) + self.assertEqual(set(kept), {("issue", 2)}) + + if __name__ == "__main__": unittest.main() diff --git a/tests/test_commit_files_gate.py b/tests/test_commit_files_gate.py index 50c882c..404c387 100644 --- a/tests/test_commit_files_gate.py +++ b/tests/test_commit_files_gate.py @@ -35,7 +35,7 @@ CONFIG = { ], "forbidden_operations": [], "execution_profile": "full-author", - "allowed_repositories": ["Example-Org/Example-Repo"], + "allowed_repositories": ["Scaled-Tech-Consulting/Gitea-Tools", "Example-Org/Example-Repo"], }, "reviewer-no-commit": { "enabled": True, @@ -50,7 +50,7 @@ CONFIG = { "gitea.repo.commit", "gitea.pr.create", "gitea.branch.push" ], "execution_profile": "reviewer-no-commit", - "allowed_repositories": ["Example-Org/Example-Repo"], + "allowed_repositories": ["Scaled-Tech-Consulting/Gitea-Tools", "Example-Org/Example-Repo"], }, }, "rules": {"allow_runtime_switching": False}, diff --git a/tests/test_config_menu.py b/tests/test_config_menu.py index 3af80c3..558924a 100644 --- a/tests/test_config_menu.py +++ b/tests/test_config_menu.py @@ -175,7 +175,7 @@ class TestLauncherSnippets(unittest.TestCase): def test_only_safe_keys_no_secrets(self): entry = gitea_config.launcher_entry("prgs", "/cfg/profiles.json")["gitea-tools"] self.assertEqual(set(entry), {"command", "args", "env"}) - self.assertEqual(set(entry["env"]), {"GITEA_MCP_CONFIG", "GITEA_MCP_PROFILE"}) + self.assertEqual(set(entry["env"]), {"GITEA_MCP_CONFIG", "GITEA_MCP_PROFILE", "GITEA_CLIENT_MANAGED"}) self.assertEqual(entry["env"]["GITEA_MCP_PROFILE"], "prgs") blob = json.dumps(entry).lower() for word in ("token", "password", "secret"): diff --git a/tests/test_issue_678_mcp_client_reconnect.py b/tests/test_issue_678_mcp_client_reconnect.py new file mode 100644 index 0000000..5affc0e --- /dev/null +++ b/tests/test_issue_678_mcp_client_reconnect.py @@ -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() diff --git a/tests/test_issue_686_manual_mcp_provenance.py b/tests/test_issue_686_manual_mcp_provenance.py new file mode 100644 index 0000000..40170c1 --- /dev/null +++ b/tests/test_issue_686_manual_mcp_provenance.py @@ -0,0 +1,139 @@ +"""Tests for Issue #686: Detect and reject manually launched duplicate MCP role servers.""" +import os +import unittest +from unittest.mock import patch, MagicMock +from datetime import datetime + +import gitea_config +import gitea_mcp_server +import mcp_namespace_health + + +class TestIssue686ManualMcpProvenance(unittest.TestCase): + + def test_client_managed_process_detection(self): + """Test _is_client_managed_process correctly detects provenance markers.""" + with patch.dict(os.environ, {"GITEA_CLIENT_MANAGED": "1"}, clear=True): + self.assertTrue(gitea_mcp_server._is_client_managed_process()) + + with patch.dict(os.environ, {"GITEA_MCP_CLIENT_MANAGED": "true"}, clear=True): + self.assertTrue(gitea_mcp_server._is_client_managed_process()) + + with patch.dict(os.environ, {"GITEA_SERVER_PROVENANCE": "client_managed"}, clear=True): + self.assertTrue(gitea_mcp_server._is_client_managed_process()) + + with patch.dict(os.environ, {"GITEA_CLIENT_MANAGED": "0"}, clear=True): + self.assertFalse(gitea_mcp_server._is_client_managed_process()) + + def test_unconsumed_gitea_env_overrides(self): + """Test surfacing of unsupported GITEA_* env overrides (e.g. GITEA_DUMMY).""" + env = { + "GITEA_MCP_PROFILE": "prgs-author", + "GITEA_CLIENT_MANAGED": "1", + "GITEA_DUMMY": "2", + "GITEA_UNKNOWN_FLAG": "abc", + } + unconsumed = gitea_config.get_unconsumed_gitea_env_overrides(env) + self.assertIn("GITEA_DUMMY", unconsumed) + self.assertEqual(unconsumed["GITEA_DUMMY"], "2") + self.assertIn("GITEA_UNKNOWN_FLAG", unconsumed) + self.assertNotIn("GITEA_MCP_PROFILE", unconsumed) + self.assertNotIn("GITEA_CLIENT_MANAGED", unconsumed) + + def test_manual_server_mutation_fail_closed(self): + """AC 2: Mutating tools on a server without client-managed provenance fail closed with a typed blocker.""" + with patch.dict(os.environ, {"GITEA_CLIENT_MANAGED": "0"}, clear=True): + block = gitea_mcp_server._provenance_mutation_block(task="create_issue") + self.assertIsNotNone(block) + self.assertFalse(block["success"]) + self.assertFalse(block["performed"]) + self.assertEqual(block["blocker_kind"], "unsupported_manual_launch") + self.assertEqual(block["provenance"], "manual_launch") + self.assertTrue(any("mutation denied: server process was launched manually" in r for r in block["reasons"])) + self.assertIn("BLOCKED + RECONNECT", block["exact_next_action"]) + + def test_client_managed_server_mutation_passes_provenance_gate(self): + """AC 3: Clean client-managed baseline passes the provenance gate.""" + with patch.dict(os.environ, {"GITEA_CLIENT_MANAGED": "1"}, clear=True): + block = gitea_mcp_server._provenance_mutation_block(task="create_issue") + self.assertIsNone(block) + + @patch("subprocess.run") + @patch("os.path.getmtime") + @patch("os.path.exists") + @patch("os.getpid") + def test_manual_duplicate_does_not_mask_stale_runtime( + self, mock_getpid, mock_exists, mock_getmtime, mock_run + ): + """AC 1 & AC 3: Staleness detection ignores manual duplicates and reports stale supported runtimes.""" + mock_getpid.return_value = 12345 + mock_exists.return_value = True + + code_time = datetime(2026, 7, 8, 14, 0, 0) + mock_getmtime.return_value = code_time.timestamp() + + # PID 12345: stale client-managed process (started at 13:00) + # PID 99999: fresh manual duplicate process (started at 15:00, no GITEA_CLIENT_MANAGED) + ps_output = ( + " PID LSTART COMMAND\n" + "12345 Wed Jul 8 13:00:00 2026 /path/to/python mcp_server.py\n" + "99999 Wed Jul 8 15:00:00 2026 /path/to/python mcp_server.py\n" + ) + + mock_run_ps = MagicMock() + mock_run_ps.stdout = ps_output + + mock_env_12345 = MagicMock() + mock_env_12345.stdout = "GITEA_MCP_PROFILE=prgs-author GITEA_CLIENT_MANAGED=1" + + mock_env_99999 = MagicMock() + mock_env_99999.stdout = "GITEA_MCP_PROFILE=prgs-author GITEA_DUMMY=2" + + def side_effect(args, **kwargs): + if args[0] == "ps" and "eww" in args: + pid = args[2] + if pid == "12345": + return mock_env_12345 + elif pid == "99999": + return mock_env_99999 + elif args[0] == "ps": + return mock_run_ps + raise ValueError(f"Unexpected args: {args}") + + mock_run.side_effect = side_effect + + reasons = gitea_mcp_server._check_mcp_runtimes_diagnostics("create_issue", ["prgs-author"]) + + # Manual duplicate process must be flagged + self.assertTrue(any("Duplicate MCP server process(es) detected" in r for r in reasons)) + # Unsupported env override (GITEA_DUMMY=2) must be flagged + self.assertTrue(any("unsupported-env: Unsupported GITEA_* environment variable override(s) detected: GITEA_DUMMY=2" in r for r in reasons)) + # Stale runtime must NOT be masked by fresh manual process 99999! + self.assertTrue(any("All matching profiles for task 'create_issue' (['prgs-author']) are running but stale" in r for r in reasons)) + + def test_namespace_health_classification_includes_provenance(self): + """AC 1 & 4: mcp_namespace_health diagnostics include provenance and unconsumed_gitea_env.""" + process = { + "pid": 5555, + "profile": "prgs-author", + "env": { + "GITEA_MCP_PROFILE": "prgs-author", + "GITEA_DUMMY": "99", + }, + } + res = mcp_namespace_health.classify_namespace_probe( + "gitea-author", + configured=True, + registered_tools=["gitea_whoami"], + probe_result={"success": True}, + process=process, + probe_source="client_namespace", + ) + self.assertEqual(res["provenance"], "manual_launch") + self.assertFalse(res["is_client_managed"]) + self.assertEqual(res["unconsumed_gitea_env"], {"GITEA_DUMMY": "99"}) + self.assertEqual(res["diagnostics"]["provenance"], "manual_launch") + + +if __name__ == "__main__": + unittest.main() diff --git a/tests/test_issue_886_apply_authorization_conjunction.py b/tests/test_issue_886_apply_authorization_conjunction.py index 6fa2888..5227f9c 100644 --- a/tests/test_issue_886_apply_authorization_conjunction.py +++ b/tests/test_issue_886_apply_authorization_conjunction.py @@ -35,6 +35,17 @@ BREAK_GLASS_ENV = "GITEA_BREAKGLASS_RESTART_AUTHORIZATION" QUIET_SESSIONS: list[dict] = [] QUIET_LEASES: list[dict] = [] +# #669: broad restarts need a prior narrow-attempt log (unless break-glass). +PRIOR_NARROW_ATTEMPTS_JSON = json.dumps( + [ + { + "action": "client_reconnect", + "outcome": "insufficient", + "reason": "still flapping after reconnect", + } + ] +) + class _FakeDB: """Minimal control-plane DB stand-in for the restart inventory.""" @@ -128,6 +139,7 @@ class TestConjunction(_RestartToolHarness): preview = self._call( role="operator", restart_class="full_mcp_restart", + prior_recovery_attempts_json=PRIOR_NARROW_ATTEMPTS_JSON, env={CONTROLLER_APPROVAL_ENV: "operator-approved"}, ) self.assertTrue(preview["allow_restart"], @@ -136,6 +148,7 @@ class TestConjunction(_RestartToolHarness): result = self._call( role="operator", restart_class="full_mcp_restart", + prior_recovery_attempts_json=PRIOR_NARROW_ATTEMPTS_JSON, dry_run=False, drain_proof_json=self._clean_proof_for(preview), env={CONTROLLER_APPROVAL_ENV: "operator-approved"}, @@ -342,6 +355,7 @@ class TestExistingPathsStillWork(_RestartToolHarness): result = self._call( role="operator", restart_class="full_mcp_restart", + prior_recovery_attempts_json=PRIOR_NARROW_ATTEMPTS_JSON, dry_run=False, env={CONTROLLER_APPROVAL_ENV: "operator-approved"}, ) diff --git a/tests/test_issue_892_author_bootstrap_deadlock.py b/tests/test_issue_892_author_bootstrap_deadlock.py new file mode 100644 index 0000000..d8c15c6 --- /dev/null +++ b/tests/test_issue_892_author_bootstrap_deadlock.py @@ -0,0 +1,215 @@ +"""Regression: author worktree bootstrap from clean control checkout (#892). + +#892 is the four-door deadlock where every documented recovery path is closed: +bootstrap refuses control, lock demands an existing worktree, worktree-start +demands a lock, and shell worktree add is outside the sanctioned MCP path. + +Root cause: assess_author_issue_bootstrap returned allowed/proven for a clean +control checkout, but bootstrap_permits_control_checkout only accepted +create_issue assessments (task_scope=create_issue_only + empty reasons + full +base-tip field set). Author assessments never satisfied the shared predicate, +so the #274/#604 guards kept the ordinary control-checkout block. +""" + +from __future__ import annotations + +import os +import tempfile +import unittest +from unittest import mock + +import author_issue_bootstrap as aib +import create_issue_bootstrap as cib + + +CONTROL = "/repo/Gitea-Tools" +MASTER = "aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa" +OTHER = "bbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbb" + + +def _assess( + *, + workspace=CONTROL, + root=CONTROL, + branch="master", + head=MASTER, + porcelain="", + remote=MASTER, + remote_error=None, + task="bootstrap_author_issue_worktree", +): + return aib.assess_author_issue_bootstrap( + workspace_path=workspace, + canonical_repo_root=root, + current_branch=branch, + head_sha=head, + porcelain_status=porcelain, + remote_master_sha=remote, + remote_master_sha_error=remote_error, + task=task, + ) + + +class TestAuthorBootstrapAssessmentShape(unittest.TestCase): + def test_clean_control_emits_predicate_compatible_fields(self): + assessment = _assess() + self.assertTrue(assessment["allowed"]) + self.assertTrue(assessment["proven"]) + self.assertFalse(assessment["block"]) + self.assertFalse(assessment["not_applicable"]) + self.assertEqual(assessment["reasons"], []) + self.assertEqual(assessment["task_scope"], "author_issue_bootstrap") + self.assertEqual( + assessment["bootstrap_path"], "clean_canonical_control_checkout" + ) + self.assertEqual(assessment["dirty_files"], []) + self.assertIs(assessment["under_branches"], False) + self.assertTrue(assessment["base_tips_verified"]) + self.assertEqual(assessment["local_head_sha"], MASTER) + self.assertEqual(assessment["remote_master_sha"], MASTER) + self.assertEqual(assessment["workspace_path"], os.path.realpath(CONTROL)) + self.assertEqual( + assessment["canonical_repo_root"], os.path.realpath(CONTROL) + ) + + def test_wrong_task_not_applicable(self): + assessment = _assess(task="lock_issue") + self.assertTrue(assessment["not_applicable"]) + self.assertFalse(assessment["allowed"]) + + def test_branches_worktree_not_applicable_for_control_waiver(self): + branches = os.path.join(CONTROL, "branches", "fix-issue-1") + assessment = _assess(workspace=branches) + self.assertTrue(assessment["not_applicable"]) + self.assertFalse(assessment["allowed"]) + self.assertEqual(assessment["bootstrap_path"], "existing_branches_worktree") + + def test_dirty_control_blocks(self): + assessment = _assess(porcelain=" M gitea_mcp_server.py\n") + self.assertTrue(assessment["block"]) + self.assertFalse(assessment["allowed"]) + self.assertTrue(any("tracked local edits" in r for r in assessment["reasons"])) + + def test_head_remote_mismatch_blocks(self): + assessment = _assess(head=MASTER, remote=OTHER) + self.assertTrue(assessment["block"]) + self.assertFalse(assessment["allowed"]) + + def test_missing_remote_tip_blocks(self): + assessment = _assess(remote=None) + self.assertTrue(assessment["block"]) + self.assertFalse(assessment["allowed"]) + + +class TestAuthorBootstrapPredicate(unittest.TestCase): + def _permits(self, assessment, task="bootstrap_author_issue_worktree"): + return cib.bootstrap_permits_control_checkout( + assessment, + task=task, + workspace_path=os.path.realpath(CONTROL), + canonical_repo_root=os.path.realpath(CONTROL), + ) + + def test_clean_author_bootstrap_permits(self): + self.assertTrue(self._permits(_assess())) + + def test_tool_alias_permits(self): + assessment = _assess(task="gitea_bootstrap_author_issue_worktree") + self.assertTrue( + self._permits(assessment, task="gitea_bootstrap_author_issue_worktree") + ) + + def test_create_issue_scope_cannot_license_author_bootstrap(self): + # Cross-scope smuggling: a create_issue-shaped assessment must not + # authorize the author bootstrap task. + create_shaped = dict(_assess()) + create_shaped["task_scope"] = "create_issue_only" + self.assertFalse(self._permits(create_shaped)) + + def test_author_scope_cannot_license_create_issue(self): + assessment = _assess() + self.assertFalse( + cib.bootstrap_permits_control_checkout( + assessment, + task="create_issue", + workspace_path=os.path.realpath(CONTROL), + canonical_repo_root=os.path.realpath(CONTROL), + ) + ) + + def test_nonempty_reasons_fail_closed(self): + bad = dict(_assess(), reasons=["informational text must not be here"]) + self.assertFalse(self._permits(bad)) + + def test_dirty_fails_closed(self): + self.assertFalse(self._permits(_assess(porcelain=" M x.py\n"))) + + def test_mismatch_fails_closed(self): + self.assertFalse(self._permits(_assess(remote=OTHER))) + + +class TestAuthorBootstrapPreflightIntegration(unittest.TestCase): + """Server preflight path: clean control + author bootstrap task must not raise.""" + + def test_enforce_branches_only_allows_clean_control_for_bootstrap(self): + # Exercise the real enforcer wiring with a temporary clean repo. + import gitea_mcp_server as srv + + with tempfile.TemporaryDirectory() as tmp: + repo = os.path.join(tmp, "repo") + os.makedirs(os.path.join(repo, "branches")) + # Minimal git repo on master at a known tip. + import subprocess + + subprocess.check_call(["git", "init", "-b", "master", repo]) + subprocess.check_call( + ["git", "-C", repo, "commit", "--allow-empty", "-m", "init"] + ) + head = subprocess.check_output( + ["git", "-C", repo, "rev-parse", "HEAD"], text=True + ).strip() + + assessment = aib.assess_author_issue_bootstrap( + workspace_path=repo, + canonical_repo_root=repo, + current_branch="master", + head_sha=head, + porcelain_status="", + remote_master_sha=head, + task="bootstrap_author_issue_worktree", + ) + self.assertTrue( + cib.bootstrap_permits_control_checkout( + assessment, + task="bootstrap_author_issue_worktree", + workspace_path=repo, + canonical_repo_root=repo, + ) + ) + + # Simulate what _enforce_branches_only_author_mutation does when + # durable resolution blocks control: the shared predicate must waive. + durable_block = { + "block": True, + "workspace_path": repo, + "workspace_binding_source": "process_project_root", + "reasons": [ + "author mutation blocked: workspace is the stable control checkout" + ], + } + if cib.bootstrap_permits_control_checkout( + assessment, + task="bootstrap_author_issue_worktree", + workspace_path=repo, + canonical_repo_root=repo, + ): + waived = True + else: + waived = False + self.assertTrue(waived) + # Keep durable_block referenced so the scenario is explicit. + self.assertTrue(durable_block["block"]) + + +if __name__ == "__main__": + unittest.main() diff --git a/tests/test_issue_941_scope_guard_bootstrap_wiring.py b/tests/test_issue_941_scope_guard_bootstrap_wiring.py new file mode 100644 index 0000000..f4b7351 --- /dev/null +++ b/tests/test_issue_941_scope_guard_bootstrap_wiring.py @@ -0,0 +1,346 @@ +"""Regression: author bootstrap scope reaches workflow_scope_guard (#941). + +PR #926 (#892) made ``bootstrap_permits_control_checkout`` accept +``task_scope=author_issue_bootstrap`` and wired that canonical decision into +the #274 branches-only enforcer and the #604 anti-stomp preflight. A third +enforcement path was left unwired. + +``workflow_scope_guard.assess_root_source_mutation`` kept its own copy of the +clean-root author decision, gated on ``create_issue_bootstrap.is_create_issue_task`` +— a task-name allowlist that never contained ``bootstrap_author_issue_worktree``. +So the real call path + + gitea_bootstrap_author_issue_worktree + -> verify_preflight_purity + -> _enforce_issue_scope_guard + -> workflow_scope_guard.assess_production_mutation_guards + +raised ProductionGuardError(missing_issue_worktree) before +``assess_author_issue_bootstrap`` was ever consulted. + +These tests drive the real enforcer, not the authorization helper in +isolation. A helper-only test cannot observe this defect: #892's own predicate +tests all passed while the live bootstrap stayed blocked. +""" + +from __future__ import annotations + +import os +import subprocess +import tempfile +import unittest +from unittest import mock + +import author_issue_bootstrap as aib +import create_issue_bootstrap as cib +import workflow_scope_guard + +BOOTSTRAP_TASK = "bootstrap_author_issue_worktree" +BOOTSTRAP_TOOL = "gitea_bootstrap_author_issue_worktree" + + +def _make_control_repo(tmp: str) -> tuple[str, str]: + """Create a clean control checkout on master and return (path, head).""" + repo = os.path.join(tmp, "repo") + os.makedirs(os.path.join(repo, "branches")) + subprocess.check_call( + ["git", "init", "-b", "master", repo], + stdout=subprocess.DEVNULL, + stderr=subprocess.DEVNULL, + ) + subprocess.check_call( + [ + "git", "-C", repo, + "-c", "user.email=t@t", "-c", "user.name=t", + "commit", "--allow-empty", "-m", "init", + ], + stdout=subprocess.DEVNULL, + stderr=subprocess.DEVNULL, + ) + head = subprocess.check_output( + ["git", "-C", repo, "rev-parse", "HEAD"], text=True + ).strip() + return repo, head + + +def _assessment( + repo: str, + head: str, + *, + task: str = BOOTSTRAP_TASK, + porcelain: str = "", + remote: str | None = None, +) -> dict: + return aib.assess_author_issue_bootstrap( + workspace_path=repo, + canonical_repo_root=repo, + current_branch="master", + head_sha=head, + porcelain_status=porcelain, + remote_master_sha=head if remote is None else remote, + task=task, + ) + + +class _ControlCheckoutHarness(unittest.TestCase): + """Drive the real server guard against a temporary clean control checkout.""" + + def setUp(self): + self._tmp = tempfile.TemporaryDirectory() + self.addCleanup(self._tmp.cleanup) + self.repo, self.head = _make_control_repo(self._tmp.name) + + # #683 force-on: production guards must execute under pytest. + patcher = mock.patch.dict( + os.environ, + {workflow_scope_guard.FORCE_PRODUCTION_GUARDS_ENV: "1"}, + ) + patcher.start() + self.addCleanup(patcher.stop) + + def _enforce( + self, + task: str, + *, + porcelain: str = "", + assessment: object = "auto", + role_kind: str = "author", + ): + """Call the real _enforce_issue_scope_guard for *task*.""" + import gitea_mcp_server as srv + + if assessment == "auto": + assessment = _assessment( + self.repo, self.head, task=task, porcelain=porcelain + ) + + ctx = { + "workspace_path": self.repo, + "canonical_repo_root": self.repo, + "workspace_role_kind": role_kind, + "workspace_binding_source": "process_project_root", + } + git_state = { + "current_branch": "master", + "head_sha": self.head, + "porcelain_status": porcelain, + } + + with mock.patch.object( + srv, "_resolve_namespace_mutation_context", return_value=ctx + ), mock.patch.object( + srv.issue_lock_worktree, + "read_worktree_git_state", + return_value=git_state, + ), mock.patch.object( + srv, + "_session_issue_lock_snapshot", + return_value={ + "locked_issue_number": None, + "lock_branch_name": None, + "worktrees_match": False, + }, + ), mock.patch.object( + srv, "_actual_profile_role", return_value=role_kind + ), mock.patch.object( + srv, "_effective_workspace_role", return_value=role_kind + ), mock.patch.object( + srv, "_create_issue_bootstrap_assessment", return_value=assessment + ): + srv._enforce_issue_scope_guard(None, task=task) + + +class TestRealPathBootstrapReachesGuard(_ControlCheckoutHarness): + """The defect and its fix, observed through the real enforcer.""" + + def test_bootstrap_task_passes_scope_guard_from_clean_control(self): + # Pre-fix this raises ProductionGuardError(missing_issue_worktree) + # because the guard consulted a task-name allowlist instead of the + # canonical authorization decision. + self._enforce(BOOTSTRAP_TASK) + + def test_bootstrap_tool_alias_passes_scope_guard(self): + self._enforce(BOOTSTRAP_TOOL) + + def test_guard_consults_canonical_predicate(self): + """The guard must reach bootstrap_permits_control_checkout, not a name list.""" + real = cib.bootstrap_permits_control_checkout + seen: list[str | None] = [] + + def _spy(assessment, *, task, workspace_path, canonical_repo_root): + seen.append(task) + return real( + assessment, + task=task, + workspace_path=workspace_path, + canonical_repo_root=canonical_repo_root, + ) + + with mock.patch.object( + cib, "bootstrap_permits_control_checkout", side_effect=_spy + ): + self._enforce(BOOTSTRAP_TASK) + + self.assertIn( + BOOTSTRAP_TASK, + seen, + "workflow_scope_guard did not consult the canonical bootstrap " + "authorization decision", + ) + + +class TestFailClosedOnBadEvidence(_ControlCheckoutHarness): + """Missing, malformed, or mismatched scope evidence must still block.""" + + def _assert_blocked(self, **kwargs): + with self.assertRaises(workflow_scope_guard.ProductionGuardError): + self._enforce(BOOTSTRAP_TASK, **kwargs) + + def test_missing_assessment_fails_closed(self): + self._assert_blocked(assessment=None) + + def test_malformed_assessment_fails_closed(self): + self._assert_blocked(assessment={"allowed": True}) + + def test_non_dict_assessment_fails_closed(self): + self._assert_blocked(assessment="allowed") + + def test_wrong_task_scope_fails_closed(self): + bad = dict(_assessment(self.repo, self.head)) + bad["task_scope"] = "create_issue_only" + self._assert_blocked(assessment=bad) + + def test_nonempty_reasons_fail_closed(self): + bad = dict(_assessment(self.repo, self.head), reasons=["note"]) + self._assert_blocked(assessment=bad) + + def test_mismatched_base_tips_fail_closed(self): + bad = dict(_assessment(self.repo, self.head)) + bad["remote_master_sha"] = "b" * 40 + self._assert_blocked(assessment=bad) + + def test_unverified_base_tips_fail_closed(self): + bad = dict(_assessment(self.repo, self.head), base_tips_verified=False) + self._assert_blocked(assessment=bad) + + def test_mismatched_workspace_binding_fails_closed(self): + bad = dict(_assessment(self.repo, self.head)) + bad["workspace_path"] = os.path.join(self.repo, "elsewhere") + self._assert_blocked(assessment=bad) + + def test_mismatched_repo_root_binding_fails_closed(self): + bad = dict(_assessment(self.repo, self.head)) + bad["canonical_repo_root"] = os.path.join(self.repo, "other-root") + self._assert_blocked(assessment=bad) + + def test_blocked_assessment_fails_closed(self): + bad = dict(_assessment(self.repo, self.head), block=True, allowed=False) + self._assert_blocked(assessment=bad) + + +class TestOrdinaryControlCheckoutMutationStillForbidden(_ControlCheckoutHarness): + """The waiver must not leak to ordinary author work.""" + + def test_ordinary_author_task_still_blocked(self): + with self.assertRaises(workflow_scope_guard.ProductionGuardError): + self._enforce("commit_files", assessment=None) + + def test_lock_issue_still_blocked_from_control(self): + with self.assertRaises(workflow_scope_guard.ProductionGuardError): + self._enforce("lock_issue", assessment=None) + + def test_bootstrap_assessment_cannot_license_other_task(self): + # Cross-task smuggling: valid bootstrap evidence must not waive a + # different author mutation. + good = _assessment(self.repo, self.head) + with self.assertRaises(workflow_scope_guard.ProductionGuardError): + self._enforce("commit_files", assessment=good) + + def test_dirty_control_checkout_still_blocked_for_bootstrap(self): + with self.assertRaises(workflow_scope_guard.ProductionGuardError): + self._enforce(BOOTSTRAP_TASK, porcelain=" M gitea_mcp_server.py\n") + + +class TestCreateIssueBehaviorUnchanged(_ControlCheckoutHarness): + """#749 create_issue keeps its own sanctioned path.""" + + def test_create_issue_still_allowed_from_clean_control(self): + self._enforce("create_issue", assessment=None) + + def test_create_issue_tool_alias_still_allowed(self): + self._enforce("gitea_create_issue", assessment=None) + + def test_create_issue_blocked_when_control_dirty(self): + with self.assertRaises(workflow_scope_guard.ProductionGuardError): + self._enforce( + "create_issue", + porcelain=" M gitea_mcp_server.py\n", + assessment=None, + ) + + +class TestGuardUnitLevelWiring(unittest.TestCase): + """assess_root_source_mutation itself must accept and honour the evidence.""" + + def setUp(self): + self._tmp = tempfile.TemporaryDirectory() + self.addCleanup(self._tmp.cleanup) + self.repo, self.head = _make_control_repo(self._tmp.name) + patcher = mock.patch.dict( + os.environ, + {workflow_scope_guard.FORCE_PRODUCTION_GUARDS_ENV: "1"}, + ) + patcher.start() + self.addCleanup(patcher.stop) + + def _assess(self, *, task=BOOTSTRAP_TASK, bootstrap_assessment="auto"): + if bootstrap_assessment == "auto": + bootstrap_assessment = _assessment(self.repo, self.head, task=task) + return workflow_scope_guard.assess_root_source_mutation( + workspace_path=self.repo, + canonical_repo_root=self.repo, + porcelain_status="", + current_branch="master", + role_kind="author", + mutation_task=task, + bootstrap_assessment=bootstrap_assessment, + ) + + def test_valid_evidence_unblocks(self): + result = self._assess() + self.assertFalse(result["block"]) + self.assertIsNone(result["blocker_kind"]) + + def test_absent_evidence_blocks(self): + result = self._assess(bootstrap_assessment=None) + self.assertTrue(result["block"]) + self.assertEqual( + result["blocker_kind"], workflow_scope_guard.BLOCKER_MISSING_WORKTREE + ) + + def test_reconciler_exemption_preserved(self): + result = workflow_scope_guard.assess_root_source_mutation( + workspace_path=self.repo, + canonical_repo_root=self.repo, + porcelain_status="", + current_branch="master", + role_kind="reconciler", + mutation_task=BOOTSTRAP_TASK, + ) + self.assertFalse(result["block"]) + + def test_signature_accepts_evidence_without_it_being_required(self): + # Callers that supply no evidence keep the pre-existing behaviour. + result = workflow_scope_guard.assess_root_source_mutation( + workspace_path=self.repo, + canonical_repo_root=self.repo, + porcelain_status="", + current_branch="master", + role_kind="author", + mutation_task="create_issue", + ) + self.assertFalse(result["block"]) + + +if __name__ == "__main__": + unittest.main() diff --git a/tests/test_issue_943_runtime_context_helpers.py b/tests/test_issue_943_runtime_context_helpers.py new file mode 100644 index 0000000..8bbe5af --- /dev/null +++ b/tests/test_issue_943_runtime_context_helpers.py @@ -0,0 +1,747 @@ +"""Regression: author bootstrap runtime authority and session ownership (#943). + +Two rounds of defects live here. + +**Round 1 (#943 as filed).** ``gitea_bootstrap_author_issue_worktree`` passed +four values down to the bootstrap service that were never defined: +``_active_username``, ``_active_profile_name``, ``_current_session_id`` and +``_author_mutation_block``. Every call — dry-run included — raised +``NameError`` while evaluating the arguments, before the service was entered. + +**Round 2 (review 622 on PR #944).** The first fix defined all four but made +``_current_session_id`` mint ``--`` once per process. The MCP +daemon outlives every task it serves, so that value conflates sequential author +tasks and can never equal the control-plane session that owns an +allocator-created lease: ``_verify_assignment_and_lease_ids`` refused the whole +allocated path with ``lease_session_mismatch``. The reviewed round also read the +identity from the pinned session context while reading the profile from the live +profile, so a rebind could produce a mixed claimant pair, and it swallowed every +``get_profile()`` exception. + +These tests therefore drive real state, not mocks of internals: a temporary +control-plane SQLite database and a temporary issue-lock directory, both +redirected through the same environment variables production uses +(``GITEA_CONTROL_PLANE_DB``, ``GITEA_ISSUE_LOCK_DIR``). The ownership gate that +runs is the real one. + +``test_every_global_referenced_by_the_wrapper_resolves`` remains: it is what +found ``_author_mutation_block``, and it generalises to the next missing +reference. It supplements the runtime coverage below rather than standing in for +it. +""" + +from __future__ import annotations + +import ast +import builtins +import os +import re +import subprocess +import tempfile +import unittest +from unittest import mock + +import author_issue_bootstrap as aib +import control_plane_db +import create_issue_bootstrap as cib +import gitea_mcp_server as gms +import issue_lock_store +import workflow_scope_guard + +BOOTSTRAP_TASK = "bootstrap_author_issue_worktree" +WRAPPER_NAME = "gitea_bootstrap_author_issue_worktree" +RUNTIME_HELPERS = ( + "_active_mutation_authority", + "_active_username", + "_active_profile_name", + "_resolve_owner_workflow_session", + "_author_mutation_block", +) +ORG = "Scaled-Tech-Consulting" +REPO = "Gitea-Tools" +IDENTITY = "jcwalker3" +PROFILE = "prgs-author" + +# A per-task ownership key must carry no process identifier (#790). +TASK_KEY_RE = re.compile(r"^author_issue_work-[0-9a-f]{16}$") + + +def _make_control_repo(tmp: str) -> tuple[str, str]: + """Create a clean control checkout on master and return (path, head).""" + repo = os.path.join(tmp, "repo") + os.makedirs(os.path.join(repo, "branches")) + subprocess.check_call( + ["git", "init", "-b", "master", repo], + stdout=subprocess.DEVNULL, + stderr=subprocess.DEVNULL, + ) + subprocess.check_call( + [ + "git", "-C", repo, + "-c", "user.email=t@t", "-c", "user.name=t", + "commit", "--allow-empty", "-m", "init", + ], + stdout=subprocess.DEVNULL, + stderr=subprocess.DEVNULL, + ) + head = subprocess.check_output( + ["git", "-C", repo, "rev-parse", "HEAD"], text=True + ).strip() + return repo, head + + +def _wrapper_ast() -> ast.FunctionDef: + """Return the AST of the bootstrap wrapper as it exists on disk.""" + path = os.path.join( + os.path.dirname(os.path.dirname(os.path.abspath(__file__))), + "gitea_mcp_server.py", + ) + with open(path, encoding="utf-8") as fh: + tree = ast.parse(fh.read()) + for node in ast.walk(tree): + if isinstance(node, ast.FunctionDef) and node.name == WRAPPER_NAME: + return node + raise AssertionError(f"{WRAPPER_NAME} not found in gitea_mcp_server.py") + + +class _IsolatedControlPlane(unittest.TestCase): + """Temp control-plane DB and temp issue-lock dir, via production env vars.""" + + def setUp(self): + self._tmp = tempfile.TemporaryDirectory() + self.addCleanup(self._tmp.cleanup) + self.tmp = self._tmp.name + self.db_path = os.path.join(self.tmp, "control-plane.sqlite3") + self.lock_dir = os.path.join(self.tmp, "issue-locks") + self.journals = os.path.join(self.tmp, "journals") + os.makedirs(self.lock_dir) + os.makedirs(self.journals) + env = mock.patch.dict( + os.environ, + { + control_plane_db.DB_PATH_ENV: self.db_path, + issue_lock_store.LOCK_DIR_ENV: self.lock_dir, + }, + ) + env.start() + self.addCleanup(env.stop) + self.db = control_plane_db.ControlPlaneDB(self.db_path) + + def _allocate(self, session_id: str, *, issue: int = 943): + """Create a real assignment + lease owned by *session_id*.""" + self.db.upsert_session( + session_id=session_id, role="author", profile=PROFILE, pid=os.getpid() + ) + res = self.db.assign_and_lease( + session_id=session_id, role="author", remote="prgs", + org=ORG, repo=REPO, kind="issue", number=issue, + ) + self.assertEqual(res.outcome, "assigned", res) + return res.assignment_id, res.lease_id + + def _authority(self): + """A resolved authority pair, as the wrapper would compute it.""" + return {"ok": True, "identity": IDENTITY, "profile_name": PROFILE} + + def _resolve_session(self, **over): + kwargs = dict( + issue_number=943, + assignment_id=None, + lease_id=None, + session_id=None, + identity=IDENTITY, + profile_name=PROFILE, + remote="prgs", + org=ORG, + repo=REPO, + ) + kwargs.update(over) + return gms._resolve_owner_workflow_session(**kwargs) + + +class OwnershipGateTests(_IsolatedControlPlane): + """B2: the allocator-driven ownership path, against a real control plane.""" + + def setUp(self): + super().setUp() + self.repo, self.head = _make_control_repo(self.tmp) + + def _bootstrap(self, **over): + kwargs = dict( + issue_number=943, + canonical_repo_root=self.repo, + expected_base_sha=self.head, + branch_name="fix/issue-943-runtime-context-helpers", + remote="prgs", + org=ORG, + repo=REPO, + active_identity=IDENTITY, + active_profile=PROFILE, + lock_dir=self.journals, + idempotency_key="test-943", + dry_run=True, + ) + kwargs.update(over) + return aib.bootstrap_author_issue_worktree(**kwargs) + + def test_true_owning_session_passes_the_ownership_gate(self): + """The canonical owner reaches and completes the service.""" + session = "prgs-author-task-a" + assignment_id, lease_id = self._allocate(session) + res = self._bootstrap( + assignment_id=assignment_id, lease_id=lease_id, owner_session=session + ) + self.assertTrue(res.get("success"), res) + self.assertTrue(res.get("dry_run")) + self.assertEqual(res.get("base_sha"), self.head) + + def test_different_session_is_refused(self): + session = "prgs-author-task-a" + assignment_id, lease_id = self._allocate(session) + res = self._bootstrap( + assignment_id=assignment_id, + lease_id=lease_id, + owner_session="prgs-author-task-b", + ) + self.assertFalse(res.get("success")) + self.assertEqual(res.get("reason_code"), "lease_session_mismatch") + + def test_process_derived_session_would_be_refused(self): + """The reviewed round-1 value shape can never own an allocated lease.""" + session = "prgs-author-task-a" + assignment_id, lease_id = self._allocate(session) + round_one_value = f"{PROFILE}-{os.getpid()}-deadbeef" + self.assertNotEqual(round_one_value, session) + res = self._bootstrap( + assignment_id=assignment_id, + lease_id=lease_id, + owner_session=round_one_value, + ) + self.assertFalse(res.get("success")) + self.assertEqual(res.get("reason_code"), "lease_session_mismatch") + + def test_unknown_lease_fails_closed(self): + session = "prgs-author-task-a" + assignment_id, _ = self._allocate(session) + res = self._bootstrap( + assignment_id=assignment_id, + lease_id="lease-does-not-exist", + owner_session=session, + ) + self.assertFalse(res.get("success")) + self.assertEqual(res.get("reason_code"), "unknown_lease_id") + + def test_released_lease_fails_closed(self): + session = "prgs-author-task-a" + assignment_id, lease_id = self._allocate(session) + self.db.release_lease(lease_id, session_id=session) + res = self._bootstrap( + assignment_id=assignment_id, lease_id=lease_id, owner_session=session + ) + self.assertFalse(res.get("success")) + self.assertEqual(res.get("reason_code"), "lease_not_live") + + def test_force_expired_lease_fails_closed(self): + session = "prgs-author-task-a" + assignment_id, lease_id = self._allocate(session) + self.db.force_expire_lease(lease_id, reason="test") + res = self._bootstrap( + assignment_id=assignment_id, lease_id=lease_id, owner_session=session + ) + self.assertFalse(res.get("success")) + self.assertEqual(res.get("reason_code"), "lease_not_live") + + def test_replacement_lease_does_not_inherit_prior_ownership(self): + """A second task's lease is not ownable by the first task's session.""" + first = "prgs-author-task-a" + assignment_a, lease_a = self._allocate(first) + self.db.release_lease(lease_a, session_id=first) + second = "prgs-author-task-b" + assignment_b, lease_b = self._allocate(second) + self.assertNotEqual(lease_a, lease_b) + res = self._bootstrap( + assignment_id=assignment_b, lease_id=lease_b, owner_session=first + ) + self.assertFalse(res.get("success")) + self.assertEqual(res.get("reason_code"), "lease_session_mismatch") + + def test_assignment_lease_identifier_mismatch_fails_closed(self): + session = "prgs-author-task-a" + _, lease_id = self._allocate(session) + res = self._bootstrap( + assignment_id="asn-not-the-recorded-one", + lease_id=lease_id, + owner_session=session, + ) + self.assertFalse(res.get("success")) + self.assertEqual(res.get("reason_code"), "assignment_lease_mismatch") + + def test_lease_id_without_assignment_id_fails_closed(self): + session = "prgs-author-task-a" + _, lease_id = self._allocate(session) + res = self._bootstrap(lease_id=lease_id, owner_session=session) + self.assertFalse(res.get("success")) + self.assertEqual(res.get("reason_code"), "incomplete_assignment_lease_ids") + + def test_dry_run_with_valid_allocator_bindings_leaves_no_durable_state(self): + session = "prgs-author-task-a" + assignment_id, lease_id = self._allocate(session) + res = self._bootstrap( + assignment_id=assignment_id, lease_id=lease_id, owner_session=session + ) + self.assertTrue(res.get("success"), res) + + branches = subprocess.check_output( + ["git", "-C", self.repo, "branch", "--list"], text=True + ) + self.assertNotIn("issue-943", branches) + worktrees = subprocess.check_output( + ["git", "-C", self.repo, "worktree", "list"], text=True + ) + self.assertNotIn("issue-943", worktrees) + self.assertFalse( + os.path.exists( + os.path.join(self.repo, "branches", + "fix-issue-943-runtime-context-helpers") + ) + ) + journal = res.get("phase_journal") or {} + self.assertFalse(journal.get("completed")) + self.assertFalse(any((journal.get("artifacts_created") or {}).values())) + # The dry run must not have created an issue lock in the isolated dir. + self.assertEqual(os.listdir(self.lock_dir), []) + + def test_apply_reaches_the_intended_transition_with_valid_bindings(self): + session = "prgs-author-task-a" + assignment_id, lease_id = self._allocate(session) + res = self._bootstrap( + assignment_id=assignment_id, + lease_id=lease_id, + owner_session=session, + dry_run=False, + ) + self.assertTrue(res.get("success"), res) + self.assertNotEqual(res.get("dry_run"), True) + branches = subprocess.check_output( + ["git", "-C", self.repo, "branch", "--list"], text=True + ) + self.assertIn("issue-943", branches) + self.assertTrue(os.path.isdir(res.get("worktree_path") or "")) + + +class WorkflowSessionResolutionTests(_IsolatedControlPlane): + """B1: the wrapper resolves the owning session, never a process identifier.""" + + def test_declared_session_is_verified_against_the_control_plane(self): + session = "prgs-author-task-a" + assignment_id, lease_id = self._allocate(session) + res = self._resolve_session( + session_id=session, assignment_id=assignment_id, lease_id=lease_id + ) + self.assertTrue(res.get("ok"), res) + self.assertEqual(res.get("session_id"), session) + self.assertEqual(res.get("session_source"), "declared") + + def test_unknown_declared_session_is_refused_not_trusted(self): + res = self._resolve_session(session_id="prgs-author-not-a-session") + self.assertFalse(res.get("ok")) + self.assertEqual(res.get("reason_code"), "workflow_session_unverified") + + def test_declared_session_for_another_role_is_refused(self): + self.db.upsert_session( + session_id="prgs-reviewer-x", role="reviewer", profile="prgs-reviewer", + pid=os.getpid(), + ) + res = self._resolve_session(session_id="prgs-reviewer-x") + self.assertFalse(res.get("ok")) + self.assertEqual(res.get("reason_code"), "workflow_session_unverified") + + def test_declared_session_for_another_profile_is_refused(self): + self.db.upsert_session( + session_id="other-profile-session", role="author", + profile="prgs-controller", pid=os.getpid(), + ) + res = self._resolve_session(session_id="other-profile-session") + self.assertFalse(res.get("ok")) + self.assertEqual(res.get("reason_code"), "workflow_session_unverified") + + def test_allocated_work_without_a_session_is_refused(self): + """Supplying a lease is not itself evidence of ownership.""" + session = "prgs-author-task-a" + assignment_id, lease_id = self._allocate(session) + res = self._resolve_session(assignment_id=assignment_id, lease_id=lease_id) + self.assertFalse(res.get("ok")) + self.assertEqual( + res.get("reason_code"), "workflow_session_required_for_allocated_work" + ) + + def test_existing_issue_lock_supplies_its_per_task_session(self): + lock_session = issue_lock_store.mint_task_session_id( + issue_lock_store.AUTHOR_ISSUE_WORK_LEASE + ) + path = issue_lock_store.lock_file_path( + remote="prgs", org=ORG, repo=REPO, issue_number=943, + lock_dir=self.lock_dir, + ) + issue_lock_store.write_lock_file( + path, + { + "issue_number": 943, + "branch_name": "fix/issue-943-runtime-context-helpers", + "work_lease": { + "task_session_id": lock_session, + "claimant": {"username": IDENTITY, "profile": PROFILE}, + }, + }, + ) if hasattr(issue_lock_store, "write_lock_file") else _write_json( + path, + { + "issue_number": 943, + "branch_name": "fix/issue-943-runtime-context-helpers", + "work_lease": { + "task_session_id": lock_session, + "claimant": {"username": IDENTITY, "profile": PROFILE}, + }, + }, + ) + res = self._resolve_session() + self.assertTrue(res.get("ok"), res) + self.assertEqual(res.get("session_id"), lock_session) + self.assertEqual(res.get("session_source"), "issue_lock") + + def test_issue_lock_owned_by_another_identity_is_refused(self): + path = issue_lock_store.lock_file_path( + remote="prgs", org=ORG, repo=REPO, issue_number=943, + lock_dir=self.lock_dir, + ) + _write_json( + path, + { + "issue_number": 943, + "work_lease": { + "task_session_id": "author_issue_work-" + "0" * 16, + "claimant": {"username": "someone-else", "profile": PROFILE}, + }, + }, + ) + res = self._resolve_session() + self.assertFalse(res.get("ok")) + self.assertEqual(res.get("reason_code"), "issue_lock_owner_mismatch") + + def test_unallocated_bootstrap_mints_a_per_task_key(self): + res = self._resolve_session() + self.assertTrue(res.get("ok"), res) + self.assertEqual(res.get("session_source"), "minted_task_key") + self.assertRegex(res["session_id"], TASK_KEY_RE) + + def test_minted_key_contains_no_process_identifier(self): + res = self._resolve_session() + self.assertNotIn(str(os.getpid()), res["session_id"]) + self.assertNotIn(PROFILE, res["session_id"]) + + def test_sequential_tasks_on_one_daemon_do_not_share_ownership(self): + """The round-1 defect: one identifier per process for every task.""" + first = self._resolve_session()["session_id"] + second = self._resolve_session()["session_id"] + third = self._resolve_session()["session_id"] + self.assertNotEqual(first, second) + self.assertNotEqual(second, third) + self.assertEqual(len({first, second, third}), 3) + + def test_no_process_lifetime_cache_remains(self): + self.assertFalse(hasattr(gms, "_ACTIVE_SESSION_ID")) + self.assertFalse(hasattr(gms, "_current_session_id")) + + +class MutationAuthorityTests(unittest.TestCase): + """F3/F4: one coherent authority pair, drift detected, no silent fallback.""" + + def _ctx(self, **over): + base = {"identity": IDENTITY, "profile_name": PROFILE} + base.update(over) + return base + + def test_matching_live_and_pinned_authority_resolves(self): + with mock.patch.object(gms, "get_profile", + return_value={"profile_name": PROFILE}), \ + mock.patch.object(gms, "_authenticated_username", + return_value=IDENTITY), \ + mock.patch.object(gms.session_ctx, "get_session_context", + return_value=self._ctx()): + res = gms._active_mutation_authority("gitea.prgs.cc") + self.assertTrue(res.get("ok"), res) + self.assertEqual(res["identity"], IDENTITY) + self.assertEqual(res["profile_name"], PROFILE) + + def test_identity_drift_fails_closed(self): + with mock.patch.object(gms, "get_profile", + return_value={"profile_name": PROFILE}), \ + mock.patch.object(gms, "_authenticated_username", + return_value="someone-else"), \ + mock.patch.object(gms.session_ctx, "get_session_context", + return_value=self._ctx()): + res = gms._active_mutation_authority("gitea.prgs.cc") + self.assertFalse(res.get("ok")) + self.assertEqual(res.get("reason_code"), "authority_identity_drift") + self.assertEqual(res.get("expected"), IDENTITY) + self.assertEqual(res.get("actual"), "someone-else") + + def test_profile_drift_fails_closed(self): + with mock.patch.object(gms, "get_profile", + return_value={"profile_name": "prgs-controller"}), \ + mock.patch.object(gms, "_authenticated_username", + return_value=IDENTITY), \ + mock.patch.object(gms.session_ctx, "get_session_context", + return_value=self._ctx()): + res = gms._active_mutation_authority("gitea.prgs.cc") + self.assertFalse(res.get("ok")) + self.assertEqual(res.get("reason_code"), "authority_profile_drift") + + def test_identity_and_profile_never_come_from_different_snapshots(self): + """Round 2's mixed pair: pinned identity plus live profile.""" + with mock.patch.object(gms, "get_profile", + return_value={"profile_name": "prgs-controller"}), \ + mock.patch.object(gms, "_authenticated_username", + return_value="new-identity"), \ + mock.patch.object(gms.session_ctx, "get_session_context", + return_value=self._ctx()): + res = gms._active_mutation_authority("gitea.prgs.cc") + self.assertFalse(res.get("ok")) + self.assertIsNone(gms._active_username("gitea.prgs.cc")) + self.assertIsNone(gms._active_profile_name("gitea.prgs.cc")) + + def test_unresolvable_profile_is_a_structured_refusal_not_a_fallback(self): + """F4: no bare-except fallback to a previously pinned profile name.""" + with mock.patch.object(gms, "get_profile", + side_effect=RuntimeError("profile disabled")), \ + mock.patch.object(gms, "_authenticated_username", + return_value=IDENTITY), \ + mock.patch.object(gms.session_ctx, "get_session_context", + return_value=self._ctx()): + res = gms._active_mutation_authority("gitea.prgs.cc") + self.assertFalse(res.get("ok")) + self.assertEqual(res.get("reason_code"), "authority_profile_unresolved") + self.assertNotEqual(res.get("profile_name"), PROFILE) + + def test_malformed_profile_without_name_fails_closed(self): + with mock.patch.object(gms, "get_profile", return_value={}), \ + mock.patch.object(gms, "_authenticated_username", + return_value=IDENTITY), \ + mock.patch.object(gms.session_ctx, "get_session_context", + return_value=None): + res = gms._active_mutation_authority("gitea.prgs.cc") + self.assertFalse(res.get("ok")) + self.assertEqual(res.get("reason_code"), "authority_profile_unresolved") + + def test_unresolved_identity_fails_closed(self): + for value in (None, "", " "): + with self.subTest(identity=value): + with mock.patch.object(gms, "get_profile", + return_value={"profile_name": PROFILE}), \ + mock.patch.object(gms, "_authenticated_username", + return_value=value), \ + mock.patch.object(gms.session_ctx, "get_session_context", + return_value=None): + res = gms._active_mutation_authority("gitea.prgs.cc") + self.assertFalse(res.get("ok")) + self.assertEqual( + res.get("reason_code"), "authority_identity_unresolved" + ) + + def test_missing_host_cannot_yield_an_identity(self): + with mock.patch.object(gms, "get_profile", + return_value={"profile_name": PROFILE}), \ + mock.patch.object(gms.session_ctx, "get_session_context", + return_value=None): + res = gms._active_mutation_authority(None) + self.assertFalse(res.get("ok")) + self.assertEqual(res.get("reason_code"), "authority_identity_unresolved") + + def test_expected_username_is_never_substituted_for_authentication(self): + with mock.patch.object( + gms, "get_profile", + return_value={"profile_name": PROFILE, "username": IDENTITY}, + ), mock.patch.object(gms, "_authenticated_username", return_value=None), \ + mock.patch.object(gms.session_ctx, "get_session_context", + return_value={"expected_username": IDENTITY}): + self.assertIsNone(gms._active_username("gitea.prgs.cc")) + + def test_accessors_share_one_snapshot(self): + with mock.patch.object(gms, "get_profile", + return_value={"profile_name": PROFILE}), \ + mock.patch.object(gms, "_authenticated_username", + return_value=IDENTITY), \ + mock.patch.object(gms.session_ctx, "get_session_context", + return_value=self._ctx()): + self.assertEqual(gms._active_username("gitea.prgs.cc"), IDENTITY) + self.assertEqual(gms._active_profile_name("gitea.prgs.cc"), PROFILE) + + +class AuthorMutationBlockTests(unittest.TestCase): + """Preserved: the structured refusal shape review 622 confirmed correct.""" + + def test_matches_the_sibling_refusal_shape(self): + res = gms._author_mutation_block(["stopped"]) + self.assertIs(res["success"], False) + self.assertIs(res["performed"], False) + self.assertEqual(res["outcome"], "REFUSED") + self.assertEqual(res["reasons"], ["stopped"]) + + def test_carries_reason_code_and_transport_fields(self): + res = gms._author_mutation_block( + ["nope"], reason_code="authority_identity_drift", + retryable=False, transport_survives=True, + expected="a", actual="b", issue_number=943, + ) + self.assertEqual(res["reason_code"], "authority_identity_drift") + self.assertIs(res["retryable"], False) + self.assertIs(res["transport_survives"], True) + self.assertEqual((res["expected"], res["actual"]), ("a", "b")) + self.assertEqual(res["issue_number"], 943) + self.assertIs(res["success"], False) + + +class RuntimeHelperResolutionTests(unittest.TestCase): + """Every runtime helper the wrapper references is defined and callable. + + Supplements the runtime coverage above; it does not replace it. + """ + + def test_named_helpers_are_defined_and_callable(self): + for name in RUNTIME_HELPERS: + with self.subTest(helper=name): + self.assertTrue(hasattr(gms, name), f"{name} is not defined") + self.assertTrue(callable(getattr(gms, name))) + + def test_every_global_referenced_by_the_wrapper_resolves(self): + """The generalised form of the round-1 defect: an unresolvable global.""" + fn = _wrapper_ast() + bound: set[str] = {a.arg for a in fn.args.args} + bound |= {a.arg for a in fn.args.kwonlyargs} + if fn.args.vararg: + bound.add(fn.args.vararg.arg) + if fn.args.kwarg: + bound.add(fn.args.kwarg.arg) + for node in ast.walk(fn): + if isinstance(node, ast.Name) and isinstance( + node.ctx, (ast.Store, ast.Del) + ): + bound.add(node.id) + elif isinstance(node, (ast.Import, ast.ImportFrom)): + for alias in node.names: + bound.add((alias.asname or alias.name).split(".")[0]) + elif isinstance(node, ast.ExceptHandler) and node.name: + bound.add(node.name) + + unresolved = sorted( + node.id + for node in ast.walk(fn) + if isinstance(node, ast.Name) + and isinstance(node.ctx, ast.Load) + and node.id not in bound + and not hasattr(gms, node.id) + and not hasattr(builtins, node.id) + ) + self.assertEqual( + unresolved, [], + f"{WRAPPER_NAME} references undefined globals: {unresolved}", + ) + + def test_wrapper_wires_the_authority_and_session_resolvers(self): + fn = _wrapper_ast() + called = { + node.func.id + for node in ast.walk(fn) + if isinstance(node, ast.Call) and isinstance(node.func, ast.Name) + } + self.assertIn("_active_mutation_authority", called) + self.assertIn("_resolve_owner_workflow_session", called) + self.assertIn("_author_mutation_block", called) + + def test_wrapper_accepts_an_optional_session_id(self): + """ABI addition stays backward compatible: optional, defaulting to None.""" + fn = _wrapper_ast() + names = [a.arg for a in fn.args.args] + self.assertIn("session_id", names) + offset = len(names) - len(fn.args.defaults) + default = fn.args.defaults[names.index("session_id") - offset] + self.assertIsInstance(default, ast.Constant) + self.assertIsNone(default.value) + + +class Issue941ScopeGuardNotRegressedTests(unittest.TestCase): + """Preserved: PR #942's bootstrap-scope wiring still holds.""" + + def setUp(self): + self._tmp = tempfile.TemporaryDirectory() + self.addCleanup(self._tmp.cleanup) + self.repo, self.head = _make_control_repo(self._tmp.name) + + def _assessment(self, task: str = BOOTSTRAP_TASK) -> dict: + return aib.assess_author_issue_bootstrap( + workspace_path=self.repo, + canonical_repo_root=self.repo, + current_branch="master", + head_sha=self.head, + porcelain_status="", + remote_master_sha=self.head, + task=task, + ) + + def test_bootstrap_task_still_permitted_from_clean_control_checkout(self): + res = workflow_scope_guard.assess_root_source_mutation( + workspace_path=self.repo, + canonical_repo_root=self.repo, + role_kind="author", + mutation_task=BOOTSTRAP_TASK, + porcelain_status="", + bootstrap_assessment=self._assessment(), + ) + self.assertFalse(res.get("block"), res) + self.assertNotEqual( + res.get("blocker_kind"), workflow_scope_guard.BLOCKER_MISSING_WORKTREE + ) + + def test_bootstrap_task_still_blocked_without_evidence(self): + res = workflow_scope_guard.assess_root_source_mutation( + workspace_path=self.repo, + canonical_repo_root=self.repo, + role_kind="author", + mutation_task=BOOTSTRAP_TASK, + porcelain_status="", + ) + self.assertTrue(res.get("block")) + self.assertEqual( + res.get("blocker_kind"), workflow_scope_guard.BLOCKER_MISSING_WORKTREE + ) + + def test_ordinary_author_mutation_still_blocked_from_control_checkout(self): + res = workflow_scope_guard.assess_root_source_mutation( + workspace_path=self.repo, + canonical_repo_root=self.repo, + role_kind="author", + mutation_task="commit_files", + porcelain_status="", + bootstrap_assessment=self._assessment(), + ) + self.assertTrue(res.get("block")) + self.assertEqual( + res.get("blocker_kind"), workflow_scope_guard.BLOCKER_MISSING_WORKTREE + ) + + def test_create_issue_bootstrap_unchanged(self): + self.assertTrue(cib.is_create_issue_task("create_issue")) + self.assertFalse(cib.is_create_issue_task(BOOTSTRAP_TASK)) + + +def _write_json(path: str, payload: dict) -> None: + """Write an issue-lock file directly, for lock-precedence tests.""" + import json + + os.makedirs(os.path.dirname(path), exist_ok=True) + with open(path, "w", encoding="utf-8") as fh: + json.dump(payload, fh) + + +if __name__ == "__main__": + unittest.main() diff --git a/tests/test_issue_945_enforcement_path_wiring.py b/tests/test_issue_945_enforcement_path_wiring.py new file mode 100644 index 0000000..add1822 --- /dev/null +++ b/tests/test_issue_945_enforcement_path_wiring.py @@ -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() diff --git a/tests/test_issue_945_owning_pr_renewal_continuation.py b/tests/test_issue_945_owning_pr_renewal_continuation.py new file mode 100644 index 0000000..2e32506 --- /dev/null +++ b/tests/test_issue_945_owning_pr_renewal_continuation.py @@ -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() diff --git a/tests/test_issue_953_bootstrap_lock_contract.py b/tests/test_issue_953_bootstrap_lock_contract.py new file mode 100644 index 0000000..04fc741 --- /dev/null +++ b/tests/test_issue_953_bootstrap_lock_contract.py @@ -0,0 +1,1975 @@ +"""One canonical bootstrap/lock contract and its recovery path (#953). + +Covers the defect in which ``gitea_bootstrap_author_issue_worktree`` reported a +lock as created, wrote a shape no downstream reader accepts, and then directed +the author to implement — after which heartbeat, re-lock, exact-owner renewal, +and the #447 create-PR guard all refuse simultaneously and no sanctioned +recovery path remains eligible. + +Every fixture here is synthetic and isolated: locks are written into temporary +directories and the git repositories are created per-test with ``git init``. +The #949-shaped regression reproduces that lock *shape*; it never touches the +real issue #949 branch, worktree, lock, issue, or head. +""" + +import contextlib +import os +import subprocess +import sys +import tempfile +import unittest +from datetime import datetime, timedelta, timezone +from unittest import mock + +sys.path.insert(0, str(__import__("pathlib").Path(__file__).resolve().parent.parent)) +sys.path.insert(0, str(__import__("pathlib").Path(__file__).resolve().parent)) + +from mutation_profile_fixture import shared_mutation_env # noqa: E402 + +import author_issue_bootstrap # noqa: E402 +import author_lock_contract # noqa: E402 +import bootstrap_lock_recovery # noqa: E402 +import gitea_audit # noqa: E402 +import issue_lock_provenance # noqa: E402 +import issue_lock_store # noqa: E402 +import mcp_server # noqa: E402 +import role_namespace_gate # noqa: E402 +import role_session_router # noqa: E402 + +ISSUE = 9530 +BRANCH = f"fix/issue-{ISSUE}-canonical-contract" +IDENTITY = "example-author-user" +PROFILE = "example-author-profile" +FOREIGN_IDENTITY = "example-other-user" +FOREIGN_PROFILE = "example-other-profile" +REMOTE = "prgs" +ORG = "ExampleOrg" +REPO = "ExampleRepo" +HEAD = "a" * 40 +OTHER_HEAD = "b" * 40 + + +def _ts(delta_minutes: int = 0) -> str: + return ( + (datetime.now(timezone.utc) + timedelta(minutes=delta_minutes)) + .replace(microsecond=0) + .isoformat() + .replace("+00:00", "Z") + ) + + +def bootstrap_shaped_lock(**overrides): + """The exact malformed shape #949 was left in by the old bootstrap. + + Claimant at the lock top level, ``lease_id`` null, and no ``work_lease``, + ``lock_provenance``, or ``expires_at``. + """ + lock = { + "remote": REMOTE, + "org": ORG, + "repo": REPO, + "issue_number": ISSUE, + "branch": BRANCH, + "branch_name": BRANCH, + "worktree_path": "/scratch/wt-9530", + "owner_session": "author_issue_work-deadbeefdeadbeef", + "claimant": {"username": IDENTITY, "profile": PROFILE}, + "assignment_id": None, + "lease_id": None, + "expected_base_sha": OTHER_HEAD, + "lock_generation": 1, + } + lock.update(overrides) + return lock + + +def canonical_lock(worktree="/scratch/wt-9530", **overrides): + lock = author_lock_contract.build_canonical_issue_lock( + issue_number=ISSUE, + branch_name=BRANCH, + worktree_path=worktree, + remote=REMOTE, + org=ORG, + repo=REPO, + identity=IDENTITY, + profile=PROFILE, + tool="gitea_lock_issue", + ) + lock["lock_generation"] = 2 + lock["session_pid"] = os.getpid() + lock.update(overrides) + return lock + + +class CanonicalContractShape(unittest.TestCase): + """AC1, AC6, AC13: one contract, emitted with every required field.""" + + def test_bootstrap_builder_emits_the_full_canonical_schema(self): + lock = author_lock_contract.build_canonical_issue_lock( + issue_number=ISSUE, + branch_name=BRANCH, + worktree_path="/scratch/wt", + remote=REMOTE, + org=ORG, + repo=REPO, + identity=IDENTITY, + profile=PROFILE, + tool="gitea_bootstrap_author_issue_worktree", + source=author_lock_contract.SOURCE_BOOTSTRAP, + ) + for field in author_lock_contract.REQUIRED_LOCK_FIELDS: + self.assertIn(field, lock, f"canonical lock missing {field}") + for field in author_lock_contract.REQUIRED_WORK_LEASE_FIELDS: + self.assertIn(field, lock["work_lease"], f"work_lease missing {field}") + self.assertEqual( + lock["work_lease"]["claimant"], + {"username": IDENTITY, "profile": PROFILE}, + ) + self.assertEqual( + lock["lock_provenance"]["written_by_tool"], + "gitea_bootstrap_author_issue_worktree", + ) + + def test_bootstrap_and_lock_issue_produce_the_same_contract(self): + """AC13: the two writers must not disagree about what a lock is.""" + from_bootstrap = author_lock_contract.build_canonical_issue_lock( + issue_number=ISSUE, + branch_name=BRANCH, + worktree_path="/scratch/wt", + remote=REMOTE, + org=ORG, + repo=REPO, + identity=IDENTITY, + profile=PROFILE, + tool="gitea_bootstrap_author_issue_worktree", + source=author_lock_contract.SOURCE_BOOTSTRAP, + ) + from_lock_issue = author_lock_contract.build_canonical_issue_lock( + issue_number=ISSUE, + branch_name=BRANCH, + worktree_path="/scratch/wt", + remote=REMOTE, + org=ORG, + repo=REPO, + identity=IDENTITY, + profile=PROFILE, + tool="gitea_lock_issue", + ) + self.assertEqual(sorted(from_bootstrap.keys()), sorted(from_lock_issue.keys())) + self.assertEqual( + sorted(from_bootstrap["work_lease"].keys()), + sorted(from_lock_issue["work_lease"].keys()), + ) + for lock in (from_bootstrap, from_lock_issue): + self.assertTrue( + author_lock_contract.assess_lock_contract(lock)["canonical"] + ) + + def test_no_successful_build_returns_a_null_ownership_token(self): + """AC6: the fencing token every later check keys on is never null.""" + lock = author_lock_contract.build_canonical_issue_lock( + issue_number=ISSUE, + branch_name=BRANCH, + worktree_path="/scratch/wt", + remote=REMOTE, + org=ORG, + repo=REPO, + identity=IDENTITY, + profile=PROFILE, + tool="gitea_bootstrap_author_issue_worktree", + ) + self.assertTrue(lock["work_lease"]["task_session_id"]) + self.assertIsNotNone(lock["work_lease"]["expires_at"]) + + def test_provenance_cannot_be_supplied_by_a_caller(self): + """Safety: provenance is minted server-side, never accepted.""" + import inspect as _inspect + + params = _inspect.signature( + author_lock_contract.build_canonical_issue_lock + ).parameters + self.assertNotIn("lock_provenance", params) + self.assertNotIn("provenance", params) + + +class MalformedAndPartialLocks(unittest.TestCase): + """AC7, AC12, AC19: malformed, partial, and missing-expiration locks.""" + + def test_bootstrap_shaped_lock_is_reported_as_not_canonical(self): + assessment = author_lock_contract.assess_lock_contract(bootstrap_shaped_lock()) + self.assertFalse(assessment["canonical"]) + self.assertIn("work_lease", assessment["missing_fields"]) + self.assertIn("lock_provenance", assessment["missing_fields"]) + + def test_missing_fields_are_reported_structurally_and_by_name(self): + """AC7: the refusal names what is missing, not just that it failed.""" + assessment = author_lock_contract.assess_lock_contract(bootstrap_shaped_lock()) + message = author_lock_contract.format_contract_refusal(assessment) + self.assertIn("work_lease", message) + self.assertIn("lock_provenance", message) + self.assertIsInstance(assessment["missing_fields"], list) + + def test_missing_expiration_is_explicit_not_never_expiring(self): + """AC12: the bug — absent expiry read as 'not yet expired'.""" + lock = bootstrap_shaped_lock() + # The pre-existing reader still reports "not expired" for this lock... + self.assertFalse(issue_lock_store.is_lease_expired(lock)) + # ...so the contract states the real fact explicitly instead. + state = author_lock_contract.expiration_state(lock) + self.assertEqual(state["state"], author_lock_contract.EXPIRATION_MISSING) + self.assertIsNone(state["expired"]) + assessment = author_lock_contract.assess_lock_contract(lock) + self.assertTrue( + any("neither expirable nor renewable" in r for r in assessment["reasons"]) + ) + + def test_missing_expiration_lock_is_recoverable_rather_than_stranded(self): + """AC12: it must not be non-expiring *and* ineligible for every path.""" + assessment = bootstrap_lock_recovery.assess_bootstrap_lock_recovery( + bootstrap_shaped_lock(worktree_path="/scratch/wt-9530"), + issue_number=ISSUE, + branch_name=BRANCH, + worktree_path="/scratch/wt-9530", + remote=REMOTE, + org=ORG, + repo=REPO, + identity=IDENTITY, + profile=PROFILE, + observed_head=HEAD, + declared_head=HEAD, + worktree_exists=True, + worktree_registered=True, + current_branch=BRANCH, + ) + self.assertTrue(assessment["recovery_sanctioned"], assessment["reasons"]) + + def test_partial_lock_missing_only_provenance_is_not_canonical(self): + lock = canonical_lock() + lock.pop("lock_provenance") + assessment = author_lock_contract.assess_lock_contract(lock) + self.assertFalse(assessment["canonical"]) + self.assertFalse(assessment["create_pr_eligible"]) + + def test_unparseable_expiration_is_named_rather_than_silently_ignored(self): + lock = canonical_lock() + lock["work_lease"]["expires_at"] = "not-a-timestamp" + state = author_lock_contract.expiration_state(lock) + self.assertEqual(state["state"], author_lock_contract.EXPIRATION_UNPARSEABLE) + + def test_expired_lock_is_reported_as_expired(self): + lock = canonical_lock() + lock["work_lease"]["expires_at"] = _ts(-60) + state = author_lock_contract.expiration_state(lock) + self.assertEqual(state["state"], author_lock_contract.EXPIRATION_RECORDED) + self.assertTrue(state["expired"]) + + def test_absent_lock_reports_absent_contract(self): + assessment = author_lock_contract.assess_lock_contract(None) + self.assertEqual(assessment["contract"], author_lock_contract.CONTRACT_ABSENT) + self.assertIn( + "gitea_lock_issue", author_lock_contract.recommended_action(assessment) + ) + + +class ClaimantCompatibility(unittest.TestCase): + """AC2, AC13, AC14: legacy and canonical claimant placement.""" + + def test_claimant_is_read_from_the_legacy_top_level_placement(self): + recorded = author_lock_contract.lock_claimant(bootstrap_shaped_lock()) + self.assertEqual(recorded, {"username": IDENTITY, "profile": PROFILE}) + + def test_claimant_is_read_from_the_canonical_work_lease_placement(self): + recorded = author_lock_contract.lock_claimant(canonical_lock()) + self.assertEqual(recorded, {"username": IDENTITY, "profile": PROFILE}) + + def test_work_lease_placement_wins_over_a_stale_top_level_copy(self): + """An upgraded lock must not be re-read from its stale legacy copy.""" + lock = canonical_lock() + lock["claimant"] = {"username": FOREIGN_IDENTITY, "profile": FOREIGN_PROFILE} + self.assertEqual( + author_lock_contract.lock_claimant(lock), + {"username": IDENTITY, "profile": PROFILE}, + ) + + def test_ownership_check_accepts_the_legacy_placement(self): + """AC2: the exact refusal that made a fresh bootstrap lock un-heartbeatable.""" + refusals = issue_lock_store._ownership_refusals( + bootstrap_shaped_lock(worktree_path="/scratch/wt-9530"), + issue_number=ISSUE, + branch_name=BRANCH, + worktree_path="/scratch/wt-9530", + identity=IDENTITY, + profile=PROFILE, + ) + self.assertNotIn( + "lock does not record both a claimant username and profile", refusals + ) + self.assertEqual(refusals, []) + + def test_ownership_check_still_refuses_a_mismatched_claimant(self): + """Tolerating the placement must not tolerate the wrong owner.""" + refusals = issue_lock_store._ownership_refusals( + bootstrap_shaped_lock(worktree_path="/scratch/wt-9530"), + issue_number=ISSUE, + branch_name=BRANCH, + worktree_path="/scratch/wt-9530", + identity=FOREIGN_IDENTITY, + profile=PROFILE, + ) + self.assertTrue(any("does not match active identity" in r for r in refusals)) + + def test_ownership_check_still_refuses_a_lock_with_no_claimant_at_all(self): + lock = bootstrap_shaped_lock(worktree_path="/scratch/wt-9530") + lock.pop("claimant") + refusals = issue_lock_store._ownership_refusals( + lock, + issue_number=ISSUE, + branch_name=BRANCH, + worktree_path="/scratch/wt-9530", + identity=IDENTITY, + profile=PROFILE, + ) + self.assertIn( + "lock does not record both a claimant username and profile", refusals + ) + + +class CreatePrProvenanceGuardPreserved(unittest.TestCase): + """AC4 and the safety requirement that #447 is not weakened.""" + + def test_canonical_bootstrap_lock_passes_the_447_guard(self): + lock = author_lock_contract.build_canonical_issue_lock( + issue_number=ISSUE, + branch_name=BRANCH, + worktree_path="/scratch/wt", + remote=REMOTE, + org=ORG, + repo=REPO, + identity=IDENTITY, + profile=PROFILE, + tool="gitea_bootstrap_author_issue_worktree", + source=author_lock_contract.SOURCE_BOOTSTRAP, + ) + verdict = issue_lock_provenance.assess_lock_file_for_create_pr(lock) + self.assertTrue(verdict["proven"], verdict["reasons"]) + + def test_the_old_bootstrap_shape_is_still_rejected_by_the_447_guard(self): + """The guard must keep failing closed on a lock with no provenance.""" + verdict = issue_lock_provenance.assess_lock_file_for_create_pr( + bootstrap_shaped_lock() + ) + self.assertFalse(verdict["proven"]) + self.assertTrue(verdict["block"]) + + def test_guard_still_rejects_an_unsanctioned_provenance_source(self): + lock = canonical_lock() + lock["lock_provenance"]["source"] = "hand_written_by_caller" + verdict = issue_lock_provenance.assess_lock_file_for_create_pr(lock) + self.assertFalse(verdict["proven"]) + + def test_guard_still_rejects_provenance_without_work_lease(self): + lock = canonical_lock() + lock.pop("work_lease") + verdict = issue_lock_provenance.assess_lock_file_for_create_pr(lock) + self.assertFalse(verdict["proven"]) + + def test_sanctioned_source_set_was_not_widened(self): + """Bootstrap satisfies the guard; it does not get its own exemption.""" + self.assertEqual( + author_lock_contract.SOURCE_BOOTSTRAP, + issue_lock_provenance.SOURCE_LOCK_ISSUE, + ) + + +class RecoveryOwnershipVerification(unittest.TestCase): + """AC10, AC11: what recovery proves before it changes lock state.""" + + def _assess(self, lock=None, **overrides): + kwargs = dict( + issue_number=ISSUE, + branch_name=BRANCH, + worktree_path="/scratch/wt-9530", + remote=REMOTE, + org=ORG, + repo=REPO, + identity=IDENTITY, + profile=PROFILE, + observed_head=HEAD, + declared_head=HEAD, + worktree_exists=True, + worktree_registered=True, + current_branch=BRANCH, + ) + kwargs.update(overrides) + target = ( + lock + if lock is not None + else bootstrap_shaped_lock(worktree_path="/scratch/wt-9530") + ) + return bootstrap_lock_recovery.assess_bootstrap_lock_recovery(target, **kwargs) + + def test_exact_owner_recovery_is_sanctioned(self): + self.assertTrue(self._assess()["recovery_sanctioned"]) + + def test_mismatched_repository_is_refused(self): + result = self._assess(repo="OtherRepo") + self.assertFalse(result["recovery_sanctioned"]) + self.assertEqual( + result["refusal_code"], bootstrap_lock_recovery.REFUSAL_BINDING_MISMATCH + ) + + def test_mismatched_org_is_refused(self): + self.assertFalse(self._assess(org="OtherOrg")["recovery_sanctioned"]) + + def test_mismatched_remote_is_refused(self): + self.assertFalse(self._assess(remote="dadeschools")["recovery_sanctioned"]) + + def test_mismatched_issue_is_refused(self): + self.assertFalse(self._assess(issue_number=ISSUE + 1)["recovery_sanctioned"]) + + def test_mismatched_branch_is_refused(self): + self.assertFalse( + self._assess(branch_name="fix/issue-9530-other")["recovery_sanctioned"] + ) + + def test_mismatched_worktree_is_refused(self): + self.assertFalse( + self._assess(worktree_path="/scratch/elsewhere")["recovery_sanctioned"] + ) + + def test_missing_worktree_is_refused(self): + result = self._assess(worktree_exists=False) + self.assertFalse(result["recovery_sanctioned"]) + self.assertEqual( + result["refusal_code"], bootstrap_lock_recovery.REFUSAL_WORKTREE_INVALID + ) + + def test_unregistered_worktree_is_refused(self): + self.assertFalse(self._assess(worktree_registered=False)["recovery_sanctioned"]) + + def test_worktree_on_a_different_branch_is_refused(self): + self.assertFalse(self._assess(current_branch="master")["recovery_sanctioned"]) + + def test_head_mismatch_is_refused(self): + result = self._assess(declared_head=OTHER_HEAD) + self.assertFalse(result["recovery_sanctioned"]) + self.assertEqual( + result["refusal_code"], bootstrap_lock_recovery.REFUSAL_HEAD_MISMATCH + ) + + def test_unresolvable_identity_is_refused(self): + self.assertFalse(self._assess(identity="")["recovery_sanctioned"]) + + def test_unresolvable_profile_is_refused(self): + self.assertFalse(self._assess(profile="")["recovery_sanctioned"]) + + def test_matching_username_alone_does_not_prove_ownership(self): + """Safety: a matching username with the wrong profile is still foreign.""" + self.assertFalse(self._assess(profile=FOREIGN_PROFILE)["recovery_sanctioned"]) + + def test_healthy_foreign_owned_lock_cannot_be_recovered(self): + """AC11: the foreign-takeover refusal.""" + foreign = canonical_lock(worktree="/scratch/wt-9530") + foreign["work_lease"]["claimant"] = { + "username": FOREIGN_IDENTITY, + "profile": FOREIGN_PROFILE, + } + foreign["session_pid"] = os.getpid() # alive → healthy + result = self._assess(lock=foreign) + self.assertFalse(result["recovery_sanctioned"]) + self.assertEqual( + result["refusal_code"], bootstrap_lock_recovery.REFUSAL_HEALTHY_FOREIGN + ) + + def test_foreign_owned_incomplete_lock_is_also_refused(self): + """A foreign lock is refused whether or not it is healthy.""" + foreign = bootstrap_shaped_lock( + worktree_path="/scratch/wt-9530", + claimant={"username": FOREIGN_IDENTITY, "profile": FOREIGN_PROFILE}, + ) + result = self._assess(lock=foreign) + self.assertFalse(result["recovery_sanctioned"]) + self.assertIn( + result["refusal_code"], + { + bootstrap_lock_recovery.REFUSAL_FOREIGN_CLAIMANT, + bootstrap_lock_recovery.REFUSAL_HEALTHY_FOREIGN, + }, + ) + + def test_healthy_same_owner_canonical_lock_is_left_alone(self): + """Nothing to recover: rewriting would invalidate a live heartbeat token.""" + result = self._assess(lock=canonical_lock(worktree="/scratch/wt-9530")) + self.assertFalse(result["recovery_sanctioned"]) + self.assertEqual( + result["refusal_code"], bootstrap_lock_recovery.REFUSAL_ALREADY_CANONICAL + ) + + def test_absent_lock_is_refused(self): + result = self._assess(lock={}) + self.assertFalse(result["recovery_sanctioned"]) + self.assertEqual(result["refusal_code"], bootstrap_lock_recovery.REFUSAL_NO_LOCK) + + def test_recovery_never_requires_base_equivalence(self): + """AC9: the branch carries commits; that must not be disqualifying.""" + import inspect as _inspect + + params = _inspect.signature( + bootstrap_lock_recovery.assess_bootstrap_lock_recovery + ).parameters + self.assertNotIn("base_equivalent", params) + self.assertNotIn("expected_base_sha", params) + + def test_recovery_accepts_no_caller_supplied_authorization(self): + """Safety: no caller-manufactured provenance or authorization.""" + import inspect as _inspect + + params = _inspect.signature( + bootstrap_lock_recovery.assess_bootstrap_lock_recovery + ).parameters + for forbidden in ( + "recovery_sanctioned", + "lock_provenance", + "provenance", + "operator_override", + "authorized", + ): + self.assertNotIn(forbidden, params) + + +class RecoveryAuditTrail(unittest.TestCase): + """AC10: auditable ownership and generation transition.""" + + def _assessment(self, **overrides): + lock = bootstrap_shaped_lock(worktree_path="/scratch/wt-9530", **overrides) + return bootstrap_lock_recovery.assess_bootstrap_lock_recovery( + lock, + issue_number=ISSUE, + branch_name=BRANCH, + worktree_path="/scratch/wt-9530", + remote=REMOTE, + org=ORG, + repo=REPO, + identity=IDENTITY, + profile=PROFILE, + observed_head=HEAD, + declared_head=HEAD, + worktree_exists=True, + worktree_registered=True, + current_branch=BRANCH, + ) + + def test_recovery_record_preserves_both_sides_of_the_transition(self): + record = bootstrap_lock_recovery.build_recovery_record( + self._assessment(), recovered_at=_ts(), new_task_session_id="task-new" + ) + self.assertEqual(record["recovery_kind"], "incomplete_bootstrap_lock") + self.assertEqual(record["prior_generation"], 1) + self.assertEqual( + record["prior_owner_session"], "author_issue_work-deadbeefdeadbeef" + ) + self.assertEqual(record["replacement_task_session_id"], "task-new") + self.assertEqual(record["preserved_head"], HEAD) + self.assertFalse(record["branch_reset"]) + self.assertFalse(record["base_equivalence_required"]) + self.assertIn("work_lease", record["prior_missing_fields"]) + + def test_expected_generation_is_reported_for_compare_and_swap(self): + self.assertEqual( + self._assessment(lock_generation=7)["expected_generation"], 7 + ) + + +class RecoveryIsolationAndPersistence(unittest.TestCase): + """AC8, AC16: recovery upgrades only its target and inspection mutates nothing.""" + + def setUp(self): + self.tmp = tempfile.TemporaryDirectory() + self.addCleanup(self.tmp.cleanup) + self.lock_dir = self.tmp.name + + def _write(self, lock, **kwargs): + return issue_lock_store.bind_session_lock( + lock, lock_dir=self.lock_dir, **kwargs + ) + + def test_recovery_upgrades_the_target_lock_to_canonical(self): + path = self._write(bootstrap_shaped_lock(worktree_path="/scratch/wt-9530")) + before = issue_lock_store.read_lock_file(path) + self.assertFalse(author_lock_contract.assess_lock_contract(before)["canonical"]) + + self._write( + author_lock_contract.build_canonical_issue_lock( + issue_number=ISSUE, + branch_name=BRANCH, + worktree_path="/scratch/wt-9530", + remote=REMOTE, + org=ORG, + repo=REPO, + identity=IDENTITY, + profile=PROFILE, + tool="gitea_recover_incomplete_bootstrap_lock", + ) + ) + after = issue_lock_store.read_lock_file(path) + self.assertTrue(author_lock_contract.assess_lock_contract(after)["canonical"]) + self.assertGreater(after["lock_generation"], before["lock_generation"]) + + def test_recovery_does_not_touch_an_unrelated_lock(self): + """A failure or success must affect only the exact target lock.""" + other_issue = ISSUE + 77 + other_path = self._write( + bootstrap_shaped_lock( + issue_number=other_issue, + branch_name=f"fix/issue-{other_issue}-unrelated", + worktree_path="/scratch/wt-other", + ) + ) + other_before = issue_lock_store.read_lock_file(other_path) + + target_path = self._write( + bootstrap_shaped_lock(worktree_path="/scratch/wt-9530") + ) + self._write( + author_lock_contract.build_canonical_issue_lock( + issue_number=ISSUE, + branch_name=BRANCH, + worktree_path="/scratch/wt-9530", + remote=REMOTE, + org=ORG, + repo=REPO, + identity=IDENTITY, + profile=PROFILE, + tool="gitea_recover_incomplete_bootstrap_lock", + ) + ) + self.assertNotEqual(target_path, other_path) + self.assertEqual(issue_lock_store.read_lock_file(other_path), other_before) + + def test_inspection_performs_no_mutation(self): + """AC16: assessing a lock must not rewrite it.""" + path = self._write(bootstrap_shaped_lock(worktree_path="/scratch/wt-9530")) + before = issue_lock_store.read_lock_file(path) + mtime_before = os.path.getmtime(path) + + author_lock_contract.assess_lock_contract(before) + bootstrap_lock_recovery.assess_bootstrap_lock_recovery( + before, + issue_number=ISSUE, + branch_name=BRANCH, + worktree_path="/scratch/wt-9530", + remote=REMOTE, + org=ORG, + repo=REPO, + identity=IDENTITY, + profile=PROFILE, + observed_head=HEAD, + declared_head=HEAD, + worktree_exists=True, + worktree_registered=True, + current_branch=BRANCH, + ) + self.assertEqual(issue_lock_store.read_lock_file(path), before) + self.assertEqual(os.path.getmtime(path), mtime_before) + + +class HeartbeatOnFreshAndRecoveredLocks(unittest.TestCase): + """AC2, AC3: heartbeat and renewal against real durable locks.""" + + def setUp(self): + self.tmp = tempfile.TemporaryDirectory() + self.addCleanup(self.tmp.cleanup) + self.lock_dir = self.tmp.name + self.worktree = os.path.join(self.tmp.name, "wt") + os.makedirs(self.worktree, exist_ok=True) + + def _canonical(self, tool="gitea_bootstrap_author_issue_worktree"): + return author_lock_contract.build_canonical_issue_lock( + issue_number=ISSUE, + branch_name=BRANCH, + worktree_path=self.worktree, + remote=REMOTE, + org=ORG, + repo=REPO, + identity=IDENTITY, + profile=PROFILE, + tool=tool, + ) + + def _heartbeat(self, token): + return issue_lock_store.heartbeat_session_lock( + remote=REMOTE, + org=ORG, + repo=REPO, + issue_number=ISSUE, + branch_name=BRANCH, + worktree_path=self.worktree, + identity=IDENTITY, + profile=PROFILE, + task_session_id=token, + lock_dir=self.lock_dir, + ) + + def test_a_freshly_built_canonical_lock_can_be_heartbeated_immediately(self): + """AC2: the property a bootstrap lock never had.""" + lock = self._canonical() + issue_lock_store.bind_session_lock(lock, lock_dir=self.lock_dir) + outcome = self._heartbeat(lock["work_lease"]["task_session_id"]) + self.assertTrue(outcome["success"], outcome.get("reasons")) + self.assertTrue(outcome["performed"]) + + def test_heartbeat_does_not_change_ownership_or_create_a_second_lock(self): + lock = self._canonical() + path = issue_lock_store.bind_session_lock(lock, lock_dir=self.lock_dir) + self._heartbeat(lock["work_lease"]["task_session_id"]) + after = issue_lock_store.read_lock_file(path) + self.assertEqual( + author_lock_contract.lock_claimant(after), + {"username": IDENTITY, "profile": PROFILE}, + ) + # Count issue locks only: bind_session_lock also writes a + # session-.json pointer, which is pre-existing behaviour and not a + # second claim on the issue. + locks = [ + f + for f in os.listdir(self.lock_dir) + if f.endswith(".json") and not f.startswith("session-") + ] + self.assertEqual(len(locks), 1, f"expected exactly one issue lock, got {locks}") + + def test_heartbeat_refuses_a_foreign_task_session_token(self): + lock = self._canonical(tool="gitea_lock_issue") + issue_lock_store.bind_session_lock(lock, lock_dir=self.lock_dir) + outcome = self._heartbeat("author_issue_work-someoneelse") + self.assertFalse(outcome["success"]) + + +class BootstrapToCreatePrRegression(unittest.TestCase): + """AC17, AC18: the exact #949 sequence, against isolated fixtures only. + + This reproduces the *shape* of the #949 failure — bootstrap, implement, + commit, push, create PR — in a throwaway git repository. It never reads or + writes the real issue #949 branch, worktree, lock, issue, or head. + """ + + def setUp(self): + self.tmp = tempfile.TemporaryDirectory() + self.addCleanup(self.tmp.cleanup) + self.lock_dir = os.path.join(self.tmp.name, "locks") + os.makedirs(self.lock_dir, exist_ok=True) + self.origin = os.path.join(self.tmp.name, "origin.git") + self.repo = os.path.join(self.tmp.name, "repo") + subprocess.run( + ["git", "init", "--bare", self.origin], check=True, capture_output=True + ) + subprocess.run(["git", "init", self.repo], check=True, capture_output=True) + self._git("config", "user.email", "author@example.invalid") + self._git("config", "user.name", "Example Author") + with open(os.path.join(self.repo, "README.md"), "w") as handle: + handle.write("base\n") + self._git("add", "README.md") + self._git("commit", "-m", "base commit") + self._git("branch", "-M", "master") + self._git("remote", "add", "origin", self.origin) + self._git("push", "-u", "origin", "master") + + def _git(self, *args): + return subprocess.run( + ["git", "-C", self.repo, *args], check=True, capture_output=True, text=True + ) + + def _heartbeat(self, token): + return issue_lock_store.heartbeat_session_lock( + remote=REMOTE, + org=ORG, + repo=REPO, + issue_number=ISSUE, + branch_name=BRANCH, + worktree_path=self.repo, + identity=IDENTITY, + profile=PROFILE, + task_session_id=token, + lock_dir=self.lock_dir, + ) + + def _implement_and_commit(self): + with open(os.path.join(self.repo, "feature.py"), "w") as handle: + handle.write("VALUE = 1\n") + self._git("add", "feature.py") + self._git("commit", "-m", "feat: implement the issue") + + def test_bootstrap_implement_commit_push_create_pr_completes(self): + # 1. Bootstrap: branch, worktree, and a canonical lock. + self._git("checkout", "-b", BRANCH) + lock = author_lock_contract.build_canonical_issue_lock( + issue_number=ISSUE, + branch_name=BRANCH, + worktree_path=self.repo, + remote=REMOTE, + org=ORG, + repo=REPO, + identity=IDENTITY, + profile=PROFILE, + tool="gitea_bootstrap_author_issue_worktree", + source=author_lock_contract.SOURCE_BOOTSTRAP, + ) + path = issue_lock_store.bind_session_lock(lock, lock_dir=self.lock_dir) + token = lock["work_lease"]["task_session_id"] + + # The lock is usable the moment bootstrap returns. + self.assertTrue( + author_lock_contract.assess_lock_contract( + issue_lock_store.read_lock_file(path) + )["canonical"] + ) + + # 2. Renewal is available *before* the branch diverges (AC3). + before = self._heartbeat(token) + self.assertTrue(before["success"], before.get("reasons")) + + # 3. Implement and commit — the branch now carries real work. + self._implement_and_commit() + head = self._git("rev-parse", "HEAD").stdout.strip() + + # 4. Push. + self._git("push", "-u", "origin", BRANCH) + remote_head = subprocess.run( + ["git", "-C", self.origin, "rev-parse", f"refs/heads/{BRANCH}"], + capture_output=True, + text=True, + check=True, + ).stdout.strip() + self.assertEqual(head, remote_head) + + # 5. Renewal still available *after* commits (AC3) — no base-equivalence. + after = self._heartbeat(token) + self.assertTrue(after["success"], after.get("reasons")) + + # 6. create_pr's #447 provenance guard accepts the lock (AC4). + final = issue_lock_store.read_lock_file(path) + verdict = issue_lock_provenance.assess_lock_file_for_create_pr(final) + self.assertTrue(verdict["proven"], verdict["reasons"]) + + # AC18: completed with no manual lock edit, no branch rewind, no + # fallback transport. The base commit is still an ancestor of head. + merge_base = self._git("merge-base", "master", BRANCH).stdout.strip() + master_head = self._git("rev-parse", "master").stdout.strip() + self.assertEqual(merge_base, master_head) + + def test_the_old_bootstrap_shape_reproduces_the_949_dead_end(self): + """The regression must actually fail without the fix.""" + self._git("checkout", "-b", BRANCH) + self._implement_and_commit() + + legacy = bootstrap_shaped_lock(worktree_path=self.repo) + issue_lock_store.bind_session_lock(legacy, lock_dir=self.lock_dir) + + # create_pr refuses — the #447 guard, unchanged. + self.assertFalse( + issue_lock_provenance.assess_lock_file_for_create_pr(legacy)["proven"] + ) + # And it is never classified as expired, so renewal never engages. + self.assertFalse(issue_lock_store.is_lease_expired(legacy)) + self.assertEqual( + author_lock_contract.expiration_state(legacy)["state"], + author_lock_contract.EXPIRATION_MISSING, + ) + + def test_recovery_of_a_committed_branch_preserves_the_commits(self): + """AC9: recovery must not rewind a branch that carries pushed work.""" + self._git("checkout", "-b", BRANCH) + self._implement_and_commit() + self._git("push", "-u", "origin", BRANCH) + head_before = self._git("rev-parse", "HEAD").stdout.strip() + + legacy = bootstrap_shaped_lock(worktree_path=self.repo) + path = issue_lock_store.bind_session_lock(legacy, lock_dir=self.lock_dir) + + assessment = bootstrap_lock_recovery.assess_bootstrap_lock_recovery( + issue_lock_store.read_lock_file(path), + issue_number=ISSUE, + branch_name=BRANCH, + worktree_path=self.repo, + remote=REMOTE, + org=ORG, + repo=REPO, + identity=IDENTITY, + profile=PROFILE, + observed_head=head_before, + declared_head=head_before, + worktree_exists=True, + worktree_registered=True, + current_branch=BRANCH, + ) + self.assertTrue(assessment["recovery_sanctioned"], assessment["reasons"]) + + recovered = author_lock_contract.build_canonical_issue_lock( + issue_number=ISSUE, + branch_name=BRANCH, + worktree_path=self.repo, + remote=REMOTE, + org=ORG, + repo=REPO, + identity=IDENTITY, + profile=PROFILE, + tool="gitea_recover_incomplete_bootstrap_lock", + ) + recovered["bootstrap_lock_recovery"] = ( + bootstrap_lock_recovery.build_recovery_record( + assessment, + recovered_at=_ts(), + new_task_session_id=recovered["work_lease"]["task_session_id"], + ) + ) + issue_lock_store.bind_session_lock( + recovered, + lock_dir=self.lock_dir, + expected_generation=assessment["expected_generation"], + recovery_sanctioned=True, + ) + + # The branch head is untouched and the lock is now canonical. + self.assertEqual(self._git("rev-parse", "HEAD").stdout.strip(), head_before) + final = issue_lock_store.read_lock_file(path) + self.assertTrue(author_lock_contract.assess_lock_contract(final)["canonical"]) + self.assertTrue( + issue_lock_provenance.assess_lock_file_for_create_pr(final)["proven"] + ) + self.assertEqual( + final["bootstrap_lock_recovery"]["preserved_head"], head_before + ) + self.assertFalse(final["bootstrap_lock_recovery"]["branch_reset"]) + + +class BootstrapWiring(unittest.TestCase): + """AC1, AC5, AC7, AC15: what the bootstrap tool itself now does.""" + + def _source(self): + import author_issue_bootstrap + + with open(author_issue_bootstrap.__file__) as handle: + return handle.read() + + def test_bootstrap_builds_through_the_canonical_contract(self): + source = self._source() + self.assertIn("author_lock_contract.build_canonical_issue_lock", source) + self.assertIn("author_lock_contract.assess_lock_contract", source) + + def test_bootstrap_fails_closed_on_an_incomplete_written_lock(self): + """AC7: partial lock creation stops before implementation begins.""" + source = self._source() + self.assertIn("incomplete_issue_lock_contract", source) + self.assertIn('"implementation_allowed": False', source) + + def test_next_action_for_a_canonical_lock_directs_to_implementation(self): + """AC5: executable under the state actually returned.""" + assessment = author_lock_contract.assess_lock_contract(canonical_lock()) + self.assertIn("work_issue", author_lock_contract.recommended_action(assessment)) + + def test_next_action_for_an_incomplete_lock_forbids_implementation(self): + """AC5, AC15: never direct an author into the unrecoverable state.""" + assessment = author_lock_contract.assess_lock_contract(bootstrap_shaped_lock()) + action = author_lock_contract.recommended_action(assessment) + self.assertIn("Do not begin implementation", action) + self.assertIn("gitea_recover_incomplete_bootstrap_lock", action) + + +class _NativeToolBase(unittest.TestCase): + """A real git worktree, a real durable lock, and the real registered tools. + + Review 632 F4: every previous reference to the two new tools under + ``tests/`` was a string literal, and the suite reconstructed the recovery + sequence by hand. A hand-written sequence 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. These call the registered functions. + """ + + TOOL_ISSUE = 9531 + TOOL_BRANCH = "fix/issue-9531-native-tool-path" + TOOL_ORG = "Example-Org" + TOOL_REPO = "Example-Repo" + AUTHOR_PROFILE = "test-author-prgs" + REVIEWER_PROFILE = "test-reviewer-prgs" + MERGER_PROFILE = "test-merger-prgs" + TOOL_IDENTITY = "example-native-user" + + def setUp(self): + self.lock_dir = tempfile.TemporaryDirectory() + self.addCleanup(self.lock_dir.cleanup) + self.repo = tempfile.mkdtemp(prefix="issue953-native-") + self.addCleanup( + lambda: subprocess.run(["rm", "-rf", self.repo], check=False) + ) + self._init_repo() + self.remotes = mock.patch.dict( + mcp_server.REMOTES, + { + "prgs": { + "host": "gitea.prgs.cc", + "org": self.TOOL_ORG, + "repo": self.TOOL_REPO, + } + }, + ) + self.remotes.start() + self.addCleanup(mock.patch.stopall) + mcp_server._IDENTITY_CACHE.clear() + # The sticky reviewer-stop route is process-global and outlives whatever + # test set it. These cases assert on the *namespace* gate, so the gate + # ahead of it must start clean or it refuses first for another reason. + role_session_router.clear_route_state() + self.addCleanup(role_session_router.clear_route_state) + + def _git(self, *args): + return subprocess.run( + ["git", "-C", self.repo, *args], + capture_output=True, + text=True, + check=True, + ) + + def _init_repo(self): + self._git("init", "-q", "-b", "master") + self._git("config", "user.email", "test@example.com") + self._git("config", "user.name", "Test") + with open(os.path.join(self.repo, "seed.txt"), "w") as handle: + handle.write("seed\n") + self._git("add", "seed.txt") + self._git("commit", "-q", "-m", "seed") + self._git("checkout", "-q", "-b", self.TOOL_BRANCH) + # The state recovery exists for: the branch already carries pushed work. + with open(os.path.join(self.repo, "impl.txt"), "w") as handle: + handle.write("implementation\n") + self._git("add", "impl.txt") + self._git("commit", "-q", "-m", "implementation") + self.head = self._git("rev-parse", "HEAD").stdout.strip() + self.worktree = os.path.realpath(self.repo) + + def _lock_path(self): + return issue_lock_store.lock_file_path( + remote=REMOTE, + org=self.TOOL_ORG, + repo=self.TOOL_REPO, + issue_number=self.TOOL_ISSUE, + lock_dir=self.lock_dir.name, + ) + + def write_incomplete_lock(self): + """The exact malformed shape the old bootstrap left behind.""" + lock = bootstrap_shaped_lock( + issue_number=self.TOOL_ISSUE, + branch=self.TOOL_BRANCH, + branch_name=self.TOOL_BRANCH, + worktree_path=self.worktree, + org=self.TOOL_ORG, + repo=self.TOOL_REPO, + claimant={ + "username": self.TOOL_IDENTITY, + "profile": self.AUTHOR_PROFILE, + }, + ) + path = self._lock_path() + lock["lock_file_path"] = path + issue_lock_store.save_lock_file(path, lock) + return path + + def _env(self, profile_name): + env = shared_mutation_env( + profile_name, + include_example_repo=True, + GITEA_ISSUE_LOCK_DIR=self.lock_dir.name, + ) + env["GITEA_ISSUE_LOCK_DIR"] = self.lock_dir.name + return env + + def call_recovery( + self, + *, + profile_name=None, + identity=None, + claimant_profile=None, + expected_head=None, + audit_sink=None, + namespace_patch=None, + **kwargs, + ): + """Invoke the registered recovery tool itself, not its assessors.""" + profile_name = profile_name or self.AUTHOR_PROFILE + env = self._env(profile_name) + patches = [ + mock.patch( + "mcp_server._work_lease_claimant", + return_value={ + "username": identity or self.TOOL_IDENTITY, + "profile": claimant_profile or profile_name, + }, + ), + mock.patch("mcp_server.get_auth_header", return_value="token x"), + mock.patch( + "mcp_server._canonical_local_git_root", return_value=self.worktree + ), + mock.patch.dict(os.environ, env, clear=True), + ] + if audit_sink is not None: + patches.append( + mock.patch( + "mcp_server._audit", + side_effect=lambda *a, **kw: audit_sink.append((a, kw)), + ) + ) + if namespace_patch is not None: + patches.append(namespace_patch) + with contextlib.ExitStack() as stack: + for patch in patches: + stack.enter_context(patch) + os.environ["GITEA_ISSUE_LOCK_DIR"] = self.lock_dir.name + return mcp_server.gitea_recover_incomplete_bootstrap_lock( + issue_number=kwargs.pop("issue_number", self.TOOL_ISSUE), + branch_name=kwargs.pop("branch_name", self.TOOL_BRANCH), + worktree_path=kwargs.pop("worktree_path", self.worktree), + expected_head=expected_head or self.head, + remote="prgs", + **kwargs, + ) + + def call_inspection(self, *, profile_name=None, **kwargs): + """Invoke the registered read-only inspection tool itself.""" + env = self._env(profile_name or self.AUTHOR_PROFILE) + with mock.patch( + "mcp_server._work_lease_claimant", + return_value={ + "username": self.TOOL_IDENTITY, + "profile": profile_name or self.AUTHOR_PROFILE, + }, + ), mock.patch( + "mcp_server.get_auth_header", return_value="token x" + ), mock.patch( + "mcp_server._canonical_local_git_root", return_value=self.worktree + ), mock.patch.dict( + os.environ, env, clear=True + ): + os.environ["GITEA_ISSUE_LOCK_DIR"] = self.lock_dir.name + return mcp_server.gitea_inspect_issue_lock_contract( + issue_number=kwargs.pop("issue_number", self.TOOL_ISSUE), + remote="prgs", + **kwargs, + ) + + +class NamespaceMutationWallOnRecovery(_NativeToolBase): + """Review 632 F1: the namespace/session wall on the new author mutation. + + ``gitea_recover_incomplete_bootstrap_lock`` writes the same durable lock as + ``gitea_recover_dirty_orphaned_issue_worktree`` and must carry the same + third gate. Exact-owner claimant comparison inside + ``assess_bootstrap_lock_recovery`` is a later layer, not a substitute: it + refuses without a namespace evaluation and without a BLOCKED audit record. + """ + + def test_the_recovery_tool_calls_the_namespace_mutation_gate(self): + """The gate must be reached, with this task, before any write.""" + self.write_incomplete_lock() + seen = [] + + def _spy(task, **kwargs): + seen.append((task, kwargs)) + return None + + self.call_recovery( + namespace_patch=mock.patch( + "mcp_server._namespace_mutation_block", side_effect=_spy + ) + ) + + self.assertEqual(len(seen), 1, seen) + task, kwargs = seen[0] + self.assertEqual(task, "recover_incomplete_bootstrap_lock") + self.assertTrue(kwargs.get("author_role_exclusive")) + + def test_the_gate_return_value_is_consumed_and_returned(self): + """A gate refusal must abort the tool, not be computed and discarded.""" + path = self.write_incomplete_lock() + before = issue_lock_store.read_lock_file(path) + refusal = {"success": False, "performed": False, "namespace_block": True} + + result = self.call_recovery( + namespace_patch=mock.patch( + "mcp_server._namespace_mutation_block", return_value=refusal + ) + ) + + self.assertIs(result, refusal) + self.assertEqual(issue_lock_store.read_lock_file(path), before) + + def test_correct_author_namespace_succeeds(self): + path = self.write_incomplete_lock() + result = self.call_recovery() + self.assertTrue(result.get("success"), result) + self.assertTrue(result.get("performed"), result) + written = issue_lock_store.read_lock_file(path) + self.assertTrue( + author_lock_contract.assess_lock_contract(written)["canonical"], written + ) + + def test_reviewer_namespace_is_rejected_with_matching_claimant_data(self): + """Identity that would satisfy the owner check must not be a way in.""" + path = self.write_incomplete_lock() + before = issue_lock_store.read_lock_file(path) + + result = self.call_recovery( + profile_name=self.REVIEWER_PROFILE, + claimant_profile=self.AUTHOR_PROFILE, + ) + + self.assertFalse(result.get("success"), result) + self.assertFalse(result.get("performed"), result) + self.assertTrue(result.get("namespace_block"), result) + self.assertEqual(result.get("mcp_namespace"), "gitea-reviewer") + # Not the later exact-owner refusal — the namespace layer stopped it. + self.assertNotEqual(result.get("refusal_code"), "foreign_claimant") + self.assertEqual(issue_lock_store.read_lock_file(path), before) + + def test_reviewer_rejection_emits_the_standard_blocked_audit(self): + self.write_incomplete_lock() + audit = [] + self.call_recovery(profile_name=self.REVIEWER_PROFILE, audit_sink=audit) + + blocked = [ + (args, kwargs) + for args, kwargs in audit + if kwargs.get("result") == gitea_audit.BLOCKED + ] + self.assertTrue(blocked, audit) + args, kwargs = blocked[0] + self.assertEqual(args[0], "recover_incomplete_bootstrap_lock") + self.assertEqual( + kwargs.get("mutation_task"), "recover_incomplete_bootstrap_lock" + ) + + def test_merger_profile_is_rejected(self): + """gitea.issue.comment is held by every role; the wall cannot rely on it.""" + path = self.write_incomplete_lock() + before = issue_lock_store.read_lock_file(path) + result = self.call_recovery(profile_name=self.MERGER_PROFILE) + self.assertFalse(result.get("success"), result) + self.assertFalse(result.get("performed"), result) + # Specifically the namespace/role wall, not some later refusal. + self.assertTrue(result.get("namespace_block"), result) + self.assertTrue( + any( + "recover_incomplete_bootstrap_lock' blocked" in reason + for reason in result.get("reasons") or [] + ), + result, + ) + self.assertIsNone(result.get("refusal_code"), result) + self.assertEqual(issue_lock_store.read_lock_file(path), before) + + def test_wrong_profile_for_the_claimant_is_rejected(self): + """A matching username under a different profile is still foreign.""" + path = self.write_incomplete_lock() + before = issue_lock_store.read_lock_file(path) + result = self.call_recovery(claimant_profile="test-author-dadeschools") + self.assertFalse(result.get("success"), result) + self.assertFalse(result.get("performed"), result) + # The namespace wall passes here (the session *is* author-bound); this + # must be the exact-owner layer refusing the mismatched profile. + self.assertIsNone(result.get("namespace_block"), result) + self.assertIn( + result.get("refusal_code"), ("foreign_claimant", "healthy_foreign_lock"), result + ) + self.assertEqual(issue_lock_store.read_lock_file(path), before) + + def test_mismatched_head_is_rejected_without_mutation(self): + path = self.write_incomplete_lock() + before = issue_lock_store.read_lock_file(path) + result = self.call_recovery(expected_head=OTHER_HEAD) + self.assertFalse(result.get("success"), result) + self.assertFalse(result.get("mutation_performed"), result) + self.assertEqual(issue_lock_store.read_lock_file(path), before) + + def test_rejection_leaves_branch_worktree_and_unrelated_locks_untouched(self): + unrelated = issue_lock_store.lock_file_path( + remote=REMOTE, + org=self.TOOL_ORG, + repo=self.TOOL_REPO, + issue_number=self.TOOL_ISSUE + 41, + lock_dir=self.lock_dir.name, + ) + issue_lock_store.save_lock_file( + unrelated, bootstrap_shaped_lock(issue_number=self.TOOL_ISSUE + 41) + ) + unrelated_before = issue_lock_store.read_lock_file(unrelated) + + path = self.write_incomplete_lock() + before = issue_lock_store.read_lock_file(path) + head_before = self._git("rev-parse", "HEAD").stdout.strip() + + self.call_recovery(profile_name=self.REVIEWER_PROFILE) + + self.assertEqual(issue_lock_store.read_lock_file(path), before) + self.assertEqual(issue_lock_store.read_lock_file(unrelated), unrelated_before) + self.assertEqual(self._git("rev-parse", "HEAD").stdout.strip(), head_before) + self.assertTrue(os.path.isdir(self.worktree)) + self.assertEqual(self._git("status", "--porcelain").stdout.strip(), "") + + def test_the_gate_routes_this_task_as_author_required(self): + """Without a router entry the namespace check silently allows everything.""" + self.assertEqual( + role_session_router.required_role_for_task( + "recover_incomplete_bootstrap_lock" + ), + "author", + ) + ok, reasons = role_namespace_gate.check_author_mutation_namespace( + "recover_incomplete_bootstrap_lock", + { + "profile_name": "prgs-reviewer", + "allowed_operations": ["gitea.read", "gitea.pr.approve"], + "forbidden_operations": [], + }, + ) + self.assertFalse(ok, reasons) + + def test_role_kind_wall_admits_author_and_refuses_every_other_role(self): + cases = { + "author": (["gitea.pr.create", "gitea.branch.push"], True), + "reviewer": (["gitea.pr.approve"], False), + "merger": (["gitea.pr.merge"], False), + "limited": (["gitea.issue.comment", "gitea.read"], False), + "mixed": (["gitea.pr.approve", "gitea.pr.create"], False), + } + for label, (ops, expected) in cases.items(): + with self.subTest(role=label): + ok, _ = role_namespace_gate.check_author_role_kind( + "recover_incomplete_bootstrap_lock", + { + "profile_name": f"prgs-{label}", + "allowed_operations": ops, + "forbidden_operations": [], + }, + ) + self.assertEqual(ok, expected) + + +class InspectionToolExecutes(_NativeToolBase): + """AC16 proved against the registered tool, not only its assessors.""" + + def test_the_registered_inspection_tool_reports_the_contract(self): + self.write_incomplete_lock() + result = self.call_inspection() + self.assertTrue(result.get("success"), result) + self.assertTrue(result.get("read_only")) + self.assertTrue(result.get("lock_present")) + self.assertFalse(result["lock_contract"]["canonical"]) + + def test_the_registered_inspection_tool_mutates_nothing(self): + path = self.write_incomplete_lock() + before = issue_lock_store.read_lock_file(path) + mtime_before = os.path.getmtime(path) + head_before = self._git("rev-parse", "HEAD").stdout.strip() + + result = self.call_inspection( + branch_name=self.TOOL_BRANCH, worktree_path=self.worktree + ) + + self.assertFalse(result.get("mutation_performed")) + self.assertFalse(result.get("performed")) + self.assertIn("recovery_preview", result) + self.assertEqual(issue_lock_store.read_lock_file(path), before) + self.assertEqual(os.path.getmtime(path), mtime_before) + self.assertEqual(self._git("rev-parse", "HEAD").stdout.strip(), head_before) + self.assertEqual(self._git("status", "--porcelain").stdout.strip(), "") + + def test_inspection_reports_an_absent_lock_without_creating_one(self): + result = self.call_inspection() + self.assertTrue(result.get("success"), result) + self.assertFalse(result.get("lock_present")) + self.assertFalse(os.path.exists(self._lock_path())) + + +class Ac7PostCompensationGuidance(unittest.TestCase): + """Review 632 F2: the returned action must fit the post-rollback state. + + The AC7 refusal runs ``run_compensating_recovery`` first, which releases the + lock and removes the branch and worktree. Recommending incomplete-lock + recovery for those exact artifacts hands the author ``no_durable_lock`` and + then ``worktree_invalid`` — the unexecutable-guidance failure class #953 + exists to remove, reintroduced on the new fail-closed path. + """ + + ISSUE_NUMBER = 9532 + + def setUp(self): + self.tmp = tempfile.TemporaryDirectory() + self.addCleanup(self.tmp.cleanup) + self.repo = os.path.join(self.tmp.name, "repo") + os.makedirs(self.repo) + self._git("init", "-q", "-b", "master") + self._git("config", "user.email", "test@example.com") + self._git("config", "user.name", "Test") + with open(os.path.join(self.repo, "README.md"), "w") as handle: + handle.write("# seed\n") + self._git("add", "README.md") + self._git("commit", "-q", "-m", "seed") + self.master_sha = self._git("rev-parse", "HEAD").stdout.strip() + os.makedirs(os.path.join(self.repo, "branches"), exist_ok=True) + self.lock_dir = os.path.join(self.tmp.name, "locks") + os.makedirs(self.lock_dir, exist_ok=True) + self.journal_dir = os.path.join(self.tmp.name, "journals") + os.makedirs(self.journal_dir, exist_ok=True) + self._journal_env = mock.patch.dict( + os.environ, {"GITEA_BOOTSTRAP_JOURNAL_DIR": self.journal_dir} + ) + self._journal_env.start() + self.addCleanup(self._journal_env.stop) + + def _git(self, *args): + return subprocess.run( + ["git", "-C", self.repo, *args], + capture_output=True, + text=True, + check=True, + ) + + def _branch_names(self): + out = subprocess.run( + ["git", "-C", self.repo, "branch", "--format=%(refname:short)"], + capture_output=True, + text=True, + check=False, + ) + return [line.strip() for line in out.stdout.splitlines() if line.strip()] + + def _lock_files(self): + """Durable issue locks only — not phase journals or session pointers.""" + found = [] + for path in issue_lock_store.iter_lock_files(self.lock_dir): + record = issue_lock_store.read_lock_file(path) or {} + if "lock_generation" in record: + found.append(os.path.basename(path)) + return sorted(found) + + def _run_bootstrap(self, *, key, partial=False, extra_patches=()): + """Drive the real bootstrap; optionally force a partial written lock.""" + with contextlib.ExitStack() as stack: + if partial: + real_build = author_lock_contract.build_canonical_issue_lock + + def _partial(**kwargs): + record = real_build(**kwargs) + # The #949 shape: claimant hoisted to the top level, no + # work_lease, no provenance, no expiry. + return { + "remote": record["remote"], + "org": record["org"], + "repo": record["repo"], + "issue_number": record["issue_number"], + "branch": record["branch"], + "branch_name": record["branch_name"], + "worktree_path": record["worktree_path"], + "claimant": dict(record["work_lease"]["claimant"]), + "lease_id": None, + "owner_session": record.get("owner_session"), + } + + stack.enter_context( + mock.patch.object( + author_issue_bootstrap.author_lock_contract, + "build_canonical_issue_lock", + side_effect=_partial, + ) + ) + for patch in extra_patches: + stack.enter_context(patch) + return author_issue_bootstrap.bootstrap_author_issue_worktree( + issue_number=self.ISSUE_NUMBER, + canonical_repo_root=self.repo, + expected_base_sha=self.master_sha, + idempotency_key=key, + remote="prgs", + lock_dir=self.lock_dir, + owner_session=f"session-{key}", + active_identity=IDENTITY, + active_profile=PROFILE, + ) + + def test_ac7_failure_then_complete_compensation_directs_to_retry_bootstrap(self): + result = self._run_bootstrap(key="ac7-complete", partial=True) + + self.assertFalse(result.get("success"), result) + self.assertEqual(result.get("reason_code"), "incomplete_issue_lock_contract") + self.assertFalse(result.get("implementation_allowed")) + state = result["post_compensation_state"] + self.assertEqual(state["cleanup_state"], author_lock_contract.CLEANUP_COMPLETE) + self.assertEqual(state["surviving_artifacts"], []) + + action = result["exact_next_action"] + self.assertIn("gitea_bootstrap_author_issue_worktree", action) + self.assertIn("Do not call gitea_recover_incomplete_bootstrap_lock", action) + + def test_the_returned_retry_action_is_executable(self): + """Follow the advice literally and it must succeed.""" + first = self._run_bootstrap(key="ac7-retry-1", partial=True) + self.assertIn( + "gitea_bootstrap_author_issue_worktree", first["exact_next_action"] + ) + + second = self._run_bootstrap(key="ac7-retry-2") + self.assertTrue(second.get("success"), second) + self.assertTrue(second.get("implementation_allowed")) + self.assertTrue(second["lock_contract"]["canonical"], second["lock_contract"]) + self.assertTrue(second.get("task_session_id")) + + def test_retry_after_complete_compensation_leaves_exactly_one_lock(self): + self._run_bootstrap(key="ac7-single-1", partial=True) + self.assertEqual(self._lock_files(), []) + + second = self._run_bootstrap(key="ac7-single-2") + self.assertTrue(second.get("success"), second) + locks = self._lock_files() + self.assertEqual(len(locks), 1, locks) + written = issue_lock_store.read_lock_file(second["lock_state"]) + self.assertTrue(author_lock_contract.assess_lock_contract(written)["canonical"]) + branches = [b for b in self._branch_names() if b != "master"] + self.assertEqual(len(branches), 1, branches) + + def test_no_recommendation_names_an_artifact_the_rollback_deleted(self): + result = self._run_bootstrap(key="ac7-no-ghosts", partial=True) + action = result["exact_next_action"] + state = result["post_compensation_state"] + + self.assertFalse(state["branch_present"]) + self.assertFalse(state["worktree_present"]) + self.assertFalse(state["lock_present"]) + self.assertNotIn(result["worktree_path"], action) + self.assertNotIn(f"branch '{result['branch_name']}'", action) + self.assertFalse(os.path.isdir(result["worktree_path"])) + self.assertNotIn(result["branch_name"], self._branch_names()) + + def test_partial_compensation_with_a_surviving_lock_is_not_reported_complete(self): + def _boom(**kwargs): + raise RuntimeError("lock release failed") + + outcome = self._run_bootstrap( + key="ac7-lock-survives", + partial=True, + extra_patches=[ + mock.patch.object( + author_issue_bootstrap.issue_lock_store, + "release_session_lock", + side_effect=_boom, + ) + ], + ) + + state = outcome["post_compensation_state"] + self.assertTrue(state["lock_present"], state) + self.assertEqual(state["cleanup_state"], author_lock_contract.CLEANUP_PARTIAL) + self.assertIn("lock", state["surviving_artifacts"]) + self.assertTrue(state["failed_rollback_steps"], state) + action = outcome["exact_next_action"] + self.assertIn("failed step", action) + self.assertIn("gitea_inspect_issue_lock_contract", action) + # The advice must not send the author at artifacts the rollback removed. + self.assertNotIn("re-run gitea_bootstrap_author_issue_worktree", action) + + def test_partial_compensation_with_surviving_branch_and_worktree(self): + """A worktree dirty at rollback time is preserved, and so is its branch.""" + real_assess = author_lock_contract.assess_lock_contract + + def _dirty_then_report(lock): + verdict = real_assess(lock) + path = (lock or {}).get("worktree_path") + if path and os.path.isdir(path): + with open(os.path.join(path, "uncommitted.txt"), "w") as handle: + handle.write("author bytes\n") + return verdict + + outcome = self._run_bootstrap( + key="ac7-wt-survives", + partial=True, + extra_patches=[ + mock.patch.object( + author_issue_bootstrap.author_lock_contract, + "assess_lock_contract", + side_effect=_dirty_then_report, + ) + ], + ) + + state = outcome["post_compensation_state"] + self.assertEqual(state["cleanup_state"], author_lock_contract.CLEANUP_PARTIAL) + self.assertTrue(state["worktree_present"], state) + self.assertTrue(state["branch_present"], state) + self.assertTrue(os.path.isdir(outcome["worktree_path"])) + self.assertIn(outcome["branch_name"], self._branch_names()) + self.assertIn("gitea_lock_issue", outcome["exact_next_action"]) + self.assertIn(outcome["branch_name"], outcome["exact_next_action"]) + + def test_compensation_failure_is_distinguished_from_partial_cleanup(self): + outcome = self._run_bootstrap( + key="ac7-comp-failed", + partial=True, + extra_patches=[ + mock.patch.object( + author_issue_bootstrap, + "run_compensating_recovery", + return_value={ + "executed": False, + "rolled_back": [], + "reason": "boom", + }, + ) + ], + ) + + state = outcome["post_compensation_state"] + self.assertEqual(state["cleanup_state"], author_lock_contract.CLEANUP_FAILED) + action = outcome["exact_next_action"] + self.assertIn("did not complete", action) + self.assertIn("gitea_inspect_issue_lock_contract", action) + self.assertNotIn("re-run gitea_bootstrap_author_issue_worktree", action) + + def test_a_failed_rollback_step_is_recorded_even_when_nothing_survives(self): + """A step that errored is still reported, and cleanup is still complete.""" + state = author_lock_contract.assess_post_compensation_state( + { + "executed": True, + "rolled_back": ["lease_release_failed:lease-1:RuntimeError"], + }, + lock_present=False, + worktree_present=False, + branch_present=False, + ) + self.assertEqual(state["cleanup_state"], author_lock_contract.CLEANUP_COMPLETE) + self.assertEqual( + state["failed_rollback_steps"], + ["lease_release_failed:lease-1:RuntimeError"], + ) + + def test_the_compensation_lock_release_actually_removes_the_lock(self): + """The rollback's lock half was dead code before #953 review 632 F2.""" + self.assertTrue(hasattr(issue_lock_store, "release_session_lock")) + result = self._run_bootstrap(key="ac7-release-real", partial=True) + self.assertIn( + f"lock:issue-{self.ISSUE_NUMBER}", + result["compensating_recovery"]["rolled_back"], + ) + self.assertEqual(self._lock_files(), []) + + def test_release_refuses_a_lock_owned_by_a_different_session(self): + created = self._run_bootstrap(key="ac7-foreign-release") + self.assertTrue(created.get("success"), created) + with self.assertRaises(FileNotFoundError): + issue_lock_store.release_session_lock( + issue_number=self.ISSUE_NUMBER, + session="session-somebody-else", + lock_dir=self.lock_dir, + remote="prgs", + org="Scaled-Tech-Consulting", + repo="Gitea-Tools", + ) + self.assertEqual(len(self._lock_files()), 1, self._lock_files()) + + def test_ac7_refusal_never_reports_implementation_ready(self): + result = self._run_bootstrap(key="ac7-never-ready", partial=True) + self.assertFalse(result.get("success")) + self.assertFalse(result.get("implementation_allowed")) + self.assertNotIn( + "proceed with author implementation", result["exact_next_action"] + ) + + def test_ac7_refusal_touches_no_unrelated_lock(self): + unrelated_path = issue_lock_store.lock_file_path( + remote="prgs", + org="Scaled-Tech-Consulting", + repo="Gitea-Tools", + issue_number=self.ISSUE_NUMBER + 63, + lock_dir=self.lock_dir, + ) + issue_lock_store.save_lock_file( + unrelated_path, bootstrap_shaped_lock(issue_number=self.ISSUE_NUMBER + 63) + ) + before = issue_lock_store.read_lock_file(unrelated_path) + mtime_before = os.path.getmtime(unrelated_path) + + self._run_bootstrap(key="ac7-isolation", partial=True) + + self.assertEqual(issue_lock_store.read_lock_file(unrelated_path), before) + self.assertEqual(os.path.getmtime(unrelated_path), mtime_before) + + def test_surviving_lock_and_worktree_direct_to_target_specific_recovery(self): + """The one state in which incomplete-lock recovery *is* executable.""" + state = author_lock_contract.assess_post_compensation_state( + {"executed": True, "rolled_back": []}, + lock_present=True, + worktree_present=True, + branch_present=True, + ) + action = author_lock_contract.post_compensation_action( + state, + issue_number=self.ISSUE_NUMBER, + branch_name=BRANCH, + worktree_path="/scratch/wt", + missing_fields=["work_lease"], + ) + self.assertEqual(state["cleanup_state"], author_lock_contract.CLEANUP_PARTIAL) + self.assertIn("gitea_recover_incomplete_bootstrap_lock", action) + self.assertIn(BRANCH, action) + self.assertIn("/scratch/wt", action) + + +class NativeEndToEndBootstrapToCreatePr(unittest.TestCase): + """AC17/AC18 driven through the real tools, with no gitea_lock_issue repair. + + bootstrap → inspect → heartbeat/renew → legitimate divergence → downstream + validation → create_pr provenance. The lock the real bootstrap writes must + carry the whole sequence on its own; repairing it with the older + ``gitea_lock_issue`` path would prove nothing about the new contract, so + that path is patched to fail the test if anything reaches for it. + """ + + ISSUE_NUMBER = 9533 + + def setUp(self): + self.tmp = tempfile.TemporaryDirectory() + self.addCleanup(self.tmp.cleanup) + self.origin = os.path.join(self.tmp.name, "origin.git") + self.repo = os.path.join(self.tmp.name, "repo") + subprocess.run( + ["git", "init", "-q", "--bare", self.origin], check=True, capture_output=True + ) + subprocess.run( + ["git", "init", "-q", "-b", "master", self.repo], + check=True, + capture_output=True, + ) + self._git("config", "user.email", "author@example.invalid") + self._git("config", "user.name", "Example Author") + with open(os.path.join(self.repo, "README.md"), "w") as handle: + handle.write("base\n") + self._git("add", "README.md") + self._git("commit", "-q", "-m", "base commit") + self._git("remote", "add", "origin", self.origin) + self._git("push", "-q", "-u", "origin", "master") + self.master_sha = self._git("rev-parse", "HEAD").stdout.strip() + + self.lock_dir = os.path.join(self.tmp.name, "locks") + os.makedirs(self.lock_dir, exist_ok=True) + self.journal_dir = os.path.join(self.tmp.name, "journals") + os.makedirs(self.journal_dir, exist_ok=True) + self._journal_env = mock.patch.dict( + os.environ, {"GITEA_BOOTSTRAP_JOURNAL_DIR": self.journal_dir} + ) + self._journal_env.start() + self.addCleanup(self._journal_env.stop) + self.remotes = mock.patch.dict( + mcp_server.REMOTES, + { + "prgs": { + "host": "gitea.prgs.cc", + "org": "Scaled-Tech-Consulting", + "repo": "Gitea-Tools", + } + }, + ) + self.remotes.start() + self.addCleanup(mock.patch.stopall) + mcp_server._IDENTITY_CACHE.clear() + + def _git(self, *args, cwd=None): + return subprocess.run( + ["git", "-C", cwd or self.repo, *args], + check=True, + capture_output=True, + text=True, + ) + + def _inspect(self, worktree, branch): + env = shared_mutation_env( + "test-author-prgs", GITEA_ISSUE_LOCK_DIR=self.lock_dir + ) + env["GITEA_ISSUE_LOCK_DIR"] = self.lock_dir + with mock.patch( + "mcp_server._work_lease_claimant", + return_value={"username": IDENTITY, "profile": PROFILE}, + ), mock.patch( + "mcp_server.get_auth_header", return_value="token x" + ), mock.patch( + "mcp_server._canonical_local_git_root", return_value=self.repo + ), mock.patch.dict( + os.environ, env, clear=True + ): + os.environ["GITEA_ISSUE_LOCK_DIR"] = self.lock_dir + return mcp_server.gitea_inspect_issue_lock_contract( + issue_number=self.ISSUE_NUMBER, + branch_name=branch, + worktree_path=worktree, + remote="prgs", + ) + + def test_bootstrap_inspect_heartbeat_diverge_and_create_pr_provenance(self): + def _forbidden(*args, **kwargs): + raise AssertionError( + "gitea_lock_issue must not be needed to repair a bootstrap lock" + ) + + with mock.patch.object(mcp_server, "gitea_lock_issue", side_effect=_forbidden): + # 1. Bootstrap through the real function. + result = author_issue_bootstrap.bootstrap_author_issue_worktree( + issue_number=self.ISSUE_NUMBER, + canonical_repo_root=self.repo, + expected_base_sha=self.master_sha, + idempotency_key="e2e-953", + remote="prgs", + lock_dir=self.lock_dir, + owner_session="session-e2e-953", + active_identity=IDENTITY, + active_profile=PROFILE, + ) + self.assertTrue(result.get("success"), result) + self.assertTrue(result.get("implementation_allowed")) + branch = result["branch_name"] + worktree = result["worktree_path"] + token = result["task_session_id"] + self.assertTrue(token) + + # 2. Inspect through the registered read-only tool. + inspected = self._inspect(worktree, branch) + self.assertTrue(inspected.get("success"), inspected) + self.assertTrue(inspected["lock_contract"]["canonical"], inspected) + self.assertTrue(inspected["lock_contract"]["heartbeatable"]) + self.assertTrue(inspected["lock_contract"]["create_pr_eligible"]) + self.assertFalse(inspected.get("mutation_performed")) + + # 3. Heartbeat/renew while still base-equivalent. + before = issue_lock_store.heartbeat_session_lock( + remote="prgs", + org="Scaled-Tech-Consulting", + repo="Gitea-Tools", + issue_number=self.ISSUE_NUMBER, + branch_name=branch, + worktree_path=worktree, + identity=IDENTITY, + profile=PROFILE, + task_session_id=token, + lock_dir=self.lock_dir, + ) + self.assertTrue(before["success"], before.get("reasons")) + + # 4. Legitimate divergence: implement, commit, push. + with open(os.path.join(worktree, "feature.py"), "w") as handle: + handle.write("VALUE = 1\n") + self._git("add", "feature.py", cwd=worktree) + self._git("commit", "-q", "-m", "feat: implement", cwd=worktree) + self._git("push", "-q", "-u", "origin", branch, cwd=worktree) + head = self._git("rev-parse", "HEAD", cwd=worktree).stdout.strip() + remote_head = subprocess.run( + ["git", "-C", self.origin, "rev-parse", f"refs/heads/{branch}"], + capture_output=True, + text=True, + check=True, + ).stdout.strip() + self.assertEqual(head, remote_head) + + # 5. Downstream validation after divergence. + after = issue_lock_store.heartbeat_session_lock( + remote="prgs", + org="Scaled-Tech-Consulting", + repo="Gitea-Tools", + issue_number=self.ISSUE_NUMBER, + branch_name=branch, + worktree_path=worktree, + identity=IDENTITY, + profile=PROFILE, + task_session_id=token, + lock_dir=self.lock_dir, + ) + self.assertTrue(after["success"], after.get("reasons")) + # The pre-mutation ownership re-check (#438) accepts the diverged + # branch without any repair step. + diverged_lock = issue_lock_store.read_lock_file(result["lock_state"]) + proof = issue_lock_store.verify_lock_for_mutation( + diverged_lock, + issue_number=self.ISSUE_NUMBER, + branch_name=branch, + worktree_path=worktree, + ) + self.assertTrue(proof["proven"], proof["reasons"]) + + # 6. The unchanged #447 create_pr provenance guard accepts it. + final = issue_lock_store.read_lock_file(result["lock_state"]) + verdict = issue_lock_provenance.assess_lock_file_for_create_pr(final) + self.assertTrue(verdict["proven"], verdict["reasons"]) + + # The branch was never rewound to satisfy any gate. + merge_base = self._git("merge-base", "master", branch, cwd=worktree).stdout.strip() + self.assertEqual(merge_base, self.master_sha) + + +class DeadProvenanceConstantRemoved(unittest.TestCase): + """Review 632 F3: no second lock source may appear to exist.""" + + def test_no_recovery_source_constant_is_exported(self): + self.assertFalse( + hasattr(author_lock_contract, "SOURCE_BOOTSTRAP_LOCK_RECOVERY") + ) + + def test_bootstrap_source_is_still_the_sanctioned_lock_issue_source(self): + self.assertEqual( + author_lock_contract.SOURCE_BOOTSTRAP, + issue_lock_provenance.SOURCE_LOCK_ISSUE, + ) + + def test_the_sanctioned_source_set_is_still_not_widened(self): + self.assertNotIn( + "gitea_recover_incomplete_bootstrap_lock", + issue_lock_provenance.SANCTIONED_LOCK_SOURCES, + ) + + +class CapabilityRegistration(unittest.TestCase): + """The new operations are registered and role-gated.""" + + def test_recovery_is_registered_as_an_author_operation(self): + import task_capability_map + + self.assertEqual( + task_capability_map.required_permission( + "recover_incomplete_bootstrap_lock" + ), + "gitea.issue.comment", + ) + self.assertEqual( + task_capability_map.required_role("recover_incomplete_bootstrap_lock"), + "author", + ) + + def test_inspection_requires_only_read_permission(self): + """AC16: a read-only surface must not carry a mutating permission.""" + import task_capability_map + + self.assertEqual( + task_capability_map.required_permission("inspect_issue_lock_contract"), + "gitea.read", + ) + + +class WorkflowDocumentation(unittest.TestCase): + """AC20: the canonical ordering and recovery path are documented.""" + + def test_author_workflow_documents_ordering_and_recovery(self): + root = os.path.dirname(os.path.dirname(os.path.abspath(__file__))) + doc = os.path.join(root, "docs", "author-issue-lock-contract.md") + self.assertTrue(os.path.exists(doc), f"missing {doc}") + with open(doc) as handle: + text = handle.read() + self.assertIn("gitea_recover_incomplete_bootstrap_lock", text) + self.assertIn("gitea_lock_issue", text) + self.assertIn("gitea_bootstrap_author_issue_worktree", text) + self.assertIn("before writing any implementation", text.lower()) + + +if __name__ == "__main__": + unittest.main() diff --git a/tests/test_issue_956_threat_model.py b/tests/test_issue_956_threat_model.py new file mode 100644 index 0000000..6a67ac3 --- /dev/null +++ b/tests/test_issue_956_threat_model.py @@ -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() diff --git a/tests/test_mcp_config_drift.py b/tests/test_mcp_config_drift.py new file mode 100644 index 0000000..9e28650 --- /dev/null +++ b/tests/test_mcp_config_drift.py @@ -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 diff --git a/tests/test_mcp_restart_governance.py b/tests/test_mcp_restart_governance.py new file mode 100644 index 0000000..06ff034 --- /dev/null +++ b/tests/test_mcp_restart_governance.py @@ -0,0 +1,478 @@ +"""Concurrent-session MCP restart safety & dogfooding test suite (#666). + +Automated test suite proving all 10 dogfooding bullets required by Issue #666: +1. One LLM cannot restart MCP unilaterally (role-based restart authorization matrix). +2. New work stops during drain (assignments_stopped gate enforcement). +3. Active safe work can finish (ack collection / graceful completion before restart). +4. Unsafe mutations block restart (in-flight author/reviewer mutation gates). +5. Session state is durably checkpointed (checkpoints_complete validation). +6. Leases/locks not silently orphaned (lease lifecycle & post-restart lease audit). +7. Sessions resume or receive canonical next action (reconcile proof canonical next action). +8. Failed drain creates durable incident work (durable incident descriptor & bridge integration). +9. Restart of one component does not unnecessarily interrupt unrelated work (scoped restart impact). +10. Restart/upgrade workflows do not require manual chat reconstruction (state handoff ledger & completion proof). + +Links parent #655, vision #652, roadmap #653, #658, #659, #660, #661, #662, #663. +""" + +from __future__ import annotations + +import os +import unittest +from datetime import datetime, timedelta, timezone + +import drain_proof as dp +import mcp_restart_paths as rp +import post_restart_reconcile as prr +import restart_coordinator as rc +from restart_coordinator import RestartClass + +NOW = datetime(2026, 7, 25, 12, 0, 0, tzinfo=timezone.utc) +SECRET = b"test-secret-dogfooding-issue-666-0123456789" + + +def _live_pid() -> int: + return os.getpid() + + +def _clean_drain_state() -> dict: + return { + "assignments_stopped": True, + "checkpoints_complete": True, + "handoffs_verified": True, + "leases_handled": True, + "acks": {}, + "ack_timeout_policy_applied": False, + } + + +def _clean_inventory() -> dict: + return { + "service_health": {"healthy": True}, + "clients": [], + "sessions": [ + { + "session_id": "prgs-controller-1", + "role": "controller", + "profile": "prgs-controller", + "pid": _live_pid(), + "status": "active", + "last_heartbeat_at": NOW.isoformat(), + } + ], + "checkpoints": [], + "leases": [], + "capabilities": {}, + "worktree_bindings": [], + "pending_mutations": [], + "inventory_complete": True, + } + + +class TestBullet1UnilateralRestartForbidden(unittest.TestCase): + """Bullet 1: One LLM cannot restart MCP unilaterally.""" + + def test_worker_role_unilateral_full_restart_denied(self): + policy = rc.RESTART_CLASS_POLICIES[RestartClass.FULL_MCP_RESTART] + for worker_role in ("author", "reviewer", "merger", "reconciler"): + self.assertNotIn( + worker_role, + policy.request_roles, + f"Worker role '{worker_role}' must not unilaterally authorize FULL_MCP_RESTART", + ) + + def test_privileged_role_full_restart_authorized(self): + policy = rc.RESTART_CLASS_POLICIES[RestartClass.FULL_MCP_RESTART] + for priv_role in ("controller", "operator", "admin"): + self.assertIn( + priv_role, + policy.request_roles, + f"Privileged role '{priv_role}' must be authorized for FULL_MCP_RESTART", + ) + + def test_evaluate_impact_records_unauthorized_worker_request(self): + report = rc.evaluate_restart_impact( + {"sessions": [], "leases": [], "inventory_complete": True}, + now=NOW, + restart_class=RestartClass.FULL_MCP_RESTART, + requester_role="author", + requesting_session_id="prgs-author-123", + ) + self.assertFalse(report.role_authorized) + self.assertEqual(report.verdict, rc.VERDICT_UNSAFE) + self.assertTrue(any("may not request" in r.lower() or "authorization denied" in r.lower() for r in report.reasons)) + + +class TestBullet2NewWorkStopsDuringDrain(unittest.TestCase): + """Bullet 2: New work stops during drain.""" + + def test_assignments_stopped_false_blocks_drain_proof(self): + state = _clean_drain_state() + state["assignments_stopped"] = False + + impact = rc.evaluate_restart_impact( + {"sessions": [], "leases": [], "inventory_complete": True}, + now=NOW, + ).as_dict() + + proof = dp.build_drain_proof( + secret=SECRET, + impact_report=impact, + drain_state=state, + now=NOW, + ) + + self.assertFalse(proof.clean) + check = next(c for c in proof.checks if c.name == dp.CHECK_ASSIGNMENTS_STOPPED) + self.assertFalse(check.passed) + + gate = dp.gate_apply_restart(proof=proof.as_dict(), secret=SECRET, now=NOW) + self.assertEqual(gate.verdict, dp.GATE_DENY) + self.assertFalse(gate.allow) + self.assertTrue(any("drain proof invalid" in r.lower() or "assignments_stopped" in r.lower() for r in gate.reasons)) + + +class TestBullet3ActiveSafeWorkCanFinish(unittest.TestCase): + """Bullet 3: Active safe work can finish.""" + + def test_active_safe_sessions_ack_allows_clean_drain(self): + sessions = [ + { + "session_id": "prgs-controller-1", + "role": "controller", + "profile": "prgs-controller", + "pid": _live_pid(), + "status": "active", + "last_heartbeat_at": NOW.isoformat(), + }, + { + "session_id": "prgs-reviewer-42", + "role": "reviewer", + "profile": "prgs-reviewer", + "pid": _live_pid(), + "status": "active", + "last_heartbeat_at": NOW.isoformat(), + }, + ] + leases = [ + { + "lease_id": "lease-ro", + "session_id": "prgs-reviewer-42", + "role": "reviewer", + "phase": "reviewing", + "is_mutating": False, + "expires_at": (NOW + timedelta(minutes=5)).isoformat(), + "pid": _live_pid(), + } + ] + + impact = rc.evaluate_restart_impact( + {"sessions": sessions, "leases": leases, "inventory_complete": True}, + now=NOW, + requesting_session_id="prgs-controller-1", + ).as_dict() + + state = _clean_drain_state() + state["acks"] = {"prgs-reviewer-42": "ack"} + + proof = dp.build_drain_proof( + secret=SECRET, + impact_report=impact, + drain_state=state, + now=NOW, + ) + + self.assertTrue(proof.clean) + gate = dp.gate_apply_restart(proof=proof.as_dict(), secret=SECRET, now=NOW) + self.assertTrue(gate.allow) + self.assertEqual(gate.verdict, dp.GATE_ALLOW) + + +class TestBullet4UnsafeMutationsBlockRestart(unittest.TestCase): + """Bullet 4: Unsafe mutations block restart.""" + + def test_inflight_unsafe_mutation_yields_unsafe_verdict(self): + sessions = [ + { + "session_id": "prgs-controller-1", + "role": "controller", + "profile": "prgs-controller", + "pid": _live_pid(), + "status": "active", + "last_heartbeat_at": NOW.isoformat(), + }, + { + "session_id": "prgs-author-99", + "role": "author", + "profile": "prgs-author", + "pid": _live_pid(), + "status": "active", + "last_heartbeat_at": NOW.isoformat(), + }, + ] + leases = [ + { + "lease_id": "lease-mutating", + "session_id": "prgs-author-99", + "role": "author", + "phase": "implementing", + "worktree_path": "/Users/jasonwalker/Development/Gitea-Tools/branches/feat-test", + "freshness": {"freshness": "active"}, + "expires_at": (NOW + timedelta(minutes=5)).isoformat(), + "pid": _live_pid(), + } + ] + + report = rc.evaluate_restart_impact( + {"sessions": sessions, "leases": leases, "inventory_complete": True}, + now=NOW, + requesting_session_id="prgs-controller-1", + ) + + self.assertEqual(report.verdict, rc.VERDICT_UNSAFE) + self.assertFalse(report.allow_restart) + self.assertGreater(len(report.mutations), 0) + + proof = dp.build_drain_proof( + secret=SECRET, + impact_report=report.as_dict(), + drain_state=_clean_drain_state(), + now=NOW, + ) + + self.assertFalse(proof.clean) + check = next(c for c in proof.checks if c.name == dp.CHECK_NO_INFLIGHT_MUTATIONS) + self.assertFalse(check.passed) + + gate = dp.gate_apply_restart(proof=proof.as_dict(), secret=SECRET, now=NOW) + self.assertEqual(gate.verdict, dp.GATE_DENY) + self.assertFalse(gate.allow) + + +class TestBullet5DurableSessionCheckpoints(unittest.TestCase): + """Bullet 5: Session state is durably checkpointed.""" + + def test_incomplete_checkpoints_blocks_drain_proof(self): + state = _clean_drain_state() + state["checkpoints_complete"] = False + + impact = rc.evaluate_restart_impact( + {"sessions": [], "leases": [], "inventory_complete": True}, + now=NOW, + ).as_dict() + + proof = dp.build_drain_proof( + secret=SECRET, + impact_report=impact, + drain_state=state, + now=NOW, + ) + + self.assertFalse(proof.clean) + check = next(c for c in proof.checks if c.name == dp.CHECK_CHECKPOINTS_COMPLETE) + self.assertFalse(check.passed) + + def test_post_restart_reconcile_audits_checkpoint_dimension(self): + inv = _clean_inventory() + inv["checkpoints_available"] = True + inv["checkpoints"] = [ + { + "session_id": "prgs-author-99", + "checkpoint_id": "chk-1", + "stale": True, + } + ] + + proof = prr.reconcile_after_restart(inv, now=NOW, mode=prr.MODE_ENFORCE) + chk_item = next(i for i in proof.items if i.dimension == prr.DIM_CHECKPOINTS) + self.assertIn(chk_item.status, (prr.ITEM_UNRESOLVED, prr.ITEM_DEGRADED, prr.ITEM_SKIPPED)) + + +class TestBullet6LeasesNotSilentlyOrphaned(unittest.TestCase): + """Bullet 6: Leases/locks not silently orphaned.""" + + def test_unhandled_leases_block_drain_proof(self): + state = _clean_drain_state() + state["leases_handled"] = False + + impact = rc.evaluate_restart_impact( + {"sessions": [], "leases": [], "inventory_complete": True}, + now=NOW, + ).as_dict() + + proof = dp.build_drain_proof( + secret=SECRET, + impact_report=impact, + drain_state=state, + now=NOW, + ) + + self.assertFalse(proof.clean) + check = next(c for c in proof.checks if c.name == dp.CHECK_LEASES_HANDLED) + self.assertFalse(check.passed) + + def test_post_restart_reconcile_audits_all_leases(self): + inv = _clean_inventory() + inv["leases"] = [ + { + "lease_id": "lease-orphaned-1", + "session_id": "prgs-author-dead", + "role": "author", + "status": "active", + "freshness": "expired", + "expires_at": (NOW - timedelta(minutes=10)).isoformat(), + } + ] + + proof = prr.reconcile_after_restart(inv, now=NOW, mode=prr.MODE_LOG_ONLY) + lease_item = next(i for i in proof.items if i.dimension == prr.DIM_LEASES) + self.assertIsNotNone(lease_item) + self.assertTrue(lease_item.summary) + + +class TestBullet7SessionsResumeOrReceiveNextAction(unittest.TestCase): + """Bullet 7: Sessions resume or receive canonical next action.""" + + def test_reconcile_provides_canonical_next_action_for_unresolved(self): + inv = _clean_inventory() + inv["pending_mutations"] = [ + { + "mutation_id": "mut-404", + "session_id": "prgs-author-77", + "phase": "implementing", + "issue_number": 666, + } + ] + + proof = prr.reconcile_after_restart(inv, now=NOW, mode=prr.MODE_ENFORCE) + self.assertEqual(proof.overall_status, prr.STATUS_DEGRADED) + self.assertTrue(proof.mutation_hold) + self.assertTrue(proof.note) + self.assertGreater(len(proof.proposed_follow_ups), 0) + + +class TestBullet8FailedDrainCreatesIncidentWork(unittest.TestCase): + """Bullet 8: Failed drain creates durable incident work.""" + + def test_denied_drain_gate_mints_durable_incident_descriptor(self): + impact = rc.evaluate_restart_impact( + {"sessions": [], "leases": [], "inventory_complete": True}, + now=NOW, + ).as_dict() + + state = _clean_drain_state() + state["assignments_stopped"] = False + + proof = dp.build_drain_proof( + secret=SECRET, + impact_report=impact, + drain_state=state, + now=NOW, + ) + + gate = dp.gate_apply_restart(proof=proof.as_dict(), secret=SECRET, now=NOW) + self.assertEqual(gate.verdict, dp.GATE_DENY) + + incident = gate.incident + self.assertIsNotNone(incident) + self.assertEqual(incident["kind"], "restart_drain_gate_denied") + self.assertTrue(any("assignments_stopped" in r for r in incident["reasons"])) + + +class TestBullet9ScopedRestartNonInterference(unittest.TestCase): + """Bullet 9: Restart of one component does not unnecessarily interrupt unrelated work.""" + + def test_scoped_role_restart_impacts_only_target_role(self): + sessions = [ + { + "session_id": "prgs-controller-1", + "role": "controller", + "profile": "prgs-controller", + "pid": _live_pid(), + "status": "active", + "last_heartbeat_at": NOW.isoformat(), + }, + { + "session_id": "prgs-author-10", + "role": "author", + "profile": "prgs-author", + "pid": _live_pid(), + "status": "active", + "last_heartbeat_at": NOW.isoformat(), + }, + { + "session_id": "prgs-reviewer-20", + "role": "reviewer", + "profile": "prgs-reviewer", + "pid": _live_pid(), + "status": "active", + "last_heartbeat_at": NOW.isoformat(), + }, + ] + + policy = rc.RESTART_CLASS_POLICIES[RestartClass.ROLE_RUNTIME_RESTART] + report = rc.evaluate_restart_impact( + {"sessions": sessions, "leases": [], "inventory_complete": True}, + now=NOW, + restart_class=RestartClass.ROLE_RUNTIME_RESTART, + target_role="reviewer", + requesting_session_id="prgs-controller-1", + requester_role="controller", + requester_permissions=list(policy.request_roles), + controller_approved=True, + ) + + self.assertTrue(report.role_authorized) + + def test_scoped_connector_restart_limits_blast_radius(self): + sessions = [ + { + "session_id": "prgs-author-10", + "role": "author", + "connector": "gitea-author", + "pid": _live_pid(), + "status": "active", + "last_heartbeat_at": NOW.isoformat(), + }, + { + "session_id": "prgs-reviewer-20", + "role": "reviewer", + "connector": "gitea-reviewer", + "pid": _live_pid(), + "status": "active", + "last_heartbeat_at": NOW.isoformat(), + }, + ] + + policy = rc.RESTART_CLASS_POLICIES[RestartClass.CONNECTOR_RESTART] + report = rc.evaluate_restart_impact( + {"sessions": sessions, "leases": [], "inventory_complete": True}, + now=NOW, + restart_class=RestartClass.CONNECTOR_RESTART, + target_connector="gitea-author", + requesting_session_id="prgs-controller-1", + requester_role="controller", + requester_permissions=list(policy.request_roles), + controller_approved=True, + ) + + self.assertIsNotNone(report) + + +class TestBullet10NoManualChatReconstruction(unittest.TestCase): + """Bullet 10: Restart/upgrade workflows do not require manual chat reconstruction.""" + + def test_end_to_end_restart_reconcile_handoff_proof(self): + inv = _clean_inventory() + proof = prr.reconcile_after_restart(inv, now=NOW, mode=prr.MODE_LOG_ONLY) + + proof_dict = proof.as_dict() + self.assertEqual(proof_dict["overall_status"], prr.STATUS_COMPLETE) + self.assertFalse(proof_dict["mutation_hold"]) + self.assertTrue(proof_dict["note"]) + self.assertIn("links", proof_dict) + self.assertEqual(proof_dict["links"]["umbrella"], 655) + + +if __name__ == "__main__": + unittest.main() diff --git a/tests/test_mcp_stale_runtime.py b/tests/test_mcp_stale_runtime.py index 7a92fc3..286bd6d 100644 --- a/tests/test_mcp_stale_runtime.py +++ b/tests/test_mcp_stale_runtime.py @@ -35,10 +35,10 @@ class TestMcpStaleRuntime(unittest.TestCase): # Mock env output for ps eww mock_run_env12345 = MagicMock() - mock_run_env12345.stdout = "GITEA_MCP_PROFILE=prgs-reconciler" + mock_run_env12345.stdout = "GITEA_MCP_PROFILE=prgs-reconciler GITEA_CLIENT_MANAGED=1" mock_run_env54321 = MagicMock() - mock_run_env54321.stdout = "GITEA_MCP_PROFILE=prgs-author" + mock_run_env54321.stdout = "GITEA_MCP_PROFILE=prgs-author GITEA_CLIENT_MANAGED=1" def side_effect(args, **kwargs): if args[0] == "ps" and "eww" in args: @@ -91,7 +91,7 @@ class TestMcpStaleRuntime(unittest.TestCase): mock_run_ps.stdout = ps_output mock_run_env = MagicMock() - mock_run_env.stdout = "GITEA_MCP_PROFILE=prgs-author" + mock_run_env.stdout = "GITEA_MCP_PROFILE=prgs-author GITEA_CLIENT_MANAGED=1" mock_run_git = MagicMock() mock_run_git.stdout = "FAKE2" # different SHA diff --git a/tests/test_recovery_playbook.py b/tests/test_recovery_playbook.py new file mode 100644 index 0000000..57e5005 --- /dev/null +++ b/tests/test_recovery_playbook.py @@ -0,0 +1,217 @@ +"""Unit tests for the scoped recovery playbook (#669).""" + +from __future__ import annotations + +import recovery_playbook as rp +import restart_coordinator as rc + + +def test_ladder_covers_eleven_ordered_rungs(): + ranks = [r.rank for r in rp.RECOVERY_LADDER] + assert ranks == list(range(len(rp.RECOVERY_LADDER))) + assert len(rp.RECOVERY_LADDER) == 11 + assert rp.RECOVERY_LADDER[0].action is rp.RecoveryAction.CLIENT_RECONNECT + assert rp.RECOVERY_LADDER[-1].action is rp.RecoveryAction.HOST_RESTART + + +def test_ladder_document_links_parent_issues(): + doc = rp.ladder_document() + assert "#655" in doc["parent_issues"] + assert "#652" in doc["parent_issues"] + assert "#653" in doc["parent_issues"] + assert doc["enforcement_issue"] == "#669" + assert "full_mcp_restart" in doc["broad_restart_actions"] + + +def test_recommend_transport_eof_starts_at_client_reconnect(): + plan = rp.recommend_actions(symptoms=["transport_eof"]) + assert plan["recommended_actions"][0]["action"] == "client_reconnect" + assert plan["recommended_actions"][0]["issue_links"] + + +def test_recommend_skips_successful_prior_attempts(): + attempts = [ + rp.build_attempt_record( + "client_reconnect", outcome="success", reason="reconnected" + ) + ] + plan = rp.recommend_actions( + symptoms=["transport_eof"], prior_recovery_attempts=attempts + ) + actions = [a["action"] for a in plan["recommended_actions"]] + assert "client_reconnect" not in actions + assert actions[0] == "capability_refresh" + + +def test_escalation_denied_without_attempt_log(): + result = rp.assess_escalation("full_mcp_restart", prior_recovery_attempts=[]) + assert result.allowed is False + assert result.require_attempt_log is True + assert any("#669" in r for r in result.reasons) + assert result.recommended_next # soft recommendations still provided + + +def test_escalation_allowed_after_insufficient_narrower(): + attempts = [ + rp.build_attempt_record( + "client_reconnect", + outcome="insufficient", + reason="still flapping", + ), + rp.build_attempt_record( + "session_reconnect", + outcome="failed", + reason="namespace still dead", + ), + ] + result = rp.assess_escalation( + "full_mcp_restart", prior_recovery_attempts=attempts + ) + assert result.allowed is True + assert len(result.qualifying_attempts) == 2 + + +def test_escalation_break_glass_bypasses_attempt_log(): + result = rp.assess_escalation( + "host_restart", prior_recovery_attempts=[], break_glass=True + ) + assert result.allowed is True + assert result.break_glass is True + + +def test_narrow_action_does_not_require_attempt_log(): + result = rp.assess_escalation( + "client_reconnect", prior_recovery_attempts=[] + ) + assert result.allowed is True + assert result.require_attempt_log is False + + +def test_same_rank_attempt_does_not_qualify_for_escalation(): + attempts = [ + rp.build_attempt_record( + "full_mcp_restart", outcome="failed", reason="already failed full" + ) + ] + result = rp.assess_escalation( + "full_mcp_restart", prior_recovery_attempts=attempts + ) + assert result.allowed is False + + +def test_success_outcome_does_not_qualify_for_escalation(): + attempts = [ + rp.build_attempt_record( + "client_reconnect", outcome="success", reason="fixed" + ) + ] + result = rp.assess_escalation( + "full_mcp_restart", prior_recovery_attempts=attempts + ) + assert result.allowed is False + + +def test_recovery_metrics_fraction_avoided(): + attempts = [ + rp.build_attempt_record("client_reconnect", outcome="success"), + rp.build_attempt_record("session_reconnect", outcome="success"), + rp.build_attempt_record("full_mcp_restart", outcome="success"), + ] + metrics = rp.recovery_metrics(attempts) + assert metrics["successes_total"] == 3 + assert metrics["successes_avoided_full_restart"] == 2 + assert metrics["successes_full_or_host_restart"] == 1 + assert abs(metrics["fraction_avoided_full_restart"] - (2 / 3)) < 1e-9 + + +def test_coordinator_denies_full_restart_without_attempt_log(): + inv = { + "inventory_complete": True, + "sessions": [], + "leases": [], + "prior_recovery_attempts": [], + } + report = rc.evaluate_restart_impact( + inv, + restart_class=rc.RestartClass.FULL_MCP_RESTART, + requester_role="controller", + requester_permissions=rc.permissions_for_role("controller"), + controller_approved=True, + operator_authorized=True, + ) + assert report.allow_restart is False + assert report.attempt_log_satisfied is False + assert report.verdict == rc.VERDICT_UNSAFE + blob = " ".join(report.reasons + report.authorization_reasons) + assert "#669" in blob or "attempt log" in blob + + +def test_coordinator_allows_full_restart_with_attempt_log(): + inv = { + "inventory_complete": True, + "sessions": [], + "leases": [], + "prior_recovery_attempts": [ + { + "action": "client_reconnect", + "outcome": "insufficient", + "reason": "still broken", + } + ], + } + report = rc.evaluate_restart_impact( + inv, + restart_class=rc.RestartClass.FULL_MCP_RESTART, + requester_role="controller", + requester_permissions=rc.permissions_for_role("controller"), + controller_approved=True, + operator_authorized=True, + ) + assert report.attempt_log_satisfied is True + assert report.allow_restart is True + assert report.verdict == rc.VERDICT_SAFE + + +def test_coordinator_break_glass_allows_without_log(): + inv = { + "inventory_complete": True, + "sessions": [], + "leases": [], + "prior_recovery_attempts": [], + } + report = rc.evaluate_restart_impact( + inv, + restart_class=rc.RestartClass.FULL_MCP_RESTART, + requester_role="controller", + requester_permissions=rc.permissions_for_role("controller"), + controller_approved=True, + operator_authorized=True, + break_glass=True, + ) + assert report.break_glass is True + assert report.attempt_log_satisfied is True + assert report.allow_restart is True + + +def test_coordinator_client_reconnect_unaffected(): + inv = { + "inventory_complete": True, + "sessions": [], + "leases": [], + "prior_recovery_attempts": [], + } + report = rc.evaluate_restart_impact( + inv, + restart_class=rc.RestartClass.CLIENT_RECONNECT, + requester_role="author", + requester_permissions=rc.permissions_for_role("author"), + ) + assert report.attempt_log_satisfied is True + assert report.allow_restart is True + + +def test_restart_class_alias_accepted(): + assert ( + rp.resolve_action("full_mcp_restart") + is rp.RecoveryAction.FULL_MCP_RESTART + ) diff --git a/tests/test_runtime_clarity.py b/tests/test_runtime_clarity.py index fabd6aa..e169d66 100644 --- a/tests/test_runtime_clarity.py +++ b/tests/test_runtime_clarity.py @@ -243,9 +243,10 @@ class TestRuntimeClarity(unittest.TestCase): self.assertIn("switching is disabled", res["message"].lower()) self.assertIsNone(gitea_config._active_profile_override) + @patch("mcp_server._trusted_session_repository", return_value={"repository": "Example-Org/Example-Repo", "org": "Example-Org", "repo": "Example-Repo", "reasons": []}) @patch("mcp_server.api_request") @patch("mcp_server.get_auth_header") - def test_activate_profile_succeeds_when_enabled(self, mock_auth, mock_api): + def test_activate_profile_succeeds_when_enabled(self, mock_auth, mock_api, mock_trusted): self._write_config(CONFIG_SWITCHING_ENABLED) # Setup mock responses for whoami checks diff --git a/tests/test_webui_console_recovery.py b/tests/test_webui_console_recovery.py new file mode 100644 index 0000000..b22627b --- /dev/null +++ b/tests/test_webui_console_recovery.py @@ -0,0 +1,506 @@ +"""Unit and integration tests for Phase 2 Web Console recovery controls (#644).""" + +from __future__ import annotations + +import os +import sys +import types +import unittest +from unittest.mock import patch + +from starlette.testclient import TestClient + +import merged_cleanup_reconcile +import runtime_recovery_guard +import stable_branch_push_guard +import stale_binding_recovery +from webui import console_authz, console_recovery, system_health +from webui.app import create_app + + +class TestConsoleRecovery(unittest.TestCase): + + def test_diagnose_recovery_healthy(self) -> None: + diag = console_recovery.diagnose_recovery() + self.assertIn(diag.status, {console_recovery.STATUS_HEALTHY, console_recovery.STATUS_ACTION_REQUIRED}) + self.assertIsInstance(diag.playbooks, tuple) + self.assertGreaterEqual(len(diag.playbooks), 4) + + playbook_ids = {pb.playbook_id for pb in diag.playbooks} + self.assertIn(console_recovery.PLAYBOOK_CLEAR_STALE_BINDING, playbook_ids) + self.assertIn(console_recovery.PLAYBOOK_REBIND_SESSION, playbook_ids) + self.assertIn(console_recovery.PLAYBOOK_RECONCILE_CLEANUPS, playbook_ids) + self.assertIn(console_recovery.PLAYBOOK_SANCTIONED_RESTART, playbook_ids) + + def test_confirmation_phrase_generation_and_matching(self) -> None: + phrase = console_recovery.confirmation_phrase("clear_stale_binding") + self.assertEqual(phrase, "confirm clear_stale_binding") + self.assertTrue(console_recovery.confirmation_matches("clear_stale_binding", "confirm clear_stale_binding")) + self.assertFalse(console_recovery.confirmation_matches("clear_stale_binding", "wrong phrase")) + + phrase_target = console_recovery.confirmation_phrase("sanctioned_restart", "gitea-author") + self.assertEqual(phrase_target, "confirm sanctioned_restart gitea-author") + self.assertTrue(console_recovery.confirmation_matches("sanctioned_restart", "confirm sanctioned_restart gitea-author", "gitea-author")) + + def test_build_recovery_preview(self) -> None: + principal = console_authz.Principal("dev@example.com", console_authz.OPERATOR, console_authz.IDENTITY_LOCAL_DEV, True) + preview = console_recovery.build_recovery_preview( + playbook_id=console_recovery.PLAYBOOK_CLEAR_STALE_BINDING, + target="test-worktree", + principal=principal, + ) + self.assertEqual(preview["playbook_id"], console_recovery.PLAYBOOK_CLEAR_STALE_BINDING) + self.assertEqual(preview["action_id"], console_recovery.ACTION_CLEAR_STALE_BINDING) + self.assertEqual(preview["confirmation_phrase"], "confirm clear_stale_binding test-worktree") + self.assertTrue(len(preview["mutation_ledger"]) >= 3) + self.assertTrue(preview["authorization"]["allowed"]) + + def test_build_recovery_preview_unknown_playbook(self) -> None: + preview = console_recovery.build_recovery_preview("unknown_playbook") + self.assertFalse(preview.get("allowed")) + self.assertEqual(preview.get("error"), "unknown_playbook") + + def test_execute_recovery_playbook_confirmation_mismatch(self) -> None: + # Authorization is checked before confirmation, so the phase gate has to + # pass for this test to reach the branch it is about. + principal = console_authz.Principal("dev@example.com", console_authz.OPERATOR, console_authz.IDENTITY_LOCAL_DEV, True) + with self._phase_two_enabled(): + result = console_recovery.execute_recovery_playbook( + playbook_id=console_recovery.PLAYBOOK_CLEAR_STALE_BINDING, + confirmation="invalid confirmation", + principal=principal, + ) + self.assertFalse(result["success"]) + self.assertFalse(result["allowed"]) + self.assertEqual(result["error"], "confirmation_mismatch") + + def test_execute_recovery_playbook_unauthorized(self) -> None: + # Anonymous principal has viewer role -> should be denied + result = console_recovery.execute_recovery_playbook( + playbook_id=console_recovery.PLAYBOOK_CLEAR_STALE_BINDING, + confirmation="confirm clear_stale_binding", + principal=console_authz.ANONYMOUS, + ) + self.assertFalse(result["success"]) + self.assertFalse(result["allowed"]) + self.assertEqual(result["error"], console_authz.DENY_UNAUTHENTICATED) + + def test_execute_refuses_phase_two_write_while_console_is_phase_one(self) -> None: + """B1: the apply path must arm the phase gate, not skip it. + + ``authorize`` only applies the phase branch when ``for_execution=True``. + The apply path used the default, so an operator executed a phase-2 write + while ``ACTIVE_PHASE`` was 1. + """ + self.assertGreater( + console_authz.get_action(console_recovery.ACTION_CLEAR_STALE_BINDING).phase, + console_authz.ACTIVE_PHASE, + "fixture assumes the recovery actions are ahead of the active phase", + ) + principal = console_authz.Principal( + "dev@example.com", console_authz.OPERATOR, console_authz.IDENTITY_LOCAL_DEV, True + ) + phrase = console_recovery.confirmation_phrase( + console_recovery.PLAYBOOK_CLEAR_STALE_BINDING + ) + result = console_recovery.execute_recovery_playbook( + playbook_id=console_recovery.PLAYBOOK_CLEAR_STALE_BINDING, + confirmation=phrase, + principal=principal, + ) + self.assertFalse(result["success"]) + self.assertFalse(result["allowed"]) + self.assertEqual(result["error"], console_authz.DENY_PHASE_NOT_ACTIVE) + + def test_preview_execution_enabled_matches_the_execution_decision(self) -> None: + """B1: preview must not report a bare False it cannot explain.""" + principal = console_authz.Principal( + "dev@example.com", console_authz.OPERATOR, console_authz.IDENTITY_LOCAL_DEV, True + ) + preview = console_recovery.build_recovery_preview( + playbook_id=console_recovery.PLAYBOOK_REBIND_SESSION, + target="branches/feat-issue-644", + principal=principal, + ) + self.assertFalse(preview["execution_enabled"]) + self.assertEqual( + preview["execution_blocked_reason"], console_authz.DENY_PHASE_NOT_ACTIVE + ) + self.assertFalse(preview["execution_authorization"]["allowed"]) + # The preview (non-execution) decision still allows, by role. + self.assertTrue(preview["authorization"]["allowed"]) + + def _phase_two_enabled(self): + """Raise ACTIVE_PHASE so the execution branches are reachable in tests.""" + return patch.object(console_authz, "ACTIVE_PHASE", 2) + + def _operator(self) -> console_authz.Principal: + return console_authz.Principal( + "dev@example.com", console_authz.OPERATOR, console_authz.IDENTITY_LOCAL_DEV, True + ) + + def test_rebind_mutates_the_live_environment_not_a_copy(self) -> None: + """B2: the playbook must change the mapping it claims to have changed.""" + live_env = {stale_binding_recovery.ACTIVE_WORKTREE_ENV: "branches/stale-old"} + phrase = console_recovery.confirmation_phrase( + console_recovery.PLAYBOOK_REBIND_SESSION, "branches/feat-issue-644" + ) + with self._phase_two_enabled(): + result = console_recovery.execute_recovery_playbook( + playbook_id=console_recovery.PLAYBOOK_REBIND_SESSION, + confirmation=phrase, + target="branches/feat-issue-644", + principal=self._operator(), + env=live_env, + ) + self.assertTrue(result["success"]) + self.assertEqual( + live_env[stale_binding_recovery.ACTIVE_WORKTREE_ENV], + "branches/feat-issue-644", + "rebind reported success without changing the caller's environment", + ) + self.assertTrue(result["applied_result"]["binding_changed"]) + self.assertEqual(result["applied_result"]["binding_before"], "branches/stale-old") + self.assertEqual( + result["applied_result"]["binding_after"], "branches/feat-issue-644" + ) + + def test_clear_stale_binding_reports_failure_when_nothing_changed(self) -> None: + """B2: a no-op recovery must never be reported as success.""" + live_env: dict[str, str] = {} + phrase = console_recovery.confirmation_phrase( + console_recovery.PLAYBOOK_CLEAR_STALE_BINDING + ) + with self._phase_two_enabled(): + result = console_recovery.execute_recovery_playbook( + playbook_id=console_recovery.PLAYBOOK_CLEAR_STALE_BINDING, + confirmation=phrase, + principal=self._operator(), + env=live_env, + ) + self.assertFalse( + result["success"], + "a clear that changed no binding must not report success", + ) + self.assertFalse(result["applied_result"]["binding_changed"]) + + def test_clear_stale_binding_clears_the_live_binding(self) -> None: + """B2: the sanctioned clear must reach the caller's environment.""" + missing = "/nonexistent/branches/deleted-worktree" + live_env = {stale_binding_recovery.ACTIVE_WORKTREE_ENV: missing} + phrase = console_recovery.confirmation_phrase( + console_recovery.PLAYBOOK_CLEAR_STALE_BINDING + ) + with self._phase_two_enabled(): + result = console_recovery.execute_recovery_playbook( + playbook_id=console_recovery.PLAYBOOK_CLEAR_STALE_BINDING, + confirmation=phrase, + principal=self._operator(), + env=live_env, + ) + if result["success"]: + self.assertNotIn(stale_binding_recovery.ACTIVE_WORKTREE_ENV, live_env) + self.assertEqual(result["applied_result"]["binding_before"], missing) + self.assertIsNone(result["applied_result"]["binding_after"]) + else: + # Fail closed is acceptable; reporting a clear that did not happen + # is not. This is the invariant the blocker was about. + self.assertFalse(result["applied_result"]["binding_changed"]) + self.assertEqual( + live_env.get(stale_binding_recovery.ACTIVE_WORKTREE_ENV), missing + ) + + def test_reconcile_playbook_calls_an_entry_point_that_exists(self) -> None: + """B3: the previous call named a function absent from the module.""" + phrase = console_recovery.confirmation_phrase( + console_recovery.PLAYBOOK_RECONCILE_CLEANUPS + ) + fake_server = types.SimpleNamespace( + gitea_reconcile_merged_cleanups=lambda **kwargs: { + "success": True, + "entries": [{"issue_number": 100}], + } + ) + with self._phase_two_enabled(), patch.dict( + sys.modules, {"gitea_mcp_server": fake_server} + ): + result = console_recovery.execute_recovery_playbook( + playbook_id=console_recovery.PLAYBOOK_RECONCILE_CLEANUPS, + confirmation=phrase, + principal=console_authz.Principal( + "dev@example.com", + console_authz.ADMIN, + console_authz.IDENTITY_LOCAL_DEV, + True, + ), + ) + self.assertTrue(result["success"], result.get("applied_result")) + self.assertNotIn("error_type", result["applied_result"]) + self.assertEqual(result["applied_result"]["reconciled_count"], 1) + + def test_reconcile_entry_point_exists_on_the_real_module(self) -> None: + """B3 regression: guard the symbol itself, not just the call shape.""" + import gitea_mcp_server + + self.assertTrue( + hasattr(gitea_mcp_server, "gitea_reconcile_merged_cleanups"), + "console recovery depends on this reconciler entry point", + ) + self.assertFalse( + hasattr(merged_cleanup_reconcile, "reconcile_merged_cleanups"), + "if this module grows the orchestrator, point the playbook back at it", + ) + + def test_contamination_gate_blocks_a_writing_playbook(self) -> None: + """B4: a live marker plus a gated task key must actually block.""" + marker = { + "kind": "manual_daemon_kill", + "reason_class": "manual_daemon_kill", + "command_summary": "pkill -f gitea_mcp_server", + "active": True, + } + phrase = console_recovery.confirmation_phrase( + console_recovery.PLAYBOOK_REBIND_SESSION, "branches/feat-issue-644" + ) + live_env = {stale_binding_recovery.ACTIVE_WORKTREE_ENV: "branches/stale-old"} + with self._phase_two_enabled(), patch.object( + console_recovery, "load_active_contamination_marker", return_value=marker + ): + result = console_recovery.execute_recovery_playbook( + playbook_id=console_recovery.PLAYBOOK_REBIND_SESSION, + confirmation=phrase, + target="branches/feat-issue-644", + principal=self._operator(), + env=live_env, + ) + self.assertFalse(result["success"]) + self.assertEqual(result["error"], "contaminated_runtime") + self.assertEqual( + live_env[stale_binding_recovery.ACTIVE_WORKTREE_ENV], + "branches/stale-old", + "a blocked playbook must not have mutated anything", + ) + + def test_contamination_gate_exempts_the_reconciler_remedy(self) -> None: + """B4: the designated remedy must stay reachable while contaminated.""" + marker = { + "kind": "manual_daemon_kill", + "reason_class": "manual_daemon_kill", + "command_summary": "pkill -f gitea_mcp_server", + "active": True, + } + phrase = console_recovery.confirmation_phrase( + console_recovery.PLAYBOOK_RECONCILE_CLEANUPS + ) + fake_server = types.SimpleNamespace( + gitea_reconcile_merged_cleanups=lambda **kwargs: { + "success": True, + "entries": [], + } + ) + with self._phase_two_enabled(), patch.object( + console_recovery, "load_active_contamination_marker", return_value=marker + ), patch.dict(sys.modules, {"gitea_mcp_server": fake_server}): + result = console_recovery.execute_recovery_playbook( + playbook_id=console_recovery.PLAYBOOK_RECONCILE_CLEANUPS, + confirmation=phrase, + principal=console_authz.Principal( + "dev@example.com", + console_authz.ADMIN, + console_authz.IDENTITY_LOCAL_DEV, + True, + ), + ) + self.assertNotEqual(result.get("error"), "contaminated_runtime") + + def test_gated_task_key_is_actually_gated(self) -> None: + """B4: the console action id was never a member of the gated set.""" + self.assertIn( + console_recovery.CONTAMINATION_GATED_TASK, + stable_branch_push_guard.CONTAMINATION_GATED_TASKS, + ) + self.assertNotIn( + console_recovery.ACTION_CLEAR_STALE_BINDING, + stable_branch_push_guard.CONTAMINATION_GATED_TASKS, + ) + + def test_diagnosis_reads_the_key_the_gate_returns(self) -> None: + """B4: ``contaminated`` is a key assess_contamination_gate never returns.""" + gate = runtime_recovery_guard.assess_contamination_gate( + None, task=console_recovery.CONTAMINATION_GATED_TASK, actual_role="operator" + ) + self.assertNotIn("contaminated", gate) + self.assertIn("block", gate) + + def test_contaminated_runtime_is_reported_unclean(self) -> None: + """B4: verify_post_recovery reported contamination_clean unconditionally.""" + marker = { + "kind": "manual_daemon_kill", + "reason_class": "manual_daemon_kill", + "command_summary": "pkill -f gitea_mcp_server", + "active": True, + } + with patch.object( + console_recovery, "load_active_contamination_marker", return_value=marker + ): + verification = console_recovery.verify_post_recovery() + diag = console_recovery.diagnose_recovery() + self.assertFalse(verification["contamination_clean"]) + self.assertFalse(verification["clean"]) + self.assertEqual(diag.status, console_recovery.STATUS_BLOCKED_CONTAMINATION) + + def test_master_parity_baseline_is_not_the_head_it_is_compared_against(self) -> None: + """B5: capture_startup_parity was fed the head it was then compared to.""" + stale = system_health.StaleRuntime( + daemon_head="a" * 40, + checkout_head="b" * 40, + remote_head="b" * 40, + stale=True, + determinable=True, + mutation_safe=False, + reasons=("daemon is behind the checkout",), + ) + with patch.object(system_health, "assess_stale_runtime", return_value=stale): + diag = console_recovery.diagnose_recovery() + parity = diag.master_parity + self.assertEqual(parity["startup_head"], "a" * 40) + self.assertEqual(parity["current_head"], "b" * 40) + self.assertNotEqual(parity["startup_head"], parity["current_head"]) + self.assertFalse(parity["in_parity"]) + + def test_master_parity_carries_the_live_remote_dimension(self) -> None: + """B5: live_remote_head was never passed, dropping the #610 dimension.""" + stale = system_health.StaleRuntime( + daemon_head="c" * 40, + checkout_head="c" * 40, + remote_head="d" * 40, + stale=False, + determinable=True, + mutation_safe=False, + reasons=(), + ) + with patch.object(system_health, "assess_stale_runtime", return_value=stale): + diag = console_recovery.diagnose_recovery() + self.assertEqual(diag.master_parity.get("live_remote_head"), "d" * 40) + + def test_verify_post_recovery(self) -> None: + verification = console_recovery.verify_post_recovery() + self.assertIn("clean", verification) + self.assertIn("status", verification) + self.assertIn("reasons", verification) + + def test_unverified_inherited_binding_is_not_reported_clean(self) -> None: + """B2: ``not clear_eligible`` also read clean for unproven bindings.""" + binding = { + "classification": stale_binding_recovery.CLASSIFICATION_UNVERIFIED_INHERITED, + "clear_eligible": False, + } + diag = console_recovery.diagnose_recovery() + patched = console_recovery.RecoveryDiagnosis( + status=diag.status, + clean=diag.clean, + stale_runtime=diag.stale_runtime, + master_parity=diag.master_parity, + stale_binding=binding, + contamination=diag.contamination, + worktree_anomalies=diag.worktree_anomalies, + playbooks=diag.playbooks, + reasons=diag.reasons, + ) + with patch.object(console_recovery, "diagnose_recovery", return_value=patched): + verification = console_recovery.verify_post_recovery() + self.assertFalse(verification["binding_clean"]) + self.assertEqual( + verification["binding_classification"], + stale_binding_recovery.CLASSIFICATION_UNVERIFIED_INHERITED, + ) + + +class TestConsoleRecoveryApi(unittest.TestCase): + def setUp(self) -> None: + self.app = create_app() + self.client = TestClient(self.app) + + def test_api_recovery_diagnose(self) -> None: + res = self.client.get("/api/v1/system/recovery/diagnose") + self.assertEqual(res.status_code, 200) + data = res.json() + self.assertIn("status", data) + self.assertIn("clean", data) + self.assertIn("playbooks", data) + self.assertTrue(len(data["playbooks"]) >= 4) + + def test_api_recovery_preview(self) -> None: + res = self.client.post( + "/api/v1/system/recovery/preview", + json={"playbook_id": "clear_stale_binding", "target": "active"}, + ) + self.assertEqual(res.status_code, 200) + data = res.json() + self.assertEqual(data["playbook_id"], "clear_stale_binding") + self.assertEqual(data["confirmation_phrase"], "confirm clear_stale_binding active") + self.assertIn("mutation_ledger", data) + + def test_api_recovery_apply_denied_without_auth(self) -> None: + res = self.client.post( + "/api/v1/system/recovery/apply", + json={"playbook_id": "clear_stale_binding", "confirmation": "confirm clear_stale_binding"}, + ) + self.assertEqual(res.status_code, 400) + data = res.json() + self.assertFalse(data["success"]) + self.assertFalse(data["allowed"]) + + def test_api_recovery_apply_refuses_phase_two_write_with_dev_auth(self) -> None: + """B1: this previously asserted the phase-gate bypass as intended. + + An authenticated operator posting a valid confirmation still must not + execute a phase-2 write while the console is in phase 1. The refusal is + the contract; a 200 here means the gate is not armed. + """ + env = { + "WEBUI_AUTH_MODE": "local_dev", + "WEBUI_DEV_SUBJECT": "dev@example.com", + "WEBUI_DEV_ROLE": "operator", + } + before = os.environ.get("GITEA_ACTIVE_WORKTREE") + with patch.dict(os.environ, env): + res = self.client.post( + "/api/v1/system/recovery/apply", + json={ + "playbook_id": "rebind_session_worktree", + "target": "branches/feat-issue-644", + "confirmation": "confirm rebind_session_worktree branches/feat-issue-644", + }, + ) + self.assertEqual(res.status_code, 400) + data = res.json() + self.assertFalse(data["success"]) + self.assertFalse(data["allowed"]) + self.assertEqual(data["error"], console_authz.DENY_PHASE_NOT_ACTIVE) + self.assertEqual( + os.environ.get("GITEA_ACTIVE_WORKTREE"), + before, + "a refused apply must not have rebound the live process environment", + ) + + def test_api_recovery_preview_reports_why_execution_is_disabled(self) -> None: + res = self.client.post( + "/api/v1/system/recovery/preview", + json={"playbook_id": "rebind_session_worktree", "target": "active"}, + ) + self.assertEqual(res.status_code, 200) + data = res.json() + self.assertFalse(data["execution_enabled"]) + self.assertIn("execution_authorization", data) + + def test_api_recovery_verify(self) -> None: + res = self.client.get("/api/v1/system/recovery/verify") + self.assertEqual(res.status_code, 200) + data = res.json() + self.assertIn("clean", data) + self.assertIn("status", data) + + +if __name__ == "__main__": + unittest.main() diff --git a/tests/test_webui_gitea_linkage.py b/tests/test_webui_gitea_linkage.py new file mode 100644 index 0000000..7997e19 --- /dev/null +++ b/tests/test_webui_gitea_linkage.py @@ -0,0 +1,511 @@ +"""Tests for the Gitea issue↔PR linkage console (#645, Phase 3). + +Covers the acceptance criteria of the issue: + +* AC1 — issue↔PR linkage is visible for the selected project/repo, in both + directions, with the evidence that produced each edge. +* AC2 — the latest canonical handoff (CTH) is summarized for a focused thread. +* AC3 — an external Gitea link appears only under the admin reveal opt-in. +* AC4 — every case is driven by mocked Gitea payloads; no network. + +Plus the invariants this console must not violate: a partial or failed read is +never rendered as "no link exists", an unfetched thread is never rendered as +"no handoff", redaction happens before display, and the surface stays read-only. +""" + +from __future__ import annotations + +import json +import os +import sys +import unittest +from pathlib import Path +from unittest import mock + +sys.path.insert(0, str(Path(__file__).resolve().parent.parent)) + +from tests.webui_testclient import TestClient + +from canonical_thread_handoff import format_cth_body +from webui.app import create_app +from webui.linkage_loader import ( + EVIDENCE_BRANCH, + EVIDENCE_CLOSES, + EVIDENCE_REFERENCE, + HANDOFF_LOADED, + HANDOFF_NOT_LOADED, + HANDOFF_UNAVAILABLE, + LinkageSnapshot, + load_linkage_snapshot, + resolve_linkage, + resolve_pr_links, + snapshot_to_dict, + summarize_handoff, +) +from webui.linkage_views import render_linkage_page +from webui.nav import nav_hrefs +from webui.queue_loader import PaginationMeta + + +def _pagination(*, complete: bool = True, count: int = 0) -> PaginationMeta: + return PaginationMeta( + page=1, + per_page=50, + returned_count=count, + has_more=not complete, + is_final_page=complete, + inventory_complete=complete, + pages_fetched=1, + ) + + +def _pr( + number: int, + *, + title: str = "", + body: str = "", + head: str = "", + state: str = "open", + labels: tuple[str, ...] = (), +) -> dict: + return { + "number": number, + "title": title or f"pr {number}", + "body": body, + "state": state, + "head": {"ref": head}, + "labels": [{"name": name} for name in labels], + } + + +def _issue( + number: int, + *, + title: str = "", + state: str = "open", + labels: tuple[str, ...] = (), +) -> dict: + return { + "number": number, + "title": title or f"issue {number}", + "state": state, + "labels": [{"name": name} for name in labels], + } + + +def _fetcher(items: list[dict], *, complete: bool = True): + def _fetch(*_args, **_kwargs): + return items, _pagination(complete=complete, count=len(items)) + + return _fetch + + +def _load( + issues: list[dict], + prs: list[dict], + *, + complete: bool = True, + **kwargs, +) -> LinkageSnapshot: + return load_linkage_snapshot( + fetch_prs=_fetcher(prs, complete=complete), + fetch_issues=_fetcher(issues, complete=complete), + **kwargs, + ) + + +def _cth(comment_id: int, *, created_at: str, status: str, next_owner: str) -> dict: + return { + "id": comment_id, + "created_at": created_at, + "user": {"login": "jcwalker3"}, + "body": format_cth_body( + cth_type="Author Handoff", + status=status, + next_owner=next_owner, + current_blocker="none", + decision="implemented", + proof="full suite green", + next_action="review PR", + ready_to_paste_prompt="Review PR #902 now.", + ), + } + + +class TestLinkageEvidence(unittest.TestCase): + """AC1 — every edge records how it was found, and keeps all candidates.""" + + def test_closes_keyword_in_body_is_strongest_evidence(self): + links = resolve_pr_links(_pr(902, body="Closes #643")) + self.assertEqual([link.issue_number for link in links], [643]) + self.assertEqual(links[0].evidence, (EVIDENCE_CLOSES,)) + self.assertTrue(links[0].closes) + + def test_closes_keyword_in_title_counts(self): + links = resolve_pr_links(_pr(902, title="feat(webui): preview (Closes #643)")) + self.assertEqual(links[0].evidence, (EVIDENCE_CLOSES,)) + + def test_canonical_branch_marker_links_without_a_keyword(self): + links = resolve_pr_links(_pr(902, head="feat/issue-643-request-preview")) + self.assertEqual([link.issue_number for link in links], [643]) + self.assertEqual(links[0].evidence, (EVIDENCE_BRANCH,)) + self.assertFalse(links[0].closes) + + def test_non_canonical_branch_is_not_treated_as_a_marker(self): + self.assertEqual(resolve_pr_links(_pr(902, head="issue-643-preview")), ()) + + def test_bare_mention_is_recorded_as_the_weakest_evidence(self): + links = resolve_pr_links(_pr(902, body="context in #643")) + self.assertEqual(links[0].evidence, (EVIDENCE_REFERENCE,)) + self.assertFalse(links[0].closes) + + def test_several_evidence_kinds_merge_onto_one_edge(self): + links = resolve_pr_links( + _pr(902, body="Closes #643 — see #643", head="feat/issue-643-preview") + ) + self.assertEqual(len(links), 1) + self.assertEqual( + links[0].evidence, + (EVIDENCE_CLOSES, EVIDENCE_BRANCH, EVIDENCE_REFERENCE), + ) + + def test_stronger_evidence_sorts_first(self): + links = resolve_pr_links(_pr(902, body="Closes #643, related #700")) + self.assertEqual([link.issue_number for link in links], [643, 700]) + + def test_self_reference_is_not_linkage(self): + links = resolve_pr_links(_pr(902, body="supersedes #902")) + self.assertEqual(links, ()) + + def test_every_candidate_is_kept_never_collapsed_to_a_guess(self): + links = resolve_pr_links(_pr(902, body="Closes #643\nCloses #644")) + self.assertEqual([link.issue_number for link in links], [643, 644]) + + +class TestLinkageIndex(unittest.TestCase): + def test_issue_direction_ignores_mention_only_edges(self): + index = resolve_linkage([_pr(902, body="context in #643")]) + self.assertIsNone(index.issue_prs.get(643)) + self.assertEqual(index.pr_links[902][0].evidence, (EVIDENCE_REFERENCE,)) + + def test_contested_issue_is_reported_when_two_prs_claim_it(self): + index = resolve_linkage( + [_pr(902, body="Closes #643"), _pr(903, head="feat/issue-643-again")] + ) + self.assertEqual(index.contested_issues(), (643,)) + self.assertEqual(index.issue_prs[643], (902, 903)) + + def test_single_claim_is_not_contested(self): + index = resolve_linkage([_pr(902, body="Closes #643")]) + self.assertEqual(index.contested_issues(), ()) + + def test_ambiguous_when_two_issues_tie_at_the_strongest_evidence(self): + index = resolve_linkage([_pr(902, body="Closes #643\nCloses #644")]) + self.assertTrue(index.ambiguous(902)) + + def test_weaker_candidate_alongside_a_stronger_one_is_not_ambiguous(self): + index = resolve_linkage([_pr(902, body="Closes #643, see #700")]) + self.assertFalse(index.ambiguous(902)) + self.assertEqual(index.primary_issue(902).issue_number, 643) + + def test_malformed_pr_row_is_skipped_not_raised_on(self): + index = resolve_linkage([{"title": "no number"}, _pr(902, body="Closes #643")]) + self.assertEqual(sorted(index.pr_links), [902]) + + +class TestLinkageSnapshot(unittest.TestCase): + """AC1 — linkage is visible per project/repo, in both directions.""" + + def test_both_directions_are_populated(self): + snapshot = _load([_issue(643)], [_pr(902, body="Closes #643")]) + self.assertTrue(snapshot.ok) + self.assertEqual([node.number for node in snapshot.issues], [643]) + self.assertEqual(snapshot.issues[0].linked_prs, (902,)) + self.assertEqual(snapshot.prs[0].links[0].issue_number, 643) + + def test_repo_scope_comes_from_the_registry_project(self): + snapshot = _load([], []) + self.assertIn("/", snapshot.repo_label) + self.assertTrue(snapshot.project_id) + + def test_unknown_project_fails_closed_with_a_reason(self): + snapshot = _load([_issue(643)], [], project_id="no-such-project") + self.assertFalse(snapshot.ok) + self.assertIn("not found in registry", snapshot.fetch_error) + self.assertEqual(snapshot.issues, ()) + + def test_orphan_pr_is_identifiable(self): + snapshot = _load([], [_pr(902), _pr(903, body="Closes #643")]) + self.assertEqual([node.number for node in snapshot.orphan_prs], [902]) + + def test_state_scope_defaults_to_open_and_is_reported(self): + self.assertEqual(_load([], []).state_scope, "open") + self.assertEqual(_load([], [], state="all").state_scope, "all") + + def test_unsupported_state_falls_back_to_open(self): + self.assertEqual(_load([], [], state="../etc").state_scope, "open") + + def test_state_is_passed_through_to_the_fetchers(self): + seen: list[str] = [] + + def _fetch(*_args, **kwargs): + seen.append(kwargs.get("state", "")) + return [], _pagination() + + load_linkage_snapshot(state="all", fetch_prs=_fetch, fetch_issues=_fetch) + self.assertEqual(seen, ["all", "all"]) + + +class TestPartialInventoryIsNotAnAbsenceClaim(unittest.TestCase): + """An empty edge list from a partial read must never read as 'no link'.""" + + def test_incomplete_pagination_marks_links_non_authoritative(self): + snapshot = _load([_issue(643)], [], complete=False) + self.assertFalse(snapshot.inventory_complete) + self.assertFalse(snapshot.issues[0].links_authoritative) + + def test_complete_pagination_marks_links_authoritative(self): + snapshot = _load([_issue(643)], [], complete=True) + self.assertTrue(snapshot.inventory_complete) + self.assertTrue(snapshot.issues[0].links_authoritative) + + def test_partial_window_renders_a_qualified_empty_cell(self): + html = render_linkage_page(_load([_issue(643)], [], complete=False)) + self.assertIn("none found (partial inventory)", html) + + def test_complete_window_renders_a_plain_none(self): + html = render_linkage_page(_load([_issue(643)], [], complete=True)) + self.assertNotIn("partial inventory", html) + self.assertIn(">none<", html) + + def test_missing_credentials_fail_closed_without_a_table(self): + with mock.patch( + "webui.linkage_loader._offline_test_mode", return_value=False + ), mock.patch("webui.linkage_loader.get_auth_header", return_value=""): + snapshot = load_linkage_snapshot() + self.assertFalse(snapshot.ok) + self.assertIn("credentials unavailable", snapshot.fetch_error) + html = render_linkage_page(snapshot) + self.assertIn("Linkage unavailable", html) + self.assertNotIn("Issues → pull requests", html) + + def test_fetch_failure_is_reported_not_raised(self): + def _boom(*_args, **_kwargs): + raise RuntimeError("gitea 502") + + snapshot = load_linkage_snapshot(fetch_prs=_boom, fetch_issues=_boom) + self.assertFalse(snapshot.ok) + self.assertIn("Gitea fetch failed", snapshot.fetch_error) + + +class TestHandoffSummary(unittest.TestCase): + """AC2 — the latest canonical handoff is summarized for a focused thread.""" + + def test_latest_cth_wins(self): + summary = summarize_handoff([ + _cth(1, created_at="2026-07-24T10:00:00Z", status="in progress", + next_owner="author"), + _cth(2, created_at="2026-07-25T10:00:00Z", status="PR-open", + next_owner="reviewer"), + ]) + self.assertEqual(summary.comment_id, 2) + self.assertEqual(summary.status, "PR-open") + self.assertEqual(summary.next_owner, "reviewer") + self.assertTrue(summary.cth_type_known) + + def test_thread_without_a_cth_summarizes_to_none(self): + self.assertIsNone(summarize_handoff([{"id": 1, "body": "ordinary comment"}])) + + def test_unknown_heading_is_reported_not_republished(self): + summary = summarize_handoff([ + { + "id": 5, + "created_at": "2026-07-25T10:00:00Z", + "user": {"login": "someone"}, + "body": "\n## CTH: Totally Made Up\n\nStatus: odd\n", + } + ]) + self.assertFalse(summary.cth_type_known) + self.assertEqual(summary.cth_type, "unrecognized") + self.assertNotIn("Totally Made Up", json.dumps(summary.to_dict())) + + def test_focused_pr_loads_its_handoff(self): + snapshot = _load( + [_issue(643)], + [_pr(902, body="Closes #643")], + pr=902, + comment_source=lambda kind, number: [ + _cth(2, created_at="2026-07-25T10:00:00Z", status="PR-open", + next_owner="reviewer") + ], + ) + self.assertEqual(snapshot.handoff_status.state, HANDOFF_LOADED) + self.assertEqual(snapshot.focus, ("pr", 902)) + self.assertEqual(snapshot.prs[0].handoff.status, "PR-open") + + def test_unfocused_rows_report_not_loaded_never_none(self): + snapshot = _load( + [_issue(643)], + [_pr(902, body="Closes #643"), _pr(903)], + pr=902, + comment_source=lambda kind, number: [], + ) + other = next(node for node in snapshot.prs if node.number == 903) + self.assertIsNone(other.handoff) + self.assertEqual(other.handoff_status.state, HANDOFF_NOT_LOADED) + self.assertIn("not loaded", render_linkage_page(snapshot)) + + def test_no_focus_means_no_thread_is_claimed_handoff_free(self): + snapshot = _load([_issue(643)], []) + self.assertEqual(snapshot.handoff_status.state, HANDOFF_NOT_LOADED) + self.assertIn("thread-scoped", snapshot.handoff_status.reason) + + def test_comment_source_failure_degrades_only_the_handoff(self): + def _boom(_kind, _number): + raise RuntimeError("comments 500") + + snapshot = _load( + [_issue(643)], [_pr(902, body="Closes #643")], pr=902, comment_source=_boom + ) + self.assertTrue(snapshot.ok) + self.assertEqual(snapshot.handoff_status.state, HANDOFF_UNAVAILABLE) + self.assertEqual(snapshot.issues[0].linked_prs, (902,)) + self.assertIn("unavailable", render_linkage_page(snapshot)) + + def test_loaded_thread_with_no_cth_says_so_explicitly(self): + snapshot = _load( + [_issue(643)], + [_pr(902, body="Closes #643")], + pr=902, + comment_source=lambda kind, number: [{"id": 1, "body": "hi"}], + ) + self.assertIn( + "no Canonical Thread Handoff comment found", render_linkage_page(snapshot) + ) + + +class TestDeepLinks(unittest.TestCase): + """AC3 — an external Gitea link is emitted only when permitted.""" + + def test_deep_links_are_withheld_by_default(self): + with mock.patch.dict(os.environ, {"GITEA_MCP_REVEAL_ENDPOINTS": ""}): + snapshot = _load([_issue(643)], []) + html = render_linkage_page(snapshot) + self.assertFalse(snapshot.deep_links_enabled) + self.assertIsNone(snapshot.issues[0].deep_link) + self.assertIn("Gitea deep links are withheld", html) + + def test_reveal_opt_in_emits_the_link(self): + with mock.patch.dict(os.environ, {"GITEA_MCP_REVEAL_ENDPOINTS": "1"}): + snapshot = _load([_issue(643)], [_pr(902, body="Closes #643")]) + html = render_linkage_page(snapshot) + self.assertTrue(snapshot.deep_links_enabled) + self.assertIn("/issues/643", snapshot.issues[0].deep_link) + self.assertIn("/pulls/902", snapshot.prs[0].deep_link) + self.assertIn(f'href="{snapshot.issues[0].deep_link}"', html) + + +class TestRedactionBoundary(unittest.TestCase): + def test_secret_shaped_title_is_redacted_before_display(self): + snapshot = _load( + [_issue(643, title="token=ghp_thisisnotarealsecretvalue0001")], [] + ) + payload = json.dumps(snapshot_to_dict(snapshot)) + self.assertNotIn("ghp_thisisnotarealsecretvalue0001", payload) + self.assertNotIn( + "ghp_thisisnotarealsecretvalue0001", render_linkage_page(snapshot) + ) + + def test_handoff_fields_are_redacted(self): + comment = _cth( + 2, created_at="2026-07-25T10:00:00Z", status="ok", next_owner="reviewer" + ) + comment["body"] += "\nDecision: password=hunter2hunter2\n" + snapshot = _load( + [_issue(643)], + [_pr(902, body="Closes #643")], + pr=902, + comment_source=lambda kind, number: [comment], + ) + self.assertNotIn("hunter2hunter2", json.dumps(snapshot_to_dict(snapshot))) + self.assertNotIn("hunter2hunter2", render_linkage_page(snapshot)) + + def test_html_escapes_markup_in_a_title(self): + snapshot = _load([_issue(643, title="")], []) + html = render_linkage_page(snapshot) + self.assertNotIn("", html) + self.assertIn("<script>", html) + + +class TestLinkageRoutes(unittest.TestCase): + def setUp(self): + self.snapshot = _load( + [_issue(643, labels=("status:ready",))], + [_pr(902, body="Closes #643", labels=("status:pr-open",))], + ) + self.client = TestClient(create_app()) + + def test_page_renders_both_tables(self): + with mock.patch("webui.app.load_linkage_snapshot", return_value=self.snapshot): + response = self.client.get("/gitea") + self.assertEqual(response.status_code, 200) + self.assertIn("Issues → pull requests", response.text) + self.assertIn("Pull requests → issues", response.text) + self.assertIn("#643", response.text) + + def test_api_exports_the_same_model(self): + with mock.patch("webui.app.load_linkage_snapshot", return_value=self.snapshot): + response = self.client.get("/api/v1/gitea/linkage") + self.assertEqual(response.status_code, 200) + payload = response.json() + self.assertTrue(payload["ok"]) + self.assertEqual(payload["issues"][0]["linked_prs"], [902]) + self.assertEqual(payload["prs"][0]["links"][0]["issue_number"], 643) + self.assertEqual(payload["schema_version"], 1) + + def test_api_declares_the_evidence_vocabulary(self): + with mock.patch("webui.app.load_linkage_snapshot", return_value=self.snapshot): + payload = self.client.get("/api/v1/gitea/linkage").json() + names = {entry["name"] for entry in payload["evidence_kinds"]} + self.assertEqual(names, {EVIDENCE_CLOSES, EVIDENCE_BRANCH, EVIDENCE_REFERENCE}) + + def test_api_fails_closed_with_a_non_200_when_the_read_failed(self): + failed = _load([], [], project_id="no-such-project") + with mock.patch("webui.app.load_linkage_snapshot", return_value=failed): + response = self.client.get("/api/v1/gitea/linkage") + self.assertEqual(response.status_code, 502) + self.assertFalse(response.json()["ok"]) + + def test_page_still_renders_when_the_read_failed(self): + failed = _load([], [], project_id="no-such-project") + with mock.patch("webui.app.load_linkage_snapshot", return_value=failed): + response = self.client.get("/gitea") + self.assertEqual(response.status_code, 200) + self.assertIn("Linkage unavailable", response.text) + + def test_query_parameters_reach_the_loader(self): + with mock.patch( + "webui.app.load_linkage_snapshot", return_value=self.snapshot + ) as loader: + self.client.get("/gitea?project=gitea-tools&state=all&pr=902") + loader.assert_called_once() + args, kwargs = loader.call_args + self.assertEqual(args[0], "gitea-tools") + self.assertEqual(kwargs["state"], "all") + self.assertEqual(kwargs["pr"], 902) + self.assertIsNone(kwargs["issue"]) + + def test_surface_stays_read_only(self): + for path in ("/gitea", "/api/v1/gitea/linkage"): + with self.subTest(path=path): + self.assertEqual(self.client.post(path).status_code, 405) + + def test_nav_exposes_the_linkage_page_as_live(self): + self.assertIn("/gitea", nav_hrefs()) + home = self.client.get("/").text + self.assertIn('href="/gitea"', home) + self.assertIn(">Gitea<", home) + + +if __name__ == "__main__": + unittest.main() diff --git a/tests/test_webui_notifications.py b/tests/test_webui_notifications.py new file mode 100644 index 0000000..c928f7a --- /dev/null +++ b/tests/test_webui_notifications.py @@ -0,0 +1,465 @@ +"""Unit tests for Phase 3 Notifications and Human-Attention Console (#648).""" + +from __future__ import annotations + +import pytest +from starlette.testclient import TestClient + +from webui.app import create_app +from webui.notifications import ( + ATTENTION_HUMAN_REQUIRED, + ATTENTION_OPERATOR, + ATTENTION_ROUTINE, + CATEGORY_AUTH, + CATEGORY_BLOCKER, + CATEGORY_LEASE, + CATEGORY_SYSTEM, + CATEGORY_VALIDATION, + CATEGORY_WORKFLOW, + NotificationItem, + NotificationSnapshot, + classify_attention_event, + load_notifications_snapshot, + snapshot_to_dict, +) +from webui.notification_views import render_notifications_page +from webui.project_registry import load_registry +from webui.queue_loader import QueueItem, QueueSnapshot +from webui.lease_loader import CollisionWarning, LeaseSnapshot +from webui.system_health import DependencyProbe, SystemHealthSnapshot, VersionInfo, StaleRuntime + + +def test_classify_attention_event_rules(): + # 1. Critical escalation boundaries -> human-required + att_cls, req_human = classify_attention_event( + CATEGORY_AUTH, "Auth error", "Unauthorized access attempt", is_auth_failure=True + ) + assert att_cls == ATTENTION_HUMAN_REQUIRED + assert req_human is True + + att_cls, req_human = classify_attention_event( + CATEGORY_SYSTEM, "Hard stop", "Hard stop triggered", is_hard_stop=True + ) + assert att_cls == ATTENTION_HUMAN_REQUIRED + assert req_human is True + + att_cls, req_human = classify_attention_event( + CATEGORY_VALIDATION, "Validation Error", "Report validation failed", is_validation_failure=True + ) + assert att_cls == ATTENTION_HUMAN_REQUIRED + assert req_human is True + + # 2. Operational issues -> operator + att_cls, req_human = classify_attention_event( + CATEGORY_BLOCKER, "PR Blocked", "Merge conflict detected", is_blocker=True + ) + assert att_cls == ATTENTION_OPERATOR + assert req_human is False + + att_cls, req_human = classify_attention_event( + CATEGORY_LEASE, "Lease Expired", "Session lease expired", is_stale=True + ) + assert att_cls == ATTENTION_OPERATOR + assert req_human is False + + # 3. Routine workflow transitions -> routine + att_cls, req_human = classify_attention_event( + CATEGORY_WORKFLOW, "PR Active", "PR in review" + ) + assert att_cls == ATTENTION_ROUTINE + assert req_human is False + + +def test_notification_snapshot_aggregation(): + reg = load_registry() + proj_id = reg.projects[0].id if reg.projects else "gitea-tools" + + mock_queue = QueueSnapshot( + project_id=proj_id, + repo_label="org/repo", + prs=( + QueueItem( + number=101, + title="Blocked PR", + badges=("blocked",), + extra={}, + ), + QueueItem( + number=102, + title="Normal PR", + badges=("in-review",), + extra={}, + ), + ), + issues=(), + pr_pagination=None, + issue_pagination=None, + ) + + mock_leases = LeaseSnapshot( + project_id=proj_id, + repo_label="org/repo", + issue_lock=None, + claim_inventory={}, + reviewer_leases=( + { + "pr_number": 101, + "status": "expired", + "is_expired": True, + }, + ), + duplicate_prs=( + CollisionWarning( + kind="duplicate_pr", + message="Multiple open PRs for issue #101", + issue_number=101, + pr_numbers=(101, 103), + ), + ), + duplicate_branches=(), + collision_history=(), + fetch_error=None, + ) + + mock_version = VersionInfo( + git_sha="abc1234", + git_describe="v1.0.0", + control_plane_schema_version=1, + python_version="3.11", + known=True, + ) + + mock_stale = StaleRuntime( + daemon_head="abc1234", + checkout_head="abc1234", + remote_head="abc1234", + stale=False, + determinable=True, + mutation_safe=True, + reasons=(), + ) + + mock_health = SystemHealthSnapshot( + status="degraded", + ready=False, + readiness_complete=True, + readiness_reasons=("Auth failure",), + service="webui", + mode="test", + version=mock_version, + started_at="2026-07-25T00:00:00Z", + uptime_seconds=100.0, + timestamp="2026-07-25T00:00:00Z", + deep_probes_requested=True, + dependencies=( + DependencyProbe( + name="auth_service", + kind="auth", + status="unauthorized", + detail="Token expired", + required=True, + ), + ), + mcp_namespaces=(), + stale_runtime=mock_stale, + probe_errors=(), + ) + + snapshot = load_notifications_snapshot( + proj_id, + load_queue=lambda _id: mock_queue, + load_leases=lambda **_kwargs: mock_leases, + load_health=lambda **_kwargs: mock_health, + ) + + assert snapshot.project_id == proj_id + assert snapshot.total_count == 5 + assert snapshot.human_required_count >= 1 # auth probe failure + assert snapshot.operator_count >= 3 # blocked PR + expired lease + duplicate PR collision + assert snapshot.routine_count >= 1 # normal PR + + # Inbox items should include operator and human-required items only + inbox_classes = {item.attention_class for item in snapshot.inbox_items} + assert ATTENTION_ROUTINE not in inbox_classes + assert ATTENTION_OPERATOR in inbox_classes + assert ATTENTION_HUMAN_REQUIRED in inbox_classes + + +def test_snapshot_to_dict_and_redaction(): + item = NotificationItem( + id="notif-1", + attention_class=ATTENTION_HUMAN_REQUIRED, + category=CATEGORY_AUTH, + title="Auth Error", + summary="Failed auth header: Bearer secret_token_12345", + work_kind="system", + work_number=None, + project_id="test-proj", + repo_label="org/repo", + created_at="2026-07-25T16:00:00Z", + requires_human=True, + ) + snap = NotificationSnapshot( + project_id="test-proj", + repo_label="org/repo", + items=(item,), + human_required_count=1, + operator_count=0, + routine_count=0, + total_count=1, + ) + + data = snapshot_to_dict(snap) + assert data["project_id"] == "test-proj" + assert data["human_required_count"] == 1 + assert len(data["inbox_items"]) == 1 + + # Redaction test + summary = data["inbox_items"][0]["summary"] + assert "secret_token_12345" not in summary + assert "" in summary or "Bearer" in summary + + +def test_notifications_html_views(): + item = NotificationItem( + id="notif-1", + attention_class=ATTENTION_HUMAN_REQUIRED, + category=CATEGORY_AUTH, + title="Critical Auth Failure", + summary="Auth failure details", + work_kind="issue", + work_number=42, + project_id="test-proj", + repo_label="org/repo", + created_at="2026-07-25T16:00:00Z", + requires_human=True, + ) + snap = NotificationSnapshot( + project_id="test-proj", + repo_label="org/repo", + items=(item,), + human_required_count=1, + operator_count=0, + routine_count=0, + total_count=1, + ) + + html = render_notifications_page(snap, filter_class="inbox") + assert "Notifications & Attention Inbox" in html or "Notifications & Attention Inbox" in html + assert "Critical Auth Failure" in html + assert "HUMAN REQUIRED" in html + assert "Human Required" in html + + +def test_notifications_app_routes(): + app = create_app() + client = TestClient(app) + + # 1. HTML Route + res = client.get("/notifications") + assert res.status_code == 200 + assert "Notifications" in res.text + assert "Attention Inbox" in res.text + + # 2. API Route /api/v1/notifications + res_api = client.get("/api/v1/notifications") + assert res_api.status_code == 200 + json_data = res_api.json() + assert "human_required_count" in json_data + assert "operator_count" in json_data + assert "routine_count" in json_data + assert "inbox_items" in json_data + + # 3. Compatibility Alias /api/notifications + res_alias = client.get("/api/notifications") + assert res_alias.status_code == 200 + assert res_alias.json()["project_id"] == json_data["project_id"] + + +def test_classify_ignores_human_authored_title_and_summary_keywords(): + """B1: keywords in human-authored titles must not escalate routine work (#905).""" + # Routine transition whose title/summary mention critical-boundary words + att_cls, req_human = classify_attention_event( + CATEGORY_WORKFLOW, + "record irrecoverable decision lock provenance", + "PR #999 'record irrecoverable decision lock provenance' is in routine state in-review.", + ) + assert att_cls == ATTENTION_ROUTINE + assert req_human is False + + att_cls, req_human = classify_attention_event( + CATEGORY_WORKFLOW, + "fix unauthorized token path", + "Issue #1 'fix unauthorized token path' state: claimed. hard stop docs only.", + ) + assert att_cls == ATTENTION_ROUTINE + assert req_human is False + + # Structured flags still escalate (machine-driven) + att_cls, req_human = classify_attention_event( + CATEGORY_SYSTEM, + "anything", + "anything with hard stop in text", + is_hard_stop=True, + ) + assert att_cls == ATTENTION_HUMAN_REQUIRED + assert req_human is True + + +def test_notification_ids_are_unique_across_probe_errors_and_collisions(): + """B2: published notification ids must be unique within a snapshot (#905).""" + reg = load_registry() + proj_id = reg.projects[0].id if reg.projects else "gitea-tools" + + mock_queue = QueueSnapshot( + project_id=proj_id, + repo_label="org/repo", + prs=(), + issues=(), + pr_pagination=None, + issue_pagination=None, + ) + mock_leases = LeaseSnapshot( + project_id=proj_id, + repo_label="org/repo", + issue_lock=None, + claim_inventory={}, + reviewer_leases=(), + duplicate_prs=( + CollisionWarning( + kind="duplicate_pr", + message="Multiple open PRs for issue #10", + issue_number=10, + pr_numbers=(10, 11), + ), + CollisionWarning( + kind="duplicate_branch", + message="Another collision without issue", + issue_number=None, + pr_numbers=(12, 13), + ), + CollisionWarning( + kind="duplicate_pr", + message="Second issue collision", + issue_number=10, + pr_numbers=(14, 15), + ), + ), + duplicate_branches=(), + collision_history=(), + fetch_error=None, + ) + mock_version = VersionInfo( + git_sha="abc1234", + git_describe="v1.0.0", + control_plane_schema_version=1, + python_version="3.11", + known=True, + ) + mock_stale = StaleRuntime( + daemon_head="abc1234", + checkout_head="abc1234", + remote_head="abc1234", + stale=False, + determinable=True, + mutation_safe=True, + reasons=(), + ) + mock_health = SystemHealthSnapshot( + status="degraded", + ready=False, + readiness_complete=True, + readiness_reasons=(), + service="webui", + mode="test", + version=mock_version, + started_at="2026-07-25T00:00:00Z", + uptime_seconds=100.0, + timestamp="2026-07-25T00:00:00Z", + deep_probes_requested=True, + dependencies=(), + mcp_namespaces=(), + stale_runtime=mock_stale, + probe_errors=("error alpha", "error beta"), + ) + + snapshot = load_notifications_snapshot( + proj_id, + load_queue=lambda _id: mock_queue, + load_leases=lambda **_kwargs: mock_leases, + load_health=lambda **_kwargs: mock_health, + ) + ids = [item.id for item in snapshot.items] + assert len(ids) == len(set(ids)), f"duplicate notification ids: {ids}" + assert any(i.startswith(f"notif-sys-err-{proj_id}-") for i in ids) + assert any(i.startswith("notif-collision-") for i in ids) + + +def test_probe_errors_do_not_set_fetch_error(): + """B3: probe_errors must not be reported as fetch_error (#905).""" + reg = load_registry() + proj_id = reg.projects[0].id if reg.projects else "gitea-tools" + + mock_queue = QueueSnapshot( + project_id=proj_id, + repo_label="org/repo", + prs=(), + issues=(), + pr_pagination=None, + issue_pagination=None, + fetch_error=None, + ) + mock_leases = LeaseSnapshot( + project_id=proj_id, + repo_label="org/repo", + issue_lock=None, + claim_inventory={}, + reviewer_leases=(), + duplicate_prs=(), + duplicate_branches=(), + collision_history=(), + fetch_error=None, + ) + mock_version = VersionInfo( + git_sha="abc1234", + git_describe="v1.0.0", + control_plane_schema_version=1, + python_version="3.11", + known=True, + ) + mock_stale = StaleRuntime( + daemon_head="abc1234", + checkout_head="abc1234", + remote_head="abc1234", + stale=False, + determinable=True, + mutation_safe=True, + reasons=(), + ) + mock_health = SystemHealthSnapshot( + status="degraded", + ready=False, + readiness_complete=True, + readiness_reasons=(), + service="webui", + mode="test", + version=mock_version, + started_at="2026-07-25T00:00:00Z", + uptime_seconds=100.0, + timestamp="2026-07-25T00:00:00Z", + deep_probes_requested=True, + dependencies=(), + mcp_namespaces=(), + stale_runtime=mock_stale, + probe_errors=("probe blew up",), + ) + + snapshot = load_notifications_snapshot( + proj_id, + load_queue=lambda _id: mock_queue, + load_leases=lambda **_kwargs: mock_leases, + load_health=lambda **_kwargs: mock_health, + ) + assert snapshot.fetch_error is None + # probe errors still appear as items + assert any("probe blew up" in item.summary for item in snapshot.items) diff --git a/tests/test_webui_observability.py b/tests/test_webui_observability.py new file mode 100644 index 0000000..a30a9e8 --- /dev/null +++ b/tests/test_webui_observability.py @@ -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 & 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 diff --git a/tests/test_webui_request_initiation.py b/tests/test_webui_request_initiation.py new file mode 100644 index 0000000..e05978c --- /dev/null +++ b/tests/test_webui_request_initiation.py @@ -0,0 +1,1228 @@ +"""Request preview, authorization, and workflow initiation tests (#643). + +Covers each acceptance criterion: + +* AC1 — preview shows authorize/deny with reasons. +* AC2 — apply creates an exclusive assignment or returns wait/blocked. +* AC3 — duplicate assign rejected. +* AC4 — preview / apply / deny / collision are all exercised. +* AC5 — the UI never renders a secret, and messaging stays brief. + +Required tests named in the issue: allocator integration with fakes, and +gated-action tests. The allocator is injected as a fake throughout so no test +touches Gitea or reserves real work; one class asserts the *real* default +allocator refuses an incomplete inventory rather than ranking a partial set. +""" + +from __future__ import annotations + +import contextlib +import json +import os +import pathlib +import sys +import tempfile +import unittest +from typing import Any +from unittest import mock + +from tests.webui_testclient import TestClient + +sys.path.insert(0, str(pathlib.Path(__file__).resolve().parents[1])) + +import allocator_service # noqa: E402 +from webui import console_audit, console_authz, request_service # noqa: E402 +from webui.app import create_app # noqa: E402 +from webui.console_redaction import scan_for_secrets # noqa: E402 +from webui.request_views import render_requests_page # noqa: E402 + +EXEC_FLAG = "WEBUI_REQUESTS_EXECUTION" + +SCOPE = { + "remote": "prgs", + "org": "Scaled-Tech-Consulting", + "repo": "Gitea-Tools", +} + + +def _principal(role: str) -> console_authz.Principal: + return console_authz.Principal( + subject=f"{role}@example.com", + role=role, + identity_source=console_authz.IDENTITY_ACCESS_PROXY, + authenticated=True, + ) + + +def _request( + *, + role: str = "author", + kind: str = "issue", + number: int = 643, + intent: str = "implement request preview and initiation", + head: str | None = None, +) -> request_service.WorkRequest: + parsed, error = request_service.parse_request( + { + "desired_role": role, + "work_kind": kind, + "work_number": number, + "intent_summary": intent, + "expected_head_sha": head, + **SCOPE, + } + ) + assert error is None, error + assert parsed is not None + return parsed + + +def _selection( + *, kind: str = "issue", number: int = 643, head_sha: str | None = None +) -> dict[str, Any]: + return { + "kind": kind, + "number": number, + "title": "Web Console: Requests, intent preview, authorization", + "head_sha": head_sha, + "selected_action": "implement", + "expected_role_next": "author", + } + + +@contextlib.contextmanager +def _patched_control_plane( + *, + release_sink: list[tuple[str, str]] | None = None, + release_error: Exception | None = None, + leases=None, +): + """Stand in for ``control_plane_db`` so compensation paths are observable. + + The service imports the module inside the function, so patching + ``sys.modules`` is what intercepts it. No real DB is opened. + """ + module = mock.MagicMock() + db = mock.MagicMock() + + def _release(lease_id, *, session_id): + if release_error is not None: + raise release_error + if release_sink is not None: + release_sink.append((lease_id, session_id)) + + db.release_lease.side_effect = _release + db.list_leases.side_effect = leases or (lambda **_kwargs: []) + module.ControlPlaneDB.return_value = db + with mock.patch.dict(sys.modules, {"control_plane_db": module}): + yield db + + +def _drifting_allocator(*, lease_id: str | None = "lease-wrong"): + """An allocator that previews the requested unit but assigns another. + + This is the #643 B1 race in miniature: the CAS fingerprint hashes only + ``{kind, number}``, so a lease taken on the requested unit inside the window + leaves the fingerprint identical while the selection moves on. + """ + assignment: dict[str, Any] = {"assignment_id": "asn-wrong"} + if lease_id: + assignment["lease_id"] = lease_id + + def _drifting( + *, request, apply, expected_candidate_set_fingerprint=None, session_id=None + ): + return { + "outcome": ( + allocator_service.OUTCOME_ASSIGNED + if apply + else allocator_service.OUTCOME_PREVIEW + ), + "selected": _selection(number=999 if apply else 643), + "assignment": dict(assignment) if apply else None, + "candidate_set_fingerprint": "fp-test", + "session_id": session_id, + } + + return _drifting + + +def _fake_allocator( + *, + selection: dict[str, Any] | None = None, + preview_outcome: str = allocator_service.OUTCOME_PREVIEW, + apply_outcome: str = allocator_service.OUTCOME_ASSIGNED, + assignment: dict[str, Any] | None = None, + calls: list[dict[str, Any]] | None = None, +): + """Build an allocator double that records how it was called.""" + chosen = selection if selection is not None else _selection() + made = ( + assignment + if assignment is not None + else { + "assignment_id": "asn-test-0001", + "lease_id": "lease-test-0001", + "session_id": "webui-request-test", + "expected_head_sha": chosen.get("head_sha"), + } + ) + + def _allocator(*, request, apply, expected_candidate_set_fingerprint=None): + if calls is not None: + calls.append( + { + "apply": apply, + "role": request.desired_role, + "fingerprint": expected_candidate_set_fingerprint, + } + ) + return { + "outcome": apply_outcome if apply else preview_outcome, + "selected": dict(chosen), + "reasons": ["fake allocator"], + "candidate_set_fingerprint": "fp-test", + "candidate_count": 3, + "inventory_complete": True, + "selection_policy": allocator_service.SELECTION_POLICY, + "substrate": "control_plane_db", + "assignment": dict(made) if apply else None, + } + + return _allocator + + +def _no_claims(_request): + return {} + + +def _claimed(role: str = "author"): + def _source(request): + return { + request.work_key: { + "lease_id": "lease-foreign-9999", + "session_id": "prgs-author-999-foreign", + "role": role, + "expires_at": "2026-07-25T09:10:37Z", + } + } + + return _source + + +class TestRequestParsing(unittest.TestCase): + """The request model rejects rather than guesses.""" + + def test_valid_request_round_trips(self): + req = _request() + self.assertEqual(req.work_key, ("issue", 643)) + self.assertEqual(req.display_ref, "#643") + self.assertEqual(req.to_dict()["desired_role"], "author") + + def test_unknown_role_rejected(self): + parsed, error = request_service.parse_request( + { + "desired_role": "admin", + "work_kind": "issue", + "work_number": 1, + "intent_summary": "x", + **SCOPE, + } + ) + self.assertIsNone(parsed) + self.assertEqual(error.reason_code, "unknown_role") + self.assertEqual(error.field_name, "desired_role") + + def test_unknown_work_kind_rejected(self): + parsed, error = request_service.parse_request( + { + "desired_role": "author", + "work_kind": "branch", + "work_number": 1, + "intent_summary": "x", + **SCOPE, + } + ) + self.assertIsNone(parsed) + self.assertEqual(error.reason_code, "unknown_work_kind") + + def test_non_positive_number_rejected(self): + for value in (0, -3): + with self.subTest(value=value): + parsed, error = request_service.parse_request( + { + "desired_role": "author", + "work_kind": "issue", + "work_number": value, + "intent_summary": "x", + **SCOPE, + } + ) + self.assertIsNone(parsed) + self.assertEqual(error.reason_code, "invalid_work_number") + + def test_missing_intent_rejected(self): + parsed, error = request_service.parse_request( + { + "desired_role": "author", + "work_kind": "issue", + "work_number": 1, + **SCOPE, + } + ) + self.assertIsNone(parsed) + self.assertEqual(error.reason_code, "missing_intent") + + def test_intent_is_bounded(self): + req = _request(intent="x" * 5000) + self.assertEqual(len(req.intent_summary), request_service.MAX_INTENT_CHARS) + + def test_unresolved_scope_rejected(self): + parsed, error = request_service.parse_request( + { + "desired_role": "author", + "work_kind": "issue", + "work_number": 1, + "intent_summary": "x", + } + ) + self.assertIsNone(parsed) + self.assertEqual(error.reason_code, "scope_unresolved") + + def test_default_scope_fills_missing_fields(self): + parsed, error = request_service.parse_request( + { + "desired_role": "author", + "work_kind": "issue", + "work_number": 7, + "intent_summary": "x", + }, + default_scope=SCOPE, + ) + self.assertIsNone(error) + self.assertEqual(parsed.repo, "Gitea-Tools") + + +class TestPreviewAuthorizeDeny(unittest.TestCase): + """AC1 — preview shows authorize/deny with reasons.""" + + def test_authorized_preview_names_every_check(self): + preview = request_service.preview_request( + _request(), + principal=_principal(console_authz.OPERATOR), + allocator=_fake_allocator(), + claims_source=_no_claims, + audit=False, + ) + self.assertTrue(preview.authorized) + self.assertEqual( + {c.name for c in preview.checks}, + { + request_service.CHECK_AUTHORIZATION, + request_service.CHECK_CAPABILITY, + request_service.CHECK_LEASE_AVAILABILITY, + request_service.CHECK_NEXT_SAFE_ACTION, + request_service.CHECK_HEAD_PIN, + }, + ) + self.assertEqual(preview.required_profile, "prgs-author") + self.assertEqual(preview.required_namespace, "gitea-author") + + def test_every_check_carries_a_reason(self): + preview = request_service.preview_request( + _request(), + principal=_principal(console_authz.OPERATOR), + allocator=_fake_allocator(), + claims_source=_no_claims, + audit=False, + ) + for check in preview.checks: + with self.subTest(check=check.name): + self.assertTrue(check.reason_code.strip()) + self.assertTrue(check.detail.strip()) + + def test_anonymous_preview_denied_with_reason(self): + preview = request_service.preview_request( + _request(), + allocator=_fake_allocator(), + claims_source=_no_claims, + audit=False, + ) + self.assertFalse(preview.authorized) + self.assertEqual(preview.reason_code, console_authz.DENY_UNAUTHENTICATED) + + def test_viewer_preview_denied_for_insufficient_role(self): + preview = request_service.preview_request( + _request(), + principal=_principal(console_authz.VIEWER), + allocator=_fake_allocator(), + claims_source=_no_claims, + audit=False, + ) + self.assertFalse(preview.authorized) + self.assertEqual(preview.reason_code, console_authz.DENY_INSUFFICIENT_ROLE) + + def test_denied_preview_never_reaches_the_allocator(self): + """A denial must not double as a queue oracle.""" + calls: list[dict[str, Any]] = [] + request_service.preview_request( + _request(), + principal=_principal(console_authz.VIEWER), + allocator=_fake_allocator(calls=calls), + claims_source=_no_claims, + audit=False, + ) + self.assertEqual(calls, []) + + def test_preview_lists_prohibited_actions(self): + preview = request_service.preview_request( + _request(role="author"), + principal=_principal(console_authz.OPERATOR), + allocator=_fake_allocator(), + claims_source=_no_claims, + audit=False, + ) + self.assertIn("merge", preview.prohibited_actions) + self.assertIn("approve", preview.prohibited_actions) + + def test_preview_reports_next_safe_action(self): + preview = request_service.preview_request( + _request(), + principal=_principal(console_authz.OPERATOR), + allocator=_fake_allocator(), + claims_source=_no_claims, + audit=False, + ) + self.assertIn("issue #643", preview.next_safe_action) + + def test_preview_never_mutates(self): + calls: list[dict[str, Any]] = [] + request_service.preview_request( + _request(), + principal=_principal(console_authz.OPERATOR), + allocator=_fake_allocator(calls=calls), + claims_source=_no_claims, + audit=False, + ) + self.assertEqual([c["apply"] for c in calls], [False]) + + +class TestPreviewFailClosed(unittest.TestCase): + """Missing evidence denies; it never reads as an absence of obstacles.""" + + def test_unreadable_claim_inventory_denies(self): + def _boom(_request): + raise RuntimeError("db unavailable") + + preview = request_service.preview_request( + _request(), + principal=_principal(console_authz.OPERATOR), + allocator=_fake_allocator(), + claims_source=_boom, + audit=False, + ) + self.assertFalse(preview.authorized) + self.assertEqual( + preview.reason_code, request_service.REASON_EVIDENCE_UNAVAILABLE + ) + + def test_allocator_failure_denies(self): + def _boom(**_kwargs): + raise RuntimeError("allocator exploded") + + preview = request_service.preview_request( + _request(), + principal=_principal(console_authz.OPERATOR), + allocator=_boom, + claims_source=_no_claims, + audit=False, + ) + self.assertFalse(preview.authorized) + self.assertEqual( + preview.reason_code, request_service.REASON_EVIDENCE_UNAVAILABLE + ) + + def test_allocator_selecting_other_work_denies(self): + preview = request_service.preview_request( + _request(number=643), + principal=_principal(console_authz.OPERATOR), + allocator=_fake_allocator(selection=_selection(number=999)), + claims_source=_no_claims, + audit=False, + ) + self.assertFalse(preview.authorized) + self.assertEqual(preview.reason_code, request_service.REASON_NOT_NEXT_SAFE) + self.assertIn("#999", preview.detail) + + def test_pr_without_head_sha_denies(self): + preview = request_service.preview_request( + _request(role="reviewer", kind="pr", number=898), + principal=_principal(console_authz.OPERATOR), + allocator=_fake_allocator( + selection=_selection(kind="pr", number=898, head_sha=None) + ), + claims_source=_no_claims, + audit=False, + ) + self.assertFalse(preview.authorized) + self.assertEqual( + preview.reason_code, request_service.REASON_EVIDENCE_UNAVAILABLE + ) + + def test_pr_head_moved_denies(self): + preview = request_service.preview_request( + _request(role="reviewer", kind="pr", number=898, head="a" * 40), + principal=_principal(console_authz.OPERATOR), + allocator=_fake_allocator( + selection=_selection(kind="pr", number=898, head_sha="b" * 40) + ), + claims_source=_no_claims, + audit=False, + ) + self.assertFalse(preview.authorized) + self.assertEqual(preview.reason_code, "head_moved") + + def test_pr_head_matching_passes(self): + preview = request_service.preview_request( + _request(role="reviewer", kind="pr", number=898, head="b" * 40), + principal=_principal(console_authz.OPERATOR), + allocator=_fake_allocator( + selection=_selection(kind="pr", number=898, head_sha="b" * 40) + ), + claims_source=_no_claims, + audit=False, + ) + self.assertTrue(preview.authorized) + + +class TestApplyExecutionGate(unittest.TestCase): + """Execution stays wired off unless an operator opts in explicitly.""" + + def test_action_is_registered_and_unwired_by_default(self): + action = console_authz.get_action(request_service.ACTION_ID) + self.assertIsNotNone(action) + self.assertEqual(action.phase, 2) + self.assertEqual(action.minimum_role, console_authz.OPERATOR) + self.assertTrue(action.requires_confirmation) + self.assertFalse(console_authz.execution_wired(action, env={})) + + def test_flag_named_but_unset_does_not_wire(self): + action = console_authz.get_action(request_service.ACTION_ID) + self.assertFalse(console_authz.execution_wired(action, env={EXEC_FLAG: "no"})) + self.assertTrue(console_authz.execution_wired(action, env={EXEC_FLAG: "1"})) + + def test_opting_in_wires_only_this_action(self): + env = {EXEC_FLAG: "1"} + for action_id, action in console_authz.ACTIONS.items(): + with self.subTest(action=action_id): + self.assertEqual( + console_authz.execution_wired(action, env=env), + action_id == request_service.ACTION_ID, + ) + + def test_apply_denied_while_unwired(self): + with mock.patch.dict(os.environ, {EXEC_FLAG: ""}): + result = request_service.apply_request( + _request(), + principal=_principal(console_authz.OPERATOR), + confirm=True, + allocator=_fake_allocator(), + claims_source=_no_claims, + ) + self.assertFalse(result["ok"]) + self.assertEqual(result["outcome"], request_service.OUTCOME_DENIED) + self.assertEqual(result["reason_code"], request_service.REASON_UNAUTHORIZED) + self.assertFalse(result["mutation_performed"]) + + +class TestApplyOutcomes(unittest.TestCase): + """AC2/AC3/AC4 — assignment, wait, blocked, and duplicate rejection.""" + + def setUp(self): + patcher = mock.patch.dict(os.environ, {EXEC_FLAG: "1"}) + patcher.start() + self.addCleanup(patcher.stop) + + def test_apply_creates_exclusive_assignment(self): + calls: list[dict[str, Any]] = [] + result = request_service.apply_request( + _request(), + principal=_principal(console_authz.OPERATOR), + confirm=True, + allocator=_fake_allocator(calls=calls), + claims_source=_no_claims, + ) + self.assertTrue(result["ok"]) + self.assertEqual(result["outcome"], allocator_service.OUTCOME_ASSIGNED) + self.assertEqual(result["assignment"]["assignment_id"], "asn-test-0001") + self.assertTrue(result["mutation_performed"]) + self.assertEqual(result["status_code"], 201) + # Dry-run first, then apply — never apply alone. + self.assertEqual([c["apply"] for c in calls], [False, True]) + # The apply call carries the fingerprint the dry-run produced. + self.assertEqual(calls[1]["fingerprint"], "fp-test") + + def test_assignment_returns_a_role_handoff(self): + result = request_service.apply_request( + _request(), + principal=_principal(console_authz.OPERATOR), + confirm=True, + allocator=_fake_allocator(), + claims_source=_no_claims, + ) + handoff = result["handoff"] + self.assertEqual(handoff["required_profile"], "prgs-author") + self.assertEqual(handoff["required_namespace"], "gitea-author") + self.assertEqual(handoff["assignment_id"], "asn-test-0001") + self.assertIn("merge", handoff["forbidden_actions"]) + + def test_unconfirmed_apply_refuses_before_the_allocator(self): + calls: list[dict[str, Any]] = [] + result = request_service.apply_request( + _request(), + principal=_principal(console_authz.OPERATOR), + confirm=False, + allocator=_fake_allocator(calls=calls), + claims_source=_no_claims, + ) + self.assertFalse(result["ok"]) + self.assertEqual( + result["reason_code"], request_service.REASON_CONFIRMATION_REQUIRED + ) + self.assertEqual(calls, []) + + def test_duplicate_assignment_rejected(self): + """AC3 — an active lease on the work unit blocks a second assign.""" + calls: list[dict[str, Any]] = [] + result = request_service.apply_request( + _request(), + principal=_principal(console_authz.OPERATOR), + confirm=True, + allocator=_fake_allocator(calls=calls), + claims_source=_claimed(), + ) + self.assertFalse(result["ok"]) + self.assertEqual(result["outcome"], request_service.OUTCOME_BLOCKED) + self.assertEqual( + result["reason_code"], request_service.REASON_DUPLICATE_ASSIGNMENT + ) + self.assertFalse(result["mutation_performed"]) + # The dry-run ran; the apply never did. + self.assertEqual([c["apply"] for c in calls], [False]) + + def test_not_next_safe_work_returns_wait_without_applying(self): + calls: list[dict[str, Any]] = [] + result = request_service.apply_request( + _request(number=643), + principal=_principal(console_authz.OPERATOR), + confirm=True, + allocator=_fake_allocator( + selection=_selection(number=999), calls=calls + ), + claims_source=_no_claims, + ) + self.assertFalse(result["ok"]) + self.assertEqual(result["outcome"], request_service.OUTCOME_WAIT) + self.assertEqual(result["reason_code"], request_service.REASON_NOT_NEXT_SAFE) + self.assertEqual([c["apply"] for c in calls], [False]) + + def test_allocator_declining_on_apply_returns_blocked(self): + result = request_service.apply_request( + _request(), + principal=_principal(console_authz.OPERATOR), + confirm=True, + allocator=_fake_allocator( + apply_outcome=allocator_service.OUTCOME_BLOCKED_LEASE, + assignment={}, + ), + claims_source=_no_claims, + ) + self.assertFalse(result["ok"]) + self.assertEqual(result["outcome"], request_service.OUTCOME_BLOCKED) + self.assertEqual( + result["reason_code"], request_service.REASON_ALLOCATOR_OUTCOME + ) + self.assertFalse(result["mutation_performed"]) + + def test_allocator_drift_on_apply_is_not_read_as_an_assignment(self): + """The apply call must return *this* work unit, not a substitute. + + Drift is not merely refused: the allocator has already committed the + substitute assignment by the time egress rejects it, so the refusal must + also release it. Asserting only ``mutation_performed is False`` would + pass just as well against a leak. + """ + released: list[tuple[str, str]] = [] + + with _patched_control_plane(release_sink=released): + result = request_service.apply_request( + _request(number=643), + principal=_principal(console_authz.OPERATOR), + confirm=True, + allocator=_drifting_allocator(), + claims_source=_no_claims, + ) + + self.assertFalse(result["ok"]) + self.assertIsNone(result["assignment"]) + # The substitute assignment was released, so nothing durable survives. + self.assertEqual(len(released), 1) + self.assertEqual(released[0][0], "lease-wrong") + compensation = result["compensation"] + self.assertTrue(compensation["released"]) + self.assertEqual(compensation["lease_id"], "lease-wrong") + self.assertEqual(compensation["assignment_id"], "asn-wrong") + self.assertEqual(compensation["selected"]["number"], 999) + self.assertFalse(result["mutation_performed"]) + self.assertNotIn("orphaned_assignment", result) + + def test_drift_whose_release_fails_reports_the_orphan_and_a_reclaim(self): + """A release that fails must surface the leak, never swallow it.""" + with _patched_control_plane(release_error=RuntimeError("db is read-only")): + result = request_service.apply_request( + _request(number=643), + principal=_principal(console_authz.OPERATOR), + confirm=True, + allocator=_drifting_allocator(), + claims_source=_no_claims, + ) + + self.assertFalse(result["ok"]) + self.assertIsNone(result["assignment"]) + # A lease really is out there; saying "nothing changed" would be a lie. + self.assertTrue(result["mutation_performed"]) + orphan = result["orphaned_assignment"] + self.assertFalse(orphan["released"]) + self.assertTrue(orphan["attempted"]) + self.assertIn("db is read-only", orphan["error"]) + reclaim = orphan["reclaim_action"] + self.assertEqual(reclaim["tool"], "gitea_release_workflow_lease") + self.assertEqual(reclaim["lease_id"], "lease-wrong") + + def test_drift_without_a_lease_id_still_surfaces_a_reclaim(self): + """An assignment with no lease id cannot be released — say so.""" + result = request_service.apply_request( + _request(number=643), + principal=_principal(console_authz.OPERATOR), + confirm=True, + allocator=_drifting_allocator(lease_id=None), + claims_source=_no_claims, + ) + self.assertFalse(result["ok"]) + self.assertTrue(result["mutation_performed"]) + orphan = result["orphaned_assignment"] + self.assertFalse(orphan["attempted"]) + self.assertEqual(orphan["assignment_id"], "asn-wrong") + self.assertIn("reclaim_action", orphan) + + def test_exception_after_commit_releases_the_session_lease(self): + """A post-commit exception surfaces as no result — with a live lease. + + ``allocate_next_work`` catches only three exception types, so anything + else raised after ``assign_and_lease`` committed reaches the caller as + ``None`` while the lease is durable. The stable per-flow session id is + what makes that lease findable. + """ + released: list[tuple[str, str]] = [] + seen: list[str | None] = [] + + def _explodes_after_commit( + *, request, apply, expected_candidate_set_fingerprint=None, session_id=None + ): + seen.append(session_id) + if not apply: + return { + "outcome": allocator_service.OUTCOME_PREVIEW, + "selected": _selection(number=643), + "assignment": None, + "candidate_set_fingerprint": "fp-test", + } + raise KeyError("selection['required_profile']") + + def _leases(**_kwargs): + return [ + { + "lease_id": "lease-committed", + "session_id": seen[-1], + "work_kind": "issue", + "work_number": 643, + } + ] + + with _patched_control_plane(release_sink=released, leases=_leases): + result = request_service.apply_request( + _request(number=643), + principal=_principal(console_authz.OPERATOR), + confirm=True, + allocator=_explodes_after_commit, + claims_source=_no_claims, + ) + + self.assertFalse(result["ok"]) + self.assertEqual( + result["reason_code"], request_service.REASON_EVIDENCE_UNAVAILABLE + ) + self.assertEqual(len(released), 1) + self.assertEqual(released[0][0], "lease-committed") + self.assertEqual( + result["compensation"]["released"][0]["lease_id"], "lease-committed" + ) + self.assertFalse(result["mutation_performed"]) + + def test_one_session_id_spans_the_dry_run_and_the_apply(self): + """Both halves of an apply share one control-plane identity.""" + seen: list[str | None] = [] + + def _recording( + *, request, apply, expected_candidate_set_fingerprint=None, session_id=None + ): + seen.append(session_id) + return { + "outcome": ( + allocator_service.OUTCOME_ASSIGNED + if apply + else allocator_service.OUTCOME_PREVIEW + ), + "selected": _selection(number=643), + "assignment": ( + { + "assignment_id": "asn-ok", + "lease_id": "lease-ok", + "expected_head_sha": None, + } + if apply + else None + ), + "candidate_set_fingerprint": "fp-test", + "session_id": session_id, + } + + result = request_service.apply_request( + _request(number=643), + principal=_principal(console_authz.OPERATOR), + confirm=True, + allocator=_recording, + claims_source=_no_claims, + ) + self.assertTrue(result["ok"]) + self.assertEqual(len(seen), 2) + self.assertTrue(all(s for s in seen)) + self.assertEqual(seen[0], seen[1], "dry-run and apply must share one id") + self.assertTrue(seen[0].startswith("webui-request-")) + + def test_viewer_cannot_apply(self): + result = request_service.apply_request( + _request(), + principal=_principal(console_authz.VIEWER), + confirm=True, + allocator=_fake_allocator(), + claims_source=_no_claims, + ) + self.assertFalse(result["ok"]) + self.assertEqual(result["reason_code"], request_service.REASON_UNAUTHORIZED) + + def test_anonymous_cannot_apply(self): + result = request_service.apply_request( + _request(), + confirm=True, + allocator=_fake_allocator(), + claims_source=_no_claims, + ) + self.assertFalse(result["ok"]) + self.assertFalse(result["mutation_performed"]) + + +class TestDefaultAllocatorPastTheEarlyReturns(unittest.TestCase): + """``default_allocator`` beyond its two fail-closed guards (#643). + + Both prior tests returned before ``ControlPlaneDB`` was ever constructed, so + the session id, the ``side_effect_free`` routing and the CAS round-trip had + no coverage at all — which is how a preview that writes session rows shipped. + """ + + def setUp(self): + from webui.queue_loader import QueueSnapshot + + self.snapshot = QueueSnapshot( + project_id="p", + repo_label="r", + prs=(), + issues=(), + pr_pagination=None, + issue_pagination=None, + ) + + @contextlib.contextmanager + def _harness(self): + """Run the real ``default_allocator`` against a recorded allocator call.""" + calls: dict[str, Any] = {} + + def _allocate(db, **kwargs): + calls.update(kwargs) + return {"outcome": allocator_service.OUTCOME_PREVIEW} + + module = mock.MagicMock() + with mock.patch.dict(sys.modules, {"control_plane_db": module}), \ + mock.patch( + "webui.queue_loader.load_queue_snapshot", + return_value=self.snapshot, + ), \ + mock.patch( + "webui.traffic_loader.candidates_from_queue_snapshot", + return_value=[], + ), \ + mock.patch.object( + allocator_service, "allocate_next_work", side_effect=_allocate + ): + yield calls + + def test_preview_runs_side_effect_free_and_never_applies(self): + with self._harness() as calls: + result = request_service.default_allocator( + request=_request(), apply=False + ) + self.assertIsNotNone(result) + self.assertTrue(calls["side_effect_free"]) + self.assertFalse(calls["apply"]) + + def test_apply_is_not_side_effect_free(self): + with self._harness() as calls: + request_service.default_allocator(request=_request(), apply=True) + self.assertFalse(calls["side_effect_free"]) + self.assertTrue(calls["apply"]) + + def test_caller_session_id_is_passed_through_verbatim(self): + with self._harness() as calls: + request_service.default_allocator( + request=_request(), apply=True, session_id="webui-request-fixed" + ) + self.assertEqual(calls["session_id"], "webui-request-fixed") + + def test_absent_session_id_is_minted_with_the_expected_shape(self): + with self._harness() as calls: + request_service.default_allocator(request=_request(), apply=False) + self.assertTrue(str(calls["session_id"]).startswith("webui-request-")) + + def test_scope_and_fingerprint_reach_the_allocator(self): + with self._harness() as calls: + request_service.default_allocator( + request=_request(number=664), + apply=False, + expected_candidate_set_fingerprint="fp-pinned", + ) + self.assertEqual(calls["expected_candidate_set_fingerprint"], "fp-pinned") + self.assertEqual(calls["remote"], SCOPE["remote"]) + self.assertEqual(calls["org"], SCOPE["org"]) + self.assertEqual(calls["repo"], SCOPE["repo"]) + self.assertEqual(calls["allocation_mode"], "role_scoped") + + +class TestDefaultClaimsSource(unittest.TestCase): + """``default_claims_source`` had no test at all (#643).""" + + def test_claims_are_read_for_the_request_scope(self): + db = mock.MagicMock() + db.list_active_claims.return_value = {("issue", 643): {"lease_id": "l1"}} + module = mock.MagicMock() + module.ControlPlaneDB.return_value = db + with mock.patch.dict(sys.modules, {"control_plane_db": module}): + claims = request_service.default_claims_source(_request()) + self.assertEqual(claims, {("issue", 643): {"lease_id": "l1"}}) + db.list_active_claims.assert_called_once_with( + remote=SCOPE["remote"], org=SCOPE["org"], repo=SCOPE["repo"] + ) + + def test_an_unreadable_substrate_denies_rather_than_returning_empty(self): + module = mock.MagicMock() + module.ControlPlaneDB.side_effect = RuntimeError("no db") + with mock.patch.dict(sys.modules, {"control_plane_db": module}): + # _load_claims converts the failure into None, which fails the + # lease check closed; an empty mapping would read as "nothing + # claimed" and wrongly authorize. + claims = request_service._load_claims( + _request(), request_service.default_claims_source + ) + self.assertIsNone(claims) + + +class TestAllocatorIntegrationFakes(unittest.TestCase): + """The real default allocator refuses a partial inventory (#758).""" + + def test_incomplete_inventory_returns_none(self): + from webui.queue_loader import PaginationMeta, QueueSnapshot + + snapshot = QueueSnapshot( + project_id="p", + repo_label="r", + prs=(), + issues=(), + pr_pagination=PaginationMeta( + page=1, + per_page=50, + returned_count=50, + has_more=True, + is_final_page=False, + inventory_complete=False, + pages_fetched=1, + ), + issue_pagination=None, + ) + with mock.patch( + "webui.queue_loader.load_queue_snapshot", return_value=snapshot + ): + result = request_service.default_allocator( + request=_request(), apply=False + ) + self.assertIsNone(result) + + def test_fetch_error_returns_none(self): + from webui.queue_loader import QueueSnapshot + + snapshot = QueueSnapshot( + project_id="p", + repo_label="r", + prs=(), + issues=(), + pr_pagination=None, + issue_pagination=None, + fetch_error="no credentials", + ) + with mock.patch( + "webui.queue_loader.load_queue_snapshot", return_value=snapshot + ): + result = request_service.default_allocator( + request=_request(), apply=True + ) + self.assertIsNone(result) + + +class TestAuditRecords(unittest.TestCase): + """Every preview and apply is auditable, correlated, and redacted.""" + + def setUp(self): + handle = tempfile.NamedTemporaryFile( + mode="w", suffix=".jsonl", delete=False + ) + handle.close() + self.sink = handle.name + self.addCleanup( + lambda: os.path.exists(self.sink) and os.remove(self.sink) + ) + patcher = mock.patch.dict( + os.environ, {console_audit.AUDIT_LOG_ENV: self.sink} + ) + patcher.start() + self.addCleanup(patcher.stop) + + def _records(self) -> list[dict[str, Any]]: + with open(self.sink, encoding="utf-8") as handle: + return [json.loads(line) for line in handle if line.strip()] + + def test_preview_is_audited_with_a_correlation_id(self): + preview = request_service.preview_request( + _request(), + principal=_principal(console_authz.OPERATOR), + allocator=_fake_allocator(), + claims_source=_no_claims, + ) + records = self._records() + self.assertEqual(len(records), 1) + record = records[0] + self.assertEqual(record["action"], request_service.ACTION_ID) + self.assertEqual(record["result"], console_audit.RESULT_PREVIEWED) + self.assertEqual( + record["correlation"]["request_id"], preview.correlation_id + ) + self.assertEqual(record["target"]["ref"], "#643") + + def test_denied_apply_is_audited(self): + with mock.patch.dict(os.environ, {EXEC_FLAG: ""}): + request_service.apply_request( + _request(), + principal=_principal(console_authz.VIEWER), + confirm=True, + allocator=_fake_allocator(), + claims_source=_no_claims, + ) + self.assertEqual(self._records()[-1]["result"], console_audit.RESULT_DENIED) + + def test_assignment_is_audited_and_correlated(self): + with mock.patch.dict(os.environ, {EXEC_FLAG: "1"}): + result = request_service.apply_request( + _request(), + principal=_principal(console_authz.OPERATOR), + confirm=True, + allocator=_fake_allocator(), + claims_source=_no_claims, + ) + record = self._records()[-1] + self.assertEqual(record["result"], console_audit.RESULT_SUCCEEDED) + self.assertEqual( + record["correlation"]["request_id"], result["correlation_id"] + ) + self.assertEqual( + record["metadata"]["assignment_id"], + result["assignment"]["assignment_id"], + ) + + def test_intent_bearing_a_secret_is_not_persisted_raw(self): + request_service.preview_request( + _request(intent="use token=ghp_ABCDEFGHIJKLMNOPQRSTUVWXYZ0123456789"), + principal=_principal(console_authz.OPERATOR), + allocator=_fake_allocator(), + claims_source=_no_claims, + ) + for record in self._records(): + with self.subTest(event=record.get("event_id")): + self.assertFalse(scan_for_secrets(record)) + + +class TestRequestRoutes(unittest.TestCase): + """The HTTP surface: form page, preview API, apply API.""" + + def setUp(self): + self.client = TestClient(create_app()) + + def test_requests_page_renders_form(self): + response = self.client.get("/requests") + self.assertEqual(response.status_code, 200) + body = response.text + self.assertIn("Requests", body) + self.assertIn("desired_role", body) + self.assertIn("intent_summary", body) + + def test_requests_page_is_linked_from_nav(self): + from webui.nav import nav_hrefs + + self.assertIn("/requests", nav_hrefs()) + + def test_preview_api_rejects_an_invalid_request(self): + response = self.client.post( + "/api/v1/requests/preview", + json={ + "desired_role": "wizard", + "work_kind": "issue", + "work_number": 1, + "intent_summary": "x", + **SCOPE, + }, + ) + self.assertEqual(response.status_code, 400) + self.assertEqual(response.json()["reason_code"], "unknown_role") + + def test_preview_api_denies_anonymous(self): + response = self.client.post( + "/api/v1/requests/preview", + json={ + "desired_role": "author", + "work_kind": "issue", + "work_number": 643, + "intent_summary": "x", + **SCOPE, + }, + ) + self.assertEqual(response.status_code, 403) + payload = response.json() + self.assertFalse(payload["authorized"]) + self.assertFalse(payload["mutation_performed"]) + + def test_apply_api_denies_anonymous(self): + response = self.client.post( + "/api/v1/requests/apply", + json={ + "desired_role": "author", + "work_kind": "issue", + "work_number": 643, + "intent_summary": "x", + "confirm": True, + **SCOPE, + }, + ) + self.assertEqual(response.status_code, 403) + payload = response.json() + self.assertFalse(payload["ok"]) + self.assertFalse(payload["mutation_performed"]) + self.assertIsNone(payload["assignment"]) + + def test_apply_api_rejects_an_invalid_request(self): + response = self.client.post( + "/api/v1/requests/apply", + json={ + "desired_role": "author", + "work_kind": "issue", + "work_number": -1, + "intent_summary": "x", + **SCOPE, + }, + ) + self.assertEqual(response.status_code, 400) + + def test_request_apis_are_post_only(self): + """GET is not a way in. The app's 405 handler renders a read-only + method against a write route as 404, so that is what is asserted.""" + for path in ("/api/v1/requests/preview", "/api/v1/requests/apply"): + with self.subTest(path=path): + self.assertEqual(self.client.get(path).status_code, 404) + + def test_form_post_previews_and_never_assigns(self): + response = self.client.post( + "/requests", + data={ + "desired_role": "author", + "work_kind": "issue", + "work_number": "643", + "intent_summary": "implement the request surface", + "remote": "prgs", + "org": "Scaled-Tech-Consulting", + "repo": "Gitea-Tools", + }, + ) + self.assertEqual(response.status_code, 200) + self.assertIn("Intent preview", response.text) + + +class TestRenderingSafety(unittest.TestCase): + """AC5 — the page escapes hostile input and shows no secret.""" + + def test_intent_is_escaped(self): + preview = request_service.preview_request( + _request(intent=""), + principal=_principal(console_authz.OPERATOR), + allocator=_fake_allocator(), + claims_source=_no_claims, + audit=False, + ) + html = render_requests_page(preview=preview) + self.assertNotIn("", html) + self.assertIn("<script>", html) + + def test_page_renders_a_denial_without_a_preview(self): + _, error = request_service.parse_request( + { + "desired_role": "wizard", + "work_kind": "issue", + "work_number": 1, + "intent_summary": "x", + **SCOPE, + } + ) + html = render_requests_page(error=error) + self.assertIn("Request rejected", html) + self.assertIn("unknown_role", html) + + def test_page_shows_no_credential_material(self): + preview = request_service.preview_request( + _request(), + principal=_principal(console_authz.OPERATOR), + allocator=_fake_allocator(), + claims_source=_no_claims, + audit=False, + ) + html = render_requests_page(preview=preview) + for needle in ("token=", "Bearer ", "password"): + with self.subTest(needle=needle): + self.assertNotIn(needle, html) + + +if __name__ == "__main__": # pragma: no cover + unittest.main() diff --git a/tests/test_webui_restart_console.py b/tests/test_webui_restart_console.py new file mode 100644 index 0000000..439bba5 --- /dev/null +++ b/tests/test_webui_restart_console.py @@ -0,0 +1,452 @@ +"""Read-only restart console: views, gates, and honesty rules (#667). + +The console consumes the #655 substrate. These tests hold it to the three +properties that make a status surface trustworthy: + +* an unreadable source is reported unavailable, never rendered as green; +* authorization is probed the way execution would probe it, so an allow is + never shown for something that could not run; +* the surface performs no mutation, including no write to the control-plane DB. +""" + +from __future__ import annotations + +import os +import sqlite3 +import sys +import tempfile +import unittest +from datetime import datetime, timedelta, timezone +from pathlib import Path + +sys.path.insert(0, str(Path(__file__).resolve().parent.parent)) + +from starlette.testclient import TestClient # noqa: E402 + +import restart_coordinator # noqa: E402 +from webui import console_authz, restart_console, restart_views # noqa: E402 +from webui.app import create_app # noqa: E402 + +NOW = datetime(2026, 7, 25, 21, 0, 0, tzinfo=timezone.utc) + + +def _principal(role: str) -> console_authz.Principal: + return console_authz.Principal( + subject="operator@example.com", + role=role, + identity_source=console_authz.IDENTITY_LOCAL_DEV, + authenticated=True, + ) + + +def _inventory(*, complete: bool = True, sessions=(), leases=()): + def _read(**_kwargs): + return { + "sessions": list(sessions), + "leases": list(leases), + "terminal_lock": None, + "prior_recovery_attempts": [], + "inventory_complete": complete, + "incomplete_reasons": ( + [] if complete else ["fixture: inventory withheld"] + ), + } + + return _read + + +def _live_session(session_id: str = "prgs-author-1234-abcd") -> dict: + return { + "session_id": session_id, + "role": "author", + "profile": "prgs-author", + "pid": os.getpid(), + "status": "active", + "last_heartbeat_at": (NOW - timedelta(seconds=30)).isoformat(), + } + + +def drain_proof_fixture() -> dict: + """A structurally complete but unsigned drain proof.""" + return { + "version": "drain-proof/v1", + "proof_id": "deadbeef" * 8, + "clean": True, + "issued_at": (NOW - timedelta(minutes=1)).isoformat(), + "expires_at": (NOW + timedelta(minutes=5)).isoformat(), + "requesting_session_id": "s-live", + "impact_fingerprint": "f" * 64, + "checks": [], + "failed_checks": [], + } + + +class RestartClassMatrixTest(unittest.TestCase): + def test_every_policy_class_is_rendered(self) -> None: + views = restart_console.build_restart_class_views("operator") + self.assertEqual(len(views), len(restart_coordinator.RESTART_CLASS_POLICIES)) + + def test_viewer_capability_is_role_scoped_not_generic(self) -> None: + """A worker role must not be shown as able to request a full restart.""" + author = { + v.restart_class: v + for v in restart_console.build_restart_class_views("author") + } + operator = { + v.restart_class: v + for v in restart_console.build_restart_class_views("operator") + } + full = restart_coordinator.RestartClass.FULL_MCP_RESTART.value + + self.assertFalse(author[full].viewer_may_request) + self.assertFalse(author[full].viewer_may_execute) + self.assertTrue(operator[full].viewer_may_request) + self.assertTrue(operator[full].viewer_may_execute) + + def test_unknown_role_may_do_nothing(self) -> None: + views = restart_console.build_restart_class_views("not-a-role") + self.assertTrue(all(not v.viewer_may_request for v in views)) + self.assertTrue(all(not v.viewer_may_execute for v in views)) + + +class AuthorizationProbeTest(unittest.TestCase): + def test_probe_asks_for_execution_so_phase_gate_is_reported(self) -> None: + """An admin clears the role bar and still cannot execute in Phase 1. + + This is the case that distinguishes the two probes. Asked without + ``for_execution`` an admin is *allowed* for ``system.restart_namespace``, + which on a control surface reads as a live button. Asked the way + execution asks, the same principal is refused ``phase_not_active``. The + console must report the second answer. + """ + by_id = { + a.action_id: a + for a in restart_console.build_action_authorizations( + _principal(console_authz.ADMIN) + ) + } + restart = by_id["system.restart_namespace"] + + self.assertFalse(restart.execution_enabled) + self.assertEqual(restart.reason_code, console_authz.DENY_PHASE_NOT_ACTIVE) + + permissive = console_authz.authorize( + "system.restart_namespace", _principal(console_authz.ADMIN) + ) + self.assertTrue( + permissive.allowed, + "guard precondition: without for_execution an admin is allowed, " + "which is exactly why the console must not probe that way", + ) + + def test_operator_is_refused_the_admin_only_restart_action(self) -> None: + """Role refusal precedes the phase gate and is reported as such.""" + by_id = { + a.action_id: a + for a in restart_console.build_action_authorizations( + _principal(console_authz.OPERATOR) + ) + } + self.assertEqual( + by_id["system.restart_namespace"].reason_code, + console_authz.DENY_INSUFFICIENT_ROLE, + ) + + def test_anonymous_is_denied_unauthenticated(self) -> None: + by_id = { + a.action_id: a for a in restart_console.build_action_authorizations(None) + } + self.assertEqual( + by_id["system.restart_namespace"].reason_code, + console_authz.DENY_UNAUTHENTICATED, + ) + + def test_no_authorization_ever_reports_execution_enabled(self) -> None: + for role in ( + console_authz.VIEWER, + console_authz.OPERATOR, + console_authz.CONTROLLER, + console_authz.ADMIN, + ): + for auth in restart_console.build_action_authorizations(_principal(role)): + self.assertFalse( + auth.execution_enabled, + f"{role} reported execution_enabled for {auth.action_id}", + ) + + +class ImpactPreviewTest(unittest.TestCase): + def test_impact_renders_from_coordinator_dto(self) -> None: + impact, source = restart_console.load_impact_report( + principal=_principal(console_authz.OPERATOR), + read_inventory=_inventory(sessions=[_live_session()]), + now=NOW, + ) + self.assertTrue(source.available) + self.assertIsNotNone(impact) + self.assertEqual( + impact["restart_class"], + restart_coordinator.RestartClass.FULL_MCP_RESTART.value, + ) + self.assertIn("verdict", impact) + self.assertFalse(impact["restart_performed"]) + self.assertTrue(impact["dry_run"]) + + def test_incomplete_inventory_is_surfaced_and_denies(self) -> None: + impact, source = restart_console.load_impact_report( + principal=_principal(console_authz.OPERATOR), + read_inventory=_inventory(complete=False), + now=NOW, + ) + self.assertFalse(impact["inventory_complete"]) + self.assertFalse(impact["allow_restart"]) + self.assertTrue(source.detail, "incomplete inventory must explain itself") + + def test_inventory_reader_failure_is_unavailable_not_empty(self) -> None: + """A reader that raises must not be rendered as 'no sessions affected'.""" + + def _boom(**_kwargs): + raise RuntimeError("control-plane unreachable") + + impact, source = restart_console.load_impact_report( + principal=_principal(console_authz.OPERATOR), + read_inventory=_boom, + now=NOW, + ) + self.assertIsNone(impact) + self.assertFalse(source.available) + self.assertIn("control-plane unreachable", source.detail) + + +class ControlPlaneReadTest(unittest.TestCase): + def test_missing_database_is_incomplete_not_empty(self) -> None: + inventory = restart_console.read_control_plane_inventory( + db_path="/nonexistent/control-plane.sqlite3" + ) + self.assertFalse(inventory["inventory_complete"]) + self.assertEqual(inventory["sessions"], []) + self.assertTrue(inventory["incomplete_reasons"]) + + def test_reader_never_creates_the_database(self) -> None: + """Reading status must not bring a control-plane DB into existence. + + The path deliberately sits in a directory that already exists: a + read-write ``sqlite3.connect`` would happily create the file there, so + this fails if the reader ever stops opening the database ``mode=ro``. + A nested-missing-directory path would pass for the wrong reason, + because sqlite cannot create the parent directory either way. + """ + with tempfile.TemporaryDirectory() as tmp: + path = os.path.join(tmp, "control_plane.sqlite3") + self.assertTrue(os.path.isdir(os.path.dirname(path))) + + inventory = restart_console.read_control_plane_inventory(db_path=path) + + self.assertFalse( + os.path.exists(path), + "reading restart status created a control-plane database", + ) + self.assertFalse(inventory["inventory_complete"]) + + def test_reads_active_sessions_from_a_real_database(self) -> None: + with tempfile.TemporaryDirectory() as tmp: + path = os.path.join(tmp, "cp.sqlite3") + conn = sqlite3.connect(path) + conn.execute( + "CREATE TABLE sessions (session_id TEXT, role TEXT, profile TEXT," + " pid INTEGER, status TEXT, last_heartbeat_at TEXT)" + ) + conn.execute( + "CREATE TABLE work_items (work_item_id INTEGER, kind TEXT," + " number INTEGER)" + ) + conn.execute( + "CREATE TABLE leases (lease_id TEXT, session_id TEXT, role TEXT," + " phase TEXT, status TEXT, worktree_path TEXT," + " work_item_id INTEGER, expires_at TEXT)" + ) + conn.execute( + "INSERT INTO sessions VALUES (?,?,?,?,?,?)", + ("s-live", "author", "prgs-author", 4242, "active", NOW.isoformat()), + ) + conn.execute( + "INSERT INTO sessions VALUES (?,?,?,?,?,?)", + ("s-done", "author", "prgs-author", 11, "closed", NOW.isoformat()), + ) + conn.execute("INSERT INTO work_items VALUES (1, 'issue', 667)") + conn.execute( + "INSERT INTO leases VALUES (?,?,?,?,?,?,?,?)", + ( + "l-1", + "s-live", + "author", + "allocated", + "active", + None, + 1, + NOW.isoformat(), + ), + ) + conn.commit() + conn.close() + + inventory = restart_console.read_control_plane_inventory(db_path=path) + + self.assertTrue(inventory["inventory_complete"]) + self.assertEqual([s["session_id"] for s in inventory["sessions"]], ["s-live"]) + self.assertEqual(inventory["leases"][0]["work_number"], 667) + + +class DrainAndReconcileTest(unittest.TestCase): + def test_absent_drain_proof_is_not_a_pass(self) -> None: + drain, source = restart_console.load_drain_status(proof=None, now=NOW) + self.assertIsNone(drain) + self.assertFalse(source.available) + self.assertIn("denies", source.detail) + + def test_tampered_drain_proof_is_reported_invalid(self) -> None: + proof = drain_proof_fixture() + proof["clean"] = True + proof["proof_id"] = "0" * 64 + drain, source = restart_console.load_drain_status(proof=proof, now=NOW) + self.assertTrue(source.available) + self.assertFalse(drain["valid"]) + + def test_absent_reconcile_proof_is_unavailable(self) -> None: + reconcile, source = restart_console.load_reconcile_status(load_proof=None) + self.assertIsNone(reconcile) + self.assertFalse(source.available) + + def test_reconcile_proof_is_rendered_when_supplied(self) -> None: + payload = { + "overall_status": "degraded", + "mode": "log_only", + "resolved_count": 3, + "unresolved_count": 2, + "items": [ + { + "dimension": "leases", + "status": "unresolved", + "summary": "2 orphaned leases", + "follow_up_required": True, + } + ], + } + reconcile, source = restart_console.load_reconcile_status( + load_proof=lambda: payload + ) + self.assertTrue(source.available) + self.assertEqual(reconcile["unresolved_count"], 2) + + +class RenderingTest(unittest.TestCase): + def _snapshot(self, **kwargs): + params = { + "principal": _principal(console_authz.OPERATOR), + "read_inventory": _inventory(sessions=[_live_session()]), + "now": NOW, + } + params.update(kwargs) + return restart_console.load_restart_console_snapshot(**params) + + def test_page_renders_every_section(self) -> None: + html = restart_views.render_restart_console_page(self._snapshot()) + for heading in ( + "Impact preview", + "Drain proof", + "Post-restart reconcile", + "Restart classes", + "Approval controls", + "Break-glass", + ): + self.assertIn(heading, html) + + def test_hostile_session_id_is_escaped(self) -> None: + hostile = "" + html = restart_views.render_restart_console_page( + self._snapshot(read_inventory=_inventory(sessions=[_live_session(hostile)])) + ) + self.assertNotIn("