@securecoms/agent-bridge 0.6.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.
Files changed (30) hide show
  1. package/README.md +2 -0
  2. package/connectors/hermes/__init__.py +60 -0
  3. package/connectors/hermes/__pycache__/__init__.cpython-311.pyc +0 -0
  4. package/connectors/hermes/__pycache__/adapter.cpython-311.pyc +0 -0
  5. package/connectors/hermes/__pycache__/gate.cpython-311.pyc +0 -0
  6. package/connectors/hermes/__pycache__/securecoms_gate.cpython-311.pyc +0 -0
  7. package/connectors/hermes/__pycache__/securecoms_transport.cpython-311.pyc +0 -0
  8. package/connectors/hermes/__pycache__/transport.cpython-311.pyc +0 -0
  9. package/connectors/hermes/adapter.py +470 -0
  10. package/connectors/hermes/plugin.yaml +25 -0
  11. package/connectors/hermes/pytest.ini +3 -0
  12. package/connectors/hermes/securecoms_gate.py +70 -0
  13. package/connectors/hermes/securecoms_transport.py +257 -0
  14. package/connectors/hermes/tests/__pycache__/conftest.cpython-311-pytest-8.4.0.pyc +0 -0
  15. package/connectors/hermes/tests/__pycache__/test_adapter_wiring.cpython-311-pytest-8.4.0.pyc +0 -0
  16. package/connectors/hermes/tests/__pycache__/test_gate.cpython-311-pytest-8.4.0.pyc +0 -0
  17. package/connectors/hermes/tests/__pycache__/test_securecoms_gate.cpython-311-pytest-8.4.0.pyc +0 -0
  18. package/connectors/hermes/tests/__pycache__/test_securecoms_transport.cpython-311-pytest-8.4.0.pyc +0 -0
  19. package/connectors/hermes/tests/__pycache__/test_transport.cpython-311-pytest-8.4.0.pyc +0 -0
  20. package/connectors/hermes/tests/conftest.py +23 -0
  21. package/connectors/hermes/tests/test_adapter_wiring.py +541 -0
  22. package/connectors/hermes/tests/test_securecoms_gate.py +113 -0
  23. package/connectors/hermes/tests/test_securecoms_transport.py +608 -0
  24. package/dist/bin.d.ts +2 -0
  25. package/dist/bin.js +436 -108
  26. package/dist/service/harness-wire.d.ts +22 -0
  27. package/dist/service/hermes-config.d.ts +51 -0
  28. package/dist/service/install-prep.d.ts +5 -8
  29. package/dist/service/wire.d.ts +97 -40
  30. 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)
@@ -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)