Merge branch 'master' into feat/issue-543-mcp-namespace-health-check
This commit is contained in:
@@ -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",
|
||||||
|
}
|
||||||
@@ -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
|
||||||
|
|||||||
@@ -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).
|
||||||
|
|||||||
@@ -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
|
||||||
+80
-3
@@ -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,
|
||||||
@@ -128,4 +205,4 @@ no tokens or MCP restart actions are exposed.
|
|||||||
```bash
|
```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_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_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_lease_visibility.py tests/test_webui_runtime_health.py -q
|
||||||
```
|
```
|
||||||
|
|||||||
@@ -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
@@ -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
|
||||||
|
|||||||
+161
-1
@@ -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
|
||||||
@@ -366,4 +369,161 @@ def assess_gitea_operation_path(
|
|||||||
else "proceed with native MCP"
|
else "proceed with native MCP"
|
||||||
)
|
)
|
||||||
),
|
),
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
||||||
|
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,
|
||||||
|
}
|
||||||
|
|||||||
+21
-11
@@ -190,18 +190,28 @@ 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:
|
||||||
if phase in _TERMINAL_PHASES:
|
return None
|
||||||
continue
|
newest = entries[0]
|
||||||
if _lease_expired(lease, now=now):
|
phase = (newest.get("phase") or "").strip().lower()
|
||||||
continue
|
if phase in _TERMINAL_PHASES:
|
||||||
if phase in _ACTIVE_PHASES or phase:
|
return None
|
||||||
lease = dict(lease)
|
if _lease_expired(newest, now=now):
|
||||||
lease["freshness"] = classify_lease_freshness(lease, now=now)
|
return None
|
||||||
return lease
|
if phase in _ACTIVE_PHASES or phase:
|
||||||
|
lease = dict(newest)
|
||||||
|
lease["freshness"] = classify_lease_freshness(lease, now=now)
|
||||||
|
return lease
|
||||||
return None
|
return None
|
||||||
|
|
||||||
|
|
||||||
|
|||||||
@@ -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",
|
||||||
@@ -406,4 +408,4 @@ def assess_infra_stop(project_root: str | None = None) -> dict:
|
|||||||
|
|
||||||
def check_mid_merge(project_root: str | None = None) -> bool:
|
def check_mid_merge(project_root: str | None = None) -> bool:
|
||||||
"""Return True if the repository is mid-merge, mid-rebase, or has conflict markers."""
|
"""Return True if the repository is mid-merge, mid-rebase, or has conflict markers."""
|
||||||
return assess_infra_stop(project_root)["infra_stop"]
|
return assess_infra_stop(project_root)["infra_stop"]
|
||||||
|
|||||||
Executable
+53
@@ -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
|
||||||
Executable
+14
@@ -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
|
||||||
|
|||||||
@@ -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",
|
||||||
|
|||||||
@@ -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()
|
||||||
@@ -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)
|
||||||
|
|||||||
@@ -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()
|
||||||
@@ -118,4 +203,4 @@ class TestNativeMcpPreferenceGate(unittest.TestCase):
|
|||||||
|
|
||||||
|
|
||||||
if __name__ == "__main__":
|
if __name__ == "__main__":
|
||||||
unittest.main()
|
unittest.main()
|
||||||
|
|||||||
@@ -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):
|
||||||
|
|||||||
@@ -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()
|
||||||
|
|||||||
@@ -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()
|
||||||
@@ -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()
|
||||||
@@ -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()
|
||||||
@@ -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()
|
||||||
@@ -206,8 +206,9 @@ 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("webui.queue_loader.get_auth_header", return_value=None):
|
with mock.patch.dict("os.environ", {"WEBUI_TEST_OFFLINE": "0"}):
|
||||||
snapshot = load_queue_snapshot()
|
with mock.patch("webui.queue_loader.get_auth_header", return_value=None):
|
||||||
|
snapshot = load_queue_snapshot()
|
||||||
self.assertIsNotNone(snapshot.fetch_error)
|
self.assertIsNotNone(snapshot.fetch_error)
|
||||||
self.assertEqual(len(snapshot.prs), 0)
|
self.assertEqual(len(snapshot.prs), 0)
|
||||||
|
|
||||||
@@ -285,4 +286,4 @@ class TestQueueFailClosedUx(unittest.TestCase):
|
|||||||
|
|
||||||
|
|
||||||
if __name__ == "__main__":
|
if __name__ == "__main__":
|
||||||
unittest.main()
|
unittest.main()
|
||||||
|
|||||||
@@ -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
|
||||||
|
|||||||
+19
-2
@@ -2,18 +2,35 @@
|
|||||||
|
|
||||||
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")
|
||||||
|
|
||||||
|
|
||||||
if __name__ == "__main__":
|
if __name__ == "__main__":
|
||||||
main()
|
main()
|
||||||
|
|||||||
+61
-3
@@ -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
|
||||||
@@ -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)
|
||||||
@@ -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."
|
||||||
|
),
|
||||||
|
}
|
||||||
@@ -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)
|
||||||
@@ -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)
|
||||||
@@ -11,6 +11,7 @@ NAV_ITEMS = (
|
|||||||
("/audit", "Audit"),
|
("/audit", "Audit"),
|
||||||
("/worktrees", "Worktrees"),
|
("/worktrees", "Worktrees"),
|
||||||
("/leases", "Leases"),
|
("/leases", "Leases"),
|
||||||
|
("/actions", "Actions"),
|
||||||
)
|
)
|
||||||
|
|
||||||
MVP_NOTICE = (
|
MVP_NOTICE = (
|
||||||
|
|||||||
+24
-5
@@ -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:
|
||||||
@@ -328,4 +347,4 @@ def snapshot_to_dict(snapshot: LeaseSnapshot) -> dict[str, Any]:
|
|||||||
],
|
],
|
||||||
"collision_history": list(snapshot.collision_history),
|
"collision_history": list(snapshot.collision_history),
|
||||||
"fetch_error": snapshot.fetch_error,
|
"fetch_error": snapshot.fetch_error,
|
||||||
}
|
}
|
||||||
|
|||||||
+19
-4
@@ -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(
|
||||||
@@ -382,4 +397,4 @@ def snapshot_to_dict(snapshot: QueueSnapshot) -> dict[str, Any]:
|
|||||||
"prs": _page(snapshot.pr_pagination),
|
"prs": _page(snapshot.pr_pagination),
|
||||||
"issues": _page(snapshot.issue_pagination),
|
"issues": _page(snapshot.issue_pagination),
|
||||||
},
|
},
|
||||||
}
|
}
|
||||||
|
|||||||
+18
-3
@@ -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)
|
||||||
|
|
||||||
@@ -317,4 +332,4 @@ def snapshot_to_dict(snapshot: RuntimeSnapshot) -> dict[str, Any]:
|
|||||||
"schema_hashes": [_hash_row(item) for item in snapshot.schema_hashes],
|
"schema_hashes": [_hash_row(item) for item in snapshot.schema_hashes],
|
||||||
"restart_guidance": snapshot.restart_guidance,
|
"restart_guidance": snapshot.restart_guidance,
|
||||||
"fetch_error": snapshot.fetch_error,
|
"fetch_error": snapshot.fetch_error,
|
||||||
}
|
}
|
||||||
|
|||||||
Reference in New Issue
Block a user