@securecoms/agent-bridge 0.7.0 → 0.8.0
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.
- package/README.md +2 -0
- package/connectors/hermes/__init__.py +60 -0
- package/connectors/hermes/__pycache__/__init__.cpython-311.pyc +0 -0
- package/connectors/hermes/__pycache__/adapter.cpython-311.pyc +0 -0
- package/connectors/hermes/__pycache__/gate.cpython-311.pyc +0 -0
- package/connectors/hermes/__pycache__/securecoms_gate.cpython-311.pyc +0 -0
- package/connectors/hermes/__pycache__/securecoms_transport.cpython-311.pyc +0 -0
- package/connectors/hermes/__pycache__/transport.cpython-311.pyc +0 -0
- package/connectors/hermes/adapter.py +470 -0
- package/connectors/hermes/plugin.yaml +25 -0
- package/connectors/hermes/pytest.ini +3 -0
- package/connectors/hermes/securecoms_gate.py +70 -0
- package/connectors/hermes/securecoms_transport.py +257 -0
- package/connectors/hermes/tests/__pycache__/conftest.cpython-311-pytest-8.4.0.pyc +0 -0
- package/connectors/hermes/tests/__pycache__/test_adapter_wiring.cpython-311-pytest-8.4.0.pyc +0 -0
- package/connectors/hermes/tests/__pycache__/test_gate.cpython-311-pytest-8.4.0.pyc +0 -0
- package/connectors/hermes/tests/__pycache__/test_securecoms_gate.cpython-311-pytest-8.4.0.pyc +0 -0
- package/connectors/hermes/tests/__pycache__/test_securecoms_transport.cpython-311-pytest-8.4.0.pyc +0 -0
- package/connectors/hermes/tests/__pycache__/test_transport.cpython-311-pytest-8.4.0.pyc +0 -0
- package/connectors/hermes/tests/conftest.py +23 -0
- package/connectors/hermes/tests/test_adapter_wiring.py +541 -0
- package/connectors/hermes/tests/test_securecoms_gate.py +113 -0
- package/connectors/hermes/tests/test_securecoms_transport.py +608 -0
- package/dist/bin.d.ts +2 -0
- package/dist/bin.js +285 -27
- package/dist/service/harness-wire.d.ts +3 -2
- package/dist/service/hermes-config.d.ts +51 -0
- package/dist/service/wire.d.ts +97 -40
- package/package.json +1 -1
|
@@ -0,0 +1,257 @@
|
|
|
1
|
+
# packages/connect-agent-bridge/connectors/hermes/securecoms_transport.py
|
|
2
|
+
#
|
|
3
|
+
# Contract transport layer for the Hermes connector - the daemon-loopback
|
|
4
|
+
# client half of the ADR-0009 connector contract. Python port (in spirit; see
|
|
5
|
+
# tests/test_securecoms_transport.py's header) of ../openclaw/openclaw-entry.js's
|
|
6
|
+
# LoopbackChannel: resolve_target/probe_and_gate replace _resolveConnection/
|
|
7
|
+
# _probeAndGate, should_drop_inbound replaces the "own sender" echo check in
|
|
8
|
+
# the WS "message" handler, and ChannelStatusManager replaces the ad-hoc
|
|
9
|
+
# reportStatus("working")/reportStatus("idle") pair scattered through
|
|
10
|
+
# _handleInbound.
|
|
11
|
+
#
|
|
12
|
+
# Audit constraints (task-2-brief.md):
|
|
13
|
+
# - ZERO `gateway.*` imports: this module is a plain daemon HTTP/WS client,
|
|
14
|
+
# never a gateway/plugin-registry abstraction. Enforced by a source-scan
|
|
15
|
+
# trip-wire in tests/test_securecoms_transport.py.
|
|
16
|
+
# - `session` is INJECTED (an aiohttp.ClientSession or a fake with the same
|
|
17
|
+
# `async def get(url)` shape) so no `aiohttp` import is needed here or in
|
|
18
|
+
# tests; `resolve_target` itself is pure file reads, no network at all.
|
|
19
|
+
# - Per-attempt re-resolution: `resolve_target`/`probe_and_gate` cache
|
|
20
|
+
# NOTHING between calls - every attempt re-reads the binding, the
|
|
21
|
+
# connection file, and the token file from scratch, so a moved port or a
|
|
22
|
+
# rotated token is picked up on the very next call (spec S4.2 step 6 ->
|
|
23
|
+
# step 1; audit C3).
|
|
24
|
+
|
|
25
|
+
from __future__ import annotations
|
|
26
|
+
|
|
27
|
+
import json
|
|
28
|
+
import os
|
|
29
|
+
from typing import Awaitable, Callable, Mapping, NamedTuple, Optional, Tuple
|
|
30
|
+
|
|
31
|
+
from securecoms_gate import evaluate_gate, resolve_bound_identity
|
|
32
|
+
|
|
33
|
+
|
|
34
|
+
class ResolvedTarget(NamedTuple):
|
|
35
|
+
bridge_url: str # http(s):// base - target for /healthz, /v1/send, /v1/status
|
|
36
|
+
ws_url: str # ws(s):// URL as written by the daemon - target for the WS connect
|
|
37
|
+
token: str # bearer token, read from token_file NOW (never cached)
|
|
38
|
+
identity_id: str # identity_id as recorded IN THE CONNECTION FILE (file identity)
|
|
39
|
+
|
|
40
|
+
|
|
41
|
+
def _ws_to_http(ws_url: str) -> str:
|
|
42
|
+
"""ws://host:port/v1/ws -> http://host:port ; wss:// -> https://.
|
|
43
|
+
|
|
44
|
+
Mirrors openclaw-entry.js's `bridge_url.replace(/^ws/, "http").replace(/\\/v1\\/ws$/, "")`.
|
|
45
|
+
"""
|
|
46
|
+
if ws_url.startswith("wss://"):
|
|
47
|
+
http_url = "https://" + ws_url[len("wss://"):]
|
|
48
|
+
elif ws_url.startswith("ws://"):
|
|
49
|
+
http_url = "http://" + ws_url[len("ws://"):]
|
|
50
|
+
else:
|
|
51
|
+
http_url = ws_url
|
|
52
|
+
if http_url.endswith("/v1/ws"):
|
|
53
|
+
http_url = http_url[: -len("/v1/ws")]
|
|
54
|
+
return http_url
|
|
55
|
+
|
|
56
|
+
|
|
57
|
+
def _resolve_target_for_identity(
|
|
58
|
+
bound_identity: Optional[str],
|
|
59
|
+
read_file_text: Callable[[str], str],
|
|
60
|
+
connections_dir: str,
|
|
61
|
+
) -> Optional[ResolvedTarget]:
|
|
62
|
+
"""connections_dir/<bound_identity>.json -> (bridge_url, token_file,
|
|
63
|
+
identity_id) -> token read NOW. Shared body for resolve_target (which
|
|
64
|
+
derives bound_identity from env/binding) and probe_and_gate_bound (which
|
|
65
|
+
takes an already-fixed bound_identity and never derives it) - see the
|
|
66
|
+
BINDING-STABILITY fold note on probe_and_gate_bound below.
|
|
67
|
+
|
|
68
|
+
Returns None on ANY absence/partial/unreadable condition along the
|
|
69
|
+
chain - no bound_identity, no connection file, a connection file that
|
|
70
|
+
parses but is missing/blank bridge_url or token_file (e.g. daemon
|
|
71
|
+
mid-write), or a token_file that can't be read yet. Never raises: the
|
|
72
|
+
caller's job is to hold and retry, not to catch exceptions from here
|
|
73
|
+
(spec S4.2).
|
|
74
|
+
"""
|
|
75
|
+
if not bound_identity:
|
|
76
|
+
return None
|
|
77
|
+
|
|
78
|
+
conn_path = os.path.join(connections_dir, f"{bound_identity}.json")
|
|
79
|
+
try:
|
|
80
|
+
conn = json.loads(read_file_text(conn_path))
|
|
81
|
+
except Exception:
|
|
82
|
+
return None
|
|
83
|
+
if not isinstance(conn, dict):
|
|
84
|
+
return None
|
|
85
|
+
|
|
86
|
+
ws_url = conn.get("bridge_url")
|
|
87
|
+
token_file = conn.get("token_file")
|
|
88
|
+
identity_id = conn.get("identity_id")
|
|
89
|
+
if not ws_url or not token_file or not identity_id:
|
|
90
|
+
return None
|
|
91
|
+
|
|
92
|
+
try:
|
|
93
|
+
token = read_file_text(token_file)
|
|
94
|
+
except Exception:
|
|
95
|
+
return None
|
|
96
|
+
token = token.strip() if isinstance(token, str) else ""
|
|
97
|
+
if not token:
|
|
98
|
+
return None
|
|
99
|
+
|
|
100
|
+
return ResolvedTarget(
|
|
101
|
+
bridge_url=_ws_to_http(ws_url),
|
|
102
|
+
ws_url=ws_url,
|
|
103
|
+
token=token,
|
|
104
|
+
identity_id=identity_id,
|
|
105
|
+
)
|
|
106
|
+
|
|
107
|
+
|
|
108
|
+
def resolve_target(
|
|
109
|
+
env: Mapping[str, str],
|
|
110
|
+
read_file_text: Callable[[str], str],
|
|
111
|
+
binding_path: str,
|
|
112
|
+
connections_dir: str,
|
|
113
|
+
) -> Optional[ResolvedTarget]:
|
|
114
|
+
"""bound_identity (env or binding file) -> connections_dir/<id>.json
|
|
115
|
+
(bridge_url, token_file, identity_id) -> token read NOW.
|
|
116
|
+
|
|
117
|
+
Re-derives bound_identity from env/binding_path on every call - fine for
|
|
118
|
+
this pure lookup helper (and for probe_and_gate's per-call convenience
|
|
119
|
+
wrapper below), but the WRONG entrypoint for a running connector's
|
|
120
|
+
per-attempt reconnect path: see probe_and_gate_bound.
|
|
121
|
+
"""
|
|
122
|
+
bound_identity = resolve_bound_identity(env, read_file_text, binding_path)
|
|
123
|
+
return _resolve_target_for_identity(bound_identity, read_file_text, connections_dir)
|
|
124
|
+
|
|
125
|
+
|
|
126
|
+
async def probe_and_gate_bound(
|
|
127
|
+
session,
|
|
128
|
+
bound_identity: Optional[str],
|
|
129
|
+
read_file_text: Callable[[str], str],
|
|
130
|
+
connections_dir: str,
|
|
131
|
+
) -> Tuple[Optional[ResolvedTarget], str]:
|
|
132
|
+
"""probe_and_gate, but for a bound_identity the CALLER already resolved
|
|
133
|
+
and is holding fixed - this function never reads env or a binding file,
|
|
134
|
+
so it structurally cannot re-bind (BINDING-STABILITY fold, task-3
|
|
135
|
+
review): the contract requires bound_identity be decided exactly ONCE
|
|
136
|
+
for a connector's process life (2026-07-19 spec:153); re-deriving it on
|
|
137
|
+
every reconnect attempt (the old probe_and_gate's per-call behavior)
|
|
138
|
+
would let a binding-file rewrite silently re-bind a running connector to
|
|
139
|
+
a different agent mid-session. Callers that own a long-lived bound
|
|
140
|
+
identity (adapter.py's SecureComsAdapter) resolve it once - e.g. at
|
|
141
|
+
connect() - and pass the SAME value into every subsequent reconnect
|
|
142
|
+
attempt via this entrypoint.
|
|
143
|
+
|
|
144
|
+
Returns (target, "") on success, or (None, reason) on any hold/refuse:
|
|
145
|
+
unwired, not-yet-resolvable, daemon unreachable, or a two-anchor gate
|
|
146
|
+
mismatch. Never opens a websocket - that stays entirely the caller's
|
|
147
|
+
job, once (and only once) this returns a non-None target.
|
|
148
|
+
"""
|
|
149
|
+
if not bound_identity:
|
|
150
|
+
return None, "unwired: no binding (refusing to guess)"
|
|
151
|
+
|
|
152
|
+
target = _resolve_target_for_identity(bound_identity, read_file_text, connections_dir)
|
|
153
|
+
if target is None:
|
|
154
|
+
return None, "not-ready: no connection file / token yet"
|
|
155
|
+
|
|
156
|
+
try:
|
|
157
|
+
resp = await session.get(f"{target.bridge_url}/healthz")
|
|
158
|
+
health = await resp.json()
|
|
159
|
+
except Exception as e:
|
|
160
|
+
return None, f"daemon unreachable at {target.bridge_url}: {e}"
|
|
161
|
+
|
|
162
|
+
healthz_identity = health.get("identity_id") if isinstance(health, dict) else None
|
|
163
|
+
ok, reason = evaluate_gate(bound_identity, target.identity_id, healthz_identity)
|
|
164
|
+
if not ok:
|
|
165
|
+
return None, reason
|
|
166
|
+
|
|
167
|
+
return target, ""
|
|
168
|
+
|
|
169
|
+
|
|
170
|
+
async def probe_and_gate(
|
|
171
|
+
session,
|
|
172
|
+
env: Mapping[str, str],
|
|
173
|
+
read_file_text: Callable[[str], str],
|
|
174
|
+
binding_path: str,
|
|
175
|
+
connections_dir: str,
|
|
176
|
+
) -> Tuple[Optional[ResolvedTarget], str]:
|
|
177
|
+
"""Convenience wrapper: resolve bound_identity from env/binding_path
|
|
178
|
+
ONCE per call, then delegate to probe_and_gate_bound. Kept for callers
|
|
179
|
+
(and existing tests) that want single-call per-attempt re-resolution
|
|
180
|
+
semantics from env/binding_path; a long-lived connector process should
|
|
181
|
+
prefer resolving bound_identity itself exactly once and calling
|
|
182
|
+
probe_and_gate_bound directly on every attempt (see its docstring).
|
|
183
|
+
"""
|
|
184
|
+
bound_identity = resolve_bound_identity(env, read_file_text, binding_path)
|
|
185
|
+
return await probe_and_gate_bound(session, bound_identity, read_file_text, connections_dir)
|
|
186
|
+
|
|
187
|
+
|
|
188
|
+
def should_drop_inbound(frame: dict, bound_identity: str) -> bool:
|
|
189
|
+
"""Echo guard: never feed our own sends back into the agent.
|
|
190
|
+
|
|
191
|
+
True only when the frame carries a truthy sender_id that equals a
|
|
192
|
+
truthy bound_identity - matches openclaw-entry.js's
|
|
193
|
+
`frame.sender_id && this._ownIdentity && frame.sender_id === this._ownIdentity`.
|
|
194
|
+
"""
|
|
195
|
+
sender_id = frame.get("sender_id")
|
|
196
|
+
return bool(sender_id) and bool(bound_identity) and sender_id == bound_identity
|
|
197
|
+
|
|
198
|
+
|
|
199
|
+
class ChannelStatusManager:
|
|
200
|
+
"""Per-channel working/idle status emitter, decoupled from any one reply
|
|
201
|
+
turn (unlike openclaw-entry.js's inline reportStatus("working")/("idle")
|
|
202
|
+
pair, which only spans a single dispatch). States are limited to
|
|
203
|
+
"idle"|"working" - this manager never emits "stalled"/"error" (those are
|
|
204
|
+
daemon-side WS status states outside this manager's job; see
|
|
205
|
+
src/ws-server.ts ~295-320).
|
|
206
|
+
|
|
207
|
+
now() is injected so tests can drive time deterministically (a FakeClock)
|
|
208
|
+
instead of racing real wall-clock sleeps.
|
|
209
|
+
"""
|
|
210
|
+
|
|
211
|
+
def __init__(
|
|
212
|
+
self,
|
|
213
|
+
send_frame: Callable[[dict], Awaitable[None]],
|
|
214
|
+
*,
|
|
215
|
+
refresh_s: float = 10.0,
|
|
216
|
+
idle_after_s: float = 20.0,
|
|
217
|
+
now: Callable[[], float],
|
|
218
|
+
) -> None:
|
|
219
|
+
self._send_frame = send_frame
|
|
220
|
+
self._refresh_s = refresh_s
|
|
221
|
+
self._idle_after_s = idle_after_s
|
|
222
|
+
self._now = now
|
|
223
|
+
# channel_id -> {"last_emit": float, "deadline": float}. Presence in
|
|
224
|
+
# this dict means "currently working"; on_reply_posted/idle-via-tick
|
|
225
|
+
# both pop the entry, so presence alone doubles as the state.
|
|
226
|
+
self._working: dict[str, dict] = {}
|
|
227
|
+
|
|
228
|
+
async def _emit(self, channel_id: str, state: str) -> None:
|
|
229
|
+
await self._send_frame({"type": "status", "channel_id": channel_id, "state": state})
|
|
230
|
+
|
|
231
|
+
async def on_typing(self, channel_id: str) -> None:
|
|
232
|
+
now = self._now()
|
|
233
|
+
st = self._working.get(channel_id)
|
|
234
|
+
if st is None:
|
|
235
|
+
# First call for this channel (or the first since it last went
|
|
236
|
+
# idle): emit "working" unconditionally. NEVER emit "idle" here -
|
|
237
|
+
# doing so would flash the UI strip (task-2-brief mutation #3).
|
|
238
|
+
await self._emit(channel_id, "working")
|
|
239
|
+
self._working[channel_id] = {"last_emit": now, "deadline": now + self._idle_after_s}
|
|
240
|
+
return
|
|
241
|
+
# Already working: always push the silence deadline out, but only
|
|
242
|
+
# re-emit the frame at most once per refresh_s (the daemon-side
|
|
243
|
+
# publish is expensive - see openclaw-entry.js's reportStatus comment).
|
|
244
|
+
st["deadline"] = now + self._idle_after_s
|
|
245
|
+
if now - st["last_emit"] >= self._refresh_s:
|
|
246
|
+
await self._emit(channel_id, "working")
|
|
247
|
+
st["last_emit"] = now
|
|
248
|
+
|
|
249
|
+
async def on_reply_posted(self, channel_id: str) -> None:
|
|
250
|
+
await self._emit(channel_id, "idle")
|
|
251
|
+
self._working.pop(channel_id, None)
|
|
252
|
+
|
|
253
|
+
async def tick(self) -> None:
|
|
254
|
+
now = self._now()
|
|
255
|
+
for channel_id in [cid for cid, st in self._working.items() if now >= st["deadline"]]:
|
|
256
|
+
await self._emit(channel_id, "idle")
|
|
257
|
+
self._working.pop(channel_id, None)
|
|
Binary file
|
|
Binary file
|
|
Binary file
|
package/connectors/hermes/tests/__pycache__/test_securecoms_transport.cpython-311-pytest-8.4.0.pyc
ADDED
|
Binary file
|
|
Binary file
|
|
@@ -0,0 +1,23 @@
|
|
|
1
|
+
# packages/connect-agent-bridge/connectors/hermes/tests/conftest.py
|
|
2
|
+
#
|
|
3
|
+
# securecoms_gate.py/securecoms_transport.py/adapter.py import each other as
|
|
4
|
+
# bare top-level modules ("from securecoms_gate import ...", "import
|
|
5
|
+
# securecoms_transport") - the shape Hermes's own
|
|
6
|
+
# plugin loader uses (each plugin dir is spec_from_file_location'd, not
|
|
7
|
+
# imported as a dotted package). Once connectors/hermes/__init__.py exists
|
|
8
|
+
# (required so Hermes can load this directory as a plugin), pytest's default
|
|
9
|
+
# "prepend" import mode would otherwise walk up the __init__.py chain and
|
|
10
|
+
# import test modules as `hermes.tests.test_x`, executing
|
|
11
|
+
# connectors/hermes/__init__.py (which imports adapter.py, which imports
|
|
12
|
+
# gateway.* - absent from this venv) as a side effect of merely collecting
|
|
13
|
+
# tests. --import-mode=importlib (pytest.ini) avoids that dotted-package
|
|
14
|
+
# walk; this conftest just puts connectors/hermes on sys.path so the bare
|
|
15
|
+
# top-level imports inside securecoms_gate.py/securecoms_transport.py/
|
|
16
|
+
# adapter.py keep resolving.
|
|
17
|
+
|
|
18
|
+
import os
|
|
19
|
+
import sys
|
|
20
|
+
|
|
21
|
+
_HERMES_DIR = os.path.dirname(os.path.dirname(os.path.abspath(__file__)))
|
|
22
|
+
if _HERMES_DIR not in sys.path:
|
|
23
|
+
sys.path.insert(0, _HERMES_DIR)
|