eda-bridge-runtime 0.1.0a5__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.
- eda_bridge_runtime/__init__.py +33 -0
- eda_bridge_runtime/_version.py +1 -0
- eda_bridge_runtime/adapter.py +36 -0
- eda_bridge_runtime/agent_audit.py +152 -0
- eda_bridge_runtime/artifacts.py +43 -0
- eda_bridge_runtime/cli.py +154 -0
- eda_bridge_runtime/connections.py +192 -0
- eda_bridge_runtime/context.py +150 -0
- eda_bridge_runtime/jobs.py +203 -0
- eda_bridge_runtime/lease.py +65 -0
- eda_bridge_runtime/ledger.py +264 -0
- eda_bridge_runtime/mcp_server.py +389 -0
- eda_bridge_runtime/protocol.py +274 -0
- eda_bridge_runtime/redaction.py +24 -0
- eda_bridge_runtime/runtime.py +143 -0
- eda_bridge_runtime/supervisor.py +73 -0
- eda_bridge_runtime/transport.py +171 -0
- eda_bridge_runtime-0.1.0a5.dist-info/METADATA +97 -0
- eda_bridge_runtime-0.1.0a5.dist-info/RECORD +22 -0
- eda_bridge_runtime-0.1.0a5.dist-info/WHEEL +4 -0
- eda_bridge_runtime-0.1.0a5.dist-info/entry_points.txt +2 -0
- eda_bridge_runtime-0.1.0a5.dist-info/licenses/LICENSE +22 -0
|
@@ -0,0 +1,33 @@
|
|
|
1
|
+
"""Agent-neutral execution runtime for EDA bridges."""
|
|
2
|
+
|
|
3
|
+
from ._version import __version__
|
|
4
|
+
from .adapter import Adapter, AdapterContext, AdapterResult
|
|
5
|
+
from .connections import ConnectionRegistry, ConnectionSpec, discover_connection_origin
|
|
6
|
+
from .context import EDAContext, capability_digest, stable_origin_id
|
|
7
|
+
from .jobs import JobStore
|
|
8
|
+
from .ledger import ExecutionLedger
|
|
9
|
+
from .protocol import ActorIdentity, RequestEnvelope, ResponseEnvelope, project_run
|
|
10
|
+
from .runtime import Runtime
|
|
11
|
+
from .supervisor import run_job_worker, spawn_detached_worker
|
|
12
|
+
|
|
13
|
+
__all__ = [
|
|
14
|
+
"__version__",
|
|
15
|
+
"ActorIdentity",
|
|
16
|
+
"Adapter",
|
|
17
|
+
"AdapterContext",
|
|
18
|
+
"AdapterResult",
|
|
19
|
+
"ConnectionRegistry",
|
|
20
|
+
"ConnectionSpec",
|
|
21
|
+
"discover_connection_origin",
|
|
22
|
+
"EDAContext",
|
|
23
|
+
"capability_digest",
|
|
24
|
+
"ExecutionLedger",
|
|
25
|
+
"JobStore",
|
|
26
|
+
"RequestEnvelope",
|
|
27
|
+
"ResponseEnvelope",
|
|
28
|
+
"Runtime",
|
|
29
|
+
"project_run",
|
|
30
|
+
"run_job_worker",
|
|
31
|
+
"spawn_detached_worker",
|
|
32
|
+
"stable_origin_id",
|
|
33
|
+
]
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
__version__ = "0.1.0a5"
|
|
@@ -0,0 +1,36 @@
|
|
|
1
|
+
"""Minimal adapter SDK. Vendor semantics stay outside the runtime core."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
from abc import ABC, abstractmethod
|
|
6
|
+
from collections.abc import Callable
|
|
7
|
+
from dataclasses import dataclass, field
|
|
8
|
+
from typing import Any
|
|
9
|
+
|
|
10
|
+
from .protocol import RequestEnvelope
|
|
11
|
+
|
|
12
|
+
|
|
13
|
+
@dataclass(frozen=True)
|
|
14
|
+
class AdapterResult:
|
|
15
|
+
status: str
|
|
16
|
+
result: dict[str, Any] = field(default_factory=dict)
|
|
17
|
+
artifacts: tuple[dict[str, Any], ...] = ()
|
|
18
|
+
|
|
19
|
+
|
|
20
|
+
@dataclass(frozen=True)
|
|
21
|
+
class AdapterContext:
|
|
22
|
+
emit: Callable[[str, dict[str, Any]], None]
|
|
23
|
+
fencing_token: int | None = None
|
|
24
|
+
|
|
25
|
+
|
|
26
|
+
class Adapter(ABC):
|
|
27
|
+
name: str
|
|
28
|
+
version: str
|
|
29
|
+
|
|
30
|
+
@abstractmethod
|
|
31
|
+
def capabilities(self, target: dict[str, Any] | None = None) -> dict[str, Any]:
|
|
32
|
+
"""Return typed operations and explicit escape lanes for an optional target."""
|
|
33
|
+
|
|
34
|
+
@abstractmethod
|
|
35
|
+
def execute(self, request: RequestEnvelope, context: AdapterContext) -> AdapterResult:
|
|
36
|
+
"""Execute one idempotent request and return normalized evidence."""
|
|
@@ -0,0 +1,152 @@
|
|
|
1
|
+
"""Agent-host audit events captured outside the model context."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
import hashlib
|
|
6
|
+
import json
|
|
7
|
+
import os
|
|
8
|
+
import re
|
|
9
|
+
from collections.abc import Mapping
|
|
10
|
+
from pathlib import Path
|
|
11
|
+
from typing import Any
|
|
12
|
+
|
|
13
|
+
from .ledger import ExecutionLedger
|
|
14
|
+
from .protocol import RUN_VIEW_PROTOCOL, ActorIdentity
|
|
15
|
+
|
|
16
|
+
AGENT_AUDIT_PROTOCOL = "eda-runtime.agent-audit/v1"
|
|
17
|
+
_RUNTIME_TOOL = re.compile(r"^mcp__(?P<server>.+)__(?P<tool>.+)$")
|
|
18
|
+
_TOOL_NAMES = {
|
|
19
|
+
"eda_capabilities": "eda.capabilities",
|
|
20
|
+
"eda_submit": "eda.submit",
|
|
21
|
+
"eda_job_status": "eda.job.status",
|
|
22
|
+
"eda_job_events": "eda.job.events",
|
|
23
|
+
"eda_connections_list": "eda.connections.list",
|
|
24
|
+
"eda_context_resolve": "eda.context.resolve",
|
|
25
|
+
"eda.capabilities": "eda.capabilities",
|
|
26
|
+
"eda.submit": "eda.submit",
|
|
27
|
+
"eda.job.status": "eda.job.status",
|
|
28
|
+
"eda.job.events": "eda.job.events",
|
|
29
|
+
"eda.connections.list": "eda.connections.list",
|
|
30
|
+
"eda.context.resolve": "eda.context.resolve",
|
|
31
|
+
}
|
|
32
|
+
|
|
33
|
+
|
|
34
|
+
def default_agent_audit_path() -> Path:
|
|
35
|
+
root = Path(os.environ.get("EDA_RUNTIME_HOME", Path.home() / ".eda-bridge-runtime"))
|
|
36
|
+
return root / "agent-audit.sqlite3"
|
|
37
|
+
|
|
38
|
+
|
|
39
|
+
def record_codex_hook(
|
|
40
|
+
event: Mapping[str, Any],
|
|
41
|
+
*,
|
|
42
|
+
phase: str,
|
|
43
|
+
database: str | Path | None = None,
|
|
44
|
+
) -> bool:
|
|
45
|
+
"""Record one Codex lifecycle fact without changing the pending tool call."""
|
|
46
|
+
if phase not in {"pre", "post"}:
|
|
47
|
+
raise ValueError(f"unsupported hook phase: {phase}")
|
|
48
|
+
tool_name = str(event.get("tool_name") or "")
|
|
49
|
+
match = _RUNTIME_TOOL.fullmatch(tool_name)
|
|
50
|
+
if match is None:
|
|
51
|
+
return False
|
|
52
|
+
server_name = match.group("server").lower().replace("-", "_")
|
|
53
|
+
native_tool_name = match.group("tool")
|
|
54
|
+
if server_name != "eda_bridge_runtime" or native_tool_name not in _TOOL_NAMES:
|
|
55
|
+
return False
|
|
56
|
+
tool_call_id = str(event.get("tool_use_id") or "").strip()
|
|
57
|
+
session_id = str(event.get("session_id") or "").strip()
|
|
58
|
+
if not tool_call_id or not session_id:
|
|
59
|
+
return False
|
|
60
|
+
tool_input = event.get("tool_input")
|
|
61
|
+
tool_input = tool_input if isinstance(tool_input, Mapping) else {}
|
|
62
|
+
purpose = str(tool_input.get("purpose") or "unspecified EDA operation")[:240]
|
|
63
|
+
fingerprint = _fingerprint(tool_name, tool_input)
|
|
64
|
+
actor = ActorIdentity.detect(
|
|
65
|
+
observed={
|
|
66
|
+
key: value
|
|
67
|
+
for key, value in {
|
|
68
|
+
"agent_family": "codex",
|
|
69
|
+
"model": event.get("model"),
|
|
70
|
+
"session_id": session_id,
|
|
71
|
+
"turn_id": event.get("turn_id"),
|
|
72
|
+
"tool_call_id": tool_call_id,
|
|
73
|
+
"permission_mode": event.get("permission_mode"),
|
|
74
|
+
}.items()
|
|
75
|
+
if value
|
|
76
|
+
},
|
|
77
|
+
inferred={"harness": "mcp"},
|
|
78
|
+
)
|
|
79
|
+
run_id = _audit_run_id(tool_call_id)
|
|
80
|
+
request_id = str(event.get("turn_id") or session_id)
|
|
81
|
+
payload: dict[str, Any] = {
|
|
82
|
+
"protocol": AGENT_AUDIT_PROTOCOL,
|
|
83
|
+
"actor": actor.to_dict(),
|
|
84
|
+
"tool": _TOOL_NAMES[native_tool_name],
|
|
85
|
+
"purpose": purpose,
|
|
86
|
+
"input_sha256": fingerprint,
|
|
87
|
+
}
|
|
88
|
+
event_type = "agent.tool.requested"
|
|
89
|
+
if phase == "post":
|
|
90
|
+
event_type = "agent.tool.completed"
|
|
91
|
+
payload["execution"] = _execution_reference(event.get("tool_response"))
|
|
92
|
+
with ExecutionLedger(database or default_agent_audit_path()) as ledger:
|
|
93
|
+
ledger.append(
|
|
94
|
+
run_id=run_id,
|
|
95
|
+
request_id=request_id,
|
|
96
|
+
event_type=event_type,
|
|
97
|
+
source="codex-hook",
|
|
98
|
+
payload=payload,
|
|
99
|
+
)
|
|
100
|
+
if phase == "post":
|
|
101
|
+
ledger.finalize(run_id)
|
|
102
|
+
return True
|
|
103
|
+
|
|
104
|
+
|
|
105
|
+
def audit_events(database: str | Path | None = None, *, limit: int = 20) -> list[dict[str, Any]]:
|
|
106
|
+
with ExecutionLedger(database or default_agent_audit_path()) as ledger:
|
|
107
|
+
events = ledger.events()
|
|
108
|
+
return events[-max(1, min(limit, 1000)) :]
|
|
109
|
+
|
|
110
|
+
|
|
111
|
+
def _audit_run_id(tool_call_id: str) -> str:
|
|
112
|
+
digest = hashlib.sha256(tool_call_id.encode("utf-8")).hexdigest()[:32]
|
|
113
|
+
return f"agent_{digest}"
|
|
114
|
+
|
|
115
|
+
|
|
116
|
+
def _fingerprint(tool_name: str, tool_input: Mapping[str, Any]) -> str:
|
|
117
|
+
material = {"tool_name": tool_name, "tool_input": tool_input}
|
|
118
|
+
canonical = json.dumps(material, sort_keys=True, separators=(",", ":"), ensure_ascii=False)
|
|
119
|
+
return hashlib.sha256(canonical.encode("utf-8")).hexdigest()
|
|
120
|
+
|
|
121
|
+
|
|
122
|
+
def _execution_reference(value: Any) -> dict[str, Any]:
|
|
123
|
+
run = _find_run_view(value)
|
|
124
|
+
if run is None:
|
|
125
|
+
return {"linked": False}
|
|
126
|
+
return {
|
|
127
|
+
"linked": True,
|
|
128
|
+
"run_id": str(run.get("run_id") or "") or None,
|
|
129
|
+
"request_id": str(run.get("request_id") or "") or None,
|
|
130
|
+
"job_id": str(run.get("job_id") or "") or None,
|
|
131
|
+
"state": str(run.get("state") or "unknown"),
|
|
132
|
+
"terminal": bool(run.get("terminal", False)),
|
|
133
|
+
}
|
|
134
|
+
|
|
135
|
+
|
|
136
|
+
def _find_run_view(value: Any, *, depth: int = 0) -> Mapping[str, Any] | None:
|
|
137
|
+
if depth > 8:
|
|
138
|
+
return None
|
|
139
|
+
if isinstance(value, Mapping):
|
|
140
|
+
if value.get("protocol") == RUN_VIEW_PROTOCOL:
|
|
141
|
+
return value
|
|
142
|
+
run = value.get("run")
|
|
143
|
+
if isinstance(run, Mapping) and run.get("protocol") == RUN_VIEW_PROTOCOL:
|
|
144
|
+
return run
|
|
145
|
+
for item in value.values():
|
|
146
|
+
if found := _find_run_view(item, depth=depth + 1):
|
|
147
|
+
return found
|
|
148
|
+
elif isinstance(value, list):
|
|
149
|
+
for item in value:
|
|
150
|
+
if found := _find_run_view(item, depth=depth + 1):
|
|
151
|
+
return found
|
|
152
|
+
return None
|
|
@@ -0,0 +1,43 @@
|
|
|
1
|
+
"""Content-addressed artifact evidence."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
import hashlib
|
|
6
|
+
from dataclasses import asdict, dataclass
|
|
7
|
+
from pathlib import Path
|
|
8
|
+
|
|
9
|
+
|
|
10
|
+
@dataclass(frozen=True)
|
|
11
|
+
class Artifact:
|
|
12
|
+
logical_name: str
|
|
13
|
+
path: str
|
|
14
|
+
sha256: str
|
|
15
|
+
size: int
|
|
16
|
+
media_type: str = "application/octet-stream"
|
|
17
|
+
retention_days: int | None = None
|
|
18
|
+
|
|
19
|
+
@classmethod
|
|
20
|
+
def from_path(
|
|
21
|
+
cls,
|
|
22
|
+
path: str | Path,
|
|
23
|
+
*,
|
|
24
|
+
logical_name: str | None = None,
|
|
25
|
+
media_type: str = "application/octet-stream",
|
|
26
|
+
retention_days: int | None = None,
|
|
27
|
+
) -> Artifact:
|
|
28
|
+
source = Path(path)
|
|
29
|
+
digest = hashlib.sha256()
|
|
30
|
+
with source.open("rb") as stream:
|
|
31
|
+
for chunk in iter(lambda: stream.read(1024 * 1024), b""):
|
|
32
|
+
digest.update(chunk)
|
|
33
|
+
return cls(
|
|
34
|
+
logical_name or source.name,
|
|
35
|
+
str(source),
|
|
36
|
+
digest.hexdigest(),
|
|
37
|
+
source.stat().st_size,
|
|
38
|
+
media_type,
|
|
39
|
+
retention_days,
|
|
40
|
+
)
|
|
41
|
+
|
|
42
|
+
def to_dict(self) -> dict[str, str | int | None]:
|
|
43
|
+
return asdict(self)
|
|
@@ -0,0 +1,154 @@
|
|
|
1
|
+
"""Small operational CLI; EDA-specific commands are supplied by adapters."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
import argparse
|
|
6
|
+
import json
|
|
7
|
+
import sys
|
|
8
|
+
from pathlib import Path
|
|
9
|
+
|
|
10
|
+
from ._version import __version__
|
|
11
|
+
from .connections import ConnectionRegistry, ConnectionSpec, discover_connection_origin
|
|
12
|
+
from .context import EDAContext
|
|
13
|
+
from .ledger import ExecutionLedger
|
|
14
|
+
from .protocol import ActorIdentity, RuntimeFacts
|
|
15
|
+
|
|
16
|
+
|
|
17
|
+
def _parser() -> argparse.ArgumentParser:
|
|
18
|
+
parser = argparse.ArgumentParser(prog="eda-runtime")
|
|
19
|
+
parser.add_argument("--version", action="version", version=__version__)
|
|
20
|
+
sub = parser.add_subparsers(dest="command", required=True)
|
|
21
|
+
sub.add_parser("doctor")
|
|
22
|
+
context = sub.add_parser("context")
|
|
23
|
+
context_sub = context.add_subparsers(dest="context_command", required=True)
|
|
24
|
+
decode = context_sub.add_parser("decode")
|
|
25
|
+
decode.add_argument("token")
|
|
26
|
+
ledger = sub.add_parser("ledger")
|
|
27
|
+
ledger_sub = ledger.add_subparsers(dest="ledger_command", required=True)
|
|
28
|
+
verify = ledger_sub.add_parser("verify")
|
|
29
|
+
verify.add_argument("database", type=Path)
|
|
30
|
+
verify.add_argument("run_id")
|
|
31
|
+
export = ledger_sub.add_parser("export")
|
|
32
|
+
export.add_argument("database", type=Path)
|
|
33
|
+
export.add_argument("destination", type=Path)
|
|
34
|
+
connection = sub.add_parser("connection")
|
|
35
|
+
connection_sub = connection.add_subparsers(dest="connection_command", required=True)
|
|
36
|
+
for action in ("list", "set", "remove"):
|
|
37
|
+
item = connection_sub.add_parser(action)
|
|
38
|
+
item.add_argument("--registry", type=Path)
|
|
39
|
+
if action in {"set", "remove"}:
|
|
40
|
+
item.add_argument("connection_id")
|
|
41
|
+
if action == "set":
|
|
42
|
+
item.add_argument("--eda", required=True)
|
|
43
|
+
item.add_argument("--kind", choices=("local", "ssh"), required=True)
|
|
44
|
+
item.add_argument("--host")
|
|
45
|
+
item.add_argument("--origin-id")
|
|
46
|
+
item.add_argument("--no-origin-probe", action="store_true")
|
|
47
|
+
item.add_argument("--ssh-option", action="append", default=[])
|
|
48
|
+
item.add_argument("--timeout-seconds", type=float, default=30)
|
|
49
|
+
item.add_argument("launch_command", nargs=argparse.REMAINDER)
|
|
50
|
+
mcp = sub.add_parser("mcp")
|
|
51
|
+
mcp_sub = mcp.add_subparsers(dest="mcp_command", required=True)
|
|
52
|
+
mcp_serve = mcp_sub.add_parser("serve")
|
|
53
|
+
mcp_serve.add_argument("--registry", type=Path)
|
|
54
|
+
hook = sub.add_parser("hook")
|
|
55
|
+
hook_sub = hook.add_subparsers(dest="hook_command", required=True)
|
|
56
|
+
for phase in ("codex-pre-tool-use", "codex-post-tool-use"):
|
|
57
|
+
item = hook_sub.add_parser(phase)
|
|
58
|
+
item.add_argument("--database", type=Path)
|
|
59
|
+
audit = sub.add_parser("audit")
|
|
60
|
+
audit_sub = audit.add_subparsers(dest="audit_command", required=True)
|
|
61
|
+
audit_list = audit_sub.add_parser("list")
|
|
62
|
+
audit_list.add_argument("--database", type=Path)
|
|
63
|
+
audit_list.add_argument("--limit", type=int, default=20)
|
|
64
|
+
return parser
|
|
65
|
+
|
|
66
|
+
|
|
67
|
+
def main(argv: list[str] | None = None) -> int:
|
|
68
|
+
args = _parser().parse_args(argv)
|
|
69
|
+
if args.command == "doctor":
|
|
70
|
+
print(
|
|
71
|
+
json.dumps(
|
|
72
|
+
{
|
|
73
|
+
"status": "ok",
|
|
74
|
+
"runtime": RuntimeFacts(__version__).to_dict(),
|
|
75
|
+
"actor": ActorIdentity.detect().to_dict(),
|
|
76
|
+
"protocols": {"request": 1, "context": [1, 2], "handshake": 1},
|
|
77
|
+
},
|
|
78
|
+
indent=2,
|
|
79
|
+
)
|
|
80
|
+
)
|
|
81
|
+
return 0
|
|
82
|
+
if args.command == "context" and args.context_command == "decode":
|
|
83
|
+
print(json.dumps(EDAContext.decode(args.token).__dict__, indent=2))
|
|
84
|
+
return 0
|
|
85
|
+
if args.command == "connection":
|
|
86
|
+
registry = ConnectionRegistry(args.registry)
|
|
87
|
+
if args.connection_command == "list":
|
|
88
|
+
print(
|
|
89
|
+
json.dumps(
|
|
90
|
+
{
|
|
91
|
+
"status": "ready",
|
|
92
|
+
"connections": [item.to_dict() for item in registry.list()],
|
|
93
|
+
},
|
|
94
|
+
indent=2,
|
|
95
|
+
)
|
|
96
|
+
)
|
|
97
|
+
return 0
|
|
98
|
+
if args.connection_command == "remove":
|
|
99
|
+
removed = registry.remove(args.connection_id)
|
|
100
|
+
print(json.dumps({"status": "removed" if removed else "not_found"}))
|
|
101
|
+
return 0 if removed else 1
|
|
102
|
+
launch_command = list(args.launch_command)
|
|
103
|
+
if launch_command and launch_command[0] == "--":
|
|
104
|
+
launch_command.pop(0)
|
|
105
|
+
spec = ConnectionSpec(
|
|
106
|
+
connection_id=args.connection_id,
|
|
107
|
+
eda=args.eda,
|
|
108
|
+
kind=args.kind,
|
|
109
|
+
command=tuple(launch_command),
|
|
110
|
+
host=args.host,
|
|
111
|
+
ssh_options=tuple(args.ssh_option),
|
|
112
|
+
timeout_seconds=args.timeout_seconds,
|
|
113
|
+
origin_id=args.origin_id,
|
|
114
|
+
)
|
|
115
|
+
if not spec.origin_id and not args.no_origin_probe:
|
|
116
|
+
spec = discover_connection_origin(spec)
|
|
117
|
+
registry.upsert(spec)
|
|
118
|
+
print(json.dumps({"status": "ready", "connection": spec.to_dict()}, indent=2))
|
|
119
|
+
return 0
|
|
120
|
+
if args.command == "mcp" and args.mcp_command == "serve":
|
|
121
|
+
from .mcp_server import serve_mcp
|
|
122
|
+
|
|
123
|
+
serve_mcp(registry=ConnectionRegistry(args.registry))
|
|
124
|
+
return 0
|
|
125
|
+
if args.command == "hook":
|
|
126
|
+
from .agent_audit import record_codex_hook
|
|
127
|
+
|
|
128
|
+
try:
|
|
129
|
+
event = json.load(sys.stdin)
|
|
130
|
+
phase = "pre" if args.hook_command == "codex-pre-tool-use" else "post"
|
|
131
|
+
record_codex_hook(event, phase=phase, database=args.database)
|
|
132
|
+
except Exception:
|
|
133
|
+
# Audit is fail-open: telemetry must never alter or block an EDA call.
|
|
134
|
+
return 0
|
|
135
|
+
return 0
|
|
136
|
+
if args.command == "audit" and args.audit_command == "list":
|
|
137
|
+
from .agent_audit import audit_events
|
|
138
|
+
|
|
139
|
+
print(json.dumps({"events": audit_events(args.database, limit=args.limit)}, indent=2))
|
|
140
|
+
return 0
|
|
141
|
+
if args.command == "ledger":
|
|
142
|
+
with ExecutionLedger(args.database) as ledger:
|
|
143
|
+
if args.ledger_command == "verify":
|
|
144
|
+
valid = ledger.verify(args.run_id)
|
|
145
|
+
print(json.dumps({"run_id": args.run_id, "valid": valid}))
|
|
146
|
+
return 0 if valid else 1
|
|
147
|
+
ledger.export_ndjson(args.destination)
|
|
148
|
+
print(json.dumps({"status": "ok", "destination": str(args.destination)}))
|
|
149
|
+
return 0
|
|
150
|
+
return 2
|
|
151
|
+
|
|
152
|
+
|
|
153
|
+
if __name__ == "__main__":
|
|
154
|
+
sys.exit(main())
|
|
@@ -0,0 +1,192 @@
|
|
|
1
|
+
"""Deterministic local and SSH adapter connection registry."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
import json
|
|
6
|
+
import os
|
|
7
|
+
import re
|
|
8
|
+
import tempfile
|
|
9
|
+
from dataclasses import asdict, dataclass, replace
|
|
10
|
+
from pathlib import Path
|
|
11
|
+
from typing import Any
|
|
12
|
+
|
|
13
|
+
from .protocol import RequestEnvelope
|
|
14
|
+
from .transport import PersistentStdioTransport, SSHStdioTransport, Transport
|
|
15
|
+
|
|
16
|
+
_ID = re.compile(r"^[A-Za-z0-9][A-Za-z0-9_.-]{0,63}$")
|
|
17
|
+
|
|
18
|
+
|
|
19
|
+
def runtime_home() -> Path:
|
|
20
|
+
return Path(os.environ.get("EDA_RUNTIME_HOME", Path.home() / ".eda-bridge-runtime"))
|
|
21
|
+
|
|
22
|
+
|
|
23
|
+
def default_connections_path() -> Path:
|
|
24
|
+
return runtime_home() / "connections.json"
|
|
25
|
+
|
|
26
|
+
|
|
27
|
+
@dataclass(frozen=True)
|
|
28
|
+
class ConnectionSpec:
|
|
29
|
+
connection_id: str
|
|
30
|
+
eda: str
|
|
31
|
+
kind: str
|
|
32
|
+
command: tuple[str, ...]
|
|
33
|
+
host: str | None = None
|
|
34
|
+
ssh_options: tuple[str, ...] = ()
|
|
35
|
+
timeout_seconds: float = 30
|
|
36
|
+
origin_id: str | None = None
|
|
37
|
+
|
|
38
|
+
def __post_init__(self) -> None:
|
|
39
|
+
if not _ID.fullmatch(self.connection_id):
|
|
40
|
+
raise ValueError("connection_id must be 1..64 safe identifier characters")
|
|
41
|
+
if not self.eda.strip() or not self.command:
|
|
42
|
+
raise ValueError("eda and command are required")
|
|
43
|
+
if self.kind not in {"local", "ssh"}:
|
|
44
|
+
raise ValueError("connection kind must be local or ssh")
|
|
45
|
+
if self.kind == "ssh" and not self.host:
|
|
46
|
+
raise ValueError("SSH connections require host")
|
|
47
|
+
if self.kind == "local" and self.host:
|
|
48
|
+
raise ValueError("local connections must not define host")
|
|
49
|
+
if self.timeout_seconds <= 0:
|
|
50
|
+
raise ValueError("timeout_seconds must be positive")
|
|
51
|
+
if self.origin_id and not _ID.fullmatch(self.origin_id):
|
|
52
|
+
raise ValueError("origin_id must be 1..64 safe identifier characters")
|
|
53
|
+
|
|
54
|
+
def to_dict(self) -> dict[str, Any]:
|
|
55
|
+
value = asdict(self)
|
|
56
|
+
value["command"] = list(self.command)
|
|
57
|
+
value["ssh_options"] = list(self.ssh_options)
|
|
58
|
+
return value
|
|
59
|
+
|
|
60
|
+
@classmethod
|
|
61
|
+
def from_dict(cls, value: dict[str, Any]) -> ConnectionSpec:
|
|
62
|
+
data = dict(value)
|
|
63
|
+
data["command"] = tuple(data.get("command", ()))
|
|
64
|
+
data["ssh_options"] = tuple(data.get("ssh_options", ()))
|
|
65
|
+
return cls(**data)
|
|
66
|
+
|
|
67
|
+
def open(self) -> Transport:
|
|
68
|
+
if self.kind == "local":
|
|
69
|
+
return PersistentStdioTransport(self.command, timeout_seconds=self.timeout_seconds)
|
|
70
|
+
return SSHStdioTransport(
|
|
71
|
+
str(self.host),
|
|
72
|
+
self.command,
|
|
73
|
+
ssh_options=self.ssh_options,
|
|
74
|
+
timeout_seconds=self.timeout_seconds,
|
|
75
|
+
)
|
|
76
|
+
|
|
77
|
+
|
|
78
|
+
def discover_connection_origin(
|
|
79
|
+
spec: ConnectionSpec, *, transport: Transport | None = None
|
|
80
|
+
) -> ConnectionSpec:
|
|
81
|
+
"""Read one adapter identity during connection setup and bind its stable origin."""
|
|
82
|
+
|
|
83
|
+
owned = transport is None
|
|
84
|
+
selected = transport or spec.open()
|
|
85
|
+
try:
|
|
86
|
+
response = selected.request(
|
|
87
|
+
RequestEnvelope(
|
|
88
|
+
purpose="Bind one registered EDA connection to its stable origin",
|
|
89
|
+
target={"eda": spec.eda, "connection_id": spec.connection_id},
|
|
90
|
+
operation="runtime.capabilities",
|
|
91
|
+
payload={"mutating": False},
|
|
92
|
+
)
|
|
93
|
+
)
|
|
94
|
+
finally:
|
|
95
|
+
if owned:
|
|
96
|
+
selected.close()
|
|
97
|
+
data = response.result.get("data") if isinstance(response.result, dict) else None
|
|
98
|
+
capabilities = data.get("capabilities") if isinstance(data, dict) else None
|
|
99
|
+
origin_id = str((capabilities or {}).get("origin_id") or "")
|
|
100
|
+
if response.status != "passed" or not _ID.fullmatch(origin_id):
|
|
101
|
+
raise ValueError("EDA adapter did not return a valid stable origin_id")
|
|
102
|
+
return replace(spec, origin_id=origin_id)
|
|
103
|
+
|
|
104
|
+
|
|
105
|
+
class ConnectionRegistry:
|
|
106
|
+
def __init__(self, path: str | Path | None = None):
|
|
107
|
+
self.path = Path(path) if path else default_connections_path()
|
|
108
|
+
|
|
109
|
+
def list(self) -> list[ConnectionSpec]:
|
|
110
|
+
if not self.path.is_file():
|
|
111
|
+
return []
|
|
112
|
+
data = json.loads(self.path.read_text(encoding="utf-8"))
|
|
113
|
+
if data.get("schema_version") != 1 or not isinstance(data.get("connections"), list):
|
|
114
|
+
raise ValueError(f"unsupported connection registry: {self.path}")
|
|
115
|
+
return sorted(
|
|
116
|
+
(ConnectionSpec.from_dict(item) for item in data["connections"]),
|
|
117
|
+
key=lambda item: item.connection_id,
|
|
118
|
+
)
|
|
119
|
+
|
|
120
|
+
def upsert(self, spec: ConnectionSpec) -> ConnectionSpec:
|
|
121
|
+
values = {item.connection_id: item for item in self.list()}
|
|
122
|
+
values[spec.connection_id] = spec
|
|
123
|
+
self._write(list(values.values()))
|
|
124
|
+
return spec
|
|
125
|
+
|
|
126
|
+
def remove(self, connection_id: str) -> bool:
|
|
127
|
+
values = {item.connection_id: item for item in self.list()}
|
|
128
|
+
removed = values.pop(connection_id, None) is not None
|
|
129
|
+
if removed:
|
|
130
|
+
self._write(list(values.values()))
|
|
131
|
+
return removed
|
|
132
|
+
|
|
133
|
+
def resolve(
|
|
134
|
+
self,
|
|
135
|
+
*,
|
|
136
|
+
connection_id: str | None = None,
|
|
137
|
+
eda: str | None = None,
|
|
138
|
+
origin_id: str | None = None,
|
|
139
|
+
) -> ConnectionSpec:
|
|
140
|
+
values = self.list()
|
|
141
|
+
if connection_id:
|
|
142
|
+
match = next((item for item in values if item.connection_id == connection_id), None)
|
|
143
|
+
if not match:
|
|
144
|
+
raise ValueError(f"unknown EDA connection: {connection_id}")
|
|
145
|
+
if eda and match.eda != eda:
|
|
146
|
+
raise ValueError(f"connection {connection_id} does not target {eda}")
|
|
147
|
+
if origin_id and match.origin_id not in {None, origin_id}:
|
|
148
|
+
raise ValueError(f"connection {connection_id} does not target origin {origin_id}")
|
|
149
|
+
return match
|
|
150
|
+
if origin_id:
|
|
151
|
+
matches = [
|
|
152
|
+
item
|
|
153
|
+
for item in values
|
|
154
|
+
if item.origin_id == origin_id and (not eda or item.eda == eda)
|
|
155
|
+
]
|
|
156
|
+
if len(matches) == 1:
|
|
157
|
+
return matches[0]
|
|
158
|
+
legacy = [item for item in values if not eda or item.eda == eda]
|
|
159
|
+
if len(matches) == 0 and len(legacy) == 1 and legacy[0].origin_id is None:
|
|
160
|
+
return legacy[0]
|
|
161
|
+
raise ValueError(
|
|
162
|
+
f"origin {origin_id} resolves to {len(matches)} connections; "
|
|
163
|
+
"register one exact origin binding"
|
|
164
|
+
)
|
|
165
|
+
matches = [item for item in values if not eda or item.eda == eda]
|
|
166
|
+
if len(matches) != 1:
|
|
167
|
+
target = eda or "requested EDA"
|
|
168
|
+
raise ValueError(
|
|
169
|
+
f"{target} resolves to {len(matches)} connections; provide a captured context "
|
|
170
|
+
"with connection_id or select one connection"
|
|
171
|
+
)
|
|
172
|
+
return matches[0]
|
|
173
|
+
|
|
174
|
+
def _write(self, values: list[ConnectionSpec]) -> None:
|
|
175
|
+
self.path.parent.mkdir(parents=True, exist_ok=True)
|
|
176
|
+
payload = {
|
|
177
|
+
"schema_version": 1,
|
|
178
|
+
"connections": [
|
|
179
|
+
item.to_dict() for item in sorted(values, key=lambda x: x.connection_id)
|
|
180
|
+
],
|
|
181
|
+
}
|
|
182
|
+
handle, temporary = tempfile.mkstemp(
|
|
183
|
+
prefix="connections-", suffix=".json", dir=self.path.parent
|
|
184
|
+
)
|
|
185
|
+
try:
|
|
186
|
+
with os.fdopen(handle, "w", encoding="utf-8") as stream:
|
|
187
|
+
json.dump(payload, stream, indent=2, sort_keys=True)
|
|
188
|
+
stream.write("\n")
|
|
189
|
+
os.replace(temporary, self.path)
|
|
190
|
+
finally:
|
|
191
|
+
if os.path.exists(temporary):
|
|
192
|
+
os.unlink(temporary)
|