Author mutation tools now resolve workspace via explicit worktree_path,
env bindings, or the active author issue lock — never silent fallback to
the control checkout/master. Missing configured bindings fail closed with
operator recovery; create_issue and create_issue_comment agree.
Recovered onto 0568f44 from preserved candidate cbf56ccd (AUTHOR_RECOVERY).
LLM_LOCK_ID=author-618-recovery-508eb3162d01-1784569919
Co-Authored-By: Claude Opus 4.8 (1M context) <[email protected]>
466 lines
18 KiB
Python
466 lines
18 KiB
Python
"""Namespace-scoped MCP workspace binding (#510).
|
|
|
|
Each role namespace (author, reviewer, merger, reconciler) resolves its own
|
|
active task workspace. Foreign role worktree environment variables must not
|
|
poison workspace purity checks in another namespace.
|
|
"""
|
|
|
|
from __future__ import annotations
|
|
|
|
import os
|
|
|
|
import author_mutation_worktree as amw
|
|
|
|
ACTIVE_WORKTREE_ENV = amw.ACTIVE_WORKTREE_ENV
|
|
AUTHOR_WORKTREE_ENV = amw.AUTHOR_WORKTREE_ENV
|
|
REVIEWER_WORKTREE_ENV = "GITEA_REVIEWER_WORKTREE"
|
|
MERGER_WORKTREE_ENV = "GITEA_MERGER_WORKTREE"
|
|
RECONCILER_WORKTREE_ENV = "GITEA_RECONCILER_WORKTREE"
|
|
|
|
ROLE_WORKTREE_ENVS: dict[str, str] = {
|
|
"author": AUTHOR_WORKTREE_ENV,
|
|
"reviewer": REVIEWER_WORKTREE_ENV,
|
|
"merger": MERGER_WORKTREE_ENV,
|
|
"reconciler": RECONCILER_WORKTREE_ENV,
|
|
}
|
|
|
|
NON_AUTHOR_ROLES = frozenset({"reviewer", "merger", "reconciler"})
|
|
|
|
|
|
def normalize_role_kind(
|
|
role_kind: str | None,
|
|
*,
|
|
profile_name: str | None = None,
|
|
) -> str:
|
|
"""Map profile/task role to a workspace namespace key."""
|
|
role = (role_kind or "author").strip().lower()
|
|
profile = (profile_name or "").strip().lower()
|
|
if role == "reviewer" and "merger" in profile:
|
|
return "merger"
|
|
if role in ROLE_WORKTREE_ENVS:
|
|
return role
|
|
return "author"
|
|
|
|
|
|
def _env_value(env: dict[str, str] | os._Environ, key: str) -> str | None:
|
|
text = (env.get(key) or "").strip()
|
|
return text or None
|
|
|
|
|
|
def resolve_namespace_workspace(
|
|
*,
|
|
role_kind: str,
|
|
worktree_path: str | None = None,
|
|
worktree: str | None = None,
|
|
process_project_root: str,
|
|
env: dict[str, str] | os._Environ | None = None,
|
|
session_lease_worktree: str | None = None,
|
|
session_lock_worktree: str | None = None,
|
|
profile_name: str | None = None,
|
|
demotions: list[str] | None = None,
|
|
verify_paths: bool = False,
|
|
durable_author_result: dict | None = None,
|
|
) -> tuple[str, str]:
|
|
"""Return ``(resolved_path, binding_source)`` for *role_kind*.
|
|
|
|
With *verify_paths*, env-sourced candidates whose path no longer exists
|
|
are demoted (#702) for non-author roles: a binding to a deleted worktree
|
|
can never name a valid task workspace, so resolution falls through to the
|
|
next candidate. Explicit arguments are never demoted — a caller-declared
|
|
path must fail loudly downstream rather than silently rebind. Demotion
|
|
notes are appended to *demotions* when provided.
|
|
|
|
Author role (#618): never demotes a missing configured binding to the
|
|
control checkout. When *verify_paths* is true, resolution goes through
|
|
:func:`author_mutation_worktree.resolve_durable_author_worktree` so
|
|
mutations either use an explicit validated worktree, derive from the
|
|
active author issue lock, or fail closed. Runtime-context and mutation
|
|
guards resolve through :func:`resolve_namespace_mutation_context`, which
|
|
always verifies.
|
|
"""
|
|
env_map = env if env is not None else os.environ
|
|
role = normalize_role_kind(role_kind, profile_name=profile_name)
|
|
role_env_key = ROLE_WORKTREE_ENVS[role]
|
|
|
|
# #618: durable author resolution — no silent control/master fallback.
|
|
if role == "author" and verify_paths:
|
|
durable = durable_author_result
|
|
if durable is None:
|
|
durable = amw.resolve_durable_author_worktree(
|
|
worktree_path=worktree_path,
|
|
worktree=worktree,
|
|
process_project_root=process_project_root,
|
|
active_worktree_env=_env_value(env_map, ACTIVE_WORKTREE_ENV),
|
|
author_worktree_env=_env_value(env_map, AUTHOR_WORKTREE_ENV),
|
|
session_lock_worktree=session_lock_worktree,
|
|
profile_name=profile_name,
|
|
# Path selection only here; full validation is re-run in
|
|
# resolve_namespace_mutation_context with the canonical root.
|
|
validate=False,
|
|
)
|
|
workspace = durable.get("workspace_path") or os.path.realpath(
|
|
process_project_root
|
|
)
|
|
source = durable.get("workspace_binding_source") or "no author worktree binding"
|
|
if demotions is not None and durable.get("bound_worktree_missing"):
|
|
demotions.append(
|
|
f"{source} '{workspace}' not demoted: {amw.BOUND_WORKTREE_MISSING_MESSAGE}"
|
|
)
|
|
return workspace, source
|
|
|
|
for candidate, source, env_sourced in (
|
|
(worktree_path, "worktree_path argument", False),
|
|
(worktree, "worktree argument", False),
|
|
(_env_value(env_map, ACTIVE_WORKTREE_ENV),
|
|
f"{ACTIVE_WORKTREE_ENV} environment variable", True),
|
|
(_env_value(env_map, role_env_key),
|
|
f"{role_env_key} environment variable", True),
|
|
(session_lease_worktree if role in {"reviewer", "merger"} else None,
|
|
"reviewer PR lease worktree", False),
|
|
# Author lock derivation is handled by the durable path above when
|
|
# verify_paths is true; when verify_paths is false, surface the lock
|
|
# path as a non-demoted candidate so tooling can inspect it.
|
|
(session_lock_worktree if role == "author" else None,
|
|
"active author issue lock worktree", False),
|
|
):
|
|
text = (candidate or "").strip()
|
|
if not text:
|
|
continue
|
|
real = os.path.realpath(os.path.abspath(text))
|
|
if verify_paths and env_sourced and not os.path.isdir(real):
|
|
if demotions is not None:
|
|
demotions.append(
|
|
f"{source} '{real}' demoted: path no longer exists "
|
|
"(stale binding, #702)"
|
|
)
|
|
continue
|
|
return real, source
|
|
|
|
return os.path.realpath(process_project_root), "MCP server process root (default)"
|
|
|
|
|
|
def resolve_namespace_mutation_context(
|
|
*,
|
|
role_kind: str,
|
|
worktree_path: str | None,
|
|
process_project_root: str,
|
|
env: dict[str, str] | os._Environ | None = None,
|
|
session_lease_worktree: str | None = None,
|
|
session_lock_worktree: str | None = None,
|
|
worktree: str | None = None,
|
|
profile_name: str | None = None,
|
|
configured_canonical_root: str | None = None,
|
|
) -> dict:
|
|
"""Shared workspace resolution for runtime_context and mutation guards.
|
|
|
|
When *configured_canonical_root* is supplied (a cross-repository namespace
|
|
bound to an external target repository, #706), the canonical repository root
|
|
is that configured target rather than the MCP install checkout. This keeps
|
|
the branches-only / worktree-membership guards (#274) evaluating against the
|
|
repository the namespace actually mutates. Without it the single-repo
|
|
default is preserved: the canonical root follows the process checkout.
|
|
|
|
Author role (#618): uses durable worktree resolution (explicit path, env,
|
|
or active issue lock) and never silently falls back to the control checkout.
|
|
"""
|
|
demotions: list[str] = []
|
|
env_map = env if env is not None else os.environ
|
|
process_root = os.path.realpath(process_project_root)
|
|
role = normalize_role_kind(role_kind, profile_name=profile_name)
|
|
configured = (configured_canonical_root or "").strip()
|
|
if configured:
|
|
canonical_root = os.path.realpath(configured)
|
|
else:
|
|
canonical_root = amw.resolve_canonical_repo_root(process_root, process_root)
|
|
|
|
durable: dict | None = None
|
|
if role == "author":
|
|
durable = amw.resolve_durable_author_worktree(
|
|
worktree_path=worktree_path,
|
|
worktree=worktree,
|
|
process_project_root=process_root,
|
|
active_worktree_env=_env_value(env_map, ACTIVE_WORKTREE_ENV),
|
|
author_worktree_env=_env_value(env_map, AUTHOR_WORKTREE_ENV),
|
|
session_lock_worktree=session_lock_worktree,
|
|
canonical_repo_root=canonical_root,
|
|
profile_name=profile_name,
|
|
validate=True,
|
|
)
|
|
workspace = durable["workspace_path"]
|
|
binding_source = durable["workspace_binding_source"]
|
|
if durable.get("bound_worktree_missing"):
|
|
demotions.append(
|
|
f"{binding_source} '{workspace}' not demoted: "
|
|
f"{amw.BOUND_WORKTREE_MISSING_MESSAGE}"
|
|
)
|
|
else:
|
|
workspace, binding_source = resolve_namespace_workspace(
|
|
role_kind=role,
|
|
worktree_path=worktree_path,
|
|
worktree=worktree,
|
|
process_project_root=process_project_root,
|
|
env=env,
|
|
session_lease_worktree=session_lease_worktree,
|
|
session_lock_worktree=session_lock_worktree,
|
|
profile_name=profile_name,
|
|
demotions=demotions,
|
|
verify_paths=True,
|
|
)
|
|
|
|
pollution = assess_foreign_role_worktree_pollution(
|
|
role_kind=role,
|
|
resolved_workspace=workspace,
|
|
binding_source=binding_source,
|
|
env=env,
|
|
profile_name=profile_name,
|
|
)
|
|
result = {
|
|
"workspace_path": workspace,
|
|
"workspace_binding_source": binding_source,
|
|
"workspace_role_kind": role,
|
|
"ignored_bindings": demotions + (pollution.get("ignored_bindings") or []),
|
|
"process_project_root": process_root,
|
|
"canonical_repo_root": canonical_root,
|
|
"roots_aligned": canonical_root == process_root,
|
|
}
|
|
if durable is not None:
|
|
result["author_worktree_resolution"] = durable
|
|
result["bound_worktree_missing"] = bool(durable.get("bound_worktree_missing"))
|
|
result["path_exists"] = durable.get("path_exists")
|
|
result["in_git_worktree_list"] = durable.get("in_git_worktree_list")
|
|
result["inspected_git_root"] = durable.get("inspected_git_root")
|
|
result["author_worktree_block"] = bool(durable.get("block"))
|
|
result["author_worktree_reasons"] = list(durable.get("reasons") or [])
|
|
result["author_worktree_blocker_kind"] = durable.get("blocker_kind")
|
|
result["operator_recovery"] = durable.get("operator_recovery")
|
|
return result
|
|
|
|
|
|
def assess_foreign_role_worktree_pollution(
|
|
*,
|
|
role_kind: str,
|
|
resolved_workspace: str,
|
|
binding_source: str,
|
|
env: dict[str, str] | os._Environ | None = None,
|
|
profile_name: str | None = None,
|
|
) -> dict:
|
|
"""Detect when a foreign role env would have hijacked workspace binding."""
|
|
env_map = env if env is not None else os.environ
|
|
role = normalize_role_kind(role_kind, profile_name=profile_name)
|
|
if role == "author":
|
|
return {"would_pollute": False, "ignored_bindings": []}
|
|
|
|
ignored: list[str] = []
|
|
author_path = _env_value(env_map, AUTHOR_WORKTREE_ENV)
|
|
if author_path:
|
|
author_real = os.path.realpath(os.path.abspath(author_path))
|
|
resolved_real = os.path.realpath(resolved_workspace)
|
|
if author_real != resolved_real and binding_source != f"{AUTHOR_WORKTREE_ENV} environment variable":
|
|
ignored.append(
|
|
f"{AUTHOR_WORKTREE_ENV}={author_real} (ignored for {role} namespace)"
|
|
)
|
|
return {
|
|
"would_pollute": bool(ignored),
|
|
"ignored_bindings": ignored,
|
|
}
|
|
|
|
|
|
def assess_metadata_only_worktree_binding(
|
|
*,
|
|
role_kind: str,
|
|
declared_worktree_path: str | None,
|
|
mutation_workspace: str,
|
|
process_project_root: str,
|
|
profile_name: str | None = None,
|
|
) -> dict:
|
|
"""Fail closed when declared worktree_path would not redirect mutations."""
|
|
declared = (declared_worktree_path or "").strip()
|
|
process_root = os.path.realpath(process_project_root)
|
|
mutation_root = os.path.realpath(mutation_workspace)
|
|
role = normalize_role_kind(role_kind, profile_name=profile_name)
|
|
if not declared:
|
|
return {"block": False, "reasons": [], "metadata_only": False}
|
|
|
|
declared_root = os.path.realpath(os.path.abspath(declared))
|
|
if declared_root == mutation_root:
|
|
return {"block": False, "reasons": [], "metadata_only": False}
|
|
|
|
if declared_root != process_root and mutation_root == process_root:
|
|
return {
|
|
"block": True,
|
|
"metadata_only": True,
|
|
"reasons": [
|
|
f"worktree_path is metadata-only for {role} mutations: preflight "
|
|
f"inspected '{declared_root}' but mutation tools would still "
|
|
f"validate MCP server process root '{process_root}'"
|
|
],
|
|
"declared_worktree_path": declared_root,
|
|
"mutation_workspace": mutation_root,
|
|
"process_project_root": process_root,
|
|
}
|
|
|
|
return {"block": False, "reasons": [], "metadata_only": False}
|
|
|
|
|
|
def format_namespace_workspace_binding_error(
|
|
*,
|
|
role_kind: str,
|
|
workspace_path: str,
|
|
binding_source: str,
|
|
reasons: list[str] | None = None,
|
|
ignored_bindings: list[str] | None = None,
|
|
dirty_files: list[str] | None = None,
|
|
operator_recovery: str | None = None,
|
|
) -> str:
|
|
"""Canonical error when namespace workspace binding blocks mutations."""
|
|
role = normalize_role_kind(role_kind)
|
|
reason_list = list(reasons or [])
|
|
# #618: prefer the durable author missing-worktree message when present.
|
|
if role == "author" and any(
|
|
amw.BOUND_WORKTREE_MISSING_MESSAGE in r for r in reason_list
|
|
):
|
|
return amw.format_bound_worktree_missing_error(
|
|
{
|
|
"reasons": reason_list,
|
|
"binding_source": binding_source,
|
|
"configured_path": workspace_path,
|
|
"role_kind": role,
|
|
"operator_recovery": operator_recovery
|
|
or amw.OPERATOR_RECOVERY_RECREATE_REPOINT,
|
|
}
|
|
)
|
|
try:
|
|
workspace = os.path.realpath(workspace_path)
|
|
except OSError:
|
|
workspace = workspace_path
|
|
parts = [
|
|
f"Namespace workspace binding blocked ({role} namespace, #510): "
|
|
f"resolved workspace '{workspace}' via {binding_source}."
|
|
]
|
|
if ignored_bindings:
|
|
parts.append(
|
|
"Foreign role bindings ignored: " + "; ".join(ignored_bindings) + "."
|
|
)
|
|
if dirty_files:
|
|
parts.append(
|
|
"Dirty tracked files in active task workspace: "
|
|
+ ", ".join(dirty_files)
|
|
+ "."
|
|
)
|
|
if reason_list:
|
|
parts.append("Details: " + "; ".join(reason_list) + ".")
|
|
if operator_recovery:
|
|
parts.append(f"Operator recovery: {operator_recovery}")
|
|
else:
|
|
parts.append(
|
|
"Remediation: reconnect or relaunch the MCP server from a clean dedicated "
|
|
f"branches/ {role} worktree, set "
|
|
f"{ROLE_WORKTREE_ENVS.get(role, ACTIVE_WORKTREE_ENV)} or {ACTIVE_WORKTREE_ENV} "
|
|
"to that path, or pass worktree_path on mutation tools. Do not clean or "
|
|
"reset foreign role worktrees to unblock this namespace."
|
|
)
|
|
return " ".join(parts)
|
|
|
|
|
|
def assess_namespace_mutation_workspace(
|
|
*,
|
|
role_kind: str,
|
|
worktree_path: str | None,
|
|
worktree: str | None,
|
|
process_project_root: str,
|
|
env: dict[str, str] | os._Environ | None = None,
|
|
session_lease_worktree: str | None = None,
|
|
session_lock_worktree: str | None = None,
|
|
profile_name: str | None = None,
|
|
current_branch: str | None = None,
|
|
configured_canonical_root: str | None = None,
|
|
) -> dict:
|
|
"""Evaluate namespace workspace binding before preflight/mutation."""
|
|
ctx = resolve_namespace_mutation_context(
|
|
role_kind=role_kind,
|
|
worktree_path=worktree_path,
|
|
worktree=worktree,
|
|
process_project_root=process_project_root,
|
|
env=env,
|
|
session_lease_worktree=session_lease_worktree,
|
|
session_lock_worktree=session_lock_worktree,
|
|
profile_name=profile_name,
|
|
configured_canonical_root=configured_canonical_root,
|
|
)
|
|
mutation_workspace = ctx["workspace_path"]
|
|
binding_source = ctx["workspace_binding_source"]
|
|
role = ctx["workspace_role_kind"]
|
|
process_root = ctx["process_project_root"]
|
|
|
|
metadata = assess_metadata_only_worktree_binding(
|
|
role_kind=role,
|
|
declared_worktree_path=worktree_path,
|
|
mutation_workspace=mutation_workspace,
|
|
process_project_root=process_root,
|
|
profile_name=profile_name,
|
|
)
|
|
pollution = assess_foreign_role_worktree_pollution(
|
|
role_kind=role,
|
|
resolved_workspace=mutation_workspace,
|
|
binding_source=binding_source,
|
|
env=env,
|
|
profile_name=profile_name,
|
|
)
|
|
|
|
reasons = list(metadata.get("reasons") or [])
|
|
operator_recovery = ctx.get("operator_recovery")
|
|
if role == "author":
|
|
# #618 durable resolution already validated existence, membership,
|
|
# branches/, lock ownership, and traversal safety when present.
|
|
durable_reasons = list(ctx.get("author_worktree_reasons") or [])
|
|
if durable_reasons:
|
|
reasons.extend(durable_reasons)
|
|
elif ctx.get("author_worktree_block"):
|
|
reasons.append(amw.BOUND_WORKTREE_MISSING_MESSAGE)
|
|
else:
|
|
branches = amw.assess_author_mutation_worktree(
|
|
workspace_path=mutation_workspace,
|
|
project_root=ctx["canonical_repo_root"],
|
|
current_branch=current_branch,
|
|
)
|
|
if branches["block"]:
|
|
reasons.extend(branches["reasons"])
|
|
elif (
|
|
role == "reviewer"
|
|
and mutation_workspace == process_root
|
|
and not amw.is_path_under_branches(mutation_workspace, ctx["canonical_repo_root"])
|
|
):
|
|
reasons.append(
|
|
f"{role} mutation blocked: workspace is the stable control checkout; "
|
|
f"create or reconnect to a session-owned worktree under branches/ "
|
|
f"or set {ROLE_WORKTREE_ENVS[role]} / {ACTIVE_WORKTREE_ENV}"
|
|
)
|
|
elif (
|
|
role in {"reviewer", "merger"}
|
|
and mutation_workspace != process_root
|
|
and not amw.is_path_under_branches(mutation_workspace, ctx["canonical_repo_root"])
|
|
):
|
|
reasons.append(
|
|
f"{role} mutation blocked: workspace '{mutation_workspace}' is not under "
|
|
f"'{ctx['canonical_repo_root']}/branches/'"
|
|
)
|
|
|
|
block = bool(reasons)
|
|
return {
|
|
"block": block,
|
|
"reasons": reasons,
|
|
"mutation_workspace": mutation_workspace,
|
|
"workspace_binding_source": binding_source,
|
|
"workspace_role_kind": role,
|
|
"process_project_root": process_root,
|
|
"canonical_repo_root": ctx["canonical_repo_root"],
|
|
"metadata_only": metadata.get("metadata_only", False),
|
|
"declared_worktree_path": metadata.get("declared_worktree_path"),
|
|
"ignored_bindings": pollution.get("ignored_bindings") or [],
|
|
"bound_worktree_missing": bool(ctx.get("bound_worktree_missing")),
|
|
"path_exists": ctx.get("path_exists"),
|
|
"in_git_worktree_list": ctx.get("in_git_worktree_list"),
|
|
"inspected_git_root": ctx.get("inspected_git_root"),
|
|
"operator_recovery": operator_recovery,
|
|
"blocker_kind": ctx.get("author_worktree_blocker_kind"),
|
|
} |