Initialize repository snapshot
This commit is contained in:
935
scripts/console_receipts_server.py
Normal file
935
scripts/console_receipts_server.py
Normal file
@@ -0,0 +1,935 @@
|
||||
#!/usr/bin/env python3
|
||||
"""
|
||||
VaultMesh Console Receipts HTTP Bridge
|
||||
|
||||
A minimal FastAPI server that exposes the Console receipt emitter
|
||||
for the OpenCode plugin to call via HTTP.
|
||||
|
||||
Usage:
|
||||
python scripts/console_receipts_server.py
|
||||
|
||||
# Or with uvicorn directly:
|
||||
uvicorn scripts.console_receipts_server:app --host 127.0.0.1 --port 9110
|
||||
"""
|
||||
|
||||
import os
|
||||
import sys
|
||||
from pathlib import Path
|
||||
from typing import Any, Dict, List, Literal, Optional
|
||||
|
||||
# Add parent directory to path for imports
|
||||
SCRIPT_DIR = Path(__file__).parent.absolute()
|
||||
VAULTMESH_ROOT = SCRIPT_DIR.parent
|
||||
sys.path.insert(0, str(VAULTMESH_ROOT))
|
||||
|
||||
# Set environment variable for the emitter
|
||||
os.environ.setdefault("VAULTMESH_ROOT", str(VAULTMESH_ROOT))
|
||||
|
||||
from fastapi import FastAPI, HTTPException, Request
|
||||
from fastapi.middleware.cors import CORSMiddleware
|
||||
from pydantic import BaseModel
|
||||
|
||||
from dataclasses import asdict
|
||||
|
||||
from engines.console.receipts import (
|
||||
ConsoleReceiptEmitter,
|
||||
ReceiptType,
|
||||
emit_console_receipt,
|
||||
get_emitter,
|
||||
)
|
||||
from engines.console.approvals import get_approval_manager, ApprovalRequest
|
||||
|
||||
# ============================================================================
|
||||
# FastAPI App
|
||||
# ============================================================================
|
||||
|
||||
app = FastAPI(
|
||||
title="VaultMesh Console Receipts API",
|
||||
description="HTTP bridge for emitting Console receipts to the Civilization Ledger",
|
||||
version="0.1.0",
|
||||
)
|
||||
|
||||
# Allow CORS for local development
|
||||
app.add_middleware(
|
||||
CORSMiddleware,
|
||||
allow_origins=["*"],
|
||||
allow_credentials=True,
|
||||
allow_methods=["*"],
|
||||
allow_headers=["*"],
|
||||
)
|
||||
|
||||
|
||||
# ============================================================================
|
||||
# Request/Response Models
|
||||
# ============================================================================
|
||||
|
||||
class ReceiptIn(BaseModel):
|
||||
"""Request body for emitting a receipt."""
|
||||
type: ReceiptType
|
||||
session_id: Optional[str] = None
|
||||
payload: Dict[str, Any]
|
||||
|
||||
|
||||
class ReceiptOut(BaseModel):
|
||||
"""Response after emitting a receipt."""
|
||||
ok: bool
|
||||
record: Dict[str, Any]
|
||||
|
||||
|
||||
class RootInfo(BaseModel):
|
||||
"""Console scroll root information."""
|
||||
engine_id: str
|
||||
merkle_root: str
|
||||
events: int
|
||||
updated_at: Optional[str] = None
|
||||
|
||||
|
||||
class AnchorRequest(BaseModel):
|
||||
"""Request to trigger Guardian anchor."""
|
||||
scrolls: Optional[List[str]] = None
|
||||
|
||||
|
||||
class SearchRequest(BaseModel):
|
||||
"""Request to search receipts."""
|
||||
scroll: Optional[str] = "Console"
|
||||
receipt_type: Optional[str] = None
|
||||
limit: int = 50
|
||||
|
||||
|
||||
class ApprovalRequestIn(BaseModel):
|
||||
"""Request to create an approval."""
|
||||
session_id: str
|
||||
action_type: str
|
||||
action_details: Dict[str, Any]
|
||||
requested_by: str
|
||||
approvers: List[str]
|
||||
timeout_minutes: int = 60
|
||||
|
||||
|
||||
class ApprovalDecisionIn(BaseModel):
|
||||
"""Request to decide on an approval."""
|
||||
approved: bool
|
||||
approver: str
|
||||
reason: str = ""
|
||||
|
||||
|
||||
# ============================================================================
|
||||
# Endpoints
|
||||
# ============================================================================
|
||||
|
||||
@app.get("/health")
|
||||
async def health_check():
|
||||
"""Health check endpoint."""
|
||||
return {"status": "ok", "engine": "console"}
|
||||
|
||||
|
||||
@app.post("/v1/console/receipt", response_model=ReceiptOut)
|
||||
async def post_receipt(req: ReceiptIn):
|
||||
"""
|
||||
Emit a Console receipt.
|
||||
|
||||
This is the main endpoint called by the OpenCode plugin.
|
||||
"""
|
||||
try:
|
||||
record = emit_console_receipt(
|
||||
receipt_type=req.type,
|
||||
payload=req.payload,
|
||||
session_id=req.session_id,
|
||||
)
|
||||
return ReceiptOut(ok=True, record=record)
|
||||
except Exception as e:
|
||||
raise HTTPException(status_code=500, detail=str(e))
|
||||
|
||||
|
||||
@app.get("/v1/console/root", response_model=RootInfo)
|
||||
async def get_root():
|
||||
"""
|
||||
Get Console scroll Merkle root info.
|
||||
"""
|
||||
try:
|
||||
emitter = get_emitter()
|
||||
info = emitter.get_root_info()
|
||||
return RootInfo(**info)
|
||||
except Exception as e:
|
||||
raise HTTPException(status_code=500, detail=str(e))
|
||||
|
||||
|
||||
@app.post("/v1/guardian/anchor")
|
||||
async def trigger_anchor(req: AnchorRequest):
|
||||
"""
|
||||
Trigger a Guardian anchor cycle.
|
||||
|
||||
NOTE: This is a stub - in production, this would call the
|
||||
actual Guardian engine to anchor the specified scrolls.
|
||||
"""
|
||||
scrolls = req.scrolls or ["Console"]
|
||||
# TODO: Integrate with actual Guardian engine
|
||||
return {
|
||||
"ok": True,
|
||||
"message": f"Anchor requested for scrolls: {scrolls}",
|
||||
"note": "Stub implementation - Guardian integration pending",
|
||||
}
|
||||
|
||||
|
||||
@app.post("/v1/receipts/search")
|
||||
async def search_receipts(req: SearchRequest):
|
||||
"""
|
||||
Search Console receipts.
|
||||
|
||||
NOTE: This is a simple implementation that reads from JSONL.
|
||||
In production, use a proper index or database.
|
||||
"""
|
||||
import json
|
||||
from pathlib import Path
|
||||
|
||||
emitter = get_emitter()
|
||||
events_path = Path(emitter.events_path)
|
||||
|
||||
if not events_path.exists():
|
||||
return {"results": [], "total": 0}
|
||||
|
||||
results = []
|
||||
with open(events_path, "r", encoding="utf-8") as f:
|
||||
for line in f:
|
||||
if not line.strip():
|
||||
continue
|
||||
try:
|
||||
record = json.loads(line)
|
||||
# Filter by receipt type if specified
|
||||
if req.receipt_type and record.get("type") != req.receipt_type:
|
||||
continue
|
||||
results.append(record)
|
||||
except json.JSONDecodeError:
|
||||
continue
|
||||
|
||||
# Return most recent first, with limit
|
||||
results = list(reversed(results))[:req.limit]
|
||||
|
||||
return {
|
||||
"results": results,
|
||||
"total": len(results),
|
||||
"scroll": req.scroll,
|
||||
}
|
||||
|
||||
|
||||
@app.get("/v1/console/receipts")
|
||||
async def list_receipts(limit: int = 20, offset: int = 0):
|
||||
"""
|
||||
List Console receipts with pagination.
|
||||
"""
|
||||
import json
|
||||
from pathlib import Path
|
||||
|
||||
emitter = get_emitter()
|
||||
events_path = Path(emitter.events_path)
|
||||
|
||||
if not events_path.exists():
|
||||
return {"receipts": [], "total": 0, "limit": limit, "offset": offset}
|
||||
|
||||
all_receipts = []
|
||||
with open(events_path, "r", encoding="utf-8") as f:
|
||||
for line in f:
|
||||
if not line.strip():
|
||||
continue
|
||||
try:
|
||||
all_receipts.append(json.loads(line))
|
||||
except json.JSONDecodeError:
|
||||
continue
|
||||
|
||||
# Most recent first
|
||||
all_receipts = list(reversed(all_receipts))
|
||||
total = len(all_receipts)
|
||||
page = all_receipts[offset : offset + limit]
|
||||
|
||||
return {
|
||||
"receipts": page,
|
||||
"total": total,
|
||||
"limit": limit,
|
||||
"offset": offset,
|
||||
}
|
||||
|
||||
|
||||
# ============================================================================
|
||||
# Session Query Endpoints
|
||||
# ============================================================================
|
||||
|
||||
@app.get("/v1/console/sessions")
|
||||
async def list_sessions(status: str = "all", limit: int = 20):
|
||||
"""List Console sessions."""
|
||||
import json
|
||||
|
||||
emitter = get_emitter()
|
||||
events_path = Path(emitter.events_path)
|
||||
|
||||
if not events_path.exists():
|
||||
return {"sessions": [], "total": 0}
|
||||
|
||||
sessions: Dict[str, Dict[str, Any]] = {}
|
||||
|
||||
for line in events_path.read_text(encoding="utf-8").splitlines():
|
||||
if not line.strip():
|
||||
continue
|
||||
try:
|
||||
r = json.loads(line)
|
||||
except Exception:
|
||||
continue
|
||||
|
||||
sid = r.get("session_id")
|
||||
if not sid:
|
||||
continue
|
||||
|
||||
if sid not in sessions:
|
||||
sessions[sid] = {
|
||||
"session_id": sid,
|
||||
"status": "active",
|
||||
"started_at": None,
|
||||
"ended_at": None,
|
||||
"events": 0,
|
||||
}
|
||||
|
||||
sessions[sid]["events"] += 1
|
||||
r_type = r.get("type")
|
||||
|
||||
if r_type == "console_session_start":
|
||||
sessions[sid]["started_at"] = r["ts"]
|
||||
elif r_type == "console_session_end":
|
||||
sessions[sid]["ended_at"] = r["ts"]
|
||||
sessions[sid]["status"] = "ended"
|
||||
|
||||
results = list(sessions.values())
|
||||
if status == "active":
|
||||
results = [s for s in results if s["status"] == "active"]
|
||||
elif status == "ended":
|
||||
results = [s for s in results if s["status"] == "ended"]
|
||||
|
||||
return {"sessions": results[-limit:], "total": len(results)}
|
||||
|
||||
|
||||
@app.get("/v1/console/sessions/{session_id}")
|
||||
async def get_session(session_id: str):
|
||||
"""Get detailed session status."""
|
||||
import json
|
||||
|
||||
emitter = get_emitter()
|
||||
events_path = Path(emitter.events_path)
|
||||
|
||||
if not events_path.exists():
|
||||
raise HTTPException(status_code=404, detail="Session not found")
|
||||
|
||||
session: Optional[Dict[str, Any]] = None
|
||||
|
||||
for line in events_path.read_text(encoding="utf-8").splitlines():
|
||||
if not line.strip():
|
||||
continue
|
||||
try:
|
||||
r = json.loads(line)
|
||||
except Exception:
|
||||
continue
|
||||
|
||||
if r.get("session_id") != session_id:
|
||||
continue
|
||||
|
||||
r_type = r.get("type")
|
||||
payload = r.get("payload") or {}
|
||||
|
||||
if r_type == "console_session_start":
|
||||
session = {
|
||||
"session_id": session_id,
|
||||
"status": "active",
|
||||
"started_at": r["ts"],
|
||||
"agent_type": payload.get("agent_type"),
|
||||
"model_id": payload.get("model_id"),
|
||||
"caller": payload.get("caller"),
|
||||
}
|
||||
elif r_type == "console_session_end" and session is not None:
|
||||
session["status"] = "ended"
|
||||
session["ended_at"] = r["ts"]
|
||||
|
||||
if session is None:
|
||||
raise HTTPException(status_code=404, detail="Session not found")
|
||||
|
||||
return session
|
||||
|
||||
|
||||
# ============================================================================
|
||||
# Approval Endpoints
|
||||
# ============================================================================
|
||||
|
||||
@app.post("/v1/console/approvals/request")
|
||||
async def request_approval(req: ApprovalRequestIn):
|
||||
"""Request approval for an action."""
|
||||
manager = get_approval_manager()
|
||||
request = manager.request_approval(
|
||||
session_id=req.session_id,
|
||||
action_type=req.action_type,
|
||||
action_details=req.action_details,
|
||||
requested_by=req.requested_by,
|
||||
approvers=req.approvers,
|
||||
timeout_minutes=req.timeout_minutes,
|
||||
)
|
||||
return {"ok": True, "approval_id": request.approval_id}
|
||||
|
||||
|
||||
@app.get("/v1/console/approvals/pending")
|
||||
async def list_pending_approvals(session_id: Optional[str] = None):
|
||||
"""List pending approval requests."""
|
||||
manager = get_approval_manager()
|
||||
pending = manager.list_pending(session_id)
|
||||
return {"pending": [asdict(r) for r in pending]}
|
||||
|
||||
|
||||
@app.post("/v1/console/approvals/{approval_id}/decide")
|
||||
async def decide_approval(approval_id: str, req: ApprovalDecisionIn):
|
||||
"""Approve or reject a pending action."""
|
||||
manager = get_approval_manager()
|
||||
try:
|
||||
success = manager.decide(
|
||||
approval_id=approval_id,
|
||||
approved=req.approved,
|
||||
approver=req.approver,
|
||||
reason=req.reason,
|
||||
)
|
||||
return {
|
||||
"ok": success,
|
||||
"decision": "approved" if req.approved else "rejected",
|
||||
}
|
||||
except PermissionError as e:
|
||||
raise HTTPException(status_code=403, detail=str(e))
|
||||
except KeyError:
|
||||
raise HTTPException(status_code=404, detail="Approval not found")
|
||||
|
||||
|
||||
# ============================================================================
|
||||
# GitLab Webhook Handler
|
||||
# ============================================================================
|
||||
|
||||
@app.post("/gitlab/webhook")
|
||||
async def gitlab_webhook(request: Request):
|
||||
"""
|
||||
Handle GitLab webhook events and emit Console receipts.
|
||||
|
||||
Supports:
|
||||
- Push events → console_gitlab_push
|
||||
- Merge request events → console_gitlab_mr
|
||||
- Pipeline events → console_gitlab_pipeline
|
||||
|
||||
Configure in GitLab: Settings → Webhooks
|
||||
"""
|
||||
event_type = request.headers.get("X-Gitlab-Event", "unknown")
|
||||
|
||||
try:
|
||||
body = await request.json()
|
||||
except Exception:
|
||||
raise HTTPException(status_code=400, detail="Invalid JSON body")
|
||||
|
||||
emitter = get_emitter()
|
||||
|
||||
if event_type == "Push Hook":
|
||||
# Push event
|
||||
project = body.get("project", {})
|
||||
commits = body.get("commits", [])
|
||||
session_id = f"gitlab-push-{body.get('checkout_sha', 'unknown')[:12]}"
|
||||
|
||||
emit_console_receipt(
|
||||
"console_command", # Reuse console_command for push events
|
||||
{
|
||||
"command": "git_push",
|
||||
"args_hash": body.get("checkout_sha", ""),
|
||||
"exit_code": 0,
|
||||
"duration_ms": 0,
|
||||
"gitlab_event": "push",
|
||||
"project_path": project.get("path_with_namespace", ""),
|
||||
"ref": body.get("ref", ""),
|
||||
"commits_count": len(commits),
|
||||
"user": body.get("user_name", ""),
|
||||
},
|
||||
session_id=session_id,
|
||||
)
|
||||
|
||||
return {"ok": True, "event": "push", "session_id": session_id}
|
||||
|
||||
elif event_type == "Merge Request Hook":
|
||||
# Merge request event
|
||||
mr = body.get("object_attributes", {})
|
||||
project = body.get("project", {})
|
||||
session_id = f"gitlab-mr-{mr.get('iid', 'unknown')}"
|
||||
|
||||
action = mr.get("action", "update") # open, close, merge, update, etc.
|
||||
|
||||
emit_console_receipt(
|
||||
"console_command",
|
||||
{
|
||||
"command": f"mr_{action}",
|
||||
"args_hash": mr.get("last_commit", {}).get("id", ""),
|
||||
"exit_code": 0,
|
||||
"duration_ms": 0,
|
||||
"gitlab_event": "merge_request",
|
||||
"project_path": project.get("path_with_namespace", ""),
|
||||
"mr_iid": mr.get("iid"),
|
||||
"mr_title": mr.get("title", ""),
|
||||
"mr_state": mr.get("state", ""),
|
||||
"source_branch": mr.get("source_branch", ""),
|
||||
"target_branch": mr.get("target_branch", ""),
|
||||
"user": body.get("user", {}).get("name", ""),
|
||||
},
|
||||
session_id=session_id,
|
||||
)
|
||||
|
||||
return {"ok": True, "event": "merge_request", "action": action, "session_id": session_id}
|
||||
|
||||
elif event_type == "Pipeline Hook":
|
||||
# Pipeline event
|
||||
pipeline = body.get("object_attributes", {})
|
||||
project = body.get("project", {})
|
||||
session_id = f"gitlab-pipeline-{pipeline.get('id', 'unknown')}"
|
||||
|
||||
status = pipeline.get("status", "unknown")
|
||||
|
||||
# Only emit for significant status changes
|
||||
if status in ("running", "success", "failed", "canceled"):
|
||||
receipt_type = "console_session_start" if status == "running" else "console_session_end"
|
||||
|
||||
if status == "running":
|
||||
emit_console_receipt(
|
||||
"console_session_start",
|
||||
{
|
||||
"agent_type": "gitlab-ci",
|
||||
"model_id": "none",
|
||||
"caller": "did:vm:service:gitlab-webhook",
|
||||
"project_path": project.get("path_with_namespace", ""),
|
||||
"pipeline_id": pipeline.get("id"),
|
||||
"ref": pipeline.get("ref", ""),
|
||||
"commit": pipeline.get("sha", ""),
|
||||
},
|
||||
session_id=session_id,
|
||||
)
|
||||
else:
|
||||
emit_console_receipt(
|
||||
"console_session_end",
|
||||
{
|
||||
"duration_ms": pipeline.get("duration", 0) * 1000 if pipeline.get("duration") else 0,
|
||||
"commands_executed": 0,
|
||||
"files_modified": 0,
|
||||
"exit_reason": f"pipeline-{status}",
|
||||
},
|
||||
session_id=session_id,
|
||||
)
|
||||
|
||||
return {"ok": True, "event": "pipeline", "status": status, "session_id": session_id}
|
||||
|
||||
else:
|
||||
# Unknown event type - log but don't fail
|
||||
return {"ok": True, "event": event_type, "note": "Unhandled event type"}
|
||||
|
||||
|
||||
# ============================================================================
|
||||
# HTML Dashboard
|
||||
# ============================================================================
|
||||
|
||||
from fastapi.responses import HTMLResponse
|
||||
|
||||
def format_time_ago(ts_str: str) -> str:
|
||||
"""Format timestamp as relative time."""
|
||||
from datetime import datetime, timezone
|
||||
try:
|
||||
ts = datetime.fromisoformat(ts_str.replace("Z", "+00:00"))
|
||||
now = datetime.now(timezone.utc)
|
||||
diff = now - ts
|
||||
seconds = diff.total_seconds()
|
||||
if seconds < 60:
|
||||
return f"{int(seconds)}s ago"
|
||||
elif seconds < 3600:
|
||||
return f"{int(seconds/60)}m ago"
|
||||
elif seconds < 86400:
|
||||
return f"{int(seconds/3600)}h ago"
|
||||
else:
|
||||
return f"{int(seconds/86400)}d ago"
|
||||
except:
|
||||
return ts_str[:19]
|
||||
|
||||
|
||||
def get_event_color(event_type: str) -> str:
|
||||
"""Get color for event type."""
|
||||
colors = {
|
||||
"console_session_start": "#22c55e", # green
|
||||
"console_session_end": "#6b7280", # gray
|
||||
"console_command": "#3b82f6", # blue
|
||||
"console_file_edit": "#a855f7", # purple
|
||||
"console_tool_call": "#06b6d4", # cyan
|
||||
"console_approval_request": "#f59e0b", # amber
|
||||
"console_approval": "#10b981", # emerald
|
||||
"console_git_commit": "#ec4899", # pink
|
||||
}
|
||||
return colors.get(event_type, "#9ca3af")
|
||||
|
||||
|
||||
def get_event_icon(event_type: str) -> str:
|
||||
"""Get icon for event type."""
|
||||
icons = {
|
||||
"console_session_start": "▶",
|
||||
"console_session_end": "■",
|
||||
"console_command": "⌘",
|
||||
"console_file_edit": "✎",
|
||||
"console_tool_call": "⚡",
|
||||
"console_approval_request": "⏳",
|
||||
"console_approval": "✓",
|
||||
"console_git_commit": "⬆",
|
||||
}
|
||||
return icons.get(event_type, "•")
|
||||
|
||||
|
||||
@app.get("/console/dashboard", response_class=HTMLResponse)
|
||||
async def console_dashboard():
|
||||
"""
|
||||
HTML dashboard showing Console status at a glance.
|
||||
|
||||
Shows:
|
||||
- Active and recent sessions
|
||||
- Pending approvals
|
||||
- Recent events stream
|
||||
"""
|
||||
import json
|
||||
from datetime import datetime, timezone
|
||||
|
||||
emitter = get_emitter()
|
||||
events_path = Path(emitter.events_path)
|
||||
|
||||
# Collect data
|
||||
sessions: Dict[str, Dict[str, Any]] = {}
|
||||
all_events: List[Dict[str, Any]] = []
|
||||
|
||||
if events_path.exists():
|
||||
for line in events_path.read_text(encoding="utf-8").splitlines():
|
||||
if not line.strip():
|
||||
continue
|
||||
try:
|
||||
r = json.loads(line)
|
||||
all_events.append(r)
|
||||
|
||||
sid = r.get("session_id")
|
||||
if sid:
|
||||
if sid not in sessions:
|
||||
sessions[sid] = {
|
||||
"session_id": sid,
|
||||
"status": "active",
|
||||
"started_at": None,
|
||||
"ended_at": None,
|
||||
"events": 0,
|
||||
"agent_type": None,
|
||||
"caller": None,
|
||||
}
|
||||
sessions[sid]["events"] += 1
|
||||
|
||||
if r.get("type") == "console_session_start":
|
||||
sessions[sid]["started_at"] = r.get("ts")
|
||||
payload = r.get("payload", {})
|
||||
sessions[sid]["agent_type"] = payload.get("agent_type")
|
||||
sessions[sid]["caller"] = payload.get("caller")
|
||||
elif r.get("type") == "console_session_end":
|
||||
sessions[sid]["ended_at"] = r.get("ts")
|
||||
sessions[sid]["status"] = "ended"
|
||||
except:
|
||||
continue
|
||||
|
||||
# Get pending approvals
|
||||
manager = get_approval_manager()
|
||||
pending = manager.list_pending()
|
||||
|
||||
# Get root info
|
||||
root_info = emitter.get_root_info()
|
||||
|
||||
# Sort sessions by most recent activity
|
||||
session_list = sorted(
|
||||
sessions.values(),
|
||||
key=lambda s: s.get("started_at") or "",
|
||||
reverse=True
|
||||
)[:20]
|
||||
|
||||
# Recent events (last 30)
|
||||
recent_events = list(reversed(all_events[-30:]))
|
||||
|
||||
# Build HTML
|
||||
html = f"""<!DOCTYPE html>
|
||||
<html lang="en">
|
||||
<head>
|
||||
<meta charset="UTF-8">
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1.0">
|
||||
<meta http-equiv="refresh" content="10">
|
||||
<title>VaultMesh Console Dashboard</title>
|
||||
<style>
|
||||
* {{ box-sizing: border-box; margin: 0; padding: 0; }}
|
||||
body {{
|
||||
font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', Roboto, monospace;
|
||||
background: #0f172a;
|
||||
color: #e2e8f0;
|
||||
padding: 1rem;
|
||||
min-height: 100vh;
|
||||
}}
|
||||
.header {{
|
||||
display: flex;
|
||||
justify-content: space-between;
|
||||
align-items: center;
|
||||
margin-bottom: 1.5rem;
|
||||
padding-bottom: 1rem;
|
||||
border-bottom: 1px solid #334155;
|
||||
}}
|
||||
.header h1 {{
|
||||
font-size: 1.5rem;
|
||||
font-weight: 600;
|
||||
color: #f8fafc;
|
||||
}}
|
||||
.header h1 span {{
|
||||
color: #22c55e;
|
||||
}}
|
||||
.stats {{
|
||||
display: flex;
|
||||
gap: 2rem;
|
||||
font-size: 0.875rem;
|
||||
color: #94a3b8;
|
||||
}}
|
||||
.stats strong {{
|
||||
color: #f8fafc;
|
||||
}}
|
||||
.grid {{
|
||||
display: grid;
|
||||
grid-template-columns: 1fr 1fr;
|
||||
gap: 1.5rem;
|
||||
}}
|
||||
@media (max-width: 900px) {{
|
||||
.grid {{ grid-template-columns: 1fr; }}
|
||||
}}
|
||||
.panel {{
|
||||
background: #1e293b;
|
||||
border-radius: 0.5rem;
|
||||
padding: 1rem;
|
||||
border: 1px solid #334155;
|
||||
}}
|
||||
.panel h2 {{
|
||||
font-size: 0.875rem;
|
||||
font-weight: 600;
|
||||
text-transform: uppercase;
|
||||
letter-spacing: 0.05em;
|
||||
color: #94a3b8;
|
||||
margin-bottom: 1rem;
|
||||
}}
|
||||
.session {{
|
||||
padding: 0.75rem;
|
||||
margin-bottom: 0.5rem;
|
||||
background: #0f172a;
|
||||
border-radius: 0.375rem;
|
||||
border-left: 3px solid;
|
||||
}}
|
||||
.session.active {{ border-color: #22c55e; }}
|
||||
.session.ended {{ border-color: #6b7280; }}
|
||||
.session-id {{
|
||||
font-family: monospace;
|
||||
font-size: 0.875rem;
|
||||
color: #f8fafc;
|
||||
}}
|
||||
.session-meta {{
|
||||
font-size: 0.75rem;
|
||||
color: #64748b;
|
||||
margin-top: 0.25rem;
|
||||
}}
|
||||
.approval {{
|
||||
padding: 0.75rem;
|
||||
margin-bottom: 0.5rem;
|
||||
background: #451a03;
|
||||
border-radius: 0.375rem;
|
||||
border-left: 3px solid #f59e0b;
|
||||
}}
|
||||
.approval-id {{
|
||||
font-family: monospace;
|
||||
font-size: 0.75rem;
|
||||
color: #fbbf24;
|
||||
}}
|
||||
.approval-action {{
|
||||
font-size: 0.875rem;
|
||||
color: #f8fafc;
|
||||
margin-top: 0.25rem;
|
||||
}}
|
||||
.approval-cmd {{
|
||||
font-family: monospace;
|
||||
font-size: 0.75rem;
|
||||
color: #94a3b8;
|
||||
background: #0f172a;
|
||||
padding: 0.5rem;
|
||||
margin-top: 0.5rem;
|
||||
border-radius: 0.25rem;
|
||||
word-break: break-all;
|
||||
}}
|
||||
.event {{
|
||||
display: flex;
|
||||
gap: 0.75rem;
|
||||
padding: 0.5rem 0;
|
||||
border-bottom: 1px solid #1e293b;
|
||||
font-size: 0.8125rem;
|
||||
}}
|
||||
.event:last-child {{ border-bottom: none; }}
|
||||
.event-icon {{
|
||||
width: 1.5rem;
|
||||
text-align: center;
|
||||
flex-shrink: 0;
|
||||
}}
|
||||
.event-time {{
|
||||
width: 4rem;
|
||||
flex-shrink: 0;
|
||||
color: #64748b;
|
||||
font-size: 0.75rem;
|
||||
}}
|
||||
.event-type {{
|
||||
font-family: monospace;
|
||||
font-size: 0.75rem;
|
||||
padding: 0.125rem 0.375rem;
|
||||
border-radius: 0.25rem;
|
||||
flex-shrink: 0;
|
||||
}}
|
||||
.event-detail {{
|
||||
color: #94a3b8;
|
||||
flex: 1;
|
||||
overflow: hidden;
|
||||
text-overflow: ellipsis;
|
||||
white-space: nowrap;
|
||||
}}
|
||||
.no-data {{
|
||||
color: #64748b;
|
||||
font-style: italic;
|
||||
padding: 1rem;
|
||||
text-align: center;
|
||||
}}
|
||||
.full-width {{ grid-column: 1 / -1; }}
|
||||
</style>
|
||||
</head>
|
||||
<body>
|
||||
<div class="header">
|
||||
<h1>🜂 <span>VaultMesh</span> Console Dashboard</h1>
|
||||
<div class="stats">
|
||||
<span>Sessions: <strong>{len(sessions)}</strong></span>
|
||||
<span>Events: <strong>{root_info.get('events', 0)}</strong></span>
|
||||
<span>Pending: <strong>{len(pending)}</strong></span>
|
||||
<span>Root: <strong>{root_info.get('merkle_root', '0')[:12]}...</strong></span>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="grid">
|
||||
<div class="panel">
|
||||
<h2>⏳ Pending Approvals ({len(pending)})</h2>
|
||||
"""
|
||||
|
||||
if pending:
|
||||
for p in pending:
|
||||
html += f"""
|
||||
<div class="approval">
|
||||
<div class="approval-id">{p.approval_id}</div>
|
||||
<div class="approval-action">{p.action_type}</div>
|
||||
<div class="session-meta">Session: {p.session_id}</div>
|
||||
<div class="approval-cmd">vm console approve {p.approval_id} --reason "..."</div>
|
||||
</div>
|
||||
"""
|
||||
else:
|
||||
html += '<div class="no-data">No pending approvals</div>'
|
||||
|
||||
html += """
|
||||
</div>
|
||||
|
||||
<div class="panel">
|
||||
<h2>📡 Sessions</h2>
|
||||
"""
|
||||
|
||||
if session_list:
|
||||
for s in session_list[:10]:
|
||||
status_class = "active" if s["status"] == "active" else "ended"
|
||||
agent = s.get("agent_type") or "unknown"
|
||||
started = format_time_ago(s["started_at"]) if s.get("started_at") else "?"
|
||||
html += f"""
|
||||
<div class="session {status_class}">
|
||||
<div class="session-id">{s['session_id']}</div>
|
||||
<div class="session-meta">{agent} • {s['events']} events • {started}</div>
|
||||
</div>
|
||||
"""
|
||||
else:
|
||||
html += '<div class="no-data">No sessions yet</div>'
|
||||
|
||||
html += """
|
||||
</div>
|
||||
|
||||
<div class="panel full-width">
|
||||
<h2>📜 Recent Events</h2>
|
||||
"""
|
||||
|
||||
if recent_events:
|
||||
for e in recent_events:
|
||||
etype = e.get("type", "unknown")
|
||||
ts = format_time_ago(e.get("ts", ""))
|
||||
color = get_event_color(etype)
|
||||
icon = get_event_icon(etype)
|
||||
payload = e.get("payload", {})
|
||||
|
||||
# Build detail string based on event type
|
||||
detail = ""
|
||||
if etype == "console_command":
|
||||
detail = payload.get("command", "")
|
||||
elif etype == "console_file_edit":
|
||||
detail = payload.get("file_path", "")
|
||||
elif etype == "console_approval_request":
|
||||
detail = f"{payload.get('action_type', '')} → {payload.get('approval_id', '')}"
|
||||
elif etype == "console_approval":
|
||||
approved = "✓" if payload.get("approved") else "✗"
|
||||
detail = f"{approved} {payload.get('action_type', '')} by {payload.get('approver', '')}"
|
||||
elif etype == "console_session_start":
|
||||
detail = f"{payload.get('agent_type', '')} by {payload.get('caller', '')}"
|
||||
elif etype == "console_session_end":
|
||||
detail = payload.get("exit_reason", "")
|
||||
|
||||
short_type = etype.replace("console_", "")
|
||||
|
||||
html += f"""
|
||||
<div class="event">
|
||||
<div class="event-icon" style="color: {color}">{icon}</div>
|
||||
<div class="event-time">{ts}</div>
|
||||
<div class="event-type" style="background: {color}20; color: {color}">{short_type}</div>
|
||||
<div class="event-detail">{detail}</div>
|
||||
</div>
|
||||
"""
|
||||
else:
|
||||
html += '<div class="no-data">No events yet</div>'
|
||||
|
||||
html += """
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<script>
|
||||
// Auto-refresh indicator
|
||||
setTimeout(() => {
|
||||
document.querySelector('.header h1').innerHTML = '🜂 <span>VaultMesh</span> Console Dashboard <small style="color:#64748b;font-size:0.75rem">refreshing...</small>';
|
||||
}, 9000);
|
||||
</script>
|
||||
</body>
|
||||
</html>
|
||||
"""
|
||||
|
||||
return html
|
||||
|
||||
|
||||
# ============================================================================
|
||||
# Main
|
||||
# ============================================================================
|
||||
|
||||
def main():
|
||||
"""Run the server."""
|
||||
import uvicorn
|
||||
|
||||
print(f"[VaultMesh] Console Receipts Server")
|
||||
print(f"[VaultMesh] Root: {VAULTMESH_ROOT}")
|
||||
print(f"[VaultMesh] Listening on http://127.0.0.1:9110")
|
||||
|
||||
uvicorn.run(
|
||||
app,
|
||||
host="127.0.0.1",
|
||||
port=9110,
|
||||
log_level="info",
|
||||
)
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
main()
|
||||
190
scripts/gitlab_console_session.sh
Executable file
190
scripts/gitlab_console_session.sh
Executable file
@@ -0,0 +1,190 @@
|
||||
#!/usr/bin/env bash
|
||||
# ============================================================================
|
||||
# VaultMesh GitLab Console Session Helper
|
||||
#
|
||||
# Wires GitLab CI pipelines into the Console engine as sessions.
|
||||
# Each pipeline becomes a Console session with start/end receipts.
|
||||
#
|
||||
# Usage:
|
||||
# ./scripts/gitlab_console_session.sh start
|
||||
# ./scripts/gitlab_console_session.sh end
|
||||
# ./scripts/gitlab_console_session.sh cmd <name> <exit_code>
|
||||
# ./scripts/gitlab_console_session.sh request_approval <action_type>
|
||||
#
|
||||
# Environment Variables (set in GitLab CI/CD Settings → Variables):
|
||||
# VAULTMESH_CONSOLE_BASE - Console HTTP bridge URL
|
||||
# VAULTMESH_CALLER_DID - DID for GitLab CI service
|
||||
# VAULTMESH_APPROVER_DID - Default approver DID
|
||||
# VM_ENV - Environment: dev, staging, or prod
|
||||
#
|
||||
# GitLab CI Variables (automatic):
|
||||
# CI_PIPELINE_ID, CI_PROJECT_PATH, CI_COMMIT_SHA, CI_JOB_STATUS
|
||||
# ============================================================================
|
||||
|
||||
set -euo pipefail
|
||||
|
||||
# Defaults
|
||||
BASE="${VAULTMESH_CONSOLE_BASE:-http://127.0.0.1:9110/v1/console}"
|
||||
ENDPOINT="$BASE/receipt"
|
||||
CALLER="${VAULTMESH_CALLER_DID:-did:vm:service:gitlab-ci}"
|
||||
APPROVER="${VAULTMESH_APPROVER_DID:-did:vm:human:karol}"
|
||||
SESSION_ID="gitlab-pipeline-${CI_PIPELINE_ID:-unknown}"
|
||||
PROJECT="${CI_PROJECT_PATH:-unknown}"
|
||||
COMMIT="${CI_COMMIT_SHA:-unknown}"
|
||||
ENV="${VM_ENV:-prod}" # Default to prod (most restrictive)
|
||||
|
||||
# Emit a receipt to Console
|
||||
emit() {
|
||||
local type="$1"
|
||||
local payload="$2"
|
||||
|
||||
curl -sS -X POST "$ENDPOINT" \
|
||||
-H 'Content-Type: application/json' \
|
||||
-d "{
|
||||
\"type\":\"$type\",
|
||||
\"session_id\":\"$SESSION_ID\",
|
||||
\"payload\":$payload
|
||||
}" >/dev/null || echo "[VaultMesh] Warning: Failed to emit $type receipt"
|
||||
}
|
||||
|
||||
# Request approval and return approval_id
|
||||
request_approval() {
|
||||
local action_type="$1"
|
||||
local details="${2:-{}}"
|
||||
|
||||
local resp
|
||||
resp=$(curl -sS -X POST "$BASE/approvals/request" \
|
||||
-H 'Content-Type: application/json' \
|
||||
-d "{
|
||||
\"session_id\":\"$SESSION_ID\",
|
||||
\"action_type\":\"$action_type\",
|
||||
\"action_details\":$details,
|
||||
\"requested_by\":\"$CALLER\",
|
||||
\"approvers\":[\"$APPROVER\"],
|
||||
\"timeout_minutes\": 120
|
||||
}")
|
||||
|
||||
echo "$resp"
|
||||
}
|
||||
|
||||
# Check if approval is still pending
|
||||
check_pending() {
|
||||
local approval_id="$1"
|
||||
|
||||
local resp
|
||||
resp=$(curl -sS "$BASE/approvals/pending?session_id=$SESSION_ID")
|
||||
|
||||
# Check if approval_id is in pending list
|
||||
if echo "$resp" | jq -e ".pending[] | select(.approval_id == \"$approval_id\")" >/dev/null 2>&1; then
|
||||
echo "pending"
|
||||
else
|
||||
echo "decided"
|
||||
fi
|
||||
}
|
||||
|
||||
# Main dispatch
|
||||
case "${1:-}" in
|
||||
start)
|
||||
echo "[VaultMesh] Starting Console session: $SESSION_ID (env: $ENV)"
|
||||
emit "console_session_start" "{
|
||||
\"agent_type\":\"gitlab-ci\",
|
||||
\"model_id\":\"none\",
|
||||
\"caller\":\"$CALLER\",
|
||||
\"project_path\":\"$PROJECT\",
|
||||
\"pipeline_id\":\"${CI_PIPELINE_ID:-unknown}\",
|
||||
\"commit\":\"$COMMIT\",
|
||||
\"env\":\"$ENV\"
|
||||
}"
|
||||
echo "[VaultMesh] Session started"
|
||||
;;
|
||||
|
||||
end)
|
||||
STATUS="${CI_JOB_STATUS:-unknown}"
|
||||
echo "[VaultMesh] Ending Console session: $SESSION_ID (status: $STATUS)"
|
||||
emit "console_session_end" "{
|
||||
\"duration_ms\":0,
|
||||
\"commands_executed\":0,
|
||||
\"files_modified\":0,
|
||||
\"exit_reason\":\"pipeline-$STATUS\"
|
||||
}"
|
||||
echo "[VaultMesh] Session ended"
|
||||
;;
|
||||
|
||||
cmd)
|
||||
CMD_NAME="${2:-unknown}"
|
||||
EXIT_CODE="${3:-0}"
|
||||
echo "[VaultMesh] Recording command: $CMD_NAME (exit: $EXIT_CODE)"
|
||||
emit "console_command" "{
|
||||
\"command\":\"$CMD_NAME\",
|
||||
\"args_hash\":\"$COMMIT\",
|
||||
\"exit_code\":$EXIT_CODE,
|
||||
\"duration_ms\":0
|
||||
}"
|
||||
;;
|
||||
|
||||
request_approval)
|
||||
ACTION_TYPE="${2:-deploy}"
|
||||
DETAILS="${3:-{\"env\":\"$ENV\",\"commit\":\"$COMMIT\",\"pipeline_id\":\"${CI_PIPELINE_ID:-unknown}\"}}"
|
||||
|
||||
echo "[VaultMesh] Requesting approval for: $ACTION_TYPE (env: $ENV)"
|
||||
RESP=$(request_approval "$ACTION_TYPE" "$DETAILS")
|
||||
APPROVAL_ID=$(echo "$RESP" | jq -r '.approval_id')
|
||||
|
||||
echo "[VaultMesh] Approval requested: $APPROVAL_ID"
|
||||
echo ""
|
||||
echo "============================================================"
|
||||
echo "ACTION REQUIRES APPROVAL"
|
||||
echo "============================================================"
|
||||
echo ""
|
||||
echo "Approval ID: $APPROVAL_ID"
|
||||
echo "Action Type: $ACTION_TYPE"
|
||||
echo ""
|
||||
echo "To approve, run on VaultMesh host:"
|
||||
echo ""
|
||||
echo " export VAULTMESH_ACTOR_DID=\"$APPROVER\""
|
||||
echo " vm console approve $APPROVAL_ID --reason \"Approved from GitLab\""
|
||||
echo ""
|
||||
echo "============================================================"
|
||||
|
||||
# Exit 1 to fail the job (approval required)
|
||||
exit 1
|
||||
;;
|
||||
|
||||
check_approval)
|
||||
APPROVAL_ID="${2:-}"
|
||||
if [ -z "$APPROVAL_ID" ]; then
|
||||
echo "Usage: $0 check_approval <approval_id>" >&2
|
||||
exit 1
|
||||
fi
|
||||
|
||||
STATUS=$(check_pending "$APPROVAL_ID")
|
||||
echo "[VaultMesh] Approval $APPROVAL_ID status: $STATUS"
|
||||
|
||||
if [ "$STATUS" = "pending" ]; then
|
||||
echo "Approval still pending. Cannot proceed."
|
||||
exit 1
|
||||
else
|
||||
echo "Approval decided. Proceeding."
|
||||
exit 0
|
||||
fi
|
||||
;;
|
||||
|
||||
*)
|
||||
echo "VaultMesh GitLab Console Session Helper"
|
||||
echo ""
|
||||
echo "Usage: $0 {start|end|cmd|request_approval|check_approval}"
|
||||
echo ""
|
||||
echo "Commands:"
|
||||
echo " start - Emit console_session_start"
|
||||
echo " end - Emit console_session_end"
|
||||
echo " cmd <name> <exit_code> - Emit console_command"
|
||||
echo " request_approval <type> - Request approval and exit 1"
|
||||
echo " check_approval <id> - Check if approval decided"
|
||||
echo ""
|
||||
echo "Environment:"
|
||||
echo " VAULTMESH_CONSOLE_BASE - Console HTTP bridge URL"
|
||||
echo " VAULTMESH_CALLER_DID - DID for GitLab CI service"
|
||||
echo " VAULTMESH_APPROVER_DID - Default approver DID"
|
||||
exit 1
|
||||
;;
|
||||
esac
|
||||
123
scripts/offsec_node_client.py
Normal file
123
scripts/offsec_node_client.py
Normal file
@@ -0,0 +1,123 @@
|
||||
"""
|
||||
Thin client for talking to the OffSec Shield Node (offsec-agents MCP backend).
|
||||
|
||||
Usage:
|
||||
|
||||
from scripts.offsec_node_client import OffsecNodeClient
|
||||
|
||||
client = OffsecNodeClient() # uses OFFSEC_NODE_URL env
|
||||
agents = await client.command("agents list")
|
||||
status = await client.command("tem status")
|
||||
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import asyncio
|
||||
import json
|
||||
import os
|
||||
from dataclasses import dataclass
|
||||
from typing import Any, Dict, Optional
|
||||
|
||||
import aiohttp
|
||||
|
||||
|
||||
DEFAULT_OFFSEC_NODE_URL = "http://shield-vm:8081"
|
||||
|
||||
|
||||
@dataclass
|
||||
class OffsecNodeError(Exception):
|
||||
message: str
|
||||
status: Optional[int] = None
|
||||
details: Optional[Dict[str, Any]] = None
|
||||
|
||||
def __str__(self) -> str:
|
||||
base = self.message
|
||||
if self.status is not None:
|
||||
base += f" (status={self.status})"
|
||||
if self.details:
|
||||
base += f" details={self.details}"
|
||||
return base
|
||||
|
||||
|
||||
@dataclass
|
||||
class OffsecNodeClient:
|
||||
base_url: str = DEFAULT_OFFSEC_NODE_URL
|
||||
timeout_seconds: int = 10
|
||||
|
||||
@classmethod
|
||||
def from_env(cls) -> "OffsecNodeClient":
|
||||
url = os.getenv("OFFSEC_NODE_URL", DEFAULT_OFFSEC_NODE_URL)
|
||||
return cls(base_url=url)
|
||||
|
||||
async def health(self) -> Dict[str, Any]:
|
||||
url = f"{self.base_url.rstrip('/')}/health"
|
||||
async with aiohttp.ClientSession(timeout=aiohttp.ClientTimeout(total=self.timeout_seconds)) as session:
|
||||
async with session.get(url) as resp:
|
||||
if resp.status != 200:
|
||||
raise OffsecNodeError("Shield node health check failed", status=resp.status)
|
||||
return await resp.json()
|
||||
|
||||
async def command(
|
||||
self,
|
||||
command: str,
|
||||
session_id: str = "vaultmesh-client",
|
||||
user: str = "vaultmesh",
|
||||
) -> Dict[str, Any]:
|
||||
"""
|
||||
Send a command to the Shield Node MCP backend.
|
||||
|
||||
Example commands:
|
||||
"agents list"
|
||||
"status"
|
||||
"shield status"
|
||||
"proof latest"
|
||||
"agent spawn recon"
|
||||
"""
|
||||
url = f"{self.base_url.rstrip('/')}/mcp/command"
|
||||
payload: Dict[str, Any] = {
|
||||
"session_id": session_id,
|
||||
"user": user,
|
||||
"command": command,
|
||||
}
|
||||
|
||||
async with aiohttp.ClientSession(timeout=aiohttp.ClientTimeout(total=self.timeout_seconds)) as session:
|
||||
async with session.post(url, json=payload) as resp:
|
||||
text = await resp.text()
|
||||
if resp.status != 200:
|
||||
# Try to parse JSON details if present
|
||||
try:
|
||||
data = json.loads(text)
|
||||
except json.JSONDecodeError:
|
||||
data = None
|
||||
raise OffsecNodeError(
|
||||
"Shield node command failed",
|
||||
status=resp.status,
|
||||
details=data or {"raw": text},
|
||||
)
|
||||
try:
|
||||
return json.loads(text)
|
||||
except json.JSONDecodeError as exc:
|
||||
raise OffsecNodeError("Invalid JSON from Shield node", details={"raw": text}) from exc
|
||||
|
||||
|
||||
# Optional: CLI entrypoint for quick manual tests
|
||||
async def _demo() -> None:
|
||||
client = OffsecNodeClient.from_env()
|
||||
print(f"[offsec-node] base_url={client.base_url}")
|
||||
try:
|
||||
health = await client.health()
|
||||
print("Health:", json.dumps(health, indent=2))
|
||||
except OffsecNodeError as e:
|
||||
print("Health check failed:", e)
|
||||
return
|
||||
|
||||
try:
|
||||
agents = await client.command("agents list")
|
||||
print("Agents:", json.dumps(agents, indent=2))
|
||||
except OffsecNodeError as e:
|
||||
print("Command failed:", e)
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
asyncio.run(_demo())
|
||||
Reference in New Issue
Block a user