multiagentos 0.4.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.
- agents/__init__.py +1 -0
- agents/catalog.py +52 -0
- agents/registry.py +11 -0
- core/__init__.py +38 -0
- core/artifacts.py +47 -0
- core/chat_agent_bridge.py +280 -0
- core/chat_agent_registry.py +61 -0
- core/chat_agent_router.py +85 -0
- core/chat_session.py +62 -0
- core/contracts/__init__.py +22 -0
- core/contracts/agent.py +21 -0
- core/contracts/ai.py +25 -0
- core/contracts/capability.py +44 -0
- core/contracts/chat_agent.py +34 -0
- core/contracts/checkpoint.py +78 -0
- core/contracts/discovery.py +30 -0
- core/contracts/execution.py +28 -0
- core/contracts/github.py +74 -0
- core/contracts/handoff.py +56 -0
- core/contracts/health.py +206 -0
- core/contracts/human_review.py +20 -0
- core/contracts/ide.py +113 -0
- core/contracts/mcp.py +76 -0
- core/contracts/model_runtime.py +25 -0
- core/contracts/planning.py +25 -0
- core/contracts/profile.py +66 -0
- core/contracts/quota.py +62 -0
- core/contracts/repository.py +34 -0
- core/contracts/resume.py +55 -0
- core/contracts/runtime.py +19 -0
- core/contracts/vyrelon_runtime.py +118 -0
- core/contracts/work_unit.py +48 -0
- core/delegation.py +33 -0
- core/handoff.py +68 -0
- core/lifecycle.py +85 -0
- core/multi_agent_workflow.py +574 -0
- core/orchestrator.py +51 -0
- core/planning.py +22 -0
- core/registry.py +44 -0
- core/routing.py +195 -0
- core/state.py +155 -0
- installer/__init__.py +1 -0
- installer/init.py +71 -0
- integrations/__init__.py +0 -0
- integrations/github/__init__.py +3 -0
- integrations/github/gateway.py +164 -0
- integrations/openai/__init__.py +1 -0
- integrations/openai/chat_agent.py +147 -0
- multiagentos/__init__.py +1 -0
- multiagentos/cli.py +759 -0
- multiagentos-0.4.0.dist-info/METADATA +7 -0
- multiagentos-0.4.0.dist-info/RECORD +122 -0
- multiagentos-0.4.0.dist-info/WHEEL +5 -0
- multiagentos-0.4.0.dist-info/entry_points.txt +2 -0
- multiagentos-0.4.0.dist-info/top_level.txt +7 -0
- profiles/__init__.py +1 -0
- profiles/common.py +11 -0
- profiles/detector.py +67 -0
- profiles/registry.py +46 -0
- profiles/resolver.py +63 -0
- runtime/__init__.py +5 -0
- runtime/agent/__init__.py +1 -0
- runtime/agent/ide.py +74 -0
- runtime/agent/model.py +197 -0
- runtime/agent/model_backed.py +49 -0
- runtime/agent/process.py +21 -0
- runtime/builtin_tools.py +57 -0
- runtime/capability.py +143 -0
- runtime/chat_adapter_registry.py +43 -0
- runtime/chat_config.py +57 -0
- runtime/chat_execution.py +52 -0
- runtime/config/__init__.py +1 -0
- runtime/config/loader.py +67 -0
- runtime/discovery.py +334 -0
- runtime/execution_config.py +71 -0
- runtime/execution_registry.py +50 -0
- runtime/git.py +84 -0
- runtime/github.py +56 -0
- runtime/github_probe.py +23 -0
- runtime/health.py +14 -0
- runtime/ide/__init__.py +6 -0
- runtime/ide/bridge.py +198 -0
- runtime/ide/registry.py +24 -0
- runtime/ide/runtime.py +35 -0
- runtime/local/__init__.py +18 -0
- runtime/local/filesystem.py +58 -0
- runtime/local/patch.py +79 -0
- runtime/local/path_security.py +31 -0
- runtime/local/permissions.py +20 -0
- runtime/local/process.py +56 -0
- runtime/local/shell.py +97 -0
- runtime/mcp/__init__.py +7 -0
- runtime/mcp/client.py +120 -0
- runtime/mcp/config.py +36 -0
- runtime/mcp/policy.py +57 -0
- runtime/mcp/proxy.py +30 -0
- runtime/mcp/server.py +137 -0
- runtime/mcp/session.py +19 -0
- runtime/model/__init__.py +5 -0
- runtime/model/ai_runtime.py +165 -0
- runtime/model/cli.py +49 -0
- runtime/model/config.py +98 -0
- runtime/model/credentials.py +42 -0
- runtime/model/factory.py +82 -0
- runtime/model/http.py +78 -0
- runtime/model/invoker.py +16 -0
- runtime/model/materializer.py +101 -0
- runtime/model/native.py +244 -0
- runtime/model/providers.py +55 -0
- runtime/model/registry.py +26 -0
- runtime/model/runtime_loader.py +51 -0
- runtime/model_control.py +136 -0
- runtime/multi_agent.py +142 -0
- runtime/policy.py +28 -0
- runtime/process.py +41 -0
- runtime/quota.py +259 -0
- runtime/repository.py +103 -0
- runtime/repository_tools.py +62 -0
- runtime/status.py +87 -0
- runtime/tool_calling.py +134 -0
- runtime/validation.py +121 -0
- runtime/vyrelon.py +1557 -0
runtime/ide/bridge.py
ADDED
|
@@ -0,0 +1,198 @@
|
|
|
1
|
+
from __future__ import annotations
|
|
2
|
+
|
|
3
|
+
import hmac
|
|
4
|
+
import json
|
|
5
|
+
from dataclasses import dataclass, fields, is_dataclass
|
|
6
|
+
from http.server import BaseHTTPRequestHandler, ThreadingHTTPServer
|
|
7
|
+
from threading import Thread
|
|
8
|
+
from typing import Callable
|
|
9
|
+
from urllib.parse import urlparse
|
|
10
|
+
|
|
11
|
+
from runtime.ide.runtime import IDERuntime
|
|
12
|
+
from core.contracts.ide import (
|
|
13
|
+
IDECommand, IDECommandKind, IDEContext, IDEEvent, IDEEventKind, IDEKind, IDEWorkRequest,
|
|
14
|
+
)
|
|
15
|
+
|
|
16
|
+
|
|
17
|
+
class IDEBridgeAuthorizationError(PermissionError):
|
|
18
|
+
pass
|
|
19
|
+
|
|
20
|
+
|
|
21
|
+
@dataclass(frozen=True)
|
|
22
|
+
class IDEBridgePolicy:
|
|
23
|
+
token: str
|
|
24
|
+
max_body_bytes: int = 256 * 1024
|
|
25
|
+
allow_remote: bool = False
|
|
26
|
+
|
|
27
|
+
def authorize(self, supplied: str | None) -> None:
|
|
28
|
+
if not self.token or not supplied or not hmac.compare_digest(self.token, supplied):
|
|
29
|
+
raise IDEBridgeAuthorizationError("invalid VYRELON IDE bridge token")
|
|
30
|
+
|
|
31
|
+
|
|
32
|
+
class IDEBridge:
|
|
33
|
+
def __init__(self, *, policy: IDEBridgePolicy,
|
|
34
|
+
runtime: IDERuntime | None = None,
|
|
35
|
+
event_handler: Callable[[IDEEvent], object] | None = None,
|
|
36
|
+
command_handler: Callable[[IDECommand], object] | None = None,
|
|
37
|
+
work_handler: Callable[[IDEWorkRequest], object] | None = None) -> None:
|
|
38
|
+
self.policy = policy
|
|
39
|
+
self.runtime = runtime
|
|
40
|
+
self.event_handler = event_handler or (lambda event: event)
|
|
41
|
+
self.command_handler = command_handler or (lambda command: command)
|
|
42
|
+
self.work_handler = work_handler
|
|
43
|
+
|
|
44
|
+
def handle_event(self, payload: dict[str, object]) -> object:
|
|
45
|
+
event = decode_event(payload)
|
|
46
|
+
if self.runtime is not None:
|
|
47
|
+
return self.runtime.ingest_event(event)
|
|
48
|
+
return self.event_handler(event)
|
|
49
|
+
|
|
50
|
+
def handle_work(self, payload: dict[str, object]) -> object:
|
|
51
|
+
request = decode_work_request(payload)
|
|
52
|
+
if self.work_handler is None:
|
|
53
|
+
raise ValueError("IDE work handler is not configured")
|
|
54
|
+
return self.work_handler(request)
|
|
55
|
+
|
|
56
|
+
def handle_command(self, payload: dict[str, object]) -> object:
|
|
57
|
+
command = decode_command(payload)
|
|
58
|
+
if self.runtime is not None:
|
|
59
|
+
if command.context is None:
|
|
60
|
+
raise ValueError("command context is required for IDE bridge dispatch")
|
|
61
|
+
return self.runtime.execute(command.context.kind, command)
|
|
62
|
+
return self.command_handler(command)
|
|
63
|
+
|
|
64
|
+
|
|
65
|
+
class _Handler(BaseHTTPRequestHandler):
|
|
66
|
+
bridge: IDEBridge
|
|
67
|
+
|
|
68
|
+
def do_POST(self) -> None: # noqa: N802
|
|
69
|
+
if not self.bridge.policy.allow_remote and self.client_address[0] not in {"127.0.0.1", "::1"}:
|
|
70
|
+
self._respond(403, {"error": "loopback only"})
|
|
71
|
+
return
|
|
72
|
+
token = self.headers.get("Authorization", "").removeprefix("Bearer ").strip()
|
|
73
|
+
try:
|
|
74
|
+
self.bridge.policy.authorize(token)
|
|
75
|
+
length = int(self.headers.get("Content-Length", "0"))
|
|
76
|
+
if length <= 0 or length > self.bridge.policy.max_body_bytes:
|
|
77
|
+
raise ValueError("invalid request body size")
|
|
78
|
+
payload = json.loads(self.rfile.read(length))
|
|
79
|
+
path = urlparse(self.path).path
|
|
80
|
+
if path == "/v1/ide/event":
|
|
81
|
+
result = self.bridge.handle_event(payload)
|
|
82
|
+
elif path == "/v1/ide/work":
|
|
83
|
+
result = self.bridge.handle_work(payload)
|
|
84
|
+
elif path == "/v1/ide/command":
|
|
85
|
+
result = self.bridge.handle_command(payload)
|
|
86
|
+
else:
|
|
87
|
+
self._respond(404, {"error": "not found"})
|
|
88
|
+
return
|
|
89
|
+
self._respond(200, {"ok": True, "result": _jsonable(result)})
|
|
90
|
+
except IDEBridgeAuthorizationError as exc:
|
|
91
|
+
self._respond(401, {"error": str(exc)})
|
|
92
|
+
except (ValueError, TypeError, json.JSONDecodeError) as exc:
|
|
93
|
+
self._respond(400, {"error": str(exc)})
|
|
94
|
+
except Exception as exc: # pragma: no cover
|
|
95
|
+
self._respond(500, {"error": str(exc)})
|
|
96
|
+
|
|
97
|
+
def log_message(self, format: str, *args: object) -> None:
|
|
98
|
+
return
|
|
99
|
+
|
|
100
|
+
def _respond(self, status: int, payload: dict[str, object]) -> None:
|
|
101
|
+
encoded = json.dumps(payload).encode("utf-8")
|
|
102
|
+
self.send_response(status)
|
|
103
|
+
self.send_header("Content-Type", "application/json")
|
|
104
|
+
self.send_header("Content-Length", str(len(encoded)))
|
|
105
|
+
self.end_headers()
|
|
106
|
+
self.wfile.write(encoded)
|
|
107
|
+
|
|
108
|
+
|
|
109
|
+
class IDEBridgeServer:
|
|
110
|
+
def __init__(self, bridge: IDEBridge, host: str = "127.0.0.1", port: int = 8787) -> None:
|
|
111
|
+
if host not in {"127.0.0.1", "::1"} and not bridge.policy.allow_remote:
|
|
112
|
+
raise ValueError("IDE bridge must bind to loopback unless remote access is explicitly allowed")
|
|
113
|
+
self.bridge = bridge
|
|
114
|
+
self.server = ThreadingHTTPServer((host, port), _Handler)
|
|
115
|
+
self.server.RequestHandlerClass.bridge = bridge
|
|
116
|
+
self.thread: Thread | None = None
|
|
117
|
+
|
|
118
|
+
def start(self) -> None:
|
|
119
|
+
if self.thread and self.thread.is_alive():
|
|
120
|
+
return
|
|
121
|
+
self.thread = Thread(target=self.server.serve_forever, daemon=True)
|
|
122
|
+
self.thread.start()
|
|
123
|
+
|
|
124
|
+
def stop(self) -> None:
|
|
125
|
+
self.server.shutdown()
|
|
126
|
+
self.server.server_close()
|
|
127
|
+
if self.thread:
|
|
128
|
+
self.thread.join(timeout=2)
|
|
129
|
+
|
|
130
|
+
|
|
131
|
+
def _jsonable(value: object) -> object:
|
|
132
|
+
if is_dataclass(value):
|
|
133
|
+
return {item.name: _jsonable(getattr(value, item.name)) for item in fields(value)}
|
|
134
|
+
if isinstance(value, (str, int, float, bool)) or value is None:
|
|
135
|
+
return value
|
|
136
|
+
if isinstance(value, dict):
|
|
137
|
+
return {str(key): _jsonable(item) for key, item in value.items()}
|
|
138
|
+
if isinstance(value, (list, tuple)):
|
|
139
|
+
return [_jsonable(item) for item in value]
|
|
140
|
+
return str(value)
|
|
141
|
+
|
|
142
|
+
|
|
143
|
+
def _context(payload: dict[str, object]) -> IDEContext:
|
|
144
|
+
raw = payload.get("context")
|
|
145
|
+
if not isinstance(raw, dict):
|
|
146
|
+
raise ValueError("context must be an object")
|
|
147
|
+
return IDEContext(
|
|
148
|
+
kind=IDEKind(str(raw["kind"])),
|
|
149
|
+
project_root=str(raw.get("project_root", "")),
|
|
150
|
+
workspace_id=raw.get("workspace_id"),
|
|
151
|
+
file_path=raw.get("file_path"),
|
|
152
|
+
selection_start=raw.get("selection_start"),
|
|
153
|
+
selection_end=raw.get("selection_end"),
|
|
154
|
+
language_id=raw.get("language_id"),
|
|
155
|
+
metadata=raw.get("metadata", {}),
|
|
156
|
+
)
|
|
157
|
+
|
|
158
|
+
|
|
159
|
+
def decode_event(payload: dict[str, object]) -> IDEEvent:
|
|
160
|
+
return IDEEvent(
|
|
161
|
+
kind=IDEEventKind(str(payload["kind"])),
|
|
162
|
+
context=_context(payload),
|
|
163
|
+
payload=payload.get("payload", {}),
|
|
164
|
+
metadata=payload.get("metadata", {}),
|
|
165
|
+
)
|
|
166
|
+
|
|
167
|
+
|
|
168
|
+
def decode_command(payload: dict[str, object]) -> IDECommand:
|
|
169
|
+
arguments = payload.get("arguments", {})
|
|
170
|
+
if not isinstance(arguments, dict):
|
|
171
|
+
raise ValueError("arguments must be an object")
|
|
172
|
+
return IDECommand(
|
|
173
|
+
kind=IDECommandKind(str(payload["kind"])),
|
|
174
|
+
arguments=arguments,
|
|
175
|
+
context=_context(payload) if payload.get("context") is not None else None,
|
|
176
|
+
metadata=payload.get("metadata", {}),
|
|
177
|
+
)
|
|
178
|
+
|
|
179
|
+
|
|
180
|
+
def decode_work_request(payload: dict[str, object]) -> IDEWorkRequest:
|
|
181
|
+
objective = payload.get("objective")
|
|
182
|
+
agent_id = payload.get("agent_id")
|
|
183
|
+
model_ids = payload.get("model_ids", [])
|
|
184
|
+
if not isinstance(objective, str) or not objective.strip():
|
|
185
|
+
raise ValueError("objective must be a non-empty string")
|
|
186
|
+
if not isinstance(agent_id, str) or not agent_id.strip():
|
|
187
|
+
raise ValueError("agent_id must be a non-empty string")
|
|
188
|
+
if not isinstance(model_ids, list) or not all(isinstance(item, str) for item in model_ids):
|
|
189
|
+
raise ValueError("model_ids must be a list of strings")
|
|
190
|
+
return IDEWorkRequest(
|
|
191
|
+
context=_context(payload),
|
|
192
|
+
objective=objective,
|
|
193
|
+
agent_id=agent_id,
|
|
194
|
+
model_ids=tuple(model_ids),
|
|
195
|
+
apply_changes=bool(payload.get("apply_changes", False)),
|
|
196
|
+
validation_commands=tuple(payload.get("validation_commands", [])),
|
|
197
|
+
metadata=payload.get("metadata", {}),
|
|
198
|
+
)
|
runtime/ide/registry.py
ADDED
|
@@ -0,0 +1,24 @@
|
|
|
1
|
+
"""Registry for optional IDE adapters."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
from core.contracts.ide import IDEAdapter, IDEKind
|
|
6
|
+
|
|
7
|
+
|
|
8
|
+
class IDEAdapterRegistry:
|
|
9
|
+
"""Register and resolve IDE adapters without IDE SDK dependencies."""
|
|
10
|
+
|
|
11
|
+
def __init__(self) -> None:
|
|
12
|
+
self._adapters: dict[IDEKind, IDEAdapter] = {}
|
|
13
|
+
|
|
14
|
+
def register(self, adapter: IDEAdapter) -> None:
|
|
15
|
+
self._adapters[adapter.kind] = adapter
|
|
16
|
+
|
|
17
|
+
def get(self, kind: IDEKind) -> IDEAdapter:
|
|
18
|
+
try:
|
|
19
|
+
return self._adapters[kind]
|
|
20
|
+
except KeyError as exc:
|
|
21
|
+
raise LookupError(f"IDE adapter not registered: {kind.value}") from exc
|
|
22
|
+
|
|
23
|
+
def list(self) -> tuple[IDEKind, ...]:
|
|
24
|
+
return tuple(self._adapters)
|
runtime/ide/runtime.py
ADDED
|
@@ -0,0 +1,35 @@
|
|
|
1
|
+
"""High-level facade for optional IDE adapters."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
from core.contracts.ide import IDEAdapter, IDECommand, IDECommandResult, IDEContext, IDEEvent, IDEKind
|
|
6
|
+
from runtime.ide.registry import IDEAdapterRegistry
|
|
7
|
+
|
|
8
|
+
|
|
9
|
+
class IDERuntime:
|
|
10
|
+
"""Expose IDE adapters without importing any native IDE SDK."""
|
|
11
|
+
|
|
12
|
+
def __init__(self, registry: IDEAdapterRegistry | None = None) -> None:
|
|
13
|
+
self.registry = registry or IDEAdapterRegistry()
|
|
14
|
+
self._contexts: dict[IDEKind, IDEContext] = {}
|
|
15
|
+
|
|
16
|
+
def register(self, adapter: IDEAdapter) -> None:
|
|
17
|
+
self.registry.register(adapter)
|
|
18
|
+
|
|
19
|
+
def context(self, kind: IDEKind) -> IDEContext:
|
|
20
|
+
if kind in self._contexts:
|
|
21
|
+
return self._contexts[kind]
|
|
22
|
+
context = self.registry.get(kind).context()
|
|
23
|
+
self._contexts[kind] = context
|
|
24
|
+
return context
|
|
25
|
+
|
|
26
|
+
def execute(self, kind: IDEKind, command: IDECommand) -> IDECommandResult:
|
|
27
|
+
return self.registry.get(kind).execute(command)
|
|
28
|
+
|
|
29
|
+
def ingest_event(self, event: IDEEvent) -> object:
|
|
30
|
+
"""Record normalized IDE state while keeping event ingestion separate from commands."""
|
|
31
|
+
self._contexts[event.context.kind] = event.context
|
|
32
|
+
return event
|
|
33
|
+
|
|
34
|
+
def capabilities(self, kind: IDEKind) -> frozenset[str]:
|
|
35
|
+
return self.registry.get(kind).capabilities()
|
|
@@ -0,0 +1,18 @@
|
|
|
1
|
+
"""VYRELON local tool runtime."""
|
|
2
|
+
|
|
3
|
+
from runtime.local.filesystem import FilesystemRuntime
|
|
4
|
+
from runtime.local.path_security import PathPolicy, PathSecurityError
|
|
5
|
+
from runtime.local.patch import PatchRuntime
|
|
6
|
+
from runtime.local.permissions import LocalPermissionGuard
|
|
7
|
+
from runtime.local.shell import PersistentShellRuntime, ShellResult, ShellState
|
|
8
|
+
|
|
9
|
+
__all__ = [
|
|
10
|
+
"FilesystemRuntime",
|
|
11
|
+
"LocalPermissionGuard",
|
|
12
|
+
"PatchRuntime",
|
|
13
|
+
"PathPolicy",
|
|
14
|
+
"PathSecurityError",
|
|
15
|
+
"PersistentShellRuntime",
|
|
16
|
+
"ShellResult",
|
|
17
|
+
"ShellState",
|
|
18
|
+
]
|
|
@@ -0,0 +1,58 @@
|
|
|
1
|
+
"""Policy- and path-controlled filesystem runtime."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
from pathlib import Path
|
|
6
|
+
|
|
7
|
+
from runtime.local.path_security import PathPolicy
|
|
8
|
+
from runtime.local.permissions import LocalPermissionGuard
|
|
9
|
+
from runtime.policy import ExecutionPolicy
|
|
10
|
+
|
|
11
|
+
|
|
12
|
+
class FilesystemRuntime:
|
|
13
|
+
"""First-class VYRELON filesystem operations."""
|
|
14
|
+
|
|
15
|
+
def __init__(
|
|
16
|
+
self,
|
|
17
|
+
policy: ExecutionPolicy | None = None,
|
|
18
|
+
paths: PathPolicy | None = None,
|
|
19
|
+
) -> None:
|
|
20
|
+
self.policy = policy or ExecutionPolicy()
|
|
21
|
+
self.paths = paths or PathPolicy()
|
|
22
|
+
self.permissions = LocalPermissionGuard(self.policy)
|
|
23
|
+
|
|
24
|
+
def read_text(self, path: str, *, encoding: str = "utf-8") -> str:
|
|
25
|
+
target = self.paths.resolve(path, must_exist=True)
|
|
26
|
+
if not target.is_file():
|
|
27
|
+
raise IsADirectoryError(str(target))
|
|
28
|
+
return target.read_text(encoding=encoding)
|
|
29
|
+
|
|
30
|
+
def write_text(
|
|
31
|
+
self, path: str, content: str, *, encoding: str = "utf-8", approved: bool = False
|
|
32
|
+
) -> Path:
|
|
33
|
+
self.permissions.require("filesystem.write", approved=approved)
|
|
34
|
+
target = self.paths.resolve(path)
|
|
35
|
+
target.parent.mkdir(parents=True, exist_ok=True)
|
|
36
|
+
target.write_text(content, encoding=encoding)
|
|
37
|
+
return target
|
|
38
|
+
|
|
39
|
+
def list_directory(self, path: str = ".") -> tuple[str, ...]:
|
|
40
|
+
target = self.paths.resolve(path, must_exist=True)
|
|
41
|
+
if not target.is_dir():
|
|
42
|
+
raise NotADirectoryError(str(target))
|
|
43
|
+
return tuple(sorted(item.name for item in target.iterdir()))
|
|
44
|
+
|
|
45
|
+
def create_directory(self, path: str, *, approved: bool = False) -> Path:
|
|
46
|
+
self.permissions.require("filesystem.write", approved=approved)
|
|
47
|
+
target = self.paths.resolve(path)
|
|
48
|
+
target.mkdir(parents=True, exist_ok=True)
|
|
49
|
+
return target
|
|
50
|
+
|
|
51
|
+
def delete(self, path: str, *, approved: bool = False) -> Path:
|
|
52
|
+
self.permissions.require("filesystem.write", approved=approved)
|
|
53
|
+
target = self.paths.resolve(path, must_exist=True)
|
|
54
|
+
if target.is_dir():
|
|
55
|
+
target.rmdir()
|
|
56
|
+
else:
|
|
57
|
+
target.unlink()
|
|
58
|
+
return target
|
runtime/local/patch.py
ADDED
|
@@ -0,0 +1,79 @@
|
|
|
1
|
+
"""Patch application runtime for VYRELON."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
import tempfile
|
|
6
|
+
from pathlib import Path
|
|
7
|
+
|
|
8
|
+
from runtime.local.path_security import PathPolicy
|
|
9
|
+
from runtime.local.permissions import LocalPermissionGuard
|
|
10
|
+
from runtime.policy import ExecutionPolicy
|
|
11
|
+
from runtime.process import ProcessResult, ProcessRuntime
|
|
12
|
+
|
|
13
|
+
|
|
14
|
+
class PatchRuntime:
|
|
15
|
+
"""Apply/check unified patches through the local Git patch engine.
|
|
16
|
+
|
|
17
|
+
Patch application is a filesystem-write capability. The Git subprocess used
|
|
18
|
+
internally is an implementation detail and must not grant or require the
|
|
19
|
+
separately exposed shell/process capability.
|
|
20
|
+
"""
|
|
21
|
+
|
|
22
|
+
def __init__(
|
|
23
|
+
self,
|
|
24
|
+
policy: ExecutionPolicy | None = None,
|
|
25
|
+
paths: PathPolicy | None = None,
|
|
26
|
+
process: ProcessRuntime | None = None,
|
|
27
|
+
) -> None:
|
|
28
|
+
self.policy = policy or ExecutionPolicy()
|
|
29
|
+
self.paths = paths or PathPolicy()
|
|
30
|
+
self.permissions = LocalPermissionGuard(self.policy)
|
|
31
|
+
self.process = process or ProcessRuntime(
|
|
32
|
+
ExecutionPolicy(
|
|
33
|
+
allow_process=True,
|
|
34
|
+
allow_filesystem_write=self.policy.allow_filesystem_write,
|
|
35
|
+
allow_git_write=self.policy.allow_git_write,
|
|
36
|
+
allow_network=self.policy.allow_network,
|
|
37
|
+
allow_github_write=self.policy.allow_github_write,
|
|
38
|
+
require_approval_for=self.policy.require_approval_for,
|
|
39
|
+
)
|
|
40
|
+
)
|
|
41
|
+
|
|
42
|
+
def _with_patch_file(self, patch_text: str, callback):
|
|
43
|
+
with tempfile.NamedTemporaryFile(
|
|
44
|
+
mode="w",
|
|
45
|
+
encoding="utf-8",
|
|
46
|
+
suffix=".patch",
|
|
47
|
+
delete=True,
|
|
48
|
+
) as patch_file:
|
|
49
|
+
patch_file.write(patch_text)
|
|
50
|
+
patch_file.flush()
|
|
51
|
+
return callback(patch_file.name)
|
|
52
|
+
|
|
53
|
+
def check(self, project_root: str, patch_text: str) -> ProcessResult:
|
|
54
|
+
root = self.paths.resolve(project_root, must_exist=True)
|
|
55
|
+
if not root.is_dir():
|
|
56
|
+
raise NotADirectoryError(str(root))
|
|
57
|
+
return self._with_patch_file(
|
|
58
|
+
patch_text,
|
|
59
|
+
lambda patch_path: self.process.run(
|
|
60
|
+
["git", "apply", "--check", patch_path],
|
|
61
|
+
cwd=str(root),
|
|
62
|
+
),
|
|
63
|
+
)
|
|
64
|
+
|
|
65
|
+
def apply(
|
|
66
|
+
self, project_root: str, patch_text: str, *, approved: bool = False
|
|
67
|
+
) -> ProcessResult:
|
|
68
|
+
self.permissions.require("filesystem.write", approved=approved)
|
|
69
|
+
checked = self.check(project_root, patch_text)
|
|
70
|
+
if checked.returncode != 0:
|
|
71
|
+
return checked
|
|
72
|
+
root = self.paths.resolve(project_root, must_exist=True)
|
|
73
|
+
return self._with_patch_file(
|
|
74
|
+
patch_text,
|
|
75
|
+
lambda patch_path: self.process.run(
|
|
76
|
+
["git", "apply", patch_path],
|
|
77
|
+
cwd=str(root),
|
|
78
|
+
),
|
|
79
|
+
)
|
|
@@ -0,0 +1,31 @@
|
|
|
1
|
+
"""Workspace/path security for VYRELON local tools."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
from pathlib import Path
|
|
6
|
+
|
|
7
|
+
|
|
8
|
+
class PathSecurityError(PermissionError):
|
|
9
|
+
"""Raised when a path escapes the configured local workspace."""
|
|
10
|
+
|
|
11
|
+
|
|
12
|
+
class PathPolicy:
|
|
13
|
+
"""Resolve paths and optionally confine them to explicit workspace roots."""
|
|
14
|
+
|
|
15
|
+
def __init__(self, roots: tuple[str, ...] = ()) -> None:
|
|
16
|
+
self.roots = tuple(Path(root).expanduser().resolve() for root in roots)
|
|
17
|
+
|
|
18
|
+
def resolve(self, path: str | Path, *, must_exist: bool = False) -> Path:
|
|
19
|
+
candidate = Path(path).expanduser()
|
|
20
|
+
if not candidate.is_absolute() and self.roots:
|
|
21
|
+
candidate = self.roots[0] / candidate
|
|
22
|
+
resolved = candidate.resolve(strict=must_exist)
|
|
23
|
+
self._check_allowed(resolved)
|
|
24
|
+
return resolved
|
|
25
|
+
|
|
26
|
+
def _check_allowed(self, path: Path) -> None:
|
|
27
|
+
if not self.roots:
|
|
28
|
+
return
|
|
29
|
+
if not any(path == root or root in path.parents for root in self.roots):
|
|
30
|
+
allowed = ", ".join(str(root) for root in self.roots)
|
|
31
|
+
raise PathSecurityError(f"path is outside allowed roots: {path}; allowed={allowed}")
|
|
@@ -0,0 +1,20 @@
|
|
|
1
|
+
"""Local tool permission enforcement."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
from dataclasses import dataclass
|
|
6
|
+
|
|
7
|
+
from runtime.policy import ExecutionPolicy
|
|
8
|
+
|
|
9
|
+
|
|
10
|
+
@dataclass(frozen=True)
|
|
11
|
+
class LocalPermissionGuard:
|
|
12
|
+
"""Apply VYRELON execution policy to local tool operations."""
|
|
13
|
+
|
|
14
|
+
policy: ExecutionPolicy
|
|
15
|
+
|
|
16
|
+
def require(self, capability: str, *, approved: bool = False) -> None:
|
|
17
|
+
if not self.policy.permits(capability):
|
|
18
|
+
raise PermissionError(f"local capability is disabled: {capability}")
|
|
19
|
+
if self.policy.requires_approval(capability) and not approved:
|
|
20
|
+
raise PermissionError(f"explicit approval required for: {capability}")
|
runtime/local/process.py
ADDED
|
@@ -0,0 +1,56 @@
|
|
|
1
|
+
"""Subprocess executor with explicit execution policy."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
import subprocess
|
|
6
|
+
from dataclasses import dataclass
|
|
7
|
+
from pathlib import Path
|
|
8
|
+
|
|
9
|
+
from core.contracts.runtime import ExecutionRequest
|
|
10
|
+
from runtime.policy import ExecutionPolicy
|
|
11
|
+
|
|
12
|
+
|
|
13
|
+
@dataclass(frozen=True)
|
|
14
|
+
class ProcessResult:
|
|
15
|
+
returncode: int
|
|
16
|
+
stdout: str
|
|
17
|
+
stderr: str
|
|
18
|
+
command: tuple[str, ...]
|
|
19
|
+
|
|
20
|
+
|
|
21
|
+
class LocalProcessExecutor:
|
|
22
|
+
"""Execute an explicit argv command on the local machine."""
|
|
23
|
+
|
|
24
|
+
def __init__(self, policy: ExecutionPolicy | None = None) -> None:
|
|
25
|
+
self.policy = policy or ExecutionPolicy()
|
|
26
|
+
|
|
27
|
+
def execute(self, request: ExecutionRequest) -> ProcessResult:
|
|
28
|
+
if not self.policy.permits("process"):
|
|
29
|
+
raise PermissionError("Local process execution is disabled by policy")
|
|
30
|
+
|
|
31
|
+
command = request.work_unit.inputs.get("command")
|
|
32
|
+
if not isinstance(command, (list, tuple)) or not command:
|
|
33
|
+
raise ValueError("WorkUnit.inputs['command'] must be a non-empty argv list")
|
|
34
|
+
if not all(isinstance(part, str) and part for part in command):
|
|
35
|
+
raise ValueError("Every command argument must be a non-empty string")
|
|
36
|
+
|
|
37
|
+
cwd_value = request.work_unit.inputs.get("cwd")
|
|
38
|
+
cwd = Path(cwd_value).expanduser() if isinstance(cwd_value, str) else None
|
|
39
|
+
timeout = request.work_unit.inputs.get("timeout", 300)
|
|
40
|
+
if not isinstance(timeout, (int, float)) or timeout <= 0:
|
|
41
|
+
raise ValueError("WorkUnit.inputs['timeout'] must be a positive number")
|
|
42
|
+
|
|
43
|
+
completed = subprocess.run(
|
|
44
|
+
list(command),
|
|
45
|
+
cwd=cwd,
|
|
46
|
+
capture_output=True,
|
|
47
|
+
text=True,
|
|
48
|
+
timeout=timeout,
|
|
49
|
+
check=False,
|
|
50
|
+
)
|
|
51
|
+
return ProcessResult(
|
|
52
|
+
returncode=completed.returncode,
|
|
53
|
+
stdout=completed.stdout,
|
|
54
|
+
stderr=completed.stderr,
|
|
55
|
+
command=tuple(command),
|
|
56
|
+
)
|
runtime/local/shell.py
ADDED
|
@@ -0,0 +1,97 @@
|
|
|
1
|
+
"""Persistent working-directory shell runtime."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
import os
|
|
6
|
+
import shlex
|
|
7
|
+
import subprocess
|
|
8
|
+
from dataclasses import dataclass, field
|
|
9
|
+
|
|
10
|
+
from runtime.local.path_security import PathPolicy
|
|
11
|
+
from runtime.local.permissions import LocalPermissionGuard
|
|
12
|
+
from runtime.policy import ExecutionPolicy
|
|
13
|
+
|
|
14
|
+
|
|
15
|
+
@dataclass
|
|
16
|
+
class ShellState:
|
|
17
|
+
cwd: str
|
|
18
|
+
environment: dict[str, str] = field(default_factory=dict)
|
|
19
|
+
|
|
20
|
+
|
|
21
|
+
@dataclass(frozen=True)
|
|
22
|
+
class ShellResult:
|
|
23
|
+
command: str
|
|
24
|
+
cwd: str
|
|
25
|
+
returncode: int
|
|
26
|
+
stdout: str
|
|
27
|
+
stderr: str
|
|
28
|
+
|
|
29
|
+
|
|
30
|
+
class PersistentShellRuntime:
|
|
31
|
+
"""Shell execution with persistent cwd/environment state between calls."""
|
|
32
|
+
|
|
33
|
+
def __init__(
|
|
34
|
+
self,
|
|
35
|
+
cwd: str,
|
|
36
|
+
policy: ExecutionPolicy | None = None,
|
|
37
|
+
paths: PathPolicy | None = None,
|
|
38
|
+
) -> None:
|
|
39
|
+
self.policy = policy or ExecutionPolicy()
|
|
40
|
+
self.paths = paths or PathPolicy()
|
|
41
|
+
self.permissions = LocalPermissionGuard(self.policy)
|
|
42
|
+
initial = self.paths.resolve(cwd, must_exist=True)
|
|
43
|
+
if not initial.is_dir():
|
|
44
|
+
raise NotADirectoryError(str(initial))
|
|
45
|
+
self.state = ShellState(str(initial))
|
|
46
|
+
|
|
47
|
+
def status(self) -> ShellState:
|
|
48
|
+
return ShellState(self.state.cwd, dict(self.state.environment))
|
|
49
|
+
|
|
50
|
+
def reset(self, cwd: str) -> ShellState:
|
|
51
|
+
self.permissions.require("process")
|
|
52
|
+
target = self.paths.resolve(cwd, must_exist=True)
|
|
53
|
+
if not target.is_dir():
|
|
54
|
+
raise NotADirectoryError(str(target))
|
|
55
|
+
self.state.cwd = str(target)
|
|
56
|
+
return self.status()
|
|
57
|
+
|
|
58
|
+
def set_environment(self, name: str, value: str) -> ShellState:
|
|
59
|
+
self.permissions.require("process")
|
|
60
|
+
if not name or "=" in name or "\x00" in name:
|
|
61
|
+
raise ValueError("invalid environment variable name")
|
|
62
|
+
self.state.environment[name] = value
|
|
63
|
+
return self.status()
|
|
64
|
+
|
|
65
|
+
def run(self, command: str, *, timeout: float = 120.0) -> ShellResult:
|
|
66
|
+
self.permissions.require("process")
|
|
67
|
+
if not command.strip():
|
|
68
|
+
raise ValueError("command must not be empty")
|
|
69
|
+
env = os.environ.copy()
|
|
70
|
+
env.update(self.state.environment)
|
|
71
|
+
completed = subprocess.run(
|
|
72
|
+
command,
|
|
73
|
+
cwd=self.state.cwd,
|
|
74
|
+
env=env,
|
|
75
|
+
shell=True,
|
|
76
|
+
capture_output=True,
|
|
77
|
+
text=True,
|
|
78
|
+
check=False,
|
|
79
|
+
timeout=timeout,
|
|
80
|
+
)
|
|
81
|
+
return ShellResult(
|
|
82
|
+
command=command,
|
|
83
|
+
cwd=self.state.cwd,
|
|
84
|
+
returncode=completed.returncode,
|
|
85
|
+
stdout=completed.stdout,
|
|
86
|
+
stderr=completed.stderr,
|
|
87
|
+
)
|
|
88
|
+
|
|
89
|
+
def cd(self, path: str) -> ShellState:
|
|
90
|
+
target = self.paths.resolve(path, must_exist=True)
|
|
91
|
+
if not target.is_dir():
|
|
92
|
+
raise NotADirectoryError(str(target))
|
|
93
|
+
self.state.cwd = str(target)
|
|
94
|
+
return self.status()
|
|
95
|
+
|
|
96
|
+
def command_words(self, command: str) -> tuple[str, ...]:
|
|
97
|
+
return tuple(shlex.split(command))
|
runtime/mcp/__init__.py
ADDED
|
@@ -0,0 +1,7 @@
|
|
|
1
|
+
"""MCP runtime for VYRELON."""
|
|
2
|
+
from .client import MCPClient, MCPError, MCPProtocolError
|
|
3
|
+
from .config import MCPConfigLoader
|
|
4
|
+
from .proxy import MCPToolProxy
|
|
5
|
+
from .server import VYRELONMCPServer
|
|
6
|
+
from .session import MCPSessionRegistry
|
|
7
|
+
__all__=["MCPClient","MCPConfigLoader","MCPError","MCPProtocolError","MCPToolProxy","VYRELONMCPServer","MCPSessionRegistry"]
|