Merge branch 'master' into feat/issue-543-mcp-namespace-health-check

This commit is contained in:
2026-07-09 12:51:07 -04:00
35 changed files with 2482 additions and 43 deletions
+98
View File
@@ -0,0 +1,98 @@
"""Guards for merged-PR branch cleanup and raw git delete bypasses (#514)."""
from __future__ import annotations
import re
from typing import Any
PROTECTED_BRANCHES = frozenset({"master", "main", "dev"})
_RAW_BRANCH_DELETE_PATTERNS = (
re.compile(r"\bgit(?:\s+-C\s+\S+)?\s+branch\s+-[dD]\b[^\n\r]*", re.I),
re.compile(r"\bgit(?:\s+-C\s+\S+)?\s+push\b[^\n\r]*\s--delete\b[^\n\r]*", re.I),
re.compile(r"\bgit(?:\s+-C\s+\S+)?\s+push\b[^\n\r]*\s:[^\s`]+", re.I),
)
def raw_branch_delete_commands(text: str | None) -> list[str]:
"""Return raw git branch-delete commands cited in *text*."""
if not text:
return []
commands: list[str] = []
for pattern in _RAW_BRANCH_DELETE_PATTERNS:
commands.extend(match.group(0).strip("` ") for match in pattern.finditer(text))
return list(dict.fromkeys(commands))
def assess_raw_branch_delete_report(text: str | None) -> dict[str, Any]:
"""Fail closed when a report uses raw git branch deletion as cleanup proof."""
commands = raw_branch_delete_commands(text)
reasons = [
(
"raw git branch deletion bypasses MCP branch.delete cleanup gates: "
f"{command}"
)
for command in commands
]
return {
"proven": not reasons,
"block": bool(reasons),
"commands": commands,
"reasons": reasons,
"safe_next_action": (
"use gitea_cleanup_merged_pr_branch or another approved cleanup "
"helper with explicit branch.delete capability"
if reasons
else "proceed"
),
}
def assess_merged_pr_branch_cleanup(
*,
pr_number: int,
head_branch: str,
merged: bool,
remote_branch_exists: bool,
open_pr_heads: set[str],
head_on_target: bool | None,
delete_capability_allowed: bool,
confirmation: str | None,
protected_branches: frozenset[str] | None = None,
) -> dict[str, Any]:
"""Assess whether a merged PR source branch can be deleted via MCP."""
protected = protected_branches or PROTECTED_BRANCHES
expected_confirmation = f"CLEANUP MERGED PR {pr_number} BRANCH {head_branch}"
reasons: list[str] = []
if not merged:
reasons.append("PR is not merged")
if not remote_branch_exists:
reasons.append("remote branch already absent")
if not head_branch:
reasons.append("PR head branch is missing")
if head_branch in protected:
reasons.append(f"branch '{head_branch}' is protected")
if head_branch in open_pr_heads:
reasons.append("an open PR still references this head branch")
if head_on_target is False:
reasons.append("PR head is not an ancestor of the target branch")
if head_on_target is None:
reasons.append("PR head ancestry could not be proven")
if not delete_capability_allowed:
reasons.append("gitea.branch.delete capability is not allowed")
if confirmation != expected_confirmation:
reasons.append(
"confirmation must equal "
f"'{expected_confirmation}' for branch cleanup"
)
safe = not reasons
return {
"pr_number": pr_number,
"head_branch": head_branch,
"expected_confirmation": expected_confirmation,
"remote_branch_exists": remote_branch_exists,
"safe_to_delete": safe,
"block_reasons": reasons,
"recommended_action": "delete_remote_branch" if safe else "keep_remote_branch",
}
+19
View File
@@ -54,6 +54,25 @@ Use `-q` for a compact summary and `-v` to see individual test names.
./venv/bin/python -m pytest tests/ -q ./venv/bin/python -m pytest tests/ -q
``` ```
### Web UI suite (#436)
Hermetic unittest modules matching `test_webui_*.py` cover route rendering,
read-only guards, registry/prompt/queue loaders, and optional child-issue
modules when present on the branch.
```bash
./scripts/test-webui
./scripts/ci-webui-check # skip unless the diff touches web UI paths
WEBUI_CI_FORCE=1 ./scripts/ci-webui-check
```
`scripts/test-webui` defaults `WEBUI_TEST_OFFLINE=1` so route coverage never
needs Gitea credentials or MCP daemon credential access. Set
`WEBUI_TEST_OFFLINE=0` only for explicit operator live-fetch checks.
Wire `scripts/ci-webui-check` into Jenkins (or equivalent) for PRs that touch
`webui/`, `tests/test_webui_*`, or `docs/webui*`.
### Run targeted tests ### Run targeted tests
```bash ```bash
+15
View File
@@ -661,6 +661,21 @@ session, clearing hung background terminals, switching to MCP-native commit, and
the agent temp artifact cleanup checklist. Do **not** retry shell encoding in a the agent temp artifact cleanup checklist. Do **not** retry shell encoding in a
loop and do **not** substitute WebFetch/Playwright/manual base64. loop and do **not** substitute WebFetch/Playwright/manual base64.
### Terminal launcher diagnostics (#556)
When git/pytest finalization fails with opaque spawn errors (e.g. `os error 2`),
do **not** improvise shell wrappers or fall back to direct API / temp scripts.
1. Call `gitea_diagnose_terminal` (optional `cwd`, optional `command`) — returns
categorized failure: missing cwd, cwd not a directory, missing executable,
missing runtime wrapper, missing shell, probe timeout, or session launcher
failure.
2. Call `gitea_get_shell_health` for the shell circuit-breaker state.
3. Mutation-capable `gitea_resolve_task_capability` probes the terminal launcher
before allowing git/pytest-oriented work; on failure it returns
`BLOCKED + DIAGNOSE` with `terminal_launcher_unhealthy` and diagnostics.
4. Emit the canonical `blocked-diagnose-report.md` template and stop.
### Create an issue / child issues ### Create an issue / child issues
- **Profile:** issue-manager or author (any profile allowed to create issues). - **Profile:** issue-manager or author (any profile allowed to create issues).
+59
View File
@@ -0,0 +1,59 @@
# Web UI deployment boundary (#435)
The MCP Control Plane web UI is an **internal operator console**, not a
customer-facing application. The MVP assumes local or trusted-network access
only.
## MVP deployment model
- **Default bind:** `127.0.0.1:8765` (`WEBUI_HOST` / `WEBUI_PORT`)
- **Authentication:** none in MVP — protection comes from network placement
- **Mutations:** read-only routes; gated write actions remain disabled (#434)
- **Secrets:** resolved server-side via `gitea_auth` / `GITEA_MCP_CONFIG`; never
embedded in HTML, JavaScript, or browser storage
Do **not** expose the UI on the public internet without an access layer.
## Beyond localhost
If the UI must be reachable outside the operator laptop:
1. Prefer **Cloudflare Access**, **Cloudflare WARP**, or an org **VPN** so only
authenticated staff reach the service.
2. Bind to a specific interface only when necessary — never `0.0.0.0` / `::`
without understanding the exposure.
3. Set explicit override env vars only after access controls are in place:
- `WEBUI_ALLOW_PUBLIC_BIND=1` — acknowledges all-interface bind (`0.0.0.0`, `::`)
- `WEBUI_ALLOW_REMOTE_BIND=1` — acknowledges a non-loopback host
Startup **refuses** all-interface binds unless `WEBUI_ALLOW_PUBLIC_BIND=1`.
Non-loopback binds log a warning unless `WEBUI_ALLOW_REMOTE_BIND=1`.
## Environment variables
| Variable | Default | Purpose |
|----------|---------|---------|
| `WEBUI_HOST` | `127.0.0.1` | Bind address |
| `WEBUI_PORT` | `8765` | Listen port |
| `WEBUI_REPO_ROOT` | repository root | Workflow/schema hash root for prompt library |
| `WEBUI_PROJECT_REGISTRY` | packaged JSON | Project registry file path |
| `GITEA_MCP_CONFIG` | unset | Server-side MCP profile config path (optional) |
| `GITEA_MCP_PROFILE` | unset | Active MCP profile name (optional) |
| `WEBUI_ALLOW_PUBLIC_BIND` | unset | Acknowledge `0.0.0.0` / `::` bind |
| `WEBUI_ALLOW_REMOTE_BIND` | unset | Acknowledge non-loopback bind |
Gitea credentials (`GITEA_TOKEN_*`, `.env.*`, keychain refs) are read only on
the server when a page needs live Gitea data (e.g. `/queue`). They are not
shipped to the browser.
## Health / deployment metadata
`GET /health` includes a `deployment` object with bind disposition, runtime
assumption paths, and the client-secret policy. Use it to verify an instance is
configured for internal-only operation.
## Non-goals (MVP)
- Full SSO or session login in the UI
- Hosting on the public internet without Access/VPN/WARP
- Embedding Gitea tokens in the frontend bundle
+79 -2
View File
@@ -29,6 +29,13 @@ Optional environment variables:
|----------|---------|---------| |----------|---------|---------|
| `WEBUI_HOST` | `127.0.0.1` | Bind address (keep local for MVP) | | `WEBUI_HOST` | `127.0.0.1` | Bind address (keep local for MVP) |
| `WEBUI_PORT` | `8765` | Listen port | | `WEBUI_PORT` | `8765` | Listen port |
| `WEBUI_REPO_ROOT` | repository root | Prompt library workflow hash root |
| `WEBUI_PROJECT_REGISTRY` | packaged JSON | Project registry path |
| `GITEA_MCP_CONFIG` | unset | Server-side MCP profile config (never sent to browser) |
| `GITEA_MCP_PROFILE` | unset | Active MCP profile name (server-side only) |
See [webui-deployment.md](webui-deployment.md) for internal-only serving,
Cloudflare Access/WARP/VPN guidance, and unsafe bind overrides (#435).
## Routes (MVP) ## Routes (MVP)
@@ -49,8 +56,9 @@ Optional environment variables:
| `/api/audit` | JSON validator preview (POST `report_text`, optional `task_kind`) | | `/api/audit` | JSON validator preview (POST `report_text`, optional `task_kind`) |
| `/worktrees` | Worktree hygiene dashboard (#432) | | `/worktrees` | Worktree hygiene dashboard (#432) |
| `/api/worktrees` | JSON worktree scan with classifications and anomalies | | `/api/worktrees` | JSON worktree scan with classifications and anomalies |
| `/leases` | Stub — lease visibility (#433) | | `/actions` | Gated write-action registry — all disabled in MVP (#434) |
| `/worktrees` | Stub — hygiene dashboard (#432) | | `/api/actions` | JSON action registry with capability metadata |
| `/api/actions/{id}/preview` | Mutation ledger preview (GET, read-only) |
| `/leases` | Lease and collision visibility (#433) | | `/leases` | Lease and collision visibility (#433) |
| `/api/leases` | JSON lease/collision export | | `/api/leases` | JSON lease/collision export |
@@ -97,6 +105,15 @@ credentials. The UI surfaces pagination proof (returned count, pages fetched,
If credentials are missing or the fetch fails, the page shows an explicit error If credentials are missing or the fetch fails, the page shows an explicit error
instead of an empty queue (fail closed). instead of an empty queue (fail closed).
## Gated actions (#434)
`/actions` registers future write actions (claim, comment, review, merge,
delete branch, create PR/issue). Each entry declares the MCP tool, required
permission, and profile role from `task_capability_map.py` — aligned with
`gitea_resolve_task_capability`. Buttons are disabled; previews always render
a mutation ledger. Direct `attempt_action` calls fail closed without invoking
MCP tools.
## Worktree hygiene (#432) ## Worktree hygiene (#432)
`/worktrees` scans local `branches/` directories and registered git worktrees. `/worktrees` scans local `branches/` directories and registered git worktrees.
@@ -106,6 +123,66 @@ referenced by the issue lock file are flagged as anomalies (#404). The page
includes a copy/paste canonical cleanup prompt only — no deletion actions. includes a copy/paste canonical cleanup prompt only — no deletion actions.
Override scan root with `WEBUI_REPO_ROOT` (defaults to repository root). Override scan root with `WEBUI_REPO_ROOT` (defaults to repository root).
## Lease visibility (#433)
`/leases` surfaces read-only lease and collision state: local issue lock file,
in-progress claim inventory (#268), reviewer PR lease comments when present
(`<!-- mcp-review-lease:v1 -->`, #407), duplicate open PRs per issue (#400),
and duplicate local branches per issue. Links to collision-history backend
issues (#267, #268, #400, #407) are included. No lease acquire/release from UI.
## Runtime health (#430)
`/runtime` surfaces read-only MCP/runtime diagnostics for the default registry
project: active profile and role kind, authenticated identity (when credentials
are available), config model/mode, local vs remote `master` SHA sync, shell
health, workflow/schema SHA-256 hashes, and stale-runtime warnings when the
checkout is behind merged safety-gate changes. Restart guidance links to #420;
no tokens or MCP restart actions are exposed.
## Deployment boundary (#435)
MVP serves on loopback by default. Binding `0.0.0.0` or `::` is **refused**
unless `WEBUI_ALLOW_PUBLIC_BIND=1`. Non-loopback hosts log a warning unless
`WEBUI_ALLOW_REMOTE_BIND=1`. `GET /health` exposes `deployment` metadata.
## Tests
```bash
pytest tests/test_webui_skeleton.py tests/test_webui_project_registry.py tests/test_webui_prompt_library.py tests/test_webui_queue_dashboard.py tests/test_webui_gated_actions.py -q
pytest tests/test_webui_skeleton.py tests/test_webui_project_registry.py tests/test_webui_prompt_library.py tests/test_webui_queue_dashboard.py tests/test_webui_audit.py tests/test_webui_worktree_hygiene.py -q
pytest tests/test_webui_skeleton.py tests/test_webui_project_registry.py tests/test_webui_prompt_library.py tests/test_webui_queue_dashboard.py tests/test_webui_lease_visibility.py tests/test_webui_runtime_health.py -q
pytest tests/test_webui_skeleton.py tests/test_webui_project_registry.py tests/test_webui_prompt_library.py tests/test_webui_queue_dashboard.py tests/test_webui_deployment_boundary.py -q
## Tests (#436)
Run the full hermetic web UI suite (all `test_webui_*.py` modules):
```bash
./scripts/test-webui
```
CI / Jenkins multibranch can call the path-filtered gate (runs only when the
diff touches `webui/`, `tests/test_webui_*`, or web UI docs/scripts):
```bash
./scripts/ci-webui-check
WEBUI_CI_FORCE=1 ./scripts/ci-webui-check # always run
```
`scripts/test-webui` sets `WEBUI_TEST_OFFLINE=1` by default. In that mode the
queue, lease, and runtime routes use empty offline snapshots instead of Gitea
credentials, so CI can run without MCP daemon credential access. Set
`WEBUI_TEST_OFFLINE=0` only when deliberately validating live fetch behavior.
Or invoke unittest directly:
```bash
python3 -m unittest discover -s tests -p 'test_webui_*.py' -q
```
## Lease visibility (#433) ## Lease visibility (#433)
`/leases` surfaces read-only lease and collision state: local issue lock file, `/leases` surfaces read-only lease and collision state: local issue lock file,
+13
View File
@@ -11,6 +11,7 @@ import inspect
import re import re
from typing import Any, Callable from typing import Any, Callable
import branch_cleanup_guard
import issue_acceptance_gate import issue_acceptance_gate
import issue_lock_provenance import issue_lock_provenance
import reviewer_handoff_consistency import reviewer_handoff_consistency
@@ -1294,6 +1295,17 @@ def _rule_shared_author_reviewer_same_run(report_text: str) -> list[dict[str, st
) )
def _rule_shared_raw_branch_delete_bypass(report_text: str) -> list[dict[str, str]]:
result = branch_cleanup_guard.assess_raw_branch_delete_report(report_text)
if not result["block"]:
return []
return _findings_from_reasons(
"shared.raw_branch_delete_bypass",
result["reasons"],
field="Cleanup mutations",
severity="block",
safe_next_action=result["safe_next_action"],
)
def _rule_reviewer_workflow_load_boundary(report_text: str) -> list[dict[str, str]]: def _rule_reviewer_workflow_load_boundary(report_text: str) -> list[dict[str, str]]:
"""#403: require structured workflow-load helper result, not file-view narrative.""" """#403: require structured workflow-load helper result, not file-view narrative."""
if not report_text.strip(): if not report_text.strip():
@@ -1448,6 +1460,7 @@ _SHARED_ISSUE_LOCK_RULES = (
_rule_shared_issue_lock_external_state, _rule_shared_issue_lock_external_state,
_rule_shared_manual_lock_pr_override, _rule_shared_manual_lock_pr_override,
_rule_shared_author_reviewer_same_run, _rule_shared_author_reviewer_same_run,
_rule_shared_raw_branch_delete_bypass,
_rule_shared_canonical_state_update, _rule_shared_canonical_state_update,
) )
+236 -4
View File
@@ -833,6 +833,7 @@ import pr_work_lease # noqa: E402
import workflow_skill # noqa: E402 import workflow_skill # noqa: E402
import conflict_fix_classification # noqa: E402 import conflict_fix_classification # noqa: E402
import native_mcp_preference # noqa: E402 import native_mcp_preference # noqa: E402
import branch_cleanup_guard # noqa: E402
import thread_state_ledger_validator # noqa: E402 import thread_state_ledger_validator # noqa: E402
import master_parity_gate # noqa: E402 import master_parity_gate # noqa: E402
@@ -4853,6 +4854,149 @@ def gitea_delete_branch(
return {"success": True, "message": f"Remote branch '{branch}' deleted."} return {"success": True, "message": f"Remote branch '{branch}' deleted."}
@mcp.tool()
def gitea_cleanup_merged_pr_branch(
pr_number: int,
confirmation: str,
branch: str | None = None,
remote: str = "dadeschools",
host: str | None = None,
org: str | None = None,
repo: str | None = None,
worktree_path: str | None = None,
) -> dict:
"""Delete a merged PR source branch through the guarded MCP path (#514)."""
gate_reasons = _profile_operation_gate("gitea.branch.delete")
if gate_reasons:
return {
"success": False,
"performed": False,
"required_permission": "gitea.branch.delete",
"reasons": gate_reasons,
"permission_report": _permission_block_report("gitea.branch.delete"),
}
profile = get_profile()
active_role = _role_kind(
profile.get("allowed_operations", []),
profile.get("forbidden_operations", []),
)
if active_role == "reviewer":
return {
"success": False,
"performed": False,
"required_permission": "gitea.branch.delete",
"reasons": [
"reviewer profile is not authorized for merged branch cleanup "
"(fail closed)"
],
"permission_report": _permission_block_report("gitea.branch.delete"),
}
if worktree_path is None or "/branches/" not in os.path.realpath(worktree_path):
return {
"success": False,
"performed": False,
"required_permission": "gitea.branch.delete",
"reasons": [
"merged branch cleanup requires an explicit branches/ worktree "
"path; root checkout branch ref mutation is blocked (fail closed)"
],
}
verify_preflight_purity(
remote,
worktree_path=worktree_path,
task="cleanup_merged_pr_branch",
)
h, o, r = _resolve(remote, host, org, repo)
auth = _auth(h)
base = repo_api_url(h, o, r)
pr = api_request("GET", f"{base}/pulls/{pr_number}", auth)
pr_head = pr.get("head") or {}
head_branch = branch or pr_head.get("ref") or ""
head_sha = pr_head.get("sha")
target_branch = (pr.get("base") or {}).get("ref") or "master"
if branch and branch != pr_head.get("ref"):
return {
"success": False,
"performed": False,
"pr_number": pr_number,
"branch": branch,
"reasons": [
f"requested branch '{branch}' does not match PR head "
f"'{pr_head.get('ref')}'"
],
}
open_prs = api_get_all(f"{base}/pulls?state=open", auth)
open_heads = {
str((open_pr.get("head") or {}).get("ref"))
for open_pr in open_prs
if (open_pr.get("head") or {}).get("ref")
}
remote_exists = _remote_branch_exists(h, o, r, auth, head_branch)
target_ref = (
f"{remote}/{target_branch}"
if remote in REMOTES
else f"origin/{target_branch}"
)
head_on_target = merged_cleanup_reconcile.is_head_ancestor_of_ref(
PROJECT_ROOT,
head_sha,
target_ref,
)
assessment = branch_cleanup_guard.assess_merged_pr_branch_cleanup(
pr_number=pr_number,
head_branch=head_branch,
merged=bool(pr.get("merged") or pr.get("merged_at")),
remote_branch_exists=remote_exists,
open_pr_heads=open_heads,
head_on_target=head_on_target,
delete_capability_allowed=True,
confirmation=confirmation,
)
if not assessment["safe_to_delete"]:
return {
"success": False,
"performed": False,
"pr_number": pr_number,
"branch": head_branch,
"assessment": assessment,
"reasons": assessment["block_reasons"],
}
import urllib.parse
encoded_branch = urllib.parse.quote(head_branch, safe="")
url = f"{base}/branches/{encoded_branch}"
with _audited(
"cleanup_merged_pr_branch",
host=h,
remote=remote,
org=o,
repo=r,
pr_number=pr_number,
target_branch=head_branch,
request_metadata={
"branch": head_branch,
"required_permission": "gitea.branch.delete",
"cleanup_path": "gitea_cleanup_merged_pr_branch",
},
):
api_request("DELETE", url, auth)
return {
"success": True,
"performed": True,
"pr_number": pr_number,
"branch": head_branch,
"message": f"Merged PR #{pr_number} source branch '{head_branch}' deleted.",
"assessment": assessment,
}
def _remote_branch_exists(h: str, o: str, r: str, auth: str, branch: str) -> bool: def _remote_branch_exists(h: str, o: str, r: str, auth: str, branch: str) -> bool:
import urllib.parse import urllib.parse
@@ -6010,7 +6154,9 @@ def _reviewer_pr_lease_gate(
"""Return block reasons when the session lacks an owned PR reviewer lease.""" """Return block reasons when the session lacks an owned PR reviewer lease."""
session = reviewer_pr_lease.get_session_lease() session = reviewer_pr_lease.get_session_lease()
session_id = (session or {}).get("session_id") session_id = (session or {}).get("session_id")
identity = _authenticated_username(remote) or "" # Resolve host first: _authenticated_username expects a host, not remote name.
h, _, _ = _resolve(remote, host, org, repo)
identity = _authenticated_username(h) or ""
try: try:
comments = _fetch_pr_comments( comments = _fetch_pr_comments(
pr_number, remote=remote, host=host, org=org, repo=repo) pr_number, remote=remote, host=host, org=org, repo=repo)
@@ -6064,7 +6210,8 @@ def gitea_acquire_reviewer_pr_lease(
h, o, r = _resolve(remote, host, org, repo) h, o, r = _resolve(remote, host, org, repo)
auth = _auth(h) auth = _auth(h)
profile = get_profile() profile = get_profile()
identity = _authenticated_username(remote) or profile.get("username") or "" # Host (not remote name) — remote aliases like "prgs" do not resolve identity.
identity = _authenticated_username(h) or profile.get("username") or ""
sid = (session_id or "").strip() or reviewer_pr_lease.new_session_id() sid = (session_id or "").strip() or reviewer_pr_lease.new_session_id()
repo_label = f"{o}/{r}" repo_label = f"{o}/{r}"
@@ -6207,7 +6354,8 @@ def gitea_adopt_merger_pr_lease(
auth = _auth(h) auth = _auth(h)
profile = get_profile() profile = get_profile()
profile_name = profile.get("profile_name") or "unknown" profile_name = profile.get("profile_name") or "unknown"
identity = _authenticated_username(remote) or profile.get("username") or "" # Host (not remote name) — remote aliases like "prgs" do not resolve identity.
identity = _authenticated_username(h) or profile.get("username") or ""
sid = (session_id or "").strip() or reviewer_pr_lease.new_session_id() sid = (session_id or "").strip() or reviewer_pr_lease.new_session_id()
repo_label = f"{o}/{r}" repo_label = f"{o}/{r}"
@@ -6595,7 +6743,9 @@ def gitea_release_reviewer_pr_lease(
"reasons": ["no active reviewer lease found on PR"], "reasons": ["no active reviewer lease found on PR"],
} }
identity = _authenticated_username(remote) or "" # Host (not remote name). Passing "prgs"/"dadeschools" yields empty identity
# and incorrectly blocks same-identity release across sessions.
identity = _authenticated_username(h) or ""
session = reviewer_pr_lease.get_session_lease() or {} session = reviewer_pr_lease.get_session_lease() or {}
session_id = session.get("session_id") session_id = session.get("session_id")
@@ -7912,6 +8062,44 @@ def gitea_get_shell_health() -> dict:
return native_mcp_preference.shell_health_status() return native_mcp_preference.shell_health_status()
@mcp.tool()
def gitea_diagnose_terminal(
cwd: str | None = None,
command: list[str] | None = None,
) -> dict:
"""Read-only: Run active diagnostics on terminal launcher spawn ability (#556).
Args:
cwd: Active worktree or current directory to test (defaults to GITEA_ACTIVE_WORKTREE).
command: Test command to attempt (defaults to ['git', '--version'] or ['echo', 'ok']).
"""
resolved_cwd = cwd or os.environ.get("GITEA_ACTIVE_WORKTREE")
if resolved_cwd:
resolved_cwd = os.path.realpath(resolved_cwd)
probe_cmd = command or native_mcp_preference.default_terminal_probe_command()
diag = native_mcp_preference.diagnose_terminal_failure(resolved_cwd, probe_cmd)
probe_res = native_mcp_preference.probe_terminal_spawn(resolved_cwd, probe_cmd)
return {
"healthy": probe_res["healthy"],
"error_type": (
None
if probe_res["healthy"]
else probe_res["error_type"] or diag["error_type"]
),
"error_msg": (
""
if probe_res["healthy"]
else probe_res["error_msg"] or diag["error_msg"]
),
"cwd": resolved_cwd,
"command": probe_cmd,
"diagnostics": diag,
}
@mcp.tool() @mcp.tool()
def gitea_validate_review_final_report( def gitea_validate_review_final_report(
report_text: str, report_text: str,
@@ -9449,6 +9637,50 @@ def gitea_resolve_task_capability(
"exact_safe_next_action": next_safe_action, "exact_safe_next_action": next_safe_action,
} }
# ── Terminal Launcher Preflight Check (#556) ──
if task in native_mcp_preference.GITEA_MUTATION_TASKS:
in_test = _preflight_in_test_mode()
if not in_test or os.environ.get("GITEA_FORCE_TERMINAL_PROBE") == "1":
resolved_cwd = os.environ.get("GITEA_ACTIVE_WORKTREE") or PROJECT_ROOT
probe_res = native_mcp_preference.probe_terminal_spawn(resolved_cwd)
if not probe_res["healthy"]:
# Only block if the failure is due to a missing CWD, missing shell, or the circuit breaker is tripped.
# Other failures (like missing executable in the MCP process's limited PATH) should not block the agent's shell capability.
is_real_blocker = (
probe_res.get("error_type") in ("missing cwd", "missing shell")
or native_mcp_preference.shell_health_status().get("hard_stopped")
)
if is_real_blocker:
profile = get_profile()
h = host or (REMOTES.get(remote, {}).get("host") if remote in REMOTES else None)
username = _authenticated_username(h) if h else None
next_safe_action = (
"BLOCKED + DIAGNOSE: terminal-launcher-failure "
f"({probe_res['error_type']}): {probe_res['error_msg']}. "
"Do not attempt git/pytest finalization or unsafe fallbacks. "
"Run gitea_diagnose_terminal, emit blocked-diagnose-report, and stop."
)
return {
"requested_task": task,
"required_operation_permission": required_permission,
"required_role_kind": required_role,
"active_profile": profile["profile_name"],
"active_identity": username,
"active_profile_allowed_operations": profile.get("allowed_operations") or [],
"allowed_in_current_session": False,
"stop_required": True,
"infra_stop": True,
"blocked": True,
"blocked_reason": "BLOCKED + DIAGNOSE",
"terminal_launcher_unhealthy": True,
"terminal_launcher_diagnostics": probe_res,
"task_role_guidance": [next_safe_action],
"matching_configured_profile": [],
"runtime_switching_supported": gitea_config.is_runtime_switching_enabled(),
"different_mcp_namespace_required": False,
"exact_safe_next_action": next_safe_action,
}
record_preflight_check("capability", required_role, resolved_task=task) record_preflight_check("capability", required_role, resolved_task=task)
# Try automatic dispatch switching # Try automatic dispatch switching
+160
View File
@@ -8,6 +8,9 @@ available unless explicit recovery-mode proof is supplied.
from __future__ import annotations from __future__ import annotations
import re import re
import os
import shutil
import subprocess
from typing import Any from typing import Any
from reviewer_fallback import LOCAL_GITEA_SCRIPT_NAMES from reviewer_fallback import LOCAL_GITEA_SCRIPT_NAMES
@@ -367,3 +370,160 @@ def assess_gitea_operation_path(
) )
), ),
} }
def default_terminal_probe_command() -> list[str]:
return ["git", "--version"] if shutil.which("git") else ["echo", "ok"]
def _resolve_executable(cwd: str | None, executable: str | None) -> str | None:
if not executable:
return None
if os.path.isabs(executable):
if os.path.exists(executable) and os.access(executable, os.X_OK):
return executable
return None
if "/" in executable or "\\" in executable:
target_cwd = cwd or os.getcwd()
full_path = os.path.abspath(os.path.join(target_cwd, executable))
if os.path.exists(full_path) and os.access(full_path, os.X_OK):
return full_path
return None
return shutil.which(executable)
def diagnose_terminal_failure(cwd: str | None, command: list[str]) -> dict[str, Any]:
"""Inspect and categorize command spawn failures (#556)."""
diagnostics = {
"cwd": cwd,
"command": command,
"cwd_exists": True,
"cwd_is_dir": True,
"shell_exists": True,
"executable_exists": True,
"resolved_executable": None,
"error_type": "session launcher failure",
"error_msg": (
"Subprocess spawn failed despite valid CWD and executable. This may be due "
"to sandboxing, permission restrictions, or system resource limits."
),
}
if cwd:
if not os.path.exists(cwd):
diagnostics["cwd_exists"] = False
diagnostics["error_type"] = "missing cwd"
diagnostics["error_msg"] = f"Current working directory does not exist: {cwd}"
return diagnostics
if not os.path.isdir(cwd):
diagnostics["cwd_is_dir"] = False
diagnostics["error_type"] = "cwd is not a directory"
diagnostics["error_msg"] = f"Current working directory is not a directory: {cwd}"
return diagnostics
exe = command[0] if command else None
if exe:
resolved_exe = _resolve_executable(cwd, exe)
diagnostics["resolved_executable"] = resolved_exe
if not resolved_exe:
diagnostics["executable_exists"] = False
if "venv" in exe or "pytest" in exe:
diagnostics["error_type"] = "missing runtime wrapper"
diagnostics["error_msg"] = (
"Virtual environment runtime wrapper/executable not found or not "
f"executable: {exe}"
)
else:
diagnostics["error_type"] = "missing executable"
diagnostics["error_msg"] = f"Executable not found in PATH or CWD: {exe}"
return diagnostics
# Check common shell availability
has_any_shell = False
for shell_path in ("/bin/sh", "/bin/zsh", "/bin/bash"):
if os.path.exists(shell_path) and os.access(shell_path, os.X_OK):
has_any_shell = True
break
if not has_any_shell:
diagnostics["shell_exists"] = False
diagnostics["error_type"] = "missing shell"
diagnostics["error_msg"] = (
"No standard system shell (/bin/sh, /bin/zsh, /bin/bash) is "
"available or executable."
)
return diagnostics
return diagnostics
def probe_terminal_spawn(
cwd: str | None = None,
command: list[str] | None = None,
) -> dict[str, Any]:
"""Proactively probe command spawning to detect launcher health (#556)."""
probe_cmd = command or default_terminal_probe_command()
diag = diagnose_terminal_failure(cwd, probe_cmd)
if diag["error_type"] != "session launcher failure":
return {
"healthy": False,
"error_type": diag["error_type"],
"error_msg": diag["error_msg"],
"cwd": cwd,
"command": probe_cmd,
"diagnostics": diag,
}
try:
proc = subprocess.run(
probe_cmd,
capture_output=True,
text=True,
cwd=cwd,
timeout=5,
)
if proc.returncode == 0:
return {
"healthy": True,
"error_type": None,
"error_msg": "",
"cwd": cwd,
"command": probe_cmd,
"diagnostics": diag,
}
# Non-zero status still proves the launcher spawned the process.
return {
"healthy": True,
"error_type": None,
"error_msg": "",
"cwd": cwd,
"command": probe_cmd,
"exit_code": proc.returncode,
"diagnostics": diag,
}
except FileNotFoundError:
return {
"healthy": False,
"error_type": diag["error_type"],
"error_msg": diag["error_msg"],
"cwd": cwd,
"command": probe_cmd,
"diagnostics": diag,
}
except subprocess.TimeoutExpired as exc:
return {
"healthy": False,
"error_type": "probe timeout",
"error_msg": f"Subprocess probe timed out after {exc.timeout} seconds.",
"cwd": cwd,
"command": probe_cmd,
"diagnostics": diag,
}
except Exception as exc:
return {
"healthy": False,
"error_type": diag["error_type"],
"error_msg": f"Subprocess spawn failed with exception: {exc}",
"cwd": cwd,
"command": probe_cmd,
"diagnostics": diag,
}
+17 -7
View File
@@ -190,16 +190,26 @@ def find_active_reviewer_lease(
pr_number: int, pr_number: int,
now: datetime | None = None, now: datetime | None = None,
) -> dict[str, Any] | None: ) -> dict[str, Any] | None:
"""Newest non-terminal, unexpired lease for *pr_number*.""" """Return the newest unexpired non-terminal lease for *pr_number*.
Append-only lease markers form a ledger: the **newest** marker is
authoritative. A later terminal phase (``released`` / ``done`` /
``blocked``) ends the lease even when older ``claimed`` markers remain
on the thread (#577). Skipping only terminal markers and walking older
claims incorrectly re-arms a lease after a successful release.
"""
now = now or datetime.now(timezone.utc) now = now or datetime.now(timezone.utc)
for lease in reversed(_lease_entries(comments, pr_number=pr_number)): entries = list(reversed(_lease_entries(comments, pr_number=pr_number)))
phase = (lease.get("phase") or "").strip().lower() if not entries:
return None
newest = entries[0]
phase = (newest.get("phase") or "").strip().lower()
if phase in _TERMINAL_PHASES: if phase in _TERMINAL_PHASES:
continue return None
if _lease_expired(lease, now=now): if _lease_expired(newest, now=now):
continue return None
if phase in _ACTIVE_PHASES or phase: if phase in _ACTIVE_PHASES or phase:
lease = dict(lease) lease = dict(newest)
lease["freshness"] = classify_lease_freshness(lease, now=now) lease["freshness"] = classify_lease_freshness(lease, now=now)
return lease return lease
return None return None
+2
View File
@@ -80,6 +80,7 @@ AUTHOR_TASKS = frozenset({
}) })
RECONCILER_TASKS = frozenset({ RECONCILER_TASKS = frozenset({
"cleanup_merged_pr_branch",
"reconcile_already_landed_pr", "reconcile_already_landed_pr",
"reconcile_already_landed", "reconcile_already_landed",
"reconcile-landed-pr", "reconcile-landed-pr",
@@ -109,6 +110,7 @@ TASK_REQUIRED_ROLE = {
"reconcile_already_landed_pr": "reconciler", "reconcile_already_landed_pr": "reconciler",
"reconcile_already_landed": "reconciler", "reconcile_already_landed": "reconciler",
"reconcile-landed-pr": "reconciler", "reconcile-landed-pr": "reconciler",
"cleanup_merged_pr_branch": "reconciler",
# #309: reconciler tasks close already-landed PRs/issues only. # #309: reconciler tasks close already-landed PRs/issues only.
"reconcile_close_landed_pr": "reconciler", "reconcile_close_landed_pr": "reconciler",
"reconcile_close_landed_issue": "reconciler", "reconcile_close_landed_issue": "reconciler",
+53
View File
@@ -0,0 +1,53 @@
#!/usr/bin/env bash
# Path-filtered CI gate for the internal web UI (#436).
# Runs the hermetic web UI unittest suite when a change touches UI surfaces.
set -euo pipefail
script_dir="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)"
repo_root="$(cd "$script_dir/.." && pwd)"
cd "$repo_root"
if [[ "${WEBUI_CI_FORCE:-}" == "1" ]]; then
exec "$script_dir/test-webui"
fi
base_ref="${WEBUI_CI_BASE_REF:-}"
if [[ -z "$base_ref" ]]; then
if git rev-parse --verify prgs/master >/dev/null 2>&1; then
base_ref="$(git merge-base HEAD prgs/master 2>/dev/null || true)"
fi
if [[ -z "$base_ref" ]]; then
base_ref="${GITHUB_BASE_REF:-${CHANGE_TARGET:-master}}"
if git rev-parse --verify "origin/$base_ref" >/dev/null 2>&1; then
base_ref="$(git merge-base HEAD "origin/$base_ref")"
elif git rev-parse --verify "$base_ref" >/dev/null 2>&1; then
base_ref="$(git merge-base HEAD "$base_ref")"
else
base_ref="HEAD~1"
fi
fi
fi
changed="$(git diff --name-only "$base_ref" HEAD 2>/dev/null || true)"
if [[ -z "$changed" ]]; then
echo "ci-webui-check: no changed files vs $base_ref; skipping web UI suite"
exit 0
fi
python_bin="${WEBUI_TEST_PYTHON:-python3}"
if [[ -x "$repo_root/venv/bin/python" ]]; then
python_bin="$repo_root/venv/bin/python"
fi
if printf '%s\n' "$changed" | "$python_bin" -c "
import sys
from webui.ci_paths import should_run_webui_ci
paths = [line.strip() for line in sys.stdin if line.strip()]
raise SystemExit(0 if should_run_webui_ci(paths) else 1)
"; then
echo "ci-webui-check: web UI surface changed; running suite"
exec "$script_dir/test-webui"
fi
echo "ci-webui-check: no web UI paths in diff; skipping suite"
exit 0
+14
View File
@@ -0,0 +1,14 @@
#!/usr/bin/env bash
set -euo pipefail
script_dir="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)"
repo_root="$(cd "$script_dir/.." && pwd)"
cd "$repo_root"
python_bin="${WEBUI_TEST_PYTHON:-python3}"
if [[ -x "$repo_root/venv/bin/python" ]]; then
python_bin="$repo_root/venv/bin/python"
fi
pattern="${WEBUI_TEST_PATTERN:-test_webui_*.py}"
export WEBUI_TEST_OFFLINE="${WEBUI_TEST_OFFLINE:-1}"
exec "$python_bin" -m unittest discover -s tests -p "$pattern" "$@"
@@ -936,6 +936,14 @@ Confirm:
Clean only the session-owned `branches/` review worktree if the project workflow explicitly allows cleanup. Clean only the session-owned `branches/` review worktree if the project workflow explicitly allows cleanup.
Do not delete source branches from reviewer mode with raw git commands. Commands
such as `git branch -d <branch>` and `git push <remote> --delete <branch>` are
not proof of authorized cleanup and bypass MCP role/capability gates. Merged PR
source branch cleanup must use an explicit MCP cleanup tool such as
`gitea_cleanup_merged_pr_branch` or another approved cleanup helper with exact
`gitea.branch.delete` authority, merged-PR proof, no open PR using the branch,
target-branch ancestry proof, a `branches/` worktree path, and cleanup mutations
reported separately from review mutations.
Review, baseline, and merge-simulation worktrees created during this run are Review, baseline, and merge-simulation worktrees created during this run are
transient and are removed automatically at successful completion once they are transient and are removed automatically at successful completion once they are
clean, carry no open PR, and hold no active lease (#401). Use clean, carry no open PR, and hold no active lease (#401). Use
+4
View File
@@ -100,6 +100,10 @@ TASK_CAPABILITY_MAP: dict[str, dict[str, str]] = {
"permission": "gitea.branch.delete", "permission": "gitea.branch.delete",
"role": "author", "role": "author",
}, },
"cleanup_merged_pr_branch": {
"permission": "gitea.branch.delete",
"role": "reconciler",
},
"commit_files": { "commit_files": {
"permission": "gitea.repo.commit", "permission": "gitea.repo.commit",
"role": "author", "role": "author",
+187
View File
@@ -0,0 +1,187 @@
import sys
import unittest
from unittest.mock import patch
sys.path.insert(0, str(__import__("pathlib").Path(__file__).resolve().parent.parent))
import branch_cleanup_guard as guard # noqa: E402
import mcp_server # noqa: E402
import task_capability_map # noqa: E402
from final_report_validator import assess_final_report_validator # noqa: E402
from mcp_server import gitea_cleanup_merged_pr_branch # noqa: E402
FAKE_AUTH = "token fake"
class TestRawBranchDeleteGuard(unittest.TestCase):
def test_detects_local_and_remote_raw_git_delete_commands(self):
text = """
Cleanup mutations:
- git branch -d feat/issue-485-lease-comments-non-list-guard
- git push prgs --delete feat/issue-485-lease-comments-non-list-guard
"""
commands = guard.raw_branch_delete_commands(text)
self.assertEqual(len(commands), 2)
self.assertIn("git branch -d", commands[0])
self.assertIn("git push prgs --delete", commands[1])
def test_final_report_blocks_raw_delete_as_cleanup_proof(self):
report = """
## Controller Handoff
- Task: review_pr
- Cleanup mutations: git push prgs --delete feat/branch
"""
result = assess_final_report_validator(report, "review_pr")
self.assertTrue(result["blocked"])
self.assertTrue(
any("shared.raw_branch_delete_bypass" in r for r in result["reasons"])
)
def test_cleanup_task_requires_branch_delete_capability(self):
self.assertEqual(
task_capability_map.required_permission("cleanup_merged_pr_branch"),
"gitea.branch.delete",
)
self.assertEqual(
task_capability_map.required_role("cleanup_merged_pr_branch"),
"reconciler",
)
class TestMergedPrBranchCleanupTool(unittest.TestCase):
def setUp(self):
self._remotes = patch.dict(
mcp_server.REMOTES,
{
"prgs": {
"host": "gitea.example.com",
"org": "Example-Org",
"repo": "Example-Repo",
}
},
)
self._remotes.start()
patch("gitea_audit.audit_enabled", return_value=False).start()
self.mock_api = patch("mcp_server.api_request").start()
self.mock_all = patch("mcp_server.api_get_all", return_value=[]).start()
patch("mcp_server.get_auth_header", return_value=FAKE_AUTH).start()
patch(
"mcp_server.merged_cleanup_reconcile.is_head_ancestor_of_ref",
return_value=True,
).start()
def tearDown(self):
patch.stopall()
def test_reviewer_without_delete_authority_fails_closed(self):
patch(
"mcp_server.get_profile",
return_value={
"profile_name": "reviewer",
"allowed_operations": ["gitea.read", "gitea.pr.review"],
"forbidden_operations": ["gitea.branch.delete"],
},
).start()
res = gitea_cleanup_merged_pr_branch(
pr_number=487,
confirmation="CLEANUP MERGED PR 487 BRANCH feat/branch",
branch="feat/branch",
remote="prgs",
worktree_path="/tmp/repo/branches/cleanup",
)
self.assertFalse(res["performed"])
self.assertEqual(res["required_permission"], "gitea.branch.delete")
self.mock_api.assert_not_called()
def test_root_checkout_cleanup_fails_closed(self):
patch(
"mcp_server.get_profile",
return_value={
"profile_name": "branch-cleanup",
"allowed_operations": ["gitea.read", "gitea.branch.delete"],
"forbidden_operations": [],
},
).start()
res = gitea_cleanup_merged_pr_branch(
pr_number=487,
confirmation="CLEANUP MERGED PR 487 BRANCH feat/branch",
branch="feat/branch",
remote="prgs",
worktree_path="/tmp/repo/Gitea-Tools",
)
self.assertFalse(res["performed"])
self.assertIn("root checkout", " ".join(res["reasons"]))
self.mock_api.assert_not_called()
def test_authorized_cleanup_deletes_through_api_path(self):
branch = "feat/issue-485-lease-comments-non-list-guard"
patch(
"mcp_server.get_profile",
return_value={
"profile_name": "branch-cleanup",
"allowed_operations": ["gitea.read", "gitea.branch.delete"],
"forbidden_operations": [],
},
).start()
self.mock_api.side_effect = [
{
"number": 487,
"merged": True,
"merged_at": "2026-07-08T01:00:00Z",
"head": {"ref": branch, "sha": "a" * 40},
"base": {"ref": "master"},
},
{},
{},
]
res = gitea_cleanup_merged_pr_branch(
pr_number=487,
confirmation=f"CLEANUP MERGED PR 487 BRANCH {branch}",
branch=branch,
remote="prgs",
worktree_path="/tmp/repo/branches/cleanup",
)
self.assertTrue(res["performed"])
delete_calls = [
call for call in self.mock_api.call_args_list if call.args[0] == "DELETE"
]
self.assertEqual(len(delete_calls), 1)
self.assertIn("branches/feat%2Fissue-485", delete_calls[0].args[1])
def test_confirmation_required_before_delete(self):
branch = "feat/issue-485-lease-comments-non-list-guard"
patch(
"mcp_server.get_profile",
return_value={
"profile_name": "branch-cleanup",
"allowed_operations": ["gitea.read", "gitea.branch.delete"],
"forbidden_operations": [],
},
).start()
self.mock_api.side_effect = [
{
"number": 487,
"merged": True,
"merged_at": "2026-07-08T01:00:00Z",
"head": {"ref": branch, "sha": "a" * 40},
"base": {"ref": "master"},
},
{},
]
res = gitea_cleanup_merged_pr_branch(
pr_number=487,
confirmation="wrong",
branch=branch,
remote="prgs",
worktree_path="/tmp/repo/branches/cleanup",
)
self.assertFalse(res["performed"])
self.assertIn("confirmation", " ".join(res["reasons"]))
delete_calls = [
call for call in self.mock_api.call_args_list if call.args[0] == "DELETE"
]
self.assertFalse(delete_calls)
if __name__ == "__main__":
unittest.main()
+233
View File
@@ -4288,3 +4288,236 @@ class TestIssue546Deadlock(unittest.TestCase):
self.assertIsNone(reviewer_pr_lease.get_session_lease()) self.assertIsNone(reviewer_pr_lease.get_session_lease())
# verify comment phase is released # verify comment phase is released
self.assertIn("phase: released", posted_comments[0]["body"]) self.assertIn("phase: released", posted_comments[0]["body"])
def test_reviewer_pr_lease_gate_resolves_identity_with_host(self):
"""Lease mutation gate must resolve identity using host, not remote alias."""
import mcp_server
resolved_hosts = []
def tracking_username(host):
resolved_hosts.append(host)
return None if host in {"prgs", "dadeschools"} else "reviewer-bot"
with _install_owned_reviewer_lease(
550,
session_id=_DEFAULT_LEASE_SESSION,
head_sha="abc123",
), patch(
"mcp_server._resolve",
return_value=("gitea.prgs.cc", "Scaled-Tech-Consulting", "Gitea-Tools"),
), patch(
"mcp_server._authenticated_username",
side_effect=tracking_username,
):
reasons = mcp_server._reviewer_pr_lease_gate(
pr_number=550,
remote="prgs",
host=None,
org=None,
repo=None,
mutation="approve",
live_head_sha="abc123",
pinned_head_sha="abc123",
)
self.assertEqual(reasons, [])
self.assertIn("gitea.prgs.cc", resolved_hosts)
self.assertNotIn("prgs", resolved_hosts)
def test_acquire_reviewer_pr_lease_resolves_identity_with_host(self):
"""Acquire path must not pass a remote alias into identity lookup."""
import mcp_server
resolved_hosts = []
posted = []
def tracking_username(host):
resolved_hosts.append(host)
return None if host in {"prgs", "dadeschools"} else "reviewer-bot"
def mock_api(method, url, auth, data=None):
if "/pulls/" in url:
return {
"user": {"login": "author-user"},
"state": "open",
"head": {"sha": "abc123"},
"mergeable": True,
}
if "/comments" in url:
if method == "POST":
posted.append(data["body"])
return {"id": 1003}
return []
return {}
with patch("mcp_server.api_request", side_effect=mock_api), patch(
"mcp_server._authenticated_username",
side_effect=tracking_username,
), patch(
"mcp_server._resolve",
return_value=("gitea.prgs.cc", "Scaled-Tech-Consulting", "Gitea-Tools"),
):
res = mcp_server.gitea_acquire_reviewer_pr_lease(
pr_number=550,
worktree="/workspace",
candidate_head="abc123",
remote="prgs",
)
self.assertTrue(res.get("success"), res)
self.assertTrue(res.get("acquired"), res)
self.assertEqual(res.get("comment_id"), 1003)
self.assertTrue(posted)
self.assertIn("gitea.prgs.cc", resolved_hosts)
self.assertNotIn("prgs", resolved_hosts)
def test_adopt_merger_pr_lease_resolves_identity_with_host(self):
"""Adopt path must not pass a remote alias into identity lookup."""
import mcp_server
import reviewer_pr_lease
reviewer_pr_lease.clear_session_lease()
head = "a" * 40
resolved_hosts = []
posted = []
reviewer_comment = _reviewer_lease_comment(
550,
session_id="reviewer-session",
head_sha=head,
reviewer="reviewer-bot",
)
def tracking_username(host):
resolved_hosts.append(host)
return None if host in {"prgs", "dadeschools"} else "merger-bot"
def mock_api(method, url, auth, data=None):
if "/pulls/" in url and "/comments" not in url:
return {
"number": 550,
"user": {"login": "author-user"},
"state": "open",
"head": {"sha": head},
"mergeable": True,
"merged": False,
}
if "/comments" in url:
if method == "POST":
posted.append(data["body"])
return {"id": 1004}
return [reviewer_comment]
return {}
merger_profile = {
"profile_name": "prgs-merger",
"allowed_operations": ["gitea.read", "gitea.pr.comment", "gitea.pr.merge"],
"forbidden_operations": [],
}
with patch("mcp_server.get_profile", return_value=merger_profile), patch(
"mcp_server.api_request",
side_effect=mock_api,
), patch(
"mcp_server._authenticated_username",
side_effect=tracking_username,
), patch(
"mcp_server._resolve",
return_value=("gitea.prgs.cc", "Scaled-Tech-Consulting", "Gitea-Tools"),
), patch(
"mcp_server.gitea_get_pr_review_feedback",
return_value={
"approval_at_current_head": True,
"latest_approved_head_sha": head,
},
):
res = mcp_server.gitea_adopt_merger_pr_lease(
pr_number=550,
worktree="/workspace",
expected_head_sha=head,
remote="prgs",
)
self.assertTrue(res.get("success"), res)
self.assertTrue(res.get("adopted"), res)
self.assertEqual(res.get("adoption_comment_id"), 1004)
self.assertTrue(posted)
self.assertIn("gitea.prgs.cc", resolved_hosts)
self.assertNotIn("prgs", resolved_hosts)
def test_release_reviewer_pr_lease_same_identity_cross_session(self):
"""Same reviewer identity may release a lease claimed by another session.
Regression: release used to call ``_authenticated_username(remote)``
with the remote alias (e.g. ``prgs``) instead of the resolved host,
so identity resolved empty and same-identity release fail-closed even
when the authenticated user owned the lease.
"""
import mcp_server
import reviewer_pr_lease
mcp_server.gitea_resolve_task_capability(task="review_pr", remote="prgs")
mcp_server.gitea_load_review_workflow()
reviewer_pr_lease.clear_session_lease()
foreign_session = "46820-dd78cdc4aacc"
comments = [
_reviewer_lease_comment(
457,
session_id=foreign_session,
head_sha="ef287eaf5841d9e542a4e888ce0ae7d679dbd685",
reviewer="sysadmin",
)
]
posted = []
resolved_hosts = []
def mock_api(method, url, auth, data=None):
if "/api/v1/user" in url:
return {"login": "sysadmin"}
if "/comments" in url:
if method == "POST":
new_c = {
"id": 8071,
"body": data["body"],
"user": {"login": "sysadmin"},
}
comments.append(new_c)
posted.append(new_c)
return {"id": 8071}
return comments
if "/pulls/" in url:
return {
"user": {"login": "jcwalker3"},
"state": "open",
"head": {"sha": "ef287eaf5841d9e542a4e888ce0ae7d679dbd685"},
"mergeable": False,
}
return {}
def tracking_username(host):
resolved_hosts.append(host)
# Empty identity when passed a remote alias (the pre-fix bug path).
if host in {"prgs", "dadeschools"}:
return None
return "sysadmin"
with patch("mcp_server.api_request", side_effect=mock_api), patch(
"mcp_server._authenticated_username", side_effect=tracking_username
), patch(
"mcp_server._resolve",
return_value=("gitea.prgs.cc", "Scaled-Tech-Consulting", "Gitea-Tools"),
), patch("mcp_server._auth", return_value={"Authorization": "token x"}), patch(
"mcp_server._verify_role_mutation_workspace", return_value=None
):
res = mcp_server.gitea_release_reviewer_pr_lease(
pr_number=457, remote="prgs"
)
self.assertTrue(res.get("success"), res)
self.assertTrue(res.get("released"), res)
self.assertEqual(res.get("comment_id"), 8071)
self.assertTrue(posted)
self.assertIn("phase: released", posted[0]["body"])
# Must resolve identity with host, never the remote alias alone.
self.assertIn("gitea.prgs.cc", resolved_hosts)
self.assertNotIn("prgs", resolved_hosts)
+85
View File
@@ -1,6 +1,7 @@
"""Tests for native MCP preference gate and shell circuit breaker (#270).""" """Tests for native MCP preference gate and shell circuit breaker (#270)."""
import sys import sys
import unittest import unittest
import shutil
from pathlib import Path from pathlib import Path
sys.path.insert(0, str(Path(__file__).resolve().parent.parent)) sys.path.insert(0, str(Path(__file__).resolve().parent.parent))
@@ -26,6 +27,90 @@ class TestShellHealthCircuitBreaker(unittest.TestCase):
self.assertEqual(status["consecutive_spawn_failures"], 0) self.assertEqual(status["consecutive_spawn_failures"], 0)
class TestTerminalDiagnostics(unittest.TestCase):
def test_diagnose_missing_cwd(self):
res = nmp.diagnose_terminal_failure("/nonexistent_directory_for_test", ["git", "status"])
self.assertFalse(res["cwd_exists"])
self.assertEqual(res["error_type"], "missing cwd")
self.assertIn("does not exist", res["error_msg"])
def test_diagnose_cwd_is_not_dir(self):
import tempfile
import os
with tempfile.NamedTemporaryFile() as tmp:
res = nmp.diagnose_terminal_failure(tmp.name, ["git", "status"])
self.assertFalse(res["cwd_is_dir"])
self.assertEqual(res["error_type"], "cwd is not a directory")
def test_diagnose_missing_executable(self):
res = nmp.diagnose_terminal_failure(None, ["nonexistent_exec_for_test"])
self.assertFalse(res["executable_exists"])
self.assertEqual(res["error_type"], "missing executable")
def test_diagnose_missing_runtime_wrapper(self):
res = nmp.diagnose_terminal_failure("/tmp", ["venv/bin/pytest"])
self.assertFalse(res["executable_exists"])
self.assertEqual(res["error_type"], "missing runtime wrapper")
def test_diagnose_relative_executable_from_cwd(self):
import os
import tempfile
with tempfile.TemporaryDirectory() as tmp:
script = Path(tmp) / "tool"
script.write_text("#!/bin/sh\nexit 0\n", encoding="utf-8")
os.chmod(script, 0o755)
res = nmp.diagnose_terminal_failure(tmp, ["./tool"])
self.assertTrue(res["executable_exists"])
self.assertEqual(res["resolved_executable"], str(script))
def test_probe_terminal_spawn_success(self):
res = nmp.probe_terminal_spawn()
self.assertTrue(res["healthy"])
self.assertIn("command", res)
self.assertIn("diagnostics", res)
def test_probe_terminal_spawn_missing_cwd(self):
res = nmp.probe_terminal_spawn("/nonexistent_directory_for_test")
self.assertFalse(res["healthy"])
self.assertEqual(res["error_type"], "missing cwd")
def test_probe_terminal_spawn_honors_custom_command(self):
res = nmp.probe_terminal_spawn(
command=[sys.executable, "-c", "raise SystemExit(3)"]
)
self.assertTrue(res["healthy"])
self.assertEqual(res["command"][0], sys.executable)
self.assertEqual(res["exit_code"], 3)
@unittest.skipUnless(shutil.which("git"), "git is required for git -C probe")
def test_probe_terminal_spawn_supports_explicit_git_c_worktree(self):
import subprocess
import tempfile
with tempfile.TemporaryDirectory() as tmp:
subprocess.run(
["git", "init", tmp],
check=True,
capture_output=True,
text=True,
)
res = nmp.probe_terminal_spawn(
cwd="/",
command=["git", "-C", tmp, "status", "--short"],
)
self.assertTrue(res["healthy"])
self.assertEqual(res["command"][1], "-C")
def test_probe_terminal_spawn_blocks_missing_custom_command(self):
res = nmp.probe_terminal_spawn(command=["nonexistent_exec_for_test"])
self.assertFalse(res["healthy"])
self.assertEqual(res["error_type"], "missing executable")
self.assertEqual(res["command"], ["nonexistent_exec_for_test"])
class TestNativeMcpPreferenceGate(unittest.TestCase): class TestNativeMcpPreferenceGate(unittest.TestCase):
def setUp(self): def setUp(self):
nmp.clear_shell_health_for_tests() nmp.clear_shell_health_for_tests()
+41
View File
@@ -96,6 +96,14 @@ class TestResolveTaskCapability(unittest.TestCase):
"GITEA_TOKEN_MERGER": "merger-pass", "GITEA_TOKEN_MERGER": "merger-pass",
} }
def test_diagnose_terminal_success_has_no_error(self):
res = mcp_server.gitea_diagnose_terminal(
command=[sys.executable, "-c", "raise SystemExit(0)"]
)
self.assertTrue(res["healthy"])
self.assertIsNone(res["error_type"])
self.assertEqual(res["error_msg"], "")
@patch("mcp_server.api_request", return_value={"login": "author-user"}) @patch("mcp_server.api_request", return_value={"login": "author-user"})
@patch("mcp_server.get_auth_header", return_value="token author-pass") @patch("mcp_server.get_auth_header", return_value="token author-pass")
def test_resolve_author_capable_task(self, _auth, _api): def test_resolve_author_capable_task(self, _auth, _api):
@@ -110,6 +118,39 @@ class TestResolveTaskCapability(unittest.TestCase):
self.assertFalse(res["different_mcp_namespace_required"]) self.assertFalse(res["different_mcp_namespace_required"])
self.assertIn("ready for operations", res["exact_safe_next_action"]) self.assertIn("ready for operations", res["exact_safe_next_action"])
@patch("mcp_server.native_mcp_preference.probe_terminal_spawn")
@patch("mcp_server.api_request", return_value={"login": "author-user"})
@patch("mcp_server.get_auth_header", return_value="token author-pass")
def test_resolve_mutation_task_blocks_when_terminal_launcher_unhealthy(
self,
_auth,
_api,
mock_probe,
):
mock_probe.return_value = {
"healthy": False,
"error_type": "missing cwd",
"error_msg": "Current working directory does not exist: /missing",
"cwd": "/missing",
"command": ["git", "--version"],
"diagnostics": {"cwd_exists": False},
}
env = self._env("author-profile")
# Probe is skipped under pytest unless forced (mirrors production path).
env["GITEA_FORCE_TERMINAL_PROBE"] = "1"
with patch.dict(os.environ, env):
res = mcp_server.gitea_resolve_task_capability(task="create_issue", remote="prgs")
self.assertFalse(res["allowed_in_current_session"])
self.assertTrue(res["stop_required"])
self.assertTrue(res["infra_stop"])
self.assertTrue(res["terminal_launcher_unhealthy"])
self.assertTrue(res["blocked"])
self.assertEqual(res["blocked_reason"], "BLOCKED + DIAGNOSE")
self.assertEqual(res["terminal_launcher_diagnostics"]["error_type"], "missing cwd")
self.assertIn("terminal-launcher-failure", res["exact_safe_next_action"])
self.assertIn("BLOCKED + DIAGNOSE", res["exact_safe_next_action"])
@patch("mcp_server.api_request", return_value={"login": "author-user"}) @patch("mcp_server.api_request", return_value={"login": "author-user"})
@patch("mcp_server.get_auth_header", return_value="token author-pass") @patch("mcp_server.get_auth_header", return_value="token author-pass")
def test_resolve_reviewer_capable_task_blocked(self, _auth, _api): def test_resolve_reviewer_capable_task_blocked(self, _auth, _api):
+39
View File
@@ -98,6 +98,45 @@ class TestReviewerLeaseFreshness(unittest.TestCase):
) )
class TestReviewerLeaseNewestWins(unittest.TestCase):
"""#577: terminal release must supersede older claimed markers."""
def test_released_after_claimed_has_no_active_lease(self):
claim = _lease_comment(516, "session-a", phase="claimed")
claim["id"] = 1
release = _lease_comment(516, "session-a", phase="released")
release["id"] = 2
active = leases.find_active_reviewer_lease(
[claim, release], pr_number=516
)
self.assertIsNone(active)
def test_new_claim_after_release_is_active(self):
claim = _lease_comment(516, "session-a", phase="claimed")
claim["id"] = 1
release = _lease_comment(516, "session-a", phase="released")
release["id"] = 2
reclaim = _lease_comment(516, "session-b", phase="claimed")
reclaim["id"] = 3
active = leases.find_active_reviewer_lease(
[claim, release, reclaim], pr_number=516
)
self.assertIsNotNone(active)
self.assertEqual(active.get("session_id"), "session-b")
self.assertEqual(active.get("phase"), "claimed")
self.assertEqual(active.get("comment_id"), 3)
def test_done_after_claimed_has_no_active_lease(self):
claim = _lease_comment(516, "session-a", phase="claimed")
claim["id"] = 10
done = _lease_comment(516, "session-a", phase="done")
done["id"] = 11
active = leases.find_active_reviewer_lease(
[claim, done], pr_number=516
)
self.assertIsNone(active)
class TestReviewerLeaseMutationGate(unittest.TestCase): class TestReviewerLeaseMutationGate(unittest.TestCase):
def setUp(self): def setUp(self):
leases.clear_session_lease() leases.clear_session_lease()
+90
View File
@@ -0,0 +1,90 @@
"""Tests for web UI CI path-filter gate (#436)."""
import os
import subprocess
import sys
import unittest
from pathlib import Path
from unittest import mock
sys.path.insert(0, str(Path(__file__).resolve().parent.parent))
from webui.ci_paths import should_run_webui_ci, touches_webui_surface
from webui.lease_loader import load_lease_snapshot
from webui.queue_loader import load_queue_snapshot
from webui.runtime_health import load_runtime_snapshot
_REPO_ROOT = Path(__file__).resolve().parent.parent
class TestCiPathMatching(unittest.TestCase):
def test_positive_paths(self):
for path in (
"webui/app.py",
"tests/test_webui_skeleton.py",
"docs/webui-local-dev.md",
"scripts/test-webui",
):
with self.subTest(path=path):
self.assertTrue(touches_webui_surface(path))
def test_negative_paths(self):
for path in ("mcp_server.py", "tests/test_mcp_server.py", "README.md"):
with self.subTest(path=path):
self.assertFalse(touches_webui_surface(path))
def test_should_run_aggregate(self):
self.assertTrue(should_run_webui_ci(["README.md", "webui/layout.py"]))
self.assertFalse(should_run_webui_ci(["mcp_server.py", "gitea_auth.py"]))
class TestCiRunnerScript(unittest.TestCase):
def test_test_webui_smoke(self):
script = _REPO_ROOT / "scripts" / "test-webui"
result = subprocess.run(
["bash", str(script), "-q"],
cwd=_REPO_ROOT,
capture_output=True,
text=True,
timeout=60,
env={
**os.environ,
"WEBUI_TEST_PATTERN": "test_webui_skeleton.py",
},
)
self.assertEqual(result.returncode, 0, result.stderr or result.stdout)
class TestOfflineWebuiCiMode(unittest.TestCase):
def test_offline_mode_avoids_live_queue_auth(self):
with mock.patch.dict(os.environ, {"WEBUI_TEST_OFFLINE": "1"}):
with mock.patch(
"webui.queue_loader.get_auth_header",
side_effect=AssertionError("live auth should not run"),
):
snapshot = load_queue_snapshot()
self.assertIsNone(snapshot.fetch_error)
self.assertEqual(snapshot.prs, ())
self.assertEqual(snapshot.issues, ())
def test_offline_mode_avoids_live_lease_auth(self):
with mock.patch.dict(os.environ, {"WEBUI_TEST_OFFLINE": "1"}):
with mock.patch(
"webui.lease_loader.get_auth_header",
side_effect=AssertionError("live auth should not run"),
):
snapshot = load_lease_snapshot()
self.assertIsNone(snapshot.fetch_error)
self.assertEqual(snapshot.reviewer_leases, ())
def test_offline_mode_avoids_live_runtime_identity(self):
with mock.patch.dict(os.environ, {"WEBUI_TEST_OFFLINE": "1"}):
with mock.patch(
"webui.runtime_health.get_auth_header",
side_effect=AssertionError("live auth should not run"),
):
snapshot = load_runtime_snapshot()
self.assertEqual(snapshot.identity_error, "offline web UI test mode")
if __name__ == "__main__":
unittest.main()
+106
View File
@@ -0,0 +1,106 @@
"""Tests for web UI deployment and auth boundary (#435)."""
import os
import subprocess
import sys
import unittest
from pathlib import Path
sys.path.insert(0, str(Path(__file__).resolve().parent.parent))
from starlette.testclient import TestClient
from webui.app import create_app
from webui.deployment_boundary import (
assess_bind_host,
runtime_assumptions,
scan_text_for_client_secrets,
)
class TestBindAssessment(unittest.TestCase):
def test_loopback_is_safe(self):
for host in ("127.0.0.1", "localhost", "::1"):
with self.subTest(host=host):
result = assess_bind_host(host)
self.assertEqual(result.disposition, "safe")
def test_all_interfaces_refused_by_default(self):
for host in ("0.0.0.0", "::", "*"):
with self.subTest(host=host):
result = assess_bind_host(host)
self.assertEqual(result.disposition, "refuse")
def test_all_interfaces_allowed_with_override(self):
prior = os.environ.pop("WEBUI_ALLOW_PUBLIC_BIND", None)
try:
os.environ["WEBUI_ALLOW_PUBLIC_BIND"] = "1"
result = assess_bind_host("0.0.0.0")
self.assertEqual(result.disposition, "warn")
finally:
os.environ.pop("WEBUI_ALLOW_PUBLIC_BIND", None)
if prior is not None:
os.environ["WEBUI_ALLOW_PUBLIC_BIND"] = prior
def test_non_loopback_warns_without_override(self):
prior = os.environ.pop("WEBUI_ALLOW_REMOTE_BIND", None)
try:
os.environ.pop("WEBUI_ALLOW_REMOTE_BIND", None)
result = assess_bind_host("192.168.1.10")
self.assertEqual(result.disposition, "warn")
finally:
if prior is not None:
os.environ["WEBUI_ALLOW_REMOTE_BIND"] = prior
def test_runtime_assumptions_exclude_secrets(self):
assumptions = runtime_assumptions()
blob = " ".join(str(v) for v in assumptions.values())
self.assertNotIn("GITEA_TOKEN", blob)
self.assertIn("internal-operator-console", assumptions["deployment_mode"])
class TestDeploymentRoutes(unittest.TestCase):
def setUp(self):
self.client = TestClient(create_app(bind_host="127.0.0.1"))
def test_health_includes_deployment_metadata(self):
response = self.client.get("/health")
self.assertEqual(response.status_code, 200)
data = response.json()
self.assertIn("deployment", data)
deployment = data["deployment"]
self.assertEqual(deployment["mode"], "internal-operator-console")
self.assertEqual(deployment["mvp_auth"], "none")
self.assertEqual(deployment["bind"]["disposition"], "safe")
self.assertIn("runtime_assumptions", deployment)
def test_rendered_pages_contain_no_embedded_secrets(self):
for path in ("/", "/projects", "/prompts", "/queue"):
with self.subTest(path=path):
text = self.client.get(path).text
self.assertEqual(scan_text_for_client_secrets(text), [])
class TestStartupRefusal(unittest.TestCase):
def test_refuses_all_interface_bind(self):
env = {**os.environ, "WEBUI_HOST": "0.0.0.0"}
env.pop("WEBUI_ALLOW_PUBLIC_BIND", None)
repo_root = Path(__file__).resolve().parent.parent
result = subprocess.run(
[sys.executable, "-m", "webui"],
cwd=repo_root,
env=env,
capture_output=True,
text=True,
timeout=3,
)
self.assertEqual(result.returncode, 1)
class TestClientSecretScanner(unittest.TestCase):
def test_detects_token_like_content(self):
findings = scan_text_for_client_secrets("var x = GITEA_TOKEN_PRGS")
self.assertTrue(findings)
if __name__ == "__main__":
unittest.main()
+128
View File
@@ -0,0 +1,128 @@
"""Tests for web UI gated action framework (#434)."""
import sys
import unittest
from pathlib import Path
from unittest.mock import patch
sys.path.insert(0, str(Path(__file__).resolve().parent.parent))
from starlette.testclient import TestClient
from task_capability_map import TASK_CAPABILITY_MAP
from webui.app import create_app
from webui.gated_actions import (
attempt_action,
build_action_registry,
load_action_registry,
preview_action,
)
class TestGatedActionRegistry(unittest.TestCase):
def test_all_actions_disabled_by_default(self):
registry = build_action_registry()
self.assertGreater(len(registry.actions), 0)
for action in registry.actions:
with self.subTest(action=action.action_id):
self.assertFalse(action.enabled)
def test_actions_align_with_task_capability_map(self):
registry = build_action_registry()
for action in registry.actions:
with self.subTest(task=action.task_key):
self.assertIn(action.task_key, TASK_CAPABILITY_MAP)
self.assertEqual(
action.required_permission,
TASK_CAPABILITY_MAP[action.task_key]["permission"],
)
self.assertEqual(
action.required_role,
TASK_CAPABILITY_MAP[action.task_key]["role"],
)
def test_preview_includes_mutation_ledger(self):
preview = preview_action("merge_pr", pr_number=42)
self.assertNotIn("error", preview)
ledger = preview["mutation_ledger"]
self.assertEqual(len(ledger), 1)
self.assertEqual(ledger[0]["mcp_tool"], "gitea_merge_pr")
self.assertEqual(ledger[0]["permission"], "gitea.pr.merge")
self.assertIn("42", ledger[0]["target"])
def test_attempt_fails_closed_without_mcp_calls(self):
registry = build_action_registry()
with patch("webui.gated_actions._MVP_ACTIONS_ENABLED", False):
for action in registry.actions:
with self.subTest(action=action.action_id):
result = attempt_action(action.action_id, issue_number=1)
self.assertFalse(result["success"])
self.assertEqual(result["error"], "action_disabled")
self.assertIn("preview", result)
def test_attempt_module_has_no_mcp_imports(self):
"""Ungated execution path must not import MCP server tools."""
import webui.gated_actions as ga
source_path = Path(ga.__file__).resolve()
source = source_path.read_text(encoding="utf-8")
self.assertNotIn("mcp_server", source)
result = attempt_action("merge_pr", pr_number=99)
self.assertFalse(result["success"])
def test_unknown_action_fails_closed(self):
result = attempt_action("nonexistent_action")
self.assertFalse(result["success"])
self.assertEqual(result["error"], "unknown_action")
class TestGatedActionRoutes(unittest.TestCase):
def setUp(self):
self.client = TestClient(create_app())
def test_actions_page_renders_disabled_buttons(self):
response = self.client.get("/actions")
self.assertEqual(response.status_code, 200)
self.assertIn("Gated actions", response.text)
self.assertIn("disabled", response.text.lower())
self.assertIn("mutation ledger", response.text.lower())
self.assertIn("aria-disabled", response.text)
self.assertIn(" disabled", response.text)
def test_api_actions_registry(self):
response = self.client.get("/api/actions")
self.assertEqual(response.status_code, 200)
data = response.json()
self.assertFalse(data["mvp_actions_enabled"])
action_ids = {item["action_id"] for item in data["actions"]}
self.assertIn("merge_pr", action_ids)
self.assertIn("claim_issue", action_ids)
for item in data["actions"]:
self.assertFalse(item["enabled"])
def test_api_action_preview_json(self):
response = self.client.get(
"/api/actions/review_pr/preview?pr_number=12"
)
self.assertEqual(response.status_code, 200)
data = response.json()
self.assertEqual(data["task_key"], "review_pr")
self.assertEqual(data["required_role"], "reviewer")
self.assertEqual(len(data["mutation_ledger"]), 1)
def test_post_attempt_fails_closed(self):
response = self.client.post(
"/api/actions/merge_pr/attempt",
json={"pr_number": 1},
)
self.assertEqual(response.status_code, 403)
data = response.json()
self.assertFalse(data["success"])
self.assertEqual(data["error"], "action_disabled")
def test_nav_includes_actions_link(self):
text = self.client.get("/").text
self.assertIn('href="/actions"', text)
if __name__ == "__main__":
unittest.main()
+156
View File
@@ -0,0 +1,156 @@
"""Consolidated MVP coverage for the internal web UI (#436)."""
from __future__ import annotations
import importlib
import os
import sys
import unittest
from pathlib import Path
sys.path.insert(0, str(Path(__file__).resolve().parent.parent))
from starlette.testclient import TestClient
from starlette.routing import Route
from webui.app import create_app
_REPO_ROOT = Path(__file__).resolve().parent.parent
# HTML pages and JSON exports registered on master MVP.
MVP_GET_ROUTES: tuple[tuple[str, str | tuple[str, ...]], ...] = (
("/", "Operator console"),
("/health", "mcp-control-plane-webui"),
("/queue", "Live queue"),
("/api/queue", ("prs", "issues")),
("/projects", "Gitea-Tools"),
("/api/projects", "projects"),
("/prompts", "Prompt library"),
("/api/prompts", "prompts"),
("/runtime", "Runtime"),
("/audit", "Audit"),
("/worktrees", "Worktrees"),
("/leases", "Leases"),
)
MUTATION_METHODS = ("POST", "PUT", "PATCH", "DELETE")
def _import_optional(module_name: str):
try:
return importlib.import_module(module_name)
except ImportError:
return None
class TestWebuiServerStartup(unittest.TestCase):
def test_create_app_imports_cleanly(self):
app = create_app()
self.assertIsNotNone(app)
def test_main_module_imports(self):
import webui.__main__ as main_mod
self.assertTrue(callable(main_mod.main))
class TestWebuiRouteRendering(unittest.TestCase):
def setUp(self):
self.client = TestClient(create_app())
def test_all_mvp_get_routes_render(self):
for path, needle in MVP_GET_ROUTES:
with self.subTest(path=path):
response = self.client.get(path)
self.assertEqual(response.status_code, 200, path)
if path == "/health":
assert isinstance(needle, str)
self.assertEqual(response.json()["service"], needle)
elif path.startswith("/api/"):
data = response.json()
if isinstance(needle, tuple):
for key in needle:
self.assertIn(key, data)
else:
self.assertIn(needle, data)
else:
assert isinstance(needle, str)
self.assertIn(needle, response.text)
def test_registered_routes_include_mvp_paths(self):
app = create_app()
get_paths = {
route.path
for route in app.routes
if isinstance(route, Route) and "GET" in route.methods
}
for path, _ in MVP_GET_ROUTES:
with self.subTest(path=path):
self.assertIn(path, get_paths)
class TestWebuiReadOnlyGuards(unittest.TestCase):
def setUp(self):
self.client = TestClient(create_app())
def test_mutation_methods_rejected_on_core_paths(self):
paths = ("/health", "/queue", "/projects", "/prompts", "/api/queue")
for path in paths:
for method in MUTATION_METHODS:
with self.subTest(path=path, method=method):
response = self.client.request(method, path)
self.assertEqual(response.status_code, 405)
self.assertEqual(response.json()["error"], "read-only-mvp")
class TestWebuiOptionalModules(unittest.TestCase):
"""Exercise child-issue modules when present on the branch under test."""
def test_audit_validator_basics_when_present(self):
mod = _import_optional("webui.audit_validator")
if mod is None:
self.skipTest("webui.audit_validator not merged on this branch")
report = "## Controller Handoff\n- Task: review PR #1\n"
self.assertEqual(mod.infer_task_kind(report), "review_pr")
result = mod.audit_report(report)
self.assertIn("grade", result)
def test_gated_actions_fail_closed_when_present(self):
mod = _import_optional("webui.gated_actions")
if mod is None:
self.skipTest("webui.gated_actions not merged on this branch")
registry = mod.build_action_registry()
for action in registry.actions:
with self.subTest(action=action.action_id):
self.assertFalse(action.enabled)
result = mod.attempt_action("merge_pr", pr_number=1)
self.assertFalse(result["success"])
def test_deployment_boundary_when_present(self):
mod = _import_optional("webui.deployment_boundary")
if mod is None:
self.skipTest("webui.deployment_boundary not merged on this branch")
assessment = mod.assess_bind_host("0.0.0.0")
self.assertEqual(assessment.disposition, "refuse")
class TestWebuiTestInventory(unittest.TestCase):
def test_webui_test_modules_exist(self):
modules = sorted(_REPO_ROOT.glob("tests/test_webui_*.py"))
names = [path.name for path in modules]
self.assertIn("test_webui_skeleton.py", names)
self.assertIn("test_webui_project_registry.py", names)
self.assertIn("test_webui_prompt_library.py", names)
self.assertIn("test_webui_queue_dashboard.py", names)
self.assertGreaterEqual(len(names), 5)
class TestWebuiCiScripts(unittest.TestCase):
def test_runner_scripts_exist(self):
for name in ("test-webui", "ci-webui-check"):
script = _REPO_ROOT / "scripts" / name
self.assertTrue(script.is_file(), name)
self.assertTrue(os.access(script, os.X_OK), name)
if __name__ == "__main__":
unittest.main()
+1
View File
@@ -206,6 +206,7 @@ class TestQueueRoutes(unittest.TestCase):
self.assertEqual(data["pagination"]["issues"]["returned_count"], 3) self.assertEqual(data["pagination"]["issues"]["returned_count"], 3)
def test_queue_fail_closed_without_credentials(self): def test_queue_fail_closed_without_credentials(self):
with mock.patch.dict("os.environ", {"WEBUI_TEST_OFFLINE": "0"}):
with mock.patch("webui.queue_loader.get_auth_header", return_value=None): with mock.patch("webui.queue_loader.get_auth_header", return_value=None):
snapshot = load_queue_snapshot() snapshot = load_queue_snapshot()
self.assertIsNotNone(snapshot.fetch_error) self.assertIsNotNone(snapshot.fetch_error)
+9 -2
View File
@@ -22,6 +22,8 @@ class TestWebuiSkeleton(unittest.TestCase):
self.assertEqual(data["service"], "mcp-control-plane-webui") self.assertEqual(data["service"], "mcp-control-plane-webui")
self.assertEqual(data["mode"], "read-only-mvp") self.assertEqual(data["mode"], "read-only-mvp")
self.assertIn("timestamp", data) self.assertIn("timestamp", data)
self.assertIn("deployment", data)
self.assertEqual(data["deployment"]["mode"], "internal-operator-console")
def test_home_renders(self): def test_home_renders(self):
response = self.client.get("/") response = self.client.get("/")
@@ -61,6 +63,11 @@ class TestWebuiSkeleton(unittest.TestCase):
self.assertIn("Collision warnings", response.text) self.assertIn("Collision warnings", response.text)
def test_actions_is_implemented(self):
response = self.client.get("/actions")
self.assertEqual(response.status_code, 200)
self.assertIn("Gated actions", response.text)
def test_post_is_rejected(self): def test_post_is_rejected(self):
response = self.client.post("/health") response = self.client.post("/health")
self.assertEqual(response.status_code, 405) self.assertEqual(response.status_code, 405)
@@ -72,8 +79,8 @@ class TestWebuiSkeleton(unittest.TestCase):
self.assertIn("Live queue", response.text) self.assertIn("Live queue", response.text)
def test_nav_links_on_all_pages(self): def test_nav_links_on_all_pages(self):
paths = ("/", "/queue", "/projects", "/prompts", "/runtime", "/audit", "/worktrees", "/leases") paths = ("/", "/queue", "/projects", "/prompts", "/runtime", "/audit", "/worktrees", "/leases", "/actions")
hrefs = ("/queue", "/projects", "/prompts", "/runtime", "/audit", "/worktrees", "/leases") hrefs = ("/queue", "/projects", "/prompts", "/runtime", "/audit", "/worktrees", "/leases", "/actions")
for path in paths: for path in paths:
with self.subTest(path=path): with self.subTest(path=path):
text = self.client.get(path).text text = self.client.get(path).text
+18 -1
View File
@@ -2,16 +2,33 @@
from __future__ import annotations from __future__ import annotations
import logging
import os import os
import uvicorn import uvicorn
from webui.app import create_app from webui.deployment_boundary import assess_bind_host
logger = logging.getLogger("webui")
def _validate_bind_host(host: str) -> None:
assessment = assess_bind_host(host)
if assessment.disposition == "refuse":
logger.error("webui bind refused: %s", assessment.message)
raise SystemExit(1)
if assessment.disposition == "warn":
logger.warning("webui bind warning: %s", assessment.message)
def main() -> None: def main() -> None:
host = os.environ.get("WEBUI_HOST", "127.0.0.1") host = os.environ.get("WEBUI_HOST", "127.0.0.1")
port = int(os.environ.get("WEBUI_PORT", "8765")) port = int(os.environ.get("WEBUI_PORT", "8765"))
# Validate bind host before importing the full app stack so refuse paths
# fail closed quickly (tests and operators must not hang on create_app).
_validate_bind_host(host)
from webui.app import create_app
uvicorn.run(create_app(), host=host, port=port, log_level="info") uvicorn.run(create_app(), host=host, port=port, log_level="info")
+60 -2
View File
@@ -9,6 +9,7 @@ from starlette.requests import Request
from starlette.responses import HTMLResponse, JSONResponse, Response from starlette.responses import HTMLResponse, JSONResponse, Response
from starlette.routing import Route from starlette.routing import Route
from webui.deployment_boundary import deployment_snapshot
from webui.layout import render_page from webui.layout import render_page
from webui.project_registry import find_project, load_registry, registry_to_dict from webui.project_registry import find_project, load_registry, registry_to_dict
from webui.project_views import render_project_detail, render_projects_list from webui.project_views import render_project_detail, render_projects_list
@@ -16,6 +17,8 @@ from webui.prompt_library import find_prompt, library_to_dict
from webui.prompt_views import render_prompt_detail, render_prompts_page from webui.prompt_views import render_prompt_detail, render_prompts_page
from final_report_validator import FINAL_REPORT_TASK_KINDS from final_report_validator import FINAL_REPORT_TASK_KINDS
from webui.gated_actions import attempt_action, load_action_registry, preview_action
from webui.gated_action_views import render_actions_page
from webui.audit_validator import audit_report, audit_to_dict from webui.audit_validator import audit_report, audit_to_dict
from webui.audit_views import render_audit_page from webui.audit_views import render_audit_page
from webui.lease_loader import load_lease_snapshot, snapshot_to_dict as lease_snapshot_to_dict from webui.lease_loader import load_lease_snapshot, snapshot_to_dict as lease_snapshot_to_dict
@@ -52,17 +55,20 @@ async def home(_request: Request) -> HTMLResponse:
"<li><strong>Audit</strong> — final-report paste and validator preview (#431)</li>" "<li><strong>Audit</strong> — final-report paste and validator preview (#431)</li>"
"<li><strong>Worktrees</strong> — branch hygiene dashboard (#432)</li>" "<li><strong>Worktrees</strong> — branch hygiene dashboard (#432)</li>"
"<li><strong>Leases</strong> — collision and lease visibility (#433)</li>" "<li><strong>Leases</strong> — collision and lease visibility (#433)</li>"
"<li><strong>Actions</strong> — gated write-action framework (#434)</li>"
"</ul>" "</ul>"
) )
return HTMLResponse(render_page(title="Home", body_html=body)) return HTMLResponse(render_page(title="Home", body_html=body))
async def health(_request: Request) -> JSONResponse: async def health(_request: Request) -> JSONResponse:
bind_host = _request.app.state.webui_bind_host
return JSONResponse({ return JSONResponse({
"status": "ok", "status": "ok",
"service": "mcp-control-plane-webui", "service": "mcp-control-plane-webui",
"mode": "read-only-mvp", "mode": "read-only-mvp",
"timestamp": datetime.now(timezone.utc).isoformat(), "timestamp": datetime.now(timezone.utc).isoformat(),
"deployment": deployment_snapshot(bind_host=bind_host),
}) })
@@ -200,6 +206,41 @@ async def api_leases(_request: Request) -> JSONResponse:
return JSONResponse(lease_snapshot_to_dict(load_lease_snapshot())) return JSONResponse(lease_snapshot_to_dict(load_lease_snapshot()))
async def actions(_request: Request) -> HTMLResponse:
registry = load_action_registry()
return HTMLResponse(render_actions_page(registry))
async def api_actions(_request: Request) -> JSONResponse:
return JSONResponse(load_action_registry().to_dict())
async def api_action_preview(request: Request) -> JSONResponse:
action_id = request.path_params["action_id"]
params = dict(request.query_params)
for key in ("issue_number", "pr_number"):
if key in params:
params[key] = int(params[key])
result = preview_action(action_id, **params)
if "error" in result:
return JSONResponse(result, status_code=404)
return JSONResponse(result)
async def api_action_attempt(request: Request) -> JSONResponse:
"""POST is rejected by read-only guard; kept for explicit fail-closed tests."""
action_id = request.path_params["action_id"]
try:
body = await request.json()
except Exception:
body = {}
if not isinstance(body, dict):
body = {}
result = attempt_action(action_id, **body)
status = 403 if not result.get("success") else 200
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":
@@ -212,9 +253,12 @@ async def method_not_allowed(request: Request, _exc: Exception) -> Response:
return JSONResponse({"error": "not_found"}, status_code=404) return JSONResponse({"error": "not_found"}, status_code=404)
def create_app() -> Starlette: def create_app(*, bind_host: str | None = None) -> Starlette:
"""Build the read-only MVP Starlette app.""" """Build the read-only MVP Starlette app."""
return Starlette( import os
resolved_bind = bind_host or os.environ.get("WEBUI_HOST", "127.0.0.1")
app = Starlette(
debug=False, debug=False,
routes=[ routes=[
Route("/", home, methods=["GET"]), Route("/", home, methods=["GET"]),
@@ -234,7 +278,21 @@ def create_app() -> Starlette:
Route("/worktrees", worktrees, methods=["GET"]), Route("/worktrees", worktrees, methods=["GET"]),
Route("/api/worktrees", api_worktrees, methods=["GET"]), Route("/api/worktrees", api_worktrees, methods=["GET"]),
Route("/leases", leases, methods=["GET"]), Route("/leases", leases, methods=["GET"]),
Route("/actions", actions, methods=["GET"]),
Route("/api/actions", api_actions, methods=["GET"]),
Route(
"/api/actions/{action_id}/preview",
api_action_preview,
methods=["GET"],
),
Route(
"/api/actions/{action_id}/attempt",
api_action_attempt,
methods=["POST"],
),
Route("/api/leases", api_leases, methods=["GET"]), Route("/api/leases", api_leases, methods=["GET"]),
], ],
exception_handlers={405: method_not_allowed}, exception_handlers={405: method_not_allowed},
) )
app.state.webui_bind_host = resolved_bind
return app
+20
View File
@@ -0,0 +1,20 @@
"""Path filters for web UI CI gating (#436)."""
from __future__ import annotations
import re
from collections.abc import Iterable
_WEBUI_TOUCH_RE = re.compile(
r"^(?:webui/|tests/test_webui_|docs/webui|scripts/(?:run-webui|test-webui|ci-webui-check)$)"
)
def touches_webui_surface(path: str) -> bool:
"""Return True when *path* is a web UI surface file."""
return bool(_WEBUI_TOUCH_RE.match(path.strip()))
def should_run_webui_ci(changed_paths: Iterable[str]) -> bool:
"""Return True when any changed path should trigger the web UI test suite."""
return any(touches_webui_surface(path) for path in changed_paths)
+155
View File
@@ -0,0 +1,155 @@
"""Internal-only deployment boundary for the operator web UI (#435)."""
from __future__ import annotations
import ipaddress
import os
import re
from dataclasses import asdict, dataclass
from typing import Literal
Disposition = Literal["safe", "warn", "refuse"]
_LOOPBACK_HOSTS = frozenset({"127.0.0.1", "localhost", "::1"})
_ALL_INTERFACE_HOSTS = frozenset({"0.0.0.0", "::", "*"})
_ALLOW_PUBLIC_BIND_ENV = "WEBUI_ALLOW_PUBLIC_BIND"
_ALLOW_REMOTE_BIND_ENV = "WEBUI_ALLOW_REMOTE_BIND"
_FORBIDDEN_CLIENT_PATTERNS = (
re.compile(r"GITEA_(?:TOKEN|PASS|PASSWORD)", re.I),
re.compile(r"Bearer\s+[A-Za-z0-9._\-]{20,}"),
re.compile(r"password\s*[:=]\s*['\"][^'\"]+['\"]", re.I),
)
@dataclass(frozen=True)
class BindAssessment:
host: str
disposition: Disposition
message: str
override_env: str | None = None
def _truthy_env(name: str) -> bool:
return os.environ.get(name, "").strip().lower() in {"1", "true", "yes"}
def assess_bind_host(host: str) -> BindAssessment:
"""Classify a bind host as safe, warn, or refuse (fail closed on 0.0.0.0/::)."""
normalized = (host or "").strip().lower()
if not normalized:
return BindAssessment(
host=host,
disposition="refuse",
message="WEBUI_HOST must not be empty.",
)
if normalized in _LOOPBACK_HOSTS:
return BindAssessment(
host=host,
disposition="safe",
message="Loopback bind — suitable for local operator console.",
)
if normalized in _ALL_INTERFACE_HOSTS:
if _truthy_env(_ALLOW_PUBLIC_BIND_ENV):
return BindAssessment(
host=host,
disposition="warn",
message=(
"Binding all interfaces. Protect with Cloudflare Access, "
"WARP, VPN, or equivalent before exposing beyond localhost."
),
override_env=_ALLOW_PUBLIC_BIND_ENV,
)
return BindAssessment(
host=host,
disposition="refuse",
message=(
f"Refusing all-interface bind ({host!r}). Set "
f"{_ALLOW_PUBLIC_BIND_ENV}=1 only when fronted by trusted "
"network access controls."
),
override_env=_ALLOW_PUBLIC_BIND_ENV,
)
try:
addr = ipaddress.ip_address(normalized)
if addr.is_loopback:
return BindAssessment(
host=host,
disposition="safe",
message="Loopback bind — suitable for local operator console.",
)
except ValueError:
pass
if _truthy_env(_ALLOW_REMOTE_BIND_ENV):
return BindAssessment(
host=host,
disposition="warn",
message=(
"Non-loopback bind acknowledged. Restrict to a trusted network "
"and add Cloudflare Access, WARP, or VPN if reachable beyond it."
),
override_env=_ALLOW_REMOTE_BIND_ENV,
)
return BindAssessment(
host=host,
disposition="warn",
message=(
f"Non-loopback bind ({host!r}). MVP expects 127.0.0.1; set "
f"{_ALLOW_REMOTE_BIND_ENV}=1 to acknowledge a trusted-network bind."
),
override_env=_ALLOW_REMOTE_BIND_ENV,
)
def runtime_assumptions() -> dict[str, str]:
"""Documented runtime paths and hosts — never includes secrets."""
repo_root = (os.environ.get("WEBUI_REPO_ROOT") or "").strip()
registry = (os.environ.get("WEBUI_PROJECT_REGISTRY") or "").strip()
profile_config = (os.environ.get("GITEA_MCP_CONFIG") or "").strip()
profile_name = (os.environ.get("GITEA_MCP_PROFILE") or "").strip()
return {
"deployment_mode": "internal-operator-console",
"webui_host_env": "WEBUI_HOST",
"webui_port_env": "WEBUI_PORT",
"webui_repo_root_env": "WEBUI_REPO_ROOT",
"webui_repo_root": repo_root or "(defaults to repository root)",
"webui_project_registry_env": "WEBUI_PROJECT_REGISTRY",
"webui_project_registry": registry or "(packaged webui/data/projects.registry.json)",
"gitea_mcp_config_env": "GITEA_MCP_CONFIG",
"gitea_mcp_config": profile_config or "(optional; server-side only)",
"gitea_mcp_profile_env": "GITEA_MCP_PROFILE",
"gitea_mcp_profile": profile_name or "(optional; server-side only)",
"gitea_credentials": "Resolved server-side via gitea_auth; never embedded in HTML/JS",
"public_bind_override_env": _ALLOW_PUBLIC_BIND_ENV,
"remote_bind_override_env": _ALLOW_REMOTE_BIND_ENV,
}
def scan_text_for_client_secrets(text: str) -> list[str]:
"""Return human-readable findings if *text* looks like it embeds secrets."""
findings: list[str] = []
for pattern in _FORBIDDEN_CLIENT_PATTERNS:
if pattern.search(text):
findings.append(f"matched forbidden client pattern: {pattern.pattern}")
return findings
def deployment_snapshot(*, bind_host: str | None = None) -> dict[str, object]:
host = bind_host if bind_host is not None else os.environ.get("WEBUI_HOST", "127.0.0.1")
bind = assess_bind_host(host)
return {
"mode": "internal-operator-console",
"mvp_auth": "none",
"bind": asdict(bind),
"runtime_assumptions": runtime_assumptions(),
"client_secret_policy": (
"No Gitea tokens, passwords, or profile secrets in HTML, JS, "
"or browser storage."
),
}
+101
View File
@@ -0,0 +1,101 @@
"""HTML views for gated action previews (#434)."""
from __future__ import annotations
import html
import json
from webui.gated_actions import ActionRegistry, GatedAction, preview_action
from webui.layout import render_page
def _escape(text: str) -> str:
return html.escape(text, quote=True)
def _ledger_block(action: GatedAction) -> str:
sample_params: dict[str, object]
if action.action_id == "create_issue":
sample_params = {"title": "Example issue"}
elif action.action_id == "create_pr":
sample_params = {"head": "feat/issue-99-example", "base": "master"}
elif action.action_id == "delete_branch":
sample_params = {"branch_name": "feat/issue-99-example"}
elif action.action_id in {"review_pr", "merge_pr", "comment_pr", "close_pr"}:
sample_params = {"pr_number": 99}
else:
sample_params = {"issue_number": 99}
preview = preview_action(action.action_id, **sample_params)
ledger_json = json.dumps(preview.get("mutation_ledger", []), indent=2)
return (
"<details class='action-preview'>"
"<summary>Preview mutation ledger</summary>"
f"<pre class='prompt-text'>{_escape(ledger_json)}</pre>"
f"<p class='muted meta'>Sample params: "
f"<code>{_escape(json.dumps(sample_params))}</code></p>"
f"<p><a href=\"/api/actions/{_escape(action.action_id)}/preview?"
f"{_preview_query(sample_params)}\">JSON preview</a></p>"
"</details>"
)
def _preview_query(params: dict[str, object]) -> str:
parts = []
for key, value in params.items():
parts.append(f"{key}={_escape(str(value))}")
return "&".join(parts)
def _action_card(action: GatedAction) -> str:
disabled_attr = " disabled" if not action.enabled else ""
return (
"<article class='prompt-card action-card'>"
f"<h3>{_escape(action.label)}</h3>"
f"<p class='muted'>{_escape(action.description)}</p>"
"<p class='meta'>"
f"Task <code>{_escape(action.task_key)}</code> · "
f"MCP <code>{_escape(action.mcp_tool)}</code><br>"
f"Requires <code>{_escape(action.required_permission)}</code> "
f"({_escape(action.required_role)} profile)</p>"
f"<button type='button' class='copy-btn action-btn'{disabled_attr} "
f"aria-disabled='true' title='Disabled in read-only MVP'>"
f"{_escape(action.label)}</button>"
f"{_ledger_block(action)}"
"</article>"
)
ACTION_PAGE_STYLES = """
<style>
.action-card .action-btn[disabled] {
opacity: 0.45;
cursor: not-allowed;
filter: none;
}
.action-preview { margin-top: 0.75rem; }
.action-preview summary {
cursor: pointer;
color: var(--accent);
font-size: 0.9rem;
}
</style>
"""
def render_actions_page(registry: ActionRegistry) -> str:
cards = "".join(_action_card(action) for action in registry.actions)
body = (
"<h2>Gated actions</h2>"
"<p>Future write actions are registered here but remain "
"<strong>disabled</strong> in the read-only MVP. Each action "
"declares the MCP capability and profile role from "
"<code>task_capability_map</code>; previews show the mutation "
"ledger before any execution path ships.</p>"
f"<p class='meta'>{len(registry.actions)} registered actions · "
"execution: fail-closed</p>"
f"{cards}"
"<p><a href=\"/api/actions\">JSON registry</a></p>"
f"{ACTION_PAGE_STYLES}"
)
return render_page(title="Actions", body_html=body)
+201
View File
@@ -0,0 +1,201 @@
"""Disabled-by-default gated action registry for future UI writes (#434).
Every action declares the MCP capability and profile role from
``task_capability_map``. Previews always render a mutation ledger; execution
is fail-closed in the read-only MVP (no MCP or shell fallbacks).
"""
from __future__ import annotations
from dataclasses import asdict, dataclass, field
from typing import Any
from task_capability_map import required_permission, required_role
# MVP: no UI action may execute mutations until capability gates ship.
_MVP_ACTIONS_ENABLED = False
@dataclass(frozen=True)
class MutationLedgerEntry:
"""One planned mutation step shown before any write executes."""
sequence: int
mcp_tool: str
permission: str
target: str
summary: str
@dataclass(frozen=True)
class GatedAction:
"""Registry entry tying a UI action to resolver task metadata."""
action_id: str
label: str
task_key: str
mcp_tool: str
description: str
enabled: bool = False
@property
def required_permission(self) -> str:
return required_permission(self.task_key)
@property
def required_role(self) -> str:
return required_role(self.task_key)
def mutation_ledger(self, **params: Any) -> tuple[MutationLedgerEntry, ...]:
"""Build the mutation ledger preview for *params* (read-only)."""
target = _format_target(self.action_id, params)
return (
MutationLedgerEntry(
sequence=1,
mcp_tool=self.mcp_tool,
permission=self.required_permission,
target=target,
summary=self.description,
),
)
def preview(self, **params: Any) -> dict[str, Any]:
ledger = self.mutation_ledger(**params)
return {
"action_id": self.action_id,
"label": self.label,
"task_key": self.task_key,
"mcp_tool": self.mcp_tool,
"required_permission": self.required_permission,
"required_role": self.required_role,
"enabled": self.enabled and _MVP_ACTIONS_ENABLED,
"mvp_mode": "read-only",
"mutation_ledger": [asdict(entry) for entry in ledger],
"params": dict(params),
}
def attempt(self, **params: Any) -> dict[str, Any]:
"""Fail closed — never invokes MCP tools in MVP."""
preview = self.preview(**params)
if not preview["enabled"]:
return {
"success": False,
"error": "action_disabled",
"detail": (
"UI actions are disabled in the read-only MVP. "
"Use MCP tools with capability gates."
),
"preview": preview,
}
return {
"success": False,
"error": "action_not_implemented",
"detail": "Gated execution path is not wired in MVP.",
"preview": preview,
}
def _format_target(action_id: str, params: dict[str, Any]) -> str:
if action_id == "claim_issue":
return f"issue #{params.get('issue_number', '?')}"
if action_id in {"comment_issue", "close_issue"}:
return f"issue #{params.get('issue_number', '?')}"
if action_id in {"review_pr", "merge_pr", "comment_pr", "close_pr"}:
return f"PR #{params.get('pr_number', '?')}"
if action_id == "delete_branch":
return f"branch {params.get('branch_name', '?')!r}"
if action_id == "create_pr":
return (
f"PR {params.get('head', '?')}{params.get('base', 'master')}"
)
if action_id == "create_issue":
return f"issue {params.get('title', '?')!r}"
return "unspecified"
@dataclass
class ActionRegistry:
"""Ordered registry of gated UI actions."""
actions: tuple[GatedAction, ...] = field(default_factory=tuple)
def get(self, action_id: str) -> GatedAction | None:
for action in self.actions:
if action.action_id == action_id:
return action
return None
def to_dict(self) -> dict[str, Any]:
return {
"mvp_actions_enabled": _MVP_ACTIONS_ENABLED,
"actions": [
{
"action_id": action.action_id,
"label": action.label,
"task_key": action.task_key,
"mcp_tool": action.mcp_tool,
"required_permission": action.required_permission,
"required_role": action.required_role,
"enabled": action.enabled,
"description": action.description,
}
for action in self.actions
],
}
def build_action_registry() -> ActionRegistry:
"""Return the canonical MVP action set (all disabled)."""
specs = (
("claim_issue", "Claim issue", "claim_issue", "gitea_mark_issue",
"Apply status:in-progress via issue comment gate."),
("comment_issue", "Comment on issue", "comment_issue",
"gitea_create_issue_comment", "Post an issue comment."),
("create_issue", "Create issue", "create_issue", "gitea_create_issue",
"Open a new tracking issue."),
("create_pr", "Create pull request", "create_pr", "gitea_create_pr",
"Open a PR from a locked feature branch."),
("review_pr", "Review PR", "review_pr", "gitea_review_pr",
"Submit approve/request-changes/comment review."),
("merge_pr", "Merge PR", "merge_pr", "gitea_merge_pr",
"Merge an approved pull request."),
("delete_branch", "Delete branch", "delete_branch",
"gitea_delete_branch", "Remove a remote feature branch."),
("comment_pr", "Comment on PR", "comment_pr",
"gitea_create_issue_comment", "Post a PR review thread comment."),
("close_pr", "Close PR", "close_pr", "gitea_edit_pr",
"Close a pull request without merge."),
)
actions = tuple(
GatedAction(
action_id=action_id,
label=label,
task_key=task_key,
mcp_tool=mcp_tool,
description=description,
enabled=False,
)
for action_id, label, task_key, mcp_tool, description in specs
)
return ActionRegistry(actions=actions)
_REGISTRY = build_action_registry()
def load_action_registry() -> ActionRegistry:
return _REGISTRY
def preview_action(action_id: str, **params: Any) -> dict[str, Any]:
action = _REGISTRY.get(action_id)
if action is None:
return {"error": "unknown_action", "action_id": action_id}
return action.preview(**params)
def attempt_action(action_id: str, **params: Any) -> dict[str, Any]:
action = _REGISTRY.get(action_id)
if action is None:
return {"success": False, "error": "unknown_action", "action_id": action_id}
return action.attempt(**params)
+1
View File
@@ -11,6 +11,7 @@ NAV_ITEMS = (
("/audit", "Audit"), ("/audit", "Audit"),
("/worktrees", "Worktrees"), ("/worktrees", "Worktrees"),
("/leases", "Leases"), ("/leases", "Leases"),
("/actions", "Actions"),
) )
MVP_NOTICE = ( MVP_NOTICE = (
+23 -4
View File
@@ -228,10 +228,29 @@ def load_lease_snapshot(
) )
host = _host_from_url(project.remote_host) host = _host_from_url(project.remote_host)
pr_fetch = fetch_prs or _fetch_prs offline_test = (os.environ.get("WEBUI_TEST_OFFLINE") or "").strip() in {
issue_fetch = fetch_issues or _fetch_issues "1",
comment_fetch = fetch_comments or _fetch_comments "true",
using_live = fetch_prs is None or fetch_issues is None "yes",
}
def _empty_pr_fetch(*_args, **_kwargs):
return [], None
def _empty_issue_fetch(*_args, **_kwargs):
return [], None
def _empty_comment_fetch(*_args, **_kwargs):
return []
pr_fetch = fetch_prs or (_empty_pr_fetch if offline_test else _fetch_prs)
issue_fetch = fetch_issues or (
_empty_issue_fetch if offline_test else _fetch_issues
)
comment_fetch = fetch_comments or (
_empty_comment_fetch if offline_test else _fetch_comments
)
using_live = not offline_test and (fetch_prs is None or fetch_issues is None)
auth = get_auth_header(host) if using_live else "test-auth" auth = get_auth_header(host) if using_live else "test-auth"
if using_live and not auth: if using_live and not auth:
+18 -3
View File
@@ -2,6 +2,7 @@
from __future__ import annotations from __future__ import annotations
import os
import re import re
from dataclasses import dataclass from dataclasses import dataclass
from datetime import datetime, timezone from datetime import datetime, timezone
@@ -268,9 +269,23 @@ def load_queue_snapshot(
) )
host = _host_from_url(project.remote_host) host = _host_from_url(project.remote_host)
pr_fetch = fetch_prs or _fetch_prs offline_test = (os.environ.get("WEBUI_TEST_OFFLINE") or "").strip() in {
issue_fetch = fetch_issues or _fetch_issues "1",
using_live_fetch = fetch_prs is None or fetch_issues is None "true",
"yes",
}
def _empty_fetch(*_args, **_kwargs):
return [], _pagination_from_pages(
per_page=50,
pages_fetched=1,
returned_count=0,
is_final_page=True,
)
pr_fetch = fetch_prs or (_empty_fetch if offline_test else _fetch_prs)
issue_fetch = fetch_issues or (_empty_fetch if offline_test else _fetch_issues)
using_live_fetch = not offline_test and (fetch_prs is None or fetch_issues is None)
auth = get_auth_header(host) if using_live_fetch else "test-auth" auth = get_auth_header(host) if using_live_fetch else "test-auth"
if using_live_fetch and not auth: if using_live_fetch and not auth:
return QueueSnapshot( return QueueSnapshot(
+17 -2
View File
@@ -217,6 +217,11 @@ def load_runtime_snapshot(
repo = _repo_root() repo = _repo_root()
host = _host_from_url(project.remote_host) host = _host_from_url(project.remote_host)
remote = _remote_name_for_host(host) remote = _remote_name_for_host(host)
offline_test = (os.environ.get("WEBUI_TEST_OFFLINE") or "").strip() in {
"1",
"true",
"yes",
}
profile = get_profile() profile = get_profile()
allowed = profile.get("allowed_operations") or [] allowed = profile.get("allowed_operations") or []
forbidden = profile.get("forbidden_operations") or [] forbidden = profile.get("forbidden_operations") or []
@@ -247,10 +252,20 @@ def load_runtime_snapshot(
fetch_error=str(exc), fetch_error=str(exc),
) )
identity_fn = resolve_username or _resolve_username identity_fn = resolve_username
if identity_fn is None:
if offline_test:
identity_fn = lambda _host: (None, "offline web UI test mode")
else:
identity_fn = _resolve_username
username, identity_error = identity_fn(host) username, identity_error = identity_fn(host)
git_fn = git_sync or _git_sync_status git_fn = git_sync
if git_fn is None:
if offline_test:
git_fn = lambda _repo, _remote, _branch: (None, None, None)
else:
git_fn = _git_sync_status
remote_ref = "prgs" if remote == "prgs" else "origin" remote_ref = "prgs" if remote == "prgs" else "origin"
local_sha, remote_sha, behind = git_fn(repo, remote_ref, project.default_branch) local_sha, remote_sha, behind = git_fn(repo, remote_ref, project.default_branch)