@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
package/README.md
CHANGED
|
@@ -208,6 +208,8 @@ Three ways to receive, in order of preference for a long-running agent: the WebS
|
|
|
208
208
|
|
|
209
209
|
`GET /v1/ws` upgrades to a single persistent bidirectional socket. The agent receives live messages and sends/reports status over the same connection — no polling, no separate send channel. This is the interface to use for an always-on agent.
|
|
210
210
|
|
|
211
|
+
Two reference connectors speak this protocol today: openclaw (JavaScript) and hermes (Python), both implementing the same ADR-0009 connection-file + two-anchor-gate contract.
|
|
212
|
+
|
|
211
213
|
Authenticate with the bearer token either as a query param (`ws://127.0.0.1:7077/v1/ws?token=<BRIDGE_TOKEN>`) or an `Authorization: Bearer` header on the upgrade request. The same loopback/Origin/Host rules as the HTTP API apply; an unauthorized upgrade is accepted and then closed with code `1008`.
|
|
212
214
|
|
|
213
215
|
All frames are JSON text.
|
|
@@ -0,0 +1,60 @@
|
|
|
1
|
+
"""SecureComs platform plugin for Hermes Agent.
|
|
2
|
+
|
|
3
|
+
Bridges the local SecureComs Agent Bridge daemon (loopback) to the Hermes
|
|
4
|
+
gateway, speaking the ADR-0009 connector contract (connection-file
|
|
5
|
+
resolution, the two-anchor gate, the echo guard). See adapter.py for the
|
|
6
|
+
BasePlatformAdapter implementation and the register(ctx) entry point;
|
|
7
|
+
securecoms_transport.py and securecoms_gate.py carry all contract logic and
|
|
8
|
+
are gateway-import-free.
|
|
9
|
+
|
|
10
|
+
Packaging notes (task-3 review, DEFECT 1 + DEFECT 2 fold):
|
|
11
|
+
|
|
12
|
+
Hermes's real plugin loader (hermes_cli/plugins.py's ``_load_directory_module``)
|
|
13
|
+
imports this file via ``importlib.util.spec_from_file_location(module_name,
|
|
14
|
+
init_file, submodule_search_locations=[plugin_dir])`` and sets
|
|
15
|
+
``module.__path__ = [plugin_dir]`` on the resulting module BEFORE executing
|
|
16
|
+
it - that gives a relative import here (``from .adapter import ...``) enough
|
|
17
|
+
to find sibling files via the package's ``__path__``, WITHOUT ever adding
|
|
18
|
+
``plugin_dir`` to ``sys.path``. But adapter.py/securecoms_transport.py import
|
|
19
|
+
each other as bare top-level modules (``import securecoms_transport``,
|
|
20
|
+
``from securecoms_gate import ...``) - the shape shared with their standalone
|
|
21
|
+
unit tests (tests/test_securecoms_gate.py, tests/test_securecoms_transport.py
|
|
22
|
+
import ``securecoms_gate``/``securecoms_transport`` directly, no package
|
|
23
|
+
prefix) - and a bare absolute import resolves via ``sys.path``, not a
|
|
24
|
+
package's ``__path__``. So we bootstrap ``sys.path`` here, once, before
|
|
25
|
+
anything below needs it (DEFECT 1's fix).
|
|
26
|
+
|
|
27
|
+
The `register` entry point below defers importing adapter.py (and therefore
|
|
28
|
+
adapter.py's `gateway.*` imports, which don't exist outside a real Hermes
|
|
29
|
+
install) until Hermes actually CALLS `register(ctx)` to activate the plugin -
|
|
30
|
+
not merely when this file is imported. That matters because merely
|
|
31
|
+
IMPORTING this `__init__.py` also happens as a side effect of pytest
|
|
32
|
+
collecting any test file under `tests/` (pytest's Package collector runs
|
|
33
|
+
`__init__.py` for any directory containing one, independent of which test
|
|
34
|
+
file was asked for - see tests/test_securecoms_gate.py's docstring). A
|
|
35
|
+
module-level `from .adapter import register` would make every solo `pytest
|
|
36
|
+
tests/test_x.py` invocation require `gateway.*` to be importable in the CI
|
|
37
|
+
backend venv, which spec §6 forbids for securecoms_gate.py/
|
|
38
|
+
securecoms_transport.py's tests (DEFECT 2's fix).
|
|
39
|
+
"""
|
|
40
|
+
|
|
41
|
+
import os
|
|
42
|
+
import sys
|
|
43
|
+
|
|
44
|
+
_PLUGIN_DIR = os.path.dirname(os.path.abspath(__file__))
|
|
45
|
+
if _PLUGIN_DIR not in sys.path:
|
|
46
|
+
sys.path.insert(0, _PLUGIN_DIR)
|
|
47
|
+
|
|
48
|
+
|
|
49
|
+
def register(ctx):
|
|
50
|
+
"""Plugin entry point: called by the Hermes plugin system.
|
|
51
|
+
|
|
52
|
+
Deferred import - see module docstring. Only reached when Hermes
|
|
53
|
+
actually activates this plugin, never merely by importing this file.
|
|
54
|
+
"""
|
|
55
|
+
from .adapter import register as _register
|
|
56
|
+
|
|
57
|
+
return _register(ctx)
|
|
58
|
+
|
|
59
|
+
|
|
60
|
+
__all__ = ["register"]
|
|
Binary file
|
|
Binary file
|
|
Binary file
|
|
Binary file
|
|
Binary file
|
|
@@ -0,0 +1,470 @@
|
|
|
1
|
+
"""
|
|
2
|
+
SecureComs Platform Adapter for Hermes Agent.
|
|
3
|
+
|
|
4
|
+
Bridges the local SecureComs Agent Bridge daemon to the Hermes gateway over
|
|
5
|
+
loopback. The daemon (a separate process started with
|
|
6
|
+
``connect-agent-bridge --credential <agent>.json``) holds the agent credential
|
|
7
|
+
and performs ALL MLS key agreement and AEAD encryption. This adapter speaks
|
|
8
|
+
plaintext to the daemon on 127.0.0.1, guarded by the daemon's bearer token,
|
|
9
|
+
and never touches cryptographic keys.
|
|
10
|
+
|
|
11
|
+
Flow:
|
|
12
|
+
inbound: daemon --WS {type:message}--> this adapter --> handle_message()
|
|
13
|
+
--> Hermes gateway --> LLM --> send() --> daemon --MLS--> owner
|
|
14
|
+
outbound: the agent's reply is delivered to send(), which POSTs it to the
|
|
15
|
+
daemon's /v1/send; the daemon encrypts and relays it.
|
|
16
|
+
|
|
17
|
+
This is the universal daemon-client pattern wearing a Hermes BasePlatformAdapter
|
|
18
|
+
shim. The same daemon serves any framework; only this thin shim is
|
|
19
|
+
Hermes-specific - ALL contract logic (connection-file/binding resolution, the
|
|
20
|
+
two-anchor gate, the echo guard, per-channel status) lives in
|
|
21
|
+
securecoms_transport.py and securecoms_gate.py, which are gateway-import-free
|
|
22
|
+
and unit tested (see tests/test_securecoms_transport.py,
|
|
23
|
+
tests/test_securecoms_gate.py). This module is covered by
|
|
24
|
+
glue-wiring tests (tests/test_adapter_wiring.py, a stubbed gateway.*) plus
|
|
25
|
+
live validation - it cannot run gateway.* imports in backend CI (spec §6).
|
|
26
|
+
|
|
27
|
+
Wiring is a positive act (ADR-0009 C1): a ``securecoms.binding.json`` file
|
|
28
|
+
beside this plugin (or the ``SECURECOMS_CONNECTION_FILE`` env override) names
|
|
29
|
+
the identity_id this connector is bound to. That binding is resolved EXACTLY
|
|
30
|
+
ONCE, at connect() - never re-derived on a reconnect - so a binding-file
|
|
31
|
+
rewrite while the connector is running cannot silently re-bind it to a
|
|
32
|
+
different agent (task-3 review, "BINDING-STABILITY fold"; see
|
|
33
|
+
securecoms_transport.probe_and_gate_bound). Once bound, every (re)connect
|
|
34
|
+
attempt re-reads ``~/.securecoms/connections/<identity_id>.json`` and the token file
|
|
35
|
+
it points to from scratch, probes ``GET /healthz``, and runs the two-anchor
|
|
36
|
+
gate before ever opening a websocket - so a daemon port move or a rotated
|
|
37
|
+
token is picked up on the very next attempt (securecoms_gate.py's
|
|
38
|
+
``evaluate_gate``).
|
|
39
|
+
|
|
40
|
+
Config:
|
|
41
|
+
securecoms.binding.json (beside this plugin) {"identity_id": "..."}
|
|
42
|
+
SECURECOMS_CONNECTION_FILE override: an alternate
|
|
43
|
+
binding-shaped file to
|
|
44
|
+
read identity_id from
|
|
45
|
+
SECURECOMS_ALLOW_ALL_USERS true to accept any
|
|
46
|
+
admitted channel member
|
|
47
|
+
SECURECOMS_ALLOWED_USERS comma-separated sender
|
|
48
|
+
identity_ids
|
|
49
|
+
|
|
50
|
+
Deliberately NOT read here (dead config as of this promotion - see spec §5):
|
|
51
|
+
``SECURECOMS_BRIDGE_URL``, ``SECURECOMS_BRIDGE_TOKEN``,
|
|
52
|
+
``SECURECOMS_BRIDGE_TOKEN_FILE``. The daemon's address and bearer token are
|
|
53
|
+
never accepted by value or cached across attempts - they come from the
|
|
54
|
+
connection file and token file, re-read on every attempt, exactly like every
|
|
55
|
+
other contract detail.
|
|
56
|
+
"""
|
|
57
|
+
|
|
58
|
+
import asyncio
|
|
59
|
+
import json
|
|
60
|
+
import logging
|
|
61
|
+
import os
|
|
62
|
+
import time
|
|
63
|
+
from datetime import datetime
|
|
64
|
+
from typing import Any, Dict, Optional
|
|
65
|
+
|
|
66
|
+
from gateway.platforms.base import (
|
|
67
|
+
BasePlatformAdapter,
|
|
68
|
+
SendResult,
|
|
69
|
+
MessageEvent,
|
|
70
|
+
MessageType,
|
|
71
|
+
)
|
|
72
|
+
from gateway.config import Platform
|
|
73
|
+
|
|
74
|
+
import securecoms_transport
|
|
75
|
+
from securecoms_gate import resolve_bound_identity
|
|
76
|
+
|
|
77
|
+
logger = logging.getLogger(__name__)
|
|
78
|
+
|
|
79
|
+
_PLUGIN_DIR = os.path.dirname(os.path.abspath(__file__))
|
|
80
|
+
_BINDING_PATH = os.path.join(_PLUGIN_DIR, "securecoms.binding.json")
|
|
81
|
+
_CONNECTIONS_DIR = os.path.expanduser("~/.securecoms/connections")
|
|
82
|
+
|
|
83
|
+
|
|
84
|
+
def _read_file_text(path: str) -> str:
|
|
85
|
+
with open(path, "r") as f:
|
|
86
|
+
return f.read()
|
|
87
|
+
|
|
88
|
+
|
|
89
|
+
def _new_client_session():
|
|
90
|
+
"""Seam for tests: the real implementation is a fresh aiohttp session
|
|
91
|
+
per connect attempt (see _open_ws's docstring for why per-attempt, not
|
|
92
|
+
per-adapter-life)."""
|
|
93
|
+
import aiohttp
|
|
94
|
+
|
|
95
|
+
return aiohttp.ClientSession()
|
|
96
|
+
|
|
97
|
+
|
|
98
|
+
class SecureComsAdapter(BasePlatformAdapter):
|
|
99
|
+
"""Hermes platform adapter fronting the local SecureComs daemon."""
|
|
100
|
+
|
|
101
|
+
def __init__(self, config, **kwargs):
|
|
102
|
+
super().__init__(config=config, platform=Platform("securecoms"))
|
|
103
|
+
|
|
104
|
+
# channel_id -> {"name": str, "is_dm": bool}
|
|
105
|
+
self._channels: Dict[str, Dict[str, Any]] = {}
|
|
106
|
+
|
|
107
|
+
# Resolved EXACTLY ONCE, by connect() - see the BINDING-STABILITY
|
|
108
|
+
# fold in the module docstring. None until connect() runs.
|
|
109
|
+
self._bound_identity: Optional[str] = None
|
|
110
|
+
|
|
111
|
+
self.bridge_url: Optional[str] = None
|
|
112
|
+
self.ws_url: Optional[str] = None
|
|
113
|
+
self._token: Optional[str] = None
|
|
114
|
+
|
|
115
|
+
self._session = None # aiohttp.ClientSession - the CURRENT connected link
|
|
116
|
+
self._ws = None
|
|
117
|
+
self._recv_task: Optional[asyncio.Task] = None
|
|
118
|
+
self._tick_task: Optional[asyncio.Task] = None
|
|
119
|
+
self._stopped = False # set by disconnect(); stops the reconnect supervisor
|
|
120
|
+
self._hold_reason: Optional[str] = None # None == not holding
|
|
121
|
+
|
|
122
|
+
self._status = securecoms_transport.ChannelStatusManager(
|
|
123
|
+
self._send_status_frame, now=time.monotonic
|
|
124
|
+
)
|
|
125
|
+
|
|
126
|
+
@property
|
|
127
|
+
def name(self) -> str:
|
|
128
|
+
return "SecureComs"
|
|
129
|
+
|
|
130
|
+
def _auth(self) -> Dict[str, str]:
|
|
131
|
+
return {"Authorization": f"Bearer {self._token}"}
|
|
132
|
+
|
|
133
|
+
# ── Connection lifecycle ──────────────────────────────────────────────
|
|
134
|
+
|
|
135
|
+
async def connect(self) -> bool:
|
|
136
|
+
self._stopped = False
|
|
137
|
+
|
|
138
|
+
# BINDING-STABILITY fold: resolve bound_identity here, ONCE, for the
|
|
139
|
+
# entire process life of this adapter instance. Every subsequent
|
|
140
|
+
# attempt (below, and every supervisor reconnect) passes this SAME
|
|
141
|
+
# value into securecoms_transport.probe_and_gate_bound, which takes no
|
|
142
|
+
# env/binding_path argument at all and so structurally cannot
|
|
143
|
+
# re-derive it.
|
|
144
|
+
self._bound_identity = resolve_bound_identity(
|
|
145
|
+
os.environ, _read_file_text, _BINDING_PATH
|
|
146
|
+
)
|
|
147
|
+
if not self._bound_identity:
|
|
148
|
+
# Boot-time check_requirements() already gates on the binding
|
|
149
|
+
# file's presence, so reaching connect() without a resolvable
|
|
150
|
+
# identity is a genuine config error (e.g. the file lost its
|
|
151
|
+
# identity_id field mid-race), not a transient daemon-not-up
|
|
152
|
+
# condition - non-retryable, matching the prior config_missing
|
|
153
|
+
# precedent.
|
|
154
|
+
self._set_fatal_error(
|
|
155
|
+
"unwired",
|
|
156
|
+
"no securecoms.binding.json (run `agent-bridge wire`)",
|
|
157
|
+
retryable=False,
|
|
158
|
+
)
|
|
159
|
+
return False
|
|
160
|
+
|
|
161
|
+
ok = await self._open_ws()
|
|
162
|
+
if not ok:
|
|
163
|
+
# _open_ws already reported the hold via _set_fatal_error
|
|
164
|
+
# (retryable=True); the gateway's reconnect watcher owns retry
|
|
165
|
+
# cadence from here (spec H1).
|
|
166
|
+
return False
|
|
167
|
+
|
|
168
|
+
# Supervisor owns the receive pump AND reconnection: any daemon-side
|
|
169
|
+
# drop (daemon restart, network blip) is recovered automatically
|
|
170
|
+
# with backoff, so presence and message flow self-heal without a
|
|
171
|
+
# gateway restart. A lightweight periodic tick keeps per-channel
|
|
172
|
+
# status honest even when a turn's send_typing refreshes stop
|
|
173
|
+
# arriving (handler error, silently-abandoned turn).
|
|
174
|
+
self._recv_task = asyncio.create_task(self._supervise())
|
|
175
|
+
self._tick_task = asyncio.create_task(self._tick_loop())
|
|
176
|
+
self._mark_connected()
|
|
177
|
+
logger.info("SecureComs: connected to daemon at %s", self.bridge_url)
|
|
178
|
+
return True
|
|
179
|
+
|
|
180
|
+
async def _open_ws(self) -> bool:
|
|
181
|
+
"""One connect attempt: fresh session -> probe_and_gate_bound (the
|
|
182
|
+
FIXED self._bound_identity - never re-derived) -> ws_connect.
|
|
183
|
+
|
|
184
|
+
Every failed attempt closes its OWN per-attempt session (the #37011
|
|
185
|
+
fd-leak class this design engineers against) before returning; only
|
|
186
|
+
a successful attempt installs its session as self._session. Reports
|
|
187
|
+
exactly one log line per hold-state CHANGE (spec H1), via
|
|
188
|
+
_enter_hold/_leave_hold, not per retry.
|
|
189
|
+
"""
|
|
190
|
+
session = _new_client_session()
|
|
191
|
+
target, reason = await securecoms_transport.probe_and_gate_bound(
|
|
192
|
+
session, self._bound_identity, _read_file_text, _CONNECTIONS_DIR
|
|
193
|
+
)
|
|
194
|
+
if target is None:
|
|
195
|
+
await session.close()
|
|
196
|
+
self._enter_hold(reason)
|
|
197
|
+
return False
|
|
198
|
+
|
|
199
|
+
try:
|
|
200
|
+
ws = await session.ws_connect(
|
|
201
|
+
target.ws_url,
|
|
202
|
+
headers={"Authorization": f"Bearer {target.token}"},
|
|
203
|
+
heartbeat=30.0,
|
|
204
|
+
)
|
|
205
|
+
except Exception as e:
|
|
206
|
+
await session.close()
|
|
207
|
+
self._enter_hold(f"daemon unreachable at {target.bridge_url}: {e}")
|
|
208
|
+
return False
|
|
209
|
+
|
|
210
|
+
self._session = session
|
|
211
|
+
self._ws = ws
|
|
212
|
+
self.bridge_url = target.bridge_url
|
|
213
|
+
self.ws_url = target.ws_url
|
|
214
|
+
self._token = target.token
|
|
215
|
+
self._leave_hold()
|
|
216
|
+
await self._refresh_channels()
|
|
217
|
+
return True
|
|
218
|
+
|
|
219
|
+
def _enter_hold(self, reason: str) -> None:
|
|
220
|
+
changed = self._hold_reason != reason
|
|
221
|
+
self._hold_reason = reason
|
|
222
|
+
if changed:
|
|
223
|
+
logger.warning("SecureComs: waiting for SecureComs daemon: %s", reason)
|
|
224
|
+
self._set_fatal_error(
|
|
225
|
+
"waiting_for_daemon",
|
|
226
|
+
f"waiting for SecureComs daemon: {reason}",
|
|
227
|
+
retryable=True,
|
|
228
|
+
)
|
|
229
|
+
|
|
230
|
+
def _leave_hold(self) -> None:
|
|
231
|
+
self._hold_reason = None
|
|
232
|
+
|
|
233
|
+
async def _supervise(self) -> None:
|
|
234
|
+
"""Receive pump + auto-reconnect loop. Runs until disconnect() sets
|
|
235
|
+
_stopped. On a WS drop it marks the channel disconnected (so
|
|
236
|
+
presence reflects the gap), tears down the dead link, then retries
|
|
237
|
+
_open_ws with capped exponential backoff. Mirrors the OpenClaw
|
|
238
|
+
connector."""
|
|
239
|
+
backoff = 1.0
|
|
240
|
+
while not self._stopped:
|
|
241
|
+
try:
|
|
242
|
+
await self._pump() # returns when the WS closes
|
|
243
|
+
except asyncio.CancelledError:
|
|
244
|
+
raise
|
|
245
|
+
except Exception as e:
|
|
246
|
+
logger.warning("SecureComs: receive error: %s", e)
|
|
247
|
+
if self._stopped:
|
|
248
|
+
break
|
|
249
|
+
self._mark_disconnected()
|
|
250
|
+
await self._teardown_link()
|
|
251
|
+
while not self._stopped:
|
|
252
|
+
logger.info(
|
|
253
|
+
"SecureComs: daemon link dropped; reconnecting in %.0fs", backoff
|
|
254
|
+
)
|
|
255
|
+
await asyncio.sleep(backoff)
|
|
256
|
+
if self._stopped:
|
|
257
|
+
break
|
|
258
|
+
if await self._open_ws():
|
|
259
|
+
self._mark_connected()
|
|
260
|
+
backoff = 1.0
|
|
261
|
+
logger.info("SecureComs: reconnected to daemon")
|
|
262
|
+
break
|
|
263
|
+
backoff = min(backoff * 2, 30.0)
|
|
264
|
+
|
|
265
|
+
async def _teardown_link(self) -> None:
|
|
266
|
+
try:
|
|
267
|
+
if self._ws and not self._ws.closed:
|
|
268
|
+
await self._ws.close()
|
|
269
|
+
except Exception:
|
|
270
|
+
pass
|
|
271
|
+
try:
|
|
272
|
+
if self._session and not self._session.closed:
|
|
273
|
+
await self._session.close()
|
|
274
|
+
except Exception:
|
|
275
|
+
pass
|
|
276
|
+
self._ws = None
|
|
277
|
+
self._session = None
|
|
278
|
+
|
|
279
|
+
async def disconnect(self) -> None:
|
|
280
|
+
self._stopped = True # stop the reconnect supervisor before tearing down
|
|
281
|
+
self._mark_disconnected()
|
|
282
|
+
for task_attr in ("_recv_task", "_tick_task"):
|
|
283
|
+
task = getattr(self, task_attr)
|
|
284
|
+
if task and not task.done():
|
|
285
|
+
task.cancel()
|
|
286
|
+
try:
|
|
287
|
+
await task
|
|
288
|
+
except asyncio.CancelledError:
|
|
289
|
+
pass
|
|
290
|
+
await self._teardown_link()
|
|
291
|
+
|
|
292
|
+
async def _refresh_channels(self) -> None:
|
|
293
|
+
try:
|
|
294
|
+
async with self._session.get(
|
|
295
|
+
f"{self.bridge_url}/v1/channels", headers=self._auth()
|
|
296
|
+
) as r:
|
|
297
|
+
if r.status == 200:
|
|
298
|
+
for c in await r.json():
|
|
299
|
+
self._channels[c["channel_id"]] = {
|
|
300
|
+
"name": c.get("name") or c["channel_id"],
|
|
301
|
+
"is_dm": bool(c.get("is_dm")),
|
|
302
|
+
}
|
|
303
|
+
except Exception as e:
|
|
304
|
+
logger.debug("SecureComs: channel refresh failed: %s", e)
|
|
305
|
+
|
|
306
|
+
# ── Status emit ───────────────────────────────────────────────────────
|
|
307
|
+
|
|
308
|
+
async def _send_status_frame(self, frame: dict) -> None:
|
|
309
|
+
if self._ws is None:
|
|
310
|
+
return
|
|
311
|
+
try:
|
|
312
|
+
await self._ws.send_json(frame)
|
|
313
|
+
except Exception as e:
|
|
314
|
+
logger.debug("SecureComs: status frame send failed: %s", e)
|
|
315
|
+
|
|
316
|
+
async def _tick_loop(self) -> None:
|
|
317
|
+
"""Drives ChannelStatusManager.tick() so a channel goes idle after
|
|
318
|
+
20s of silence even when no send()/send_typing() fires again for it
|
|
319
|
+
(turn ended without a reply, handler raised, etc.)."""
|
|
320
|
+
while not self._stopped:
|
|
321
|
+
try:
|
|
322
|
+
await asyncio.sleep(5.0)
|
|
323
|
+
await self._status.tick()
|
|
324
|
+
except asyncio.CancelledError:
|
|
325
|
+
raise
|
|
326
|
+
except Exception as e:
|
|
327
|
+
logger.debug("SecureComs: status tick error: %s", e)
|
|
328
|
+
|
|
329
|
+
# ── Inbound ───────────────────────────────────────────────────────────
|
|
330
|
+
|
|
331
|
+
async def _pump(self) -> None:
|
|
332
|
+
"""Receive frames until the WS closes, then RETURN so the supervisor
|
|
333
|
+
reconnects. Does NOT signal a fatal error - a dropped daemon link is
|
|
334
|
+
recoverable and the supervisor heals it."""
|
|
335
|
+
import aiohttp
|
|
336
|
+
|
|
337
|
+
async for msg in self._ws:
|
|
338
|
+
if msg.type == aiohttp.WSMsgType.TEXT:
|
|
339
|
+
try:
|
|
340
|
+
frame = json.loads(msg.data)
|
|
341
|
+
except (ValueError, TypeError):
|
|
342
|
+
continue
|
|
343
|
+
if frame.get("type") == "message":
|
|
344
|
+
await self._dispatch(frame)
|
|
345
|
+
# at-least-once: ack every accepted frame, dropped or not
|
|
346
|
+
# (echo-guard drops still need the daemon to stop
|
|
347
|
+
# redelivering them).
|
|
348
|
+
try:
|
|
349
|
+
await self._ws.send_json(
|
|
350
|
+
{"type": "ack", "message_id": frame.get("message_id")}
|
|
351
|
+
)
|
|
352
|
+
except Exception as e:
|
|
353
|
+
logger.warning("SecureComs: ack failed: %s", e)
|
|
354
|
+
elif msg.type in (aiohttp.WSMsgType.CLOSED, aiohttp.WSMsgType.ERROR):
|
|
355
|
+
return # WS closed → supervisor reconnects
|
|
356
|
+
|
|
357
|
+
async def _dispatch(self, frame: Dict[str, Any]) -> None:
|
|
358
|
+
if not self._message_handler:
|
|
359
|
+
return
|
|
360
|
+
if securecoms_transport.should_drop_inbound(frame, self._bound_identity):
|
|
361
|
+
return # echo guard (contract §4.4): never re-feed our own sends
|
|
362
|
+
channel_id = frame.get("channel_id", "")
|
|
363
|
+
sender_id = frame.get("sender_id", "")
|
|
364
|
+
text = frame.get("text", "")
|
|
365
|
+
meta = self._channels.get(channel_id, {})
|
|
366
|
+
chat_type = "dm" if meta.get("is_dm", True) else "group"
|
|
367
|
+
|
|
368
|
+
source = self.build_source(
|
|
369
|
+
chat_id=channel_id,
|
|
370
|
+
chat_name=meta.get("name", channel_id),
|
|
371
|
+
chat_type=chat_type,
|
|
372
|
+
user_id=sender_id,
|
|
373
|
+
user_name=sender_id,
|
|
374
|
+
)
|
|
375
|
+
event = MessageEvent(
|
|
376
|
+
text=text,
|
|
377
|
+
message_type=MessageType.TEXT,
|
|
378
|
+
source=source,
|
|
379
|
+
message_id=str(frame.get("message_id") or int(time.time() * 1000)),
|
|
380
|
+
timestamp=datetime.now(),
|
|
381
|
+
)
|
|
382
|
+
await self.handle_message(event)
|
|
383
|
+
|
|
384
|
+
# ── Outbound ──────────────────────────────────────────────────────────
|
|
385
|
+
|
|
386
|
+
async def send(
|
|
387
|
+
self,
|
|
388
|
+
chat_id: str,
|
|
389
|
+
content: str,
|
|
390
|
+
reply_to: Optional[str] = None,
|
|
391
|
+
metadata: Optional[Dict[str, Any]] = None,
|
|
392
|
+
) -> SendResult:
|
|
393
|
+
if not self._session or self._session.closed:
|
|
394
|
+
return SendResult(success=False, error="not connected")
|
|
395
|
+
try:
|
|
396
|
+
async with self._session.post(
|
|
397
|
+
f"{self.bridge_url}/v1/send",
|
|
398
|
+
headers=self._auth(),
|
|
399
|
+
json={"channel_id": chat_id, "text": content},
|
|
400
|
+
) as r:
|
|
401
|
+
if r.status == 200:
|
|
402
|
+
j = await r.json()
|
|
403
|
+
await self._status.on_reply_posted(chat_id)
|
|
404
|
+
return SendResult(success=True, message_id=str(j.get("message_id")))
|
|
405
|
+
if r.status == 503:
|
|
406
|
+
# daemon not yet admitted to the channel — retryable upstream
|
|
407
|
+
return SendResult(success=False, error="channel_pending")
|
|
408
|
+
body = await r.text()
|
|
409
|
+
return SendResult(success=False, error=f"send failed: {r.status} {body[:200]}")
|
|
410
|
+
except Exception as e:
|
|
411
|
+
return SendResult(success=False, error=str(e))
|
|
412
|
+
|
|
413
|
+
async def send_typing(self, chat_id: str, metadata=None) -> None:
|
|
414
|
+
await self._status.on_typing(chat_id)
|
|
415
|
+
|
|
416
|
+
async def send_image(self, chat_id: str, image_url: str, caption: str = "", **kwargs) -> SendResult:
|
|
417
|
+
# The daemon's text send is the only modality wired today; degrade to
|
|
418
|
+
# sending the URL + caption as text rather than failing the turn.
|
|
419
|
+
text = f"{caption}\n{image_url}".strip() if caption else image_url
|
|
420
|
+
return await self.send(chat_id, text)
|
|
421
|
+
|
|
422
|
+
async def get_chat_info(self, chat_id: str) -> Dict[str, Any]:
|
|
423
|
+
meta = self._channels.get(chat_id)
|
|
424
|
+
if not meta:
|
|
425
|
+
await self._refresh_channels()
|
|
426
|
+
meta = self._channels.get(chat_id, {})
|
|
427
|
+
return {
|
|
428
|
+
"name": meta.get("name", chat_id),
|
|
429
|
+
"type": "dm" if meta.get("is_dm", True) else "group",
|
|
430
|
+
"chat_id": chat_id,
|
|
431
|
+
}
|
|
432
|
+
|
|
433
|
+
|
|
434
|
+
# ---------------------------------------------------------------------------
|
|
435
|
+
# Plugin registration
|
|
436
|
+
# ---------------------------------------------------------------------------
|
|
437
|
+
|
|
438
|
+
def check_requirements() -> bool:
|
|
439
|
+
"""Wiring is a positive act (ADR-0009 C1): the connector is enabled at
|
|
440
|
+
boot only when its binding file is present - unwired hosts stay quietly
|
|
441
|
+
disabled, exactly like today's env-gated quiet. This does NOT probe the
|
|
442
|
+
daemon; connect() alone decides connected vs. holding.
|
|
443
|
+
|
|
444
|
+
Also honors the SECURECOMS_CONNECTION_FILE override (task-3 review,
|
|
445
|
+
reviewer one-liner (a)): a host wired purely via that env var - no
|
|
446
|
+
securecoms.binding.json on disk - must still show as enabled at boot,
|
|
447
|
+
matching gate.resolve_bound_identity's own env-first precedence."""
|
|
448
|
+
return os.path.isfile(os.getenv("SECURECOMS_CONNECTION_FILE") or _BINDING_PATH)
|
|
449
|
+
|
|
450
|
+
|
|
451
|
+
def register(ctx):
|
|
452
|
+
"""Plugin entry point: called by the Hermes plugin system."""
|
|
453
|
+
ctx.register_platform(
|
|
454
|
+
name="securecoms",
|
|
455
|
+
label="SecureComs",
|
|
456
|
+
adapter_factory=lambda cfg: SecureComsAdapter(cfg),
|
|
457
|
+
check_fn=check_requirements,
|
|
458
|
+
is_connected=check_requirements,
|
|
459
|
+
install_hint="Run `agent-bridge wire --harness hermes` to write securecoms.binding.json",
|
|
460
|
+
allowed_users_env="SECURECOMS_ALLOWED_USERS",
|
|
461
|
+
allow_all_env="SECURECOMS_ALLOW_ALL_USERS",
|
|
462
|
+
emoji="🔒",
|
|
463
|
+
pii_safe=True,
|
|
464
|
+
allow_update_command=False,
|
|
465
|
+
platform_hint=(
|
|
466
|
+
"You are chatting via SecureComs, an end-to-end encrypted channel. "
|
|
467
|
+
"Messages are relayed through a local encryption daemon; the server "
|
|
468
|
+
"never sees plaintext. Respond normally and conversationally."
|
|
469
|
+
),
|
|
470
|
+
)
|
|
@@ -0,0 +1,25 @@
|
|
|
1
|
+
name: securecoms-platform
|
|
2
|
+
label: SecureComs
|
|
3
|
+
kind: platform
|
|
4
|
+
version: 0.2.0
|
|
5
|
+
description: >
|
|
6
|
+
SecureComs gateway adapter for Hermes Agent. Bridges the local SecureComs
|
|
7
|
+
Agent Bridge daemon (end-to-end MLS encrypted messaging) to the Hermes
|
|
8
|
+
gateway over loopback. The daemon holds the credential and does all
|
|
9
|
+
cryptography; this adapter speaks plaintext to the daemon on 127.0.0.1 and
|
|
10
|
+
never touches keys. Inbound SecureComs messages are dispatched to the agent;
|
|
11
|
+
the agent's replies are sent back through the daemon. Wiring is a
|
|
12
|
+
securecoms.binding.json file written by `agent-bridge wire` - there is no
|
|
13
|
+
token env var to configure; the daemon address and bearer token are read
|
|
14
|
+
fresh from ~/.securecoms/connections/<identity_id>.json on every connect
|
|
15
|
+
attempt.
|
|
16
|
+
author: AgentVault
|
|
17
|
+
optional_env:
|
|
18
|
+
- name: SECURECOMS_ALLOW_ALL_USERS
|
|
19
|
+
description: "Allow any member of an admitted SecureComs channel to talk to the agent (the channel is already E2E-restricted to admitted members)"
|
|
20
|
+
prompt: "Allow all users? (true/false)"
|
|
21
|
+
password: false
|
|
22
|
+
- name: SECURECOMS_ALLOWED_USERS
|
|
23
|
+
description: "Comma-separated SecureComs sender identity_ids allowed to talk to the agent"
|
|
24
|
+
prompt: "Allowed sender identity_ids"
|
|
25
|
+
password: false
|
|
@@ -0,0 +1,70 @@
|
|
|
1
|
+
# packages/connect-agent-bridge/connectors/hermes/securecoms_gate.py
|
|
2
|
+
#
|
|
3
|
+
# Python port of ../openclaw/gate.js. Pure functions, no I/O, stdlib only.
|
|
4
|
+
# Proves the ADR-0009 connector contract isn't language-shaped.
|
|
5
|
+
|
|
6
|
+
from __future__ import annotations
|
|
7
|
+
|
|
8
|
+
import json
|
|
9
|
+
import math
|
|
10
|
+
from typing import Callable, Mapping, Optional, Tuple
|
|
11
|
+
|
|
12
|
+
|
|
13
|
+
def resolve_bound_identity(
|
|
14
|
+
env: Mapping[str, str],
|
|
15
|
+
read_file_text: Callable[[str], str],
|
|
16
|
+
binding_path: str,
|
|
17
|
+
) -> Optional[str]:
|
|
18
|
+
"""bound_identity: explicit binding ONLY.
|
|
19
|
+
|
|
20
|
+
There is no directory-census / auto-discovery path here (audit C1) -
|
|
21
|
+
bound_identity must be a positive act (an env var or a binding file),
|
|
22
|
+
never inferred by scanning a connections directory for a sole entry.
|
|
23
|
+
Any read_file_text exception (missing file, bad JSON, ...) is treated
|
|
24
|
+
as "absent" and falls through.
|
|
25
|
+
"""
|
|
26
|
+
conn_file = env.get("SECURECOMS_CONNECTION_FILE") if env else None
|
|
27
|
+
if conn_file:
|
|
28
|
+
try:
|
|
29
|
+
identity_id = json.loads(read_file_text(conn_file)).get("identity_id")
|
|
30
|
+
if identity_id:
|
|
31
|
+
return identity_id
|
|
32
|
+
except Exception:
|
|
33
|
+
pass # fall through
|
|
34
|
+
try:
|
|
35
|
+
identity_id = json.loads(read_file_text(binding_path)).get("identity_id")
|
|
36
|
+
if identity_id:
|
|
37
|
+
return identity_id
|
|
38
|
+
except Exception:
|
|
39
|
+
pass # no binding
|
|
40
|
+
return None
|
|
41
|
+
|
|
42
|
+
|
|
43
|
+
def evaluate_gate(
|
|
44
|
+
bound_identity: Optional[str],
|
|
45
|
+
file_identity: Optional[str],
|
|
46
|
+
healthz_identity: Optional[str],
|
|
47
|
+
) -> Tuple[bool, str]:
|
|
48
|
+
"""The two-anchor gate: refuse unless the connector's own binding, the
|
|
49
|
+
on-disk connection file, and the daemon's live healthz identity all
|
|
50
|
+
agree. Order matters (see the check-order mutation in the task brief) -
|
|
51
|
+
check 1 must run before check 2.
|
|
52
|
+
"""
|
|
53
|
+
if not bound_identity:
|
|
54
|
+
return False, "unwired: no binding (refusing to guess)"
|
|
55
|
+
if file_identity != healthz_identity:
|
|
56
|
+
return False, "check 1 failed: file identity != daemon identity (stale port reuse)"
|
|
57
|
+
if bound_identity != healthz_identity:
|
|
58
|
+
return False, "check 2 failed: bound identity != daemon identity (wrong file / compartment)"
|
|
59
|
+
return True, ""
|
|
60
|
+
|
|
61
|
+
|
|
62
|
+
def backoff_delay_ms(attempt: int, rand: Callable[[], float]) -> int:
|
|
63
|
+
"""Full-jitter exponential backoff, capped at 30s. `rand` is injected
|
|
64
|
+
(rather than reaching for the stdlib `random` module) so tests can pin
|
|
65
|
+
the jitter and assert the ceiling deterministically (F6).
|
|
66
|
+
"""
|
|
67
|
+
base = 250
|
|
68
|
+
cap = 30000
|
|
69
|
+
exp = min(cap, base * (2 ** max(0, attempt)))
|
|
70
|
+
return math.floor(rand() * exp)
|