@securecoms/agent-bridge 0.7.0 → 0.9.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/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/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 +467 -67
- package/dist/bridge-agent.d.ts +4 -0
- package/dist/config.d.ts +4 -0
- package/dist/http-server.d.ts +4 -4
- package/dist/index.js +160 -18
- package/dist/memory/openclaw-provider.d.ts +10 -1
- package/dist/memory/openclaw-workspace-facts.d.ts +38 -0
- package/dist/memory/provider.d.ts +2 -1
- 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,541 @@
|
|
|
1
|
+
# packages/connect-agent-bridge/connectors/hermes/tests/test_adapter_wiring.py
|
|
2
|
+
#
|
|
3
|
+
# adapter.py imports `gateway.*` (Hermes-only packages, absent from the
|
|
4
|
+
# connect-app backend venv - spec §6's CI-honest split: ALL contract logic
|
|
5
|
+
# lives in securecoms_transport.py/securecoms_gate.py, which are
|
|
6
|
+
# gateway-import-free and covered case-for-case by
|
|
7
|
+
# test_securecoms_transport.py/test_securecoms_gate.py). adapter.py itself is thin
|
|
8
|
+
# glue over that contract layer, and its correctness on the real gateway is
|
|
9
|
+
# proven by live validation (spec §6) - but the WIRING itself (which seam
|
|
10
|
+
# calls which transport/gate function, in what order, with what fallback) is
|
|
11
|
+
# still red-first testable without a real Hermes install, by installing a
|
|
12
|
+
# minimal STUB of gateway.platforms.base / gateway.config into sys.modules
|
|
13
|
+
# before importing adapter (F5). The stub covers only what adapter.py
|
|
14
|
+
# actually touches: BasePlatformAdapter's
|
|
15
|
+
# _mark_connected/_mark_disconnected/_set_fatal_error/handle_message/
|
|
16
|
+
# set_message_handler/build_source, plus MessageEvent/SendResult/Platform/
|
|
17
|
+
# MessageType.
|
|
18
|
+
|
|
19
|
+
import asyncio
|
|
20
|
+
import json
|
|
21
|
+
import os
|
|
22
|
+
import sys
|
|
23
|
+
import types
|
|
24
|
+
from dataclasses import dataclass, field
|
|
25
|
+
from datetime import datetime
|
|
26
|
+
from enum import Enum
|
|
27
|
+
from typing import Any, Optional
|
|
28
|
+
|
|
29
|
+
import aiohttp
|
|
30
|
+
import pytest
|
|
31
|
+
|
|
32
|
+
|
|
33
|
+
def _install_gateway_stub():
|
|
34
|
+
"""Install a stub gateway.platforms.base / gateway.config BEFORE
|
|
35
|
+
adapter.py's module-level `from gateway.platforms.base import ...`
|
|
36
|
+
executes. Must run before `import adapter` below."""
|
|
37
|
+
|
|
38
|
+
gateway_pkg = types.ModuleType("gateway")
|
|
39
|
+
platforms_pkg = types.ModuleType("gateway.platforms")
|
|
40
|
+
base_mod = types.ModuleType("gateway.platforms.base")
|
|
41
|
+
config_mod = types.ModuleType("gateway.config")
|
|
42
|
+
|
|
43
|
+
class Platform:
|
|
44
|
+
def __init__(self, value):
|
|
45
|
+
self.value = value
|
|
46
|
+
|
|
47
|
+
class MessageType(Enum):
|
|
48
|
+
TEXT = "text"
|
|
49
|
+
|
|
50
|
+
@dataclass
|
|
51
|
+
class MessageEvent:
|
|
52
|
+
text: str
|
|
53
|
+
message_type: Any = None
|
|
54
|
+
source: Any = None
|
|
55
|
+
message_id: Optional[str] = None
|
|
56
|
+
timestamp: Any = field(default_factory=datetime.now)
|
|
57
|
+
|
|
58
|
+
@dataclass
|
|
59
|
+
class SendResult:
|
|
60
|
+
success: bool
|
|
61
|
+
message_id: Optional[str] = None
|
|
62
|
+
error: Optional[str] = None
|
|
63
|
+
|
|
64
|
+
class BasePlatformAdapter:
|
|
65
|
+
"""Stub covering only the seams SecureComsAdapter touches."""
|
|
66
|
+
|
|
67
|
+
def __init__(self, config, platform):
|
|
68
|
+
self.config = config
|
|
69
|
+
self.platform = platform
|
|
70
|
+
self._message_handler = None
|
|
71
|
+
self.mark_connected_calls = 0
|
|
72
|
+
self.mark_disconnected_calls = 0
|
|
73
|
+
self.fatal_errors = [] # list[(code, message, retryable)]
|
|
74
|
+
|
|
75
|
+
def set_message_handler(self, handler):
|
|
76
|
+
self._message_handler = handler
|
|
77
|
+
|
|
78
|
+
def _mark_connected(self):
|
|
79
|
+
self.mark_connected_calls += 1
|
|
80
|
+
|
|
81
|
+
def _mark_disconnected(self):
|
|
82
|
+
self.mark_disconnected_calls += 1
|
|
83
|
+
|
|
84
|
+
def _set_fatal_error(self, code, message, *, retryable):
|
|
85
|
+
self.fatal_errors.append((code, message, retryable))
|
|
86
|
+
|
|
87
|
+
async def handle_message(self, event):
|
|
88
|
+
if self._message_handler:
|
|
89
|
+
await self._message_handler(event)
|
|
90
|
+
|
|
91
|
+
def build_source(
|
|
92
|
+
self,
|
|
93
|
+
chat_id,
|
|
94
|
+
chat_name=None,
|
|
95
|
+
chat_type="dm",
|
|
96
|
+
user_id=None,
|
|
97
|
+
user_name=None,
|
|
98
|
+
**kw,
|
|
99
|
+
):
|
|
100
|
+
return types.SimpleNamespace(
|
|
101
|
+
chat_id=chat_id,
|
|
102
|
+
chat_name=chat_name,
|
|
103
|
+
chat_type=chat_type,
|
|
104
|
+
user_id=user_id,
|
|
105
|
+
user_name=user_name,
|
|
106
|
+
)
|
|
107
|
+
|
|
108
|
+
base_mod.BasePlatformAdapter = BasePlatformAdapter
|
|
109
|
+
base_mod.SendResult = SendResult
|
|
110
|
+
base_mod.MessageEvent = MessageEvent
|
|
111
|
+
base_mod.MessageType = MessageType
|
|
112
|
+
config_mod.Platform = Platform
|
|
113
|
+
|
|
114
|
+
sys.modules["gateway"] = gateway_pkg
|
|
115
|
+
sys.modules["gateway.platforms"] = platforms_pkg
|
|
116
|
+
sys.modules["gateway.platforms.base"] = base_mod
|
|
117
|
+
sys.modules["gateway.config"] = config_mod
|
|
118
|
+
|
|
119
|
+
|
|
120
|
+
_install_gateway_stub()
|
|
121
|
+
|
|
122
|
+
import adapter # noqa: E402 (must import AFTER the gateway stub is installed)
|
|
123
|
+
|
|
124
|
+
|
|
125
|
+
# ---------------------------------------------------------------------------
|
|
126
|
+
# Fakes: a minimal aiohttp.ClientSession/WS surface, recording what happened.
|
|
127
|
+
# ---------------------------------------------------------------------------
|
|
128
|
+
|
|
129
|
+
|
|
130
|
+
class _FakeWSMsg:
|
|
131
|
+
def __init__(self, type_, data=None):
|
|
132
|
+
self.type = type_
|
|
133
|
+
self.data = data
|
|
134
|
+
|
|
135
|
+
|
|
136
|
+
class FakeWS:
|
|
137
|
+
def __init__(self, frames=None):
|
|
138
|
+
self._frames = list(frames or [])
|
|
139
|
+
self.closed = False
|
|
140
|
+
self.sent = []
|
|
141
|
+
|
|
142
|
+
def __aiter__(self):
|
|
143
|
+
return self
|
|
144
|
+
|
|
145
|
+
async def __anext__(self):
|
|
146
|
+
if not self._frames:
|
|
147
|
+
raise StopAsyncIteration
|
|
148
|
+
return self._frames.pop(0)
|
|
149
|
+
|
|
150
|
+
async def send_json(self, payload):
|
|
151
|
+
self.sent.append(payload)
|
|
152
|
+
|
|
153
|
+
async def close(self):
|
|
154
|
+
self.closed = True
|
|
155
|
+
|
|
156
|
+
|
|
157
|
+
class _FakeGetResponse:
|
|
158
|
+
def __init__(self, payload):
|
|
159
|
+
self._payload = payload
|
|
160
|
+
|
|
161
|
+
async def json(self):
|
|
162
|
+
return self._payload
|
|
163
|
+
|
|
164
|
+
|
|
165
|
+
class _FakePostResponse:
|
|
166
|
+
def __init__(self, status, body):
|
|
167
|
+
self.status = status
|
|
168
|
+
self._body = body
|
|
169
|
+
|
|
170
|
+
async def json(self):
|
|
171
|
+
return self._body
|
|
172
|
+
|
|
173
|
+
async def text(self):
|
|
174
|
+
return self._body if isinstance(self._body, str) else json.dumps(self._body)
|
|
175
|
+
|
|
176
|
+
|
|
177
|
+
class _FakePostCtx:
|
|
178
|
+
def __init__(self, response):
|
|
179
|
+
self._response = response
|
|
180
|
+
|
|
181
|
+
async def __aenter__(self):
|
|
182
|
+
return self._response
|
|
183
|
+
|
|
184
|
+
async def __aexit__(self, *exc):
|
|
185
|
+
return False
|
|
186
|
+
|
|
187
|
+
|
|
188
|
+
class FakeSession:
|
|
189
|
+
"""Records every call; ws_connect/post return canned fakes."""
|
|
190
|
+
|
|
191
|
+
def __init__(self, healthz_by_url=None, ws=None, post_response=None):
|
|
192
|
+
self._healthz_by_url = healthz_by_url or {}
|
|
193
|
+
self._ws = ws
|
|
194
|
+
self._post_response = post_response # (status, body) or None
|
|
195
|
+
self.closed = False
|
|
196
|
+
self.get_calls = []
|
|
197
|
+
self.ws_connect_calls = []
|
|
198
|
+
self.post_calls = []
|
|
199
|
+
|
|
200
|
+
async def get(self, url):
|
|
201
|
+
self.get_calls.append(url)
|
|
202
|
+
if url not in self._healthz_by_url:
|
|
203
|
+
raise ConnectionError(f"connection refused: {url}")
|
|
204
|
+
return _FakeGetResponse(self._healthz_by_url[url])
|
|
205
|
+
|
|
206
|
+
async def ws_connect(self, url, headers=None, heartbeat=None):
|
|
207
|
+
self.ws_connect_calls.append((url, headers, heartbeat))
|
|
208
|
+
return self._ws
|
|
209
|
+
|
|
210
|
+
def post(self, url, headers=None, json=None):
|
|
211
|
+
self.post_calls.append((url, headers, json))
|
|
212
|
+
status, body = self._post_response
|
|
213
|
+
return _FakePostCtx(_FakePostResponse(status, body))
|
|
214
|
+
|
|
215
|
+
async def close(self):
|
|
216
|
+
self.closed = True
|
|
217
|
+
|
|
218
|
+
|
|
219
|
+
BINDING_PATH = "/plugin/securecoms.binding.json"
|
|
220
|
+
CONNECTIONS_DIR = "/home/.securecoms/connections"
|
|
221
|
+
|
|
222
|
+
|
|
223
|
+
def make_fs(files: dict):
|
|
224
|
+
def read(path):
|
|
225
|
+
v = files.get(path)
|
|
226
|
+
if v is None:
|
|
227
|
+
raise FileNotFoundError(f"ENOENT: {path}")
|
|
228
|
+
return v
|
|
229
|
+
|
|
230
|
+
return read
|
|
231
|
+
|
|
232
|
+
|
|
233
|
+
def binding(identity_id="A"):
|
|
234
|
+
return json.dumps({"identity_id": identity_id})
|
|
235
|
+
|
|
236
|
+
|
|
237
|
+
def conn_file(bridge_url="ws://127.0.0.1:9001/v1/ws", token_file="/tok/A", identity_id="A"):
|
|
238
|
+
return json.dumps(
|
|
239
|
+
{
|
|
240
|
+
"identity_id": identity_id,
|
|
241
|
+
"bridge_url": bridge_url,
|
|
242
|
+
"token_file": token_file,
|
|
243
|
+
}
|
|
244
|
+
)
|
|
245
|
+
|
|
246
|
+
|
|
247
|
+
def make_adapter(monkeypatch, files=None):
|
|
248
|
+
files = dict(files or {})
|
|
249
|
+
monkeypatch.setattr(adapter, "_read_file_text", make_fs(files))
|
|
250
|
+
monkeypatch.setattr(adapter, "_BINDING_PATH", BINDING_PATH)
|
|
251
|
+
monkeypatch.setattr(adapter, "_CONNECTIONS_DIR", CONNECTIONS_DIR)
|
|
252
|
+
cfg = types.SimpleNamespace(extra={})
|
|
253
|
+
a = adapter.SecureComsAdapter(cfg)
|
|
254
|
+
return a, files
|
|
255
|
+
|
|
256
|
+
|
|
257
|
+
# ---------------------------------------------------------------------------
|
|
258
|
+
# (1) Echo guard: inbound frame with sender_id == bound_identity is dropped
|
|
259
|
+
# ---------------------------------------------------------------------------
|
|
260
|
+
|
|
261
|
+
|
|
262
|
+
class TestEchoGuardWiring:
|
|
263
|
+
@pytest.mark.asyncio
|
|
264
|
+
async def test_own_sender_dropped_no_dispatch_but_still_acked(self, monkeypatch):
|
|
265
|
+
a, _ = make_adapter(monkeypatch)
|
|
266
|
+
a._bound_identity = "A"
|
|
267
|
+
handled = []
|
|
268
|
+
|
|
269
|
+
async def handler(event):
|
|
270
|
+
handled.append(event)
|
|
271
|
+
|
|
272
|
+
a.set_message_handler(handler)
|
|
273
|
+
|
|
274
|
+
frame = {
|
|
275
|
+
"type": "message",
|
|
276
|
+
"sender_id": "A", # == bound_identity: our own echoed send
|
|
277
|
+
"channel_id": "chan-1",
|
|
278
|
+
"text": "echo",
|
|
279
|
+
"message_id": "m1",
|
|
280
|
+
}
|
|
281
|
+
ws = FakeWS(
|
|
282
|
+
[
|
|
283
|
+
_FakeWSMsg(aiohttp.WSMsgType.TEXT, json.dumps(frame)),
|
|
284
|
+
_FakeWSMsg(aiohttp.WSMsgType.CLOSED),
|
|
285
|
+
]
|
|
286
|
+
)
|
|
287
|
+
a._ws = ws
|
|
288
|
+
|
|
289
|
+
await a._pump()
|
|
290
|
+
|
|
291
|
+
assert handled == []
|
|
292
|
+
assert ws.sent == [{"type": "ack", "message_id": "m1"}]
|
|
293
|
+
|
|
294
|
+
@pytest.mark.asyncio
|
|
295
|
+
async def test_other_sender_is_dispatched(self, monkeypatch):
|
|
296
|
+
a, _ = make_adapter(monkeypatch)
|
|
297
|
+
a._bound_identity = "A"
|
|
298
|
+
handled = []
|
|
299
|
+
|
|
300
|
+
async def handler(event):
|
|
301
|
+
handled.append(event)
|
|
302
|
+
|
|
303
|
+
a.set_message_handler(handler)
|
|
304
|
+
|
|
305
|
+
frame = {
|
|
306
|
+
"type": "message",
|
|
307
|
+
"sender_id": "B",
|
|
308
|
+
"channel_id": "chan-1",
|
|
309
|
+
"text": "hi",
|
|
310
|
+
"message_id": "m2",
|
|
311
|
+
}
|
|
312
|
+
ws = FakeWS(
|
|
313
|
+
[
|
|
314
|
+
_FakeWSMsg(aiohttp.WSMsgType.TEXT, json.dumps(frame)),
|
|
315
|
+
_FakeWSMsg(aiohttp.WSMsgType.CLOSED),
|
|
316
|
+
]
|
|
317
|
+
)
|
|
318
|
+
a._ws = ws
|
|
319
|
+
|
|
320
|
+
await a._pump()
|
|
321
|
+
|
|
322
|
+
assert len(handled) == 1
|
|
323
|
+
assert handled[0].text == "hi"
|
|
324
|
+
assert ws.sent == [{"type": "ack", "message_id": "m2"}]
|
|
325
|
+
|
|
326
|
+
|
|
327
|
+
# ---------------------------------------------------------------------------
|
|
328
|
+
# (2) send() success fires the status manager's on_reply_posted
|
|
329
|
+
# ---------------------------------------------------------------------------
|
|
330
|
+
|
|
331
|
+
|
|
332
|
+
class TestSendWiring:
|
|
333
|
+
@pytest.mark.asyncio
|
|
334
|
+
async def test_send_success_fires_on_reply_posted(self, monkeypatch):
|
|
335
|
+
a, _ = make_adapter(monkeypatch)
|
|
336
|
+
calls = []
|
|
337
|
+
|
|
338
|
+
async def fake_on_reply_posted(channel_id):
|
|
339
|
+
calls.append(channel_id)
|
|
340
|
+
|
|
341
|
+
monkeypatch.setattr(a._status, "on_reply_posted", fake_on_reply_posted)
|
|
342
|
+
|
|
343
|
+
a._session = FakeSession(post_response=(200, {"message_id": "42"}))
|
|
344
|
+
a.bridge_url = "http://127.0.0.1:9001"
|
|
345
|
+
a._token = "tok"
|
|
346
|
+
|
|
347
|
+
result = await a.send("chan-1", "hello")
|
|
348
|
+
|
|
349
|
+
assert result.success is True
|
|
350
|
+
assert calls == ["chan-1"]
|
|
351
|
+
|
|
352
|
+
@pytest.mark.asyncio
|
|
353
|
+
async def test_send_failure_does_not_fire_on_reply_posted(self, monkeypatch):
|
|
354
|
+
a, _ = make_adapter(monkeypatch)
|
|
355
|
+
calls = []
|
|
356
|
+
|
|
357
|
+
async def fake_on_reply_posted(channel_id):
|
|
358
|
+
calls.append(channel_id)
|
|
359
|
+
|
|
360
|
+
monkeypatch.setattr(a._status, "on_reply_posted", fake_on_reply_posted)
|
|
361
|
+
|
|
362
|
+
a._session = FakeSession(post_response=(503, {}))
|
|
363
|
+
a.bridge_url = "http://127.0.0.1:9001"
|
|
364
|
+
a._token = "tok"
|
|
365
|
+
|
|
366
|
+
result = await a.send("chan-1", "hello")
|
|
367
|
+
|
|
368
|
+
assert result.success is False
|
|
369
|
+
assert result.error == "channel_pending"
|
|
370
|
+
assert calls == []
|
|
371
|
+
|
|
372
|
+
|
|
373
|
+
# ---------------------------------------------------------------------------
|
|
374
|
+
# (3) hold path: probe_and_gate_bound returns (None, reason) -> fatal error
|
|
375
|
+
# retryable=True AND the per-attempt session is closed
|
|
376
|
+
# ---------------------------------------------------------------------------
|
|
377
|
+
|
|
378
|
+
|
|
379
|
+
class TestHoldPathWiring:
|
|
380
|
+
@pytest.mark.asyncio
|
|
381
|
+
async def test_hold_sets_retryable_fatal_error_and_closes_session(self, monkeypatch):
|
|
382
|
+
a, _ = make_adapter(monkeypatch, files={}) # no connection file for "A" at all
|
|
383
|
+
a._bound_identity = "A"
|
|
384
|
+
|
|
385
|
+
session = FakeSession() # every GET raises; also no connection file to read anyway
|
|
386
|
+
monkeypatch.setattr(adapter, "_new_client_session", lambda: session)
|
|
387
|
+
|
|
388
|
+
ok = await a._open_ws()
|
|
389
|
+
|
|
390
|
+
assert ok is False
|
|
391
|
+
assert session.closed is True
|
|
392
|
+
assert a.fatal_errors, "expected _set_fatal_error to have been called"
|
|
393
|
+
code, message, retryable = a.fatal_errors[-1]
|
|
394
|
+
assert retryable is True
|
|
395
|
+
assert session.ws_connect_calls == []
|
|
396
|
+
|
|
397
|
+
@pytest.mark.asyncio
|
|
398
|
+
async def test_hold_logs_once_per_state_change_not_per_retry(self, monkeypatch, caplog):
|
|
399
|
+
a, _ = make_adapter(monkeypatch, files={})
|
|
400
|
+
a._bound_identity = "A"
|
|
401
|
+
|
|
402
|
+
import logging
|
|
403
|
+
|
|
404
|
+
caplog.set_level(logging.WARNING, logger="adapter")
|
|
405
|
+
|
|
406
|
+
for _ in range(3):
|
|
407
|
+
session = FakeSession()
|
|
408
|
+
monkeypatch.setattr(adapter, "_new_client_session", lambda s=session: s)
|
|
409
|
+
ok = await a._open_ws()
|
|
410
|
+
assert ok is False
|
|
411
|
+
|
|
412
|
+
hold_lines = [r for r in caplog.records if "waiting for SecureComs daemon" in r.message]
|
|
413
|
+
assert len(hold_lines) == 1, "same-reason holds must log once, not per retry"
|
|
414
|
+
|
|
415
|
+
|
|
416
|
+
# ---------------------------------------------------------------------------
|
|
417
|
+
# (4) send_typing -> status manager's on_typing
|
|
418
|
+
# ---------------------------------------------------------------------------
|
|
419
|
+
|
|
420
|
+
|
|
421
|
+
class TestSendTypingWiring:
|
|
422
|
+
@pytest.mark.asyncio
|
|
423
|
+
async def test_send_typing_calls_on_typing(self, monkeypatch):
|
|
424
|
+
a, _ = make_adapter(monkeypatch)
|
|
425
|
+
calls = []
|
|
426
|
+
|
|
427
|
+
async def fake_on_typing(channel_id):
|
|
428
|
+
calls.append(channel_id)
|
|
429
|
+
|
|
430
|
+
monkeypatch.setattr(a._status, "on_typing", fake_on_typing)
|
|
431
|
+
|
|
432
|
+
await a.send_typing("chan-9")
|
|
433
|
+
|
|
434
|
+
assert calls == ["chan-9"]
|
|
435
|
+
|
|
436
|
+
|
|
437
|
+
# ---------------------------------------------------------------------------
|
|
438
|
+
# REQUIRED: BINDING-STABILITY - a binding-file rewrite between attempts must
|
|
439
|
+
# NOT re-bind a running connector. bound_identity is resolved once (mirrored
|
|
440
|
+
# here as a direct field set, standing in for connect()'s one-shot
|
|
441
|
+
# resolution); every subsequent attempt must keep using that fixed value even
|
|
442
|
+
# though the on-disk binding (and, in this adversarial setup, the connection
|
|
443
|
+
# file + daemon at the SAME path our fixed identity reads) now agree on a
|
|
444
|
+
# DIFFERENT identity - check 2 refuses, and zero re-bind occurs.
|
|
445
|
+
# ---------------------------------------------------------------------------
|
|
446
|
+
|
|
447
|
+
|
|
448
|
+
class TestBindingStabilityAcrossReconnect:
|
|
449
|
+
@pytest.mark.asyncio
|
|
450
|
+
async def test_stays_anchored_to_original_identity_after_binding_rewrite(self, monkeypatch):
|
|
451
|
+
files = {
|
|
452
|
+
BINDING_PATH: binding("A"),
|
|
453
|
+
CONNECTIONS_DIR + "/A.json": conn_file(identity_id="A"),
|
|
454
|
+
"/tok/A": "token-A",
|
|
455
|
+
}
|
|
456
|
+
a, files = make_adapter(monkeypatch, files=files)
|
|
457
|
+
|
|
458
|
+
# Attempt 1: connect()'s one-shot resolution, mirrored directly here.
|
|
459
|
+
a._bound_identity = adapter.resolve_bound_identity(
|
|
460
|
+
{}, adapter._read_file_text, adapter._BINDING_PATH
|
|
461
|
+
)
|
|
462
|
+
assert a._bound_identity == "A"
|
|
463
|
+
|
|
464
|
+
session1 = FakeSession(
|
|
465
|
+
healthz_by_url={"http://127.0.0.1:9001/healthz": {"identity_id": "A"}},
|
|
466
|
+
ws=FakeWS(),
|
|
467
|
+
)
|
|
468
|
+
monkeypatch.setattr(adapter, "_new_client_session", lambda: session1)
|
|
469
|
+
ok1 = await a._open_ws()
|
|
470
|
+
assert ok1 is True
|
|
471
|
+
assert a._bound_identity == "A"
|
|
472
|
+
|
|
473
|
+
# An external rewrite: the binding file now names "B", AND the
|
|
474
|
+
# connection-file slot our fixed identity reads (plus the daemon
|
|
475
|
+
# reachable there) now self-consistently describe "B" too - the
|
|
476
|
+
# exact scenario check 2 exists to catch even when file+daemon
|
|
477
|
+
# agree with each other.
|
|
478
|
+
files[BINDING_PATH] = binding("B")
|
|
479
|
+
files[CONNECTIONS_DIR + "/A.json"] = conn_file(identity_id="B")
|
|
480
|
+
session2 = FakeSession(
|
|
481
|
+
healthz_by_url={"http://127.0.0.1:9001/healthz": {"identity_id": "B"}},
|
|
482
|
+
ws=FakeWS(),
|
|
483
|
+
)
|
|
484
|
+
monkeypatch.setattr(adapter, "_new_client_session", lambda: session2)
|
|
485
|
+
|
|
486
|
+
# Attempt 2: bound_identity must NEVER be re-derived from the
|
|
487
|
+
# (now-rewritten) binding file.
|
|
488
|
+
ok2 = await a._open_ws()
|
|
489
|
+
|
|
490
|
+
assert a._bound_identity == "A", "bound_identity must never be re-derived after connect()"
|
|
491
|
+
assert ok2 is False, "check 2 must refuse: our bound identity no longer matches the daemon"
|
|
492
|
+
assert session2.closed is True
|
|
493
|
+
assert session2.ws_connect_calls == [], "a refused attempt must never open a websocket"
|
|
494
|
+
code, message, retryable = a.fatal_errors[-1]
|
|
495
|
+
assert retryable is True
|
|
496
|
+
assert "check 2 failed" in message
|
|
497
|
+
|
|
498
|
+
|
|
499
|
+
# ---------------------------------------------------------------------------
|
|
500
|
+
# check_requirements(): boot-time enablement - binding file OR the
|
|
501
|
+
# SECURECOMS_CONNECTION_FILE override (task-3 review, reviewer one-liner (a)).
|
|
502
|
+
# ---------------------------------------------------------------------------
|
|
503
|
+
|
|
504
|
+
|
|
505
|
+
class TestCheckRequirementsWiring:
|
|
506
|
+
def test_true_when_binding_file_present(self, tmp_path, monkeypatch):
|
|
507
|
+
binding_path = tmp_path / "securecoms.binding.json"
|
|
508
|
+
binding_path.write_text(binding("A"))
|
|
509
|
+
monkeypatch.setattr(adapter, "_BINDING_PATH", str(binding_path))
|
|
510
|
+
monkeypatch.delenv("SECURECOMS_CONNECTION_FILE", raising=False)
|
|
511
|
+
|
|
512
|
+
assert adapter.check_requirements() is True
|
|
513
|
+
|
|
514
|
+
def test_false_when_neither_binding_file_nor_env_override_present(self, tmp_path, monkeypatch):
|
|
515
|
+
monkeypatch.setattr(adapter, "_BINDING_PATH", str(tmp_path / "absent.json"))
|
|
516
|
+
monkeypatch.delenv("SECURECOMS_CONNECTION_FILE", raising=False)
|
|
517
|
+
|
|
518
|
+
assert adapter.check_requirements() is False
|
|
519
|
+
|
|
520
|
+
def test_true_when_env_override_points_to_an_existing_file_and_binding_absent(
|
|
521
|
+
self, tmp_path, monkeypatch
|
|
522
|
+
):
|
|
523
|
+
conn_path = tmp_path / "alt-connection.json"
|
|
524
|
+
conn_path.write_text(conn_file())
|
|
525
|
+
monkeypatch.setattr(adapter, "_BINDING_PATH", str(tmp_path / "absent.json"))
|
|
526
|
+
monkeypatch.setenv("SECURECOMS_CONNECTION_FILE", str(conn_path))
|
|
527
|
+
|
|
528
|
+
assert adapter.check_requirements() is True
|
|
529
|
+
|
|
530
|
+
def test_false_when_env_override_points_to_a_missing_file_even_if_binding_present(
|
|
531
|
+
self, tmp_path, monkeypatch
|
|
532
|
+
):
|
|
533
|
+
# The override is a positive, exclusive choice, not a fallback-OR:
|
|
534
|
+
# a stale/misconfigured SECURECOMS_CONNECTION_FILE must not silently
|
|
535
|
+
# fall back to the binding file, even when the binding file exists.
|
|
536
|
+
binding_path = tmp_path / "securecoms.binding.json"
|
|
537
|
+
binding_path.write_text(binding("A"))
|
|
538
|
+
monkeypatch.setattr(adapter, "_BINDING_PATH", str(binding_path))
|
|
539
|
+
monkeypatch.setenv("SECURECOMS_CONNECTION_FILE", str(tmp_path / "does-not-exist.json"))
|
|
540
|
+
|
|
541
|
+
assert adapter.check_requirements() is False
|
|
@@ -0,0 +1,113 @@
|
|
|
1
|
+
# packages/connect-agent-bridge/connectors/hermes/tests/test_securecoms_gate.py
|
|
2
|
+
#
|
|
3
|
+
# Python port of ../../openclaw/gate.test.ts, case-for-case. Reason strings
|
|
4
|
+
# must match gate.js verbatim (audit ADR-0009: the connector contract isn't
|
|
5
|
+
# language-shaped).
|
|
6
|
+
|
|
7
|
+
import json
|
|
8
|
+
import os
|
|
9
|
+
|
|
10
|
+
from securecoms_gate import backoff_delay_ms, evaluate_gate, resolve_bound_identity
|
|
11
|
+
|
|
12
|
+
_GATE_SRC_PATH = os.path.join(os.path.dirname(os.path.dirname(__file__)), "securecoms_gate.py")
|
|
13
|
+
|
|
14
|
+
|
|
15
|
+
class TestEvaluateGate:
|
|
16
|
+
def test_passes_when_file_healthz_bound_all_agree(self):
|
|
17
|
+
ok, _reason = evaluate_gate("A", "A", "A")
|
|
18
|
+
assert ok is True
|
|
19
|
+
|
|
20
|
+
def test_check_1_fails_on_stale_port_reuse(self):
|
|
21
|
+
# file A, daemon reports B
|
|
22
|
+
ok, reason = evaluate_gate("A", "A", "B")
|
|
23
|
+
assert ok is False
|
|
24
|
+
assert reason == "check 1 failed: file identity != daemon identity (stale port reuse)"
|
|
25
|
+
|
|
26
|
+
def test_check_2_fails_on_wrong_file_compartment_crossing(self):
|
|
27
|
+
# C1-regression shape: file(A) and daemon(A) agree, but bound to B.
|
|
28
|
+
ok, reason = evaluate_gate("B", "A", "A")
|
|
29
|
+
assert ok is False
|
|
30
|
+
assert reason == "check 2 failed: bound identity != daemon identity (wrong file / compartment)"
|
|
31
|
+
|
|
32
|
+
def test_refuses_when_no_binding(self):
|
|
33
|
+
# refuses when there is no binding (None) - no directory census
|
|
34
|
+
ok, reason = evaluate_gate(None, "A", "A")
|
|
35
|
+
assert ok is False
|
|
36
|
+
assert reason == "unwired: no binding (refusing to guess)"
|
|
37
|
+
|
|
38
|
+
|
|
39
|
+
class TestResolveBoundIdentity:
|
|
40
|
+
def test_reads_identity_from_env_connection_file(self):
|
|
41
|
+
def read(path):
|
|
42
|
+
return json.dumps({"identity_id": "A"}) if path == "/conn/A.json" else ""
|
|
43
|
+
|
|
44
|
+
result = resolve_bound_identity(
|
|
45
|
+
{"SECURECOMS_CONNECTION_FILE": "/conn/A.json"},
|
|
46
|
+
read,
|
|
47
|
+
"/plugin/securecoms.binding.json",
|
|
48
|
+
)
|
|
49
|
+
assert result == "A"
|
|
50
|
+
|
|
51
|
+
def test_reads_identity_from_binding_json_when_no_env(self):
|
|
52
|
+
def read(path):
|
|
53
|
+
return json.dumps({"identity_id": "B"}) if path == "/plugin/securecoms.binding.json" else ""
|
|
54
|
+
|
|
55
|
+
result = resolve_bound_identity({}, read, "/plugin/securecoms.binding.json")
|
|
56
|
+
assert result == "B"
|
|
57
|
+
|
|
58
|
+
def test_returns_none_when_neither_present(self):
|
|
59
|
+
# connector must wait, never guess
|
|
60
|
+
def read(_path):
|
|
61
|
+
raise FileNotFoundError("ENOENT")
|
|
62
|
+
|
|
63
|
+
result = resolve_bound_identity({}, read, "/plugin/securecoms.binding.json")
|
|
64
|
+
assert result is None
|
|
65
|
+
|
|
66
|
+
|
|
67
|
+
class TestBackoffDelayMs:
|
|
68
|
+
def test_ceiling_is_non_decreasing_and_saturates_at_cap(self):
|
|
69
|
+
# F6: full jitter is NOT monotone. Never assert delay(i) <= delay(i+1)
|
|
70
|
+
# on jittered output - assert the ceiling instead via rand=lambda:1.0.
|
|
71
|
+
prev = -1
|
|
72
|
+
for attempt in range(20):
|
|
73
|
+
ceiling = backoff_delay_ms(attempt, lambda: 1.0)
|
|
74
|
+
assert ceiling >= prev
|
|
75
|
+
assert ceiling <= 30000
|
|
76
|
+
prev = ceiling
|
|
77
|
+
assert backoff_delay_ms(19, lambda: 1.0) == 30000
|
|
78
|
+
|
|
79
|
+
def test_zero_rand_returns_zero_every_attempt(self):
|
|
80
|
+
for attempt in range(20):
|
|
81
|
+
assert backoff_delay_ms(attempt, lambda: 0.0) == 0
|
|
82
|
+
|
|
83
|
+
def test_bounded_by_cap_and_never_negative(self):
|
|
84
|
+
for attempt in range(20):
|
|
85
|
+
d = backoff_delay_ms(attempt, lambda: 1.0)
|
|
86
|
+
assert d >= 0
|
|
87
|
+
assert d <= 30000
|
|
88
|
+
|
|
89
|
+
def test_attempt_0_bounded_by_base(self):
|
|
90
|
+
d = backoff_delay_ms(0, lambda: 1.0)
|
|
91
|
+
assert 0 <= d <= 250
|
|
92
|
+
|
|
93
|
+
def test_attempt_20_bounded_by_cap(self):
|
|
94
|
+
d = backoff_delay_ms(20, lambda: 1.0)
|
|
95
|
+
assert d <= 30000
|
|
96
|
+
|
|
97
|
+
|
|
98
|
+
class TestNoAutoDiscovery:
|
|
99
|
+
def test_gate_source_has_zero_directory_census_tokens(self):
|
|
100
|
+
# Reviewer-recommended trip-wire (task-2-brief.md): pins the ADR-0009
|
|
101
|
+
# no-auto-discovery property at the source level, not just via the
|
|
102
|
+
# behavioral "returns None when neither present" test above (which a
|
|
103
|
+
# future edit could satisfy while ALSO adding a directory-census
|
|
104
|
+
# fallback that only fires in some other branch). securecoms_gate.py
|
|
105
|
+
# must never contain os.listdir/os.scandir/glob.glob/iterdir -
|
|
106
|
+
# bound_identity is a positive wiring act ONLY, never inferred by
|
|
107
|
+
# scanning a connections directory for a sole entry (audit C1).
|
|
108
|
+
with open(_GATE_SRC_PATH, "r", encoding="utf-8") as f:
|
|
109
|
+
source = f.read()
|
|
110
|
+
for token in ("os.listdir", "os.scandir", "glob.glob", "iterdir"):
|
|
111
|
+
assert token not in source, (
|
|
112
|
+
f"securecoms_gate.py must not contain {token!r} (ADR-0009 no-auto-discovery)"
|
|
113
|
+
)
|