Fixes the six defects from the formal REQUEST_CHANGES review at
889931d553:
- F1: run sanctioned stale-binding recovery in
gitea_resolve_task_capability BEFORE the terminal launcher probe, so a
worktree removed mid-session can no longer wedge mutation resolution on
'missing cwd' before recovery runs. The fail-closed probe block now also
carries the binding classification evidence.
- F2: _resolve_preflight_workspace_path resolves with the same
verify_paths existence checks as the canonical mutation context, and
_get_workspace_porcelain returns a synthetic tracked-dirty sentinel when
the workspace is missing or git fails — an uninspectable workspace can
never read as clean/empty porcelain.
- F3: the orphaned_expired_superseded_head cleanup matrix now requires
affirmative safe worktree evidence (worktree_clean=true or
worktree_exists=false), aligned with the sibling orphaned_owner_missing
gate and the diagnosis path; unknown evidence fails closed.
- F4: reviewer-session-lease shadows and stale-binding audit records are
recovery-critical kinds exempt from the 4h session-state TTL; they
persist until a sanctioned clear terminally reconciles them.
- F5: session-lease shadows are keyed by lease session id (collision-safe
identity) with a list_states enumeration API; concurrent same-profile
sessions can no longer overwrite or misattribute each other's crash
evidence. Legacy single-slot records remain readable.
- F6: the durable audit record is persisted (status=pending) BEFORE the
environment binding is cleared; if audit persistence fails the clear
does not happen and the failure is reported explicitly.
30 new regression tests cover deleted-worktree recovery ordering, missing/
deleted/symlinked/mid-evaluation path changes, unknown-evidence cleanup,
TTL boundary (before/at/after), interleaved and reconnected concurrent
sessions, and audit-write failure/retry/idempotence/ordering.
Issue #704 dotenv load-path prevention is intentionally NOT included.
Full suite: 2695 passed, 6 skipped, 161 subtests passed.
Co-Authored-By: Claude Fable 5 <[email protected]>
486 lines
16 KiB
Python
486 lines
16 KiB
Python
"""Durable MCP session validation state shared across daemon process pools (#559).
|
|
|
|
The IDE often routes sequential MCP tool calls to different daemon processes.
|
|
Session-scoped proofs (workflow load, review decision lock) must therefore
|
|
survive process boundaries while remaining fail-closed against spoofing.
|
|
|
|
Security notes (extends #211):
|
|
- Never store under host-global ``/tmp`` (world-writable, spoofable).
|
|
- Default root is ``~/.cache/gitea-tools/session-state`` (mode ``0o700``).
|
|
- Files are written atomically with mode ``0o600``.
|
|
- Records are keyed by remote + org + repo + profile identity, not by PID.
|
|
- TTL prevents indefinitely stale reuse across unrelated sessions.
|
|
"""
|
|
|
|
from __future__ import annotations
|
|
|
|
import fcntl
|
|
import json
|
|
import os
|
|
import re
|
|
import tempfile
|
|
from contextlib import contextmanager
|
|
from datetime import datetime, timedelta, timezone
|
|
from typing import Any
|
|
|
|
STATE_DIR_ENV = "GITEA_MCP_SESSION_STATE_DIR"
|
|
DEFAULT_STATE_DIR = os.path.expanduser("~/.cache/gitea-tools/session-state")
|
|
TTL_HOURS_ENV = "GITEA_MCP_SESSION_STATE_TTL_HOURS"
|
|
DEFAULT_TTL_HOURS = 4.0
|
|
|
|
KIND_WORKFLOW_LOAD = "review_workflow_load"
|
|
KIND_DECISION_LOCK = "review_decision_lock"
|
|
KIND_REVIEW_DRAFT = "review_draft"
|
|
# Durable marker set when a worker session attempts a direct stable-branch push
|
|
# or a root-checkout local commit (#671). Keyed per profile identity like the
|
|
# other session proofs; a contaminated session fails closed on gated mutations
|
|
# until a reconciler audits and clears it.
|
|
KIND_STABLE_BRANCH_CONTAMINATION = "stable_branch_contamination"
|
|
# Durable shadow of the in-memory reviewer session lease (#702). Written on
|
|
# every sanctioned record/heartbeat and removed on sanctioned clear, so a
|
|
# daemon that dies without teardown leaves provable orphan evidence (owner
|
|
# pid + session id) for the guarded lease-cleanup path. Never a substitute
|
|
# for the in-session lease: mutation gates ignore it.
|
|
KIND_REVIEWER_SESSION_LEASE = "reviewer_session_lease"
|
|
# Durable audit record written whenever the daemon's sanctioned stale-binding
|
|
# recovery clears an inherited GITEA_ACTIVE_WORKTREE binding (#702).
|
|
KIND_STALE_BINDING_RECOVERY = "stale_binding_recovery"
|
|
|
|
# Kinds that carry recovery-critical crash-orphan evidence (#702). They are
|
|
# exempt from TTL expiry: a crashed daemon can never refresh its own record,
|
|
# so a wall-clock TTL would silently destroy the only owner-exit proof the
|
|
# guarded cleanup path accepts. These records live until a sanctioned clear
|
|
# terminally reconciles them (durable lifecycle), never until a timer fires.
|
|
RECOVERY_CRITICAL_KINDS = frozenset({
|
|
KIND_REVIEWER_SESSION_LEASE,
|
|
KIND_STALE_BINDING_RECOVERY,
|
|
})
|
|
|
|
|
|
|
|
_SAFE_SEGMENT_RE = re.compile(r"[^A-Za-z0-9._+-]+")
|
|
SESSION_PROFILE_LOCK_ENV = "GITEA_SESSION_PROFILE_LOCK"
|
|
|
|
|
|
def default_state_dir() -> str:
|
|
raw = (os.environ.get(STATE_DIR_ENV) or DEFAULT_STATE_DIR).strip()
|
|
return raw or DEFAULT_STATE_DIR
|
|
|
|
|
|
def ttl_hours() -> float:
|
|
raw = (os.environ.get(TTL_HOURS_ENV) or "").strip()
|
|
if not raw:
|
|
return DEFAULT_TTL_HOURS
|
|
try:
|
|
value = float(raw)
|
|
except ValueError:
|
|
return DEFAULT_TTL_HOURS
|
|
return value if value > 0 else DEFAULT_TTL_HOURS
|
|
|
|
|
|
def _sanitize_segment(value: str) -> str:
|
|
text = (value or "").strip()
|
|
if not text:
|
|
return "_"
|
|
return _SAFE_SEGMENT_RE.sub("_", text)
|
|
|
|
|
|
def current_profile_identity(
|
|
profile_name: str | None = None,
|
|
session_profile_lock: str | None = None,
|
|
profile_identity: str | None = None,
|
|
) -> str:
|
|
"""Resolve the session profile identity used as the durable key."""
|
|
env_lock = (os.environ.get(SESSION_PROFILE_LOCK_ENV) or "").strip()
|
|
explicit = (profile_identity or session_profile_lock or "").strip()
|
|
lock = (explicit or env_lock or "").strip()
|
|
name = (profile_name or "").strip()
|
|
return lock or name or "unknown-profile"
|
|
|
|
|
|
def state_key(
|
|
*,
|
|
kind: str,
|
|
remote: str | None = None,
|
|
org: str | None = None,
|
|
repo: str | None = None,
|
|
profile_identity: str | None = None,
|
|
instance_id: str | None = None,
|
|
) -> str:
|
|
"""Build durable filename key.
|
|
|
|
Session proofs are one-active-per-profile (workflow load / decision lock),
|
|
so the primary key is kind + profile identity. Remote/org/repo are stored
|
|
inside the payload and validated on load (#559), which lets a later daemon
|
|
process recover state without already knowing the remote argument.
|
|
|
|
*instance_id* extends the key for kinds where one-per-profile collides —
|
|
concurrent same-profile sessions each own their reviewer-lease shadow
|
|
(#702 F5), keyed by their lease session id so a later heartbeat can never
|
|
overwrite or misattribute another session's crash evidence.
|
|
"""
|
|
# Keep remote/org/repo parameters for API stability / future kinds; they are
|
|
# intentionally not part of the filename for session-scoped proofs.
|
|
_ = (remote, org, repo)
|
|
parts = [kind, profile_identity or "unknown-profile"]
|
|
instance = (instance_id or "").strip()
|
|
if instance:
|
|
parts.append(instance)
|
|
return "-".join(_sanitize_segment(part) for part in parts)
|
|
|
|
|
|
def state_file_path(
|
|
*,
|
|
kind: str,
|
|
remote: str | None = None,
|
|
org: str | None = None,
|
|
repo: str | None = None,
|
|
profile_identity: str | None = None,
|
|
state_dir: str | None = None,
|
|
instance_id: str | None = None,
|
|
) -> str:
|
|
root = (state_dir or default_state_dir()).strip()
|
|
name = state_key(
|
|
kind=kind,
|
|
remote=remote,
|
|
org=org,
|
|
repo=repo,
|
|
profile_identity=profile_identity,
|
|
instance_id=instance_id,
|
|
)
|
|
return os.path.join(root, f"{name}.json")
|
|
|
|
|
|
def _ensure_state_dir(state_dir: str | None = None) -> str:
|
|
root = (state_dir or default_state_dir()).strip()
|
|
os.makedirs(root, mode=0o700, exist_ok=True)
|
|
return root
|
|
|
|
|
|
def _now_utc() -> datetime:
|
|
return datetime.now(timezone.utc)
|
|
|
|
|
|
def _parse_iso(value: str | None) -> datetime | None:
|
|
text = (value or "").strip()
|
|
if not text:
|
|
return None
|
|
if text.endswith("Z"):
|
|
text = text[:-1] + "+00:00"
|
|
try:
|
|
parsed = datetime.fromisoformat(text)
|
|
except ValueError:
|
|
return None
|
|
if parsed.tzinfo is None:
|
|
return parsed.replace(tzinfo=timezone.utc)
|
|
return parsed.astimezone(timezone.utc)
|
|
|
|
|
|
@contextmanager
|
|
def _exclusive_file_lock(lock_path: str):
|
|
os.makedirs(os.path.dirname(lock_path) or ".", exist_ok=True)
|
|
fd = os.open(lock_path, os.O_CREAT | os.O_RDWR, 0o600)
|
|
try:
|
|
fcntl.flock(fd, fcntl.LOCK_EX)
|
|
yield fd
|
|
finally:
|
|
try:
|
|
fcntl.flock(fd, fcntl.LOCK_UN)
|
|
finally:
|
|
os.close(fd)
|
|
|
|
|
|
def _read_json(path: str) -> dict[str, Any] | None:
|
|
if not path or not os.path.exists(path):
|
|
return None
|
|
try:
|
|
with open(path, encoding="utf-8") as handle:
|
|
data = json.load(handle)
|
|
except (OSError, json.JSONDecodeError):
|
|
return None
|
|
return data if isinstance(data, dict) else None
|
|
|
|
|
|
def _write_json(path: str, data: dict[str, Any]) -> None:
|
|
parent = os.path.dirname(path) or "."
|
|
os.makedirs(parent, mode=0o700, exist_ok=True)
|
|
payload = json.dumps(data, indent=2, sort_keys=True) + "\n"
|
|
fd, temp_path = tempfile.mkstemp(prefix=".session-", suffix=".json", dir=parent)
|
|
try:
|
|
with os.fdopen(fd, "w", encoding="utf-8") as handle:
|
|
handle.write(payload)
|
|
handle.flush()
|
|
os.fsync(handle.fileno())
|
|
os.chmod(temp_path, 0o600)
|
|
os.replace(temp_path, path)
|
|
try:
|
|
os.chmod(path, 0o600)
|
|
except OSError:
|
|
pass
|
|
finally:
|
|
if os.path.exists(temp_path):
|
|
try:
|
|
os.remove(temp_path)
|
|
except OSError:
|
|
pass
|
|
|
|
|
|
def identity_match_reasons(
|
|
record: dict[str, Any] | None,
|
|
*,
|
|
remote: str | None = None,
|
|
org: str | None = None,
|
|
repo: str | None = None,
|
|
profile_identity: str | None = None,
|
|
) -> list[str]:
|
|
"""Return fail-closed reasons when durable record identity does not match."""
|
|
if record is None:
|
|
return []
|
|
reasons: list[str] = []
|
|
expected_profile = current_profile_identity(profile_identity=profile_identity)
|
|
stored_profile = (
|
|
(record.get("session_profile_lock") or record.get("profile_identity") or "")
|
|
.strip()
|
|
)
|
|
if stored_profile and expected_profile and stored_profile != expected_profile:
|
|
if expected_profile != "unknown-profile":
|
|
reasons.append(
|
|
"session state profile identity mismatch "
|
|
f"(stored={stored_profile!r}, active={expected_profile!r}; fail closed)"
|
|
)
|
|
|
|
for field, expected in (
|
|
("remote", remote),
|
|
("org", org),
|
|
("repo", repo),
|
|
):
|
|
want = (expected or "").strip()
|
|
have = (str(record.get(field) or "")).strip()
|
|
if want and have and want != have:
|
|
reasons.append(
|
|
f"session state {field} mismatch "
|
|
f"(stored={have!r}, expected={want!r}; fail closed)"
|
|
)
|
|
|
|
recorded_at = _parse_iso(record.get("recorded_at") or record.get("updated_at"))
|
|
if recorded_at is None:
|
|
reasons.append("session state missing recorded_at timestamp (fail closed)")
|
|
else:
|
|
age = _now_utc() - recorded_at
|
|
record_kind = (str(record.get("kind") or "")).strip()
|
|
if age > timedelta(hours=ttl_hours()):
|
|
if record_kind not in RECOVERY_CRITICAL_KINDS:
|
|
reasons.append(
|
|
f"session state expired after {ttl_hours():g}h (fail closed)"
|
|
)
|
|
if age < timedelta(0):
|
|
reasons.append("session state recorded_at is in the future (fail closed)")
|
|
return reasons
|
|
|
|
|
|
def load_state(
|
|
*,
|
|
kind: str,
|
|
remote: str | None = None,
|
|
org: str | None = None,
|
|
repo: str | None = None,
|
|
profile_identity: str | None = None,
|
|
state_dir: str | None = None,
|
|
instance_id: str | None = None,
|
|
) -> dict[str, Any] | None:
|
|
"""Load durable state payload when identity checks pass."""
|
|
profile = current_profile_identity(profile_identity=profile_identity)
|
|
path = state_file_path(
|
|
kind=kind,
|
|
remote=remote,
|
|
org=org,
|
|
repo=repo,
|
|
profile_identity=profile,
|
|
state_dir=state_dir,
|
|
instance_id=instance_id,
|
|
)
|
|
lock_path = f"{path}.lock"
|
|
with _exclusive_file_lock(lock_path):
|
|
envelope = _read_json(path)
|
|
if not envelope:
|
|
return None
|
|
payload = envelope.get("payload")
|
|
if not isinstance(payload, dict):
|
|
return None
|
|
# Identity fields live on both envelope and payload for convenience.
|
|
merged = dict(payload)
|
|
for key in (
|
|
"kind",
|
|
"remote",
|
|
"org",
|
|
"repo",
|
|
"profile_identity",
|
|
"session_profile_lock",
|
|
"recorded_at",
|
|
"updated_at",
|
|
"writer_pid",
|
|
):
|
|
if key in envelope and key not in merged:
|
|
merged[key] = envelope[key]
|
|
reasons = identity_match_reasons(
|
|
merged,
|
|
remote=remote,
|
|
org=org,
|
|
repo=repo,
|
|
profile_identity=profile,
|
|
)
|
|
if reasons:
|
|
return None
|
|
return merged
|
|
|
|
|
|
def save_state(
|
|
*,
|
|
kind: str,
|
|
payload: dict[str, Any] | None,
|
|
remote: str | None = None,
|
|
org: str | None = None,
|
|
repo: str | None = None,
|
|
profile_identity: str | None = None,
|
|
state_dir: str | None = None,
|
|
instance_id: str | None = None,
|
|
) -> dict[str, Any] | None:
|
|
"""Persist or clear durable state for the given session identity key."""
|
|
profile = current_profile_identity(
|
|
profile_name=payload.get("session_profile") if payload else None,
|
|
session_profile_lock=(
|
|
(payload or {}).get("session_profile_lock") or profile_identity
|
|
),
|
|
)
|
|
# Prefer explicit args over payload fields for key location.
|
|
key_remote = remote if remote is not None else (payload or {}).get("remote")
|
|
key_org = org if org is not None else (payload or {}).get("org")
|
|
key_repo = repo if repo is not None else (payload or {}).get("repo")
|
|
|
|
root = _ensure_state_dir(state_dir)
|
|
path = state_file_path(
|
|
kind=kind,
|
|
remote=key_remote,
|
|
org=key_org,
|
|
repo=key_repo,
|
|
profile_identity=profile,
|
|
state_dir=root,
|
|
instance_id=instance_id,
|
|
)
|
|
lock_path = f"{path}.lock"
|
|
with _exclusive_file_lock(lock_path):
|
|
if payload is None:
|
|
for candidate in (path, lock_path):
|
|
if os.path.exists(candidate):
|
|
try:
|
|
os.remove(candidate)
|
|
except OSError:
|
|
pass
|
|
return None
|
|
|
|
now = _now_utc().isoformat().replace("+00:00", "Z")
|
|
body = dict(payload)
|
|
body.setdefault("session_pid", os.getpid())
|
|
body["writer_pid"] = os.getpid()
|
|
body["profile_identity"] = profile
|
|
if not (body.get("session_profile_lock") or "").strip():
|
|
body["session_profile_lock"] = profile
|
|
body["recorded_at"] = body.get("recorded_at") or now
|
|
body["updated_at"] = now
|
|
if (instance_id or "").strip():
|
|
body["instance_id"] = instance_id.strip()
|
|
if key_remote is not None:
|
|
body["remote"] = key_remote
|
|
if key_org is not None:
|
|
body["org"] = key_org
|
|
if key_repo is not None:
|
|
body["repo"] = key_repo
|
|
|
|
envelope = {
|
|
"kind": kind,
|
|
"remote": key_remote,
|
|
"org": key_org,
|
|
"repo": key_repo,
|
|
"profile_identity": profile,
|
|
"session_profile_lock": body.get("session_profile_lock"),
|
|
"recorded_at": body["recorded_at"],
|
|
"updated_at": body["updated_at"],
|
|
"writer_pid": body["writer_pid"],
|
|
"payload": body,
|
|
}
|
|
_write_json(path, envelope)
|
|
return dict(body)
|
|
|
|
|
|
def clear_state(
|
|
*,
|
|
kind: str,
|
|
remote: str | None = None,
|
|
org: str | None = None,
|
|
repo: str | None = None,
|
|
profile_identity: str | None = None,
|
|
state_dir: str | None = None,
|
|
instance_id: str | None = None,
|
|
) -> None:
|
|
save_state(
|
|
kind=kind,
|
|
payload=None,
|
|
remote=remote,
|
|
org=org,
|
|
repo=repo,
|
|
profile_identity=profile_identity,
|
|
state_dir=state_dir,
|
|
instance_id=instance_id,
|
|
)
|
|
|
|
|
|
def list_states(
|
|
*,
|
|
kind: str,
|
|
profile_identity: str | None = None,
|
|
state_dir: str | None = None,
|
|
) -> list[dict[str, Any]]:
|
|
"""Enumerate durable records of *kind* across all instance keys.
|
|
|
|
Crash recovery cannot know which session id left a shadow behind, so it
|
|
must be able to enumerate every record of a kind (#702 F5). Identity
|
|
checks (profile / TTL policy) apply per record exactly as in
|
|
:func:`load_state`; records that fail closed are omitted.
|
|
"""
|
|
root = (state_dir or default_state_dir()).strip()
|
|
if not root or not os.path.isdir(root):
|
|
return []
|
|
profile = current_profile_identity(profile_identity=profile_identity)
|
|
results: list[dict[str, Any]] = []
|
|
try:
|
|
names = sorted(os.listdir(root))
|
|
except OSError:
|
|
return []
|
|
for name in names:
|
|
if not name.endswith(".json") or name.startswith("."):
|
|
continue
|
|
envelope = _read_json(os.path.join(root, name))
|
|
if not envelope or envelope.get("kind") != kind:
|
|
continue
|
|
payload = envelope.get("payload")
|
|
if not isinstance(payload, dict):
|
|
continue
|
|
merged = dict(payload)
|
|
for key in (
|
|
"kind",
|
|
"remote",
|
|
"org",
|
|
"repo",
|
|
"profile_identity",
|
|
"session_profile_lock",
|
|
"recorded_at",
|
|
"updated_at",
|
|
"writer_pid",
|
|
):
|
|
if key in envelope and key not in merged:
|
|
merged[key] = envelope[key]
|
|
if identity_match_reasons(merged, profile_identity=profile):
|
|
continue
|
|
results.append(merged)
|
|
return results
|