Compare commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
47bfae07d2 | ||
|
|
f49e781102 | ||
|
|
aab54d4825 | ||
|
|
a09c485fc0 | ||
|
|
8c1d22a658 | ||
|
|
6a56260768 | ||
|
|
2066623986 |
+207
-44
@@ -196,6 +196,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 {
|
||||
@@ -386,6 +438,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 +511,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 +527,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 +580,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)"
|
||||
)
|
||||
elif remote_master_sha and head_sha:
|
||||
h = head_sha.strip().lower()
|
||||
rm = remote_master_sha.strip().lower()
|
||||
if h != rm:
|
||||
resolver_error = (remote_master_sha_error or "").strip() or None
|
||||
if resolver_error:
|
||||
reasons.append(
|
||||
f"control checkout HEAD ({h[:12]}) != live master tip ({rm[:12]})"
|
||||
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]})"
|
||||
)
|
||||
|
||||
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
|
||||
|
||||
@@ -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
|
||||
|
||||
+387
-4
@@ -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)
|
||||
|
||||
@@ -3549,6 +3580,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 "<profile>-<pid>-<hex>" 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).
|
||||
|
||||
@@ -9871,6 +10189,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 = {
|
||||
@@ -10127,6 +10463,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,
|
||||
@@ -10148,6 +10485,14 @@ def gitea_bootstrap_author_issue_worktree(
|
||||
branch_name: Optional custom branch name (must match issue-<N> 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.
|
||||
@@ -10186,6 +10531,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(
|
||||
@@ -10201,9 +10584,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,
|
||||
)
|
||||
|
||||
|
||||
@@ -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()
|
||||
@@ -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()
|
||||
@@ -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 ``<profile>-<pid>-<hex>`` 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()
|
||||
+30
-1
@@ -279,6 +279,7 @@ def assess_root_source_mutation(
|
||||
locked_issue_number: int | None = None,
|
||||
role_kind: str | None = None,
|
||||
mutation_task: str | None = None,
|
||||
bootstrap_assessment: Any | None = None,
|
||||
) -> dict[str, Any]:
|
||||
"""Fail closed for diagnostic/source edits on the control/root checkout.
|
||||
|
||||
@@ -286,6 +287,13 @@ def assess_root_source_mutation(
|
||||
tracked source/test files on the control checkout always block, including
|
||||
temporary/diagnostic/test-only intent.
|
||||
|
||||
#941: ``bootstrap_author_issue_worktree`` is judged by the canonical
|
||||
``create_issue_bootstrap.bootstrap_permits_control_checkout`` decision over
|
||||
*bootstrap_assessment* — the same server-derived evidence the #274 and
|
||||
#604 guards consume — instead of a task-name allowlist local to this
|
||||
module. Evidence that is absent, malformed, wrongly scoped, or bound to
|
||||
another workspace leaves the ordinary block in force.
|
||||
|
||||
#749: ``create_issue`` is a pure remote mutation with no local tree write.
|
||||
When *mutation_task* is create_issue and the control checkout has no dirty
|
||||
source/test files, the missing-worktree signal is suppressed so the
|
||||
@@ -336,6 +344,19 @@ def assess_root_source_mutation(
|
||||
if _cib is not None and _cib.is_create_issue_task(mutation_task):
|
||||
# #749: clean-root create_issue is the sanctioned bootstrap path.
|
||||
create_issue_bootstrap = True
|
||||
elif _cib is not None and _cib.bootstrap_permits_control_checkout(
|
||||
bootstrap_assessment,
|
||||
task=mutation_task,
|
||||
workspace_path=workspace,
|
||||
canonical_repo_root=root,
|
||||
):
|
||||
# #941: the author issue-worktree bootstrap is authorized by the
|
||||
# canonical shared decision over server-derived task-scope
|
||||
# evidence, never by a task-name allowlist kept in this module.
|
||||
# The predicate fails closed on missing, malformed, cross-scope,
|
||||
# dirty, drifted, or wrongly bound evidence, so this arm cannot
|
||||
# widen the waiver beyond the one sanctioned bootstrap task.
|
||||
create_issue_bootstrap = True
|
||||
else:
|
||||
# Explicit missing-worktree signal for force-on author entrypoints.
|
||||
reasons.append(
|
||||
@@ -393,8 +414,15 @@ def assess_production_mutation_guards(
|
||||
require_author_lock: bool = False,
|
||||
in_test_mode: bool = False,
|
||||
mutation_task: str | None = None,
|
||||
bootstrap_assessment: Any | None = None,
|
||||
) -> dict[str, Any]:
|
||||
"""Compose root + scope production guards when they must be active (#683)."""
|
||||
"""Compose root + scope production guards when they must be active (#683).
|
||||
|
||||
#941: *bootstrap_assessment* is the server-derived author-bootstrap
|
||||
evidence, forwarded unchanged to :func:`assess_root_source_mutation` so
|
||||
this guard reaches the same canonical decision as the #274 and #604
|
||||
guards. Omitting it preserves the pre-existing behaviour.
|
||||
"""
|
||||
if not production_guards_active(in_test_mode=in_test_mode):
|
||||
return {
|
||||
"proven": True,
|
||||
@@ -414,6 +442,7 @@ def assess_production_mutation_guards(
|
||||
locked_issue_number=locked_issue_number,
|
||||
role_kind=role_kind,
|
||||
mutation_task=mutation_task,
|
||||
bootstrap_assessment=bootstrap_assessment,
|
||||
)
|
||||
if root_assess["block"]:
|
||||
return {**root_assess, "skipped": False}
|
||||
|
||||
Reference in New Issue
Block a user