|
|
"""Node MCP tool implementations and lightweight HTTP server.""" |
|
|
|
|
|
from __future__ import annotations |
|
|
|
|
|
import json |
|
|
import logging |
|
|
import os |
|
|
import platform |
|
|
import shlex |
|
|
import shutil |
|
|
import subprocess |
|
|
import time |
|
|
from dataclasses import dataclass |
|
|
from http import HTTPStatus |
|
|
from http.server import BaseHTTPRequestHandler, ThreadingHTTPServer |
|
|
from pathlib import Path |
|
|
from typing import Any, Callable, Dict |
|
|
|
|
|
import psutil |
|
|
from pydantic import BaseModel, Field, ValidationError |
|
|
|
|
|
from .config import NodeConfig |
|
|
from .filesystem import hash_file, list_files |
|
|
|
|
|
logger = logging.getLogger(__name__) |
|
|
|
|
|
ToolFunc = Callable[[NodeConfig, dict[str, Any]], dict[str, Any]] |
|
|
|
|
|
|
|
|
def _resolve_within_root(root: Path, requested: str) -> Path: |
|
|
candidate = Path(requested) |
|
|
candidate = candidate.expanduser() |
|
|
if not candidate.is_absolute(): |
|
|
candidate = (root / candidate).resolve() |
|
|
else: |
|
|
candidate = candidate.resolve() |
|
|
|
|
|
try: |
|
|
candidate.relative_to(root) |
|
|
except ValueError as exc: |
|
|
raise PermissionError(f"Requested path escapes root: {candidate}") from exc |
|
|
return candidate |
|
|
|
|
|
|
|
|
class ListFilesRequest(BaseModel): |
|
|
path: str = "." |
|
|
recursive: bool = False |
|
|
pattern: str | None = None |
|
|
include_hash: bool = False |
|
|
limit: int | None = Field(default=None, ge=1, le=20_000) |
|
|
|
|
|
|
|
|
class ReadFileRequest(BaseModel): |
|
|
path: str |
|
|
encoding: str | None = "utf-8" |
|
|
max_bytes: int | None = Field(default=None, ge=1, le=50_000_000) |
|
|
|
|
|
|
|
|
class WriteFileRequest(BaseModel): |
|
|
path: str |
|
|
content: str |
|
|
encoding: str = "utf-8" |
|
|
overwrite: bool = False |
|
|
create_dirs: bool = True |
|
|
backup: bool = True |
|
|
|
|
|
|
|
|
class ExecuteCommandRequest(BaseModel): |
|
|
command: list[str] | str |
|
|
timeout: float = Field(default=60.0, gt=0, le=600) |
|
|
env: dict[str, str] = Field(default_factory=dict) |
|
|
cwd: str | None = None |
|
|
|
|
|
|
|
|
class SyncFilesRequest(BaseModel): |
|
|
source_path: str |
|
|
targets: list[str] = Field(..., min_length=1) |
|
|
strategy: str = Field(default="mirror", pattern=r"^(mirror|append)$") |
|
|
|
|
|
|
|
|
class GetNodeInfoRequest(BaseModel): |
|
|
include_processes: bool = False |
|
|
|
|
|
|
|
|
@dataclass(slots=True) |
|
|
class SyncReport: |
|
|
target: str |
|
|
dest_path: str |
|
|
files_synced: int |
|
|
bytes_copied: int |
|
|
duration: float |
|
|
|
|
|
def to_dict(self) -> dict[str, Any]: |
|
|
return { |
|
|
"target": self.target, |
|
|
"dest_path": self.dest_path, |
|
|
"files_synced": self.files_synced, |
|
|
"bytes_copied": self.bytes_copied, |
|
|
"duration": self.duration, |
|
|
} |
|
|
|
|
|
|
|
|
def list_files_tool(config: NodeConfig, payload: dict[str, Any]) -> dict[str, Any]: |
|
|
request = ListFilesRequest.model_validate(payload) |
|
|
target = _resolve_within_root(config.root_dir, request.path) |
|
|
files = list_files( |
|
|
target, |
|
|
recursive=request.recursive, |
|
|
pattern=request.pattern, |
|
|
include_hash=request.include_hash, |
|
|
root=config.root_dir, |
|
|
) |
|
|
if request.limit is not None: |
|
|
files = files[: request.limit] |
|
|
return {"files": [file.to_dict() for file in files], "count": len(files)} |
|
|
|
|
|
|
|
|
def read_file_tool(config: NodeConfig, payload: dict[str, Any]) -> dict[str, Any]: |
|
|
request = ReadFileRequest.model_validate(payload) |
|
|
target = _resolve_within_root(config.root_dir, request.path) |
|
|
if not target.exists(): |
|
|
raise FileNotFoundError(str(target)) |
|
|
if target.is_dir(): |
|
|
raise IsADirectoryError(str(target)) |
|
|
|
|
|
data = target.read_bytes() |
|
|
if request.max_bytes and len(data) > request.max_bytes: |
|
|
raise ValueError("File exceeds max_bytes limit") |
|
|
|
|
|
content: str | None = None |
|
|
if request.encoding: |
|
|
content = data.decode(request.encoding) |
|
|
return { |
|
|
"path": str(target.relative_to(config.root_dir)), |
|
|
"size": len(data), |
|
|
"hash": hash_file(target), |
|
|
"content": content, |
|
|
} |
|
|
|
|
|
|
|
|
def write_file_tool(config: NodeConfig, payload: dict[str, Any]) -> dict[str, Any]: |
|
|
request = WriteFileRequest.model_validate(payload) |
|
|
target = _resolve_within_root(config.root_dir, request.path) |
|
|
target.parent.mkdir(parents=True, exist_ok=request.create_dirs) |
|
|
|
|
|
backup_path: Path | None = None |
|
|
if target.exists(): |
|
|
if not request.overwrite: |
|
|
raise FileExistsError(str(target)) |
|
|
if request.backup: |
|
|
timestamp = int(time.time()) |
|
|
backup_path = target.parent / f"{target.name}.bak.{timestamp}" |
|
|
shutil.copy2(target, backup_path) |
|
|
|
|
|
data = request.content.encode(request.encoding) |
|
|
target.write_bytes(data) |
|
|
file_hash = hash_file(target) |
|
|
return { |
|
|
"success": True, |
|
|
"path": str(target.relative_to(config.root_dir)), |
|
|
"bytes_written": len(data), |
|
|
"hash": file_hash, |
|
|
"backup_path": str(backup_path) if backup_path else None, |
|
|
"message": f"File written successfully: {target.relative_to(config.root_dir)}" |
|
|
} |
|
|
|
|
|
|
|
|
def execute_command_tool(config: NodeConfig, payload: dict[str, Any]) -> dict[str, Any]: |
|
|
request = ExecuteCommandRequest.model_validate(payload) |
|
|
if isinstance(request.command, str): |
|
|
command = shlex.split(request.command) |
|
|
else: |
|
|
command = request.command |
|
|
|
|
|
if not command: |
|
|
raise ValueError("Command cannot be empty") |
|
|
|
|
|
base_cmd = Path(command[0]).name |
|
|
if base_cmd not in config.allowed_commands: |
|
|
raise PermissionError(f"Command '{base_cmd}' not on allow list") |
|
|
|
|
|
cwd = _resolve_within_root(config.root_dir, request.cwd) if request.cwd else config.root_dir |
|
|
|
|
|
env = os.environ.copy() |
|
|
env.update({key: value for key, value in request.env.items() if isinstance(value, str)}) |
|
|
|
|
|
started = time.time() |
|
|
proc = subprocess.run( |
|
|
command, |
|
|
cwd=str(cwd), |
|
|
env=env, |
|
|
capture_output=True, |
|
|
text=True, |
|
|
timeout=request.timeout, |
|
|
) |
|
|
duration = time.time() - started |
|
|
return { |
|
|
"command": command, |
|
|
"stdout": proc.stdout, |
|
|
"stderr": proc.stderr, |
|
|
"exit_code": proc.returncode, |
|
|
"duration": duration, |
|
|
"cwd": str(cwd.relative_to(config.root_dir)), |
|
|
} |
|
|
|
|
|
|
|
|
def sync_files_tool(config: NodeConfig, payload: dict[str, Any]) -> dict[str, Any]: |
|
|
request = SyncFilesRequest.model_validate(payload) |
|
|
source = _resolve_within_root(config.root_dir, request.source_path) |
|
|
if not source.exists(): |
|
|
raise FileNotFoundError(str(source)) |
|
|
|
|
|
reports: list[SyncReport] = [] |
|
|
source_rel = source.relative_to(config.root_dir) |
|
|
|
|
|
for target_name in request.targets: |
|
|
if target_name not in config.sync_targets: |
|
|
raise ValueError(f"Unknown sync target: {target_name}") |
|
|
target_dir = config.sync_targets[target_name] |
|
|
dest_root = target_dir / source_rel |
|
|
dest_root.parent.mkdir(parents=True, exist_ok=True) |
|
|
|
|
|
started = time.time() |
|
|
files_synced, bytes_copied = _copy_path(source, dest_root, strategy=request.strategy) |
|
|
duration = time.time() - started |
|
|
reports.append( |
|
|
SyncReport( |
|
|
target=target_name, |
|
|
dest_path=str(dest_root), |
|
|
files_synced=files_synced, |
|
|
bytes_copied=bytes_copied, |
|
|
duration=duration, |
|
|
) |
|
|
) |
|
|
|
|
|
return { |
|
|
"source": str(source_rel), |
|
|
"targets": [report.to_dict() for report in reports], |
|
|
} |
|
|
|
|
|
|
|
|
def _copy_path(source: Path, dest: Path, *, strategy: str) -> tuple[int, int]: |
|
|
files_synced = 0 |
|
|
bytes_copied = 0 |
|
|
|
|
|
if source.is_file(): |
|
|
dest.parent.mkdir(parents=True, exist_ok=True) |
|
|
shutil.copy2(source, dest) |
|
|
files_synced = 1 |
|
|
bytes_copied = source.stat().st_size |
|
|
return files_synced, bytes_copied |
|
|
|
|
|
if strategy == "mirror" and dest.exists(): |
|
|
shutil.rmtree(dest) |
|
|
|
|
|
for src_file in source.rglob("*"): |
|
|
if not src_file.is_file(): |
|
|
continue |
|
|
rel = src_file.relative_to(source) |
|
|
dest_file = dest / rel |
|
|
dest_file.parent.mkdir(parents=True, exist_ok=True) |
|
|
shutil.copy2(src_file, dest_file) |
|
|
files_synced += 1 |
|
|
bytes_copied += src_file.stat().st_size |
|
|
|
|
|
return files_synced, bytes_copied |
|
|
|
|
|
|
|
|
def get_node_info_tool(config: NodeConfig, payload: dict[str, Any]) -> dict[str, Any]: |
|
|
_ = GetNodeInfoRequest.model_validate(payload or {}) |
|
|
cpu = psutil.cpu_percent(interval=0.05) |
|
|
mem = psutil.virtual_memory() |
|
|
disk = psutil.disk_usage(str(config.root_dir)) |
|
|
boot_time = psutil.boot_time() |
|
|
|
|
|
return { |
|
|
"node_id": config.node_id, |
|
|
"tags": config.tags, |
|
|
"description": config.description, |
|
|
"root_dir": str(config.root_dir), |
|
|
"allowed_commands": config.allowed_commands, |
|
|
"sync_targets": {key: str(value) for key, value in config.sync_targets.items()}, |
|
|
"metrics": { |
|
|
"cpu_percent": cpu, |
|
|
"memory_percent": mem.percent, |
|
|
"memory_total": mem.total, |
|
|
"disk_percent": disk.percent, |
|
|
"disk_total": disk.total, |
|
|
"uptime_seconds": time.time() - boot_time, |
|
|
}, |
|
|
"platform": { |
|
|
"system": platform.system(), |
|
|
"release": platform.release(), |
|
|
"version": platform.version(), |
|
|
"machine": platform.machine(), |
|
|
"python_version": platform.python_version(), |
|
|
}, |
|
|
"timestamp": time.time(), |
|
|
} |
|
|
|
|
|
|
|
|
class NodeServer: |
|
|
"""Minimal HTTP server that exposes node tools as JSON endpoints.""" |
|
|
|
|
|
def __init__(self, config: NodeConfig, *, host: str = "0.0.0.0", port: int = 8765): |
|
|
self.config = config |
|
|
self.host = host |
|
|
self.port = port |
|
|
self._httpd: ThreadingHTTPServer | None = None |
|
|
|
|
|
def serve_forever(self) -> None: |
|
|
handler = self._build_handler() |
|
|
self._httpd = ThreadingHTTPServer((self.host, self.port), handler) |
|
|
logger.info("[nacc-node] serving http://%s:%s", self.host, self.port) |
|
|
try: |
|
|
self._httpd.serve_forever() |
|
|
finally: |
|
|
self._httpd.server_close() |
|
|
logger.info("[nacc-node] server stopped") |
|
|
|
|
|
def shutdown(self) -> None: |
|
|
if self._httpd: |
|
|
self._httpd.shutdown() |
|
|
|
|
|
def _build_handler(self) -> type[BaseHTTPRequestHandler]: |
|
|
config = self.config |
|
|
tools: Dict[str, ToolFunc] = { |
|
|
"list-files": list_files_tool, |
|
|
"read-file": read_file_tool, |
|
|
"write-file": write_file_tool, |
|
|
"execute-command": execute_command_tool, |
|
|
"sync-files": sync_files_tool, |
|
|
"get-node-info": get_node_info_tool, |
|
|
} |
|
|
max_body = 512 * 1024 |
|
|
|
|
|
class NodeRequestHandler(BaseHTTPRequestHandler): |
|
|
server_version = "NACCNode/0.3" |
|
|
|
|
|
def log_message(self, format: str, *args: Any) -> None: |
|
|
logger.info("%s - %s", self.address_string(), format % args) |
|
|
|
|
|
def _read_json_body(self) -> dict[str, Any]: |
|
|
content_length = int(self.headers.get("Content-Length", 0)) |
|
|
if content_length > max_body: |
|
|
raise ValueError("Payload too large") |
|
|
if content_length <= 0: |
|
|
return {} |
|
|
body = self.rfile.read(content_length) |
|
|
if not body: |
|
|
return {} |
|
|
return json.loads(body.decode("utf-8")) |
|
|
|
|
|
def _send_json(self, status: HTTPStatus, payload: dict[str, Any]) -> None: |
|
|
data = json.dumps(payload).encode("utf-8") |
|
|
self.send_response(status) |
|
|
self.send_header("Content-Type", "application/json") |
|
|
self.send_header("Content-Length", str(len(data))) |
|
|
self.end_headers() |
|
|
self.wfile.write(data) |
|
|
|
|
|
def do_GET(self) -> None: |
|
|
logger.info(f"GET request to: {self.path}") |
|
|
|
|
|
|
|
|
path_clean = self.path.split('?')[0] |
|
|
|
|
|
if path_clean == "/healthz": |
|
|
self._send_json(HTTPStatus.OK, { |
|
|
"status": "ok", |
|
|
"service": "nacc-node", |
|
|
"node_id": config.node_id |
|
|
}) |
|
|
return |
|
|
if path_clean == "/node": |
|
|
payload = get_node_info_tool(config, {}) |
|
|
self._send_json(HTTPStatus.OK, payload) |
|
|
return |
|
|
|
|
|
if path_clean == "/" or path_clean == "/index.html" or path_clean == "/dashboard": |
|
|
|
|
|
html = """ |
|
|
<!DOCTYPE html> |
|
|
<html lang="en"> |
|
|
<head> |
|
|
<meta charset="UTF-8"> |
|
|
<meta name="viewport" content="width=device-width, initial-scale=1.0"> |
|
|
<title>NACC VM Node</title> |
|
|
<style> |
|
|
body { font-family: 'Courier New', Courier, monospace; background: #0d1117; color: #c9d1d9; margin: 0; padding: 20px; } |
|
|
.container { max-width: 1000px; margin: 0 auto; } |
|
|
h1 { border-bottom: 1px solid #30363d; padding-bottom: 10px; color: #58a6ff; font-family: -apple-system, sans-serif; } |
|
|
.card { background: #161b22; border: 1px solid #30363d; border-radius: 6px; padding: 20px; margin-bottom: 20px; } |
|
|
.card h2 { margin-top: 0; font-size: 1.2em; color: #79c0ff; font-family: -apple-system, sans-serif; } |
|
|
|
|
|
/* Terminal Styles */ |
|
|
.terminal { background: #010409; padding: 15px; border-radius: 6px; border: 1px solid #30363d; height: 400px; overflow-y: auto; display: flex; flex-direction: column; } |
|
|
.output { flex-grow: 1; white-space: pre-wrap; word-break: break-all; } |
|
|
.input-line { display: flex; align-items: center; margin-top: 10px; border-top: 1px solid #21262d; padding-top: 10px; } |
|
|
.prompt { color: #3fb950; margin-right: 10px; font-weight: bold; } |
|
|
input { background: transparent; border: none; color: #c9d1d9; flex-grow: 1; font-family: inherit; font-size: 1em; outline: none; } |
|
|
|
|
|
.status-ok { color: #3fb950; font-weight: bold; } |
|
|
table { width: 100%; border-collapse: collapse; font-family: -apple-system, sans-serif; } |
|
|
th, td { text-align: left; padding: 8px; border-bottom: 1px solid #21262d; } |
|
|
th { color: #8b949e; } |
|
|
tr:hover { background: #21262d; } |
|
|
</style> |
|
|
</head> |
|
|
<body> |
|
|
<div class="container"> |
|
|
<h1>🖥️ NACC Virtual Machine Node</h1> |
|
|
|
|
|
<div class="card"> |
|
|
<h2>Status: <span class="status-ok">RUNNING</span></h2> |
|
|
<div id="node-info" style="font-family: -apple-system, sans-serif;">Loading system info...</div> |
|
|
</div> |
|
|
|
|
|
<div class="card"> |
|
|
<h2>💻 Secure Terminal</h2> |
|
|
<div class="terminal" id="terminal" onclick="document.getElementById('cmd-input').focus()"> |
|
|
<div class="output" id="output"> |
|
|
Welcome to NACC VM Secure Terminal. |
|
|
Allowed commands: ls, cat, pwd, echo, grep, find, head, tail, tree, whoami, id |
|
|
Type 'help' for info. |
|
|
</div> |
|
|
<div class="input-line"> |
|
|
<span class="prompt" id="prompt">user@vm:~$</span> |
|
|
<input type="text" id="cmd-input" autocomplete="off" spellcheck="false"> |
|
|
</div> |
|
|
</div> |
|
|
</div> |
|
|
|
|
|
<div class="card"> |
|
|
<h2>📂 File Browser</h2> |
|
|
<button onclick="listFiles()" style="background: #238636; color: white; border: none; padding: 6px 12px; border-radius: 6px; cursor: pointer;">Refresh Current Dir</button> |
|
|
<div id="file-list" style="margin-top: 10px;"></div> |
|
|
</div> |
|
|
</div> |
|
|
|
|
|
<script> |
|
|
let currentDir = "."; |
|
|
const input = document.getElementById('cmd-input'); |
|
|
const output = document.getElementById('output'); |
|
|
const prompt = document.getElementById('prompt'); |
|
|
|
|
|
input.addEventListener('keydown', async (e) => { |
|
|
if (e.key === 'Enter') { |
|
|
const cmd = input.value.trim(); |
|
|
input.value = ''; |
|
|
if (!cmd) return; |
|
|
|
|
|
appendToOutput(prompt.innerText + ' ' + cmd); |
|
|
await processCommand(cmd); |
|
|
// Keep focus |
|
|
input.focus(); |
|
|
// Scroll to bottom |
|
|
document.getElementById('terminal').scrollTop = document.getElementById('terminal').scrollHeight; |
|
|
} |
|
|
}); |
|
|
|
|
|
function appendToOutput(text) { |
|
|
const div = document.createElement('div'); |
|
|
div.innerText = text; |
|
|
output.appendChild(div); |
|
|
} |
|
|
|
|
|
async function processCommand(cmd) { |
|
|
const args = cmd.split(' '); |
|
|
const baseCmd = args[0]; |
|
|
|
|
|
if (baseCmd === 'clear') { |
|
|
output.innerHTML = ''; |
|
|
return; |
|
|
} |
|
|
|
|
|
if (baseCmd === 'help') { |
|
|
appendToOutput("Available commands: ls, cat, pwd, echo, grep, find, head, tail, tree, whoami, id\\nNavigation: cd <path>"); |
|
|
return; |
|
|
} |
|
|
|
|
|
if (baseCmd === 'cd') { |
|
|
const target = args[1] || '.'; |
|
|
// Optimistic update, verify with pwd/ls later if needed |
|
|
// Simple path joining logic for display |
|
|
if (target === '..') { |
|
|
// Very basic parent handling |
|
|
const parts = currentDir.split('/'); |
|
|
parts.pop(); |
|
|
currentDir = parts.join('/') || '.'; |
|
|
} else if (target.startsWith('/')) { |
|
|
currentDir = target; |
|
|
} else { |
|
|
currentDir = (currentDir === '.' ? '' : currentDir + '/') + target; |
|
|
} |
|
|
updatePrompt(); |
|
|
// Verify path by running ls |
|
|
try { |
|
|
await execute('ls', currentDir); |
|
|
} catch (e) { |
|
|
appendToOutput("Error: Directory not found (or access denied)"); |
|
|
// Revert? Nah, let user fix it |
|
|
} |
|
|
listFiles(); // Update file browser too |
|
|
return; |
|
|
} |
|
|
|
|
|
// Execute on server |
|
|
await execute(cmd, currentDir); |
|
|
} |
|
|
|
|
|
async function execute(command, cwd) { |
|
|
try { |
|
|
const res = await fetch('/tools/execute-command', { |
|
|
method: 'POST', |
|
|
headers: {'Content-Type': 'application/json'}, |
|
|
body: JSON.stringify({command: command, cwd: cwd}) |
|
|
}); |
|
|
const data = await res.json(); |
|
|
|
|
|
if (data.error) { |
|
|
appendToOutput("Error: " + data.error); |
|
|
} else { |
|
|
if (data.stdout) appendToOutput(data.stdout); |
|
|
if (data.stderr) appendToOutput("Stderr: " + data.stderr); |
|
|
if (data.exit_code !== 0) appendToOutput("[Exit: " + data.exit_code + "]"); |
|
|
|
|
|
// Update cwd from server response if available (it returns the resolved cwd) |
|
|
if (data.cwd) { |
|
|
// currentDir = data.cwd; // Optional: sync with server truth |
|
|
// updatePrompt(); |
|
|
} |
|
|
} |
|
|
} catch (e) { |
|
|
appendToOutput("Network Error: " + e.message); |
|
|
} |
|
|
} |
|
|
|
|
|
function updatePrompt() { |
|
|
prompt.innerText = `user@vm:${currentDir}$`; |
|
|
} |
|
|
|
|
|
async function fetchNodeInfo() { |
|
|
try { |
|
|
const res = await fetch('/node'); |
|
|
const data = await res.json(); |
|
|
document.getElementById('node-info').innerHTML = ` |
|
|
<p><strong>Node ID:</strong> ${data.node_id}</p> |
|
|
<p><strong>OS:</strong> ${data.platform.system} ${data.platform.release}</p> |
|
|
<p><strong>Root:</strong> ${data.root_dir}</p> |
|
|
`; |
|
|
} catch (e) {} |
|
|
} |
|
|
|
|
|
async function listFiles() { |
|
|
try { |
|
|
const res = await fetch('/tools/list-files', { |
|
|
method: 'POST', |
|
|
headers: {'Content-Type': 'application/json'}, |
|
|
body: JSON.stringify({path: currentDir, recursive: false}) |
|
|
}); |
|
|
const data = await res.json(); |
|
|
if (data.files) { |
|
|
let html = '<table><tr><th>Name</th><th>Type</th><th>Size</th></tr>'; |
|
|
data.files.forEach(f => { |
|
|
html += `<tr><td>${f.name}</td><td>${f.is_dir ? 'DIR' : 'FILE'}</td><td>${f.size || '-'}</td></tr>`; |
|
|
}); |
|
|
html += '</table>'; |
|
|
document.getElementById('file-list').innerHTML = html; |
|
|
} else { |
|
|
document.getElementById('file-list').innerText = "Error listing files: " + JSON.stringify(data); |
|
|
} |
|
|
} catch (e) { |
|
|
document.getElementById('file-list').innerText = 'Error: ' + e.message; |
|
|
} |
|
|
} |
|
|
|
|
|
// Init |
|
|
fetchNodeInfo(); |
|
|
listFiles(); |
|
|
</script> |
|
|
</body> |
|
|
</html> |
|
|
""" |
|
|
self.send_response(HTTPStatus.OK) |
|
|
self.send_header("Content-Type", "text/html") |
|
|
self.send_header("Content-Length", str(len(html))) |
|
|
self.end_headers() |
|
|
self.wfile.write(html.encode("utf-8")) |
|
|
return |
|
|
|
|
|
self._send_json(HTTPStatus.NOT_FOUND, {"error": "Not Found"}) |
|
|
|
|
|
def do_POST(self) -> None: |
|
|
if not self.path.startswith("/tools/"): |
|
|
self._send_json(HTTPStatus.NOT_FOUND, {"error": "Unknown endpoint"}) |
|
|
return |
|
|
tool_name = self.path.split("/", 2)[-1] |
|
|
tool = tools.get(tool_name) |
|
|
if not tool: |
|
|
self._send_json(HTTPStatus.NOT_FOUND, {"error": f"Tool '{tool_name}' not available"}) |
|
|
return |
|
|
|
|
|
try: |
|
|
payload = self._read_json_body() |
|
|
result = tool(config, payload) |
|
|
except json.JSONDecodeError as exc: |
|
|
self._send_json(HTTPStatus.BAD_REQUEST, {"error": "Invalid JSON", "details": str(exc)}) |
|
|
return |
|
|
except ValidationError as exc: |
|
|
self._send_json(HTTPStatus.BAD_REQUEST, {"error": "Validation failed", "details": exc.errors()}) |
|
|
return |
|
|
except PermissionError as exc: |
|
|
self._send_json(HTTPStatus.FORBIDDEN, {"error": str(exc)}) |
|
|
return |
|
|
except FileNotFoundError as exc: |
|
|
self._send_json(HTTPStatus.NOT_FOUND, {"error": str(exc)}) |
|
|
return |
|
|
except Exception as exc: |
|
|
logger.exception("Tool '%s' crashed", tool_name) |
|
|
self._send_json(HTTPStatus.INTERNAL_SERVER_ERROR, {"error": str(exc)}) |
|
|
return |
|
|
|
|
|
self._send_json(HTTPStatus.OK, result) |
|
|
|
|
|
return NodeRequestHandler |
|
|
|
|
|
|
|
|
__all__ = [ |
|
|
"NodeServer", |
|
|
"list_files_tool", |
|
|
"read_file_tool", |
|
|
"write_file_tool", |
|
|
"execute_command_tool", |
|
|
"sync_files_tool", |
|
|
"get_node_info_tool", |
|
|
] |
|
|
|