Compare commits
10
Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
6da68fffb8 | ||
|
|
8598537a35 | ||
|
|
53ce1b1a5e | ||
|
|
220361ad94 | ||
|
|
433f66add8 | ||
|
|
9bc021e9c0 | ||
|
|
930dc24632 | ||
|
|
41622c5985 | ||
|
|
301c78de20 | ||
|
|
714190e02a |
@@ -23,6 +23,7 @@ import json
|
|||||||
import os
|
import os
|
||||||
import uuid
|
import uuid
|
||||||
from dataclasses import dataclass, field
|
from dataclasses import dataclass, field
|
||||||
|
from datetime import datetime, timezone
|
||||||
from typing import Any, Mapping, Sequence
|
from typing import Any, Mapping, Sequence
|
||||||
|
|
||||||
from control_plane_db import (
|
from control_plane_db import (
|
||||||
@@ -738,6 +739,46 @@ def normalize_exclude_issue_numbers(
|
|||||||
return sorted(out)
|
return sorted(out)
|
||||||
|
|
||||||
|
|
||||||
|
def _claim_expires_at(claim: Any) -> datetime | None:
|
||||||
|
"""Parse a claim's ``expires_at``, or ``None`` when it is absent/malformed."""
|
||||||
|
if not isinstance(claim, Mapping):
|
||||||
|
return None
|
||||||
|
text = str(claim.get("expires_at") or "").strip()
|
||||||
|
if not text:
|
||||||
|
return None
|
||||||
|
if text.endswith("Z"):
|
||||||
|
text = text[:-1] + "+00:00"
|
||||||
|
try:
|
||||||
|
parsed = datetime.fromisoformat(text)
|
||||||
|
except ValueError:
|
||||||
|
return None
|
||||||
|
if parsed.tzinfo is None:
|
||||||
|
parsed = parsed.replace(tzinfo=timezone.utc)
|
||||||
|
return parsed.astimezone(timezone.utc)
|
||||||
|
|
||||||
|
|
||||||
|
def _drop_expired_claims(
|
||||||
|
claims: Mapping[tuple[str, int], dict[str, Any]],
|
||||||
|
*,
|
||||||
|
now: datetime | None = None,
|
||||||
|
) -> dict[tuple[str, int], dict[str, Any]]:
|
||||||
|
"""Claims minus those whose lease has already expired (#643).
|
||||||
|
|
||||||
|
The read-only mirror of ``expire_stale_leases``: the sweep marks such rows
|
||||||
|
``expired`` so they stop being returned as claims, and this reaches the same
|
||||||
|
view without writing. A claim with no parseable ``expires_at`` is **kept** —
|
||||||
|
an unreadable expiry is not evidence that work is free.
|
||||||
|
"""
|
||||||
|
moment = now or datetime.now(timezone.utc)
|
||||||
|
kept: dict[tuple[str, int], dict[str, Any]] = {}
|
||||||
|
for key, claim in (claims or {}).items():
|
||||||
|
expires_at = _claim_expires_at(claim)
|
||||||
|
if expires_at is not None and expires_at <= moment:
|
||||||
|
continue
|
||||||
|
kept[key] = claim
|
||||||
|
return kept
|
||||||
|
|
||||||
|
|
||||||
def candidate_set_fingerprint(
|
def candidate_set_fingerprint(
|
||||||
candidates: Sequence[WorkCandidate],
|
candidates: Sequence[WorkCandidate],
|
||||||
*,
|
*,
|
||||||
@@ -826,12 +867,22 @@ def allocate_next_work(
|
|||||||
exclude_issue_numbers: Sequence[int] | None = None,
|
exclude_issue_numbers: Sequence[int] | None = None,
|
||||||
expected_candidate_set_fingerprint: str | None = None,
|
expected_candidate_set_fingerprint: str | None = None,
|
||||||
allocation_mode: str | None = None,
|
allocation_mode: str | None = None,
|
||||||
|
side_effect_free: bool = False,
|
||||||
) -> dict[str, Any]:
|
) -> dict[str, Any]:
|
||||||
"""Select and optionally reserve the next work unit via control-plane DB.
|
"""Select and optionally reserve the next work unit via control-plane DB.
|
||||||
|
|
||||||
*apply=False* (default): dry-run selection only — no lease/assignment.
|
*apply=False* (default): dry-run selection only — no lease/assignment.
|
||||||
*apply=True*: atomic ``assign_and_lease`` for the selected candidate.
|
*apply=True*: atomic ``assign_and_lease`` for the selected candidate.
|
||||||
|
|
||||||
|
*side_effect_free* (#643): a dry run that writes **nothing** to the
|
||||||
|
control-plane DB. A plain ``apply=False`` still registered a session row and
|
||||||
|
swept stale leases globally, so a caller advertising a read-only preview was
|
||||||
|
mutating on every call. Under this flag both writes are suppressed and stale
|
||||||
|
leases are instead filtered out of the claim map in memory, which yields the
|
||||||
|
same selection the sweep would have produced without persisting anything.
|
||||||
|
Incompatible with *apply* — the combination fails closed rather than
|
||||||
|
silently reserving.
|
||||||
|
|
||||||
*allocation_mode* (#840): ``cross_role`` (default for controller) inspects
|
*allocation_mode* (#840): ``cross_role`` (default for controller) inspects
|
||||||
the complete queue and returns one authoritative selection naming the
|
the complete queue and returns one authoritative selection naming the
|
||||||
required downstream role/profile/action. ``role_scoped`` keeps prior
|
required downstream role/profile/action. ``role_scoped`` keeps prior
|
||||||
@@ -885,7 +936,24 @@ def allocate_next_work(
|
|||||||
"allocation_mode": (allocation_mode or "").strip() or None,
|
"allocation_mode": (allocation_mode or "").strip() or None,
|
||||||
}
|
}
|
||||||
|
|
||||||
|
# A side-effect-free run may never reserve: reserving is a write, and the
|
||||||
|
# flag is the caller's assertion that this call writes nothing (#643).
|
||||||
|
if side_effect_free and apply:
|
||||||
|
return {
|
||||||
|
"success": False,
|
||||||
|
"outcome": OUTCOME_NO_SAFE,
|
||||||
|
"apply": True,
|
||||||
|
"reasons": [
|
||||||
|
"side_effect_free is incompatible with apply=True; an "
|
||||||
|
"assignment is a write (fail closed, #643)"
|
||||||
|
],
|
||||||
|
"skipped": [],
|
||||||
|
"assignment": None,
|
||||||
|
"substrate": "control_plane_db",
|
||||||
|
}
|
||||||
|
|
||||||
session_id = (session_id or "").strip() or f"alloc-{uuid.uuid4().hex[:12]}"
|
session_id = (session_id or "").strip() or f"alloc-{uuid.uuid4().hex[:12]}"
|
||||||
|
if not side_effect_free:
|
||||||
try:
|
try:
|
||||||
db.upsert_session(
|
db.upsert_session(
|
||||||
session_id=session_id,
|
session_id=session_id,
|
||||||
@@ -953,6 +1021,12 @@ def allocate_next_work(
|
|||||||
"assignment": None,
|
"assignment": None,
|
||||||
"substrate": "control_plane_db",
|
"substrate": "control_plane_db",
|
||||||
}
|
}
|
||||||
|
if side_effect_free:
|
||||||
|
# ``list_active_claims`` filters on status alone, so without the
|
||||||
|
# global sweep an already-expired lease would still read as a live
|
||||||
|
# claim and the preview would report work as taken that is free.
|
||||||
|
# Drop those in memory: same view the sweep produces, no write.
|
||||||
|
claims = _drop_expired_claims(claims)
|
||||||
|
|
||||||
try:
|
try:
|
||||||
exclude_nums = normalize_exclude_issue_numbers(exclude_issue_numbers)
|
exclude_nums = normalize_exclude_issue_numbers(exclude_issue_numbers)
|
||||||
|
|||||||
@@ -0,0 +1,53 @@
|
|||||||
|
# MCP restart classes and blast-radius permissions (#663)
|
||||||
|
|
||||||
|
This is the machine-enforced class matrix used by
|
||||||
|
`restart_coordinator.RESTART_CLASS_POLICIES`. It implements the narrower-first
|
||||||
|
recovery ladder from #655 and the authorization policy from #656, using the
|
||||||
|
path inventory from #657 and the impact coordinator from #658. Product and
|
||||||
|
delivery lineage: vision #652 and roadmap #653.
|
||||||
|
|
||||||
|
Unknown class names are denied. The coordinator requires both the class
|
||||||
|
permission and an eligible request role. Approval gates are additional: a
|
||||||
|
caller cannot turn a request permission into execution authority.
|
||||||
|
|
||||||
|
| Restart class | Required permission | Expected blast radius | Drain requirement | Approval requirement | Audit requirement | Recovery behavior |
|
||||||
|
|---|---|---|---|---|---|---|
|
||||||
|
| `client_reconnect` | `mcp.reconnect.client` | none | none | self service | class, actor, client namespace, reason, outcome | Reconnect only the caller's client transport. No daemon or peer work changes. |
|
||||||
|
| `session_reconnect` | `mcp.reconnect.session` | low | requesting-session safe point | self service | class, actor, session, reason, outcome | Rebind identity, capability, and workspace state for one session. |
|
||||||
|
| `worker_restart` | `mcp.restart.worker.request` | low | target worker | controller approval + automated gates | class, actor, worker, approval, scoped drain, outcome | Restart one worker after its own leases and mutations drain. |
|
||||||
|
| `role_runtime_restart` | `mcp.restart.role_runtime.request` | medium | target role runtime | controller approval + automated gates | class, actor, role namespace, approval, scoped drain, outcome | Restart and re-probe one role runtime; unrelated roles remain available. |
|
||||||
|
| `connector_restart` | `mcp.restart.connector.request` | medium | target connector | controller approval + automated gates | class, actor, connector, approval, scoped drain, outcome | Restart one connector while unrelated runtimes remain available. |
|
||||||
|
| `configuration_reload` | `mcp.reload.configuration.request` | low | mutation quiesce | controller approval + automated gates | class, actor, configuration revision, approval, outcome | Gracefully reload configuration without replacing the daemon. |
|
||||||
|
| `rolling_mcp_restart` | `mcp.restart.rolling.request` | medium | one instance at a time | controller approval + automated gates | class, actor, instance order, approval, per-instance drains, outcome | Drain, restart, verify, and restore each instance before advancing. |
|
||||||
|
| `full_mcp_restart` | `mcp.restart.full.request` | high | all sessions and mutations | controller approval + automated gates | class, actor, full impact, approval, full drain proof, outcome | Replace the complete MCP runtime only after a verified full drain. |
|
||||||
|
| `host_restart` | `mcp.restart.host.request` | high | all host work | controller approval + infrastructure operator | class, actor, host/change or incident id, approval, full drain proof, outcome | Hand off to infrastructure ownership and reconcile every runtime afterward. |
|
||||||
|
|
||||||
|
## Drain boundary
|
||||||
|
|
||||||
|
Only `full_mcp_restart` and `host_restart` set `full_drain_required=true`.
|
||||||
|
Reconnects and configuration reloads do not disrupt peer sessions. Worker,
|
||||||
|
role-runtime, and connector restarts evaluate only their explicitly named
|
||||||
|
target. Rolling restart drains one instance at a time. Missing required target
|
||||||
|
scope denies the request rather than silently widening it to a full restart.
|
||||||
|
|
||||||
|
## Permission and approval boundary
|
||||||
|
|
||||||
|
Author, reviewer, merger, and reconciler roles may self-request reconnects and
|
||||||
|
request scoped worker/role/connector/reload recovery. They cannot request
|
||||||
|
rolling, full, or host restart classes. Controller/operator/admin roles may
|
||||||
|
request the broader classes, while execution remains operator/admin-owned.
|
||||||
|
Controller approval is independently required for every class above a session
|
||||||
|
reconnect. Host restart additionally requires infrastructure-operator proof.
|
||||||
|
|
||||||
|
The MCP request tool derives class permissions from its authenticated runtime
|
||||||
|
role. It does not accept caller-supplied permissions. Controller and operator
|
||||||
|
authorization are read from the already-running daemon environment, never
|
||||||
|
from a request argument.
|
||||||
|
|
||||||
|
## Audit and failure behavior
|
||||||
|
|
||||||
|
Every impact audit and every console restart/reload audit includes a
|
||||||
|
`restart_class` field. The impact audit also includes the exact
|
||||||
|
`required_permission`. Unknown classes, missing permissions, ineligible roles,
|
||||||
|
missing approval, missing scoped targets, and incomplete inventory all deny
|
||||||
|
fail closed. Manual process kills remain forbidden and contaminating (#630).
|
||||||
@@ -6,11 +6,23 @@ console (#642 / #652) can see the blast radius *before* concurrent LLM work is
|
|||||||
disrupted. Uncoordinated restarts destroy in-flight author/reviewer/merger work
|
disrupted. Uncoordinated restarts destroy in-flight author/reviewer/merger work
|
||||||
and give operators no way to see what they are about to break.
|
and give operators no way to see what they are about to break.
|
||||||
|
|
||||||
This lands the coordinator + impact DTO + a dry-run MCP tool. It is the single
|
This lands the coordinator + impact DTO + the MCP tool. It is the single
|
||||||
sanctioned entry point for restart evaluation post-#657 (which inventoried the
|
sanctioned entry point for restart evaluation post-#657 (which inventoried the
|
||||||
restart/reload/kill paths). The **mutative apply** path — actually performing a
|
restart/reload/kill paths).
|
||||||
restart — is a later child gated by a drain proof and is explicitly out of
|
|
||||||
scope here.
|
The **drain-proof hard gate now executes inside this tool** (#661, via PR #882):
|
||||||
|
an apply request (`dry_run=False`) is evaluated against a drain proof here and
|
||||||
|
denied when that proof is missing, expired, unclean, tampered with, or stale.
|
||||||
|
It is no longer a separate child operation. What remains a later child is only
|
||||||
|
the **execution** step — actually stopping and restoring a process. This tool
|
||||||
|
still never restarts anything: `apply_supported` is always `false` and
|
||||||
|
`restart_performed` is always `false`.
|
||||||
|
|
||||||
|
The coordinator now routes every request through the restart-class policy
|
||||||
|
matrix defined for #663. See
|
||||||
|
[`mcp-restart-classes.md`](./mcp-restart-classes.md) for permissions, expected
|
||||||
|
blast radius, scoped drain and approval requirements, audit fields, and
|
||||||
|
recovery behavior for all nine classes.
|
||||||
|
|
||||||
## Components
|
## Components
|
||||||
|
|
||||||
@@ -19,7 +31,8 @@ scope here.
|
|||||||
| `restart_coordinator.evaluate_restart_impact` | `restart_coordinator.py` | Pure classification: inventory → impact report DTO. No I/O, no restart. |
|
| `restart_coordinator.evaluate_restart_impact` | `restart_coordinator.py` | Pure classification: inventory → impact report DTO. No I/O, no restart. |
|
||||||
| `RestartImpactReport` / `SessionImpact` / `LeaseImpact` | `restart_coordinator.py` | Console-facing DTO (`.as_dict()` is JSON-serializable). |
|
| `RestartImpactReport` / `SessionImpact` / `LeaseImpact` | `restart_coordinator.py` | Console-facing DTO (`.as_dict()` is JSON-serializable). |
|
||||||
| `ControlPlaneDB.list_sessions` | `control_plane_db.py` | Read-only session inventory (the process-level unit a restart kills). |
|
| `ControlPlaneDB.list_sessions` | `control_plane_db.py` | Read-only session inventory (the process-level unit a restart kills). |
|
||||||
| `gitea_request_mcp_restart` | `gitea_mcp_server.py` | MCP tool: gathers inventory from the #613 DB, calls the coordinator, returns the report. Dry-run only. |
|
| `gitea_request_mcp_restart` | `gitea_mcp_server.py` | MCP tool: gathers inventory from the #613 DB, calls the coordinator, returns the report, and on `dry_run=False` runs the #661 drain-proof hard gate. Never restarts a process. |
|
||||||
|
| `drain_proof.gate_apply_restart` | `drain_proof.py` | The #661 hard gate: verifies a drain proof against the current impact fingerprint, or records an authorized break-glass bypass. |
|
||||||
|
|
||||||
## Dimensions evaluated
|
## Dimensions evaluated
|
||||||
|
|
||||||
@@ -77,17 +90,61 @@ authorization is present.
|
|||||||
```text
|
```text
|
||||||
gitea_request_mcp_restart(remote, host, org, repo,
|
gitea_request_mcp_restart(remote, host, org, repo,
|
||||||
dry_run=True, request_override=False,
|
dry_run=True, request_override=False,
|
||||||
session_id=None, limit=200)
|
session_id=None, limit=200,
|
||||||
|
restart_class="full_mcp_restart",
|
||||||
|
target_session_id=None, target_role=None,
|
||||||
|
target_connector=None,
|
||||||
|
drain_proof_json=None,
|
||||||
|
request_break_glass=False)
|
||||||
```
|
```
|
||||||
|
|
||||||
Read-only, dry-run, and it **never restarts anything**. `apply_supported` is
|
It **never restarts anything**: `apply_supported` is always `false` and
|
||||||
always `false`; passing `dry_run=False` performs no restart and reports that
|
`restart_performed` is always `false`.
|
||||||
apply is gated by a drain proof (a separate child).
|
|
||||||
|
### Dry-run versus apply
|
||||||
|
|
||||||
|
| Call | Behavior |
|
||||||
|
|------|----------|
|
||||||
|
| `dry_run=True` (default) | Read-only impact preview. No drain proof is required or consulted. |
|
||||||
|
| `dry_run=False` | The #661 drain-proof hard gate runs **in this tool**. The outcome is reported under `apply_gate` / `apply_authorized`; a denial also returns a durable `incident` descriptor. Still no restart. |
|
||||||
|
|
||||||
|
### Authorization ordering
|
||||||
|
|
||||||
|
An apply requires **both** authorizations, and they are independent:
|
||||||
|
|
||||||
|
1. **Restart-class authorization** (#663) — the requester's role and permissions
|
||||||
|
must allow the requested class, the class's approval requirement must be
|
||||||
|
satisfied, and any target-scoped class must name its target. Failing any of
|
||||||
|
these makes `allow_restart` `false`.
|
||||||
|
2. **Drain-proof gate** (#661) — a valid, unexpired, clean proof bound to the
|
||||||
|
current impact fingerprint, or an authorized break-glass.
|
||||||
|
|
||||||
|
`apply_authorized` is the conjunction: `gate.allow and allow_restart`. A clean
|
||||||
|
drain proof therefore cannot override a class or requester-role denial, and a
|
||||||
|
denied class never reports an authorized apply. `apply_gate` carries
|
||||||
|
`drain_gate_allow` and `restart_class_authorized` so a denial is attributable to
|
||||||
|
the authorization that produced it.
|
||||||
|
|
||||||
|
### Break-glass
|
||||||
|
|
||||||
|
Break-glass bypasses the **drain proof only** — never the restart-class matrix.
|
||||||
|
It is honoured solely when `request_break_glass` is set *and* the environment
|
||||||
|
carries `GITEA_BREAKGLASS_RESTART_AUTHORIZATION`; like operator override, the
|
||||||
|
tool argument expresses caller intent and cannot be self-asserted by a worker
|
||||||
|
session. `break_glass_requested` and `break_glass_authorized` are both reported,
|
||||||
|
so a bypass is never silent.
|
||||||
|
|
||||||
|
### Fail closed on apply
|
||||||
|
|
||||||
|
A missing, malformed, expired, unclean, tampered, or fingerprint-stale drain
|
||||||
|
proof denies the apply and returns an `incident` descriptor. An unknown restart
|
||||||
|
class denies before any of this. Ambiguity always denies.
|
||||||
|
|
||||||
## Audit
|
## Audit
|
||||||
|
|
||||||
Every evaluation carries an `audit_record` (event, coordinator version, verdict,
|
Every evaluation carries an `audit_record` (event, coordinator version, verdict,
|
||||||
allow decision, blast radius, counts, timestamp) so restart decisions are
|
restart class, required permission, allow decision, blast radius, counts,
|
||||||
|
timestamp) so restart decisions are
|
||||||
auditable. No secrets flow through the coordinator — session ids, pids, and
|
auditable. No secrets flow through the coordinator — session ids, pids, and
|
||||||
profiles are operational metadata only.
|
profiles are operational metadata only.
|
||||||
|
|
||||||
|
|||||||
@@ -94,6 +94,7 @@ already define, and a regression test asserts each mapping matches.
|
|||||||
| `record_analytics_usage` | operator | gated_write | `runtime.record_analytics_usage` | Yes | No | No | 2 |
|
| `record_analytics_usage` | operator | gated_write | `runtime.record_analytics_usage` | Yes | No | No | 2 |
|
||||||
| `system.reload_namespace` | controller | privileged | `runtime.reload_namespace` | Yes | No | No | 2 |
|
| `system.reload_namespace` | controller | privileged | `runtime.reload_namespace` | Yes | No | No | 2 |
|
||||||
| `system.restart_namespace` | admin | destructive | `runtime.restart_namespace` | Yes | **Yes** | **Yes** | 2 |
|
| `system.restart_namespace` | admin | destructive | `runtime.restart_namespace` | Yes | **Yes** | **Yes** | 2 |
|
||||||
|
| `initiate_workflow` | operator | gated_write | `gitea.read` | Yes | No | No | 2 |
|
||||||
|
|
||||||
**Dual control** means the acting principal may not be the sole authority: a
|
**Dual control** means the acting principal may not be the sole authority: a
|
||||||
second distinct principal must confirm. **Break-glass** means the action is
|
second distinct principal must confirm. **Break-glass** means the action is
|
||||||
@@ -112,6 +113,12 @@ by the console — both hand off to a host supervisor, and neither exposes a raw
|
|||||||
process kill. See
|
process kill. See
|
||||||
[`sanctioned-restart-controls.md`](sanctioned-restart-controls.md) (#642).
|
[`sanctioned-restart-controls.md`](sanctioned-restart-controls.md) (#642).
|
||||||
|
|
||||||
|
`initiate_workflow` (#643) is operator-class because its outcome is a *claim*,
|
||||||
|
not a Gitea verdict. Requesting reviewer or merger work reserves that work
|
||||||
|
through the allocator; it does not grant the right to approve or merge, which
|
||||||
|
stays with the MCP role profile and its own capability gates. See
|
||||||
|
[`webui-requests.md`](webui-requests.md).
|
||||||
|
|
||||||
### Authorization decision
|
### Authorization decision
|
||||||
|
|
||||||
`authorize(action_id, principal, for_execution=False)` returns a decision
|
`authorize(action_id, principal, for_execution=False)` returns a decision
|
||||||
@@ -126,9 +133,24 @@ record and **denies by default**. The deny reasons are closed and enumerated:
|
|||||||
| `phase_not_active` | Execution requested for an action whose phase is not open. |
|
| `phase_not_active` | Execution requested for an action whose phase is not open. |
|
||||||
| `allowed_preview_only` | Authorized — preview only, execution still disabled. |
|
| `allowed_preview_only` | Authorized — preview only, execution still disabled. |
|
||||||
|
|
||||||
There is no implicit allow branch. Even the allow result reports
|
There is no implicit allow branch.
|
||||||
`execution_enabled: false` while the console is in Phase 1, so no caller can
|
|
||||||
read an allow as permission to mutate.
|
`execution_enabled` on the decision reports whether the action has a live
|
||||||
|
execution path at all, and is computed by `execution_wired(action)`. There are
|
||||||
|
exactly two ways to be wired:
|
||||||
|
|
||||||
|
1. the action's `phase` is at or below `ACTIVE_PHASE`; or
|
||||||
|
2. the action declares an `execution_env_flag` **and** that variable is set.
|
||||||
|
|
||||||
|
Every action that declares no flag therefore reports `execution_enabled: false`
|
||||||
|
while the console is in Phase 1, so no caller can read an allow as permission
|
||||||
|
to mutate. The per-action flag exists because raising `ACTIVE_PHASE` would
|
||||||
|
enable execution for every action of that phase at once, including ones whose
|
||||||
|
execution path is not implemented. One implemented action goes live on its own
|
||||||
|
flag instead of dragging its unimplemented phase-mates with it.
|
||||||
|
|
||||||
|
`initiate_workflow` is the only action that currently declares a flag
|
||||||
|
(`WEBUI_REQUESTS_EXECUTION`), and it stays denied until an operator sets it.
|
||||||
|
|
||||||
## Secret redaction
|
## Secret redaction
|
||||||
|
|
||||||
@@ -235,13 +257,22 @@ second one. The integration points are already wired and observable:
|
|||||||
instead of adding a parallel check.
|
instead of adding a parallel check.
|
||||||
- **`GET /api/console/security-model`** publishes the RBAC matrix, redaction
|
- **`GET /api/console/security-model`** publishes the RBAC matrix, redaction
|
||||||
policy, and audit policy as JSON for operators and tests.
|
policy, and audit policy as JSON for operators and tests.
|
||||||
|
- **`POST /api/v1/requests/preview` and `.../apply`** (#643) are the first
|
||||||
|
actions to use this model for a real execution path. Preview always returns a
|
||||||
|
decision and an audited `previewed` record; apply requires `confirm=true`,
|
||||||
|
emits `succeeded` or `denied`, and reserves work only through the allocator.
|
||||||
|
See [`webui-requests.md`](webui-requests.md).
|
||||||
|
|
||||||
To open Phase 2, a child issue must: raise `ACTIVE_PHASE`, implement the
|
A Phase 2 action must: use `execution_wired` rather than a private enable flag,
|
||||||
confirmation and dual-control flow the matrix already declares, emit a
|
implement the confirmation and dual-control flow the matrix already declares,
|
||||||
`succeeded` or `failed` record alongside the `gitea_audit` mutation record, and
|
emit a `succeeded` or `failed` record alongside the `gitea_audit` mutation
|
||||||
keep `viewer` unable to reach any of it. Turning on execution without the
|
record, and keep `viewer` unable to reach any of it. Turning on execution
|
||||||
confirmation flow contradicts a declared requirement and is a review failure,
|
without the confirmation flow contradicts a declared requirement and is a
|
||||||
not a shortcut.
|
review failure, not a shortcut.
|
||||||
|
|
||||||
|
Raising `ACTIVE_PHASE` remains the way to open a whole phase at once, and is
|
||||||
|
deliberately *not* what #643 did: an action-scoped opt-in cannot enable an
|
||||||
|
action whose execution path nobody wrote.
|
||||||
|
|
||||||
## Local-dev mode
|
## Local-dev mode
|
||||||
|
|
||||||
@@ -294,6 +325,7 @@ Until Phase 2 wires it, probe protection rests on network placement alone, as
|
|||||||
| `WEBUI_ROLE_MAP` | unset | JSON subject → role map |
|
| `WEBUI_ROLE_MAP` | unset | JSON subject → role map |
|
||||||
| `WEBUI_REQUIRE_PROBE_AUTH` | unset | Require auth for non-public probes |
|
| `WEBUI_REQUIRE_PROBE_AUTH` | unset | Require auth for non-public probes |
|
||||||
| `WEBUI_CONSOLE_AUDIT_LOG` | unset | Append-only audit sink path |
|
| `WEBUI_CONSOLE_AUDIT_LOG` | unset | Append-only audit sink path |
|
||||||
|
| `WEBUI_REQUESTS_EXECUTION` | unset | Opt in to `initiate_workflow` execution (#643) |
|
||||||
|
|
||||||
All are read server-side only. None is ever rendered into a page or returned by
|
All are read server-side only. None is ever rendered into a page or returned by
|
||||||
an API.
|
an API.
|
||||||
|
|||||||
@@ -0,0 +1,160 @@
|
|||||||
|
# Web console requests: intent preview and workflow initiation (#643)
|
||||||
|
|
||||||
|
**Phase 2. Preview is always live and always read-only. Initiation is wired but
|
||||||
|
denied until an operator opts in.**
|
||||||
|
|
||||||
|
Before this surface, starting role work meant pasting a prompt into a terminal
|
||||||
|
and trusting the operator to have checked the allocator first. Nothing enforced
|
||||||
|
that check, so two sessions could reach for the same issue and each believe it
|
||||||
|
was theirs. This page replaces the paste with a *request*: a desired role, an
|
||||||
|
issue or PR, and a stated intent, answered by an authorization decision and —
|
||||||
|
on confirmation — an exclusive assignment from the allocator.
|
||||||
|
|
||||||
|
| Concern | Module |
|
||||||
|
|---------|--------|
|
||||||
|
| Request model, preview, initiation | `webui/request_service.py` |
|
||||||
|
| Form and preview rendering | `webui/request_views.py` |
|
||||||
|
| Authorization | `webui/console_authz.py` (`initiate_workflow`) |
|
||||||
|
| Audit | `webui/console_audit.py` |
|
||||||
|
| Ownership substrate | `allocator_service.py` + `control_plane_db.py` |
|
||||||
|
|
||||||
|
## Surfaces
|
||||||
|
|
||||||
|
| Path | Method | Purpose |
|
||||||
|
|------|--------|---------|
|
||||||
|
| `/requests` | GET | Request form |
|
||||||
|
| `/requests` | POST | Render an intent preview. **Never assigns.** |
|
||||||
|
| `/api/v1/requests/preview` | POST | Intent preview as JSON |
|
||||||
|
| `/api/v1/requests/apply` | POST | Initiate — confirmed, audited, allocator-owned |
|
||||||
|
|
||||||
|
The HTML form has no initiate button on purpose. Initiating requires a
|
||||||
|
confirmed POST to `/api/v1/requests/apply`, so a stray form submission cannot
|
||||||
|
reserve work as a side effect.
|
||||||
|
|
||||||
|
## The request
|
||||||
|
|
||||||
|
```json
|
||||||
|
{
|
||||||
|
"desired_role": "author",
|
||||||
|
"work_kind": "issue",
|
||||||
|
"work_number": 643,
|
||||||
|
"intent_summary": "implement request preview and initiation",
|
||||||
|
"remote": "prgs",
|
||||||
|
"org": "Scaled-Tech-Consulting",
|
||||||
|
"repo": "Gitea-Tools",
|
||||||
|
"expected_head_sha": null
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
`desired_role` is one of `author`, `reviewer`, `merger`, `reconciler`,
|
||||||
|
`controller`. `work_kind` is `issue` or `pr`. `remote`/`org`/`repo` default to
|
||||||
|
the first project in the registry when omitted; when neither the request nor
|
||||||
|
the registry resolves them, the request is rejected rather than pointed at some
|
||||||
|
other repository. `intent_summary` is required — it is what the audit record
|
||||||
|
states as the reason — and is truncated to 500 characters.
|
||||||
|
|
||||||
|
Parsing rejects rather than corrects. An unknown role, an unknown work kind, a
|
||||||
|
non-positive number, or a missing intent each return `400` with a `reason_code`
|
||||||
|
and the offending `field`.
|
||||||
|
|
||||||
|
## Preview
|
||||||
|
|
||||||
|
Five checks, each with its own verdict, reason code, and detail:
|
||||||
|
|
||||||
|
| Check | Passes when |
|
||||||
|
|-------|-------------|
|
||||||
|
| `authorization` | The console principal holds `operator` or above |
|
||||||
|
| `capability` | The desired role maps to a declared profile and MCP namespace |
|
||||||
|
| `lease_availability` | No active claim holds the work unit |
|
||||||
|
| `next_safe_action` | The allocator would independently select this exact work unit |
|
||||||
|
| `head_pin` | PR work resolves to a head SHA, and a supplied SHA still matches |
|
||||||
|
|
||||||
|
A preview also returns the role's `allowed_actions` and `prohibited_actions`
|
||||||
|
(from `allocator_service.ROLE_ACTIONS`), the `required_profile` and
|
||||||
|
`required_namespace` the work must run under, and a `correlation_id` that ties
|
||||||
|
the preview to its audit record and to any assignment that follows.
|
||||||
|
|
||||||
|
Preview is read-only in the strict sense: it calls the allocator with
|
||||||
|
`apply=false` and writes nothing but an audit line. An unauthorized principal
|
||||||
|
never reaches the allocator or the control-plane DB at all, so a denial cannot
|
||||||
|
be used to enumerate the queue.
|
||||||
|
|
||||||
|
## Initiation
|
||||||
|
|
||||||
|
`POST /api/v1/requests/apply` refuses in this order, and every refusal returns
|
||||||
|
before any assignment is attempted:
|
||||||
|
|
||||||
|
| Condition | Outcome | Status |
|
||||||
|
|-----------|---------|--------|
|
||||||
|
| Unparseable request | `invalid_request` | 400 |
|
||||||
|
| Not authorized, or execution not wired | `denied` | 403 |
|
||||||
|
| `confirm` not set | `denied` / `confirmation_required` | 409 |
|
||||||
|
| Work unit already claimed | `blocked` / `duplicate_assignment` | 409 |
|
||||||
|
| Allocator would select other work | `wait` / `not_next_safe_work` | 409 |
|
||||||
|
| Allocator declines on apply | `blocked` or `wait` | 409 |
|
||||||
|
| Evidence unavailable | `wait` / `evidence_unavailable` | 503 |
|
||||||
|
| Assigned | `assigned_work` | 201 |
|
||||||
|
|
||||||
|
A success returns the assignment plus a `handoff` block naming the profile, the
|
||||||
|
namespace, and the actions that stay forbidden — enough for the operator to
|
||||||
|
continue in the right MCP namespace without guessing.
|
||||||
|
|
||||||
|
### Why apply runs the allocator twice
|
||||||
|
|
||||||
|
The allocator is the only source of exclusive ownership (#600 / #613), and it
|
||||||
|
selects work; it does not take orders. So `apply` runs a dry-run first and
|
||||||
|
proceeds only when the allocator would independently pick the requested work
|
||||||
|
unit. If it would not, the request reports `wait` and mutates nothing.
|
||||||
|
|
||||||
|
A request is therefore a *confirmation* of the allocator's decision, never an
|
||||||
|
override of it. The apply call carries the dry-run's
|
||||||
|
`candidate_set_fingerprint` as a CAS pin (#776), so a queue that changed
|
||||||
|
between the two calls fails closed rather than assigning against a stale view.
|
||||||
|
The result is checked again on the way out: an assignment naming a different
|
||||||
|
work unit is not read as success.
|
||||||
|
|
||||||
|
### Fail-closed defaults
|
||||||
|
|
||||||
|
- An unreadable control-plane DB denies. It is never treated as "nothing holds
|
||||||
|
this work unit".
|
||||||
|
- An incomplete queue inventory denies (#758). Ranking a partial candidate set
|
||||||
|
can select the wrong work.
|
||||||
|
- An allocator that raises denies.
|
||||||
|
- PR work with no resolvable head SHA denies; a supplied SHA that no longer
|
||||||
|
matches denies with `head_moved`.
|
||||||
|
|
||||||
|
## Enabling initiation
|
||||||
|
|
||||||
|
Execution is wired off. Set `WEBUI_REQUESTS_EXECUTION=1` to enable it for the
|
||||||
|
`initiate_workflow` action only — see
|
||||||
|
[`webui-authz-audit.md`](webui-authz-audit.md) for why this is an
|
||||||
|
action-scoped flag rather than a phase bump. With the variable unset, `apply`
|
||||||
|
returns `403` with `reason_code: unauthorized` no matter who asks.
|
||||||
|
|
||||||
|
Enabling execution does **not** enable approvals or merges. Those are phase 3
|
||||||
|
console actions and remain forbidden in every path here; the console reserves
|
||||||
|
work and hands off, and the MCP role profile enforces what that role may then
|
||||||
|
do.
|
||||||
|
|
||||||
|
## Audit
|
||||||
|
|
||||||
|
Every preview and every apply emits a console audit record (schema in
|
||||||
|
[`webui-authz-audit.md`](webui-authz-audit.md)):
|
||||||
|
|
||||||
|
| Event | `result` |
|
||||||
|
|-------|----------|
|
||||||
|
| Preview | `previewed` |
|
||||||
|
| Refusal at any stage | `denied` |
|
||||||
|
| Assignment created | `succeeded` |
|
||||||
|
|
||||||
|
`correlation.request_id` carries the request's `correlation_id`, and a
|
||||||
|
successful record's `metadata` carries `assignment_id` and `lease_id`, so an
|
||||||
|
assignment can be traced back to the intent that produced it. The operator's
|
||||||
|
`intent_summary` travels in `metadata` and passes through the standard
|
||||||
|
redaction pass before persistence like every other field.
|
||||||
|
|
||||||
|
## Non-goals
|
||||||
|
|
||||||
|
- No browser-initiated approve or merge, in this phase or any other.
|
||||||
|
- No bypass of allocator exclusive ownership; no self-selection of work.
|
||||||
|
- No auto-start from raw monitoring incidents (#612 stays downstream).
|
||||||
+127
-299
@@ -9552,13 +9552,15 @@ def gitea_edit_pr(
|
|||||||
if closing:
|
if closing:
|
||||||
gate_reasons = _profile_operation_gate("gitea.pr.close")
|
gate_reasons = _profile_operation_gate("gitea.pr.close")
|
||||||
if gate_reasons:
|
if gate_reasons:
|
||||||
return _build_operation_gate_refusal(
|
return {
|
||||||
"gitea.pr.close",
|
"success": False,
|
||||||
gate_reasons,
|
"performed": False,
|
||||||
pr_number=pr_number,
|
"pr_number": pr_number,
|
||||||
requested_state="closed",
|
"requested_state": "closed",
|
||||||
required_permission="gitea.pr.close",
|
"required_permission": "gitea.pr.close",
|
||||||
)
|
"reasons": gate_reasons,
|
||||||
|
"permission_report": _permission_block_report("gitea.pr.close"),
|
||||||
|
}
|
||||||
|
|
||||||
h, o, r = _resolve(remote, host, org, repo)
|
h, o, r = _resolve(remote, host, org, repo)
|
||||||
auth = _auth(h)
|
auth = _auth(h)
|
||||||
@@ -13821,17 +13823,13 @@ def gitea_view_issue(
|
|||||||
|
|
||||||
def _permission_block_report(required_operation: str,
|
def _permission_block_report(required_operation: str,
|
||||||
identity: str | None = None) -> dict:
|
identity: str | None = None) -> dict:
|
||||||
"""Structured, LLM-safe explanation of a permission denial (#142, #897).
|
"""Structured, LLM-safe explanation of a permission denial (#142).
|
||||||
|
|
||||||
Built only after a gate has already refused; it adds guidance to the
|
Built only after a gate has already refused; it adds guidance to the
|
||||||
refusal and never widens any permission, performs network I/O, or
|
refusal and never widens any permission, performs network I/O, or
|
||||||
raises (fail-soft: degrades to a minimal fail-closed report). Names
|
raises (fail-soft: degrades to a minimal fail-closed report). Names
|
||||||
configured profiles only — never auth references, tokens, endpoint
|
configured profiles only — never auth references, tokens, endpoint
|
||||||
URLs, or keychain IDs.
|
URLs, or keychain IDs.
|
||||||
|
|
||||||
#897: never fabricate a missing permission when the active profile
|
|
||||||
already allows the operation. That path is a diagnostic defect (the
|
|
||||||
refusal was not a permission denial), not a cue to switch profiles.
|
|
||||||
"""
|
"""
|
||||||
report = {
|
report = {
|
||||||
"requested_operation": required_operation,
|
"requested_operation": required_operation,
|
||||||
@@ -13843,7 +13841,6 @@ def _permission_block_report(required_operation: str,
|
|||||||
"matching_configured_profiles": [],
|
"matching_configured_profiles": [],
|
||||||
"runtime_switching_supported": False,
|
"runtime_switching_supported": False,
|
||||||
"different_mcp_namespace_required": True,
|
"different_mcp_namespace_required": True,
|
||||||
"diagnostic_defect": False,
|
|
||||||
"exact_safe_next_action": (
|
"exact_safe_next_action": (
|
||||||
"Ask the operator to fix GITEA_MCP_CONFIG/GITEA_MCP_PROFILE; "
|
"Ask the operator to fix GITEA_MCP_CONFIG/GITEA_MCP_PROFILE; "
|
||||||
"the active profile could not be resolved (fail closed)."),
|
"the active profile could not be resolved (fail closed)."),
|
||||||
@@ -13858,32 +13855,6 @@ def _permission_block_report(required_operation: str,
|
|||||||
report["active_allowed_operations"] = (
|
report["active_allowed_operations"] = (
|
||||||
profile.get("allowed_operations") or [])
|
profile.get("allowed_operations") or [])
|
||||||
|
|
||||||
# #897: fail closed as a diagnostic defect when the active profile
|
|
||||||
# already holds the operation — callers must not invent "missing".
|
|
||||||
try:
|
|
||||||
holds, _hold_reason = gitea_config.check_operation(
|
|
||||||
required_operation,
|
|
||||||
profile.get("allowed_operations") or [],
|
|
||||||
profile.get("forbidden_operations") or [],
|
|
||||||
)
|
|
||||||
except Exception:
|
|
||||||
holds = False
|
|
||||||
if holds:
|
|
||||||
report["missing_permission"] = None
|
|
||||||
report["required_permission"] = required_operation
|
|
||||||
report["diagnostic_defect"] = True
|
|
||||||
report["different_mcp_namespace_required"] = False
|
|
||||||
report["exact_safe_next_action"] = (
|
|
||||||
"Diagnostic defect: the active profile already allows "
|
|
||||||
f"{required_operation}. This is not a permission denial — "
|
|
||||||
"inspect blocker_kind / reasons (stale-runtime or runtime-mode). "
|
|
||||||
"Do not call gitea_activate_profile or switch MCP sessions."
|
|
||||||
)
|
|
||||||
report["matching_configured_profiles"] = [
|
|
||||||
p for p in [profile.get("profile_name")] if p
|
|
||||||
]
|
|
||||||
return report
|
|
||||||
|
|
||||||
matching = []
|
matching = []
|
||||||
try:
|
try:
|
||||||
config = gitea_config.load_config() or {}
|
config = gitea_config.load_config() or {}
|
||||||
@@ -13932,205 +13903,6 @@ def _permission_block_report(required_operation: str,
|
|||||||
return report
|
return report
|
||||||
|
|
||||||
|
|
||||||
def _reason_is_stale_runtime(reason: str) -> bool:
|
|
||||||
"""True when *reason* is a master-parity / stale-daemon refusal (#897)."""
|
|
||||||
r = (reason or "").lower()
|
|
||||||
if not r:
|
|
||||||
return False
|
|
||||||
if "stale relative to live master" in r:
|
|
||||||
return True
|
|
||||||
if "server code is stale" in r:
|
|
||||||
return True
|
|
||||||
if "daemon is stale" in r:
|
|
||||||
return True
|
|
||||||
if "started at commit" in r and "workspace master is now" in r:
|
|
||||||
return True
|
|
||||||
if "mcp server started at" in r and "stale" in r:
|
|
||||||
return True
|
|
||||||
if "restart the server to load the current capability gates" in r:
|
|
||||||
return True
|
|
||||||
if "restart/reconnect before mutating" in r:
|
|
||||||
return True
|
|
||||||
return False
|
|
||||||
|
|
||||||
|
|
||||||
def _reason_is_runtime_mode(reason: str) -> bool:
|
|
||||||
"""True when *reason* is a stable-control / runtime-mode refusal (#897)."""
|
|
||||||
r = (reason or "").lower()
|
|
||||||
if not r:
|
|
||||||
return False
|
|
||||||
if _reason_is_stale_runtime(reason):
|
|
||||||
return False
|
|
||||||
if "runtime mode could not be assessed" in r:
|
|
||||||
return True
|
|
||||||
if "runtime mode is" in r:
|
|
||||||
return True
|
|
||||||
if "stable control runtime" in r:
|
|
||||||
return True
|
|
||||||
if "dev-test" in r and ("runtime" in r or "production" in r):
|
|
||||||
return True
|
|
||||||
if "development worktree" in r or "dev worktree" in r:
|
|
||||||
return True
|
|
||||||
if "launched from a 'branches/" in r or "launched from a \"branches/" in r:
|
|
||||||
return True
|
|
||||||
if "process-root / active-workspace alignment" in r:
|
|
||||||
return True
|
|
||||||
if "namespace" in r and "reproof" in r:
|
|
||||||
return True
|
|
||||||
return False
|
|
||||||
|
|
||||||
|
|
||||||
def _reason_is_permission(reason: str) -> bool:
|
|
||||||
"""True when *reason* is a genuine profile-permission denial (#897)."""
|
|
||||||
r = (reason or "").lower()
|
|
||||||
if not r:
|
|
||||||
return False
|
|
||||||
if _reason_is_stale_runtime(reason) or _reason_is_runtime_mode(reason):
|
|
||||||
return False
|
|
||||||
if "profile could not be resolved" in r:
|
|
||||||
return True
|
|
||||||
if "profile has no configured allowed operations" in r:
|
|
||||||
return True
|
|
||||||
if "profile forbids" in r:
|
|
||||||
return True
|
|
||||||
if "profile is not allowed to" in r:
|
|
||||||
return True
|
|
||||||
if "unrecognized forbidden operation" in r:
|
|
||||||
return True
|
|
||||||
return False
|
|
||||||
|
|
||||||
|
|
||||||
def _classify_operation_gate_reasons(reasons: list[str]) -> dict:
|
|
||||||
"""Partition gate reasons into stale / runtime-mode / permission (#897)."""
|
|
||||||
stale: list[str] = []
|
|
||||||
runtime_mode: list[str] = []
|
|
||||||
permission: list[str] = []
|
|
||||||
other: list[str] = []
|
|
||||||
for reason in reasons or []:
|
|
||||||
if _reason_is_stale_runtime(reason):
|
|
||||||
stale.append(reason)
|
|
||||||
elif _reason_is_runtime_mode(reason):
|
|
||||||
runtime_mode.append(reason)
|
|
||||||
elif _reason_is_permission(reason):
|
|
||||||
permission.append(reason)
|
|
||||||
else:
|
|
||||||
other.append(reason)
|
|
||||||
return {
|
|
||||||
"stale_runtime": stale,
|
|
||||||
"runtime_mode": runtime_mode,
|
|
||||||
"permission": permission,
|
|
||||||
"other": other,
|
|
||||||
}
|
|
||||||
|
|
||||||
|
|
||||||
def _stale_runtime_reconnect_action() -> str:
|
|
||||||
"""Sanctioned recovery for a stale daemon — reconnect only (#685/#897)."""
|
|
||||||
return (
|
|
||||||
"Reconnect the IDE/client MCP session so the server reloads at the "
|
|
||||||
"current master head. Do not call gitea_activate_profile or switch "
|
|
||||||
"MCP role sessions — profile switching does not clear a stale daemon."
|
|
||||||
)
|
|
||||||
|
|
||||||
|
|
||||||
def _build_operation_gate_refusal(
|
|
||||||
required_operation: str,
|
|
||||||
reasons: list[str],
|
|
||||||
**extra_fields,
|
|
||||||
) -> dict:
|
|
||||||
"""Structured gate refusal with typed blockers (#897).
|
|
||||||
|
|
||||||
Stale-runtime and runtime-mode refusals never attach a
|
|
||||||
``permission_report`` and never recommend profile switching. True
|
|
||||||
permission denials still get ``permission_report``. When both apply,
|
|
||||||
causes are reported separately under distinct fields.
|
|
||||||
"""
|
|
||||||
classified = _classify_operation_gate_reasons(reasons)
|
|
||||||
stale = classified["stale_runtime"]
|
|
||||||
runtime_mode = classified["runtime_mode"]
|
|
||||||
permission = classified["permission"]
|
|
||||||
other = classified["other"]
|
|
||||||
|
|
||||||
blocked: dict = {
|
|
||||||
"success": False,
|
|
||||||
"performed": False,
|
|
||||||
"reasons": list(reasons),
|
|
||||||
"mutation_performed": False,
|
|
||||||
"session_context_audit": session_ctx.mutation_context_audit_fields(),
|
|
||||||
"gate_reason_classes": {
|
|
||||||
"stale_runtime": list(stale),
|
|
||||||
"runtime_mode": list(runtime_mode),
|
|
||||||
"permission": list(permission),
|
|
||||||
"other": list(other),
|
|
||||||
},
|
|
||||||
}
|
|
||||||
|
|
||||||
if stale:
|
|
||||||
parity = _current_master_parity()
|
|
||||||
blocked["blocker_kind"] = "runtime_reconnect_required"
|
|
||||||
blocked["restart_required"] = True
|
|
||||||
blocked["stop_required"] = True
|
|
||||||
blocked["startup_head"] = parity.get("startup_head")
|
|
||||||
blocked["current_head"] = parity.get("current_head")
|
|
||||||
blocked["daemon_start_head"] = (
|
|
||||||
parity.get("daemon_start_head") or parity.get("startup_head")
|
|
||||||
)
|
|
||||||
blocked["local_head"] = (
|
|
||||||
parity.get("local_head") or parity.get("current_head")
|
|
||||||
)
|
|
||||||
blocked["live_remote_head"] = parity.get("live_remote_head")
|
|
||||||
blocked["live_stale"] = bool(parity.get("live_stale"))
|
|
||||||
blocked["live_known"] = bool(parity.get("live_known"))
|
|
||||||
blocked["exact_safe_next_action"] = _stale_runtime_reconnect_action()
|
|
||||||
if permission or other:
|
|
||||||
blocked["permission_block_reasons"] = list(permission) + list(other)
|
|
||||||
blocked["stale_runtime_reasons"] = list(stale)
|
|
||||||
# Never attach permission_report for a staleness refusal.
|
|
||||||
blocked.update(extra_fields)
|
|
||||||
return blocked
|
|
||||||
|
|
||||||
if runtime_mode:
|
|
||||||
blocked["blocker_kind"] = "runtime_mode_blocked"
|
|
||||||
blocked["restart_required"] = False
|
|
||||||
blocked["stop_required"] = True
|
|
||||||
blocked["exact_safe_next_action"] = (
|
|
||||||
"Real workflow mutations run only on the promoted stable control "
|
|
||||||
"runtime. Promote/reload the stable runtime; do not call "
|
|
||||||
"gitea_activate_profile or switch MCP role sessions to clear a "
|
|
||||||
"runtime-mode block."
|
|
||||||
)
|
|
||||||
if permission or other:
|
|
||||||
blocked["permission_block_reasons"] = list(permission) + list(other)
|
|
||||||
blocked["runtime_mode_reasons"] = list(runtime_mode)
|
|
||||||
blocked.update(extra_fields)
|
|
||||||
return blocked
|
|
||||||
|
|
||||||
# Pure permission (or unclassified-as-permission) denial.
|
|
||||||
blocked["blocker_kind"] = "permission_denied"
|
|
||||||
blocked["permission_report"] = _permission_block_report(required_operation)
|
|
||||||
blocked.update(extra_fields)
|
|
||||||
return blocked
|
|
||||||
|
|
||||||
|
|
||||||
def _permission_report_for_gate_reasons(
|
|
||||||
required_operation: str,
|
|
||||||
reasons: list[str] | None,
|
|
||||||
) -> dict | None:
|
|
||||||
"""Attach ``permission_report`` only for true permission denials (#897).
|
|
||||||
|
|
||||||
Call sites that historically always attached a permission report after
|
|
||||||
``_profile_operation_gate`` should use this so stale/runtime refusals
|
|
||||||
do not emit a fabricated missing-permission payload.
|
|
||||||
"""
|
|
||||||
if not reasons:
|
|
||||||
return None
|
|
||||||
classified = _classify_operation_gate_reasons(reasons)
|
|
||||||
if classified["stale_runtime"] or classified["runtime_mode"]:
|
|
||||||
return None
|
|
||||||
if not (classified["permission"] or classified["other"]):
|
|
||||||
return None
|
|
||||||
return _permission_block_report(required_operation)
|
|
||||||
|
|
||||||
|
|
||||||
def _role_for_operation(op: str) -> str | None:
|
def _role_for_operation(op: str) -> str | None:
|
||||||
# Normalize op first
|
# Normalize op first
|
||||||
try:
|
try:
|
||||||
@@ -14307,7 +14079,7 @@ def _master_parity_block(op: str) -> list[str]:
|
|||||||
|
|
||||||
|
|
||||||
def _profile_operation_gate(op: str) -> list[str]:
|
def _profile_operation_gate(op: str) -> list[str]:
|
||||||
"""Profile permission check for a single gated operation (#126, #216, #420, #897).
|
"""Profile permission check for a single gated operation (#126, #216, #420).
|
||||||
|
|
||||||
Issue discussion comments are gated separately from the gitea.pr.*
|
Issue discussion comments are gated separately from the gitea.pr.*
|
||||||
review/merge family: listing requires ``gitea.read``, creating requires
|
review/merge family: listing requires ``gitea.read``, creating requires
|
||||||
@@ -14320,26 +14092,21 @@ def _profile_operation_gate(op: str) -> list[str]:
|
|||||||
capability gate that has since been merged, and when the runtime itself is
|
capability gate that has since been merged, and when the runtime itself is
|
||||||
not the promoted stable control runtime (#615) -- a dev/test or unknown
|
not the promoted stable control runtime (#615) -- a dev/test or unknown
|
||||||
runtime holds production credentials but has not been promoted.
|
runtime holds production credentials but has not been promoted.
|
||||||
|
|
||||||
#897: collect *all* independent refusal classes (stale, runtime-mode,
|
|
||||||
permission) rather than short-circuiting after the first. Callers that
|
|
||||||
only need a boolean still treat any non-empty list as blocked; typed
|
|
||||||
consumers (``_build_operation_gate_refusal``) can separate causes.
|
|
||||||
"""
|
"""
|
||||||
reasons: list[str] = []
|
stale_reasons = _master_parity_block(op)
|
||||||
reasons.extend(_master_parity_block(op))
|
if stale_reasons:
|
||||||
reasons.extend(_runtime_mode_block(op))
|
return stale_reasons
|
||||||
|
runtime_reasons = _runtime_mode_block(op)
|
||||||
|
if runtime_reasons:
|
||||||
|
return runtime_reasons
|
||||||
try:
|
try:
|
||||||
profile = get_profile()
|
profile = get_profile()
|
||||||
except Exception as exc:
|
except Exception as exc:
|
||||||
reasons.append(
|
return [f"profile could not be resolved (fail closed): {_redact(str(exc))}"]
|
||||||
f"profile could not be resolved (fail closed): {_redact(str(exc))}"
|
|
||||||
)
|
|
||||||
return reasons
|
|
||||||
op_ok, op_reason = gitea_config.check_operation(
|
op_ok, op_reason = gitea_config.check_operation(
|
||||||
op, profile["allowed_operations"], profile["forbidden_operations"])
|
op, profile["allowed_operations"], profile["forbidden_operations"])
|
||||||
if op_ok:
|
if op_ok:
|
||||||
return reasons
|
return []
|
||||||
|
|
||||||
if _try_auto_switch_for_operation(op):
|
if _try_auto_switch_for_operation(op):
|
||||||
try:
|
try:
|
||||||
@@ -14347,26 +14114,17 @@ def _profile_operation_gate(op: str) -> list[str]:
|
|||||||
op_ok, op_reason = gitea_config.check_operation(
|
op_ok, op_reason = gitea_config.check_operation(
|
||||||
op, profile["allowed_operations"], profile["forbidden_operations"])
|
op, profile["allowed_operations"], profile["forbidden_operations"])
|
||||||
if op_ok:
|
if op_ok:
|
||||||
return reasons
|
return []
|
||||||
except Exception as exc:
|
except Exception as exc:
|
||||||
reasons.append(
|
return [f"profile could not be resolved (fail closed): {_redact(str(exc))}"]
|
||||||
f"profile could not be resolved (fail closed): {_redact(str(exc))}"
|
|
||||||
)
|
|
||||||
return reasons
|
|
||||||
|
|
||||||
if op_reason == "no-allowed-operations":
|
if op_reason == "no-allowed-operations":
|
||||||
reasons.append(
|
return ["profile has no configured allowed operations (fail closed)"]
|
||||||
"profile has no configured allowed operations (fail closed)"
|
if op_reason == "forbidden":
|
||||||
)
|
return [f"profile forbids '{op}'"]
|
||||||
elif op_reason == "forbidden":
|
if op_reason == "invalid-forbidden-entry":
|
||||||
reasons.append(f"profile forbids '{op}'")
|
return ["profile has an unrecognized forbidden operation entry (fail closed)"]
|
||||||
elif op_reason == "invalid-forbidden-entry":
|
return [f"profile is not allowed to {op}"]
|
||||||
reasons.append(
|
|
||||||
"profile has an unrecognized forbidden operation entry (fail closed)"
|
|
||||||
)
|
|
||||||
else:
|
|
||||||
reasons.append(f"profile is not allowed to {op}")
|
|
||||||
return reasons
|
|
||||||
|
|
||||||
|
|
||||||
def _mutation_config_authority_block(required_operation: str) -> dict | None:
|
def _mutation_config_authority_block(required_operation: str) -> dict | None:
|
||||||
@@ -14586,14 +14344,10 @@ def _session_context_mutation_block(
|
|||||||
|
|
||||||
|
|
||||||
def _profile_permission_block(required_operation: str, **extra_fields) -> dict | None:
|
def _profile_permission_block(required_operation: str, **extra_fields) -> dict | None:
|
||||||
"""Structured operation-gate denial for gated tools (#69, #142, #897).
|
"""Structured permission denial for gated tools (#69, #142).
|
||||||
|
|
||||||
Returns a block dict when the active profile forbids *required_operation*,
|
Returns a block dict when the active profile forbids *required_operation*,
|
||||||
the daemon is stale, or the runtime mode is not mutation-safe — or
|
or ``None`` when the gate passes. Never performs network I/O.
|
||||||
``None`` when the gate passes. Never performs network I/O.
|
|
||||||
|
|
||||||
#897: stale-runtime and runtime-mode refusals are typed
|
|
||||||
(``blocker_kind``) and never carry a ``permission_report``.
|
|
||||||
"""
|
"""
|
||||||
req_role = "reviewer" if any(required_operation.startswith(p) for p in (
|
req_role = "reviewer" if any(required_operation.startswith(p) for p in (
|
||||||
"gitea.pr.approve", "gitea.pr.merge", "gitea.pr.request_changes", "gitea.pr.review"
|
"gitea.pr.approve", "gitea.pr.merge", "gitea.pr.request_changes", "gitea.pr.review"
|
||||||
@@ -14603,9 +14357,15 @@ def _profile_permission_block(required_operation: str, **extra_fields) -> dict |
|
|||||||
|
|
||||||
reasons = _profile_operation_gate(required_operation)
|
reasons = _profile_operation_gate(required_operation)
|
||||||
if reasons:
|
if reasons:
|
||||||
return _build_operation_gate_refusal(
|
blocked = {
|
||||||
required_operation, reasons, **extra_fields
|
"success": False,
|
||||||
)
|
"performed": False,
|
||||||
|
"reasons": reasons,
|
||||||
|
"permission_report": _permission_block_report(required_operation),
|
||||||
|
"session_context_audit": session_ctx.mutation_context_audit_fields(),
|
||||||
|
}
|
||||||
|
blocked.update(extra_fields)
|
||||||
|
return blocked
|
||||||
|
|
||||||
auth_block = _mutation_config_authority_block(required_operation)
|
auth_block = _mutation_config_authority_block(required_operation)
|
||||||
if auth_block is not None:
|
if auth_block is not None:
|
||||||
@@ -14734,14 +14494,20 @@ def gitea_acquire_reviewer_pr_lease(
|
|||||||
"""Acquire a per-PR reviewer lease before review/merge mutations (#407)."""
|
"""Acquire a per-PR reviewer lease before review/merge mutations (#407)."""
|
||||||
read_block = _profile_operation_gate("gitea.read")
|
read_block = _profile_operation_gate("gitea.read")
|
||||||
if read_block:
|
if read_block:
|
||||||
return _build_operation_gate_refusal(
|
return {
|
||||||
"gitea.read", read_block, acquired=False
|
"success": False,
|
||||||
)
|
"acquired": False,
|
||||||
|
"reasons": read_block,
|
||||||
|
"permission_report": _permission_block_report("gitea.read"),
|
||||||
|
}
|
||||||
comment_block = _profile_operation_gate("gitea.pr.comment")
|
comment_block = _profile_operation_gate("gitea.pr.comment")
|
||||||
if comment_block:
|
if comment_block:
|
||||||
return _build_operation_gate_refusal(
|
return {
|
||||||
"gitea.pr.comment", comment_block, acquired=False
|
"success": False,
|
||||||
)
|
"acquired": False,
|
||||||
|
"reasons": comment_block,
|
||||||
|
"permission_report": _permission_block_report("gitea.pr.comment"),
|
||||||
|
}
|
||||||
|
|
||||||
# task=acquire_reviewer_pr_lease so verify_preflight_purity runs shared #604
|
# task=acquire_reviewer_pr_lease so verify_preflight_purity runs shared #604
|
||||||
# anti-stomp for the declared lease-acquire mutation inventory entry.
|
# anti-stomp for the declared lease-acquire mutation inventory entry.
|
||||||
@@ -14857,14 +14623,20 @@ def gitea_acquire_merger_pr_lease(
|
|||||||
"""
|
"""
|
||||||
read_block = _profile_operation_gate("gitea.read")
|
read_block = _profile_operation_gate("gitea.read")
|
||||||
if read_block:
|
if read_block:
|
||||||
return _build_operation_gate_refusal(
|
return {
|
||||||
"gitea.read", read_block, acquired=False
|
"success": False,
|
||||||
)
|
"acquired": False,
|
||||||
|
"reasons": read_block,
|
||||||
|
"permission_report": _permission_block_report("gitea.read"),
|
||||||
|
}
|
||||||
comment_block = _profile_operation_gate("gitea.pr.comment")
|
comment_block = _profile_operation_gate("gitea.pr.comment")
|
||||||
if comment_block:
|
if comment_block:
|
||||||
return _build_operation_gate_refusal(
|
return {
|
||||||
"gitea.pr.comment", comment_block, acquired=False
|
"success": False,
|
||||||
)
|
"acquired": False,
|
||||||
|
"reasons": comment_block,
|
||||||
|
"permission_report": _permission_block_report("gitea.pr.comment"),
|
||||||
|
}
|
||||||
merge_block = _profile_operation_gate("gitea.pr.merge")
|
merge_block = _profile_operation_gate("gitea.pr.merge")
|
||||||
if merge_block:
|
if merge_block:
|
||||||
return {
|
return {
|
||||||
@@ -19603,12 +19375,14 @@ def gitea_update_pr_branch_by_merge(
|
|||||||
# Permission: author branch push / PR mutation surface.
|
# Permission: author branch push / PR mutation surface.
|
||||||
push_block = _profile_operation_gate("gitea.branch.push")
|
push_block = _profile_operation_gate("gitea.branch.push")
|
||||||
if push_block:
|
if push_block:
|
||||||
return _build_operation_gate_refusal(
|
return {
|
||||||
"gitea.branch.push",
|
"success": False,
|
||||||
push_block,
|
"performed": False,
|
||||||
mutation_allowed=False,
|
"mutation_allowed": False,
|
||||||
role_kind=role,
|
"reasons": push_block,
|
||||||
)
|
"permission_report": _permission_block_report("gitea.branch.push"),
|
||||||
|
"role_kind": role,
|
||||||
|
}
|
||||||
|
|
||||||
if role != "author":
|
if role != "author":
|
||||||
pre = pr_sync_status.assess_update_pr_branch_preflight(
|
pre = pr_sync_status.assess_update_pr_branch_preflight(
|
||||||
@@ -22569,12 +22343,17 @@ def gitea_request_mcp_restart(
|
|||||||
request_override: bool = False,
|
request_override: bool = False,
|
||||||
session_id: str | None = None,
|
session_id: str | None = None,
|
||||||
limit: int = 200,
|
limit: int = 200,
|
||||||
|
restart_class: str = "full_mcp_restart",
|
||||||
|
target_session_id: str | None = None,
|
||||||
|
target_role: str | None = None,
|
||||||
|
target_connector: str | None = None,
|
||||||
drain_proof_json: str | None = None,
|
drain_proof_json: str | None = None,
|
||||||
request_break_glass: bool = False,
|
request_break_glass: bool = False,
|
||||||
) -> dict:
|
) -> dict:
|
||||||
"""Evaluate a proposed MCP restart and return an impact preview (#658).
|
"""Evaluate a proposed MCP restart and return an impact preview (#658).
|
||||||
|
|
||||||
Central restart coordinator: gathers live control-plane state (sessions,
|
Central restart coordinator: resolves the requested restart class, gathers
|
||||||
|
live control-plane state (sessions,
|
||||||
leases/locks, in-flight issue/PR work, mutations, worktrees) and returns a
|
leases/locks, in-flight issue/PR work, mutations, worktrees) and returns a
|
||||||
blast-radius impact report with a ``safe`` / ``unsafe`` / ``override``
|
blast-radius impact report with a ``safe`` / ``unsafe`` / ``override``
|
||||||
verdict, so the console (#642/#652) and operators can see what a restart
|
verdict, so the console (#642/#652) and operators can see what a restart
|
||||||
@@ -22588,7 +22367,16 @@ def gitea_request_mcp_restart(
|
|||||||
only when ``request_break_glass`` is set *and* the environment carries
|
only when ``request_break_glass`` is set *and* the environment carries
|
||||||
``GITEA_BREAKGLASS_RESTART_AUTHORIZATION``. Even an authorized gate performs
|
``GITEA_BREAKGLASS_RESTART_AUTHORIZATION``. Even an authorized gate performs
|
||||||
no restart here; actual execution is a further child. The gate outcome is
|
no restart here; actual execution is a further child. The gate outcome is
|
||||||
reported under ``apply_gate`` / ``apply_authorized``.
|
reported under ``apply_gate``.
|
||||||
|
|
||||||
|
``apply_authorized`` requires **both** authorizations to pass: the #661 drain
|
||||||
|
gate *and* the #663 restart-class matrix (``allow_restart``). They are
|
||||||
|
independent — the drain gate proves the blast radius was drained and knows
|
||||||
|
nothing about whether this requester may request this class — so a class the
|
||||||
|
matrix denied never reports an authorized apply. Break-glass bypasses the
|
||||||
|
drain proof only; it never bypasses the class matrix. ``apply_gate`` carries
|
||||||
|
``drain_gate_allow`` and ``restart_class_authorized`` so a denial is
|
||||||
|
attributable to the authorization that produced it.
|
||||||
|
|
||||||
Operator override authority is read from the process environment
|
Operator override authority is read from the process environment
|
||||||
(``GITEA_OPERATOR_RESTART_OVERRIDE_AUTHORIZATION``), never self-asserted by
|
(``GITEA_OPERATOR_RESTART_OVERRIDE_AUTHORIZATION``), never self-asserted by
|
||||||
@@ -22673,6 +22461,9 @@ def gitea_request_mcp_restart(
|
|||||||
|
|
||||||
profile = get_profile()
|
profile = get_profile()
|
||||||
profile_name = (profile.get("profile_name") or "").strip() or "session"
|
profile_name = (profile.get("profile_name") or "").strip() or "session"
|
||||||
|
requester_role = (
|
||||||
|
profile.get("role_kind") or profile.get("role") or ""
|
||||||
|
).strip().lower()
|
||||||
sid = (session_id or "").strip() or f"{profile_name}-{os.getpid()}"
|
sid = (session_id or "").strip() or f"{profile_name}-{os.getpid()}"
|
||||||
|
|
||||||
# Override authority is read from the environment only — a worker session
|
# Override authority is read from the environment only — a worker session
|
||||||
@@ -22682,6 +22473,15 @@ def gitea_request_mcp_restart(
|
|||||||
(os.environ.get("GITEA_OPERATOR_RESTART_OVERRIDE_AUTHORIZATION") or "").strip()
|
(os.environ.get("GITEA_OPERATOR_RESTART_OVERRIDE_AUTHORIZATION") or "").strip()
|
||||||
)
|
)
|
||||||
operator_override = bool(request_override and operator_authorized)
|
operator_override = bool(request_override and operator_authorized)
|
||||||
|
controller_approved = bool(
|
||||||
|
(
|
||||||
|
os.environ.get("GITEA_CONTROLLER_RESTART_APPROVAL_AUTHORIZATION")
|
||||||
|
or ""
|
||||||
|
).strip()
|
||||||
|
)
|
||||||
|
requester_permissions = restart_coordinator.permissions_for_role(
|
||||||
|
requester_role
|
||||||
|
)
|
||||||
|
|
||||||
inventory = {
|
inventory = {
|
||||||
"sessions": sessions,
|
"sessions": sessions,
|
||||||
@@ -22696,6 +22496,14 @@ def gitea_request_mcp_restart(
|
|||||||
operator_override=operator_override,
|
operator_override=operator_override,
|
||||||
requesting_session_id=sid,
|
requesting_session_id=sid,
|
||||||
dry_run=True, # coordinator is always analysis-only (#658)
|
dry_run=True, # coordinator is always analysis-only (#658)
|
||||||
|
restart_class=restart_class,
|
||||||
|
requester_role=requester_role,
|
||||||
|
requester_permissions=requester_permissions,
|
||||||
|
controller_approved=controller_approved,
|
||||||
|
operator_authorized=operator_authorized,
|
||||||
|
target_session_id=target_session_id,
|
||||||
|
target_role=target_role,
|
||||||
|
target_connector=target_connector,
|
||||||
)
|
)
|
||||||
|
|
||||||
payload = report.as_dict()
|
payload = report.as_dict()
|
||||||
@@ -22707,6 +22515,9 @@ def gitea_request_mcp_restart(
|
|||||||
payload["requesting_session_id"] = sid
|
payload["requesting_session_id"] = sid
|
||||||
payload["operator_override_requested"] = bool(request_override)
|
payload["operator_override_requested"] = bool(request_override)
|
||||||
payload["operator_override_authorized"] = operator_authorized
|
payload["operator_override_authorized"] = operator_authorized
|
||||||
|
payload["controller_approval_authorized"] = controller_approved
|
||||||
|
payload["requester_role"] = requester_role
|
||||||
|
payload["requester_permissions"] = list(requester_permissions)
|
||||||
# Actual restart execution remains a further child; this tool never restarts
|
# Actual restart execution remains a further child; this tool never restarts
|
||||||
# a process. What #661 adds is the *hard gate*: an apply request (dry_run
|
# a process. What #661 adds is the *hard gate*: an apply request (dry_run
|
||||||
# False) must present a valid, unexpired, clean drain proof, or it is denied
|
# False) must present a valid, unexpired, clean drain proof, or it is denied
|
||||||
@@ -22744,8 +22555,25 @@ def gitea_request_mcp_restart(
|
|||||||
gate_payload["reasons"] = [proof_parse_error] + list(
|
gate_payload["reasons"] = [proof_parse_error] + list(
|
||||||
gate_payload.get("reasons") or []
|
gate_payload.get("reasons") or []
|
||||||
)
|
)
|
||||||
|
# The #663 restart-class matrix and the #661 drain gate are two
|
||||||
|
# independent authorizations, and an apply requires BOTH. ``gate.allow``
|
||||||
|
# proves only that the blast radius was drained — or that break-glass
|
||||||
|
# was authorized — and knows nothing about whether this requester may
|
||||||
|
# request this class at all. Conjoining them keeps a class the matrix
|
||||||
|
# denied from ever reporting an authorized apply, and keeps break-glass
|
||||||
|
# scoped to what it is for: bypassing the drain proof, never the
|
||||||
|
# least-privilege class matrix.
|
||||||
|
restart_class_authorized = bool(report.allow_restart)
|
||||||
|
gate_payload["drain_gate_allow"] = bool(gate.allow)
|
||||||
|
gate_payload["restart_class_authorized"] = restart_class_authorized
|
||||||
|
if not restart_class_authorized:
|
||||||
|
gate_payload["reasons"] = list(gate_payload.get("reasons") or []) + [
|
||||||
|
"restart class authorization denied; apply denied regardless of "
|
||||||
|
"drain proof or break-glass (fail closed, #663)",
|
||||||
|
*(report.authorization_reasons or []),
|
||||||
|
]
|
||||||
payload["apply_gate"] = gate_payload
|
payload["apply_gate"] = gate_payload
|
||||||
payload["apply_authorized"] = gate.allow
|
payload["apply_authorized"] = bool(gate.allow and restart_class_authorized)
|
||||||
payload["break_glass_requested"] = bool(request_break_glass)
|
payload["break_glass_requested"] = bool(request_break_glass)
|
||||||
payload["break_glass_authorized"] = break_glass_authorized
|
payload["break_glass_authorized"] = break_glass_authorized
|
||||||
# Even an authorized gate performs no restart here: execution is a later
|
# Even an authorized gate performs no restart here: execution is a later
|
||||||
|
|||||||
+371
-15
@@ -28,11 +28,12 @@ from __future__ import annotations
|
|||||||
|
|
||||||
from dataclasses import dataclass, field
|
from dataclasses import dataclass, field
|
||||||
from datetime import datetime, timezone
|
from datetime import datetime, timezone
|
||||||
|
from enum import Enum
|
||||||
from typing import Any, Mapping, Sequence
|
from typing import Any, Mapping, Sequence
|
||||||
|
|
||||||
import lease_lifecycle
|
import lease_lifecycle
|
||||||
|
|
||||||
COORDINATOR_VERSION = "1.0.0-issue-658"
|
COORDINATOR_VERSION = "1.1.0-issue-663"
|
||||||
|
|
||||||
# Restart verdicts. Exactly the three the acceptance criteria name.
|
# Restart verdicts. Exactly the three the acceptance criteria name.
|
||||||
VERDICT_SAFE = "safe"
|
VERDICT_SAFE = "safe"
|
||||||
@@ -54,6 +55,194 @@ LEASE_FRESHNESS_LIVE = "active"
|
|||||||
DEFAULT_SESSION_HEARTBEAT_STALE_SECONDS = 900
|
DEFAULT_SESSION_HEARTBEAT_STALE_SECONDS = 900
|
||||||
|
|
||||||
|
|
||||||
|
class RestartClass(str, Enum):
|
||||||
|
"""The only restart/recovery classes accepted by the coordinator."""
|
||||||
|
|
||||||
|
CLIENT_RECONNECT = "client_reconnect"
|
||||||
|
SESSION_RECONNECT = "session_reconnect"
|
||||||
|
WORKER_RESTART = "worker_restart"
|
||||||
|
ROLE_RUNTIME_RESTART = "role_runtime_restart"
|
||||||
|
CONNECTOR_RESTART = "connector_restart"
|
||||||
|
CONFIGURATION_RELOAD = "configuration_reload"
|
||||||
|
ROLLING_MCP_RESTART = "rolling_mcp_restart"
|
||||||
|
FULL_MCP_RESTART = "full_mcp_restart"
|
||||||
|
HOST_RESTART = "host_restart"
|
||||||
|
|
||||||
|
|
||||||
|
@dataclass(frozen=True)
|
||||||
|
class RestartClassPolicy:
|
||||||
|
"""Least-privilege policy for one :class:`RestartClass`."""
|
||||||
|
|
||||||
|
restart_class: RestartClass
|
||||||
|
required_permission: str
|
||||||
|
expected_blast_radius: str
|
||||||
|
drain_requirement: str
|
||||||
|
full_drain_required: bool
|
||||||
|
approval_requirement: str
|
||||||
|
audit_requirement: str
|
||||||
|
recovery_behavior: str
|
||||||
|
request_roles: tuple[str, ...]
|
||||||
|
execution_roles: tuple[str, ...]
|
||||||
|
|
||||||
|
def as_dict(self) -> dict[str, Any]:
|
||||||
|
return {
|
||||||
|
"restart_class": self.restart_class.value,
|
||||||
|
"required_permission": self.required_permission,
|
||||||
|
"expected_blast_radius": self.expected_blast_radius,
|
||||||
|
"drain_requirement": self.drain_requirement,
|
||||||
|
"full_drain_required": self.full_drain_required,
|
||||||
|
"approval_requirement": self.approval_requirement,
|
||||||
|
"audit_requirement": self.audit_requirement,
|
||||||
|
"recovery_behavior": self.recovery_behavior,
|
||||||
|
"request_roles": list(self.request_roles),
|
||||||
|
"execution_roles": list(self.execution_roles),
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
WORKER_ROLES = ("author", "reviewer", "merger", "reconciler")
|
||||||
|
CONTROL_ROLES = ("controller", "operator", "admin")
|
||||||
|
ALL_REQUEST_ROLES = WORKER_ROLES + CONTROL_ROLES
|
||||||
|
|
||||||
|
RESTART_CLASS_POLICIES: dict[RestartClass, RestartClassPolicy] = {
|
||||||
|
RestartClass.CLIENT_RECONNECT: RestartClassPolicy(
|
||||||
|
RestartClass.CLIENT_RECONNECT,
|
||||||
|
"mcp.reconnect.client",
|
||||||
|
BLAST_NONE,
|
||||||
|
"none",
|
||||||
|
False,
|
||||||
|
"self_service",
|
||||||
|
"record class, actor, client namespace, reason, and outcome",
|
||||||
|
"Reconnect only the caller's client transport; no daemon or peer session changes.",
|
||||||
|
ALL_REQUEST_ROLES,
|
||||||
|
ALL_REQUEST_ROLES,
|
||||||
|
),
|
||||||
|
RestartClass.SESSION_RECONNECT: RestartClassPolicy(
|
||||||
|
RestartClass.SESSION_RECONNECT,
|
||||||
|
"mcp.reconnect.session",
|
||||||
|
BLAST_LOW,
|
||||||
|
"requesting_session_safe_point",
|
||||||
|
False,
|
||||||
|
"self_service",
|
||||||
|
"record class, actor, session id, reason, and outcome",
|
||||||
|
"Rebind identity, capability, and workspace state for one session.",
|
||||||
|
ALL_REQUEST_ROLES,
|
||||||
|
ALL_REQUEST_ROLES,
|
||||||
|
),
|
||||||
|
RestartClass.WORKER_RESTART: RestartClassPolicy(
|
||||||
|
RestartClass.WORKER_RESTART,
|
||||||
|
"mcp.restart.worker.request",
|
||||||
|
BLAST_LOW,
|
||||||
|
"target_worker",
|
||||||
|
False,
|
||||||
|
"controller_approval_and_automated_gates",
|
||||||
|
"record class, actor, target worker, approval, drain proof, and outcome",
|
||||||
|
"Restart one worker after its own lease and mutation scope is drained.",
|
||||||
|
ALL_REQUEST_ROLES,
|
||||||
|
("operator", "admin"),
|
||||||
|
),
|
||||||
|
RestartClass.ROLE_RUNTIME_RESTART: RestartClassPolicy(
|
||||||
|
RestartClass.ROLE_RUNTIME_RESTART,
|
||||||
|
"mcp.restart.role_runtime.request",
|
||||||
|
BLAST_MEDIUM,
|
||||||
|
"target_role_runtime",
|
||||||
|
False,
|
||||||
|
"controller_approval_and_automated_gates",
|
||||||
|
"record class, actor, role namespace, approval, drain proof, and outcome",
|
||||||
|
"Restart only the selected role runtime and then re-probe that namespace.",
|
||||||
|
ALL_REQUEST_ROLES,
|
||||||
|
("operator", "admin"),
|
||||||
|
),
|
||||||
|
RestartClass.CONNECTOR_RESTART: RestartClassPolicy(
|
||||||
|
RestartClass.CONNECTOR_RESTART,
|
||||||
|
"mcp.restart.connector.request",
|
||||||
|
BLAST_MEDIUM,
|
||||||
|
"target_connector",
|
||||||
|
False,
|
||||||
|
"controller_approval_and_automated_gates",
|
||||||
|
"record class, actor, connector id, approval, drain proof, and outcome",
|
||||||
|
"Restart one connector while unrelated role runtimes remain available.",
|
||||||
|
ALL_REQUEST_ROLES,
|
||||||
|
("operator", "admin"),
|
||||||
|
),
|
||||||
|
RestartClass.CONFIGURATION_RELOAD: RestartClassPolicy(
|
||||||
|
RestartClass.CONFIGURATION_RELOAD,
|
||||||
|
"mcp.reload.configuration.request",
|
||||||
|
BLAST_LOW,
|
||||||
|
"mutation_quiesce",
|
||||||
|
False,
|
||||||
|
"controller_approval_and_automated_gates",
|
||||||
|
"record class, actor, configuration revision, approval, and outcome",
|
||||||
|
"Gracefully reload configuration without replacing the daemon process.",
|
||||||
|
ALL_REQUEST_ROLES,
|
||||||
|
("operator", "admin"),
|
||||||
|
),
|
||||||
|
RestartClass.ROLLING_MCP_RESTART: RestartClassPolicy(
|
||||||
|
RestartClass.ROLLING_MCP_RESTART,
|
||||||
|
"mcp.restart.rolling.request",
|
||||||
|
BLAST_MEDIUM,
|
||||||
|
"one_instance_at_a_time",
|
||||||
|
False,
|
||||||
|
"controller_approval_and_automated_gates",
|
||||||
|
"record class, actor, instance order, approval, per-instance drains, and outcome",
|
||||||
|
"Drain, restart, verify, and restore one instance before advancing to the next.",
|
||||||
|
CONTROL_ROLES,
|
||||||
|
("operator", "admin"),
|
||||||
|
),
|
||||||
|
RestartClass.FULL_MCP_RESTART: RestartClassPolicy(
|
||||||
|
RestartClass.FULL_MCP_RESTART,
|
||||||
|
"mcp.restart.full.request",
|
||||||
|
BLAST_HIGH,
|
||||||
|
"all_sessions_and_mutations",
|
||||||
|
True,
|
||||||
|
"controller_approval_and_automated_gates",
|
||||||
|
"record class, actor, full impact report, approval, drain proof, and outcome",
|
||||||
|
"Stop and restore the complete MCP runtime only after a verified full drain.",
|
||||||
|
CONTROL_ROLES,
|
||||||
|
("operator", "admin"),
|
||||||
|
),
|
||||||
|
RestartClass.HOST_RESTART: RestartClassPolicy(
|
||||||
|
RestartClass.HOST_RESTART,
|
||||||
|
"mcp.restart.host.request",
|
||||||
|
BLAST_HIGH,
|
||||||
|
"all_host_work",
|
||||||
|
True,
|
||||||
|
"controller_approval_plus_infrastructure_operator",
|
||||||
|
"record class, actor, host, incident or change id, approval, drain proof, and outcome",
|
||||||
|
"Hand off to infrastructure ownership; reconcile every runtime after the host returns.",
|
||||||
|
("controller", "operator", "admin"),
|
||||||
|
("operator", "admin"),
|
||||||
|
),
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
def resolve_restart_class(value: RestartClass | str) -> RestartClass:
|
||||||
|
"""Resolve a restart class or fail closed for an unknown value."""
|
||||||
|
|
||||||
|
if isinstance(value, RestartClass):
|
||||||
|
return value
|
||||||
|
try:
|
||||||
|
return RestartClass(str(value).strip())
|
||||||
|
except ValueError as exc:
|
||||||
|
raise ValueError(f"unknown restart class {value!r}; deny (fail closed)") from exc
|
||||||
|
|
||||||
|
|
||||||
|
def restart_class_policy(value: RestartClass | str) -> RestartClassPolicy:
|
||||||
|
"""Return the canonical policy for *value*."""
|
||||||
|
|
||||||
|
return RESTART_CLASS_POLICIES[resolve_restart_class(value)]
|
||||||
|
|
||||||
|
|
||||||
|
def permissions_for_role(role: str | None) -> tuple[str, ...]:
|
||||||
|
"""Return request permissions granted to a workflow role by this policy."""
|
||||||
|
|
||||||
|
normalized = str(role or "").strip().lower()
|
||||||
|
return tuple(
|
||||||
|
policy.required_permission
|
||||||
|
for policy in RESTART_CLASS_POLICIES.values()
|
||||||
|
if normalized in policy.request_roles
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
def _utc_now() -> datetime:
|
def _utc_now() -> datetime:
|
||||||
return datetime.now(timezone.utc)
|
return datetime.now(timezone.utc)
|
||||||
|
|
||||||
@@ -75,6 +264,7 @@ class SessionImpact:
|
|||||||
heartbeat_stale: bool
|
heartbeat_stale: bool
|
||||||
is_requester: bool
|
is_requester: bool
|
||||||
live: bool
|
live: bool
|
||||||
|
connector: str | None = None
|
||||||
|
|
||||||
def as_dict(self) -> dict[str, Any]:
|
def as_dict(self) -> dict[str, Any]:
|
||||||
return {
|
return {
|
||||||
@@ -87,6 +277,7 @@ class SessionImpact:
|
|||||||
"heartbeat_stale": self.heartbeat_stale,
|
"heartbeat_stale": self.heartbeat_stale,
|
||||||
"is_requester": self.is_requester,
|
"is_requester": self.is_requester,
|
||||||
"live": self.live,
|
"live": self.live,
|
||||||
|
"connector": self.connector,
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
||||||
@@ -105,6 +296,7 @@ class LeaseImpact:
|
|||||||
disruptive: bool
|
disruptive: bool
|
||||||
is_mutation: bool
|
is_mutation: bool
|
||||||
is_critical_section: bool
|
is_critical_section: bool
|
||||||
|
connector: str | None = None
|
||||||
|
|
||||||
def as_dict(self) -> dict[str, Any]:
|
def as_dict(self) -> dict[str, Any]:
|
||||||
return {
|
return {
|
||||||
@@ -119,6 +311,7 @@ class LeaseImpact:
|
|||||||
"disruptive": self.disruptive,
|
"disruptive": self.disruptive,
|
||||||
"is_mutation": self.is_mutation,
|
"is_mutation": self.is_mutation,
|
||||||
"is_critical_section": self.is_critical_section,
|
"is_critical_section": self.is_critical_section,
|
||||||
|
"connector": self.connector,
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
||||||
@@ -127,6 +320,13 @@ class RestartImpactReport:
|
|||||||
"""Impact preview DTO returned to the console / operator (#642/#652)."""
|
"""Impact preview DTO returned to the console / operator (#642/#652)."""
|
||||||
|
|
||||||
coordinator_version: str
|
coordinator_version: str
|
||||||
|
restart_class: str
|
||||||
|
restart_policy: dict[str, Any]
|
||||||
|
policy_enforced: bool
|
||||||
|
permission_authorized: bool
|
||||||
|
role_authorized: bool
|
||||||
|
approval_satisfied: bool
|
||||||
|
authorization_reasons: list[str]
|
||||||
evaluated_at: str
|
evaluated_at: str
|
||||||
dry_run: bool
|
dry_run: bool
|
||||||
restart_performed: bool
|
restart_performed: bool
|
||||||
@@ -153,6 +353,13 @@ class RestartImpactReport:
|
|||||||
def as_dict(self) -> dict[str, Any]:
|
def as_dict(self) -> dict[str, Any]:
|
||||||
return {
|
return {
|
||||||
"coordinator_version": self.coordinator_version,
|
"coordinator_version": self.coordinator_version,
|
||||||
|
"restart_class": self.restart_class,
|
||||||
|
"restart_policy": dict(self.restart_policy),
|
||||||
|
"policy_enforced": self.policy_enforced,
|
||||||
|
"permission_authorized": self.permission_authorized,
|
||||||
|
"role_authorized": self.role_authorized,
|
||||||
|
"approval_satisfied": self.approval_satisfied,
|
||||||
|
"authorization_reasons": list(self.authorization_reasons),
|
||||||
"evaluated_at": self.evaluated_at,
|
"evaluated_at": self.evaluated_at,
|
||||||
"dry_run": self.dry_run,
|
"dry_run": self.dry_run,
|
||||||
"restart_performed": self.restart_performed,
|
"restart_performed": self.restart_performed,
|
||||||
@@ -206,6 +413,7 @@ def _classify_session(
|
|||||||
requesting_session_id and session_id == requesting_session_id
|
requesting_session_id and session_id == requesting_session_id
|
||||||
),
|
),
|
||||||
live=live,
|
live=live,
|
||||||
|
connector=(str(row.get("connector") or "").strip() or None),
|
||||||
)
|
)
|
||||||
|
|
||||||
|
|
||||||
@@ -258,6 +466,7 @@ def _classify_lease(row: Mapping[str, Any]) -> LeaseImpact:
|
|||||||
disruptive=disruptive,
|
disruptive=disruptive,
|
||||||
is_mutation=is_mutation,
|
is_mutation=is_mutation,
|
||||||
is_critical_section=disruptive,
|
is_critical_section=disruptive,
|
||||||
|
connector=(str(row.get("connector") or "").strip() or None),
|
||||||
)
|
)
|
||||||
|
|
||||||
|
|
||||||
@@ -279,6 +488,14 @@ def evaluate_restart_impact(
|
|||||||
requesting_session_id: str | None = None,
|
requesting_session_id: str | None = None,
|
||||||
dry_run: bool = True,
|
dry_run: bool = True,
|
||||||
session_heartbeat_stale_seconds: int = DEFAULT_SESSION_HEARTBEAT_STALE_SECONDS,
|
session_heartbeat_stale_seconds: int = DEFAULT_SESSION_HEARTBEAT_STALE_SECONDS,
|
||||||
|
restart_class: RestartClass | str | None = None,
|
||||||
|
requester_role: str | None = None,
|
||||||
|
requester_permissions: Sequence[str] | None = None,
|
||||||
|
controller_approved: bool = False,
|
||||||
|
operator_authorized: bool = False,
|
||||||
|
target_session_id: str | None = None,
|
||||||
|
target_role: str | None = None,
|
||||||
|
target_connector: str | None = None,
|
||||||
) -> RestartImpactReport:
|
) -> RestartImpactReport:
|
||||||
"""Evaluate a proposed MCP restart and return an impact preview.
|
"""Evaluate a proposed MCP restart and return an impact preview.
|
||||||
|
|
||||||
@@ -301,6 +518,61 @@ def evaluate_restart_impact(
|
|||||||
"""
|
"""
|
||||||
moment = now or _utc_now()
|
moment = now or _utc_now()
|
||||||
reasons: list[str] = []
|
reasons: list[str] = []
|
||||||
|
authorization_reasons: list[str] = []
|
||||||
|
|
||||||
|
# ``None`` preserves the pre-#663 impact-only API for callers that have not
|
||||||
|
# yet been migrated. All MCP requests pass an explicit class and therefore
|
||||||
|
# take the fail-closed policy path.
|
||||||
|
policy_enforced = restart_class is not None
|
||||||
|
try:
|
||||||
|
resolved_class = resolve_restart_class(
|
||||||
|
restart_class or RestartClass.FULL_MCP_RESTART
|
||||||
|
)
|
||||||
|
policy = RESTART_CLASS_POLICIES[resolved_class]
|
||||||
|
unknown_class = False
|
||||||
|
except ValueError as exc:
|
||||||
|
resolved_class = None
|
||||||
|
policy = None
|
||||||
|
unknown_class = True
|
||||||
|
authorization_reasons.append(str(exc))
|
||||||
|
|
||||||
|
normalized_role = str(requester_role or "").strip().lower()
|
||||||
|
granted = {str(p).strip() for p in (requester_permissions or ())}
|
||||||
|
if policy_enforced and policy is not None:
|
||||||
|
permission_authorized = policy.required_permission in granted
|
||||||
|
role_authorized = normalized_role in policy.request_roles
|
||||||
|
if not permission_authorized:
|
||||||
|
authorization_reasons.append(
|
||||||
|
f"missing required permission {policy.required_permission!r}"
|
||||||
|
)
|
||||||
|
if not role_authorized:
|
||||||
|
authorization_reasons.append(
|
||||||
|
f"role {normalized_role or 'unknown'!r} may not request "
|
||||||
|
f"{policy.restart_class.value}"
|
||||||
|
)
|
||||||
|
elif unknown_class:
|
||||||
|
permission_authorized = False
|
||||||
|
role_authorized = False
|
||||||
|
else:
|
||||||
|
permission_authorized = True
|
||||||
|
role_authorized = True
|
||||||
|
|
||||||
|
if policy_enforced and policy is not None:
|
||||||
|
approval = policy.approval_requirement
|
||||||
|
if approval == "self_service":
|
||||||
|
approval_satisfied = True
|
||||||
|
elif approval == "controller_approval_plus_infrastructure_operator":
|
||||||
|
approval_satisfied = bool(controller_approved and operator_authorized)
|
||||||
|
else:
|
||||||
|
approval_satisfied = bool(controller_approved)
|
||||||
|
if not approval_satisfied:
|
||||||
|
authorization_reasons.append(
|
||||||
|
f"approval requirement not satisfied: {approval}"
|
||||||
|
)
|
||||||
|
elif unknown_class:
|
||||||
|
approval_satisfied = False
|
||||||
|
else:
|
||||||
|
approval_satisfied = True
|
||||||
|
|
||||||
inventory_complete = bool(inventory.get("inventory_complete", False))
|
inventory_complete = bool(inventory.get("inventory_complete", False))
|
||||||
incomplete_reasons = [str(r) for r in (inventory.get("incomplete_reasons") or [])]
|
incomplete_reasons = [str(r) for r in (inventory.get("incomplete_reasons") or [])]
|
||||||
@@ -323,15 +595,67 @@ def evaluate_restart_impact(
|
|||||||
]
|
]
|
||||||
lease_impacts = [_classify_lease(l) for l in leases_raw]
|
lease_impacts = [_classify_lease(l) for l in leases_raw]
|
||||||
|
|
||||||
# Only *other* live sessions and live leases constitute blast radius: a
|
# Route impact through the selected class. Narrow classes never inherit a
|
||||||
# restart that would kill only the requesting session with no other work in
|
# full-runtime drain merely because unrelated work exists.
|
||||||
# flight is safe.
|
target_complete = True
|
||||||
other_live_sessions = [
|
if resolved_class in {
|
||||||
s for s in session_impacts if s.live and not s.is_requester
|
RestartClass.CLIENT_RECONNECT,
|
||||||
|
RestartClass.SESSION_RECONNECT,
|
||||||
|
RestartClass.CONFIGURATION_RELOAD,
|
||||||
|
}:
|
||||||
|
scoped_sessions: list[SessionImpact] = []
|
||||||
|
scoped_leases: list[LeaseImpact] = []
|
||||||
|
elif resolved_class == RestartClass.WORKER_RESTART:
|
||||||
|
selected_session = (target_session_id or "").strip()
|
||||||
|
target_complete = bool(selected_session)
|
||||||
|
scoped_sessions = [
|
||||||
|
s for s in session_impacts if s.session_id == selected_session
|
||||||
]
|
]
|
||||||
disruptive_leases = [l for l in lease_impacts if l.disruptive]
|
scoped_leases = [
|
||||||
critical_sections = [l for l in lease_impacts if l.is_critical_section]
|
l for l in lease_impacts if l.session_id == selected_session
|
||||||
mutations = [l for l in lease_impacts if l.is_mutation]
|
]
|
||||||
|
elif resolved_class == RestartClass.ROLE_RUNTIME_RESTART:
|
||||||
|
selected_role = (target_role or "").strip().lower()
|
||||||
|
target_complete = bool(selected_role)
|
||||||
|
scoped_sessions = [
|
||||||
|
s for s in session_impacts if str(s.role or "").lower() == selected_role
|
||||||
|
]
|
||||||
|
scoped_leases = [
|
||||||
|
l for l in lease_impacts if str(l.role or "").lower() == selected_role
|
||||||
|
]
|
||||||
|
elif resolved_class == RestartClass.CONNECTOR_RESTART:
|
||||||
|
selected_connector = (target_connector or "").strip()
|
||||||
|
target_complete = bool(selected_connector)
|
||||||
|
scoped_sessions = [
|
||||||
|
s for s in session_impacts if s.connector == selected_connector
|
||||||
|
]
|
||||||
|
scoped_leases = [
|
||||||
|
l for l in lease_impacts if l.connector == selected_connector
|
||||||
|
]
|
||||||
|
else:
|
||||||
|
scoped_sessions = list(session_impacts)
|
||||||
|
scoped_leases = list(lease_impacts)
|
||||||
|
|
||||||
|
if policy_enforced and not target_complete:
|
||||||
|
authorization_reasons.append(
|
||||||
|
f"target required for {resolved_class.value if resolved_class else 'unknown class'}"
|
||||||
|
)
|
||||||
|
|
||||||
|
other_live_sessions = [
|
||||||
|
s for s in scoped_sessions if s.live and not s.is_requester
|
||||||
|
]
|
||||||
|
disruptive_leases = [l for l in scoped_leases if l.disruptive]
|
||||||
|
critical_sections = [l for l in scoped_leases if l.is_critical_section]
|
||||||
|
mutations = [l for l in scoped_leases if l.is_mutation]
|
||||||
|
terminal_lock_in_scope = (
|
||||||
|
terminal_lock
|
||||||
|
if resolved_class
|
||||||
|
not in {
|
||||||
|
RestartClass.CLIENT_RECONNECT,
|
||||||
|
RestartClass.SESSION_RECONNECT,
|
||||||
|
}
|
||||||
|
else None
|
||||||
|
)
|
||||||
|
|
||||||
affected_issues = sorted(
|
affected_issues = sorted(
|
||||||
{
|
{
|
||||||
@@ -348,9 +672,24 @@ def evaluate_restart_impact(
|
|||||||
}
|
}
|
||||||
)
|
)
|
||||||
|
|
||||||
disruptive = bool(disruptive_leases or other_live_sessions or terminal_lock)
|
disruptive = bool(
|
||||||
|
disruptive_leases or other_live_sessions or terminal_lock_in_scope
|
||||||
|
)
|
||||||
|
|
||||||
if not inventory_complete:
|
authorization_ok = bool(
|
||||||
|
not unknown_class
|
||||||
|
and permission_authorized
|
||||||
|
and role_authorized
|
||||||
|
and approval_satisfied
|
||||||
|
and target_complete
|
||||||
|
)
|
||||||
|
|
||||||
|
if policy_enforced and not authorization_ok:
|
||||||
|
verdict = VERDICT_UNSAFE
|
||||||
|
allow_restart = False
|
||||||
|
reasons.append("restart class authorization denied (fail closed)")
|
||||||
|
reasons.extend(authorization_reasons)
|
||||||
|
elif not inventory_complete:
|
||||||
verdict = VERDICT_UNSAFE
|
verdict = VERDICT_UNSAFE
|
||||||
allow_restart = False
|
allow_restart = False
|
||||||
reasons.append(
|
reasons.append(
|
||||||
@@ -381,7 +720,7 @@ def evaluate_restart_impact(
|
|||||||
f"{len(critical_sections)} critical section(s) in flight "
|
f"{len(critical_sections)} critical section(s) in flight "
|
||||||
"(active lease with a live owner)"
|
"(active lease with a live owner)"
|
||||||
)
|
)
|
||||||
if terminal_lock:
|
if terminal_lock_in_scope:
|
||||||
reasons.append("active terminal (merge) lock present")
|
reasons.append("active terminal (merge) lock present")
|
||||||
|
|
||||||
override_would_allow = bool(inventory_complete and disruptive)
|
override_would_allow = bool(inventory_complete and disruptive)
|
||||||
@@ -411,6 +750,12 @@ def evaluate_restart_impact(
|
|||||||
audit_record = {
|
audit_record = {
|
||||||
"event": "restart_impact_evaluated",
|
"event": "restart_impact_evaluated",
|
||||||
"coordinator_version": COORDINATOR_VERSION,
|
"coordinator_version": COORDINATOR_VERSION,
|
||||||
|
"restart_class": (
|
||||||
|
resolved_class.value if resolved_class else str(restart_class or "")
|
||||||
|
),
|
||||||
|
"required_permission": (
|
||||||
|
policy.required_permission if policy is not None else None
|
||||||
|
),
|
||||||
"evaluated_at": moment.isoformat(),
|
"evaluated_at": moment.isoformat(),
|
||||||
"dry_run": dry_run,
|
"dry_run": dry_run,
|
||||||
"operator_override": bool(operator_override),
|
"operator_override": bool(operator_override),
|
||||||
@@ -424,6 +769,15 @@ def evaluate_restart_impact(
|
|||||||
|
|
||||||
return RestartImpactReport(
|
return RestartImpactReport(
|
||||||
coordinator_version=COORDINATOR_VERSION,
|
coordinator_version=COORDINATOR_VERSION,
|
||||||
|
restart_class=(
|
||||||
|
resolved_class.value if resolved_class else str(restart_class or "")
|
||||||
|
),
|
||||||
|
restart_policy=policy.as_dict() if policy is not None else {},
|
||||||
|
policy_enforced=policy_enforced,
|
||||||
|
permission_authorized=permission_authorized,
|
||||||
|
role_authorized=role_authorized,
|
||||||
|
approval_satisfied=approval_satisfied,
|
||||||
|
authorization_reasons=authorization_reasons,
|
||||||
evaluated_at=moment.isoformat(),
|
evaluated_at=moment.isoformat(),
|
||||||
dry_run=dry_run,
|
dry_run=dry_run,
|
||||||
restart_performed=False,
|
restart_performed=False,
|
||||||
@@ -440,9 +794,11 @@ def evaluate_restart_impact(
|
|||||||
affected_issues=affected_issues,
|
affected_issues=affected_issues,
|
||||||
affected_prs=affected_prs,
|
affected_prs=affected_prs,
|
||||||
mutations=mutations,
|
mutations=mutations,
|
||||||
terminal_lock=dict(terminal_lock)
|
terminal_lock=(
|
||||||
if isinstance(terminal_lock, Mapping)
|
dict(terminal_lock_in_scope)
|
||||||
else terminal_lock,
|
if isinstance(terminal_lock_in_scope, Mapping)
|
||||||
|
else terminal_lock_in_scope
|
||||||
|
),
|
||||||
ack_state=ack_state,
|
ack_state=ack_state,
|
||||||
prior_recovery_attempts=prior_recovery_attempts,
|
prior_recovery_attempts=prior_recovery_attempts,
|
||||||
counts=counts,
|
counts=counts,
|
||||||
|
|||||||
@@ -7,6 +7,7 @@ import tempfile
|
|||||||
import threading
|
import threading
|
||||||
import unittest
|
import unittest
|
||||||
from concurrent.futures import ThreadPoolExecutor, as_completed
|
from concurrent.futures import ThreadPoolExecutor, as_completed
|
||||||
|
from datetime import datetime, timezone
|
||||||
|
|
||||||
from allocator_service import (
|
from allocator_service import (
|
||||||
OUTCOME_ASSIGNED,
|
OUTCOME_ASSIGNED,
|
||||||
@@ -15,6 +16,7 @@ from allocator_service import (
|
|||||||
OUTCOME_PREVIEW,
|
OUTCOME_PREVIEW,
|
||||||
OUTCOME_WAIT,
|
OUTCOME_WAIT,
|
||||||
WorkCandidate,
|
WorkCandidate,
|
||||||
|
_drop_expired_claims,
|
||||||
allocate_next_work,
|
allocate_next_work,
|
||||||
candidate_from_dict,
|
candidate_from_dict,
|
||||||
classify_skip,
|
classify_skip,
|
||||||
@@ -362,5 +364,161 @@ class AllocatorServiceTest(unittest.TestCase):
|
|||||||
self.assertIn("unavailable", res["reasons"][0].lower())
|
self.assertIn("unavailable", res["reasons"][0].lower())
|
||||||
|
|
||||||
|
|
||||||
|
class SideEffectFreeAllocationTest(unittest.TestCase):
|
||||||
|
"""``side_effect_free`` dry runs write nothing to the control plane (#643).
|
||||||
|
|
||||||
|
A plain ``apply=False`` still called ``upsert_session`` and
|
||||||
|
``expire_stale_leases`` before the apply branch was consulted, so a caller
|
||||||
|
advertising a read-only preview mutated on every call — one unreferenced
|
||||||
|
session row per preview, plus a global lease sweep.
|
||||||
|
"""
|
||||||
|
|
||||||
|
def setUp(self) -> None:
|
||||||
|
self._tmp = tempfile.TemporaryDirectory()
|
||||||
|
self.db = ControlPlaneDB(os.path.join(self._tmp.name, "cp.sqlite3"))
|
||||||
|
|
||||||
|
def tearDown(self) -> None:
|
||||||
|
self._tmp.cleanup()
|
||||||
|
|
||||||
|
def _alloc(self, **kwargs):
|
||||||
|
defaults = dict(
|
||||||
|
db=self.db,
|
||||||
|
session_id="s-preview",
|
||||||
|
role="author",
|
||||||
|
remote="prgs",
|
||||||
|
org="org",
|
||||||
|
repo="repo",
|
||||||
|
candidates=[
|
||||||
|
WorkCandidate(kind="issue", number=643, labels=("status:ready",))
|
||||||
|
],
|
||||||
|
apply=False,
|
||||||
|
profile_name="prgs-author",
|
||||||
|
username="jcwalker3",
|
||||||
|
)
|
||||||
|
defaults.update(kwargs)
|
||||||
|
return allocate_next_work(**defaults)
|
||||||
|
|
||||||
|
def _session_ids(self) -> set[str]:
|
||||||
|
return {str(r.get("session_id")) for r in self.db.list_sessions()}
|
||||||
|
|
||||||
|
def test_side_effect_free_preview_writes_no_session_row(self):
|
||||||
|
before = self._session_ids()
|
||||||
|
result = self._alloc(side_effect_free=True)
|
||||||
|
self.assertEqual(result["outcome"], OUTCOME_PREVIEW)
|
||||||
|
self.assertEqual(self._session_ids(), before)
|
||||||
|
self.assertNotIn("s-preview", self._session_ids())
|
||||||
|
|
||||||
|
def test_plain_dry_run_still_registers_a_session(self):
|
||||||
|
# The default is unchanged for every existing caller.
|
||||||
|
self._alloc()
|
||||||
|
self.assertIn("s-preview", self._session_ids())
|
||||||
|
|
||||||
|
def test_repeated_previews_do_not_accumulate_rows(self):
|
||||||
|
for index in range(5):
|
||||||
|
self._alloc(side_effect_free=True, session_id=f"s-{index}")
|
||||||
|
self.assertEqual(self._session_ids(), set())
|
||||||
|
|
||||||
|
def test_side_effect_free_does_not_sweep_stale_leases(self):
|
||||||
|
self.db.upsert_session(session_id="owner", role="author", pid=1)
|
||||||
|
assigned = self.db.assign_and_lease(
|
||||||
|
session_id="owner",
|
||||||
|
role="author",
|
||||||
|
remote="prgs",
|
||||||
|
org="org",
|
||||||
|
repo="repo",
|
||||||
|
kind="issue",
|
||||||
|
number=999,
|
||||||
|
lease_ttl_seconds=-60, # already expired
|
||||||
|
)
|
||||||
|
self.assertEqual(assigned.outcome, "assigned")
|
||||||
|
|
||||||
|
self._alloc(side_effect_free=True)
|
||||||
|
|
||||||
|
# The expired row is still 'active' in the DB: nothing swept it.
|
||||||
|
statuses = {
|
||||||
|
r["lease_id"]: r["status"]
|
||||||
|
for r in self.db.list_leases(
|
||||||
|
remote="prgs", org="org", repo="repo",
|
||||||
|
statuses=("active", "expired"),
|
||||||
|
)
|
||||||
|
}
|
||||||
|
self.assertEqual(statuses.get(assigned.lease_id), "active")
|
||||||
|
|
||||||
|
def test_expired_claims_are_filtered_in_memory_so_work_stays_selectable(self):
|
||||||
|
"""The read-only mirror of the sweep: expired claims must not block."""
|
||||||
|
self.db.upsert_session(session_id="owner", role="author", pid=1)
|
||||||
|
self.db.assign_and_lease(
|
||||||
|
session_id="owner",
|
||||||
|
role="author",
|
||||||
|
remote="prgs",
|
||||||
|
org="org",
|
||||||
|
repo="repo",
|
||||||
|
kind="issue",
|
||||||
|
number=643,
|
||||||
|
lease_ttl_seconds=-60, # expired: must not withhold #643
|
||||||
|
)
|
||||||
|
result = self._alloc(side_effect_free=True)
|
||||||
|
self.assertEqual(result["outcome"], OUTCOME_PREVIEW)
|
||||||
|
self.assertEqual(result["selected"]["number"], 643)
|
||||||
|
|
||||||
|
def test_a_live_claim_still_withholds_the_work(self):
|
||||||
|
self.db.upsert_session(session_id="owner", role="author", pid=1)
|
||||||
|
self.db.assign_and_lease(
|
||||||
|
session_id="owner",
|
||||||
|
role="author",
|
||||||
|
remote="prgs",
|
||||||
|
org="org",
|
||||||
|
repo="repo",
|
||||||
|
kind="issue",
|
||||||
|
number=643,
|
||||||
|
lease_ttl_seconds=3600,
|
||||||
|
)
|
||||||
|
result = self._alloc(side_effect_free=True)
|
||||||
|
self.assertNotEqual(result["outcome"], OUTCOME_ASSIGNED)
|
||||||
|
self.assertNotEqual((result.get("selected") or {}).get("number"), 643)
|
||||||
|
|
||||||
|
def test_side_effect_free_with_apply_fails_closed(self):
|
||||||
|
result = self._alloc(side_effect_free=True, apply=True)
|
||||||
|
self.assertFalse(result["success"])
|
||||||
|
self.assertEqual(result["outcome"], OUTCOME_NO_SAFE)
|
||||||
|
self.assertIsNone(result["assignment"])
|
||||||
|
self.assertIn("incompatible with apply", result["reasons"][0])
|
||||||
|
# And it reserved nothing.
|
||||||
|
self.assertEqual(
|
||||||
|
self.db.list_leases(remote="prgs", org="org", repo="repo"), []
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
class DropExpiredClaimsTest(unittest.TestCase):
|
||||||
|
"""The in-memory expiry filter behind side-effect-free previews (#643)."""
|
||||||
|
|
||||||
|
def test_unparseable_expiry_is_kept_rather_than_assumed_free(self):
|
||||||
|
claims = {
|
||||||
|
("issue", 1): {"lease_id": "l1", "expires_at": "not-a-date"},
|
||||||
|
("issue", 2): {"lease_id": "l2"},
|
||||||
|
("issue", 3): {"lease_id": "l3", "expires_at": None},
|
||||||
|
}
|
||||||
|
self.assertEqual(_drop_expired_claims(claims), claims)
|
||||||
|
|
||||||
|
def test_expired_dropped_and_future_kept(self):
|
||||||
|
now = datetime(2026, 7, 25, 12, 0, tzinfo=timezone.utc)
|
||||||
|
claims = {
|
||||||
|
("issue", 1): {"expires_at": "2026-07-25T11:59:59+00:00"},
|
||||||
|
("issue", 2): {"expires_at": "2026-07-25T12:00:01+00:00"},
|
||||||
|
("issue", 3): {"expires_at": "2026-07-25T12:00:00+00:00"}, # boundary
|
||||||
|
}
|
||||||
|
kept = _drop_expired_claims(claims, now=now)
|
||||||
|
self.assertEqual(set(kept), {("issue", 2)})
|
||||||
|
|
||||||
|
def test_naive_and_zulu_timestamps_are_treated_as_utc(self):
|
||||||
|
now = datetime(2026, 7, 25, 12, 0, tzinfo=timezone.utc)
|
||||||
|
claims = {
|
||||||
|
("issue", 1): {"expires_at": "2026-07-25T11:00:00"}, # naive, past
|
||||||
|
("issue", 2): {"expires_at": "2026-07-25T13:00:00Z"}, # zulu, future
|
||||||
|
}
|
||||||
|
kept = _drop_expired_claims(claims, now=now)
|
||||||
|
self.assertEqual(set(kept), {("issue", 2)})
|
||||||
|
|
||||||
|
|
||||||
if __name__ == "__main__":
|
if __name__ == "__main__":
|
||||||
unittest.main()
|
unittest.main()
|
||||||
|
|||||||
@@ -0,0 +1,381 @@
|
|||||||
|
"""``apply_authorized`` requires BOTH authorizations (#886 review blocker B1).
|
||||||
|
|
||||||
|
The #663 restart-class matrix and the #661 drain-proof hard gate are independent
|
||||||
|
authorizations that first coexisted when PR #882 landed on master and PR #886
|
||||||
|
merged it into the restart-class branch. The union preserved both, but the apply
|
||||||
|
decision consulted only the drain gate::
|
||||||
|
|
||||||
|
payload["apply_authorized"] = gate.allow # pre-fix
|
||||||
|
|
||||||
|
so a clean drain proof — or an authorized break-glass, which needs no proof at
|
||||||
|
all — reported ``apply_authorized: True`` for a restart class the least-privilege
|
||||||
|
matrix had just denied, in the same payload that carried
|
||||||
|
``allow_restart: False`` and "role 'author' may not request full_mcp_restart".
|
||||||
|
|
||||||
|
These tests pin the conjunction and the properties that must survive it. They
|
||||||
|
exercise the real MCP tool, which previously had no test coverage at all — that
|
||||||
|
absence is why the defect shipped.
|
||||||
|
"""
|
||||||
|
|
||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
import json
|
||||||
|
import os
|
||||||
|
import unittest
|
||||||
|
from unittest.mock import patch
|
||||||
|
|
||||||
|
import drain_proof
|
||||||
|
import gitea_mcp_server as srv
|
||||||
|
|
||||||
|
CONTROLLER_APPROVAL_ENV = "GITEA_CONTROLLER_RESTART_APPROVAL_AUTHORIZATION"
|
||||||
|
BREAK_GLASS_ENV = "GITEA_BREAKGLASS_RESTART_AUTHORIZATION"
|
||||||
|
|
||||||
|
# A quiet control plane: nothing live, so the blast radius never masks the
|
||||||
|
# authorization outcome under test.
|
||||||
|
QUIET_SESSIONS: list[dict] = []
|
||||||
|
QUIET_LEASES: list[dict] = []
|
||||||
|
|
||||||
|
|
||||||
|
class _FakeDB:
|
||||||
|
"""Minimal control-plane DB stand-in for the restart inventory."""
|
||||||
|
|
||||||
|
def __init__(self, sessions=QUIET_SESSIONS, terminal=None):
|
||||||
|
self._sessions = list(sessions)
|
||||||
|
self._terminal = terminal
|
||||||
|
|
||||||
|
def list_sessions(self, statuses=None, limit=None):
|
||||||
|
return list(self._sessions)
|
||||||
|
|
||||||
|
def get_active_terminal_lock(self, remote=None, org=None, repo=None):
|
||||||
|
return self._terminal
|
||||||
|
|
||||||
|
|
||||||
|
def _profile(role: str) -> dict:
|
||||||
|
return {"profile_name": f"prgs-{role}", "role_kind": role, "role": role}
|
||||||
|
|
||||||
|
|
||||||
|
class _RestartToolHarness(unittest.TestCase):
|
||||||
|
"""Drives the real ``gitea_request_mcp_restart`` with a stubbed inventory."""
|
||||||
|
|
||||||
|
def _call(self, *, role: str, env: dict | None = None, **kwargs) -> dict:
|
||||||
|
environ = {k: v for k, v in os.environ.items()
|
||||||
|
if k not in (CONTROLLER_APPROVAL_ENV, BREAK_GLASS_ENV)}
|
||||||
|
environ.update(env or {})
|
||||||
|
with patch.object(srv, "_profile_operation_gate", return_value=None), \
|
||||||
|
patch.object(srv, "_resolve",
|
||||||
|
return_value=("gitea.prgs.cc",
|
||||||
|
"Scaled-Tech-Consulting",
|
||||||
|
"Gitea-Tools")), \
|
||||||
|
patch.object(srv, "get_profile", return_value=_profile(role)), \
|
||||||
|
patch.object(srv, "_control_plane_db_or_error",
|
||||||
|
return_value=(_FakeDB(), [])), \
|
||||||
|
patch.object(srv.lease_lifecycle, "list_active_leases",
|
||||||
|
return_value={"leases": list(QUIET_LEASES)}), \
|
||||||
|
patch.dict(os.environ, environ, clear=True):
|
||||||
|
return srv.gitea_request_mcp_restart(
|
||||||
|
remote="prgs",
|
||||||
|
org="Scaled-Tech-Consulting",
|
||||||
|
repo="Gitea-Tools",
|
||||||
|
session_id="probe-session",
|
||||||
|
**kwargs,
|
||||||
|
)
|
||||||
|
|
||||||
|
def _clean_proof_for(self, preview: dict) -> str:
|
||||||
|
"""Mint a genuinely clean, signature-valid proof bound to *preview*.
|
||||||
|
|
||||||
|
Built from the tool's own dry-run report, so the fingerprint matches and
|
||||||
|
the proof is rejected for authorization reasons only — never because it
|
||||||
|
was stale or forged.
|
||||||
|
"""
|
||||||
|
proof = drain_proof.build_drain_proof(
|
||||||
|
impact_report=preview,
|
||||||
|
drain_state={
|
||||||
|
"assignments_stopped": True,
|
||||||
|
"checkpoints_complete": True,
|
||||||
|
"handoffs_verified": True,
|
||||||
|
"leases_handled": True,
|
||||||
|
"acks": {},
|
||||||
|
},
|
||||||
|
requesting_session_id="probe-session",
|
||||||
|
)
|
||||||
|
self.assertTrue(proof.clean, "harness must mint a clean proof")
|
||||||
|
return json.dumps(proof.as_dict())
|
||||||
|
|
||||||
|
|
||||||
|
class TestConjunction(_RestartToolHarness):
|
||||||
|
"""AC1/AC2 — the two authorizations are ANDed, in both directions."""
|
||||||
|
|
||||||
|
def test_gate_allow_with_class_denied_yields_apply_authorized_false(self):
|
||||||
|
# An author may not request full_mcp_restart (CONTROL_ROLES only).
|
||||||
|
preview = self._call(role="author", restart_class="full_mcp_restart")
|
||||||
|
self.assertFalse(preview["allow_restart"])
|
||||||
|
|
||||||
|
result = self._call(
|
||||||
|
role="author",
|
||||||
|
restart_class="full_mcp_restart",
|
||||||
|
dry_run=False,
|
||||||
|
drain_proof_json=self._clean_proof_for(preview),
|
||||||
|
)
|
||||||
|
|
||||||
|
self.assertTrue(result["apply_gate"]["drain_gate_allow"],
|
||||||
|
"drain gate itself should have allowed this proof")
|
||||||
|
self.assertFalse(result["apply_gate"]["restart_class_authorized"])
|
||||||
|
self.assertFalse(result["apply_authorized"],
|
||||||
|
"a clean proof must not authorize a denied class")
|
||||||
|
self.assertFalse(result["allow_restart"])
|
||||||
|
|
||||||
|
def test_gate_allow_with_class_allowed_can_yield_apply_authorized_true(self):
|
||||||
|
preview = self._call(
|
||||||
|
role="operator",
|
||||||
|
restart_class="full_mcp_restart",
|
||||||
|
env={CONTROLLER_APPROVAL_ENV: "operator-approved"},
|
||||||
|
)
|
||||||
|
self.assertTrue(preview["allow_restart"],
|
||||||
|
"operator + controller approval must authorize the class")
|
||||||
|
|
||||||
|
result = self._call(
|
||||||
|
role="operator",
|
||||||
|
restart_class="full_mcp_restart",
|
||||||
|
dry_run=False,
|
||||||
|
drain_proof_json=self._clean_proof_for(preview),
|
||||||
|
env={CONTROLLER_APPROVAL_ENV: "operator-approved"},
|
||||||
|
)
|
||||||
|
|
||||||
|
self.assertTrue(result["apply_gate"]["drain_gate_allow"])
|
||||||
|
self.assertTrue(result["apply_gate"]["restart_class_authorized"])
|
||||||
|
self.assertTrue(result["apply_authorized"],
|
||||||
|
"both authorizations pass; apply must be authorized")
|
||||||
|
|
||||||
|
def test_denial_is_attributable_to_the_authorization_that_caused_it(self):
|
||||||
|
preview = self._call(role="author", restart_class="full_mcp_restart")
|
||||||
|
result = self._call(
|
||||||
|
role="author",
|
||||||
|
restart_class="full_mcp_restart",
|
||||||
|
dry_run=False,
|
||||||
|
drain_proof_json=self._clean_proof_for(preview),
|
||||||
|
)
|
||||||
|
blob = " ".join(result["apply_gate"]["reasons"]).lower()
|
||||||
|
self.assertIn("restart class authorization denied", blob)
|
||||||
|
self.assertIn("full_mcp_restart", blob)
|
||||||
|
|
||||||
|
|
||||||
|
class TestProofCannotOverrideAuthorization(_RestartToolHarness):
|
||||||
|
"""AC3 — a clean proof never overrides a class or requester-role denial."""
|
||||||
|
|
||||||
|
def test_clean_proof_cannot_override_role_denial(self):
|
||||||
|
for role in ("author", "reviewer", "merger", "reconciler"):
|
||||||
|
with self.subTest(role=role):
|
||||||
|
preview = self._call(role=role, restart_class="full_mcp_restart")
|
||||||
|
result = self._call(
|
||||||
|
role=role,
|
||||||
|
restart_class="full_mcp_restart",
|
||||||
|
dry_run=False,
|
||||||
|
drain_proof_json=self._clean_proof_for(preview),
|
||||||
|
)
|
||||||
|
self.assertFalse(result["apply_authorized"])
|
||||||
|
|
||||||
|
def test_clean_proof_cannot_override_missing_controller_approval(self):
|
||||||
|
# Correct role, but the class demands controller approval and the
|
||||||
|
# environment carries none.
|
||||||
|
preview = self._call(role="operator", restart_class="full_mcp_restart")
|
||||||
|
self.assertFalse(preview["allow_restart"])
|
||||||
|
result = self._call(
|
||||||
|
role="operator",
|
||||||
|
restart_class="full_mcp_restart",
|
||||||
|
dry_run=False,
|
||||||
|
drain_proof_json=self._clean_proof_for(preview),
|
||||||
|
)
|
||||||
|
self.assertFalse(result["apply_authorized"])
|
||||||
|
|
||||||
|
def test_clean_proof_cannot_override_unknown_class(self):
|
||||||
|
preview = self._call(role="operator", restart_class="not_a_real_class",
|
||||||
|
env={CONTROLLER_APPROVAL_ENV: "yes"})
|
||||||
|
self.assertFalse(preview["allow_restart"])
|
||||||
|
result = self._call(
|
||||||
|
role="operator",
|
||||||
|
restart_class="not_a_real_class",
|
||||||
|
dry_run=False,
|
||||||
|
drain_proof_json=self._clean_proof_for(preview),
|
||||||
|
env={CONTROLLER_APPROVAL_ENV: "yes"},
|
||||||
|
)
|
||||||
|
self.assertFalse(result["apply_authorized"])
|
||||||
|
|
||||||
|
def test_clean_proof_cannot_override_missing_scope_target(self):
|
||||||
|
# worker_restart without target_session_id fails closed on scoping.
|
||||||
|
preview = self._call(role="operator", restart_class="worker_restart",
|
||||||
|
env={CONTROLLER_APPROVAL_ENV: "yes"})
|
||||||
|
self.assertFalse(preview["allow_restart"])
|
||||||
|
result = self._call(
|
||||||
|
role="operator",
|
||||||
|
restart_class="worker_restart",
|
||||||
|
dry_run=False,
|
||||||
|
drain_proof_json=self._clean_proof_for(preview),
|
||||||
|
env={CONTROLLER_APPROVAL_ENV: "yes"},
|
||||||
|
)
|
||||||
|
self.assertFalse(result["apply_authorized"])
|
||||||
|
|
||||||
|
|
||||||
|
class TestBreakGlassDoesNotCollapseTheMatrix(_RestartToolHarness):
|
||||||
|
"""AC4 — break-glass bypasses the drain proof only, never the class matrix."""
|
||||||
|
|
||||||
|
def test_break_glass_does_not_authorize_a_denied_class(self):
|
||||||
|
result = self._call(
|
||||||
|
role="author",
|
||||||
|
restart_class="host_restart",
|
||||||
|
dry_run=False,
|
||||||
|
request_break_glass=True,
|
||||||
|
env={BREAK_GLASS_ENV: "operator-issued"},
|
||||||
|
)
|
||||||
|
self.assertTrue(result["break_glass_authorized"])
|
||||||
|
self.assertTrue(result["apply_gate"]["drain_gate_allow"],
|
||||||
|
"break-glass does satisfy the drain gate")
|
||||||
|
self.assertFalse(result["apply_gate"]["restart_class_authorized"])
|
||||||
|
self.assertFalse(result["apply_authorized"],
|
||||||
|
"break-glass must not collapse the class matrix")
|
||||||
|
|
||||||
|
def test_break_glass_across_every_worker_role_and_restricted_class(self):
|
||||||
|
for role in ("author", "reviewer", "merger", "reconciler"):
|
||||||
|
for klass in ("rolling_mcp_restart", "full_mcp_restart",
|
||||||
|
"host_restart"):
|
||||||
|
with self.subTest(role=role, restart_class=klass):
|
||||||
|
result = self._call(
|
||||||
|
role=role,
|
||||||
|
restart_class=klass,
|
||||||
|
dry_run=False,
|
||||||
|
request_break_glass=True,
|
||||||
|
env={BREAK_GLASS_ENV: "operator-issued"},
|
||||||
|
)
|
||||||
|
self.assertFalse(result["apply_authorized"])
|
||||||
|
|
||||||
|
def test_break_glass_still_works_when_the_class_is_authorized(self):
|
||||||
|
# Break-glass keeps its purpose: skipping the drain proof for a caller
|
||||||
|
# the matrix does allow.
|
||||||
|
result = self._call(
|
||||||
|
role="operator",
|
||||||
|
restart_class="full_mcp_restart",
|
||||||
|
dry_run=False,
|
||||||
|
request_break_glass=True,
|
||||||
|
env={BREAK_GLASS_ENV: "operator-issued",
|
||||||
|
CONTROLLER_APPROVAL_ENV: "operator-approved"},
|
||||||
|
)
|
||||||
|
self.assertTrue(result["apply_authorized"])
|
||||||
|
self.assertEqual(result["apply_gate"]["verdict"], "break_glass")
|
||||||
|
|
||||||
|
def test_break_glass_is_not_self_assertable(self):
|
||||||
|
# Requested but no environment authorization -> no bypass, and the
|
||||||
|
# unproven apply is denied.
|
||||||
|
result = self._call(
|
||||||
|
role="operator",
|
||||||
|
restart_class="full_mcp_restart",
|
||||||
|
dry_run=False,
|
||||||
|
request_break_glass=True,
|
||||||
|
env={CONTROLLER_APPROVAL_ENV: "operator-approved"},
|
||||||
|
)
|
||||||
|
self.assertTrue(result["break_glass_requested"])
|
||||||
|
self.assertFalse(result["break_glass_authorized"])
|
||||||
|
self.assertFalse(result["apply_authorized"])
|
||||||
|
self.assertIn("incident", result)
|
||||||
|
|
||||||
|
|
||||||
|
class TestRestrictedClassesStayDenied(_RestartToolHarness):
|
||||||
|
"""AC5 — restricted classes remain denied to unauthorized requesters."""
|
||||||
|
|
||||||
|
def test_restricted_classes_denied_for_worker_roles(self):
|
||||||
|
for role in ("author", "reviewer", "merger", "reconciler"):
|
||||||
|
for klass in ("rolling_mcp_restart", "full_mcp_restart",
|
||||||
|
"host_restart"):
|
||||||
|
with self.subTest(role=role, restart_class=klass):
|
||||||
|
preview = self._call(
|
||||||
|
role=role,
|
||||||
|
restart_class=klass,
|
||||||
|
env={CONTROLLER_APPROVAL_ENV: "yes"},
|
||||||
|
)
|
||||||
|
self.assertFalse(preview["allow_restart"])
|
||||||
|
self.assertFalse(preview["permission_authorized"])
|
||||||
|
self.assertFalse(preview["role_authorized"])
|
||||||
|
|
||||||
|
def test_host_restart_needs_controller_and_infrastructure_operator(self):
|
||||||
|
# controller approval alone is not enough for host_restart.
|
||||||
|
preview = self._call(role="controller", restart_class="host_restart",
|
||||||
|
env={CONTROLLER_APPROVAL_ENV: "yes"})
|
||||||
|
self.assertFalse(preview["approval_satisfied"])
|
||||||
|
self.assertFalse(preview["allow_restart"])
|
||||||
|
|
||||||
|
|
||||||
|
class TestExistingPathsStillWork(_RestartToolHarness):
|
||||||
|
"""AC6 — valid scoped and unscoped restart paths are unaffected."""
|
||||||
|
|
||||||
|
def test_dry_run_never_reports_apply_authorization(self):
|
||||||
|
result = self._call(role="operator", restart_class="full_mcp_restart",
|
||||||
|
env={CONTROLLER_APPROVAL_ENV: "yes"})
|
||||||
|
self.assertNotIn("apply_authorized", result)
|
||||||
|
self.assertNotIn("apply_gate", result)
|
||||||
|
self.assertFalse(result["apply_supported"])
|
||||||
|
self.assertFalse(result["restart_performed"])
|
||||||
|
|
||||||
|
def test_self_service_unscoped_classes_authorize_for_every_role(self):
|
||||||
|
for role in ("author", "reviewer", "merger", "reconciler",
|
||||||
|
"controller", "operator", "admin"):
|
||||||
|
for klass in ("client_reconnect", "session_reconnect"):
|
||||||
|
with self.subTest(role=role, restart_class=klass):
|
||||||
|
preview = self._call(role=role, restart_class=klass)
|
||||||
|
self.assertTrue(preview["allow_restart"])
|
||||||
|
|
||||||
|
def test_scoped_class_with_target_authorizes_and_applies(self):
|
||||||
|
env = {CONTROLLER_APPROVAL_ENV: "operator-approved"}
|
||||||
|
preview = self._call(role="operator", restart_class="worker_restart",
|
||||||
|
target_session_id="worker-1", env=env)
|
||||||
|
self.assertTrue(preview["allow_restart"])
|
||||||
|
|
||||||
|
result = self._call(
|
||||||
|
role="operator",
|
||||||
|
restart_class="worker_restart",
|
||||||
|
target_session_id="worker-1",
|
||||||
|
dry_run=False,
|
||||||
|
drain_proof_json=self._clean_proof_for(preview),
|
||||||
|
env=env,
|
||||||
|
)
|
||||||
|
self.assertTrue(result["apply_authorized"])
|
||||||
|
|
||||||
|
def test_apply_still_denies_without_any_proof(self):
|
||||||
|
# The #661 hard gate is untouched by the conjunction.
|
||||||
|
result = self._call(
|
||||||
|
role="operator",
|
||||||
|
restart_class="full_mcp_restart",
|
||||||
|
dry_run=False,
|
||||||
|
env={CONTROLLER_APPROVAL_ENV: "operator-approved"},
|
||||||
|
)
|
||||||
|
self.assertFalse(result["apply_gate"]["drain_gate_allow"])
|
||||||
|
self.assertTrue(result["apply_gate"]["restart_class_authorized"])
|
||||||
|
self.assertFalse(result["apply_authorized"])
|
||||||
|
self.assertEqual(result["incident"]["kind"], "restart_drain_gate_denied")
|
||||||
|
|
||||||
|
def test_apply_denies_on_malformed_proof(self):
|
||||||
|
result = self._call(
|
||||||
|
role="operator",
|
||||||
|
restart_class="full_mcp_restart",
|
||||||
|
dry_run=False,
|
||||||
|
drain_proof_json="{not valid json",
|
||||||
|
env={CONTROLLER_APPROVAL_ENV: "operator-approved"},
|
||||||
|
)
|
||||||
|
self.assertFalse(result["apply_authorized"])
|
||||||
|
self.assertTrue(any("invalid drain_proof_json" in reason
|
||||||
|
for reason in result["apply_gate"]["reasons"]))
|
||||||
|
|
||||||
|
def test_tool_never_restarts_on_any_path(self):
|
||||||
|
for kwargs in (
|
||||||
|
{"restart_class": "client_reconnect"},
|
||||||
|
{"restart_class": "full_mcp_restart", "dry_run": False},
|
||||||
|
{"restart_class": "host_restart", "dry_run": False,
|
||||||
|
"request_break_glass": True},
|
||||||
|
):
|
||||||
|
with self.subTest(**kwargs):
|
||||||
|
result = self._call(role="operator", env={
|
||||||
|
CONTROLLER_APPROVAL_ENV: "yes", BREAK_GLASS_ENV: "yes"},
|
||||||
|
**kwargs)
|
||||||
|
self.assertFalse(result["restart_performed"])
|
||||||
|
self.assertFalse(result["apply_supported"])
|
||||||
|
|
||||||
|
|
||||||
|
if __name__ == "__main__":
|
||||||
|
unittest.main()
|
||||||
@@ -1,453 +0,0 @@
|
|||||||
"""#897: stale-runtime / runtime-mode refusals must not look like permission denials.
|
|
||||||
|
|
||||||
Acceptance criteria (issue #897):
|
|
||||||
|
|
||||||
* Stale-runtime and runtime-mode refusals are typed distinctly from
|
|
||||||
profile-permission refusals (distinct ``blocker_kind``).
|
|
||||||
* A refusal caused by staleness or runtime mode never emits a
|
|
||||||
``permission_report`` and never names a permission the active profile holds.
|
|
||||||
* ``_permission_block_report`` verifies the active profile actually lacks the
|
|
||||||
operation before reporting it missing.
|
|
||||||
* A stale-runtime refusal reports reconnect-only recovery and never recommends
|
|
||||||
``gitea_activate_profile`` or an MCP session switch.
|
|
||||||
* The blocker payload states the observed heads (parity fields).
|
|
||||||
* Matrix across author / reviewer / merger / reconciler profiles.
|
|
||||||
* Regression: ``gitea_create_issue`` on a stale daemon under ``prgs-author``
|
|
||||||
never returns ``missing_permission: gitea.issue.create``.
|
|
||||||
"""
|
|
||||||
from __future__ import annotations
|
|
||||||
|
|
||||||
import os
|
|
||||||
import sys
|
|
||||||
import unittest
|
|
||||||
from unittest.mock import patch
|
|
||||||
|
|
||||||
sys.path.insert(0, str(__import__("pathlib").Path(__file__).resolve().parent.parent))
|
|
||||||
|
|
||||||
import gitea_config # noqa: E402
|
|
||||||
import gitea_mcp_server as mcp_server # noqa: E402
|
|
||||||
|
|
||||||
SHA_START = "7af40fb5ff7debd5e9165fe97d9c7c279358e175"
|
|
||||||
SHA_LIVE = "2f4dec832327513118f2fe92b74da25d124a01cb"
|
|
||||||
|
|
||||||
ROLE_MATRIX = (
|
|
||||||
(
|
|
||||||
"prgs-author",
|
|
||||||
"author",
|
|
||||||
"gitea.issue.create",
|
|
||||||
[
|
|
||||||
"gitea.read",
|
|
||||||
"gitea.issue.create",
|
|
||||||
"gitea.issue.comment",
|
|
||||||
"gitea.issue.close",
|
|
||||||
"gitea.branch.create",
|
|
||||||
"gitea.branch.push",
|
|
||||||
"gitea.pr.create",
|
|
||||||
"gitea.pr.comment",
|
|
||||||
"gitea.repo.commit",
|
|
||||||
],
|
|
||||||
["gitea.pr.approve", "gitea.pr.merge", "gitea.pr.request_changes"],
|
|
||||||
"gitea.pr.merge", # forbidden op for pure-permission case
|
|
||||||
),
|
|
||||||
(
|
|
||||||
"prgs-reviewer",
|
|
||||||
"reviewer",
|
|
||||||
"gitea.pr.review",
|
|
||||||
[
|
|
||||||
"gitea.read",
|
|
||||||
"gitea.pr.review",
|
|
||||||
"gitea.pr.approve",
|
|
||||||
"gitea.pr.request_changes",
|
|
||||||
"gitea.pr.comment",
|
|
||||||
"gitea.issue.comment",
|
|
||||||
],
|
|
||||||
["gitea.branch.push", "gitea.pr.create"],
|
|
||||||
"gitea.branch.push",
|
|
||||||
),
|
|
||||||
(
|
|
||||||
"prgs-merger",
|
|
||||||
"merger",
|
|
||||||
"gitea.pr.merge",
|
|
||||||
[
|
|
||||||
"gitea.read",
|
|
||||||
"gitea.pr.merge",
|
|
||||||
"gitea.pr.comment",
|
|
||||||
"gitea.issue.comment",
|
|
||||||
],
|
|
||||||
["gitea.pr.approve", "gitea.branch.push", "gitea.pr.create"],
|
|
||||||
"gitea.branch.push",
|
|
||||||
),
|
|
||||||
(
|
|
||||||
"prgs-reconciler",
|
|
||||||
"reconciler",
|
|
||||||
"gitea.branch.delete",
|
|
||||||
[
|
|
||||||
"gitea.read",
|
|
||||||
"gitea.branch.delete",
|
|
||||||
"gitea.pr.comment",
|
|
||||||
"gitea.issue.comment",
|
|
||||||
"gitea.pr.close",
|
|
||||||
"gitea.issue.close",
|
|
||||||
],
|
|
||||||
["gitea.pr.approve", "gitea.pr.merge"],
|
|
||||||
"gitea.pr.merge",
|
|
||||||
),
|
|
||||||
)
|
|
||||||
|
|
||||||
|
|
||||||
def _profile(name: str, role: str, allowed: list[str], forbidden: list[str]) -> dict:
|
|
||||||
return {
|
|
||||||
"profile_name": name,
|
|
||||||
"role": role,
|
|
||||||
"role_kind": role,
|
|
||||||
"allowed_operations": list(allowed),
|
|
||||||
"forbidden_operations": list(forbidden),
|
|
||||||
"identity": "test-user",
|
|
||||||
}
|
|
||||||
|
|
||||||
|
|
||||||
def _config(profiles: dict) -> dict:
|
|
||||||
return {
|
|
||||||
"version": 2,
|
|
||||||
"profiles": {
|
|
||||||
name: {
|
|
||||||
"role": p["role"],
|
|
||||||
"allowed_operations": p["allowed_operations"],
|
|
||||||
"forbidden_operations": p["forbidden_operations"],
|
|
||||||
}
|
|
||||||
for name, p in profiles.items()
|
|
||||||
},
|
|
||||||
"rules": {"allow_runtime_switching": True},
|
|
||||||
}
|
|
||||||
|
|
||||||
|
|
||||||
class Issue897Helpers(unittest.TestCase):
|
|
||||||
def test_classify_stale_reason_strings(self):
|
|
||||||
stale = (
|
|
||||||
f"live remote master is {SHA_LIVE[:12]} but the MCP server started "
|
|
||||||
f"at {SHA_START[:12]}; the daemon is stale relative to live master "
|
|
||||||
"-- restart/reconnect before mutating"
|
|
||||||
)
|
|
||||||
classified = mcp_server._classify_operation_gate_reasons([stale])
|
|
||||||
self.assertEqual(classified["stale_runtime"], [stale])
|
|
||||||
self.assertEqual(classified["permission"], [])
|
|
||||||
self.assertEqual(classified["runtime_mode"], [])
|
|
||||||
|
|
||||||
def test_classify_permission_reason(self):
|
|
||||||
reason = "profile is not allowed to gitea.pr.merge"
|
|
||||||
classified = mcp_server._classify_operation_gate_reasons([reason])
|
|
||||||
self.assertEqual(classified["permission"], [reason])
|
|
||||||
self.assertEqual(classified["stale_runtime"], [])
|
|
||||||
|
|
||||||
def test_classify_runtime_mode_reason(self):
|
|
||||||
reason = (
|
|
||||||
"runtime mode is 'dev-test' and the mutation targets the "
|
|
||||||
"production repository; dev/test runtimes must not mutate real "
|
|
||||||
"issues or PRs (ADR: stable control runtime vs dev runtime)"
|
|
||||||
)
|
|
||||||
classified = mcp_server._classify_operation_gate_reasons([reason])
|
|
||||||
self.assertEqual(classified["runtime_mode"], [reason])
|
|
||||||
self.assertEqual(classified["stale_runtime"], [])
|
|
||||||
|
|
||||||
|
|
||||||
class Issue897PermissionBlockReport(unittest.TestCase):
|
|
||||||
def test_holds_op_is_diagnostic_defect_not_missing_permission(self):
|
|
||||||
profile = _profile(
|
|
||||||
"prgs-author",
|
|
||||||
"author",
|
|
||||||
["gitea.read", "gitea.issue.create", "gitea.issue.comment"],
|
|
||||||
[],
|
|
||||||
)
|
|
||||||
with patch.object(mcp_server, "get_profile", return_value=profile), patch.object(
|
|
||||||
mcp_server.gitea_config, "load_config", return_value=_config({"prgs-author": profile})
|
|
||||||
), patch.object(
|
|
||||||
mcp_server.gitea_config, "is_runtime_switching_enabled", return_value=True
|
|
||||||
):
|
|
||||||
report = mcp_server._permission_block_report("gitea.issue.create")
|
|
||||||
self.assertTrue(report.get("diagnostic_defect"), report)
|
|
||||||
self.assertIsNone(report.get("missing_permission"), report)
|
|
||||||
action = (report.get("exact_safe_next_action") or "").lower()
|
|
||||||
# Must not *recommend* profile switching; mentioning the forbidden
|
|
||||||
# action in a "do not call" instruction is fine.
|
|
||||||
self.assertNotIn("call gitea_activate_profile with", action)
|
|
||||||
self.assertNotIn("switch to the author mcp session", action)
|
|
||||||
self.assertNotIn("switch to the reviewer mcp session", action)
|
|
||||||
self.assertIn("diagnostic defect", action)
|
|
||||||
|
|
||||||
def test_true_missing_permission_still_reports(self):
|
|
||||||
profile = _profile(
|
|
||||||
"prgs-author",
|
|
||||||
"author",
|
|
||||||
["gitea.read", "gitea.issue.create"],
|
|
||||||
["gitea.pr.merge"],
|
|
||||||
)
|
|
||||||
reviewer = _profile(
|
|
||||||
"prgs-reviewer",
|
|
||||||
"reviewer",
|
|
||||||
["gitea.read", "gitea.pr.merge", "gitea.pr.approve"],
|
|
||||||
[],
|
|
||||||
)
|
|
||||||
with patch.object(mcp_server, "get_profile", return_value=profile), patch.object(
|
|
||||||
mcp_server.gitea_config,
|
|
||||||
"load_config",
|
|
||||||
return_value=_config({"prgs-author": profile, "prgs-reviewer": reviewer}),
|
|
||||||
), patch.object(
|
|
||||||
mcp_server.gitea_config, "is_runtime_switching_enabled", return_value=True
|
|
||||||
):
|
|
||||||
report = mcp_server._permission_block_report("gitea.pr.merge")
|
|
||||||
self.assertFalse(report.get("diagnostic_defect"), report)
|
|
||||||
self.assertEqual(report.get("missing_permission"), "gitea.pr.merge")
|
|
||||||
self.assertIn("prgs-reviewer", report.get("matching_configured_profiles") or [])
|
|
||||||
|
|
||||||
|
|
||||||
class Issue897GateRefusalMatrix(unittest.TestCase):
|
|
||||||
def _stale_parity(self) -> dict:
|
|
||||||
return {
|
|
||||||
"in_parity": True,
|
|
||||||
"stale": False,
|
|
||||||
"restart_required": True,
|
|
||||||
"determinable": True,
|
|
||||||
"startup_head": SHA_START,
|
|
||||||
"current_head": SHA_START,
|
|
||||||
"daemon_start_head": SHA_START,
|
|
||||||
"local_head": SHA_START,
|
|
||||||
"live_remote_head": SHA_LIVE,
|
|
||||||
"live_known": True,
|
|
||||||
"live_stale": True,
|
|
||||||
"mutation_safe": False,
|
|
||||||
"reasons": [
|
|
||||||
f"live remote master is {SHA_LIVE[:12]} but the MCP server "
|
|
||||||
f"started at {SHA_START[:12]}; the daemon is stale relative "
|
|
||||||
"to live master -- restart/reconnect before mutating"
|
|
||||||
],
|
|
||||||
}
|
|
||||||
|
|
||||||
def test_stale_plus_permitted_op_all_roles(self):
|
|
||||||
for name, role, permitted_op, allowed, forbidden, _forbidden_op in ROLE_MATRIX:
|
|
||||||
with self.subTest(profile=name, op=permitted_op):
|
|
||||||
profile = _profile(name, role, allowed, forbidden)
|
|
||||||
parity = self._stale_parity()
|
|
||||||
with patch.object(mcp_server, "get_profile", return_value=profile), patch.object(
|
|
||||||
mcp_server, "_current_master_parity", return_value=parity
|
|
||||||
), patch.object(
|
|
||||||
mcp_server, "_master_parity_block", return_value=list(parity["reasons"])
|
|
||||||
), patch.object(
|
|
||||||
mcp_server, "_runtime_mode_block", return_value=[]
|
|
||||||
), patch.object(
|
|
||||||
mcp_server, "_ensure_matching_profile", return_value=None
|
|
||||||
), patch.object(
|
|
||||||
mcp_server.session_ctx,
|
|
||||||
"mutation_context_audit_fields",
|
|
||||||
return_value={"session_profile": name},
|
|
||||||
):
|
|
||||||
blocked = mcp_server._profile_permission_block(permitted_op)
|
|
||||||
self.assertIsNotNone(blocked, name)
|
|
||||||
assert blocked is not None
|
|
||||||
self.assertEqual(
|
|
||||||
blocked.get("blocker_kind"),
|
|
||||||
"runtime_reconnect_required",
|
|
||||||
blocked,
|
|
||||||
)
|
|
||||||
self.assertNotIn("permission_report", blocked, blocked)
|
|
||||||
self.assertTrue(blocked.get("restart_required"), blocked)
|
|
||||||
self.assertEqual(blocked.get("startup_head"), SHA_START, blocked)
|
|
||||||
self.assertEqual(blocked.get("live_remote_head"), SHA_LIVE, blocked)
|
|
||||||
action = (blocked.get("exact_safe_next_action") or "").lower()
|
|
||||||
self.assertIn("reconnect", action)
|
|
||||||
self.assertNotIn("call gitea_activate_profile with", action)
|
|
||||||
self.assertNotIn("switch to the author mcp session", action)
|
|
||||||
self.assertNotIn("switch to the reviewer mcp session", action)
|
|
||||||
|
|
||||||
def test_fresh_plus_forbidden_op_all_roles(self):
|
|
||||||
for name, role, _permitted, allowed, forbidden, forbidden_op in ROLE_MATRIX:
|
|
||||||
with self.subTest(profile=name, op=forbidden_op):
|
|
||||||
profile = _profile(name, role, allowed, forbidden)
|
|
||||||
with patch.object(mcp_server, "get_profile", return_value=profile), patch.object(
|
|
||||||
mcp_server, "_master_parity_block", return_value=[]
|
|
||||||
), patch.object(
|
|
||||||
mcp_server, "_runtime_mode_block", return_value=[]
|
|
||||||
), patch.object(
|
|
||||||
mcp_server, "_ensure_matching_profile", return_value=None
|
|
||||||
), patch.object(
|
|
||||||
mcp_server.session_ctx,
|
|
||||||
"mutation_context_audit_fields",
|
|
||||||
return_value={"session_profile": name},
|
|
||||||
), patch.object(
|
|
||||||
mcp_server.gitea_config,
|
|
||||||
"load_config",
|
|
||||||
return_value=_config({name: profile}),
|
|
||||||
), patch.object(
|
|
||||||
mcp_server.gitea_config, "is_runtime_switching_enabled", return_value=False
|
|
||||||
):
|
|
||||||
blocked = mcp_server._profile_permission_block(forbidden_op)
|
|
||||||
self.assertIsNotNone(blocked, name)
|
|
||||||
assert blocked is not None
|
|
||||||
self.assertEqual(blocked.get("blocker_kind"), "permission_denied", blocked)
|
|
||||||
self.assertIn("permission_report", blocked, blocked)
|
|
||||||
report = blocked["permission_report"]
|
|
||||||
self.assertEqual(report.get("missing_permission"), forbidden_op, report)
|
|
||||||
self.assertFalse(report.get("diagnostic_defect"), report)
|
|
||||||
# No runtime reconnect fields for pure permission denial
|
|
||||||
self.assertNotEqual(
|
|
||||||
blocked.get("blocker_kind"), "runtime_reconnect_required"
|
|
||||||
)
|
|
||||||
|
|
||||||
def test_stale_plus_forbidden_op_both_causes_separated(self):
|
|
||||||
for name, role, _permitted, allowed, forbidden, forbidden_op in ROLE_MATRIX:
|
|
||||||
with self.subTest(profile=name, op=forbidden_op):
|
|
||||||
profile = _profile(name, role, allowed, forbidden)
|
|
||||||
parity = self._stale_parity()
|
|
||||||
stale_reason = parity["reasons"][0]
|
|
||||||
with patch.object(mcp_server, "get_profile", return_value=profile), patch.object(
|
|
||||||
mcp_server, "_current_master_parity", return_value=parity
|
|
||||||
), patch.object(
|
|
||||||
mcp_server, "_master_parity_block", return_value=[stale_reason]
|
|
||||||
), patch.object(
|
|
||||||
mcp_server, "_runtime_mode_block", return_value=[]
|
|
||||||
), patch.object(
|
|
||||||
mcp_server, "_ensure_matching_profile", return_value=None
|
|
||||||
), patch.object(
|
|
||||||
mcp_server.session_ctx,
|
|
||||||
"mutation_context_audit_fields",
|
|
||||||
return_value={"session_profile": name},
|
|
||||||
):
|
|
||||||
# Gate collects both classes; force permission reason too.
|
|
||||||
with patch.object(
|
|
||||||
mcp_server,
|
|
||||||
"_profile_operation_gate",
|
|
||||||
return_value=[
|
|
||||||
stale_reason,
|
|
||||||
f"profile is not allowed to {forbidden_op}",
|
|
||||||
],
|
|
||||||
):
|
|
||||||
blocked = mcp_server._profile_permission_block(forbidden_op)
|
|
||||||
self.assertIsNotNone(blocked)
|
|
||||||
assert blocked is not None
|
|
||||||
self.assertEqual(
|
|
||||||
blocked.get("blocker_kind"), "runtime_reconnect_required", blocked
|
|
||||||
)
|
|
||||||
self.assertNotIn("permission_report", blocked, blocked)
|
|
||||||
self.assertIn("permission_block_reasons", blocked, blocked)
|
|
||||||
self.assertIn("stale_runtime_reasons", blocked, blocked)
|
|
||||||
classes = blocked.get("gate_reason_classes") or {}
|
|
||||||
self.assertTrue(classes.get("stale_runtime"), classes)
|
|
||||||
self.assertTrue(classes.get("permission"), classes)
|
|
||||||
|
|
||||||
def test_runtime_mode_block_no_permission_report(self):
|
|
||||||
profile = _profile(
|
|
||||||
"prgs-author",
|
|
||||||
"author",
|
|
||||||
["gitea.read", "gitea.issue.create"],
|
|
||||||
[],
|
|
||||||
)
|
|
||||||
runtime_reason = (
|
|
||||||
"runtime mode is 'dev-test' and the mutation targets the "
|
|
||||||
"production repository; dev/test runtimes must not mutate real "
|
|
||||||
"issues or PRs (ADR: stable control runtime vs dev runtime)"
|
|
||||||
)
|
|
||||||
with patch.object(mcp_server, "get_profile", return_value=profile), patch.object(
|
|
||||||
mcp_server, "_master_parity_block", return_value=[]
|
|
||||||
), patch.object(
|
|
||||||
mcp_server, "_runtime_mode_block", return_value=[runtime_reason]
|
|
||||||
), patch.object(
|
|
||||||
mcp_server, "_ensure_matching_profile", return_value=None
|
|
||||||
), patch.object(
|
|
||||||
mcp_server.session_ctx,
|
|
||||||
"mutation_context_audit_fields",
|
|
||||||
return_value={"session_profile": "prgs-author"},
|
|
||||||
):
|
|
||||||
blocked = mcp_server._profile_permission_block("gitea.issue.create")
|
|
||||||
self.assertIsNotNone(blocked)
|
|
||||||
assert blocked is not None
|
|
||||||
self.assertEqual(blocked.get("blocker_kind"), "runtime_mode_blocked", blocked)
|
|
||||||
self.assertNotIn("permission_report", blocked, blocked)
|
|
||||||
action = (blocked.get("exact_safe_next_action") or "").lower()
|
|
||||||
self.assertNotIn("call gitea_activate_profile with", action)
|
|
||||||
self.assertIn("stable control runtime", action)
|
|
||||||
|
|
||||||
|
|
||||||
class Issue897CreateIssueRegression(unittest.TestCase):
|
|
||||||
def test_create_issue_stale_daemon_never_missing_issue_create(self):
|
|
||||||
"""Regression AC: stale prgs-author create_issue must not claim missing create."""
|
|
||||||
profile = _profile(
|
|
||||||
"prgs-author",
|
|
||||||
"author",
|
|
||||||
[
|
|
||||||
"gitea.read",
|
|
||||||
"gitea.issue.create",
|
|
||||||
"gitea.issue.comment",
|
|
||||||
"gitea.branch.create",
|
|
||||||
"gitea.branch.push",
|
|
||||||
"gitea.pr.create",
|
|
||||||
"gitea.pr.comment",
|
|
||||||
"gitea.repo.commit",
|
|
||||||
],
|
|
||||||
[],
|
|
||||||
)
|
|
||||||
stale_reason = (
|
|
||||||
f"live remote master is {SHA_LIVE[:12]} but the MCP server started "
|
|
||||||
f"at {SHA_START[:12]}; the daemon is stale relative to live master "
|
|
||||||
"-- restart/reconnect before mutating"
|
|
||||||
)
|
|
||||||
parity = {
|
|
||||||
"in_parity": True,
|
|
||||||
"stale": False,
|
|
||||||
"restart_required": True,
|
|
||||||
"determinable": True,
|
|
||||||
"startup_head": SHA_START,
|
|
||||||
"current_head": SHA_START,
|
|
||||||
"daemon_start_head": SHA_START,
|
|
||||||
"local_head": SHA_START,
|
|
||||||
"live_remote_head": SHA_LIVE,
|
|
||||||
"live_known": True,
|
|
||||||
"live_stale": True,
|
|
||||||
"mutation_safe": False,
|
|
||||||
"reasons": [stale_reason],
|
|
||||||
}
|
|
||||||
|
|
||||||
with patch.object(mcp_server, "get_profile", return_value=profile), patch.object(
|
|
||||||
mcp_server, "_current_master_parity", return_value=parity
|
|
||||||
), patch.object(
|
|
||||||
mcp_server, "_master_parity_block", return_value=[stale_reason]
|
|
||||||
), patch.object(
|
|
||||||
mcp_server, "_runtime_mode_block", return_value=[]
|
|
||||||
), patch.object(
|
|
||||||
mcp_server, "_ensure_matching_profile", return_value=None
|
|
||||||
), patch.object(
|
|
||||||
mcp_server.session_ctx,
|
|
||||||
"mutation_context_audit_fields",
|
|
||||||
return_value={"session_profile": "prgs-author"},
|
|
||||||
), patch.object(
|
|
||||||
mcp_server, "_mutation_config_authority_block", return_value=None
|
|
||||||
), patch.object(
|
|
||||||
mcp_server, "_session_context_mutation_block", return_value=None
|
|
||||||
):
|
|
||||||
blocked = mcp_server._profile_permission_block(
|
|
||||||
"gitea.issue.create", remote="prgs"
|
|
||||||
)
|
|
||||||
|
|
||||||
self.assertIsNotNone(blocked)
|
|
||||||
assert blocked is not None
|
|
||||||
self.assertEqual(blocked.get("blocker_kind"), "runtime_reconnect_required")
|
|
||||||
self.assertNotIn("permission_report", blocked)
|
|
||||||
# Even if a caller still built a raw report, holds-check must not claim missing.
|
|
||||||
with patch.object(mcp_server, "get_profile", return_value=profile):
|
|
||||||
raw = mcp_server._permission_block_report("gitea.issue.create")
|
|
||||||
self.assertIsNone(raw.get("missing_permission"), raw)
|
|
||||||
self.assertNotEqual(raw.get("missing_permission"), "gitea.issue.create")
|
|
||||||
|
|
||||||
def test_permission_report_for_gate_reasons_skips_stale(self):
|
|
||||||
stale = (
|
|
||||||
f"live remote master is {SHA_LIVE[:12]} but the MCP server started "
|
|
||||||
f"at {SHA_START[:12]}; the daemon is stale relative to live master "
|
|
||||||
"-- restart/reconnect before mutating"
|
|
||||||
)
|
|
||||||
self.assertIsNone(
|
|
||||||
mcp_server._permission_report_for_gate_reasons(
|
|
||||||
"gitea.issue.comment", [stale]
|
|
||||||
)
|
|
||||||
)
|
|
||||||
|
|
||||||
|
|
||||||
if __name__ == "__main__":
|
|
||||||
unittest.main()
|
|
||||||
@@ -105,3 +105,120 @@ def test_cross_links_do_not_embed_secrets():
|
|||||||
text = _read(path)
|
text = _read(path)
|
||||||
for marker in ("ghp_", "BEGIN PRIVATE KEY", "Authorization: Bearer"):
|
for marker in ("ghp_", "BEGIN PRIVATE KEY", "Authorization: Bearer"):
|
||||||
assert marker not in text, f"{path} contains {marker!r}"
|
assert marker not in text, f"{path} contains {marker!r}"
|
||||||
|
|
||||||
|
|
||||||
|
# --- Coordinator doc stays in lock-step with the tool (#886 review blocker B2) --
|
||||||
|
#
|
||||||
|
# PR #882 moved the #661 drain-proof hard gate *into* gitea_request_mcp_restart,
|
||||||
|
# but the coordinator document still described the proof as "a separate child"
|
||||||
|
# and omitted both new parameters. Nothing referenced that document, so nothing
|
||||||
|
# caught the drift. These tests bind the prose to the real signature.
|
||||||
|
|
||||||
|
COORDINATOR_DOC = REPO_ROOT / "docs" / "mcp-restart-coordinator.md"
|
||||||
|
|
||||||
|
# Affirmative claims that were accurate before #661 landed and are now false.
|
||||||
|
# Matched against whitespace-normalized text so re-wrapping cannot hide them.
|
||||||
|
# Deliberately not the bare phrase "a separate child": the corrected prose uses
|
||||||
|
# it in a negation ("no longer a separate child operation"), and a guard that
|
||||||
|
# forbids naming the old behaviour would block explaining that it changed.
|
||||||
|
STALE_PRE_661_PHRASES = (
|
||||||
|
"gated by a drain proof (a separate child)",
|
||||||
|
"is a later child gated by a drain proof",
|
||||||
|
"mutative apply path is explicitly out of scope",
|
||||||
|
"apply is gated by a drain proof (a separate child)",
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
def _documented_signature_block() -> str:
|
||||||
|
"""The fenced signature block for the tool, as published in the doc."""
|
||||||
|
text = _read(COORDINATOR_DOC)
|
||||||
|
marker = "gitea_request_mcp_restart("
|
||||||
|
start = text.index(marker)
|
||||||
|
end = text.index("```", start)
|
||||||
|
return text[start:end]
|
||||||
|
|
||||||
|
|
||||||
|
def test_documented_signature_matches_the_real_tool_signature():
|
||||||
|
import inspect
|
||||||
|
|
||||||
|
import gitea_mcp_server
|
||||||
|
|
||||||
|
block = _documented_signature_block()
|
||||||
|
real = inspect.signature(gitea_mcp_server.gitea_request_mcp_restart)
|
||||||
|
for name in real.parameters:
|
||||||
|
assert name in block, (
|
||||||
|
f"docs/mcp-restart-coordinator.md documents no {name!r} parameter; "
|
||||||
|
"the published signature has drifted from the tool"
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
def test_drain_proof_and_break_glass_parameters_are_documented():
|
||||||
|
block = _documented_signature_block()
|
||||||
|
for name in ("drain_proof_json", "request_break_glass"):
|
||||||
|
assert name in block, f"signature block missing {name}"
|
||||||
|
|
||||||
|
|
||||||
|
def test_restart_class_and_target_scoping_parameters_survive():
|
||||||
|
block = _documented_signature_block()
|
||||||
|
for name in ("restart_class", "target_session_id", "target_role",
|
||||||
|
"target_connector"):
|
||||||
|
assert name in block, f"signature block lost #663 parameter {name}"
|
||||||
|
|
||||||
|
|
||||||
|
def test_gate_is_documented_as_executing_inside_this_tool():
|
||||||
|
lower = _read(COORDINATOR_DOC).lower()
|
||||||
|
assert "inside this tool" in lower, (
|
||||||
|
"the coordinator doc must state that the drain-proof gate executes in "
|
||||||
|
"gitea_request_mcp_restart, not in a later child"
|
||||||
|
)
|
||||||
|
assert "no longer a separate child operation" in lower
|
||||||
|
|
||||||
|
|
||||||
|
def test_stale_pre_661_wording_cannot_return():
|
||||||
|
normalized = " ".join(_read(COORDINATOR_DOC).split()).lower()
|
||||||
|
for phrase in STALE_PRE_661_PHRASES:
|
||||||
|
assert phrase not in normalized, (
|
||||||
|
f"stale pre-#661 wording returned to the coordinator doc: {phrase!r}"
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
def test_dry_run_versus_apply_behavior_is_documented():
|
||||||
|
lower = _read(COORDINATOR_DOC).lower()
|
||||||
|
assert "dry_run=true" in lower and "dry_run=false" in lower
|
||||||
|
assert "apply_supported" in lower and "restart_performed" in lower
|
||||||
|
assert "never restarts anything" in lower
|
||||||
|
|
||||||
|
|
||||||
|
def test_authorization_ordering_and_conjunction_are_documented():
|
||||||
|
text = _read(COORDINATOR_DOC)
|
||||||
|
lower = text.lower()
|
||||||
|
assert "authorization ordering" in lower
|
||||||
|
assert "allow_restart" in text
|
||||||
|
assert "apply_authorized" in text
|
||||||
|
# The conjunction itself, and the attribution fields behind it.
|
||||||
|
assert "gate.allow and allow_restart" in text
|
||||||
|
for field in ("drain_gate_allow", "restart_class_authorized"):
|
||||||
|
assert field in text, f"doc omits apply_gate.{field}"
|
||||||
|
|
||||||
|
|
||||||
|
def test_break_glass_scope_is_documented_as_drain_proof_only():
|
||||||
|
text = _read(COORDINATOR_DOC)
|
||||||
|
lower = text.lower()
|
||||||
|
assert "break-glass" in lower
|
||||||
|
assert "drain proof only" in lower, (
|
||||||
|
"doc must state break-glass never bypasses the restart-class matrix"
|
||||||
|
)
|
||||||
|
assert "GITEA_BREAKGLASS_RESTART_AUTHORIZATION" in text
|
||||||
|
|
||||||
|
|
||||||
|
def test_fail_closed_on_apply_is_documented():
|
||||||
|
lower = _read(COORDINATOR_DOC).lower()
|
||||||
|
assert "fail closed" in lower
|
||||||
|
for condition in ("expired", "unclean", "tampered", "stale"):
|
||||||
|
assert condition in lower, f"fail-closed list omits {condition!r}"
|
||||||
|
|
||||||
|
|
||||||
|
def test_coordinator_doc_embeds_no_secrets():
|
||||||
|
text = _read(COORDINATOR_DOC)
|
||||||
|
for marker in ("ghp_", "BEGIN PRIVATE KEY", "Authorization: Bearer"):
|
||||||
|
assert marker not in text, f"{COORDINATOR_DOC} contains {marker!r}"
|
||||||
|
|||||||
@@ -0,0 +1,232 @@
|
|||||||
|
"""Permission, drain, routing, and audit matrix for restart classes (#663)."""
|
||||||
|
|
||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
import os
|
||||||
|
from datetime import datetime, timezone
|
||||||
|
|
||||||
|
import restart_coordinator as rc
|
||||||
|
|
||||||
|
NOW = datetime(2026, 7, 24, 20, 0, tzinfo=timezone.utc)
|
||||||
|
|
||||||
|
|
||||||
|
def _inventory() -> dict:
|
||||||
|
return {
|
||||||
|
"inventory_complete": True,
|
||||||
|
"sessions": [
|
||||||
|
{
|
||||||
|
"session_id": "requester",
|
||||||
|
"role": "author",
|
||||||
|
"profile": "prgs-author",
|
||||||
|
"pid": os.getpid(),
|
||||||
|
"status": "active",
|
||||||
|
"last_heartbeat_at": NOW.isoformat(),
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"session_id": "reviewer",
|
||||||
|
"role": "reviewer",
|
||||||
|
"profile": "prgs-reviewer",
|
||||||
|
"pid": os.getpid(),
|
||||||
|
"status": "active",
|
||||||
|
"last_heartbeat_at": NOW.isoformat(),
|
||||||
|
},
|
||||||
|
],
|
||||||
|
"leases": [
|
||||||
|
{
|
||||||
|
"lease_id": "review-lease",
|
||||||
|
"session_id": "reviewer",
|
||||||
|
"role": "reviewer",
|
||||||
|
"phase": "reviewing",
|
||||||
|
"work_kind": "pr",
|
||||||
|
"work_number": 900,
|
||||||
|
"worktree_path": "/tmp/review-900",
|
||||||
|
"freshness": {"freshness": "active"},
|
||||||
|
}
|
||||||
|
],
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
def _evaluate(
|
||||||
|
restart_class: rc.RestartClass,
|
||||||
|
*,
|
||||||
|
role: str = "controller",
|
||||||
|
permissions: tuple[str, ...] | None = None,
|
||||||
|
approved: bool = True,
|
||||||
|
operator: bool = True,
|
||||||
|
**targets,
|
||||||
|
):
|
||||||
|
return rc.evaluate_restart_impact(
|
||||||
|
_inventory(),
|
||||||
|
now=NOW,
|
||||||
|
requesting_session_id="requester",
|
||||||
|
restart_class=restart_class,
|
||||||
|
requester_role=role,
|
||||||
|
requester_permissions=(
|
||||||
|
permissions if permissions is not None
|
||||||
|
else rc.permissions_for_role(role)
|
||||||
|
),
|
||||||
|
controller_approved=approved,
|
||||||
|
operator_authorized=operator,
|
||||||
|
**targets,
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
def test_policy_table_covers_exactly_all_nine_classes():
|
||||||
|
assert set(rc.RESTART_CLASS_POLICIES) == set(rc.RestartClass)
|
||||||
|
assert len(rc.RESTART_CLASS_POLICIES) == 9
|
||||||
|
for restart_class, policy in rc.RESTART_CLASS_POLICIES.items():
|
||||||
|
assert policy.restart_class is restart_class
|
||||||
|
assert policy.required_permission
|
||||||
|
assert policy.expected_blast_radius in {
|
||||||
|
rc.BLAST_NONE, rc.BLAST_LOW, rc.BLAST_MEDIUM, rc.BLAST_HIGH
|
||||||
|
}
|
||||||
|
assert policy.drain_requirement
|
||||||
|
assert policy.approval_requirement
|
||||||
|
assert policy.audit_requirement
|
||||||
|
assert policy.recovery_behavior
|
||||||
|
|
||||||
|
|
||||||
|
def test_permission_matrix_allows_each_class_with_exact_permission():
|
||||||
|
targets = {
|
||||||
|
rc.RestartClass.WORKER_RESTART: {"target_session_id": "reviewer"},
|
||||||
|
rc.RestartClass.ROLE_RUNTIME_RESTART: {"target_role": "reviewer"},
|
||||||
|
rc.RestartClass.CONNECTOR_RESTART: {"target_connector": "github"},
|
||||||
|
}
|
||||||
|
for restart_class, policy in rc.RESTART_CLASS_POLICIES.items():
|
||||||
|
report = _evaluate(
|
||||||
|
restart_class,
|
||||||
|
permissions=(policy.required_permission,),
|
||||||
|
**targets.get(restart_class, {}),
|
||||||
|
)
|
||||||
|
assert report.permission_authorized, restart_class
|
||||||
|
assert report.role_authorized, restart_class
|
||||||
|
assert report.approval_satisfied, restart_class
|
||||||
|
assert report.audit_record["restart_class"] == restart_class.value
|
||||||
|
assert (
|
||||||
|
report.audit_record["required_permission"]
|
||||||
|
== policy.required_permission
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
def test_missing_or_nearby_permission_denies():
|
||||||
|
report = _evaluate(
|
||||||
|
rc.RestartClass.ROLE_RUNTIME_RESTART,
|
||||||
|
permissions=("mcp.restart.worker.request",),
|
||||||
|
target_role="reviewer",
|
||||||
|
)
|
||||||
|
assert report.verdict == rc.VERDICT_UNSAFE
|
||||||
|
assert not report.allow_restart
|
||||||
|
assert not report.permission_authorized
|
||||||
|
assert any("missing required permission" in r for r in report.reasons)
|
||||||
|
|
||||||
|
|
||||||
|
def test_unknown_restart_class_denies_fail_closed():
|
||||||
|
report = rc.evaluate_restart_impact(
|
||||||
|
_inventory(),
|
||||||
|
now=NOW,
|
||||||
|
restart_class="surprise_reboot",
|
||||||
|
requester_role="admin",
|
||||||
|
requester_permissions=("mcp.restart.host.request",),
|
||||||
|
controller_approved=True,
|
||||||
|
operator_authorized=True,
|
||||||
|
)
|
||||||
|
assert report.verdict == rc.VERDICT_UNSAFE
|
||||||
|
assert not report.allow_restart
|
||||||
|
assert report.restart_policy == {}
|
||||||
|
assert any("unknown restart class" in r for r in report.reasons)
|
||||||
|
|
||||||
|
|
||||||
|
def test_worker_roles_cannot_request_full_or_host_restart():
|
||||||
|
for role in rc.WORKER_ROLES:
|
||||||
|
granted = rc.permissions_for_role(role)
|
||||||
|
assert "mcp.restart.full.request" not in granted
|
||||||
|
assert "mcp.restart.host.request" not in granted
|
||||||
|
report = _evaluate(
|
||||||
|
rc.RestartClass.FULL_MCP_RESTART,
|
||||||
|
role=role,
|
||||||
|
permissions=granted,
|
||||||
|
)
|
||||||
|
assert not report.role_authorized
|
||||||
|
assert not report.allow_restart
|
||||||
|
|
||||||
|
|
||||||
|
def test_controller_approval_is_independent_of_permission():
|
||||||
|
report = _evaluate(
|
||||||
|
rc.RestartClass.WORKER_RESTART,
|
||||||
|
approved=False,
|
||||||
|
target_session_id="reviewer",
|
||||||
|
)
|
||||||
|
assert report.permission_authorized
|
||||||
|
assert not report.approval_satisfied
|
||||||
|
assert not report.allow_restart
|
||||||
|
|
||||||
|
|
||||||
|
def test_narrow_classes_do_not_inherit_full_drain_or_peer_lease_block():
|
||||||
|
for restart_class in (
|
||||||
|
rc.RestartClass.CLIENT_RECONNECT,
|
||||||
|
rc.RestartClass.SESSION_RECONNECT,
|
||||||
|
rc.RestartClass.CONFIGURATION_RELOAD,
|
||||||
|
):
|
||||||
|
report = _evaluate(restart_class)
|
||||||
|
assert not report.restart_policy["full_drain_required"]
|
||||||
|
assert report.counts["leases_disruptive"] == 0
|
||||||
|
assert report.counts["sessions_live_other"] == 0
|
||||||
|
assert report.counts["critical_sections"] == 0
|
||||||
|
assert report.counts["mutations"] == 0
|
||||||
|
assert report.allow_restart, (restart_class, report.reasons)
|
||||||
|
|
||||||
|
|
||||||
|
def test_client_reconnect_does_not_wait_for_unrelated_terminal_lock():
|
||||||
|
inventory = _inventory()
|
||||||
|
inventory["terminal_lock"] = {"terminal_pr": 901}
|
||||||
|
report = rc.evaluate_restart_impact(
|
||||||
|
inventory,
|
||||||
|
now=NOW,
|
||||||
|
requesting_session_id="requester",
|
||||||
|
restart_class=rc.RestartClass.CLIENT_RECONNECT,
|
||||||
|
requester_role="author",
|
||||||
|
requester_permissions=rc.permissions_for_role("author"),
|
||||||
|
)
|
||||||
|
assert report.allow_restart
|
||||||
|
assert report.terminal_lock is None
|
||||||
|
|
||||||
|
|
||||||
|
def test_scoped_restart_only_counts_named_target():
|
||||||
|
report = _evaluate(
|
||||||
|
rc.RestartClass.ROLE_RUNTIME_RESTART,
|
||||||
|
target_role="author",
|
||||||
|
)
|
||||||
|
assert report.counts["leases_disruptive"] == 0
|
||||||
|
assert report.affected_prs == []
|
||||||
|
assert report.allow_restart
|
||||||
|
|
||||||
|
reviewer = _evaluate(
|
||||||
|
rc.RestartClass.ROLE_RUNTIME_RESTART,
|
||||||
|
target_role="reviewer",
|
||||||
|
)
|
||||||
|
assert reviewer.counts["leases_disruptive"] == 1
|
||||||
|
assert reviewer.affected_prs == [900]
|
||||||
|
assert not reviewer.allow_restart
|
||||||
|
|
||||||
|
|
||||||
|
def test_missing_scoped_target_denies_instead_of_widening():
|
||||||
|
for restart_class in (
|
||||||
|
rc.RestartClass.WORKER_RESTART,
|
||||||
|
rc.RestartClass.ROLE_RUNTIME_RESTART,
|
||||||
|
rc.RestartClass.CONNECTOR_RESTART,
|
||||||
|
):
|
||||||
|
report = _evaluate(restart_class)
|
||||||
|
assert not report.allow_restart
|
||||||
|
assert any("target required" in r for r in report.reasons)
|
||||||
|
|
||||||
|
|
||||||
|
def test_only_full_and_host_classes_require_full_drain():
|
||||||
|
requiring_full = {
|
||||||
|
restart_class
|
||||||
|
for restart_class, policy in rc.RESTART_CLASS_POLICIES.items()
|
||||||
|
if policy.full_drain_required
|
||||||
|
}
|
||||||
|
assert requiring_full == {
|
||||||
|
rc.RestartClass.FULL_MCP_RESTART,
|
||||||
|
rc.RestartClass.HOST_RESTART,
|
||||||
|
}
|
||||||
File diff suppressed because it is too large
Load Diff
@@ -444,6 +444,12 @@ class TestAuditEmission(unittest.TestCase):
|
|||||||
)
|
)
|
||||||
self.assertEqual(record["target"]["namespace"], NAMESPACE)
|
self.assertEqual(record["target"]["namespace"], NAMESPACE)
|
||||||
self.assertEqual(record["target"]["mode"], "restart")
|
self.assertEqual(record["target"]["mode"], "restart")
|
||||||
|
self.assertEqual(
|
||||||
|
record["target"]["restart_class"], "role_runtime_restart"
|
||||||
|
)
|
||||||
|
self.assertEqual(
|
||||||
|
record["metadata"]["restart_class"], "role_runtime_restart"
|
||||||
|
)
|
||||||
self.assertEqual(record["result"], console_audit.RESULT_ALLOWED)
|
self.assertEqual(record["result"], console_audit.RESULT_ALLOWED)
|
||||||
self.assertEqual(record["actor"]["subject"], "[email protected]")
|
self.assertEqual(record["actor"]["subject"], "[email protected]")
|
||||||
self.assertFalse(record["metadata"]["process_kill_executed"])
|
self.assertFalse(record["metadata"]["process_kill_executed"])
|
||||||
|
|||||||
+116
@@ -67,6 +67,8 @@ from webui.system_health import (
|
|||||||
snapshot_to_dict as system_health_to_dict,
|
snapshot_to_dict as system_health_to_dict,
|
||||||
)
|
)
|
||||||
from webui.system_health_views import render_system_health_page
|
from webui.system_health_views import render_system_health_page
|
||||||
|
from webui import request_service
|
||||||
|
from webui.request_views import render_requests_page
|
||||||
|
|
||||||
_READ_ONLY_METHODS = frozenset({"GET", "HEAD", "OPTIONS"})
|
_READ_ONLY_METHODS = frozenset({"GET", "HEAD", "OPTIONS"})
|
||||||
_AUDIT_MUTATION_PATHS = frozenset({"/audit", "/api/audit"})
|
_AUDIT_MUTATION_PATHS = frozenset({"/audit", "/api/audit"})
|
||||||
@@ -722,6 +724,109 @@ async def api_v1_analytics_ingest(request: Request) -> JSONResponse:
|
|||||||
)
|
)
|
||||||
|
|
||||||
|
|
||||||
|
def _default_request_scope() -> dict[str, str]:
|
||||||
|
"""Resolve remote/org/repo from the project registry for request forms.
|
||||||
|
|
||||||
|
Returns an empty mapping when the registry cannot be read, which makes
|
||||||
|
``parse_request`` reject a request that did not name its own scope rather
|
||||||
|
than letting it default to some other repository.
|
||||||
|
"""
|
||||||
|
from webui.queue_loader import _host_from_url # host normalisation helper
|
||||||
|
|
||||||
|
registry, error = _load_project_registry()
|
||||||
|
if error is not None or not registry.projects:
|
||||||
|
return {}
|
||||||
|
project = registry.projects[0]
|
||||||
|
host = _host_from_url(project.remote_host)
|
||||||
|
return {
|
||||||
|
"remote": _derive_remote(host),
|
||||||
|
"org": project.gitea_owner or "",
|
||||||
|
"repo": project.repo_name or "",
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
async def _request_payload(request: Request) -> dict[str, object]:
|
||||||
|
"""Read a request body as JSON or form-encoded. Never raises."""
|
||||||
|
content_type = (request.headers.get("content-type") or "").lower()
|
||||||
|
if "application/json" in content_type:
|
||||||
|
try:
|
||||||
|
body = await request.json()
|
||||||
|
except Exception:
|
||||||
|
return {}
|
||||||
|
return dict(body) if isinstance(body, dict) else {}
|
||||||
|
try:
|
||||||
|
form = await request.form()
|
||||||
|
except Exception:
|
||||||
|
return {}
|
||||||
|
return {key: form[key] for key in form}
|
||||||
|
|
||||||
|
|
||||||
|
async def requests_page(request: Request) -> HTMLResponse:
|
||||||
|
"""Operator request form and intent preview (#643).
|
||||||
|
|
||||||
|
POST here only ever *previews*. Initiation is a separate confirmed call to
|
||||||
|
``/api/v1/requests/apply`` so that submitting this form cannot reserve
|
||||||
|
work as a side effect.
|
||||||
|
"""
|
||||||
|
submitted: dict[str, object] = {}
|
||||||
|
preview = None
|
||||||
|
error = None
|
||||||
|
if request.method == "POST":
|
||||||
|
submitted = await _request_payload(request)
|
||||||
|
work_request, error = request_service.parse_request(
|
||||||
|
submitted, default_scope=_default_request_scope()
|
||||||
|
)
|
||||||
|
if work_request is not None:
|
||||||
|
preview = request_service.preview_request(
|
||||||
|
work_request,
|
||||||
|
principal=resolve_principal(headers=dict(request.headers)),
|
||||||
|
)
|
||||||
|
return HTMLResponse(
|
||||||
|
render_requests_page(
|
||||||
|
preview=preview, error=error, submitted=submitted
|
||||||
|
)
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
async def api_v1_request_preview(request: Request) -> JSONResponse:
|
||||||
|
"""Dry-run authorization and intent preview for a work request (#643)."""
|
||||||
|
payload = await _request_payload(request)
|
||||||
|
work_request, error = request_service.parse_request(
|
||||||
|
payload, default_scope=_default_request_scope()
|
||||||
|
)
|
||||||
|
if work_request is None:
|
||||||
|
return JSONResponse(error.to_dict(), status_code=400)
|
||||||
|
preview = request_service.preview_request(
|
||||||
|
work_request,
|
||||||
|
principal=resolve_principal(headers=dict(request.headers)),
|
||||||
|
)
|
||||||
|
return JSONResponse(
|
||||||
|
preview.to_dict(), status_code=200 if preview.authorized else 403
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
async def api_v1_request_apply(request: Request) -> JSONResponse:
|
||||||
|
"""Initiate a previewed work request through the allocator (#643).
|
||||||
|
|
||||||
|
Fail-closed at every step: unauthorized, unconfirmed, not-next-safe, and
|
||||||
|
already-claimed all return without attempting an assignment.
|
||||||
|
"""
|
||||||
|
payload = await _request_payload(request)
|
||||||
|
work_request, error = request_service.parse_request(
|
||||||
|
payload, default_scope=_default_request_scope()
|
||||||
|
)
|
||||||
|
if work_request is None:
|
||||||
|
return JSONResponse(error.to_dict(), status_code=400)
|
||||||
|
confirm = _truthy_flag(str(payload.get("confirm") or ""))
|
||||||
|
result = request_service.apply_request(
|
||||||
|
work_request,
|
||||||
|
principal=resolve_principal(headers=dict(request.headers)),
|
||||||
|
confirm=confirm,
|
||||||
|
)
|
||||||
|
status = int(result.pop("status_code", 403))
|
||||||
|
return JSONResponse(result, status_code=status)
|
||||||
|
|
||||||
|
|
||||||
async def method_not_allowed(request: Request, _exc: Exception) -> Response:
|
async def method_not_allowed(request: Request, _exc: Exception) -> Response:
|
||||||
path = request.url.path
|
path = request.url.path
|
||||||
if path in _AUDIT_MUTATION_PATHS and request.method == "POST":
|
if path in _AUDIT_MUTATION_PATHS and request.method == "POST":
|
||||||
@@ -786,6 +891,17 @@ def create_app(*, bind_host: str | None = None) -> Starlette:
|
|||||||
api_action_attempt,
|
api_action_attempt,
|
||||||
methods=["POST"],
|
methods=["POST"],
|
||||||
),
|
),
|
||||||
|
Route("/requests", requests_page, methods=["GET", "POST"]),
|
||||||
|
Route(
|
||||||
|
"/api/v1/requests/preview",
|
||||||
|
api_v1_request_preview,
|
||||||
|
methods=["POST"],
|
||||||
|
),
|
||||||
|
Route(
|
||||||
|
"/api/v1/requests/apply",
|
||||||
|
api_v1_request_apply,
|
||||||
|
methods=["POST"],
|
||||||
|
),
|
||||||
Route("/api/leases", api_leases, methods=["GET"]),
|
Route("/api/leases", api_leases, methods=["GET"]),
|
||||||
Route("/api/v1/inventory", api_inventory, methods=["GET"]),
|
Route("/api/v1/inventory", api_inventory, methods=["GET"]),
|
||||||
Route(
|
Route(
|
||||||
|
|||||||
+71
-8
@@ -115,6 +115,12 @@ class ConsoleAction:
|
|||||||
break_glass: bool
|
break_glass: bool
|
||||||
phase: int
|
phase: int
|
||||||
summary: str
|
summary: str
|
||||||
|
# Opt-in switch for an action whose execution path is genuinely wired
|
||||||
|
# ahead of its phase becoming globally active (#643). Naming a variable
|
||||||
|
# here enables nothing on its own: the variable must also be set in the
|
||||||
|
# environment. An action that leaves this ``None`` can only execute once
|
||||||
|
# ACTIVE_PHASE reaches its phase, exactly as before.
|
||||||
|
execution_env_flag: str | None = None
|
||||||
|
|
||||||
@property
|
@property
|
||||||
def mcp_permission(self) -> str:
|
def mcp_permission(self) -> str:
|
||||||
@@ -277,6 +283,27 @@ _ACTION_SPECS: tuple[ConsoleAction, ...] = (
|
|||||||
phase=2,
|
phase=2,
|
||||||
summary="Restart one MCP namespace via the host supervisor.",
|
summary="Restart one MCP namespace via the host supervisor.",
|
||||||
),
|
),
|
||||||
|
# #643: submit a work request — desired role, issue/PR, intent — and let
|
||||||
|
# the allocator reserve it. This is the one Phase 2 action whose execution
|
||||||
|
# path is actually implemented (``webui.request_service``), so it carries
|
||||||
|
# the opt-in flag; it stays denied until an operator sets that variable.
|
||||||
|
# Authority is operator-class because the outcome is a claim, not a Gitea
|
||||||
|
# verdict: initiating reviewer or merger *work* does not grant the right
|
||||||
|
# to approve or merge, which stays with the MCP role profile.
|
||||||
|
ConsoleAction(
|
||||||
|
action_id="initiate_workflow",
|
||||||
|
task_key="allocate_next_work",
|
||||||
|
action_class=CLASS_WRITE,
|
||||||
|
minimum_role=OPERATOR,
|
||||||
|
requires_confirmation=True,
|
||||||
|
dual_control=False,
|
||||||
|
break_glass=False,
|
||||||
|
phase=2,
|
||||||
|
summary=(
|
||||||
|
"Preview and initiate allocator-owned workflow work for a role."
|
||||||
|
),
|
||||||
|
execution_env_flag="WEBUI_REQUESTS_EXECUTION",
|
||||||
|
),
|
||||||
)
|
)
|
||||||
|
|
||||||
ACTIONS: dict[str, ConsoleAction] = {a.action_id: a for a in _ACTION_SPECS}
|
ACTIONS: dict[str, ConsoleAction] = {a.action_id: a for a in _ACTION_SPECS}
|
||||||
@@ -430,6 +457,33 @@ ALLOW_PREVIEW = "allowed_preview_only"
|
|||||||
# gated on this model landing; nothing here enables it.
|
# gated on this model landing; nothing here enables it.
|
||||||
ACTIVE_PHASE = 1
|
ACTIVE_PHASE = 1
|
||||||
|
|
||||||
|
_TRUTHY = frozenset({"1", "true", "yes", "on"})
|
||||||
|
|
||||||
|
|
||||||
|
def execution_wired(
|
||||||
|
action: ConsoleAction | None, env: dict[str, str] | None = None
|
||||||
|
) -> bool:
|
||||||
|
"""Whether *action* has a live execution path right now.
|
||||||
|
|
||||||
|
Two ways to be wired, and only two. The action's phase is active, or the
|
||||||
|
action declares an opt-in environment variable *and* that variable is set.
|
||||||
|
Everything else — including every action that never declares a flag — is
|
||||||
|
unwired, so the default across the registry stays deny.
|
||||||
|
|
||||||
|
Bumping ``ACTIVE_PHASE`` would enable execution for every action of that
|
||||||
|
phase at once. The per-action flag exists so a single implemented action
|
||||||
|
can go live without dragging its unimplemented phase-mates with it.
|
||||||
|
"""
|
||||||
|
if action is None:
|
||||||
|
return False
|
||||||
|
if action.phase <= ACTIVE_PHASE:
|
||||||
|
return True
|
||||||
|
flag = (action.execution_env_flag or "").strip()
|
||||||
|
if not flag:
|
||||||
|
return False
|
||||||
|
source = env if env is not None else os.environ
|
||||||
|
return (source.get(flag) or "").strip().lower() in _TRUTHY
|
||||||
|
|
||||||
|
|
||||||
@dataclass(frozen=True)
|
@dataclass(frozen=True)
|
||||||
class AuthorizationDecision:
|
class AuthorizationDecision:
|
||||||
@@ -469,16 +523,19 @@ def authorize(
|
|||||||
principal: Principal | None = None,
|
principal: Principal | None = None,
|
||||||
*,
|
*,
|
||||||
for_execution: bool = False,
|
for_execution: bool = False,
|
||||||
|
env: dict[str, str] | None = None,
|
||||||
) -> AuthorizationDecision:
|
) -> AuthorizationDecision:
|
||||||
"""Decide whether *principal* may invoke *action_id*. Deny by default.
|
"""Decide whether *principal* may invoke *action_id*. Deny by default.
|
||||||
|
|
||||||
``for_execution`` distinguishes a read-only preview from a real invocation.
|
``for_execution`` distinguishes a read-only preview from a real invocation.
|
||||||
Even an allowed decision reports ``execution_enabled=False`` while the
|
``execution_enabled`` reports whether the action has a live execution path
|
||||||
console is in Phase 1, so no caller can read an allow as permission to
|
at all (:func:`execution_wired`) — for every action without an explicit
|
||||||
mutate.
|
opt-in flag that stays ``False`` while the console is in Phase 1, so no
|
||||||
|
caller can read an allow as permission to mutate.
|
||||||
"""
|
"""
|
||||||
who = principal if principal is not None else ANONYMOUS
|
who = principal if principal is not None else ANONYMOUS
|
||||||
action = get_action(action_id)
|
action = get_action(action_id)
|
||||||
|
wired = execution_wired(action, env)
|
||||||
|
|
||||||
if action is None:
|
if action is None:
|
||||||
return AuthorizationDecision(
|
return AuthorizationDecision(
|
||||||
@@ -497,7 +554,7 @@ def authorize(
|
|||||||
"requires_confirmation": action.requires_confirmation,
|
"requires_confirmation": action.requires_confirmation,
|
||||||
"dual_control": action.dual_control,
|
"dual_control": action.dual_control,
|
||||||
"break_glass": action.break_glass,
|
"break_glass": action.break_glass,
|
||||||
"execution_enabled": False,
|
"execution_enabled": wired,
|
||||||
}
|
}
|
||||||
|
|
||||||
if not who.authenticated:
|
if not who.authenticated:
|
||||||
@@ -530,13 +587,19 @@ def authorize(
|
|||||||
**base,
|
**base,
|
||||||
)
|
)
|
||||||
|
|
||||||
if for_execution and action.phase > ACTIVE_PHASE:
|
if for_execution and not wired:
|
||||||
return AuthorizationDecision(
|
return AuthorizationDecision(
|
||||||
allowed=False,
|
allowed=False,
|
||||||
reason_code=DENY_PHASE_NOT_ACTIVE,
|
reason_code=DENY_PHASE_NOT_ACTIVE,
|
||||||
detail=(
|
detail=(
|
||||||
f"Action {action_id!r} belongs to phase {action.phase}; the "
|
f"Action {action_id!r} belongs to phase {action.phase}; the "
|
||||||
f"console is in phase {ACTIVE_PHASE}. Execution is not wired."
|
f"console is in phase {ACTIVE_PHASE}"
|
||||||
|
+ (
|
||||||
|
f" and {action.execution_env_flag} is not set"
|
||||||
|
if action.execution_env_flag
|
||||||
|
else ""
|
||||||
|
)
|
||||||
|
+ ". Execution is not wired."
|
||||||
),
|
),
|
||||||
**base,
|
**base,
|
||||||
)
|
)
|
||||||
@@ -545,8 +608,8 @@ def authorize(
|
|||||||
allowed=True,
|
allowed=True,
|
||||||
reason_code=ALLOW_PREVIEW,
|
reason_code=ALLOW_PREVIEW,
|
||||||
detail=(
|
detail=(
|
||||||
"Principal holds the required role. Preview only — execution "
|
"Principal holds the required role. Execution proceeds only for an "
|
||||||
"remains disabled until the Phase 2 action framework ships."
|
"action with a wired execution path; everything else is preview."
|
||||||
),
|
),
|
||||||
**base,
|
**base,
|
||||||
)
|
)
|
||||||
|
|||||||
@@ -45,6 +45,7 @@ NAV_GROUPS: tuple[NavGroup, ...] = (
|
|||||||
NavItem("/queue", "Queue"),
|
NavItem("/queue", "Queue"),
|
||||||
NavItem("/leases", "Leases"),
|
NavItem("/leases", "Leases"),
|
||||||
NavItem("/actions", "Actions"),
|
NavItem("/actions", "Actions"),
|
||||||
|
NavItem("/requests", "Requests"),
|
||||||
)),
|
)),
|
||||||
NavGroup("Runtime/Sessions", (
|
NavGroup("Runtime/Sessions", (
|
||||||
NavItem("/runtime", "Runtime health"),
|
NavItem("/runtime", "Runtime health"),
|
||||||
|
|||||||
File diff suppressed because it is too large
Load Diff
@@ -0,0 +1,164 @@
|
|||||||
|
"""HTML views for the operator request surface (#643).
|
||||||
|
|
||||||
|
The form is deliberately a *preview* form. It has no initiate button, because
|
||||||
|
initiating requires a confirmed POST to ``/api/v1/requests/apply`` and a stray
|
||||||
|
form submission must not be able to produce one by accident.
|
||||||
|
|
||||||
|
Nothing rendered here is trusted input: every interpolated value is escaped,
|
||||||
|
and the page renders only values the service already produced rather than
|
||||||
|
echoing a raw request body back.
|
||||||
|
"""
|
||||||
|
|
||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
import html
|
||||||
|
import json
|
||||||
|
from typing import Any
|
||||||
|
|
||||||
|
from webui.layout import render_page
|
||||||
|
from webui.request_service import (
|
||||||
|
REQUESTABLE_ROLES,
|
||||||
|
WORK_KINDS,
|
||||||
|
RequestError,
|
||||||
|
RequestPreview,
|
||||||
|
)
|
||||||
|
|
||||||
|
REQUESTS_PATH = "/requests"
|
||||||
|
PREVIEW_API_PATH = "/api/v1/requests/preview"
|
||||||
|
APPLY_API_PATH = "/api/v1/requests/apply"
|
||||||
|
|
||||||
|
|
||||||
|
def _escape(text: Any) -> str:
|
||||||
|
return html.escape(str(text if text is not None else ""), quote=True)
|
||||||
|
|
||||||
|
|
||||||
|
REQUEST_PAGE_STYLES = """
|
||||||
|
<style>
|
||||||
|
.request-form { display: grid; gap: 0.75rem; max-width: 44rem; }
|
||||||
|
.request-form label { display: grid; gap: 0.25rem; font-size: 0.9rem; }
|
||||||
|
.request-check { margin: 0.35rem 0; }
|
||||||
|
.request-check .verdict-ok { color: var(--accent); }
|
||||||
|
.request-check .verdict-fail { color: #d14; }
|
||||||
|
.request-prohibited code { margin-right: 0.4rem; }
|
||||||
|
</style>
|
||||||
|
"""
|
||||||
|
|
||||||
|
|
||||||
|
def _options(values: tuple[str, ...], selected: Any) -> str:
|
||||||
|
return "".join(
|
||||||
|
f"<option value='{_escape(value)}'"
|
||||||
|
+ (" selected" if selected == value else "")
|
||||||
|
+ f">{_escape(value)}</option>"
|
||||||
|
for value in values
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
def _form(values: dict[str, Any] | None = None) -> str:
|
||||||
|
current = dict(values or {})
|
||||||
|
number = current.get("work_number")
|
||||||
|
return (
|
||||||
|
f"<form class='request-form' method='post' action='{REQUESTS_PATH}'>"
|
||||||
|
"<label>Desired role<select name='desired_role'>"
|
||||||
|
f"{_options(REQUESTABLE_ROLES, current.get('desired_role'))}"
|
||||||
|
"</select></label>"
|
||||||
|
"<label>Work kind<select name='work_kind'>"
|
||||||
|
f"{_options(WORK_KINDS, current.get('work_kind'))}"
|
||||||
|
"</select></label>"
|
||||||
|
"<label>Issue or PR number"
|
||||||
|
"<input type='number' name='work_number' min='1' required "
|
||||||
|
f"value='{_escape(number) if number else ''}'></label>"
|
||||||
|
"<label>Intent summary"
|
||||||
|
"<input type='text' name='intent_summary' maxlength='500' required "
|
||||||
|
f"value='{_escape(current.get('intent_summary'))}'></label>"
|
||||||
|
"<label>Expected head SHA <span class='muted'>(PR work only)</span>"
|
||||||
|
"<input type='text' name='expected_head_sha' "
|
||||||
|
f"value='{_escape(current.get('expected_head_sha'))}'></label>"
|
||||||
|
"<button type='submit' class='copy-btn'>Preview request</button>"
|
||||||
|
"<p class='muted meta'>Preview is read-only and creates no assignment. "
|
||||||
|
f"Initiating requires a confirmed POST to <code>{APPLY_API_PATH}</code>."
|
||||||
|
"</p>"
|
||||||
|
"</form>"
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
def _checks_block(preview: RequestPreview) -> str:
|
||||||
|
rows = []
|
||||||
|
for check in preview.checks:
|
||||||
|
verdict = "PASS" if check.ok else "FAIL"
|
||||||
|
css = "verdict-ok" if check.ok else "verdict-fail"
|
||||||
|
rows.append(
|
||||||
|
"<li class='request-check'>"
|
||||||
|
f"<span class='{css}'><strong>{verdict}</strong></span> "
|
||||||
|
f"<code>{_escape(check.name)}</code> — {_escape(check.detail)} "
|
||||||
|
f"<span class='muted meta'>({_escape(check.reason_code)})</span>"
|
||||||
|
"</li>"
|
||||||
|
)
|
||||||
|
return "<ul>" + "".join(rows) + "</ul>"
|
||||||
|
|
||||||
|
|
||||||
|
def _preview_block(preview: RequestPreview) -> str:
|
||||||
|
verdict = "AUTHORIZED" if preview.authorized else "DENIED"
|
||||||
|
prohibited = "".join(
|
||||||
|
f"<code>{_escape(action)}</code>" for action in preview.prohibited_actions
|
||||||
|
)
|
||||||
|
request = preview.request
|
||||||
|
evidence = json.dumps(preview.allocator_evidence, indent=2, default=str)
|
||||||
|
return (
|
||||||
|
"<h3>Intent preview</h3>"
|
||||||
|
f"<p><strong>{verdict}</strong> — {_escape(preview.detail)}</p>"
|
||||||
|
"<p class='meta'>"
|
||||||
|
f"Role <code>{_escape(request.desired_role)}</code> · "
|
||||||
|
f"{_escape(request.work_kind)} <code>{_escape(request.display_ref)}</code>"
|
||||||
|
f" · profile <code>{_escape(preview.required_profile)}</code> · "
|
||||||
|
f"namespace <code>{_escape(preview.required_namespace)}</code> · "
|
||||||
|
f"permission <code>{_escape(preview.required_permission)}</code>"
|
||||||
|
"</p>"
|
||||||
|
f"<p>Intent: {_escape(request.intent_summary)}</p>"
|
||||||
|
f"{_checks_block(preview)}"
|
||||||
|
f"<p><strong>Next safe action:</strong> "
|
||||||
|
f"{_escape(preview.next_safe_action)}</p>"
|
||||||
|
"<p class='request-prohibited'><strong>Prohibited for this role:</strong> "
|
||||||
|
+ (prohibited or "<span class='muted'>none declared</span>")
|
||||||
|
+ "</p>"
|
||||||
|
"<p class='muted meta'>Correlation id "
|
||||||
|
f"<code>{_escape(preview.correlation_id)}</code></p>"
|
||||||
|
"<details><summary>Allocator evidence</summary>"
|
||||||
|
f"<pre class='prompt-text'>{_escape(evidence)}</pre>"
|
||||||
|
"</details>"
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
def _error_block(error: RequestError) -> str:
|
||||||
|
field = (
|
||||||
|
f"<p class='meta'>Field: <code>{_escape(error.field_name)}</code></p>"
|
||||||
|
if error.field_name
|
||||||
|
else ""
|
||||||
|
)
|
||||||
|
return (
|
||||||
|
"<h3>Request rejected</h3>"
|
||||||
|
f"<p><strong>{_escape(error.reason_code)}</strong> — "
|
||||||
|
f"{_escape(error.detail)}</p>{field}"
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
def render_requests_page(
|
||||||
|
*,
|
||||||
|
preview: RequestPreview | None = None,
|
||||||
|
error: RequestError | None = None,
|
||||||
|
submitted: dict[str, Any] | None = None,
|
||||||
|
) -> str:
|
||||||
|
"""Render the request form, plus a preview or rejection when one exists."""
|
||||||
|
body = (
|
||||||
|
"<h2>Requests</h2>"
|
||||||
|
"<p>Submit a work request — desired role, issue or PR, and intent — "
|
||||||
|
"and see whether it would be authorized before anything is reserved. "
|
||||||
|
"Initiation goes through the allocator (#600/#613); this console never "
|
||||||
|
"self-selects work, never approves, and never merges.</p>"
|
||||||
|
+ _form(submitted)
|
||||||
|
+ (_error_block(error) if error is not None else "")
|
||||||
|
+ (_preview_block(preview) if preview is not None else "")
|
||||||
|
+ f"<p class='meta'><a href='{PREVIEW_API_PATH}'>Preview API</a> · "
|
||||||
|
"<a href='/api/console/security-model'>RBAC model</a></p>"
|
||||||
|
+ REQUEST_PAGE_STYLES
|
||||||
|
)
|
||||||
|
return render_page(title="Requests", body_html=body)
|
||||||
@@ -38,6 +38,7 @@ from dataclasses import asdict, dataclass
|
|||||||
from typing import Any
|
from typing import Any
|
||||||
|
|
||||||
import mcp_namespace_health
|
import mcp_namespace_health
|
||||||
|
import restart_coordinator
|
||||||
import runtime_recovery_guard
|
import runtime_recovery_guard
|
||||||
from webui import console_audit, console_authz
|
from webui import console_audit, console_authz
|
||||||
|
|
||||||
@@ -99,6 +100,14 @@ def _clean(value: Any) -> str:
|
|||||||
return str(value or "").strip()
|
return str(value or "").strip()
|
||||||
|
|
||||||
|
|
||||||
|
def restart_class_for_mode(mode: str) -> str:
|
||||||
|
"""Map the existing namespace controls onto the #663 class taxonomy."""
|
||||||
|
|
||||||
|
if _clean(mode) == MODE_RELOAD:
|
||||||
|
return restart_coordinator.RestartClass.CONFIGURATION_RELOAD.value
|
||||||
|
return restart_coordinator.RestartClass.ROLE_RUNTIME_RESTART.value
|
||||||
|
|
||||||
|
|
||||||
# --- Mutation ledger --------------------------------------------------------
|
# --- Mutation ledger --------------------------------------------------------
|
||||||
|
|
||||||
|
|
||||||
@@ -256,6 +265,7 @@ def build_restart_preview(
|
|||||||
|
|
||||||
return {
|
return {
|
||||||
"action_id": action_id,
|
"action_id": action_id,
|
||||||
|
"restart_class": restart_class_for_mode(md),
|
||||||
"namespace": ns,
|
"namespace": ns,
|
||||||
"mode": md,
|
"mode": md,
|
||||||
"scope_valid": scope_error is None,
|
"scope_valid": scope_error is None,
|
||||||
@@ -309,6 +319,7 @@ def assess_restart_request(
|
|||||||
"reason_code": reason_code,
|
"reason_code": reason_code,
|
||||||
"detail": detail,
|
"detail": detail,
|
||||||
"action_id": action_id,
|
"action_id": action_id,
|
||||||
|
"restart_class": restart_class_for_mode(md),
|
||||||
"namespace": ns,
|
"namespace": ns,
|
||||||
"mode": md,
|
"mode": md,
|
||||||
"preview": preview,
|
"preview": preview,
|
||||||
@@ -393,6 +404,7 @@ def assess_restart_request(
|
|||||||
"process."
|
"process."
|
||||||
),
|
),
|
||||||
"action_id": action_id,
|
"action_id": action_id,
|
||||||
|
"restart_class": restart_class_for_mode(md),
|
||||||
"namespace": ns,
|
"namespace": ns,
|
||||||
"mode": md,
|
"mode": md,
|
||||||
"preview": preview,
|
"preview": preview,
|
||||||
@@ -441,7 +453,11 @@ def execute_restart(
|
|||||||
else console_audit.RESULT_DENIED
|
else console_audit.RESULT_DENIED
|
||||||
),
|
),
|
||||||
principal=principal,
|
principal=principal,
|
||||||
target={"namespace": assessment["namespace"], "mode": assessment["mode"]},
|
target={
|
||||||
|
"namespace": assessment["namespace"],
|
||||||
|
"mode": assessment["mode"],
|
||||||
|
"restart_class": assessment["restart_class"],
|
||||||
|
},
|
||||||
reason_code=assessment["reason_code"],
|
reason_code=assessment["reason_code"],
|
||||||
detail=assessment["detail"],
|
detail=assessment["detail"],
|
||||||
request_id=request_id,
|
request_id=request_id,
|
||||||
@@ -450,6 +466,7 @@ def execute_restart(
|
|||||||
"gates_passed": assessment["gates_passed"],
|
"gates_passed": assessment["gates_passed"],
|
||||||
"process_kill_executed": False,
|
"process_kill_executed": False,
|
||||||
"post_restart_verification_required": True,
|
"post_restart_verification_required": True,
|
||||||
|
"restart_class": assessment["restart_class"],
|
||||||
},
|
},
|
||||||
)
|
)
|
||||||
|
|
||||||
@@ -463,6 +480,7 @@ def execute_restart(
|
|||||||
"namespace": assessment["namespace"],
|
"namespace": assessment["namespace"],
|
||||||
"mode": assessment["mode"],
|
"mode": assessment["mode"],
|
||||||
"action_id": action_id,
|
"action_id": action_id,
|
||||||
|
"restart_class": assessment["restart_class"],
|
||||||
"process_kill_executed": False,
|
"process_kill_executed": False,
|
||||||
"host_hook": assessment["preview"]["restart_hook"],
|
"host_hook": assessment["preview"]["restart_hook"],
|
||||||
"next_action": (
|
"next_action": (
|
||||||
|
|||||||
@@ -201,6 +201,16 @@ def _candidates_from_queue_snapshot(q_snap: QueueSnapshot) -> list[WorkCandidate
|
|||||||
return candidates
|
return candidates
|
||||||
|
|
||||||
|
|
||||||
|
def candidates_from_queue_snapshot(q_snap: QueueSnapshot) -> list[WorkCandidate]:
|
||||||
|
"""Public alias for :func:`_candidates_from_queue_snapshot` (#643).
|
||||||
|
|
||||||
|
The request-initiation service ranks the same candidate set this view
|
||||||
|
renders, so both must agree on how a queue row becomes a candidate. One
|
||||||
|
construction, two callers — not two that can drift apart.
|
||||||
|
"""
|
||||||
|
return _candidates_from_queue_snapshot(q_snap)
|
||||||
|
|
||||||
|
|
||||||
def _claim_lease_records(inventory: dict[str, Any] | None) -> list[dict[str, Any]]:
|
def _claim_lease_records(inventory: dict[str, Any] | None) -> list[dict[str, Any]]:
|
||||||
"""Normalize ``build_claim_inventory`` entries into lease records.
|
"""Normalize ``build_claim_inventory`` entries into lease records.
|
||||||
|
|
||||||
|
|||||||
Reference in New Issue
Block a user