halyn 0.2.0__py3-none-any.whl
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- halyn/__init__.py +7 -0
- halyn/__main__.py +4 -0
- halyn/audit.py +278 -0
- halyn/auth.py +88 -0
- halyn/autonomy.py +262 -0
- halyn/cli.py +208 -0
- halyn/config.py +135 -0
- halyn/consent.py +243 -0
- halyn/control_plane.py +354 -0
- halyn/discovery.py +323 -0
- halyn/drivers/__init__.py +0 -0
- halyn/drivers/browser.py +60 -0
- halyn/drivers/dds.py +156 -0
- halyn/drivers/docker.py +62 -0
- halyn/drivers/http_auto.py +259 -0
- halyn/drivers/mqtt.py +93 -0
- halyn/drivers/opcua.py +77 -0
- halyn/drivers/ros2.py +124 -0
- halyn/drivers/serial.py +226 -0
- halyn/drivers/socket_raw.py +153 -0
- halyn/drivers/ssh.py +131 -0
- halyn/drivers/unitree.py +103 -0
- halyn/drivers/websocket.py +175 -0
- halyn/engine.py +222 -0
- halyn/intent.py +240 -0
- halyn/llm.py +178 -0
- halyn/mcp.py +239 -0
- halyn/memory/__init__.py +0 -0
- halyn/memory/store.py +200 -0
- halyn/nrp_bridge.py +213 -0
- halyn/py.typed +0 -0
- halyn/sanitizer.py +120 -0
- halyn/server.py +292 -0
- halyn/types.py +116 -0
- halyn/watchdog.py +252 -0
- halyn-0.2.0.dist-info/METADATA +246 -0
- halyn-0.2.0.dist-info/RECORD +41 -0
- halyn-0.2.0.dist-info/WHEEL +5 -0
- halyn-0.2.0.dist-info/entry_points.txt +2 -0
- halyn-0.2.0.dist-info/licenses/LICENSE +15 -0
- halyn-0.2.0.dist-info/top_level.txt +1 -0
halyn/drivers/ssh.py
ADDED
|
@@ -0,0 +1,131 @@
|
|
|
1
|
+
# Copyright (c) 2026 Elmadani SALKA. All rights reserved.
|
|
2
|
+
# Licensed under the MIT License. See LICENSE file.
|
|
3
|
+
"""
|
|
4
|
+
SSH Driver v2 — With Manifest + Events.
|
|
5
|
+
"""
|
|
6
|
+
|
|
7
|
+
from __future__ import annotations
|
|
8
|
+
|
|
9
|
+
import subprocess
|
|
10
|
+
import shlex
|
|
11
|
+
from typing import Any
|
|
12
|
+
|
|
13
|
+
from nrp import NRPDriver, ShieldRule, ShieldType
|
|
14
|
+
from nrp import NRPId
|
|
15
|
+
from nrp import NRPManifest, ChannelSpec, ActionSpec, ShieldSpec
|
|
16
|
+
|
|
17
|
+
|
|
18
|
+
class SSHDriver(NRPDriver):
|
|
19
|
+
"""Control any machine via SSH. Self-describing."""
|
|
20
|
+
|
|
21
|
+
def __init__(self, host: str, user: str = "", key_path: str = "", port: int = 22) -> None:
|
|
22
|
+
super().__init__()
|
|
23
|
+
self.host = host
|
|
24
|
+
self.user = user
|
|
25
|
+
self.key_path = key_path
|
|
26
|
+
self.port = port
|
|
27
|
+
|
|
28
|
+
def manifest(self) -> NRPManifest:
|
|
29
|
+
nrp_id = self._nrp_id or NRPId.create("local", "server", self.host.replace(".", "-"))
|
|
30
|
+
return NRPManifest(
|
|
31
|
+
nrp_id=nrp_id,
|
|
32
|
+
manufacturer="Generic",
|
|
33
|
+
model="Linux Server",
|
|
34
|
+
observe=[
|
|
35
|
+
ChannelSpec("hostname", "string", description="Machine hostname"),
|
|
36
|
+
ChannelSpec("cpu", "int", description="CPU core count"),
|
|
37
|
+
ChannelSpec("ram", "string", description="RAM total and free"),
|
|
38
|
+
ChannelSpec("disk", "string", description="Root disk usage"),
|
|
39
|
+
ChannelSpec("load", "string", description="System load average"),
|
|
40
|
+
ChannelSpec("uptime", "string", description="System uptime"),
|
|
41
|
+
ChannelSpec("status", "string", description="Quick alive check"),
|
|
42
|
+
],
|
|
43
|
+
act=[
|
|
44
|
+
ActionSpec("shell", {"command": "string — shell command"}, "Execute shell command", dangerous=True),
|
|
45
|
+
ActionSpec("file_read", {"path": "string — file path"}, "Read file contents"),
|
|
46
|
+
ActionSpec("file_write", {"path": "string", "content": "string"}, "Write file", dangerous=True),
|
|
47
|
+
ActionSpec("service_restart", {"service": "string"}, "Restart systemd service", dangerous=True),
|
|
48
|
+
ActionSpec("file_list", {"path": "string — directory"}, "List directory"),
|
|
49
|
+
ActionSpec("process_list", {}, "Top processes by memory"),
|
|
50
|
+
ActionSpec("log_tail", {"source": "string", "lines": "int"}, "Tail logs"),
|
|
51
|
+
ActionSpec("git_status", {"path": "string"}, "Git repository state"),
|
|
52
|
+
],
|
|
53
|
+
shield=[
|
|
54
|
+
ShieldSpec("no_rm_rf", "pattern", "rm -rf", description="Block recursive delete"),
|
|
55
|
+
ShieldSpec("no_shutdown", "pattern", "shutdown", description="Block shutdown"),
|
|
56
|
+
ShieldSpec("no_reboot", "pattern", "reboot", description="Block reboot"),
|
|
57
|
+
ShieldSpec("no_mkfs", "pattern", "mkfs", description="Block format"),
|
|
58
|
+
ShieldSpec("no_dd", "pattern", "dd if=", description="Block raw disk write"),
|
|
59
|
+
ShieldSpec("confirm_deploy", "confirm", "deploy", description="Confirm before deploy"),
|
|
60
|
+
],
|
|
61
|
+
)
|
|
62
|
+
|
|
63
|
+
async def observe(self, channels: list[str] | None = None) -> dict[str, Any]:
|
|
64
|
+
channels = channels or ["hostname", "cpu", "ram", "disk", "load"]
|
|
65
|
+
commands = {
|
|
66
|
+
"hostname": "hostname",
|
|
67
|
+
"cpu": "nproc",
|
|
68
|
+
"ram": "free -h | grep Mem",
|
|
69
|
+
"disk": "df -h / | tail -1",
|
|
70
|
+
"load": "cat /proc/loadavg",
|
|
71
|
+
"uptime": "uptime -p 2>/dev/null || uptime",
|
|
72
|
+
"status": "echo ok",
|
|
73
|
+
}
|
|
74
|
+
state: dict[str, Any] = {}
|
|
75
|
+
for ch in channels:
|
|
76
|
+
if ch in commands:
|
|
77
|
+
try:
|
|
78
|
+
state[ch] = self._exec(commands[ch]).strip()
|
|
79
|
+
except Exception as e:
|
|
80
|
+
state[ch] = f"error: {e}"
|
|
81
|
+
return state
|
|
82
|
+
|
|
83
|
+
async def act(self, command: str, args: dict[str, Any]) -> Any:
|
|
84
|
+
if command == "shell":
|
|
85
|
+
return self._exec(args.get("command", "echo no command"), args.get("timeout", 30))
|
|
86
|
+
elif command == "file_read":
|
|
87
|
+
return self._exec(f"cat {shlex.quote(args['path'])}", 10)
|
|
88
|
+
elif command == "file_write":
|
|
89
|
+
content = args["content"].replace("'", "'\\''")
|
|
90
|
+
path = shlex.quote(args["path"])
|
|
91
|
+
self._exec(f"mkdir -p $(dirname {path}) && printf '%s' '{content}' > {path}", 10)
|
|
92
|
+
return {"written": len(args["content"])}
|
|
93
|
+
elif command == "service_restart":
|
|
94
|
+
return self._exec(f"systemctl restart {shlex.quote(args['service'])}", 15)
|
|
95
|
+
elif command == "file_list":
|
|
96
|
+
return self._exec(f"ls -la {shlex.quote(args.get('path', '.'))}", 10)
|
|
97
|
+
elif command == "process_list":
|
|
98
|
+
return self._exec("ps aux --sort=-%mem | head -15", 10)
|
|
99
|
+
elif command == "log_tail":
|
|
100
|
+
src = args.get("source", "syslog")
|
|
101
|
+
n = min(args.get("lines", 30), 200)
|
|
102
|
+
if src.startswith("/"):
|
|
103
|
+
return self._exec(f"tail -{n} {shlex.quote(src)}", 10)
|
|
104
|
+
return self._exec(f"journalctl -u {shlex.quote(src)} --no-pager -n {n}", 10)
|
|
105
|
+
elif command == "git_status":
|
|
106
|
+
path = shlex.quote(args.get("path", "."))
|
|
107
|
+
return self._exec(f"cd {path} && git branch --show-current && git status --short && git log --oneline -3", 10)
|
|
108
|
+
return self._exec(args.get("command", command), args.get("timeout", 30))
|
|
109
|
+
|
|
110
|
+
def shield_rules(self) -> list[ShieldRule]:
|
|
111
|
+
return [
|
|
112
|
+
ShieldRule("no_rm_rf", ShieldType.PATTERN, "rm -rf"),
|
|
113
|
+
ShieldRule("no_shutdown", ShieldType.PATTERN, "shutdown"),
|
|
114
|
+
ShieldRule("no_reboot", ShieldType.PATTERN, "reboot"),
|
|
115
|
+
ShieldRule("no_mkfs", ShieldType.PATTERN, "mkfs"),
|
|
116
|
+
ShieldRule("no_dd", ShieldType.PATTERN, "dd if="),
|
|
117
|
+
]
|
|
118
|
+
|
|
119
|
+
def _exec(self, cmd: str, timeout: int = 30) -> str:
|
|
120
|
+
parts = ["ssh", "-o", "StrictHostKeyChecking=no", "-o", "ConnectTimeout=5"]
|
|
121
|
+
if self.key_path:
|
|
122
|
+
parts += ["-i", self.key_path]
|
|
123
|
+
if self.port != 22:
|
|
124
|
+
parts += ["-p", str(self.port)]
|
|
125
|
+
target = f"{self.user}@{self.host}" if self.user else self.host
|
|
126
|
+
parts += [target, cmd]
|
|
127
|
+
r = subprocess.run(parts, capture_output=True, text=True, timeout=min(timeout, 300))
|
|
128
|
+
if r.returncode != 0 and r.stderr.strip():
|
|
129
|
+
raise RuntimeError(r.stderr[:500])
|
|
130
|
+
return r.stdout
|
|
131
|
+
|
halyn/drivers/unitree.py
ADDED
|
@@ -0,0 +1,103 @@
|
|
|
1
|
+
# Copyright (c) 2026 Elmadani SALKA. All rights reserved.
|
|
2
|
+
# Licensed under the MIT License. See LICENSE file.
|
|
3
|
+
"""
|
|
4
|
+
Unitree Driver — G1, H1, Go2 robots.
|
|
5
|
+
|
|
6
|
+
Uses Unitree SDK2 Python bindings.
|
|
7
|
+
Falls back to HTTP API if SDK not available.
|
|
8
|
+
"""
|
|
9
|
+
from __future__ import annotations
|
|
10
|
+
from typing import Any
|
|
11
|
+
from nrp import NRPDriver, ShieldRule, ShieldType
|
|
12
|
+
|
|
13
|
+
class UnitreeDriver(NRPDriver):
|
|
14
|
+
def __init__(self, robot_ip: str = "192.168.123.161", model: str = "g1") -> None:
|
|
15
|
+
self.robot_ip = robot_ip
|
|
16
|
+
self.model = model
|
|
17
|
+
self._sdk: Any = None
|
|
18
|
+
|
|
19
|
+
@property
|
|
20
|
+
def kind(self) -> str: return "unitree"
|
|
21
|
+
|
|
22
|
+
@property
|
|
23
|
+
def capabilities(self) -> list[str]:
|
|
24
|
+
return ["stand", "sit", "walk", "stop", "pick", "place",
|
|
25
|
+
"move_joint", "set_speed", "get_state", "emergency_stop"]
|
|
26
|
+
|
|
27
|
+
async def connect(self) -> bool:
|
|
28
|
+
try:
|
|
29
|
+
import unitree_sdk2py as sdk
|
|
30
|
+
self._sdk = sdk.Robot(self.robot_ip)
|
|
31
|
+
self._sdk.connect()
|
|
32
|
+
return True
|
|
33
|
+
except ImportError:
|
|
34
|
+
# Try HTTP fallback
|
|
35
|
+
try:
|
|
36
|
+
import aiohttp
|
|
37
|
+
async with aiohttp.ClientSession() as s:
|
|
38
|
+
async with s.get(f"http://{self.robot_ip}:8080/status",
|
|
39
|
+
timeout=aiohttp.ClientTimeout(total=3)) as r:
|
|
40
|
+
return r.status == 200
|
|
41
|
+
except Exception:
|
|
42
|
+
return False
|
|
43
|
+
|
|
44
|
+
async def observe(self, channels: list[str] | None = None) -> dict[str, Any]:
|
|
45
|
+
channels = channels or ["joints", "imu", "battery", "mode"]
|
|
46
|
+
state: dict[str, Any] = {}
|
|
47
|
+
if self._sdk:
|
|
48
|
+
s = self._sdk.get_state()
|
|
49
|
+
if "joints" in channels:
|
|
50
|
+
state["joints"] = s.get("joint_angles", {})
|
|
51
|
+
if "imu" in channels:
|
|
52
|
+
state["imu"] = s.get("imu", {})
|
|
53
|
+
if "battery" in channels:
|
|
54
|
+
state["battery"] = s.get("battery_percent", 0)
|
|
55
|
+
if "mode" in channels:
|
|
56
|
+
state["mode"] = s.get("mode", "unknown")
|
|
57
|
+
else:
|
|
58
|
+
import aiohttp
|
|
59
|
+
async with aiohttp.ClientSession() as session:
|
|
60
|
+
async with session.get(f"http://{self.robot_ip}:8080/state") as r:
|
|
61
|
+
state = await r.json()
|
|
62
|
+
return state
|
|
63
|
+
|
|
64
|
+
async def act(self, command: str, args: dict[str, Any]) -> Any:
|
|
65
|
+
if command == "emergency_stop":
|
|
66
|
+
if self._sdk:
|
|
67
|
+
self._sdk.emergency_stop()
|
|
68
|
+
return {"stopped": True}
|
|
69
|
+
if command == "stand":
|
|
70
|
+
if self._sdk: self._sdk.stand()
|
|
71
|
+
return {"mode": "standing"}
|
|
72
|
+
if command == "sit":
|
|
73
|
+
if self._sdk: self._sdk.sit()
|
|
74
|
+
return {"mode": "sitting"}
|
|
75
|
+
if command == "walk":
|
|
76
|
+
speed = args.get("speed", 0.3)
|
|
77
|
+
direction = args.get("direction", "forward")
|
|
78
|
+
if self._sdk: self._sdk.walk(speed, direction)
|
|
79
|
+
return {"walking": True, "speed": speed}
|
|
80
|
+
if command == "stop":
|
|
81
|
+
if self._sdk: self._sdk.stop()
|
|
82
|
+
return {"mode": "idle"}
|
|
83
|
+
if command == "pick":
|
|
84
|
+
target = args.get("target", "")
|
|
85
|
+
if self._sdk: self._sdk.pick(target)
|
|
86
|
+
return {"picking": target}
|
|
87
|
+
if command == "move_joint":
|
|
88
|
+
joint = args["joint"]
|
|
89
|
+
angle = args["angle"]
|
|
90
|
+
if self._sdk: self._sdk.move_joint(joint, angle)
|
|
91
|
+
return {"joint": joint, "angle": angle}
|
|
92
|
+
raise ValueError(f"Unknown unitree command: {command}")
|
|
93
|
+
|
|
94
|
+
def shield_rules(self) -> list[ShieldRule]:
|
|
95
|
+
return [
|
|
96
|
+
ShieldRule("max_speed", ShieldType.LIMIT, 1.5, "m/s", "Maximum walking speed"),
|
|
97
|
+
ShieldRule("max_joint_speed", ShieldType.LIMIT, 2.0, "rad/s", "Maximum joint velocity"),
|
|
98
|
+
ShieldRule("workspace", ShieldType.ZONE, {"x":[-3,3],"y":[-3,3],"z":[0,1.5]}, "m", "Allowed area"),
|
|
99
|
+
ShieldRule("min_battery", ShieldType.THRESHOLD, 10, "percent", "Stop below 10%"),
|
|
100
|
+
ShieldRule("max_payload", ShieldType.LIMIT, 3, "kg", "Maximum carry weight"),
|
|
101
|
+
ShieldRule("emergency_stop", ShieldType.COMMAND, True, description="Always allowed"),
|
|
102
|
+
]
|
|
103
|
+
|
|
@@ -0,0 +1,175 @@
|
|
|
1
|
+
# Copyright (c) 2026 Elmadani SALKA. All rights reserved.
|
|
2
|
+
# Licensed under the MIT License. See LICENSE file.
|
|
3
|
+
"""
|
|
4
|
+
WebSocket meta-driver — bidirectional real-time channels.
|
|
5
|
+
|
|
6
|
+
Covers: live dashboards, streaming APIs, real-time data feeds,
|
|
7
|
+
WebSocket-based IoT platforms, home automation bridges (e.g. Home Assistant).
|
|
8
|
+
"""
|
|
9
|
+
|
|
10
|
+
from __future__ import annotations
|
|
11
|
+
|
|
12
|
+
import asyncio
|
|
13
|
+
import json
|
|
14
|
+
import logging
|
|
15
|
+
import time
|
|
16
|
+
from typing import Any
|
|
17
|
+
|
|
18
|
+
from nrp import (
|
|
19
|
+
NRPDriver, NRPManifest, NRPId,
|
|
20
|
+
ChannelSpec, ActionSpec, ShieldSpec, ShieldRule, ShieldType, Severity,
|
|
21
|
+
)
|
|
22
|
+
|
|
23
|
+
log = logging.getLogger("halyn.drivers.websocket")
|
|
24
|
+
|
|
25
|
+
try:
|
|
26
|
+
import aiohttp
|
|
27
|
+
HAS_WS = True
|
|
28
|
+
except ImportError:
|
|
29
|
+
HAS_WS = False
|
|
30
|
+
|
|
31
|
+
|
|
32
|
+
class WebSocketDriver(NRPDriver):
|
|
33
|
+
"""
|
|
34
|
+
Persistent WebSocket connection with message routing.
|
|
35
|
+
|
|
36
|
+
Receives JSON messages, indexes them by a configurable key,
|
|
37
|
+
and exposes the latest state per channel. Outbound messages
|
|
38
|
+
are sent via act().
|
|
39
|
+
"""
|
|
40
|
+
|
|
41
|
+
def __init__(
|
|
42
|
+
self,
|
|
43
|
+
url: str,
|
|
44
|
+
auth_token: str = "",
|
|
45
|
+
channels: list[str] | None = None,
|
|
46
|
+
message_key: str = "type",
|
|
47
|
+
ping_interval: float = 30.0,
|
|
48
|
+
) -> None:
|
|
49
|
+
super().__init__()
|
|
50
|
+
self.url = url
|
|
51
|
+
self.auth_token = auth_token
|
|
52
|
+
self.channel_names = channels or ["default"]
|
|
53
|
+
self.message_key = message_key
|
|
54
|
+
self.ping_interval = ping_interval
|
|
55
|
+
self._ws = None
|
|
56
|
+
self._session = None
|
|
57
|
+
self._state: dict[str, Any] = {}
|
|
58
|
+
self._msg_count = 0
|
|
59
|
+
self._connected_at = 0.0
|
|
60
|
+
self._listen_task = None
|
|
61
|
+
|
|
62
|
+
def manifest(self) -> NRPManifest:
|
|
63
|
+
observe = [
|
|
64
|
+
ChannelSpec("connected", "bool"),
|
|
65
|
+
ChannelSpec("message_count", "int"),
|
|
66
|
+
ChannelSpec("uptime", "float", unit="seconds"),
|
|
67
|
+
]
|
|
68
|
+
for ch in self.channel_names:
|
|
69
|
+
observe.append(ChannelSpec(ch, "json", rate="on_change"))
|
|
70
|
+
|
|
71
|
+
return NRPManifest(
|
|
72
|
+
nrp_id=self._nrp_id or NRPId.create("local", "ws", self.url.split("/")[-1]),
|
|
73
|
+
manufacturer="WebSocket",
|
|
74
|
+
model=self.url,
|
|
75
|
+
observe=observe,
|
|
76
|
+
act=[
|
|
77
|
+
ActionSpec("send", {"message": "json — payload to send"}, "Send JSON message"),
|
|
78
|
+
ActionSpec("send_raw", {"data": "string"}, "Send raw text"),
|
|
79
|
+
ActionSpec("subscribe", {"channel": "string"}, "Subscribe to channel"),
|
|
80
|
+
ActionSpec("reconnect", {}, "Force reconnect"),
|
|
81
|
+
],
|
|
82
|
+
shield=[ShieldSpec("rate", "limit", 100, "msg/s")],
|
|
83
|
+
)
|
|
84
|
+
|
|
85
|
+
async def connect(self) -> bool:
|
|
86
|
+
if not HAS_WS:
|
|
87
|
+
log.warning("websocket: aiohttp required")
|
|
88
|
+
return False
|
|
89
|
+
try:
|
|
90
|
+
headers: dict[str, str] = {}
|
|
91
|
+
if self.auth_token:
|
|
92
|
+
headers["Authorization"] = self.auth_token
|
|
93
|
+
self._session = aiohttp.ClientSession()
|
|
94
|
+
self._ws = await self._session.ws_connect(
|
|
95
|
+
self.url, headers=headers,
|
|
96
|
+
heartbeat=self.ping_interval,
|
|
97
|
+
)
|
|
98
|
+
self._connected_at = time.time()
|
|
99
|
+
self._listen_task = asyncio.create_task(self._listen())
|
|
100
|
+
log.info("ws.connected url=%s", self.url)
|
|
101
|
+
return True
|
|
102
|
+
except Exception as e:
|
|
103
|
+
log.error("ws.connect_failed url=%s error=%s", self.url, e)
|
|
104
|
+
return False
|
|
105
|
+
|
|
106
|
+
async def observe(self, channels: list[str] | None = None) -> dict[str, Any]:
|
|
107
|
+
state: dict[str, Any] = {
|
|
108
|
+
"connected": self._ws is not None and not self._ws.closed,
|
|
109
|
+
"message_count": self._msg_count,
|
|
110
|
+
"uptime": time.time() - self._connected_at if self._connected_at else 0,
|
|
111
|
+
}
|
|
112
|
+
targets = channels or self.channel_names
|
|
113
|
+
for ch in targets:
|
|
114
|
+
if ch in self._state:
|
|
115
|
+
state[ch] = self._state[ch]
|
|
116
|
+
return state
|
|
117
|
+
|
|
118
|
+
async def act(self, command: str, args: dict[str, Any]) -> Any:
|
|
119
|
+
if command == "reconnect":
|
|
120
|
+
await self.disconnect()
|
|
121
|
+
return {"reconnected": await self.connect()}
|
|
122
|
+
|
|
123
|
+
if not self._ws or self._ws.closed:
|
|
124
|
+
return {"error": "not connected"}
|
|
125
|
+
|
|
126
|
+
if command == "send":
|
|
127
|
+
msg = args.get("message", {})
|
|
128
|
+
await self._ws.send_json(msg)
|
|
129
|
+
return {"sent": True, "size": len(json.dumps(msg))}
|
|
130
|
+
if command == "send_raw":
|
|
131
|
+
data = args.get("data", "")
|
|
132
|
+
await self._ws.send_str(data)
|
|
133
|
+
return {"sent": True, "size": len(data)}
|
|
134
|
+
if command == "subscribe":
|
|
135
|
+
ch = args.get("channel", "")
|
|
136
|
+
if ch and ch not in self.channel_names:
|
|
137
|
+
self.channel_names.append(ch)
|
|
138
|
+
return {"subscribed": ch}
|
|
139
|
+
|
|
140
|
+
return {"error": f"unknown: {command}"}
|
|
141
|
+
|
|
142
|
+
def shield_rules(self) -> list[ShieldRule]:
|
|
143
|
+
return [ShieldRule("rate", ShieldType.LIMIT, 100)]
|
|
144
|
+
|
|
145
|
+
async def disconnect(self) -> None:
|
|
146
|
+
if self._listen_task:
|
|
147
|
+
self._listen_task.cancel()
|
|
148
|
+
if self._ws and not self._ws.closed:
|
|
149
|
+
await self._ws.close()
|
|
150
|
+
if self._session:
|
|
151
|
+
await self._session.close()
|
|
152
|
+
self._ws = None
|
|
153
|
+
self._session = None
|
|
154
|
+
|
|
155
|
+
async def _listen(self) -> None:
|
|
156
|
+
try:
|
|
157
|
+
async for msg in self._ws:
|
|
158
|
+
if msg.type == aiohttp.WSMsgType.TEXT:
|
|
159
|
+
self._msg_count += 1
|
|
160
|
+
try:
|
|
161
|
+
data = json.loads(msg.data)
|
|
162
|
+
key = data.get(self.message_key, "default")
|
|
163
|
+
self._state[key] = data
|
|
164
|
+
if self._event_bus:
|
|
165
|
+
await self.emit(f"ws_message_{key}", data=data)
|
|
166
|
+
except json.JSONDecodeError:
|
|
167
|
+
self._state["raw"] = msg.data
|
|
168
|
+
elif msg.type in (aiohttp.WSMsgType.CLOSED, aiohttp.WSMsgType.ERROR):
|
|
169
|
+
break
|
|
170
|
+
except asyncio.CancelledError:
|
|
171
|
+
pass
|
|
172
|
+
except Exception as e:
|
|
173
|
+
log.error("ws.listen_error: %s", e)
|
|
174
|
+
if self._event_bus:
|
|
175
|
+
await self.emit("ws_disconnected", Severity.WARNING, error=str(e))
|
halyn/engine.py
ADDED
|
@@ -0,0 +1,222 @@
|
|
|
1
|
+
# Copyright (c) 2026 Elmadani SALKA. All rights reserved.
|
|
2
|
+
# Licensed under the MIT License. See LICENSE file.
|
|
3
|
+
"""
|
|
4
|
+
Engine — The nerve center.
|
|
5
|
+
|
|
6
|
+
Routes actions to tools. Enforces policy. Writes audit.
|
|
7
|
+
Every side effect passes through here. No exceptions.
|
|
8
|
+
"""
|
|
9
|
+
|
|
10
|
+
from __future__ import annotations
|
|
11
|
+
|
|
12
|
+
import asyncio
|
|
13
|
+
import hashlib
|
|
14
|
+
import json
|
|
15
|
+
import logging
|
|
16
|
+
import time
|
|
17
|
+
from typing import Any, Callable, Awaitable
|
|
18
|
+
|
|
19
|
+
from .types import (
|
|
20
|
+
Action, Result, AuditEntry, Node, NodeKind,
|
|
21
|
+
ToolSpec, ToolCategory, ActionStatus, PolicyRule,
|
|
22
|
+
)
|
|
23
|
+
|
|
24
|
+
log = logging.getLogger("jarvis.engine")
|
|
25
|
+
|
|
26
|
+
ToolFn = Callable[[dict[str, Any], Node | None], Any | Awaitable[Any]]
|
|
27
|
+
|
|
28
|
+
|
|
29
|
+
class Registry:
|
|
30
|
+
"""All tools, all nodes, one place. Thread-safe by design (async single-thread)."""
|
|
31
|
+
|
|
32
|
+
__slots__ = ("_tools", "_tool_fns", "_nodes", "_policies")
|
|
33
|
+
|
|
34
|
+
def __init__(self) -> None:
|
|
35
|
+
self._tools: dict[str, ToolSpec] = {}
|
|
36
|
+
self._tool_fns: dict[str, ToolFn] = {}
|
|
37
|
+
self._nodes: dict[str, Node] = {}
|
|
38
|
+
self._policies: list[PolicyRule] = []
|
|
39
|
+
|
|
40
|
+
def register_tool(
|
|
41
|
+
self,
|
|
42
|
+
name: str,
|
|
43
|
+
fn: ToolFn,
|
|
44
|
+
category: ToolCategory = ToolCategory.EXECUTOR,
|
|
45
|
+
description: str = "",
|
|
46
|
+
dangerous: bool = False,
|
|
47
|
+
) -> None:
|
|
48
|
+
self._tools[name] = ToolSpec(name, category, description, dangerous)
|
|
49
|
+
self._tool_fns[name] = fn
|
|
50
|
+
|
|
51
|
+
def register_node(self, node: Node) -> None:
|
|
52
|
+
self._nodes[node.name] = node
|
|
53
|
+
log.info("node.registered name=%s kind=%s host=%s", node.name, node.kind.value, node.host)
|
|
54
|
+
|
|
55
|
+
def add_policy(self, rule: PolicyRule) -> None:
|
|
56
|
+
self._policies.append(rule)
|
|
57
|
+
|
|
58
|
+
@property
|
|
59
|
+
def tool_names(self) -> list[str]:
|
|
60
|
+
return list(self._tools)
|
|
61
|
+
|
|
62
|
+
@property
|
|
63
|
+
def nodes(self) -> dict[str, Node]:
|
|
64
|
+
return dict(self._nodes)
|
|
65
|
+
|
|
66
|
+
def get_tool_fn(self, name: str) -> ToolFn | None:
|
|
67
|
+
return self._tool_fns.get(name)
|
|
68
|
+
|
|
69
|
+
def get_node(self, name: str) -> Node | None:
|
|
70
|
+
return self._nodes.get(name)
|
|
71
|
+
|
|
72
|
+
def get_spec(self, name: str) -> ToolSpec | None:
|
|
73
|
+
return self._tools.get(name)
|
|
74
|
+
|
|
75
|
+
|
|
76
|
+
class AuditLog:
|
|
77
|
+
"""Append-only, hash-chained audit trail."""
|
|
78
|
+
|
|
79
|
+
__slots__ = ("_entries", "_last_hash")
|
|
80
|
+
|
|
81
|
+
def __init__(self) -> None:
|
|
82
|
+
self._entries: list[AuditEntry] = []
|
|
83
|
+
self._last_hash: str = "0" * 64
|
|
84
|
+
|
|
85
|
+
def append(self, entry: AuditEntry) -> None:
|
|
86
|
+
entry.prev_hash = self._last_hash
|
|
87
|
+
payload = f"{entry.timestamp}:{entry.tool}:{entry.node}:{entry.status}:{entry.prev_hash}"
|
|
88
|
+
entry.entry_hash = hashlib.sha256(payload.encode()).hexdigest()
|
|
89
|
+
self._last_hash = entry.entry_hash
|
|
90
|
+
self._entries.append(entry)
|
|
91
|
+
# Bound memory — keep last 10K entries in-memory, older go to disk
|
|
92
|
+
if len(self._entries) > 10_000:
|
|
93
|
+
self._entries = self._entries[-5_000:]
|
|
94
|
+
|
|
95
|
+
def recent(self, n: int = 50) -> list[AuditEntry]:
|
|
96
|
+
return self._entries[-n:]
|
|
97
|
+
|
|
98
|
+
@property
|
|
99
|
+
def count(self) -> int:
|
|
100
|
+
return len(self._entries)
|
|
101
|
+
|
|
102
|
+
def verify_chain(self) -> bool:
|
|
103
|
+
"""Verify integrity of the audit chain."""
|
|
104
|
+
for i in range(1, len(self._entries)):
|
|
105
|
+
if self._entries[i].prev_hash != self._entries[i - 1].entry_hash:
|
|
106
|
+
return False
|
|
107
|
+
return True
|
|
108
|
+
|
|
109
|
+
|
|
110
|
+
class Shield:
|
|
111
|
+
"""Security middleware. Blocks dangerous commands before execution."""
|
|
112
|
+
|
|
113
|
+
DESTRUCTIVE_PATTERNS: tuple[str, ...] = (
|
|
114
|
+
"rm -rf", "rm -r /", "mkfs", "dd if=", "format ",
|
|
115
|
+
"DROP TABLE", "DROP DATABASE", "TRUNCATE", "DELETE FROM",
|
|
116
|
+
"shutdown", "reboot", "halt", "init 0",
|
|
117
|
+
"chmod 777 /", "chown", "> /dev/sd",
|
|
118
|
+
)
|
|
119
|
+
|
|
120
|
+
def check(self, action: Action, spec: ToolSpec | None) -> ActionStatus | None:
|
|
121
|
+
"""Returns ActionStatus.DENIED if blocked, None if allowed."""
|
|
122
|
+
if spec and spec.dangerous:
|
|
123
|
+
log.warning("shield.dangerous tool=%s node=%s", action.tool, action.node)
|
|
124
|
+
|
|
125
|
+
cmd = action.args.get("command", "") + action.args.get("path", "")
|
|
126
|
+
cmd_lower = cmd.lower()
|
|
127
|
+
|
|
128
|
+
for pattern in self.DESTRUCTIVE_PATTERNS:
|
|
129
|
+
if pattern.lower() in cmd_lower:
|
|
130
|
+
log.warning("shield.blocked pattern=%s cmd=%s", pattern, cmd[:100])
|
|
131
|
+
return ActionStatus.DENIED
|
|
132
|
+
|
|
133
|
+
return None
|
|
134
|
+
|
|
135
|
+
|
|
136
|
+
class Engine:
|
|
137
|
+
"""The single entry point for all actions. Routes, guards, executes, audits."""
|
|
138
|
+
|
|
139
|
+
__slots__ = ("registry", "audit", "shield", "_started")
|
|
140
|
+
|
|
141
|
+
def __init__(self) -> None:
|
|
142
|
+
self.registry = Registry()
|
|
143
|
+
self.audit = AuditLog()
|
|
144
|
+
self.shield = Shield()
|
|
145
|
+
self._started = time.monotonic()
|
|
146
|
+
|
|
147
|
+
async def execute(self, action: Action, user: str = "default") -> Result:
|
|
148
|
+
"""Execute one action. The only way to do anything."""
|
|
149
|
+
t0 = time.monotonic()
|
|
150
|
+
|
|
151
|
+
# 1. Resolve tool
|
|
152
|
+
fn = self.registry.get_tool_fn(action.tool)
|
|
153
|
+
if fn is None:
|
|
154
|
+
return self._fail(action, f"unknown tool: {action.tool}", t0, user)
|
|
155
|
+
|
|
156
|
+
spec = self.registry.get_spec(action.tool)
|
|
157
|
+
|
|
158
|
+
# 2. Resolve node
|
|
159
|
+
node = self.registry.get_node(action.node)
|
|
160
|
+
if action.node != "local" and node is None:
|
|
161
|
+
return self._fail(action, f"unknown node: {action.node}", t0, user)
|
|
162
|
+
|
|
163
|
+
# 3. Shield check
|
|
164
|
+
verdict = self.shield.check(action, spec)
|
|
165
|
+
if verdict is not None:
|
|
166
|
+
return self._result(action, verdict, None, "blocked by shield", t0, user)
|
|
167
|
+
|
|
168
|
+
# 4. Execute
|
|
169
|
+
try:
|
|
170
|
+
if asyncio.iscoroutinefunction(fn):
|
|
171
|
+
data = await fn(action.args, node)
|
|
172
|
+
else:
|
|
173
|
+
data = fn(action.args, node)
|
|
174
|
+
result = self._result(action, ActionStatus.OK, data, "", t0, user)
|
|
175
|
+
|
|
176
|
+
except TimeoutError:
|
|
177
|
+
result = self._result(action, ActionStatus.TIMEOUT, None, "timeout", t0, user)
|
|
178
|
+
except Exception as exc:
|
|
179
|
+
result = self._result(action, ActionStatus.FAILED, None, str(exc)[:500], t0, user)
|
|
180
|
+
|
|
181
|
+
return result
|
|
182
|
+
|
|
183
|
+
async def batch(self, actions: list[Action], user: str = "default") -> list[Result]:
|
|
184
|
+
"""Execute multiple actions sequentially. Future: parallel with DAG."""
|
|
185
|
+
return [await self.execute(a, user) for a in actions]
|
|
186
|
+
|
|
187
|
+
def health(self) -> dict[str, Any]:
|
|
188
|
+
uptime = time.monotonic() - self._started
|
|
189
|
+
return {
|
|
190
|
+
"status": "ok",
|
|
191
|
+
"uptime_s": round(uptime, 1),
|
|
192
|
+
"tools": len(self.registry.tool_names),
|
|
193
|
+
"nodes": len(self.registry.nodes),
|
|
194
|
+
"actions_total": self.audit.count,
|
|
195
|
+
"audit_chain_valid": self.audit.verify_chain(),
|
|
196
|
+
}
|
|
197
|
+
|
|
198
|
+
# ─── Internal ──────────────────────────────────
|
|
199
|
+
|
|
200
|
+
def _result(
|
|
201
|
+
self, action: Action, status: ActionStatus,
|
|
202
|
+
data: Any, error: str, t0: float, user: str,
|
|
203
|
+
) -> Result:
|
|
204
|
+
elapsed = (time.monotonic() - t0) * 1000
|
|
205
|
+
result = Result(
|
|
206
|
+
status=status, data=data, error=error,
|
|
207
|
+
elapsed_ms=round(elapsed, 2),
|
|
208
|
+
node=action.node, tool=action.tool,
|
|
209
|
+
)
|
|
210
|
+
self.audit.append(AuditEntry(
|
|
211
|
+
tool=action.tool, node=action.node,
|
|
212
|
+
status=status.value, elapsed_ms=result.elapsed_ms,
|
|
213
|
+
user=user, error=error[:200] if error else "",
|
|
214
|
+
))
|
|
215
|
+
lvl = logging.INFO if result.ok else logging.WARNING
|
|
216
|
+
log.log(lvl, "action.%s tool=%s node=%s ms=%.1f",
|
|
217
|
+
status.value, action.tool, action.node, elapsed)
|
|
218
|
+
return result
|
|
219
|
+
|
|
220
|
+
def _fail(self, action: Action, error: str, t0: float, user: str) -> Result:
|
|
221
|
+
return self._result(action, ActionStatus.FAILED, None, error, t0, user)
|
|
222
|
+
|