Remediates reviewer findings F1, F2, and F3 from review 483 on PR #770. F1 (blocking): _current_runtime_mode_report derived the runtime-gate alignment input as realpath(workspace_root) == process_project_root, redefining workspace_roots_aligned. Its established meaning is ctx["roots_aligned"] (canonical_repo_root == process_project_root) — a repository-level question. Because the global worktree rule requires all task work to live in a branches/ worktree, the old derivation reported aligned False for exactly the sessions that are configured correctly, raising unsafe_process_root_workspace_alignment and refusing every non-read operation once an author, reviewer, or merger namespace held a worktree binding. The gate is now fed ctx["roots_aligned"]. F2 (blocking): _current_runtime_mode_report cached its first result into _STARTUP_RUNTIME_MODE, which was initialised to None rather than captured at import, and the read-only refresh=True path seeded it too. Session-scoped fields (active_task_workspace and its derived alignment) and mutable fields (dirty_files) were frozen for the process lifetime, so the acceptance criterion 7 dirty-runtime blocker stopped applying after the snapshot and one session's binding decided alignment for every later session. Immutable process facts (process root, branch, head, checkout-ness) are now captured at import as _STARTUP_RUNTIME_FACTS, matching the #420 parity baseline. Dirty state, workspace binding, and alignment are recomputed on every call. A new stable_control_runtime.observe_dirty_files() splits out the one runtime fact that legitimately changes during a process lifetime; observe_runtime() delegates to it so the parsing lives in one place. refresh is retained for the read-only reporting path but no longer selects a cache, so a read-only call can neither seed nor weaken a later mutation decision. F3 (major): no test exercised the real derivation — every server-wiring test asserting a healthy runtime permits mutations patched _current_runtime_mode_report with a fixture whose workspace_roots_aligned was True. New TestServerWiringRealDerivation patches only the derivation's inputs and lets the real function build the report: - a clean stable control checkout plus a correctly bound branches/ worktree permits an otherwise authorized mutation; - misaligned process/canonical roots fail closed; - newly dirty task state is detected after an earlier clean read; - a read-only refresh cannot freeze a permissive mutation result; - an unresolvable binding reports unknown alignment, never alignment proof. Acceptance criteria 6, 8, 10, and 11 are unchanged and untouched. Co-Authored-By: Claude Opus 4.8 (1M context) <[email protected]>
604 lines
26 KiB
Python
604 lines
26 KiB
Python
"""Tests for the stable-control runtime mode gates (#615).
|
|
|
|
Covers acceptance criteria 6-11: runtime mode + SHA reporting, the fail-closed
|
|
mutation gates (dev-test targeting production, unknown runtime, dirty stable
|
|
checkout, dev-worktree launch, unsafe alignment), per-namespace post-flap
|
|
re-proving, promotion-record completeness, and the policy statements that keep
|
|
normal sessions from restarting the stable MCP runtime.
|
|
"""
|
|
import os
|
|
import sys
|
|
import unittest
|
|
from pathlib import Path
|
|
from unittest.mock import patch
|
|
|
|
REPO_ROOT = Path(__file__).resolve().parent.parent
|
|
sys.path.insert(0, str(REPO_ROOT))
|
|
|
|
import stable_control_runtime as scr # noqa: E402
|
|
|
|
|
|
SHA_A = "a" * 40
|
|
SHA_B = "b" * 40
|
|
|
|
STABLE_ROOT = "/Users/dev/Development/Gitea-Tools"
|
|
DEV_WORKTREE_ROOT = "/Users/dev/Development/Gitea-Tools/branches/issue-615-work"
|
|
|
|
|
|
def stable_report(**overrides):
|
|
"""A healthy stable-control runtime report, overridable per test."""
|
|
base = dict(
|
|
process_root=STABLE_ROOT,
|
|
checkout_branch="master",
|
|
runtime_head=SHA_A,
|
|
active_task_workspace=STABLE_ROOT,
|
|
canonical_repository_root=STABLE_ROOT,
|
|
repository_slug="Scaled-Tech-Consulting/Gitea-Tools",
|
|
profile="prgs-author",
|
|
authenticated_identity="jcwalker3",
|
|
dirty_files=[],
|
|
workspace_roots_aligned=True,
|
|
)
|
|
base.update(overrides)
|
|
return scr.build_runtime_report(**base)
|
|
|
|
|
|
class TestClassifyRuntimeMode(unittest.TestCase):
|
|
def test_stable_branch_checkout_is_stable_control(self):
|
|
res = scr.classify_runtime_mode(
|
|
process_root=STABLE_ROOT, checkout_branch="master")
|
|
self.assertEqual(res["runtime_mode"], scr.RUNTIME_MODE_STABLE)
|
|
self.assertFalse(res["dev_worktree_launched"])
|
|
|
|
def test_main_and_dev_are_also_stable(self):
|
|
for branch in ("main", "dev"):
|
|
res = scr.classify_runtime_mode(
|
|
process_root=STABLE_ROOT, checkout_branch=branch)
|
|
self.assertEqual(res["runtime_mode"], scr.RUNTIME_MODE_STABLE, branch)
|
|
|
|
def test_branches_worktree_launch_is_dev_test(self):
|
|
res = scr.classify_runtime_mode(
|
|
process_root=DEV_WORKTREE_ROOT,
|
|
checkout_branch="feat/issue-615-runtime-mode-enforcement",
|
|
)
|
|
self.assertEqual(res["runtime_mode"], scr.RUNTIME_MODE_DEV_TEST)
|
|
self.assertTrue(res["dev_worktree_launched"])
|
|
|
|
def test_feature_branch_outside_branches_is_still_dev_test(self):
|
|
res = scr.classify_runtime_mode(
|
|
process_root="/Users/dev/Development/scratch-clone",
|
|
checkout_branch="feat/experiment",
|
|
)
|
|
self.assertEqual(res["runtime_mode"], scr.RUNTIME_MODE_DEV_TEST)
|
|
self.assertFalse(res["dev_worktree_launched"])
|
|
|
|
def test_unresolvable_root_is_unknown(self):
|
|
res = scr.classify_runtime_mode(process_root=None, checkout_branch=None)
|
|
self.assertEqual(res["runtime_mode"], scr.RUNTIME_MODE_UNKNOWN)
|
|
|
|
def test_non_git_root_is_unknown(self):
|
|
res = scr.classify_runtime_mode(
|
|
process_root="/opt/gitea-tools-release",
|
|
checkout_branch=None,
|
|
is_git_checkout=False,
|
|
)
|
|
self.assertEqual(res["runtime_mode"], scr.RUNTIME_MODE_UNKNOWN)
|
|
|
|
def test_detached_head_is_unknown(self):
|
|
res = scr.classify_runtime_mode(
|
|
process_root=STABLE_ROOT, checkout_branch=None)
|
|
self.assertEqual(res["runtime_mode"], scr.RUNTIME_MODE_UNKNOWN)
|
|
|
|
def test_operator_declaration_wins_over_inference(self):
|
|
res = scr.classify_runtime_mode(
|
|
process_root="/opt/gitea-tools-release",
|
|
checkout_branch=None,
|
|
is_git_checkout=False,
|
|
declared_mode=scr.RUNTIME_MODE_STABLE,
|
|
)
|
|
self.assertEqual(res["runtime_mode"], scr.RUNTIME_MODE_STABLE)
|
|
self.assertTrue(res["declared"])
|
|
|
|
def test_invalid_declaration_is_ignored(self):
|
|
with patch.dict(os.environ, {scr.ENV_RUNTIME_MODE: "production-ish"}):
|
|
self.assertIsNone(scr.declared_runtime_mode())
|
|
|
|
def test_valid_declaration_is_read_from_env(self):
|
|
with patch.dict(os.environ, {scr.ENV_RUNTIME_MODE: "dev-test"}):
|
|
self.assertEqual(scr.declared_runtime_mode(), scr.RUNTIME_MODE_DEV_TEST)
|
|
|
|
|
|
class TestRuntimeReport(unittest.TestCase):
|
|
"""Acceptance criterion 6: runtime mode and SHA reporting."""
|
|
|
|
def test_report_carries_every_required_field(self):
|
|
report = stable_report()
|
|
for field in (
|
|
"runtime_mode",
|
|
"runtime_git_sha",
|
|
"runtime_branch",
|
|
"runtime_checkout_path",
|
|
"mcp_process_root",
|
|
"active_task_workspace",
|
|
"repository_slug",
|
|
"profile",
|
|
"authenticated_identity",
|
|
"dirty_files",
|
|
"workspace_roots_aligned",
|
|
"real_mutations_allowed",
|
|
):
|
|
self.assertIn(field, report, field)
|
|
|
|
def test_report_records_the_runtime_sha(self):
|
|
self.assertEqual(stable_report()["runtime_git_sha"], SHA_A)
|
|
|
|
def test_format_summarises_mode_sha_and_branch(self):
|
|
summary = scr.format_runtime_mode(stable_report())
|
|
self.assertIn(scr.RUNTIME_MODE_STABLE, summary)
|
|
self.assertIn(SHA_A[:12], summary)
|
|
self.assertIn("master", summary)
|
|
|
|
|
|
class TestMutationGate(unittest.TestCase):
|
|
"""Acceptance criterion 7: fail-closed mutation gates."""
|
|
|
|
def test_stable_healthy_runtime_allows_real_mutations(self):
|
|
report = stable_report()
|
|
gate = scr.assess_runtime_mutation_gate(report)
|
|
self.assertFalse(gate["block"])
|
|
self.assertEqual(gate["reasons"], [])
|
|
self.assertTrue(report["real_mutations_allowed"])
|
|
|
|
def test_dev_test_runtime_blocks_real_production_mutations(self):
|
|
report = stable_report(
|
|
process_root=DEV_WORKTREE_ROOT,
|
|
checkout_branch="feat/issue-615-runtime-mode-enforcement",
|
|
active_task_workspace=DEV_WORKTREE_ROOT,
|
|
)
|
|
gate = scr.assess_runtime_mutation_gate(report)
|
|
self.assertTrue(gate["block"])
|
|
self.assertIn(scr.BLOCKER_DEV_TEST_PRODUCTION, gate["blocker_kinds"])
|
|
self.assertFalse(report["real_mutations_allowed"])
|
|
|
|
def test_dev_test_runtime_may_mutate_a_non_production_target(self):
|
|
report = stable_report(
|
|
process_root=DEV_WORKTREE_ROOT,
|
|
checkout_branch="feat/issue-615-runtime-mode-enforcement",
|
|
)
|
|
gate = scr.assess_runtime_mutation_gate(
|
|
report, target_is_production=False)
|
|
self.assertFalse(gate["block"])
|
|
|
|
def test_unknown_runtime_blocks_mutations(self):
|
|
report = stable_report(checkout_branch=None)
|
|
gate = scr.assess_runtime_mutation_gate(report)
|
|
self.assertTrue(gate["block"])
|
|
self.assertIn(scr.BLOCKER_UNKNOWN_RUNTIME, gate["blocker_kinds"])
|
|
|
|
def test_unknown_runtime_blocks_even_a_non_production_target(self):
|
|
report = stable_report(checkout_branch=None)
|
|
gate = scr.assess_runtime_mutation_gate(
|
|
report, target_is_production=False)
|
|
self.assertTrue(gate["block"])
|
|
|
|
def test_dirty_stable_runtime_blocks_mutations(self):
|
|
report = stable_report(dirty_files=["gitea_mcp_server.py"])
|
|
gate = scr.assess_runtime_mutation_gate(report)
|
|
self.assertTrue(gate["block"])
|
|
self.assertIn(scr.BLOCKER_DIRTY_STABLE_RUNTIME, gate["blocker_kinds"])
|
|
self.assertTrue(
|
|
any("dirty" in reason for reason in gate["reasons"]))
|
|
|
|
def test_dev_worktree_launch_is_reported_as_its_own_blocker(self):
|
|
report = stable_report(
|
|
process_root=DEV_WORKTREE_ROOT,
|
|
checkout_branch="feat/issue-615-runtime-mode-enforcement",
|
|
)
|
|
gate = scr.assess_runtime_mutation_gate(report)
|
|
self.assertIn(scr.BLOCKER_DEV_WORKTREE_LAUNCH, gate["blocker_kinds"])
|
|
|
|
def test_unsafe_workspace_alignment_blocks_mutations(self):
|
|
report = stable_report(workspace_roots_aligned=False)
|
|
gate = scr.assess_runtime_mutation_gate(report)
|
|
self.assertTrue(gate["block"])
|
|
self.assertIn(scr.BLOCKER_UNSAFE_ALIGNMENT, gate["blocker_kinds"])
|
|
|
|
def test_unknown_alignment_does_not_block(self):
|
|
report = stable_report(workspace_roots_aligned=None)
|
|
self.assertFalse(scr.assess_runtime_mutation_gate(report)["block"])
|
|
|
|
def test_env_escape_hatch_disables_the_gate(self):
|
|
report = stable_report(checkout_branch=None)
|
|
with patch.dict(os.environ, {scr.ENV_DISABLE: "1"}):
|
|
gate = scr.assess_runtime_mutation_gate(report)
|
|
self.assertFalse(gate["block"])
|
|
self.assertTrue(gate["gate_disabled"])
|
|
|
|
def test_block_reasons_helper_matches_the_gate(self):
|
|
report = stable_report(checkout_branch=None)
|
|
self.assertEqual(
|
|
scr.runtime_block_reasons(report),
|
|
scr.assess_runtime_mutation_gate(report)["reasons"],
|
|
)
|
|
|
|
def test_block_payload_names_the_operator_recovery_path(self):
|
|
report = stable_report(checkout_branch=None)
|
|
payload = scr.runtime_report_payload(report)
|
|
self.assertEqual(payload["kind"], "runtime_mode_block")
|
|
self.assertEqual(payload["blocker_kind"], scr.BLOCKER_UNKNOWN_RUNTIME)
|
|
self.assertTrue(
|
|
any("promotion-runbook" in line for line in payload["recovery"]))
|
|
|
|
|
|
class TestPostFlapReproving(unittest.TestCase):
|
|
"""Acceptance criterion 8: per-namespace post-flap re-proving."""
|
|
|
|
def test_no_flap_means_no_reproof_required(self):
|
|
state = scr.new_reproof_state()
|
|
res = scr.assess_namespace_reproof(state, "reviewer")
|
|
self.assertFalse(res["reproof_required"])
|
|
self.assertTrue(res["proven"])
|
|
|
|
def test_transport_recovery_requires_namespace_specific_reproving(self):
|
|
state = scr.record_transport_flap(
|
|
scr.new_reproof_state(), at="2026-07-20T14:00:00Z")
|
|
res = scr.assess_namespace_reproof(state, "reviewer")
|
|
self.assertTrue(res["reproof_required"])
|
|
self.assertFalse(res["proven"])
|
|
self.assertEqual(
|
|
res["missing_steps"], list(scr.REQUIRED_NAMESPACE_PROOF_STEPS))
|
|
|
|
def test_author_proof_does_not_imply_other_namespaces(self):
|
|
state = scr.record_transport_flap(
|
|
scr.new_reproof_state(), at="2026-07-20T14:00:00Z")
|
|
state = scr.record_namespace_proof(
|
|
state,
|
|
"author",
|
|
at="2026-07-20T14:05:00Z",
|
|
whoami=True,
|
|
runtime_context=True,
|
|
capability_resolved=True,
|
|
)
|
|
self.assertTrue(scr.assess_namespace_reproof(state, "author")["proven"])
|
|
for other in ("reviewer", "merger", "reconciler"):
|
|
assessment = scr.assess_namespace_reproof(state, other)
|
|
self.assertFalse(assessment["proven"], other)
|
|
self.assertTrue(
|
|
any("does not transfer" in reason
|
|
for reason in assessment["reasons"]),
|
|
other,
|
|
)
|
|
self.assertEqual(
|
|
scr.unproven_namespaces(state),
|
|
["reviewer", "merger", "reconciler"],
|
|
)
|
|
|
|
def test_proof_recorded_before_the_flap_does_not_count(self):
|
|
state = scr.record_namespace_proof(
|
|
scr.new_reproof_state(),
|
|
"merger",
|
|
at="2026-07-20T13:00:00Z",
|
|
whoami=True,
|
|
runtime_context=True,
|
|
capability_resolved=True,
|
|
)
|
|
state = scr.record_transport_flap(state, at="2026-07-20T14:00:00Z")
|
|
res = scr.assess_namespace_reproof(state, "merger")
|
|
self.assertFalse(res["proven"])
|
|
self.assertTrue(any("predates" in reason for reason in res["reasons"]))
|
|
|
|
def test_incomplete_proof_lists_the_missing_steps(self):
|
|
state = scr.record_transport_flap(
|
|
scr.new_reproof_state(), at="2026-07-20T14:00:00Z")
|
|
state = scr.record_namespace_proof(
|
|
state, "reviewer", at="2026-07-20T14:05:00Z", whoami=True)
|
|
res = scr.assess_namespace_reproof(state, "reviewer")
|
|
self.assertFalse(res["proven"])
|
|
self.assertEqual(
|
|
res["missing_steps"], ["runtime_context", "capability_resolved"])
|
|
|
|
def test_stale_runtime_report_keeps_the_namespace_unproven(self):
|
|
state = scr.record_transport_flap(
|
|
scr.new_reproof_state(), at="2026-07-20T14:00:00Z")
|
|
state = scr.record_namespace_proof(
|
|
state,
|
|
"reviewer",
|
|
at="2026-07-20T14:05:00Z",
|
|
whoami=True,
|
|
runtime_context=True,
|
|
capability_resolved=True,
|
|
stale_runtime_reported=True,
|
|
)
|
|
res = scr.assess_namespace_reproof(state, "reviewer")
|
|
self.assertFalse(res["proven"])
|
|
self.assertTrue(
|
|
any("stale-runtime" in reason for reason in res["reasons"]))
|
|
|
|
def test_unproven_namespace_blocks_the_mutation_gate(self):
|
|
state = scr.record_transport_flap(
|
|
scr.new_reproof_state(), at="2026-07-20T14:00:00Z")
|
|
gate = scr.assess_runtime_mutation_gate(
|
|
stable_report(), namespace="reviewer", namespace_reproof=state)
|
|
self.assertTrue(gate["block"])
|
|
self.assertIn(scr.BLOCKER_NAMESPACE_NOT_REPROVEN, gate["blocker_kinds"])
|
|
|
|
def test_reproven_namespace_clears_the_mutation_gate(self):
|
|
state = scr.record_transport_flap(
|
|
scr.new_reproof_state(), at="2026-07-20T14:00:00Z")
|
|
state = scr.record_namespace_proof(
|
|
state,
|
|
"reviewer",
|
|
at="2026-07-20T14:05:00Z",
|
|
whoami=True,
|
|
runtime_context=True,
|
|
capability_resolved=True,
|
|
)
|
|
gate = scr.assess_runtime_mutation_gate(
|
|
stable_report(), namespace="reviewer", namespace_reproof=state)
|
|
self.assertFalse(gate["block"])
|
|
|
|
|
|
class TestPromotionRecord(unittest.TestCase):
|
|
"""Acceptance criteria 4 / 10: promotion records previous and promoted SHAs."""
|
|
|
|
def complete_record(self, **overrides):
|
|
record = {
|
|
"previous_runtime_sha": SHA_A,
|
|
"promoted_runtime_sha": SHA_B,
|
|
"source_branch": "feat/issue-615-runtime-mode-enforcement",
|
|
"source_pr": "770",
|
|
"restart_method": "operator reload of the stable control runtime",
|
|
"health_check_proof": "gitea_assess_mcp_namespace_health: healthy",
|
|
"identity_proof": "gitea_whoami: sysadmin / prgs-reviewer",
|
|
"profile_proof": "runtime context: prgs-reviewer",
|
|
"workspace_proof": "process root == canonical root, clean",
|
|
"mutation_capability_proof": "resolve review_pr: allowed",
|
|
"rollback_instructions": "re-promote " + SHA_A,
|
|
}
|
|
record.update(overrides)
|
|
return record
|
|
|
|
def test_complete_record_is_valid(self):
|
|
res = scr.assess_promotion_record(self.complete_record())
|
|
self.assertTrue(res["valid"])
|
|
self.assertEqual(res["missing_fields"], [])
|
|
|
|
def test_promotion_records_previous_and_promoted_shas(self):
|
|
res = scr.assess_promotion_record(
|
|
self.complete_record(previous_runtime_sha="", promoted_runtime_sha=""))
|
|
self.assertFalse(res["valid"])
|
|
self.assertIn("previous_runtime_sha", res["missing_fields"])
|
|
self.assertIn("promoted_runtime_sha", res["missing_fields"])
|
|
|
|
def test_identical_shas_are_not_a_promotion(self):
|
|
res = scr.assess_promotion_record(
|
|
self.complete_record(promoted_runtime_sha=SHA_A))
|
|
self.assertFalse(res["valid"])
|
|
self.assertTrue(
|
|
any("nothing was promoted" in reason for reason in res["reasons"]))
|
|
|
|
def test_missing_rollback_instructions_fail_closed(self):
|
|
res = scr.assess_promotion_record(
|
|
self.complete_record(rollback_instructions=""))
|
|
self.assertFalse(res["valid"])
|
|
self.assertIn("rollback_instructions", res["missing_fields"])
|
|
|
|
def test_empty_record_is_invalid(self):
|
|
self.assertFalse(scr.assess_promotion_record(None)["valid"])
|
|
|
|
|
|
class TestNormalSessionsCannotRestartStableRuntime(unittest.TestCase):
|
|
"""Acceptance criterion 3: normal sessions do not restart the stable MCP."""
|
|
|
|
def test_adr_forbids_kill_restart_and_relaunch(self):
|
|
adr = (
|
|
REPO_ROOT
|
|
/ "docs"
|
|
/ "architecture"
|
|
/ "mcp-stable-control-runtime-policy-adr.md"
|
|
).read_text()
|
|
for phrase in ("Kill the running MCP server process",
|
|
"Restart / relaunch the MCP server process",
|
|
"Relaunch MCP from a development worktree"):
|
|
self.assertIn(phrase, adr, phrase)
|
|
|
|
def test_promotion_runbook_exists_and_lists_every_record_field(self):
|
|
runbook = (
|
|
REPO_ROOT / "docs" / "stable-runtime-promotion-runbook.md"
|
|
).read_text()
|
|
for field in scr.PROMOTION_REQUIRED_FIELDS:
|
|
self.assertIn(field, runbook, field)
|
|
|
|
def test_no_mcp_tool_offers_a_runtime_restart(self):
|
|
server = (REPO_ROOT / "gitea_mcp_server.py").read_text()
|
|
for forbidden in ("def gitea_restart_", "def gitea_kill_"):
|
|
self.assertNotIn(forbidden, server, forbidden)
|
|
|
|
|
|
class TestServerWiring(unittest.TestCase):
|
|
"""The gate is wired into the server's mutation permission path."""
|
|
|
|
def setUp(self):
|
|
import gitea_mcp_server as srv # imported lazily: heavy module
|
|
|
|
self.srv = srv
|
|
|
|
def test_reads_are_never_blocked_by_runtime_mode(self):
|
|
with patch.dict(os.environ, {"GITEA_TEST_FORCE_PRODUCTION_GUARDS": "1"}):
|
|
self.assertEqual(self.srv._runtime_mode_block("gitea.read"), [])
|
|
|
|
def test_gate_is_skipped_under_pure_unit_test_isolation(self):
|
|
# The suite itself runs from a branches/ worktree (dev-test by design);
|
|
# without forced production guards the gate must not fire.
|
|
self.assertEqual(self.srv._runtime_mode_block("gitea.pr.create"), [])
|
|
|
|
def test_dev_worktree_runtime_blocks_mutations_when_guards_forced(self):
|
|
report = stable_report(
|
|
process_root=DEV_WORKTREE_ROOT,
|
|
checkout_branch="feat/issue-615-runtime-mode-enforcement",
|
|
)
|
|
with patch.dict(os.environ, {"GITEA_TEST_FORCE_PRODUCTION_GUARDS": "1"}), \
|
|
patch.object(
|
|
self.srv, "_current_runtime_mode_report", return_value=report):
|
|
reasons = self.srv._runtime_mode_block("gitea.pr.create")
|
|
self.assertTrue(reasons)
|
|
self.assertTrue(any("dev-test" in reason for reason in reasons))
|
|
|
|
def test_stable_runtime_allows_mutations_when_guards_forced(self):
|
|
with patch.dict(os.environ, {"GITEA_TEST_FORCE_PRODUCTION_GUARDS": "1"}), \
|
|
patch.object(
|
|
self.srv,
|
|
"_current_runtime_mode_report",
|
|
return_value=stable_report()):
|
|
self.assertEqual(self.srv._runtime_mode_block("gitea.pr.create"), [])
|
|
|
|
def test_unassessable_runtime_fails_closed(self):
|
|
with patch.dict(os.environ, {"GITEA_TEST_FORCE_PRODUCTION_GUARDS": "1"}), \
|
|
patch.object(
|
|
self.srv,
|
|
"_current_runtime_mode_report",
|
|
side_effect=RuntimeError("boom")):
|
|
reasons = self.srv._runtime_mode_block("gitea.pr.create")
|
|
self.assertTrue(reasons)
|
|
self.assertTrue(any("fail closed" in reason for reason in reasons))
|
|
|
|
def test_live_report_describes_this_checkout(self):
|
|
report = self.srv._current_runtime_mode_report()
|
|
self.assertIn(report["runtime_mode"], scr.VALID_RUNTIME_MODES)
|
|
self.assertEqual(report["mcp_process_root"], self.srv.PROJECT_ROOT)
|
|
|
|
|
|
class TestServerWiringRealDerivation(unittest.TestCase):
|
|
"""Drive the *real* report derivation, not a pre-built fixture (#615 F3).
|
|
|
|
Every other server-wiring test patches ``_current_runtime_mode_report`` with
|
|
a fixture, so the derivation the daemon actually runs was never executed by
|
|
the suite. These tests patch only its *inputs* -- the import-time facts, the
|
|
dirty-file read, and the resolved namespace binding -- and let the real
|
|
function build the report.
|
|
"""
|
|
|
|
TASK_WORKTREE = STABLE_ROOT + "/branches/issue-615-runtime-mode-enforcement"
|
|
|
|
def setUp(self):
|
|
import gitea_mcp_server as srv # imported lazily: heavy module
|
|
|
|
self.srv = srv
|
|
|
|
def _stable_facts(self):
|
|
"""Immutable facts of a promoted stable-control runtime."""
|
|
return {
|
|
"checkout_branch": "master",
|
|
"runtime_head": SHA_A,
|
|
"is_git_checkout": True,
|
|
"dirty_files": [],
|
|
}
|
|
|
|
def _binding(self, *, roots_aligned=True, workspace=None):
|
|
"""A resolved namespace binding, as the server's resolver returns it."""
|
|
return {
|
|
"workspace_path": workspace or self.TASK_WORKTREE,
|
|
"canonical_repo_root": STABLE_ROOT,
|
|
"process_project_root": STABLE_ROOT,
|
|
"roots_aligned": roots_aligned,
|
|
}
|
|
|
|
def _real_derivation(self, *, dirty=None, roots_aligned=True, workspace=None):
|
|
"""Context managers that patch only the inputs, never the derivation."""
|
|
return (
|
|
patch.dict(os.environ, {"GITEA_TEST_FORCE_PRODUCTION_GUARDS": "1"}),
|
|
patch.object(self.srv, "PROJECT_ROOT", STABLE_ROOT),
|
|
patch.object(self.srv, "_STARTUP_RUNTIME_FACTS", self._stable_facts()),
|
|
patch.object(
|
|
self.srv,
|
|
"_resolve_namespace_mutation_context",
|
|
return_value=self._binding(
|
|
roots_aligned=roots_aligned, workspace=workspace),
|
|
),
|
|
patch.object(scr, "observe_dirty_files", return_value=list(dirty or [])),
|
|
)
|
|
|
|
def test_clean_stable_checkout_with_bound_task_worktree_permits_mutation(self):
|
|
# The sanctioned configuration: a clean control checkout on master plus a
|
|
# correctly bound branches/ worktree. Before the F1 fix this failed, because
|
|
# alignment was path equality between the task workspace and the process
|
|
# root, which a branches/ worktree can never satisfy.
|
|
env, root, facts, ctx, dirty = self._real_derivation()
|
|
with env, root, facts, ctx, dirty:
|
|
report = self.srv._current_runtime_mode_report()
|
|
self.assertEqual(report["runtime_mode"], scr.RUNTIME_MODE_STABLE)
|
|
self.assertEqual(report["active_task_workspace"], self.TASK_WORKTREE)
|
|
self.assertTrue(report["workspace_roots_aligned"])
|
|
self.assertTrue(
|
|
report["real_mutations_allowed"], report["mutation_block_reasons"])
|
|
self.assertEqual(self.srv._runtime_mode_block("gitea.pr.create"), [])
|
|
|
|
def test_misaligned_process_and_canonical_roots_fail_closed(self):
|
|
# Alignment keeps its repository-level meaning: the namespace targeting a
|
|
# different repository than the process is installed in is the unsafe case.
|
|
env, root, facts, ctx, dirty = self._real_derivation(roots_aligned=False)
|
|
with env, root, facts, ctx, dirty:
|
|
report = self.srv._current_runtime_mode_report()
|
|
self.assertFalse(report["workspace_roots_aligned"])
|
|
self.assertFalse(report["real_mutations_allowed"])
|
|
reasons = self.srv._runtime_mode_block("gitea.pr.create")
|
|
self.assertTrue(reasons)
|
|
self.assertTrue(any("alignment" in reason for reason in reasons))
|
|
|
|
def test_newly_dirty_task_state_is_detected_after_an_earlier_clean_read(self):
|
|
# A clean read must not license every later mutation: the acceptance
|
|
# criterion 7 dirty blocker has to keep applying for the process lifetime.
|
|
env, root, facts, ctx, dirty = self._real_derivation(dirty=[])
|
|
with env, root, facts, ctx, dirty:
|
|
self.assertTrue(
|
|
self.srv._current_runtime_mode_report()["real_mutations_allowed"])
|
|
self.assertEqual(self.srv._runtime_mode_block("gitea.pr.create"), [])
|
|
|
|
env, root, facts, ctx, dirty = self._real_derivation(
|
|
dirty=["gitea_mcp_server.py"])
|
|
with env, root, facts, ctx, dirty:
|
|
report = self.srv._current_runtime_mode_report()
|
|
self.assertEqual(report["dirty_files"], ["gitea_mcp_server.py"])
|
|
self.assertFalse(report["real_mutations_allowed"])
|
|
self.assertTrue(self.srv._runtime_mode_block("gitea.pr.create"))
|
|
|
|
def test_read_only_refresh_cannot_freeze_a_permissive_mutation_result(self):
|
|
# gitea_get_runtime_context() calls with refresh=True. That read-only call
|
|
# must not seed a cache that a later mutation gate would then trust.
|
|
env, root, facts, ctx, dirty = self._real_derivation(dirty=[])
|
|
with env, root, facts, ctx, dirty:
|
|
self.assertTrue(
|
|
self.srv._current_runtime_mode_report(refresh=True)[
|
|
"real_mutations_allowed"]
|
|
)
|
|
|
|
env, root, facts, ctx, dirty = self._real_derivation(
|
|
dirty=["stable_control_runtime.py"])
|
|
with env, root, facts, ctx, dirty:
|
|
self.assertFalse(
|
|
self.srv._current_runtime_mode_report()["real_mutations_allowed"])
|
|
self.assertTrue(self.srv._runtime_mode_block("gitea.pr.create"))
|
|
|
|
def test_unresolvable_binding_reports_unknown_alignment_never_alignment_proof(self):
|
|
# An unresolvable binding must report alignment as unknown (None), never
|
|
# as True. Only *definite* misalignment blocks: a session with no task
|
|
# binding resolved is the ordinary case, and failing it closed would
|
|
# reintroduce exactly the F1 breakage this change removes.
|
|
env, root, facts, _, dirty = self._real_derivation()
|
|
broken = patch.object(
|
|
self.srv,
|
|
"_resolve_namespace_mutation_context",
|
|
side_effect=RuntimeError("no binding"),
|
|
)
|
|
with env, root, facts, broken, dirty:
|
|
report = self.srv._current_runtime_mode_report()
|
|
self.assertIsNone(report["workspace_roots_aligned"])
|
|
self.assertNotIn(
|
|
scr.BLOCKER_UNSAFE_ALIGNMENT,
|
|
scr.assess_runtime_mutation_gate(report)["blocker_kinds"],
|
|
)
|
|
|
|
|
|
if __name__ == "__main__":
|
|
unittest.main()
|