@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,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
+ )