Initial commit
This commit is contained in:
149
.gitignore
vendored
Normal file
149
.gitignore
vendored
Normal file
@@ -0,0 +1,149 @@
|
|||||||
|
|
||||||
|
# Byte-compiled / Python cache
|
||||||
|
__pycache__/
|
||||||
|
*.py[codz]
|
||||||
|
*$py.class
|
||||||
|
|
||||||
|
# C extensions
|
||||||
|
*.so
|
||||||
|
|
||||||
|
|
||||||
|
# Build / packaging
|
||||||
|
.Python
|
||||||
|
build/
|
||||||
|
develop-eggs/
|
||||||
|
dist/
|
||||||
|
downloads/
|
||||||
|
eggs/
|
||||||
|
.eggs/
|
||||||
|
lib/
|
||||||
|
lib64/
|
||||||
|
parts/
|
||||||
|
sdist/
|
||||||
|
var/
|
||||||
|
wheels/
|
||||||
|
share/python-wheels/
|
||||||
|
*.egg-info/
|
||||||
|
.installed.cfg
|
||||||
|
*.egg
|
||||||
|
MANIFEST
|
||||||
|
|
||||||
|
# PyInstaller
|
||||||
|
*.manifest
|
||||||
|
*.spec
|
||||||
|
|
||||||
|
# Virtual environments
|
||||||
|
.env
|
||||||
|
.envrc
|
||||||
|
.venv/
|
||||||
|
venv/
|
||||||
|
ENV/
|
||||||
|
env/
|
||||||
|
env.bak/
|
||||||
|
venv.bak/
|
||||||
|
.python-version
|
||||||
|
|
||||||
|
# UV / Poetry / PDM / Pixi
|
||||||
|
uv.lock
|
||||||
|
poetry.lock
|
||||||
|
poetry.toml
|
||||||
|
pdm.lock
|
||||||
|
pdm.toml
|
||||||
|
.pdm-python
|
||||||
|
.pdm-build/
|
||||||
|
pixi.lock
|
||||||
|
.pixi/
|
||||||
|
|
||||||
|
# Logs / runtime / temp
|
||||||
|
logs/
|
||||||
|
*.log
|
||||||
|
runtime/
|
||||||
|
tmp/
|
||||||
|
tempCodeRunnerFile.py
|
||||||
|
|
||||||
|
# Testing / coverage
|
||||||
|
htmlcov/
|
||||||
|
.tox/
|
||||||
|
.nox/
|
||||||
|
.coverage
|
||||||
|
.coverage.*
|
||||||
|
.cache
|
||||||
|
nosetests.xml
|
||||||
|
coverage.xml
|
||||||
|
*.cover
|
||||||
|
.pytest_cache/
|
||||||
|
.hypothesis/
|
||||||
|
cover/
|
||||||
|
|
||||||
|
# Jupyter / IPython
|
||||||
|
.ipynb_checkpoints/
|
||||||
|
profile_default/
|
||||||
|
ipython_config.py
|
||||||
|
|
||||||
|
# Databases / local state
|
||||||
|
db.sqlite3
|
||||||
|
db.sqlite3-journal
|
||||||
|
|
||||||
|
# Redis / brokers
|
||||||
|
*.rdb
|
||||||
|
*.aof
|
||||||
|
*.pid
|
||||||
|
mnesia/
|
||||||
|
rabbitmq/
|
||||||
|
rabbitmq-data/
|
||||||
|
activemq-data/
|
||||||
|
|
||||||
|
# Framework junk
|
||||||
|
instance/
|
||||||
|
.webassets-cache/
|
||||||
|
.scrapy/
|
||||||
|
.pybuilder/
|
||||||
|
target/
|
||||||
|
docs/_build/
|
||||||
|
site/
|
||||||
|
celerybeat-schedule
|
||||||
|
celerybeat.pid
|
||||||
|
.scrapy/
|
||||||
|
.abstra/
|
||||||
|
.streamlit/secrets.toml
|
||||||
|
|
||||||
|
|
||||||
|
# Type checkers / linters
|
||||||
|
.mypy_cache/
|
||||||
|
.dmypy.json
|
||||||
|
dmypy.json
|
||||||
|
.pyre/
|
||||||
|
.pytype/
|
||||||
|
.ruff_cache/
|
||||||
|
|
||||||
|
|
||||||
|
# Editors / IDEs
|
||||||
|
.vscode/
|
||||||
|
.idea/
|
||||||
|
.spyderproject/
|
||||||
|
.spyproject/
|
||||||
|
.ropeproject/
|
||||||
|
|
||||||
|
# OS junk
|
||||||
|
.DS_Store
|
||||||
|
Thumbs.db
|
||||||
|
|
||||||
|
|
||||||
|
# Pip / packaging artifacts
|
||||||
|
pip-log.txt
|
||||||
|
pip-delete-this-directory.txt
|
||||||
|
.pypirc
|
||||||
|
|
||||||
|
# Security / env files
|
||||||
|
.env
|
||||||
|
.env.*
|
||||||
|
*.env
|
||||||
|
|
||||||
|
# IMPORTANT: ignore ALL dot-folders by default
|
||||||
|
.*
|
||||||
|
!.gitignore
|
||||||
|
!.github/
|
||||||
|
!.vscode/ # (comment this out if you want vscode tracked configs later)
|
||||||
|
|
||||||
|
# Exceptions (keep important hidden project dirs if you add them later)
|
||||||
|
!.env.example
|
||||||
8
MCP Server.code-workspace
Normal file
8
MCP Server.code-workspace
Normal file
@@ -0,0 +1,8 @@
|
|||||||
|
{
|
||||||
|
"folders": [
|
||||||
|
{
|
||||||
|
"path": "."
|
||||||
|
}
|
||||||
|
],
|
||||||
|
"settings": {}
|
||||||
|
}
|
||||||
174
api/server.py
Normal file
174
api/server.py
Normal file
@@ -0,0 +1,174 @@
|
|||||||
|
from fastapi import FastAPI, WebSocket, WebSocketDisconnect
|
||||||
|
from typing import List
|
||||||
|
from pydantic import BaseModel
|
||||||
|
|
||||||
|
from core.metrics import metrics
|
||||||
|
from core.events import bus, Event
|
||||||
|
from core.config import API_HOST, API_PORT
|
||||||
|
from core.event_store import event_store
|
||||||
|
|
||||||
|
app = FastAPI(title="CXOS MCP API", version="1.0")
|
||||||
|
|
||||||
|
# =========================
|
||||||
|
# MODELS
|
||||||
|
# =========================
|
||||||
|
|
||||||
|
class ReplayRequest(BaseModel):
|
||||||
|
source: str | None = None
|
||||||
|
level: str | None = None
|
||||||
|
name: str | None = None
|
||||||
|
|
||||||
|
|
||||||
|
# =========================
|
||||||
|
# WEBSOCKET STATE
|
||||||
|
# =========================
|
||||||
|
|
||||||
|
connected_clients: List[WebSocket] = []
|
||||||
|
|
||||||
|
|
||||||
|
# =========================
|
||||||
|
# EVENT BROADCAST
|
||||||
|
# =========================
|
||||||
|
|
||||||
|
async def broadcast(event: Event):
|
||||||
|
dead_clients = []
|
||||||
|
|
||||||
|
payload = {
|
||||||
|
"name": event.name,
|
||||||
|
"source": event.source,
|
||||||
|
"level": event.level,
|
||||||
|
"data": event.data,
|
||||||
|
"timestamp": event.timestamp,
|
||||||
|
}
|
||||||
|
|
||||||
|
for ws in connected_clients:
|
||||||
|
try:
|
||||||
|
await ws.send_json(payload)
|
||||||
|
except Exception:
|
||||||
|
dead_clients.append(ws)
|
||||||
|
|
||||||
|
for ws in dead_clients:
|
||||||
|
if ws in connected_clients:
|
||||||
|
connected_clients.remove(ws)
|
||||||
|
|
||||||
|
|
||||||
|
# subscribe once
|
||||||
|
bus.subscribe_async(broadcast)
|
||||||
|
|
||||||
|
|
||||||
|
# =========================
|
||||||
|
# HEALTH / METRICS
|
||||||
|
# =========================
|
||||||
|
|
||||||
|
@app.get("/health")
|
||||||
|
def health():
|
||||||
|
return {
|
||||||
|
"status": "ok",
|
||||||
|
"service": "cxos-mcp"
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
@app.get("/metrics")
|
||||||
|
def get_metrics():
|
||||||
|
return metrics.snapshot()
|
||||||
|
|
||||||
|
|
||||||
|
@app.get("/metrics/prometheus")
|
||||||
|
def prometheus_metrics():
|
||||||
|
return metrics.prometheus_format()
|
||||||
|
|
||||||
|
|
||||||
|
# =========================
|
||||||
|
# LIVE EVENT STREAM
|
||||||
|
# =========================
|
||||||
|
|
||||||
|
@app.websocket("/ws/events")
|
||||||
|
async def event_stream(ws: WebSocket):
|
||||||
|
await ws.accept()
|
||||||
|
connected_clients.append(ws)
|
||||||
|
|
||||||
|
bus.emit(Event(
|
||||||
|
name="client_connected",
|
||||||
|
source="API",
|
||||||
|
level="INFO",
|
||||||
|
data={"clients": len(connected_clients)}
|
||||||
|
))
|
||||||
|
|
||||||
|
try:
|
||||||
|
while True:
|
||||||
|
# lightweight keep-alive loop
|
||||||
|
await ws.receive_text()
|
||||||
|
|
||||||
|
except WebSocketDisconnect:
|
||||||
|
if ws in connected_clients:
|
||||||
|
connected_clients.remove(ws)
|
||||||
|
|
||||||
|
bus.emit(Event(
|
||||||
|
name="client_disconnected",
|
||||||
|
source="API",
|
||||||
|
level="INFO",
|
||||||
|
data={"clients": len(connected_clients)}
|
||||||
|
))
|
||||||
|
|
||||||
|
|
||||||
|
# =========================
|
||||||
|
# EVENT REPLAY (READ ONLY)
|
||||||
|
# =========================
|
||||||
|
|
||||||
|
@app.get("/events/replay")
|
||||||
|
def replay_events(
|
||||||
|
source: str | None = None,
|
||||||
|
level: str | None = None,
|
||||||
|
name: str | None = None
|
||||||
|
):
|
||||||
|
events = event_store.query(source, level, name)
|
||||||
|
|
||||||
|
return {
|
||||||
|
"count": len(events),
|
||||||
|
"events": [
|
||||||
|
{
|
||||||
|
"name": e.name,
|
||||||
|
"source": e.source,
|
||||||
|
"level": e.level,
|
||||||
|
"data": e.data,
|
||||||
|
"timestamp": e.timestamp,
|
||||||
|
}
|
||||||
|
for e in events
|
||||||
|
]
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
# =========================
|
||||||
|
# EVENT REPLAY (EXECUTION)
|
||||||
|
# =========================
|
||||||
|
|
||||||
|
@app.post("/events/replay/run")
|
||||||
|
def replay_run(req: ReplayRequest):
|
||||||
|
|
||||||
|
def printer(event):
|
||||||
|
print(f"[REPLAY] {event.source} | {event.name}")
|
||||||
|
|
||||||
|
event_store.replay(
|
||||||
|
source=req.source,
|
||||||
|
level=req.level,
|
||||||
|
name=req.name,
|
||||||
|
handler=printer
|
||||||
|
)
|
||||||
|
|
||||||
|
return {"status": "replayed"}
|
||||||
|
|
||||||
|
|
||||||
|
# =========================
|
||||||
|
# SERVER START
|
||||||
|
# =========================
|
||||||
|
|
||||||
|
def run_api():
|
||||||
|
import uvicorn
|
||||||
|
|
||||||
|
uvicorn.run(
|
||||||
|
"api.server:app",
|
||||||
|
host=API_HOST,
|
||||||
|
port=API_PORT,
|
||||||
|
reload=False,
|
||||||
|
log_level="info"
|
||||||
|
)
|
||||||
69
core/config.py
Normal file
69
core/config.py
Normal file
@@ -0,0 +1,69 @@
|
|||||||
|
from pathlib import Path
|
||||||
|
import os
|
||||||
|
from dotenv import load_dotenv
|
||||||
|
|
||||||
|
load_dotenv()
|
||||||
|
|
||||||
|
|
||||||
|
# =========================
|
||||||
|
# CORE PATHS
|
||||||
|
# =========================
|
||||||
|
|
||||||
|
WORKSPACE_ROOT = Path(os.getenv("WORKSPACE_ROOT", Path.cwd())).resolve()
|
||||||
|
|
||||||
|
LOG_DIR = WORKSPACE_ROOT / "logs"
|
||||||
|
TMP_DIR = WORKSPACE_ROOT / "tmp"
|
||||||
|
CONFIG_DIR = WORKSPACE_ROOT / "config"
|
||||||
|
|
||||||
|
LOG_DIR.mkdir(parents=True, exist_ok=True)
|
||||||
|
TMP_DIR.mkdir(parents=True, exist_ok=True)
|
||||||
|
CONFIG_DIR.mkdir(parents=True, exist_ok=True)
|
||||||
|
|
||||||
|
|
||||||
|
# =========================
|
||||||
|
# SYSTEM LIMITS
|
||||||
|
# =========================
|
||||||
|
|
||||||
|
MAX_FILE_SIZE = int(os.getenv("MAX_FILE_SIZE", str(10 * 1024 * 1024)))
|
||||||
|
|
||||||
|
IGNORE_DIRS = set(
|
||||||
|
os.getenv(
|
||||||
|
"IGNORE_DIRS",
|
||||||
|
".git,__pycache__,node_modules,.venv,venv,dist,build"
|
||||||
|
).split(",")
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
# =========================
|
||||||
|
# EVENT / DEBUG FLAGS
|
||||||
|
# =========================
|
||||||
|
|
||||||
|
DEBUG = os.getenv("DEBUG", "false").lower() == "true"
|
||||||
|
DRY_RUN_DEFAULT = os.getenv("DRY_RUN", "false").lower() == "true"
|
||||||
|
|
||||||
|
|
||||||
|
# =========================
|
||||||
|
# GITEA CONFIG
|
||||||
|
# =========================
|
||||||
|
|
||||||
|
GITEA_URL = os.getenv("GITEA_URL", "http://localhost:3000")
|
||||||
|
GITEA_TOKEN = os.getenv("GITEA_TOKEN", "")
|
||||||
|
GITEA_USER = os.getenv("GITEA_USER", "admin")
|
||||||
|
GITEA_API_BASE = f"{GITEA_URL}/api/v1"
|
||||||
|
|
||||||
|
|
||||||
|
# =========================
|
||||||
|
# GIT CONFIG
|
||||||
|
# =========================
|
||||||
|
|
||||||
|
GIT_AUTHOR_NAME = os.getenv("GIT_AUTHOR_NAME", "CXOS")
|
||||||
|
GIT_AUTHOR_EMAIL = os.getenv("GIT_AUTHOR_EMAIL", "cxos@local")
|
||||||
|
|
||||||
|
|
||||||
|
# =========================
|
||||||
|
# MCP / SERVER CONFIG
|
||||||
|
# =========================
|
||||||
|
|
||||||
|
SERVER_NAME = os.getenv("SERVER_NAME", "CXOS-MCP")
|
||||||
|
API_HOST = os.getenv("API_HOST", "0.0.0.0")
|
||||||
|
API_PORT = int(os.getenv("API_PORT", "5432"))
|
||||||
108
core/event_store.py
Normal file
108
core/event_store.py
Normal file
@@ -0,0 +1,108 @@
|
|||||||
|
from collections import deque
|
||||||
|
from typing import Optional, Any, Callable, Iterator
|
||||||
|
|
||||||
|
from core.events import Event, bus
|
||||||
|
|
||||||
|
|
||||||
|
class EventStore:
|
||||||
|
def __init__(self, max_size: int = 10_000):
|
||||||
|
self.buffer: deque[Event] = deque(maxlen=max_size)
|
||||||
|
self._cursor: int = 0
|
||||||
|
|
||||||
|
bus.subscribe(self._capture)
|
||||||
|
|
||||||
|
# =========================
|
||||||
|
# CAPTURE
|
||||||
|
# =========================
|
||||||
|
|
||||||
|
def _capture(self, event: Event):
|
||||||
|
event.meta["cursor"] = self._cursor
|
||||||
|
|
||||||
|
self.buffer.append(event)
|
||||||
|
self._cursor += 1
|
||||||
|
|
||||||
|
# =========================
|
||||||
|
# QUERY
|
||||||
|
# =========================
|
||||||
|
|
||||||
|
def query(
|
||||||
|
self,
|
||||||
|
source: Optional[str] = None,
|
||||||
|
level: Optional[str] = None,
|
||||||
|
name: Optional[str] = None,
|
||||||
|
start_cursor: Optional[int] = None,
|
||||||
|
end_cursor: Optional[int] = None,
|
||||||
|
) -> list[Event]:
|
||||||
|
|
||||||
|
events = list(self.buffer)
|
||||||
|
|
||||||
|
if source is not None:
|
||||||
|
events = [e for e in events if e.source == source]
|
||||||
|
|
||||||
|
if level is not None:
|
||||||
|
events = [e for e in events if e.level == level]
|
||||||
|
|
||||||
|
if name is not None:
|
||||||
|
events = [e for e in events if e.name == name]
|
||||||
|
|
||||||
|
def cursor_of(e: Event) -> int:
|
||||||
|
return e.meta.get("cursor", -1)
|
||||||
|
|
||||||
|
if start_cursor is not None:
|
||||||
|
events = [e for e in events if cursor_of(e) >= start_cursor]
|
||||||
|
|
||||||
|
if end_cursor is not None:
|
||||||
|
events = [e for e in events if cursor_of(e) <= end_cursor]
|
||||||
|
|
||||||
|
return events
|
||||||
|
|
||||||
|
# =========================
|
||||||
|
# PAGINATION
|
||||||
|
# =========================
|
||||||
|
|
||||||
|
def page(self, cursor: int = 0, limit: int = 100) -> dict[str, Any]:
|
||||||
|
events = list(self.buffer)
|
||||||
|
sliced = events[cursor:cursor + limit]
|
||||||
|
|
||||||
|
return {
|
||||||
|
"cursor": cursor,
|
||||||
|
"next_cursor": cursor + len(sliced),
|
||||||
|
"count": len(sliced),
|
||||||
|
"events": sliced
|
||||||
|
}
|
||||||
|
|
||||||
|
# =========================
|
||||||
|
# REPLAY
|
||||||
|
# =========================
|
||||||
|
|
||||||
|
def replay(
|
||||||
|
self,
|
||||||
|
source: Optional[str] = None,
|
||||||
|
level: Optional[str] = None,
|
||||||
|
name: Optional[str] = None,
|
||||||
|
handler: Optional[Callable[[Event], Any]] = None,
|
||||||
|
):
|
||||||
|
for event in self.query(source, level, name):
|
||||||
|
if handler:
|
||||||
|
handler(event)
|
||||||
|
else:
|
||||||
|
bus.emit(event)
|
||||||
|
|
||||||
|
# =========================
|
||||||
|
# REVERSE DEBUG
|
||||||
|
# =========================
|
||||||
|
|
||||||
|
def replay_reverse(self, handler: Callable[[Event], Any]):
|
||||||
|
for event in reversed(self.buffer):
|
||||||
|
handler(event)
|
||||||
|
|
||||||
|
# =========================
|
||||||
|
# STREAM TAIL
|
||||||
|
# =========================
|
||||||
|
|
||||||
|
def tail(self, last_n: int = 50) -> Iterator[Event]:
|
||||||
|
yield from list(self.buffer)[-last_n:]
|
||||||
|
|
||||||
|
|
||||||
|
# singleton
|
||||||
|
event_store = EventStore()
|
||||||
147
core/events.py
Normal file
147
core/events.py
Normal file
@@ -0,0 +1,147 @@
|
|||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
from dataclasses import dataclass, field
|
||||||
|
from typing import Any, Callable
|
||||||
|
from collections.abc import Awaitable
|
||||||
|
from datetime import datetime
|
||||||
|
import asyncio
|
||||||
|
import uuid
|
||||||
|
|
||||||
|
|
||||||
|
# =========================
|
||||||
|
# EVENT MODEL (GRAPH-AWARE)
|
||||||
|
# =========================
|
||||||
|
|
||||||
|
@dataclass
|
||||||
|
class Event:
|
||||||
|
name: str
|
||||||
|
source: str
|
||||||
|
level: str = "INFO"
|
||||||
|
data: dict[str, Any] | None = None
|
||||||
|
|
||||||
|
event_id: str = field(default_factory=lambda: str(uuid.uuid4()))
|
||||||
|
trace_id: str = field(default_factory=lambda: str(uuid.uuid4()))
|
||||||
|
parent_event_id: str | None = None
|
||||||
|
|
||||||
|
meta: dict[str, Any] = field(default_factory=dict)
|
||||||
|
|
||||||
|
timestamp: str = field(default_factory=lambda: datetime.utcnow().isoformat())
|
||||||
|
|
||||||
|
|
||||||
|
# =========================
|
||||||
|
# EVENT BUS
|
||||||
|
# =========================
|
||||||
|
|
||||||
|
class EventBus:
|
||||||
|
def __init__(self):
|
||||||
|
self._subscribers: list[Callable[[Event], Any]] = []
|
||||||
|
self._async_subscribers: list[Callable[[Event], Awaitable[Any]]] = []
|
||||||
|
|
||||||
|
self._current_trace: str | None = None
|
||||||
|
self._current_parent: str | None = None
|
||||||
|
|
||||||
|
# -------------------------
|
||||||
|
# SUBSCRIBE
|
||||||
|
# -------------------------
|
||||||
|
|
||||||
|
def subscribe(self, fn: Callable[[Event], Any]):
|
||||||
|
self._subscribers.append(fn)
|
||||||
|
|
||||||
|
def subscribe_async(self, fn: Callable[[Event], Awaitable[Any]]):
|
||||||
|
self._async_subscribers.append(fn)
|
||||||
|
|
||||||
|
# -------------------------
|
||||||
|
# TRACE CONTROL
|
||||||
|
# -------------------------
|
||||||
|
|
||||||
|
def start_trace(self) -> str:
|
||||||
|
trace_id = str(uuid.uuid4())
|
||||||
|
self._current_trace = trace_id
|
||||||
|
self._current_parent = None
|
||||||
|
return trace_id
|
||||||
|
|
||||||
|
def set_parent(self, event_id: str | None):
|
||||||
|
self._current_parent = event_id
|
||||||
|
|
||||||
|
# -------------------------
|
||||||
|
# EMIT (CORE SAFE PATH)
|
||||||
|
# -------------------------
|
||||||
|
|
||||||
|
def emit(self, event: Event):
|
||||||
|
# inject trace
|
||||||
|
if not event.trace_id:
|
||||||
|
event.trace_id = self._current_trace or event.trace_id
|
||||||
|
|
||||||
|
event.parent_event_id = event.parent_event_id or self._current_parent
|
||||||
|
|
||||||
|
# advance causal chain
|
||||||
|
self._current_parent = event.event_id
|
||||||
|
|
||||||
|
# sync subscribers
|
||||||
|
for fn in self._subscribers:
|
||||||
|
try:
|
||||||
|
fn(event)
|
||||||
|
except Exception:
|
||||||
|
pass
|
||||||
|
|
||||||
|
# async subscribers (SAFE FIX)
|
||||||
|
if self._async_subscribers:
|
||||||
|
try:
|
||||||
|
loop = asyncio.get_running_loop()
|
||||||
|
except RuntimeError:
|
||||||
|
return # no loop available
|
||||||
|
|
||||||
|
for fn in self._async_subscribers:
|
||||||
|
try:
|
||||||
|
coro = fn(event)
|
||||||
|
|
||||||
|
# IMPORTANT FIX:
|
||||||
|
# ensure it's actually awaitable before scheduling
|
||||||
|
if asyncio.iscoroutine(coro):
|
||||||
|
loop.create_task(coro)
|
||||||
|
|
||||||
|
except Exception:
|
||||||
|
pass
|
||||||
|
|
||||||
|
# -------------------------
|
||||||
|
# STRICT ASYNC EMIT
|
||||||
|
# -------------------------
|
||||||
|
|
||||||
|
async def emit_async(self, event: Event):
|
||||||
|
for fn in self._subscribers:
|
||||||
|
try:
|
||||||
|
fn(event)
|
||||||
|
except Exception:
|
||||||
|
pass
|
||||||
|
|
||||||
|
await asyncio.gather(
|
||||||
|
*(fn(event) for fn in self._async_subscribers),
|
||||||
|
return_exceptions=True
|
||||||
|
)
|
||||||
|
|
||||||
|
# -------------------------
|
||||||
|
# LOG CONVENIENCE
|
||||||
|
# -------------------------
|
||||||
|
|
||||||
|
def log(
|
||||||
|
self,
|
||||||
|
source: str,
|
||||||
|
name: str,
|
||||||
|
level: str = "INFO",
|
||||||
|
data: dict[str, Any] | None = None
|
||||||
|
):
|
||||||
|
self.emit(
|
||||||
|
Event(
|
||||||
|
name=name,
|
||||||
|
source=source,
|
||||||
|
level=level,
|
||||||
|
data=data or {}
|
||||||
|
)
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
# =========================
|
||||||
|
# SINGLETON
|
||||||
|
# =========================
|
||||||
|
|
||||||
|
bus = EventBus()
|
||||||
85
core/exception.py
Normal file
85
core/exception.py
Normal file
@@ -0,0 +1,85 @@
|
|||||||
|
from dataclasses import dataclass
|
||||||
|
from typing import Any, Optional
|
||||||
|
from core.events import bus, Event
|
||||||
|
|
||||||
|
|
||||||
|
# -------------------------
|
||||||
|
# BASE ERROR
|
||||||
|
# -------------------------
|
||||||
|
|
||||||
|
@dataclass
|
||||||
|
class CXError(Exception):
|
||||||
|
code: str
|
||||||
|
message: str
|
||||||
|
module: str = "CORE"
|
||||||
|
status: int = 500
|
||||||
|
context: Optional[Any] = None
|
||||||
|
|
||||||
|
# -------------------------
|
||||||
|
# SERIALIZATION
|
||||||
|
# -------------------------
|
||||||
|
|
||||||
|
def to_dict(self):
|
||||||
|
return {
|
||||||
|
"error": True,
|
||||||
|
"code": self.code,
|
||||||
|
"message": self.message,
|
||||||
|
"module": self.module,
|
||||||
|
"status": self.status,
|
||||||
|
"context": self.context,
|
||||||
|
}
|
||||||
|
|
||||||
|
# -------------------------
|
||||||
|
# EVENT EMISSION
|
||||||
|
# -------------------------
|
||||||
|
|
||||||
|
def emit(self):
|
||||||
|
bus.emit(Event(
|
||||||
|
name="exception",
|
||||||
|
source=self.module,
|
||||||
|
level="ERROR",
|
||||||
|
data={
|
||||||
|
"code": self.code,
|
||||||
|
"message": self.message,
|
||||||
|
"status": self.status,
|
||||||
|
"context": self.context,
|
||||||
|
}
|
||||||
|
))
|
||||||
|
|
||||||
|
|
||||||
|
# -------------------------
|
||||||
|
# 4xx CLIENT / AI ERRORS
|
||||||
|
# -------------------------
|
||||||
|
|
||||||
|
class CXBadRequest(CXError):
|
||||||
|
def __init__(self, msg, module="CORE"):
|
||||||
|
super().__init__("CX400", msg, module, 400)
|
||||||
|
|
||||||
|
|
||||||
|
class CXNotFound(CXError):
|
||||||
|
def __init__(self, msg, module="FILESYSTEM"):
|
||||||
|
super().__init__("CX404", msg, module, 404)
|
||||||
|
|
||||||
|
|
||||||
|
class CXForbidden(CXError):
|
||||||
|
def __init__(self, msg, module="SECURITY"):
|
||||||
|
super().__init__("CX403", msg, module, 403)
|
||||||
|
|
||||||
|
|
||||||
|
class CXInvalidOperation(CXError):
|
||||||
|
def __init__(self, msg, module="CORE"):
|
||||||
|
super().__init__("CX422", msg, module, 422)
|
||||||
|
|
||||||
|
|
||||||
|
# -------------------------
|
||||||
|
# 5xx SYSTEM ERRORS
|
||||||
|
# -------------------------
|
||||||
|
|
||||||
|
class CXInternalError(CXError):
|
||||||
|
def __init__(self, msg, module="CORE"):
|
||||||
|
super().__init__("CX500", msg, module, 500)
|
||||||
|
|
||||||
|
|
||||||
|
class CXToolFailure(CXError):
|
||||||
|
def __init__(self, msg, module="TOOLS"):
|
||||||
|
super().__init__("CX502", msg, module, 502)
|
||||||
62
core/executor.py
Normal file
62
core/executor.py
Normal file
@@ -0,0 +1,62 @@
|
|||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
import asyncio
|
||||||
|
import threading
|
||||||
|
from typing import Any, TypeVar, Coroutine
|
||||||
|
|
||||||
|
T = TypeVar("T")
|
||||||
|
|
||||||
|
|
||||||
|
class LoopExecutor:
|
||||||
|
def __init__(self):
|
||||||
|
self._loop: asyncio.AbstractEventLoop | None = None
|
||||||
|
self._thread: threading.Thread | None = None
|
||||||
|
|
||||||
|
# -------------------------
|
||||||
|
# START LOOP
|
||||||
|
# -------------------------
|
||||||
|
|
||||||
|
def start(self):
|
||||||
|
if self._loop:
|
||||||
|
return
|
||||||
|
|
||||||
|
def runner():
|
||||||
|
self._loop = asyncio.new_event_loop()
|
||||||
|
asyncio.set_event_loop(self._loop)
|
||||||
|
self._loop.run_forever()
|
||||||
|
|
||||||
|
self._thread = threading.Thread(target=runner, daemon=True)
|
||||||
|
self._thread.start()
|
||||||
|
|
||||||
|
while self._loop is None:
|
||||||
|
pass
|
||||||
|
|
||||||
|
# -------------------------
|
||||||
|
# SUBMIT COROUTINE
|
||||||
|
# -------------------------
|
||||||
|
|
||||||
|
def submit(self, coro: Coroutine[Any, Any, T]):
|
||||||
|
if not self._loop:
|
||||||
|
raise RuntimeError("Executor not started")
|
||||||
|
|
||||||
|
return asyncio.run_coroutine_threadsafe(coro, self._loop)
|
||||||
|
|
||||||
|
# -------------------------
|
||||||
|
# SYNC BRIDGE
|
||||||
|
# -------------------------
|
||||||
|
|
||||||
|
def run_sync(self, coro: Coroutine[Any, Any, T]) -> T:
|
||||||
|
future = self.submit(coro)
|
||||||
|
return future.result()
|
||||||
|
|
||||||
|
# -------------------------
|
||||||
|
# STOP
|
||||||
|
# -------------------------
|
||||||
|
|
||||||
|
def stop(self):
|
||||||
|
if self._loop:
|
||||||
|
self._loop.call_soon_threadsafe(self._loop.stop)
|
||||||
|
self._loop = None
|
||||||
|
|
||||||
|
|
||||||
|
executor = LoopExecutor()
|
||||||
125
core/logging_core.py
Normal file
125
core/logging_core.py
Normal file
@@ -0,0 +1,125 @@
|
|||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
import sys
|
||||||
|
from datetime import datetime
|
||||||
|
|
||||||
|
from loguru import logger
|
||||||
|
|
||||||
|
from core.config import LOG_DIR
|
||||||
|
from core.events import Event, bus
|
||||||
|
|
||||||
|
|
||||||
|
class CXLoggerSink:
|
||||||
|
def __init__(self):
|
||||||
|
logger.remove()
|
||||||
|
|
||||||
|
# =========================
|
||||||
|
# SESSION LOG FILE
|
||||||
|
# =========================
|
||||||
|
|
||||||
|
timestamp = datetime.utcnow().strftime(
|
||||||
|
"%Y-%m-%d_%H-%M-%S"
|
||||||
|
)
|
||||||
|
|
||||||
|
self.log_path = LOG_DIR / (
|
||||||
|
f"MCP-{timestamp}.log"
|
||||||
|
)
|
||||||
|
|
||||||
|
self.log_path.parent.mkdir(
|
||||||
|
parents=True,
|
||||||
|
exist_ok=True
|
||||||
|
)
|
||||||
|
|
||||||
|
# =========================
|
||||||
|
# CONSOLE LOGGER
|
||||||
|
# =========================
|
||||||
|
|
||||||
|
logger.add(
|
||||||
|
sys.stderr,
|
||||||
|
level="DEBUG",
|
||||||
|
colorize=True,
|
||||||
|
backtrace=False,
|
||||||
|
diagnose=False,
|
||||||
|
format=(
|
||||||
|
"<level>{level:<8}</level> | "
|
||||||
|
"<cyan>{extra[module]:<12}</cyan> | "
|
||||||
|
"{time:YYYY-MM-DD HH:mm:ss} | "
|
||||||
|
"{message}"
|
||||||
|
)
|
||||||
|
)
|
||||||
|
|
||||||
|
# =========================
|
||||||
|
# FILE LOGGER
|
||||||
|
# =========================
|
||||||
|
|
||||||
|
logger.add(
|
||||||
|
str(self.log_path),
|
||||||
|
level="DEBUG",
|
||||||
|
enqueue=True,
|
||||||
|
retention="30 days",
|
||||||
|
compression="zip",
|
||||||
|
backtrace=False,
|
||||||
|
diagnose=False,
|
||||||
|
format=(
|
||||||
|
"{time:YYYY-MM-DD HH:mm:ss} | "
|
||||||
|
"{level:<8} | "
|
||||||
|
"{extra[module]:<12} | "
|
||||||
|
"{message}"
|
||||||
|
)
|
||||||
|
)
|
||||||
|
|
||||||
|
logger.bind(module="LOGGER").info(
|
||||||
|
f"Session log created: {self.log_path.name}"
|
||||||
|
)
|
||||||
|
|
||||||
|
# =========================
|
||||||
|
# EVENT HANDLER
|
||||||
|
# =========================
|
||||||
|
|
||||||
|
def handle(
|
||||||
|
self,
|
||||||
|
event: Event
|
||||||
|
):
|
||||||
|
module = event.source.upper()
|
||||||
|
level = event.level.lower()
|
||||||
|
|
||||||
|
message = event.name
|
||||||
|
|
||||||
|
if event.data:
|
||||||
|
message += (
|
||||||
|
f" | {event.data}"
|
||||||
|
)
|
||||||
|
|
||||||
|
with logger.contextualize(
|
||||||
|
module=module
|
||||||
|
):
|
||||||
|
log_method = getattr(
|
||||||
|
logger,
|
||||||
|
level,
|
||||||
|
logger.info
|
||||||
|
)
|
||||||
|
|
||||||
|
log_method(message)
|
||||||
|
|
||||||
|
# =========================
|
||||||
|
# OPTIONAL DIRECT ACCESS
|
||||||
|
# =========================
|
||||||
|
|
||||||
|
def get_log_path(self) -> str:
|
||||||
|
return str(self.log_path)
|
||||||
|
|
||||||
|
|
||||||
|
# =========================
|
||||||
|
# SINGLETON
|
||||||
|
# =========================
|
||||||
|
|
||||||
|
cxlog_sink = CXLoggerSink()
|
||||||
|
|
||||||
|
|
||||||
|
# =========================
|
||||||
|
# REGISTER TO EVENT BUS
|
||||||
|
# =========================
|
||||||
|
|
||||||
|
bus.subscribe(
|
||||||
|
cxlog_sink.handle
|
||||||
|
)
|
||||||
72
core/metrics.py
Normal file
72
core/metrics.py
Normal file
@@ -0,0 +1,72 @@
|
|||||||
|
from collections import defaultdict
|
||||||
|
from typing import Any, Optional
|
||||||
|
from datetime import datetime
|
||||||
|
from core.events import bus, Event
|
||||||
|
|
||||||
|
|
||||||
|
class CXMetrics:
|
||||||
|
def __init__(self):
|
||||||
|
self.counters = defaultdict(int)
|
||||||
|
self.events: list[dict[str, Any]] = []
|
||||||
|
self.max_events = 5000
|
||||||
|
|
||||||
|
# attach to event bus
|
||||||
|
bus.subscribe(self._on_event)
|
||||||
|
|
||||||
|
# -------------------------
|
||||||
|
# EVENT HOOK
|
||||||
|
# -------------------------
|
||||||
|
|
||||||
|
def _on_event(self, event: Event):
|
||||||
|
self.counters[f"event_{event.name}"] += 1
|
||||||
|
self.counters[f"level_{event.level}"] += 1
|
||||||
|
|
||||||
|
self.events.append({
|
||||||
|
"name": event.name,
|
||||||
|
"source": event.source,
|
||||||
|
"level": event.level,
|
||||||
|
"data": event.data,
|
||||||
|
"timestamp": event.timestamp
|
||||||
|
})
|
||||||
|
|
||||||
|
if len(self.events) > self.max_events:
|
||||||
|
self.events = self.events[-self.max_events:]
|
||||||
|
|
||||||
|
# -------------------------
|
||||||
|
# MANUAL COUNTERS
|
||||||
|
# -------------------------
|
||||||
|
|
||||||
|
def inc(self, key: str, value: int = 1):
|
||||||
|
self.counters[key] += value
|
||||||
|
|
||||||
|
def event(self, name: str, data: Optional[dict] = None):
|
||||||
|
bus.emit(Event(
|
||||||
|
name=name,
|
||||||
|
source="metrics",
|
||||||
|
level="INFO",
|
||||||
|
data=data or {}
|
||||||
|
))
|
||||||
|
|
||||||
|
# -------------------------
|
||||||
|
# SNAPSHOT (Prometheus / API)
|
||||||
|
# -------------------------
|
||||||
|
|
||||||
|
def snapshot(self) -> dict:
|
||||||
|
return {
|
||||||
|
"counters": dict(self.counters),
|
||||||
|
"recent_events": self.events[-100:],
|
||||||
|
"timestamp": datetime.utcnow().isoformat()
|
||||||
|
}
|
||||||
|
|
||||||
|
# -------------------------
|
||||||
|
# SIMPLE EXPORT FORMAT (future Prometheus hook)
|
||||||
|
# -------------------------
|
||||||
|
|
||||||
|
def prometheus_format(self) -> str:
|
||||||
|
lines = []
|
||||||
|
for k, v in self.counters.items():
|
||||||
|
lines.append(f"{k} {v}")
|
||||||
|
return "\n".join(lines)
|
||||||
|
|
||||||
|
|
||||||
|
metrics = CXMetrics()
|
||||||
181
core/safety.py
Normal file
181
core/safety.py
Normal file
@@ -0,0 +1,181 @@
|
|||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
from pathlib import Path
|
||||||
|
from typing import Any, Awaitable, Callable
|
||||||
|
|
||||||
|
import asyncio
|
||||||
|
|
||||||
|
from core.config import (
|
||||||
|
WORKSPACE_ROOT,
|
||||||
|
MAX_FILE_SIZE,
|
||||||
|
IGNORE_DIRS,
|
||||||
|
DRY_RUN_DEFAULT,
|
||||||
|
)
|
||||||
|
|
||||||
|
from core.events import bus, Event
|
||||||
|
from core.metrics import metrics
|
||||||
|
|
||||||
|
|
||||||
|
# =========================
|
||||||
|
# SAFETY ERROR
|
||||||
|
# =========================
|
||||||
|
|
||||||
|
class SafetyError(Exception):
|
||||||
|
def __init__(self, code: str, message: str):
|
||||||
|
super().__init__(message)
|
||||||
|
self.code = code
|
||||||
|
self.message = message
|
||||||
|
|
||||||
|
|
||||||
|
# =========================
|
||||||
|
# SAFETY ENGINE
|
||||||
|
# =========================
|
||||||
|
|
||||||
|
class SafetyEngine:
|
||||||
|
def __init__(self):
|
||||||
|
self.dry_run = DRY_RUN_DEFAULT
|
||||||
|
self.block_destructive = True
|
||||||
|
|
||||||
|
# -------------------------
|
||||||
|
# MODE CONTROL
|
||||||
|
# -------------------------
|
||||||
|
|
||||||
|
def set_dry_run(self, value: bool):
|
||||||
|
self.dry_run = value
|
||||||
|
bus.emit(Event(
|
||||||
|
name="safety_dry_run",
|
||||||
|
source="SAFETY",
|
||||||
|
level="INFO",
|
||||||
|
data={"enabled": value}
|
||||||
|
))
|
||||||
|
|
||||||
|
# -------------------------
|
||||||
|
# PATH SANDBOXING
|
||||||
|
# -------------------------
|
||||||
|
|
||||||
|
def validate_path(self, path: str) -> Path:
|
||||||
|
target = (WORKSPACE_ROOT / path).resolve()
|
||||||
|
|
||||||
|
if not str(target).startswith(str(WORKSPACE_ROOT)):
|
||||||
|
self._violation("PATH_ESCAPE", path)
|
||||||
|
raise SafetyError("PATH_ESCAPE", "Outside workspace")
|
||||||
|
|
||||||
|
for part in target.parts:
|
||||||
|
if part in IGNORE_DIRS:
|
||||||
|
self._violation("IGNORED_PATH", path)
|
||||||
|
raise SafetyError("IGNORED_PATH", f"Blocked directory: {part}")
|
||||||
|
|
||||||
|
return target
|
||||||
|
|
||||||
|
# -------------------------
|
||||||
|
# FILE SIZE GUARD
|
||||||
|
# -------------------------
|
||||||
|
|
||||||
|
def check_file_write(self, path: Path, content: str):
|
||||||
|
if len(content.encode("utf-8")) > MAX_FILE_SIZE:
|
||||||
|
self._violation("FILE_TOO_LARGE", str(path))
|
||||||
|
raise SafetyError("FILE_TOO_LARGE", "Exceeded limit")
|
||||||
|
|
||||||
|
# -------------------------
|
||||||
|
# DESTRUCTIVE OPS
|
||||||
|
# -------------------------
|
||||||
|
|
||||||
|
def allow_action(self, action: str):
|
||||||
|
blocked = {
|
||||||
|
"delete_file",
|
||||||
|
"rm_rf",
|
||||||
|
"format",
|
||||||
|
"wipe",
|
||||||
|
"drop_db",
|
||||||
|
}
|
||||||
|
|
||||||
|
if self.block_destructive and action in blocked:
|
||||||
|
self._violation("DESTRUCTIVE_BLOCK", action)
|
||||||
|
raise SafetyError("DESTRUCTIVE_BLOCK", f"Blocked: {action}")
|
||||||
|
|
||||||
|
# =========================================================
|
||||||
|
# ASYNC SAFE WRAPPER (NEW CORE ENTRYPOINT)
|
||||||
|
# =========================================================
|
||||||
|
|
||||||
|
async def wrap_async(
|
||||||
|
self,
|
||||||
|
tool_name: str,
|
||||||
|
fn: Callable[..., Any | Awaitable[Any]],
|
||||||
|
*args: Any,
|
||||||
|
**kwargs: Any,
|
||||||
|
) -> Any:
|
||||||
|
|
||||||
|
bus.emit(Event(
|
||||||
|
name="tool_start",
|
||||||
|
source="SAFETY",
|
||||||
|
level="INFO",
|
||||||
|
data={"tool": tool_name}
|
||||||
|
))
|
||||||
|
|
||||||
|
if self.dry_run:
|
||||||
|
return {
|
||||||
|
"dry_run": True,
|
||||||
|
"tool": tool_name,
|
||||||
|
"args": args,
|
||||||
|
"kwargs": kwargs,
|
||||||
|
}
|
||||||
|
|
||||||
|
try:
|
||||||
|
result = fn(*args, **kwargs)
|
||||||
|
|
||||||
|
if asyncio.iscoroutine(result):
|
||||||
|
result = await result
|
||||||
|
|
||||||
|
metrics.inc(f"tool_{tool_name}")
|
||||||
|
metrics.inc("tools_executed")
|
||||||
|
|
||||||
|
bus.emit(Event(
|
||||||
|
name="tool_success",
|
||||||
|
source="SAFETY",
|
||||||
|
level="SUCCESS",
|
||||||
|
data={"tool": tool_name}
|
||||||
|
))
|
||||||
|
|
||||||
|
return result
|
||||||
|
|
||||||
|
except SafetyError as e:
|
||||||
|
bus.emit(Event(
|
||||||
|
name="tool_blocked",
|
||||||
|
source="SAFETY",
|
||||||
|
level="ERROR",
|
||||||
|
data={"code": e.code, "message": e.message}
|
||||||
|
))
|
||||||
|
|
||||||
|
metrics.inc("safety_blocks")
|
||||||
|
|
||||||
|
return {"error": True, "code": e.code, "message": e.message}
|
||||||
|
|
||||||
|
except Exception as e:
|
||||||
|
bus.emit(Event(
|
||||||
|
name="tool_crash",
|
||||||
|
source="SAFETY",
|
||||||
|
level="ERROR",
|
||||||
|
data={"tool": tool_name, "error": str(e)}
|
||||||
|
))
|
||||||
|
|
||||||
|
metrics.inc("tool_errors")
|
||||||
|
|
||||||
|
return {"error": True, "code": "UNEXPECTED", "message": str(e)}
|
||||||
|
|
||||||
|
# -------------------------
|
||||||
|
# INTERNAL VIOLATION LOGGER
|
||||||
|
# -------------------------
|
||||||
|
|
||||||
|
def _violation(self, code: str, context: str):
|
||||||
|
bus.emit(Event(
|
||||||
|
name="safety_violation",
|
||||||
|
source="SAFETY",
|
||||||
|
level="ERROR",
|
||||||
|
data={"code": code, "context": context}
|
||||||
|
))
|
||||||
|
|
||||||
|
metrics.inc(f"safety_{code}")
|
||||||
|
|
||||||
|
|
||||||
|
# singleton
|
||||||
|
safety = SafetyEngine()
|
||||||
211
core/subprocess.py
Normal file
211
core/subprocess.py
Normal file
@@ -0,0 +1,211 @@
|
|||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
import subprocess
|
||||||
|
from pathlib import Path
|
||||||
|
from typing import Any
|
||||||
|
|
||||||
|
from core.events import bus, Event
|
||||||
|
from core.metrics import metrics
|
||||||
|
from core.safety import safety
|
||||||
|
|
||||||
|
|
||||||
|
# =========================
|
||||||
|
# PROCESS RUNNER
|
||||||
|
# =========================
|
||||||
|
|
||||||
|
def run_command(
|
||||||
|
cmd: list[str],
|
||||||
|
cwd: str | None = None,
|
||||||
|
timeout: int = 60
|
||||||
|
) -> dict[str, Any]:
|
||||||
|
|
||||||
|
# -------------------------
|
||||||
|
# VALIDATION
|
||||||
|
# -------------------------
|
||||||
|
|
||||||
|
if not cmd:
|
||||||
|
raise ValueError(
|
||||||
|
"Command list cannot be empty"
|
||||||
|
)
|
||||||
|
|
||||||
|
for item in cmd:
|
||||||
|
if not isinstance(item, str):
|
||||||
|
raise ValueError(
|
||||||
|
"cmd must contain only strings"
|
||||||
|
)
|
||||||
|
|
||||||
|
executable = cmd[0].lower()
|
||||||
|
|
||||||
|
# -------------------------
|
||||||
|
# SAFETY CHECKS
|
||||||
|
# -------------------------
|
||||||
|
|
||||||
|
blocked_commands = {
|
||||||
|
"rm",
|
||||||
|
"rmdir",
|
||||||
|
"del",
|
||||||
|
"erase",
|
||||||
|
"shutdown",
|
||||||
|
"reboot",
|
||||||
|
"mkfs",
|
||||||
|
"format",
|
||||||
|
"diskpart"
|
||||||
|
}
|
||||||
|
|
||||||
|
if executable in blocked_commands:
|
||||||
|
bus.emit(Event(
|
||||||
|
name="process_blocked",
|
||||||
|
source="SUBPROCESS",
|
||||||
|
level="ERROR",
|
||||||
|
data={
|
||||||
|
"cmd": cmd,
|
||||||
|
"reason": "blocked_command"
|
||||||
|
}
|
||||||
|
))
|
||||||
|
|
||||||
|
metrics.inc(
|
||||||
|
"subprocess_blocked"
|
||||||
|
)
|
||||||
|
|
||||||
|
return {
|
||||||
|
"ok": False,
|
||||||
|
"error": (
|
||||||
|
f"Blocked command: "
|
||||||
|
f"{executable}"
|
||||||
|
),
|
||||||
|
"code": -1
|
||||||
|
}
|
||||||
|
|
||||||
|
resolved_cwd: Path | None = None
|
||||||
|
|
||||||
|
if cwd is not None:
|
||||||
|
resolved_cwd = (
|
||||||
|
safety.validate_path(cwd)
|
||||||
|
)
|
||||||
|
|
||||||
|
# -------------------------
|
||||||
|
# START EVENT
|
||||||
|
# -------------------------
|
||||||
|
|
||||||
|
bus.emit(Event(
|
||||||
|
name="process_start",
|
||||||
|
source="SUBPROCESS",
|
||||||
|
level="INFO",
|
||||||
|
data={
|
||||||
|
"cmd": cmd,
|
||||||
|
"cwd": str(resolved_cwd)
|
||||||
|
if resolved_cwd else None,
|
||||||
|
"timeout": timeout
|
||||||
|
}
|
||||||
|
))
|
||||||
|
|
||||||
|
metrics.inc(
|
||||||
|
"subprocess_runs"
|
||||||
|
)
|
||||||
|
|
||||||
|
# -------------------------
|
||||||
|
# EXECUTE
|
||||||
|
# -------------------------
|
||||||
|
|
||||||
|
try:
|
||||||
|
result = subprocess.run(
|
||||||
|
cmd,
|
||||||
|
cwd=(
|
||||||
|
str(resolved_cwd)
|
||||||
|
if resolved_cwd
|
||||||
|
else None
|
||||||
|
),
|
||||||
|
capture_output=True,
|
||||||
|
text=True,
|
||||||
|
timeout=timeout,
|
||||||
|
check=False
|
||||||
|
)
|
||||||
|
|
||||||
|
stdout = (
|
||||||
|
result.stdout or ""
|
||||||
|
)
|
||||||
|
|
||||||
|
stderr = (
|
||||||
|
result.stderr or ""
|
||||||
|
)
|
||||||
|
|
||||||
|
ok = (
|
||||||
|
result.returncode == 0
|
||||||
|
)
|
||||||
|
|
||||||
|
# -------------------------
|
||||||
|
# COMPLETE EVENT
|
||||||
|
# -------------------------
|
||||||
|
|
||||||
|
bus.emit(Event(
|
||||||
|
name="process_complete",
|
||||||
|
source="SUBPROCESS",
|
||||||
|
level=(
|
||||||
|
"SUCCESS"
|
||||||
|
if ok
|
||||||
|
else "ERROR"
|
||||||
|
),
|
||||||
|
data={
|
||||||
|
"cmd": cmd,
|
||||||
|
"code": result.returncode,
|
||||||
|
"stdout": stdout,
|
||||||
|
"stderr": stderr
|
||||||
|
}
|
||||||
|
))
|
||||||
|
|
||||||
|
return {
|
||||||
|
"ok": ok,
|
||||||
|
"stdout": stdout,
|
||||||
|
"stderr": stderr,
|
||||||
|
"code": result.returncode
|
||||||
|
}
|
||||||
|
|
||||||
|
# -------------------------
|
||||||
|
# TIMEOUT
|
||||||
|
# -------------------------
|
||||||
|
|
||||||
|
except subprocess.TimeoutExpired:
|
||||||
|
metrics.inc(
|
||||||
|
"subprocess_timeouts"
|
||||||
|
)
|
||||||
|
|
||||||
|
bus.emit(Event(
|
||||||
|
name="process_timeout",
|
||||||
|
source="SUBPROCESS",
|
||||||
|
level="ERROR",
|
||||||
|
data={
|
||||||
|
"cmd": cmd,
|
||||||
|
"timeout": timeout
|
||||||
|
}
|
||||||
|
))
|
||||||
|
|
||||||
|
return {
|
||||||
|
"ok": False,
|
||||||
|
"error": "timeout",
|
||||||
|
"code": -1
|
||||||
|
}
|
||||||
|
|
||||||
|
# -------------------------
|
||||||
|
# UNEXPECTED ERROR
|
||||||
|
# -------------------------
|
||||||
|
|
||||||
|
except Exception as e:
|
||||||
|
metrics.inc(
|
||||||
|
"subprocess_errors"
|
||||||
|
)
|
||||||
|
|
||||||
|
bus.emit(Event(
|
||||||
|
name="process_error",
|
||||||
|
source="SUBPROCESS",
|
||||||
|
level="ERROR",
|
||||||
|
data={
|
||||||
|
"cmd": cmd,
|
||||||
|
"error": str(e)
|
||||||
|
}
|
||||||
|
))
|
||||||
|
|
||||||
|
return {
|
||||||
|
"ok": False,
|
||||||
|
"error": str(e),
|
||||||
|
"code": -1
|
||||||
|
}
|
||||||
93
core/tools/base.py
Normal file
93
core/tools/base.py
Normal file
@@ -0,0 +1,93 @@
|
|||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
from dataclasses import dataclass, field
|
||||||
|
from typing import Any, Protocol
|
||||||
|
|
||||||
|
from core.events import bus
|
||||||
|
|
||||||
|
|
||||||
|
# =========================
|
||||||
|
# TOOL CONTEXT
|
||||||
|
# =========================
|
||||||
|
|
||||||
|
@dataclass
|
||||||
|
class ToolContext:
|
||||||
|
user: str | None = None
|
||||||
|
dry_run: bool = False
|
||||||
|
|
||||||
|
# safe mutable metadata container
|
||||||
|
meta: dict[str, Any] = field(default_factory=dict)
|
||||||
|
|
||||||
|
|
||||||
|
# =========================
|
||||||
|
# TOOL CONTRACT
|
||||||
|
# =========================
|
||||||
|
|
||||||
|
class Tool(Protocol):
|
||||||
|
name: str
|
||||||
|
|
||||||
|
def execute(self, payload: dict[str, Any], ctx: ToolContext) -> Any:
|
||||||
|
...
|
||||||
|
|
||||||
|
|
||||||
|
# =========================
|
||||||
|
# BASE TOOL
|
||||||
|
# =========================
|
||||||
|
|
||||||
|
class BaseTool:
|
||||||
|
name: str = "base"
|
||||||
|
|
||||||
|
# -------------------------
|
||||||
|
# LIFECYCLE WRAPPER
|
||||||
|
# -------------------------
|
||||||
|
|
||||||
|
def run(self, payload: dict[str, Any], ctx: ToolContext):
|
||||||
|
bus.log(self.name, "tool_start", "INFO", {"payload": payload})
|
||||||
|
|
||||||
|
try:
|
||||||
|
# attach execution metadata (useful for event graph later)
|
||||||
|
ctx.meta.setdefault("tool", self.name)
|
||||||
|
ctx.meta.setdefault("dry_run", ctx.dry_run)
|
||||||
|
|
||||||
|
if ctx.dry_run:
|
||||||
|
result = self.preview(payload, ctx)
|
||||||
|
else:
|
||||||
|
result = self.execute(payload, ctx)
|
||||||
|
|
||||||
|
bus.log(
|
||||||
|
self.name,
|
||||||
|
"tool_success",
|
||||||
|
"SUCCESS",
|
||||||
|
{
|
||||||
|
"result_type": type(result).__name__,
|
||||||
|
"result": repr(result)[:500] # prevent log explosion
|
||||||
|
}
|
||||||
|
)
|
||||||
|
|
||||||
|
return result
|
||||||
|
|
||||||
|
except Exception as e:
|
||||||
|
bus.log(
|
||||||
|
self.name,
|
||||||
|
"tool_error",
|
||||||
|
"ERROR",
|
||||||
|
{
|
||||||
|
"error_type": type(e).__name__,
|
||||||
|
"error": str(e)
|
||||||
|
}
|
||||||
|
)
|
||||||
|
raise
|
||||||
|
|
||||||
|
# -------------------------
|
||||||
|
# OVERRIDABLE
|
||||||
|
# -------------------------
|
||||||
|
|
||||||
|
def execute(self, payload: dict[str, Any], ctx: ToolContext):
|
||||||
|
raise NotImplementedError("Tool must implement execute()")
|
||||||
|
|
||||||
|
def preview(self, payload: dict[str, Any], ctx: ToolContext):
|
||||||
|
return {
|
||||||
|
"dry_run": True,
|
||||||
|
"tool": self.name,
|
||||||
|
"payload": payload
|
||||||
|
}
|
||||||
124
core/tools/registry.py
Normal file
124
core/tools/registry.py
Normal file
@@ -0,0 +1,124 @@
|
|||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
from typing import Any
|
||||||
|
|
||||||
|
from core.tools.base import BaseTool, ToolContext
|
||||||
|
from core.events import bus
|
||||||
|
|
||||||
|
|
||||||
|
# =========================
|
||||||
|
# TOOL REGISTRY
|
||||||
|
# =========================
|
||||||
|
|
||||||
|
class ToolRegistry:
|
||||||
|
def __init__(self):
|
||||||
|
self._tools: dict[str, BaseTool] = {}
|
||||||
|
|
||||||
|
# -------------------------
|
||||||
|
# REGISTER
|
||||||
|
# -------------------------
|
||||||
|
|
||||||
|
def register(self, tool: BaseTool):
|
||||||
|
self._tools[tool.name] = tool
|
||||||
|
|
||||||
|
bus.log(
|
||||||
|
"REGISTRY",
|
||||||
|
"tool_registered",
|
||||||
|
"INFO",
|
||||||
|
{
|
||||||
|
"tool": tool.name
|
||||||
|
}
|
||||||
|
)
|
||||||
|
|
||||||
|
# -------------------------
|
||||||
|
# RESOLVE
|
||||||
|
# -------------------------
|
||||||
|
|
||||||
|
def get(self, name: str) -> BaseTool:
|
||||||
|
tool = self._tools.get(name)
|
||||||
|
|
||||||
|
if tool is None:
|
||||||
|
bus.log(
|
||||||
|
"REGISTRY",
|
||||||
|
"tool_not_found",
|
||||||
|
"ERROR",
|
||||||
|
{"tool": name}
|
||||||
|
)
|
||||||
|
|
||||||
|
raise ValueError(f"Tool not found: {name}")
|
||||||
|
|
||||||
|
return tool
|
||||||
|
|
||||||
|
# -------------------------
|
||||||
|
# EXECUTE
|
||||||
|
# -------------------------
|
||||||
|
|
||||||
|
def run(
|
||||||
|
self,
|
||||||
|
name: str,
|
||||||
|
payload: dict[str, Any],
|
||||||
|
ctx: ToolContext
|
||||||
|
):
|
||||||
|
tool = self.get(name)
|
||||||
|
|
||||||
|
# registry metadata
|
||||||
|
ctx.meta.setdefault("registry", True)
|
||||||
|
ctx.meta.setdefault("tool_name", name)
|
||||||
|
|
||||||
|
bus.log(
|
||||||
|
"REGISTRY",
|
||||||
|
"tool_dispatch",
|
||||||
|
"INFO",
|
||||||
|
{
|
||||||
|
"tool": name,
|
||||||
|
"dry_run": ctx.dry_run,
|
||||||
|
}
|
||||||
|
)
|
||||||
|
|
||||||
|
try:
|
||||||
|
result = tool.run(payload, ctx)
|
||||||
|
|
||||||
|
bus.log(
|
||||||
|
"REGISTRY",
|
||||||
|
"tool_completed",
|
||||||
|
"SUCCESS",
|
||||||
|
{
|
||||||
|
"tool": name,
|
||||||
|
"result_type": type(result).__name__
|
||||||
|
}
|
||||||
|
)
|
||||||
|
|
||||||
|
return result
|
||||||
|
|
||||||
|
except Exception as e:
|
||||||
|
bus.log(
|
||||||
|
"REGISTRY",
|
||||||
|
"tool_failed",
|
||||||
|
"ERROR",
|
||||||
|
{
|
||||||
|
"tool": name,
|
||||||
|
"error": str(e),
|
||||||
|
"error_type": type(e).__name__
|
||||||
|
}
|
||||||
|
)
|
||||||
|
raise
|
||||||
|
|
||||||
|
# -------------------------
|
||||||
|
# DISCOVERY (NEW)
|
||||||
|
# -------------------------
|
||||||
|
|
||||||
|
def all_tools(self) -> list[BaseTool]:
|
||||||
|
return list(self._tools.values())
|
||||||
|
|
||||||
|
def names(self) -> list[str]:
|
||||||
|
return list(self._tools.keys())
|
||||||
|
|
||||||
|
def exists(self, name: str) -> bool:
|
||||||
|
return name in self._tools
|
||||||
|
|
||||||
|
|
||||||
|
# =========================
|
||||||
|
# SINGLETON
|
||||||
|
# =========================
|
||||||
|
|
||||||
|
registry = ToolRegistry()
|
||||||
196
main.py
Normal file
196
main.py
Normal file
@@ -0,0 +1,196 @@
|
|||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
from mcp.server.fastmcp import FastMCP
|
||||||
|
|
||||||
|
from core.config import (
|
||||||
|
WORKSPACE_ROOT,
|
||||||
|
API_HOST,
|
||||||
|
API_PORT,
|
||||||
|
SERVER_NAME,
|
||||||
|
)
|
||||||
|
from core.events import bus, Event
|
||||||
|
from core.metrics import metrics
|
||||||
|
from core.safety import safety
|
||||||
|
from core.executor import executor
|
||||||
|
from core.tools.registry import registry
|
||||||
|
from core.tools.base import ToolContext
|
||||||
|
from tools.discovery import load_all_tools
|
||||||
|
|
||||||
|
|
||||||
|
# =========================
|
||||||
|
# RUNTIME BOOTSTRAP
|
||||||
|
# =========================
|
||||||
|
|
||||||
|
executor.start()
|
||||||
|
|
||||||
|
|
||||||
|
# =========================
|
||||||
|
# MCP SERVER
|
||||||
|
# =========================
|
||||||
|
|
||||||
|
mcp = FastMCP(SERVER_NAME)
|
||||||
|
|
||||||
|
|
||||||
|
# =========================
|
||||||
|
# REGISTRY → MCP AUTO BIND
|
||||||
|
# =========================
|
||||||
|
|
||||||
|
def bind_registry_tools():
|
||||||
|
for tool in registry.all_tools():
|
||||||
|
|
||||||
|
tool_name = tool.name
|
||||||
|
tool_description = getattr(
|
||||||
|
tool,
|
||||||
|
"description",
|
||||||
|
f"{tool_name} tool"
|
||||||
|
)
|
||||||
|
|
||||||
|
def make_handler(bound_tool):
|
||||||
|
def handler(**kwargs):
|
||||||
|
ctx = ToolContext(
|
||||||
|
dry_run=safety.dry_run
|
||||||
|
)
|
||||||
|
|
||||||
|
return registry.run(
|
||||||
|
name=bound_tool.name,
|
||||||
|
payload=kwargs,
|
||||||
|
ctx=ctx
|
||||||
|
)
|
||||||
|
|
||||||
|
return handler
|
||||||
|
|
||||||
|
mcp.tool(
|
||||||
|
name=tool_name,
|
||||||
|
description=tool_description
|
||||||
|
)(make_handler(tool))
|
||||||
|
|
||||||
|
bus.log(
|
||||||
|
"CORE",
|
||||||
|
"mcp_tool_bound",
|
||||||
|
"INFO",
|
||||||
|
{
|
||||||
|
"tool": tool_name
|
||||||
|
}
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
# =========================
|
||||||
|
# SYSTEM BOOT
|
||||||
|
# =========================
|
||||||
|
|
||||||
|
def boot_system():
|
||||||
|
load_all_tools()
|
||||||
|
bind_registry_tools()
|
||||||
|
|
||||||
|
bus.emit(Event(
|
||||||
|
name="system_boot",
|
||||||
|
source="CORE",
|
||||||
|
level="INFO",
|
||||||
|
data={
|
||||||
|
"workspace": str(WORKSPACE_ROOT),
|
||||||
|
"server": SERVER_NAME,
|
||||||
|
"tool_count": len(registry._tools)
|
||||||
|
}
|
||||||
|
))
|
||||||
|
|
||||||
|
|
||||||
|
# =========================
|
||||||
|
# METRICS
|
||||||
|
# =========================
|
||||||
|
|
||||||
|
def get_metrics_snapshot():
|
||||||
|
return metrics.snapshot()
|
||||||
|
|
||||||
|
|
||||||
|
# =========================
|
||||||
|
# DEBUG EVENTS (OPTIONAL)
|
||||||
|
# =========================
|
||||||
|
|
||||||
|
def debug_event_printer(event: Event):
|
||||||
|
print(
|
||||||
|
f"[EVENT] "
|
||||||
|
f"{event.source} - "
|
||||||
|
f"{event.level} - "
|
||||||
|
f"{event.name}"
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
# bus.subscribe(debug_event_printer)
|
||||||
|
|
||||||
|
|
||||||
|
# =========================
|
||||||
|
# MCP RUNNER
|
||||||
|
# =========================
|
||||||
|
|
||||||
|
def run_mcp():
|
||||||
|
boot_system()
|
||||||
|
|
||||||
|
bus.emit(Event(
|
||||||
|
name="mcp_start",
|
||||||
|
source="CORE",
|
||||||
|
level="INFO",
|
||||||
|
data={
|
||||||
|
"server": SERVER_NAME
|
||||||
|
}
|
||||||
|
))
|
||||||
|
|
||||||
|
mcp.run(transport="stdio")
|
||||||
|
|
||||||
|
|
||||||
|
# =========================
|
||||||
|
# FASTAPI HOOK (ISOLATED)
|
||||||
|
# =========================
|
||||||
|
|
||||||
|
async def run_api():
|
||||||
|
from fastapi import FastAPI
|
||||||
|
import uvicorn
|
||||||
|
|
||||||
|
app = FastAPI()
|
||||||
|
|
||||||
|
@app.get("/metrics")
|
||||||
|
def metrics_route():
|
||||||
|
return get_metrics_snapshot()
|
||||||
|
|
||||||
|
@app.get("/health")
|
||||||
|
def health():
|
||||||
|
return {
|
||||||
|
"status": "ok"
|
||||||
|
}
|
||||||
|
|
||||||
|
config = uvicorn.Config(
|
||||||
|
app,
|
||||||
|
host=API_HOST,
|
||||||
|
port=API_PORT,
|
||||||
|
log_level="info"
|
||||||
|
)
|
||||||
|
|
||||||
|
server = uvicorn.Server(config)
|
||||||
|
|
||||||
|
await server.serve()
|
||||||
|
|
||||||
|
|
||||||
|
# =========================
|
||||||
|
# ENTRYPOINT
|
||||||
|
# =========================
|
||||||
|
|
||||||
|
if __name__ == "__main__":
|
||||||
|
try:
|
||||||
|
run_mcp()
|
||||||
|
|
||||||
|
except KeyboardInterrupt:
|
||||||
|
bus.emit(Event(
|
||||||
|
name="system_shutdown",
|
||||||
|
source="CORE",
|
||||||
|
level="INFO"
|
||||||
|
))
|
||||||
|
|
||||||
|
except Exception as e:
|
||||||
|
bus.emit(Event(
|
||||||
|
name="fatal_error",
|
||||||
|
source="CORE",
|
||||||
|
level="ERROR",
|
||||||
|
data={
|
||||||
|
"error": str(e)
|
||||||
|
}
|
||||||
|
))
|
||||||
|
raise
|
||||||
8
tests/conftest.py
Normal file
8
tests/conftest.py
Normal file
@@ -0,0 +1,8 @@
|
|||||||
|
import pytest
|
||||||
|
from pathlib import Path
|
||||||
|
|
||||||
|
TEST_ROOT = Path(__file__).parent
|
||||||
|
|
||||||
|
@pytest.fixture
|
||||||
|
def workspace(tmp_path):
|
||||||
|
return tmp_path
|
||||||
95
tests/profiles.json
Normal file
95
tests/profiles.json
Normal file
@@ -0,0 +1,95 @@
|
|||||||
|
{
|
||||||
|
"schema_version": "1.0.0",
|
||||||
|
"description": "MCP test execution profiles for safe, staged tool testing and CI orchestration.",
|
||||||
|
|
||||||
|
"default_profile": "unit",
|
||||||
|
|
||||||
|
"global": {
|
||||||
|
"timeout_seconds": 10,
|
||||||
|
"allow_network": false,
|
||||||
|
"allow_subprocess": true,
|
||||||
|
"allow_filesystem_write": false,
|
||||||
|
"allow_docker": false,
|
||||||
|
"allow_cmake": false,
|
||||||
|
"allow_qemu": false,
|
||||||
|
"allow_git_push": false,
|
||||||
|
"log_level": "info"
|
||||||
|
},
|
||||||
|
|
||||||
|
"profiles": {
|
||||||
|
"unit": {
|
||||||
|
"description": "Fast isolated tests. No side effects.",
|
||||||
|
"inherits": "global",
|
||||||
|
"overrides": {
|
||||||
|
"allow_filesystem_write": false,
|
||||||
|
"allow_network": false,
|
||||||
|
"timeout_seconds": 5
|
||||||
|
}
|
||||||
|
},
|
||||||
|
|
||||||
|
"integration": {
|
||||||
|
"description": "Tool interaction tests (http, subprocess, file system).",
|
||||||
|
"inherits": "global",
|
||||||
|
"overrides": {
|
||||||
|
"allow_filesystem_write": true,
|
||||||
|
"allow_network": true,
|
||||||
|
"timeout_seconds": 30
|
||||||
|
}
|
||||||
|
},
|
||||||
|
|
||||||
|
"dangerous": {
|
||||||
|
"description": "Full system tests. Docker, cmake, qemu allowed.",
|
||||||
|
"inherits": "global",
|
||||||
|
"overrides": {
|
||||||
|
"allow_filesystem_write": true,
|
||||||
|
"allow_network": true,
|
||||||
|
"allow_docker": true,
|
||||||
|
"allow_cmake": true,
|
||||||
|
"allow_qemu": true,
|
||||||
|
"timeout_seconds": 600
|
||||||
|
}
|
||||||
|
},
|
||||||
|
|
||||||
|
"chaos": {
|
||||||
|
"description": "Stress testing and randomness injection.",
|
||||||
|
"inherits": "dangerous",
|
||||||
|
"overrides": {
|
||||||
|
"allow_network": true,
|
||||||
|
"timeout_seconds": 120,
|
||||||
|
"enable_reruns": true,
|
||||||
|
"enable_random_order": true,
|
||||||
|
"enable_parallel": true
|
||||||
|
}
|
||||||
|
},
|
||||||
|
|
||||||
|
"ci": {
|
||||||
|
"description": "CI-safe deterministic execution.",
|
||||||
|
"inherits": "unit",
|
||||||
|
"overrides": {
|
||||||
|
"timeout_seconds": 15,
|
||||||
|
"enable_coverage": true
|
||||||
|
}
|
||||||
|
}
|
||||||
|
},
|
||||||
|
|
||||||
|
"tool_rules": {
|
||||||
|
"cmake": {
|
||||||
|
"allowed_profiles": ["dangerous", "chaos"]
|
||||||
|
},
|
||||||
|
"docker": {
|
||||||
|
"allowed_profiles": ["dangerous", "chaos"]
|
||||||
|
},
|
||||||
|
"qemu": {
|
||||||
|
"allowed_profiles": ["dangerous"]
|
||||||
|
},
|
||||||
|
"filesystem_write": {
|
||||||
|
"allowed_profiles": ["integration", "dangerous", "chaos"]
|
||||||
|
},
|
||||||
|
"git_push": {
|
||||||
|
"allowed_profiles": ["dangerous"]
|
||||||
|
},
|
||||||
|
"network": {
|
||||||
|
"allowed_profiles": ["integration", "dangerous", "chaos"]
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
23
tests/tool_types.json
Normal file
23
tests/tool_types.json
Normal file
@@ -0,0 +1,23 @@
|
|||||||
|
{
|
||||||
|
"schema_version": "1.0.0",
|
||||||
|
|
||||||
|
"tool_types": {
|
||||||
|
"filesystem": ["read_file", "write_file", "delete_file", "append_file"],
|
||||||
|
"build": ["cmake_configure", "cmake_build", "cmake_clean"],
|
||||||
|
"container": ["docker_run", "docker_exec", "docker_build"],
|
||||||
|
"vm": ["qemu_run", "bochs_run"],
|
||||||
|
"media": ["ffmpeg_transcode", "extract_audio", "resize_image"],
|
||||||
|
"network": ["gitea_push", "http_request"],
|
||||||
|
"analysis": ["search_text", "analyze_code", "workspace_stats"]
|
||||||
|
},
|
||||||
|
|
||||||
|
"risk_levels": {
|
||||||
|
"filesystem": "medium",
|
||||||
|
"build": "high",
|
||||||
|
"container": "high",
|
||||||
|
"vm": "critical",
|
||||||
|
"media": "low",
|
||||||
|
"network": "medium",
|
||||||
|
"analysis": "low"
|
||||||
|
}
|
||||||
|
}
|
||||||
16
tools/__init__.py
Normal file
16
tools/__init__.py
Normal file
@@ -0,0 +1,16 @@
|
|||||||
|
"""
|
||||||
|
Tool bootstrap.
|
||||||
|
|
||||||
|
Importing this module forces
|
||||||
|
tool registration into registry.
|
||||||
|
"""
|
||||||
|
|
||||||
|
from tools.ping import PingTool
|
||||||
|
from tools.filesystem import FilesystemTool
|
||||||
|
from tools.subprocess import SubprocessTool
|
||||||
|
|
||||||
|
__all__ = [
|
||||||
|
"PingTool",
|
||||||
|
"FilesystemTool",
|
||||||
|
"SubprocessTool",
|
||||||
|
]
|
||||||
0
tools/bochs.py
Normal file
0
tools/bochs.py
Normal file
0
tools/cmake.py
Normal file
0
tools/cmake.py
Normal file
22
tools/discovery.py
Normal file
22
tools/discovery.py
Normal file
@@ -0,0 +1,22 @@
|
|||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
import importlib
|
||||||
|
import pkgutil
|
||||||
|
|
||||||
|
import tools as tools_pkg
|
||||||
|
|
||||||
|
|
||||||
|
def load_all_tools():
|
||||||
|
"""
|
||||||
|
Explicit tool loader.
|
||||||
|
|
||||||
|
Imports all modules inside /tools so they register
|
||||||
|
themselves into the registry.
|
||||||
|
"""
|
||||||
|
|
||||||
|
for module in pkgutil.iter_modules(
|
||||||
|
tools_pkg.__path__
|
||||||
|
):
|
||||||
|
importlib.import_module(
|
||||||
|
f"tools.{module.name}"
|
||||||
|
)
|
||||||
0
tools/docker.py
Normal file
0
tools/docker.py
Normal file
0
tools/ffmpeg.py
Normal file
0
tools/ffmpeg.py
Normal file
225
tools/filesystem.py
Normal file
225
tools/filesystem.py
Normal file
@@ -0,0 +1,225 @@
|
|||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
from pathlib import Path
|
||||||
|
from typing import Any
|
||||||
|
|
||||||
|
from core.events import bus
|
||||||
|
from core.safety import safety
|
||||||
|
from core.tools.base import BaseTool, ToolContext
|
||||||
|
from core.tools.registry import registry
|
||||||
|
|
||||||
|
|
||||||
|
class FilesystemTool(BaseTool):
|
||||||
|
name = "filesystem"
|
||||||
|
description = "Safe filesystem operations"
|
||||||
|
|
||||||
|
# =========================
|
||||||
|
# EXECUTE
|
||||||
|
# =========================
|
||||||
|
|
||||||
|
def execute(
|
||||||
|
self,
|
||||||
|
payload: dict[str, Any],
|
||||||
|
ctx: ToolContext
|
||||||
|
):
|
||||||
|
action = str(payload.get("action", "")).strip()
|
||||||
|
|
||||||
|
bus.log(
|
||||||
|
"FILESYSTEM",
|
||||||
|
"filesystem_execute",
|
||||||
|
"INFO",
|
||||||
|
{
|
||||||
|
"action": action
|
||||||
|
}
|
||||||
|
)
|
||||||
|
|
||||||
|
match action:
|
||||||
|
case "read_file":
|
||||||
|
return self.read_file(payload)
|
||||||
|
|
||||||
|
case "write_file":
|
||||||
|
return self.write_file(payload)
|
||||||
|
|
||||||
|
case "list_dir":
|
||||||
|
return self.list_dir(payload)
|
||||||
|
|
||||||
|
case "exists":
|
||||||
|
return self.exists(payload)
|
||||||
|
|
||||||
|
case "mkdir":
|
||||||
|
return self.mkdir(payload)
|
||||||
|
|
||||||
|
case _:
|
||||||
|
raise ValueError(
|
||||||
|
f"Unknown filesystem action: {action}"
|
||||||
|
)
|
||||||
|
|
||||||
|
# =========================
|
||||||
|
# READ FILE
|
||||||
|
# =========================
|
||||||
|
|
||||||
|
def read_file(
|
||||||
|
self,
|
||||||
|
payload: dict[str, Any]
|
||||||
|
):
|
||||||
|
path_value = payload.get("path")
|
||||||
|
|
||||||
|
if not isinstance(path_value, str):
|
||||||
|
raise ValueError("path must be string")
|
||||||
|
|
||||||
|
path = safety.validate_path(path_value)
|
||||||
|
|
||||||
|
return {
|
||||||
|
"path": str(path),
|
||||||
|
"content": path.read_text(
|
||||||
|
encoding="utf-8"
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
# =========================
|
||||||
|
# WRITE FILE
|
||||||
|
# =========================
|
||||||
|
|
||||||
|
def write_file(
|
||||||
|
self,
|
||||||
|
payload: dict[str, Any]
|
||||||
|
):
|
||||||
|
path_value = payload.get("path")
|
||||||
|
content_value = payload.get("content")
|
||||||
|
|
||||||
|
if not isinstance(path_value, str):
|
||||||
|
raise ValueError("path must be string")
|
||||||
|
|
||||||
|
if not isinstance(content_value, str):
|
||||||
|
raise ValueError(
|
||||||
|
"content must be string"
|
||||||
|
)
|
||||||
|
|
||||||
|
path = safety.validate_path(path_value)
|
||||||
|
|
||||||
|
safety.check_file_write(
|
||||||
|
path,
|
||||||
|
content_value
|
||||||
|
)
|
||||||
|
|
||||||
|
path.parent.mkdir(
|
||||||
|
parents=True,
|
||||||
|
exist_ok=True
|
||||||
|
)
|
||||||
|
|
||||||
|
path.write_text(
|
||||||
|
content_value,
|
||||||
|
encoding="utf-8"
|
||||||
|
)
|
||||||
|
|
||||||
|
return {
|
||||||
|
"ok": True,
|
||||||
|
"path": str(path),
|
||||||
|
"bytes_written": len(
|
||||||
|
content_value.encode("utf-8")
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
# =========================
|
||||||
|
# LIST DIRECTORY
|
||||||
|
# =========================
|
||||||
|
|
||||||
|
def list_dir(
|
||||||
|
self,
|
||||||
|
payload: dict[str, Any]
|
||||||
|
):
|
||||||
|
path_value = payload.get(
|
||||||
|
"path",
|
||||||
|
"."
|
||||||
|
)
|
||||||
|
|
||||||
|
if not isinstance(path_value, str):
|
||||||
|
raise ValueError(
|
||||||
|
"path must be string"
|
||||||
|
)
|
||||||
|
|
||||||
|
path = safety.validate_path(
|
||||||
|
path_value
|
||||||
|
)
|
||||||
|
|
||||||
|
entries: list[dict[str, Any]] = []
|
||||||
|
|
||||||
|
for item in path.iterdir():
|
||||||
|
entries.append(
|
||||||
|
{
|
||||||
|
"name": item.name,
|
||||||
|
"path": str(item),
|
||||||
|
"is_dir": item.is_dir(),
|
||||||
|
"is_file": item.is_file()
|
||||||
|
}
|
||||||
|
)
|
||||||
|
|
||||||
|
return {
|
||||||
|
"path": str(path),
|
||||||
|
"count": len(entries),
|
||||||
|
"entries": entries
|
||||||
|
}
|
||||||
|
|
||||||
|
# =========================
|
||||||
|
# EXISTS
|
||||||
|
# =========================
|
||||||
|
|
||||||
|
def exists(
|
||||||
|
self,
|
||||||
|
payload: dict[str, Any]
|
||||||
|
):
|
||||||
|
path_value = payload.get("path")
|
||||||
|
|
||||||
|
if not isinstance(path_value, str):
|
||||||
|
raise ValueError(
|
||||||
|
"path must be string"
|
||||||
|
)
|
||||||
|
|
||||||
|
path = safety.validate_path(
|
||||||
|
path_value
|
||||||
|
)
|
||||||
|
|
||||||
|
return {
|
||||||
|
"path": str(path),
|
||||||
|
"exists": path.exists(),
|
||||||
|
"is_dir": path.is_dir(),
|
||||||
|
"is_file": path.is_file()
|
||||||
|
}
|
||||||
|
|
||||||
|
# =========================
|
||||||
|
# MKDIR
|
||||||
|
# =========================
|
||||||
|
|
||||||
|
def mkdir(
|
||||||
|
self,
|
||||||
|
payload: dict[str, Any]
|
||||||
|
):
|
||||||
|
path_value = payload.get("path")
|
||||||
|
|
||||||
|
if not isinstance(path_value, str):
|
||||||
|
raise ValueError(
|
||||||
|
"path must be string"
|
||||||
|
)
|
||||||
|
|
||||||
|
path = safety.validate_path(
|
||||||
|
path_value
|
||||||
|
)
|
||||||
|
|
||||||
|
path.mkdir(
|
||||||
|
parents=True,
|
||||||
|
exist_ok=True
|
||||||
|
)
|
||||||
|
|
||||||
|
return {
|
||||||
|
"ok": True,
|
||||||
|
"path": str(path)
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
# =========================
|
||||||
|
# SELF REGISTER
|
||||||
|
# =========================
|
||||||
|
|
||||||
|
registry.register(
|
||||||
|
FilesystemTool()
|
||||||
|
)
|
||||||
0
tools/git.py
Normal file
0
tools/git.py
Normal file
50
tools/gitea.py
Normal file
50
tools/gitea.py
Normal file
@@ -0,0 +1,50 @@
|
|||||||
|
import requests
|
||||||
|
from core.config import GITEA_URL, GITEA_TOKEN
|
||||||
|
from core.logging_core import logger
|
||||||
|
|
||||||
|
HEADERS = {
|
||||||
|
"Authorization": f"token {GITEA_TOKEN}",
|
||||||
|
"Content-Type": "application/json"
|
||||||
|
}
|
||||||
|
|
||||||
|
def create_repo(name: str, private: bool = True):
|
||||||
|
url = f"{GITEA_URL}/api/v1/user/repos"
|
||||||
|
|
||||||
|
payload = {
|
||||||
|
"name": name,
|
||||||
|
"private": private,
|
||||||
|
"auto_init": True
|
||||||
|
}
|
||||||
|
|
||||||
|
r = requests.post(url, json=payload, headers=HEADERS)
|
||||||
|
|
||||||
|
logger.info(f"[GITEA] create_repo={name} status={r.status_code}")
|
||||||
|
|
||||||
|
return r.json()
|
||||||
|
|
||||||
|
import subprocess
|
||||||
|
from core.subprocess import run_command
|
||||||
|
|
||||||
|
def git_push(repo_path: str, message: str):
|
||||||
|
cmds = [
|
||||||
|
["git", "-C", repo_path, "add", "."],
|
||||||
|
["git", "-C", repo_path, "commit", "-m", message],
|
||||||
|
["git", "-C", repo_path, "push"]
|
||||||
|
]
|
||||||
|
|
||||||
|
results = [run_command(c) for c in cmds]
|
||||||
|
return results
|
||||||
|
|
||||||
|
def create_or_update_file(owner, repo, path, content, message):
|
||||||
|
url = f"{GITEA_URL}/api/v1/repos/{owner}/{repo}/contents/{path}"
|
||||||
|
|
||||||
|
payload = {
|
||||||
|
"content": content.encode("utf-8").decode("utf-8"),
|
||||||
|
"message": message
|
||||||
|
}
|
||||||
|
|
||||||
|
r = requests.post(url, json=payload, headers=HEADERS)
|
||||||
|
|
||||||
|
logger.info(f"[GITEA] file={path} status={r.status_code}")
|
||||||
|
|
||||||
|
return r.json()
|
||||||
45
tools/ping.py
Normal file
45
tools/ping.py
Normal file
@@ -0,0 +1,45 @@
|
|||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
from core.tools.base import BaseTool, ToolContext
|
||||||
|
from core.tools.registry import registry
|
||||||
|
from core.events import bus
|
||||||
|
|
||||||
|
|
||||||
|
class PingTool(BaseTool):
|
||||||
|
name = "ping"
|
||||||
|
|
||||||
|
# optional metadata (future MCP auto-binding)
|
||||||
|
description = "Health check"
|
||||||
|
|
||||||
|
# -------------------------
|
||||||
|
# EXECUTE
|
||||||
|
# -------------------------
|
||||||
|
|
||||||
|
def execute(
|
||||||
|
self,
|
||||||
|
payload: dict[str, str],
|
||||||
|
ctx: ToolContext
|
||||||
|
):
|
||||||
|
message = payload.get("message", "pong")
|
||||||
|
|
||||||
|
bus.log(
|
||||||
|
"TOOLS",
|
||||||
|
"ping_execute",
|
||||||
|
"INFO",
|
||||||
|
{
|
||||||
|
"message": message
|
||||||
|
}
|
||||||
|
)
|
||||||
|
|
||||||
|
return {
|
||||||
|
"status": "ok",
|
||||||
|
"echo": message,
|
||||||
|
"tool": self.name
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
# =========================
|
||||||
|
# SELF REGISTER
|
||||||
|
# =========================
|
||||||
|
|
||||||
|
registry.register(PingTool())
|
||||||
0
tools/qemu.py
Normal file
0
tools/qemu.py
Normal file
66
tools/subprocess.py
Normal file
66
tools/subprocess.py
Normal file
@@ -0,0 +1,66 @@
|
|||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
from typing import Any
|
||||||
|
|
||||||
|
from core.subprocess import run_command
|
||||||
|
from core.tools.base import BaseTool, ToolContext
|
||||||
|
from core.tools.registry import registry
|
||||||
|
|
||||||
|
|
||||||
|
class SubprocessTool(BaseTool):
|
||||||
|
name = "subprocess"
|
||||||
|
description = "Run a subprocess command safely"
|
||||||
|
|
||||||
|
# =========================
|
||||||
|
# EXECUTE
|
||||||
|
# =========================
|
||||||
|
|
||||||
|
def execute(
|
||||||
|
self,
|
||||||
|
payload: dict[str, Any],
|
||||||
|
ctx: ToolContext
|
||||||
|
):
|
||||||
|
cmd = payload.get("cmd")
|
||||||
|
|
||||||
|
if not isinstance(cmd, list):
|
||||||
|
raise ValueError(
|
||||||
|
"cmd must be list[str]"
|
||||||
|
)
|
||||||
|
|
||||||
|
cwd = payload.get("cwd")
|
||||||
|
|
||||||
|
if cwd is not None and not isinstance(
|
||||||
|
cwd,
|
||||||
|
str
|
||||||
|
):
|
||||||
|
raise ValueError(
|
||||||
|
"cwd must be string"
|
||||||
|
)
|
||||||
|
|
||||||
|
timeout = payload.get(
|
||||||
|
"timeout",
|
||||||
|
60
|
||||||
|
)
|
||||||
|
|
||||||
|
if not isinstance(
|
||||||
|
timeout,
|
||||||
|
int
|
||||||
|
):
|
||||||
|
raise ValueError(
|
||||||
|
"timeout must be int"
|
||||||
|
)
|
||||||
|
|
||||||
|
return run_command(
|
||||||
|
cmd=cmd,
|
||||||
|
cwd=cwd,
|
||||||
|
timeout=timeout
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
# =========================
|
||||||
|
# SELF REGISTER
|
||||||
|
# =========================
|
||||||
|
|
||||||
|
registry.register(
|
||||||
|
SubprocessTool()
|
||||||
|
)
|
||||||
0
tools/venv.py
Normal file
0
tools/venv.py
Normal file
Reference in New Issue
Block a user
