Merge pull request 'feat(scripts): כפתור "הרץ" מ-UI לסקריפטי read-only (קטגוריה B #4)' (#283) from worktree-scripts-run-ui into main
All checks were successful
Build & Deploy / build-and-deploy (push) Successful in 1m34s
G12 Leak-Guard / leak-guard (push) Successful in 4s
Lint — undefined names / undefined-names (push) Successful in 10s

This commit was merged in pull request #283.
This commit is contained in:
2026-06-17 04:31:19 +00:00
5 changed files with 315 additions and 25 deletions

View File

@@ -31,7 +31,7 @@ import asyncpg
import httpx
from legal_mcp import config
from legal_mcp.services import chunker, db, embeddings, extractor, git_sync, metrics as metrics_service, processor, proofreader, research_md, storage
from legal_mcp.services import chunker, db, embeddings, extractor, git_sync, metrics as metrics_service, processor, proofreader, research_md, script_runner, storage
from legal_mcp.tools import cases as cases_tools, search as search_tools, workflow as workflow_tools, drafting as drafting_tools, precedents as precedents_tools
from legal_mcp.tools.envelope import envelope_unwrap
@@ -1286,6 +1286,8 @@ async def get_scripts_catalog():
"bytes": stat.st_size,
"last_modified": stat.st_mtime,
"gitea_url": gitea_url,
# #4 — basenames the UI may offer a "הרץ" button for (read-only allowlist).
"runnable_scripts": script_runner.runnable_names(),
}
@@ -6966,6 +6968,36 @@ async def operations_agent_migrate_adapter(req: AdapterMigrationRequest):
return payload
@app.post("/api/scripts/{name}/run")
async def scripts_run(name: str):
"""Run a read-only allowlisted script via the court-fetch host bridge (#4).
Only scripts in ``script_runner.SCRIPT_RUN_ALLOWLIST`` are runnable; the host
bridge is the enforcer (allowlist + fixed read-only argv, no args from here).
The script's exit code + stdout/stderr are relayed verbatim for the dashboard.
We pre-check the allowlist here too (defense-in-depth, avoids a useless round
trip). Audit scripts can take a while, so the timeout is generous."""
if script_runner.build_argv(name) is None:
raise HTTPException(403, f"סקריפט אינו ניתן-להרצה מה-UI: {name}")
secret = os.environ.get("COURT_FETCH_SHARED_SECRET", "").strip()
headers = {"Authorization": f"Bearer {secret}"} if secret else {}
try:
async with httpx.AsyncClient(timeout=610.0) as client:
r = await client.post(
f"{_COURT_FETCH_SERVICE_URL}/run-script",
json={"name": name}, headers=headers,
)
except Exception as e: # host bridge down / unreachable
raise HTTPException(502, f"לא ניתן להגיע לשירות-המארח: {e}") from e
try:
payload = r.json()
except Exception:
payload = {"ok": False, "error": r.text[:300]}
if r.status_code >= 400:
raise HTTPException(r.status_code, payload.get("error", "script bridge failed"))
return payload
@app.get("/api/digests/{digest_id}")
async def digest_get(digest_id: str):
try: