@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.
- 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 +436 -108
- package/dist/service/harness-wire.d.ts +22 -0
- package/dist/service/hermes-config.d.ts +51 -0
- package/dist/service/install-prep.d.ts +5 -8
- package/dist/service/wire.d.ts +97 -40
- package/package.json +1 -1
|
@@ -0,0 +1,608 @@
|
|
|
1
|
+
# packages/connect-agent-bridge/connectors/hermes/tests/test_securecoms_transport.py
|
|
2
|
+
#
|
|
3
|
+
# TDD for securecoms_transport.py: contract resolution
|
|
4
|
+
# (resolve_target/probe_and_gate), the echo guard, and the per-channel
|
|
5
|
+
# ChannelStatusManager. Ported in spirit from ../../openclaw/openclaw-entry.js's
|
|
6
|
+
# LoopbackChannel (the reference connector), not case-for-case like
|
|
7
|
+
# test_securecoms_gate.py, since openclaw has no separate transport.js to port
|
|
8
|
+
# from - the JS transport logic lives inline in openclaw-entry.js's
|
|
9
|
+
# _resolveConnection/_probeAndGate/onMessage handler.
|
|
10
|
+
|
|
11
|
+
import json
|
|
12
|
+
import os
|
|
13
|
+
|
|
14
|
+
import pytest
|
|
15
|
+
|
|
16
|
+
from securecoms_transport import (
|
|
17
|
+
ChannelStatusManager,
|
|
18
|
+
ResolvedTarget,
|
|
19
|
+
probe_and_gate,
|
|
20
|
+
probe_and_gate_bound,
|
|
21
|
+
resolve_target,
|
|
22
|
+
should_drop_inbound,
|
|
23
|
+
)
|
|
24
|
+
|
|
25
|
+
_TRANSPORT_SRC_PATH = os.path.join(
|
|
26
|
+
os.path.dirname(os.path.dirname(__file__)), "securecoms_transport.py"
|
|
27
|
+
)
|
|
28
|
+
|
|
29
|
+
|
|
30
|
+
class TestNoGatewayImports:
|
|
31
|
+
def test_transport_source_has_zero_gateway_import_tokens(self):
|
|
32
|
+
# F7: a sys.modules census is process-global and flaky (another test
|
|
33
|
+
# module importing something gateway-shaped would poison it). The
|
|
34
|
+
# real proof is source-level: securecoms_transport.py must never
|
|
35
|
+
# import a gateway/plugin-registry abstraction - it is a plain daemon
|
|
36
|
+
# HTTP/WS client (see the module docstring). Read the ACTUAL file on
|
|
37
|
+
# disk, not the imported module's __file__ compiled bytecode.
|
|
38
|
+
with open(_TRANSPORT_SRC_PATH, "r", encoding="utf-8") as f:
|
|
39
|
+
source = f.read()
|
|
40
|
+
assert "import gateway" not in source
|
|
41
|
+
assert "from gateway" not in source
|
|
42
|
+
|
|
43
|
+
BINDING_PATH = "/plugin/securecoms.binding.json"
|
|
44
|
+
CONNECTIONS_DIR = "/home/.securecoms/connections"
|
|
45
|
+
|
|
46
|
+
|
|
47
|
+
def make_fs(files: dict) -> callable:
|
|
48
|
+
"""files: path -> str content (or an Exception instance to raise)."""
|
|
49
|
+
|
|
50
|
+
def read(path):
|
|
51
|
+
v = files.get(path)
|
|
52
|
+
if v is None:
|
|
53
|
+
raise FileNotFoundError(f"ENOENT: {path}")
|
|
54
|
+
if isinstance(v, Exception):
|
|
55
|
+
raise v
|
|
56
|
+
return v
|
|
57
|
+
|
|
58
|
+
return read
|
|
59
|
+
|
|
60
|
+
|
|
61
|
+
def binding(identity_id="A"):
|
|
62
|
+
return json.dumps({"identity_id": identity_id})
|
|
63
|
+
|
|
64
|
+
|
|
65
|
+
def conn_file(bridge_url="ws://127.0.0.1:9001/v1/ws", token_file="/tok/A", identity_id="A"):
|
|
66
|
+
return json.dumps(
|
|
67
|
+
{
|
|
68
|
+
"schema": "securecoms.connection/1",
|
|
69
|
+
"identity_id": identity_id,
|
|
70
|
+
"bridge_url": bridge_url,
|
|
71
|
+
"token_file": token_file,
|
|
72
|
+
"instance_id": "inst-1",
|
|
73
|
+
"slug": identity_id,
|
|
74
|
+
"label": identity_id,
|
|
75
|
+
"written_at": "2026-07-22T00:00:00Z",
|
|
76
|
+
}
|
|
77
|
+
)
|
|
78
|
+
|
|
79
|
+
|
|
80
|
+
class FakeResponse:
|
|
81
|
+
def __init__(self, payload):
|
|
82
|
+
self._payload = payload
|
|
83
|
+
|
|
84
|
+
async def json(self):
|
|
85
|
+
return self._payload
|
|
86
|
+
|
|
87
|
+
|
|
88
|
+
class FakeSession:
|
|
89
|
+
"""Records every call so tests can assert exactly what securecoms_transport.py
|
|
90
|
+
did (and, crucially, did NOT do - e.g. no ws_connect on gate refusal)."""
|
|
91
|
+
|
|
92
|
+
def __init__(self, healthz_by_url=None):
|
|
93
|
+
self.get_calls = []
|
|
94
|
+
self.ws_calls = []
|
|
95
|
+
self._healthz_by_url = healthz_by_url or {}
|
|
96
|
+
|
|
97
|
+
async def get(self, url):
|
|
98
|
+
self.get_calls.append(url)
|
|
99
|
+
if url not in self._healthz_by_url:
|
|
100
|
+
raise ConnectionError(f"connection refused: {url}")
|
|
101
|
+
return FakeResponse(self._healthz_by_url[url])
|
|
102
|
+
|
|
103
|
+
async def ws_connect(self, *a, **kw): # pragma: no cover - must never be hit
|
|
104
|
+
self.ws_calls.append((a, kw))
|
|
105
|
+
raise AssertionError("probe_and_gate must never open a websocket")
|
|
106
|
+
|
|
107
|
+
|
|
108
|
+
# ---------------------------------------------------------------------------
|
|
109
|
+
# resolve_target: holds (returns None) on any absence/partial/unreadable input
|
|
110
|
+
# ---------------------------------------------------------------------------
|
|
111
|
+
|
|
112
|
+
|
|
113
|
+
class TestResolveTargetHolds:
|
|
114
|
+
def test_none_when_binding_missing(self):
|
|
115
|
+
read = make_fs({CONNECTIONS_DIR + "/A.json": conn_file()})
|
|
116
|
+
result = resolve_target({}, read, BINDING_PATH, CONNECTIONS_DIR)
|
|
117
|
+
assert result is None
|
|
118
|
+
|
|
119
|
+
def test_none_when_connection_file_missing(self):
|
|
120
|
+
read = make_fs({BINDING_PATH: binding("A")})
|
|
121
|
+
result = resolve_target({}, read, BINDING_PATH, CONNECTIONS_DIR)
|
|
122
|
+
assert result is None
|
|
123
|
+
|
|
124
|
+
def test_none_when_bridge_url_blank(self):
|
|
125
|
+
read = make_fs(
|
|
126
|
+
{
|
|
127
|
+
BINDING_PATH: binding("A"),
|
|
128
|
+
CONNECTIONS_DIR + "/A.json": conn_file(bridge_url=""),
|
|
129
|
+
}
|
|
130
|
+
)
|
|
131
|
+
result = resolve_target({}, read, BINDING_PATH, CONNECTIONS_DIR)
|
|
132
|
+
assert result is None
|
|
133
|
+
|
|
134
|
+
def test_none_when_token_file_field_blank(self):
|
|
135
|
+
read = make_fs(
|
|
136
|
+
{
|
|
137
|
+
BINDING_PATH: binding("A"),
|
|
138
|
+
CONNECTIONS_DIR + "/A.json": conn_file(token_file=""),
|
|
139
|
+
}
|
|
140
|
+
)
|
|
141
|
+
result = resolve_target({}, read, BINDING_PATH, CONNECTIONS_DIR)
|
|
142
|
+
assert result is None
|
|
143
|
+
|
|
144
|
+
def test_none_when_token_unreadable(self):
|
|
145
|
+
read = make_fs(
|
|
146
|
+
{
|
|
147
|
+
BINDING_PATH: binding("A"),
|
|
148
|
+
CONNECTIONS_DIR + "/A.json": conn_file(token_file="/tok/A"),
|
|
149
|
+
# /tok/A intentionally absent -> read raises
|
|
150
|
+
}
|
|
151
|
+
)
|
|
152
|
+
result = resolve_target({}, read, BINDING_PATH, CONNECTIONS_DIR)
|
|
153
|
+
assert result is None
|
|
154
|
+
|
|
155
|
+
def test_none_when_connection_file_unparseable(self):
|
|
156
|
+
read = make_fs(
|
|
157
|
+
{
|
|
158
|
+
BINDING_PATH: binding("A"),
|
|
159
|
+
CONNECTIONS_DIR + "/A.json": "{not json",
|
|
160
|
+
}
|
|
161
|
+
)
|
|
162
|
+
result = resolve_target({}, read, BINDING_PATH, CONNECTIONS_DIR)
|
|
163
|
+
assert result is None
|
|
164
|
+
|
|
165
|
+
def test_resolve_target_never_raises_on_hostile_input(self):
|
|
166
|
+
read = make_fs({BINDING_PATH: binding("A"), CONNECTIONS_DIR + "/A.json": conn_file(token_file="")})
|
|
167
|
+
# Must return None, not raise, even for a connection file present but blank.
|
|
168
|
+
assert resolve_target({}, read, BINDING_PATH, CONNECTIONS_DIR) is None
|
|
169
|
+
|
|
170
|
+
|
|
171
|
+
class TestResolveTargetSuccess:
|
|
172
|
+
def test_resolves_bridge_url_ws_url_token_identity(self):
|
|
173
|
+
read = make_fs(
|
|
174
|
+
{
|
|
175
|
+
BINDING_PATH: binding("A"),
|
|
176
|
+
CONNECTIONS_DIR + "/A.json": conn_file(
|
|
177
|
+
bridge_url="ws://127.0.0.1:9001/v1/ws", token_file="/tok/A", identity_id="A"
|
|
178
|
+
),
|
|
179
|
+
"/tok/A": "secret-token-1\n",
|
|
180
|
+
}
|
|
181
|
+
)
|
|
182
|
+
result = resolve_target({}, read, BINDING_PATH, CONNECTIONS_DIR)
|
|
183
|
+
assert result == ResolvedTarget(
|
|
184
|
+
bridge_url="http://127.0.0.1:9001",
|
|
185
|
+
ws_url="ws://127.0.0.1:9001/v1/ws",
|
|
186
|
+
token="secret-token-1",
|
|
187
|
+
identity_id="A",
|
|
188
|
+
)
|
|
189
|
+
|
|
190
|
+
def test_env_identity_overrides_binding_identity(self):
|
|
191
|
+
# SECURECOMS_CONNECTION_FILE only supplies the BOUND IDENTITY (same
|
|
192
|
+
# role as binding_path - see gate.resolve_bound_identity); the actual
|
|
193
|
+
# bridge_url/token_file always come from connections_dir/<identity>.json,
|
|
194
|
+
# keyed by whichever identity wins (env > binding).
|
|
195
|
+
read = make_fs(
|
|
196
|
+
{
|
|
197
|
+
"/conn/env.json": binding("C"),
|
|
198
|
+
BINDING_PATH: binding("Z"), # ignored: env wins
|
|
199
|
+
CONNECTIONS_DIR + "/C.json": conn_file(
|
|
200
|
+
bridge_url="ws://127.0.0.1:9002/v1/ws", token_file="/tok/C", identity_id="C"
|
|
201
|
+
),
|
|
202
|
+
"/tok/C": "secret-token-2",
|
|
203
|
+
}
|
|
204
|
+
)
|
|
205
|
+
result = resolve_target(
|
|
206
|
+
{"SECURECOMS_CONNECTION_FILE": "/conn/env.json"}, read, BINDING_PATH, CONNECTIONS_DIR
|
|
207
|
+
)
|
|
208
|
+
assert result.identity_id == "C"
|
|
209
|
+
assert result.bridge_url == "http://127.0.0.1:9002"
|
|
210
|
+
assert result.token == "secret-token-2"
|
|
211
|
+
|
|
212
|
+
|
|
213
|
+
# ---------------------------------------------------------------------------
|
|
214
|
+
# per-attempt re-resolution: nothing cached between probe_and_gate calls
|
|
215
|
+
# ---------------------------------------------------------------------------
|
|
216
|
+
|
|
217
|
+
|
|
218
|
+
class TestPerAttemptReResolution:
|
|
219
|
+
@pytest.mark.asyncio
|
|
220
|
+
async def test_port_move_and_token_rotation_pick_up_new_values(self):
|
|
221
|
+
files = {
|
|
222
|
+
BINDING_PATH: binding("A"),
|
|
223
|
+
CONNECTIONS_DIR + "/A.json": conn_file(
|
|
224
|
+
bridge_url="ws://127.0.0.1:9001/v1/ws", token_file="/tok/A", identity_id="A"
|
|
225
|
+
),
|
|
226
|
+
"/tok/A": "token-v1",
|
|
227
|
+
}
|
|
228
|
+
read = make_fs(files)
|
|
229
|
+
session = FakeSession(
|
|
230
|
+
healthz_by_url={"http://127.0.0.1:9001/healthz": {"identity_id": "A"}}
|
|
231
|
+
)
|
|
232
|
+
|
|
233
|
+
target1, reason1 = await probe_and_gate(session, {}, read, BINDING_PATH, CONNECTIONS_DIR)
|
|
234
|
+
assert reason1 == ""
|
|
235
|
+
assert target1.bridge_url == "http://127.0.0.1:9001"
|
|
236
|
+
assert target1.token == "token-v1"
|
|
237
|
+
|
|
238
|
+
# Daemon restarted on a new port with a rotated token - rewrite the
|
|
239
|
+
# connection file + token file content the fake fs serves.
|
|
240
|
+
files[CONNECTIONS_DIR + "/A.json"] = conn_file(
|
|
241
|
+
bridge_url="ws://127.0.0.1:9077/v1/ws", token_file="/tok/A", identity_id="A"
|
|
242
|
+
)
|
|
243
|
+
files["/tok/A"] = "token-v2"
|
|
244
|
+
session._healthz_by_url["http://127.0.0.1:9077/healthz"] = {"identity_id": "A"}
|
|
245
|
+
|
|
246
|
+
target2, reason2 = await probe_and_gate(session, {}, read, BINDING_PATH, CONNECTIONS_DIR)
|
|
247
|
+
assert reason2 == ""
|
|
248
|
+
assert target2.bridge_url == "http://127.0.0.1:9077"
|
|
249
|
+
assert target2.token == "token-v2"
|
|
250
|
+
# Proves nothing was cached: the second call actually hit the new URL.
|
|
251
|
+
assert session.get_calls == [
|
|
252
|
+
"http://127.0.0.1:9001/healthz",
|
|
253
|
+
"http://127.0.0.1:9077/healthz",
|
|
254
|
+
]
|
|
255
|
+
|
|
256
|
+
|
|
257
|
+
# ---------------------------------------------------------------------------
|
|
258
|
+
# probe_and_gate: hold / refuse paths never touch a websocket
|
|
259
|
+
# ---------------------------------------------------------------------------
|
|
260
|
+
|
|
261
|
+
|
|
262
|
+
class TestProbeAndGate:
|
|
263
|
+
@pytest.mark.asyncio
|
|
264
|
+
async def test_holds_none_reason_when_not_resolvable(self):
|
|
265
|
+
read = make_fs({}) # nothing present at all
|
|
266
|
+
session = FakeSession()
|
|
267
|
+
target, reason = await probe_and_gate(session, {}, read, BINDING_PATH, CONNECTIONS_DIR)
|
|
268
|
+
assert target is None
|
|
269
|
+
assert reason != ""
|
|
270
|
+
assert session.get_calls == []
|
|
271
|
+
assert session.ws_calls == []
|
|
272
|
+
|
|
273
|
+
@pytest.mark.asyncio
|
|
274
|
+
async def test_holds_when_daemon_unreachable(self):
|
|
275
|
+
read = make_fs(
|
|
276
|
+
{
|
|
277
|
+
BINDING_PATH: binding("A"),
|
|
278
|
+
CONNECTIONS_DIR + "/A.json": conn_file(),
|
|
279
|
+
"/tok/A": "tok",
|
|
280
|
+
}
|
|
281
|
+
)
|
|
282
|
+
session = FakeSession(healthz_by_url={}) # every GET raises ConnectionError
|
|
283
|
+
target, reason = await probe_and_gate(session, {}, read, BINDING_PATH, CONNECTIONS_DIR)
|
|
284
|
+
assert target is None
|
|
285
|
+
assert reason != ""
|
|
286
|
+
assert session.ws_calls == []
|
|
287
|
+
|
|
288
|
+
@pytest.mark.asyncio
|
|
289
|
+
async def test_refuses_on_gate_mismatch_and_never_opens_ws(self):
|
|
290
|
+
read = make_fs(
|
|
291
|
+
{
|
|
292
|
+
BINDING_PATH: binding("A"),
|
|
293
|
+
CONNECTIONS_DIR + "/A.json": conn_file(identity_id="A"),
|
|
294
|
+
"/tok/A": "tok",
|
|
295
|
+
}
|
|
296
|
+
)
|
|
297
|
+
# Daemon reports a DIFFERENT identity than the file -> check 1 fails.
|
|
298
|
+
session = FakeSession(
|
|
299
|
+
healthz_by_url={"http://127.0.0.1:9001/healthz": {"identity_id": "B"}}
|
|
300
|
+
)
|
|
301
|
+
target, reason = await probe_and_gate(session, {}, read, BINDING_PATH, CONNECTIONS_DIR)
|
|
302
|
+
assert target is None
|
|
303
|
+
assert reason == "check 1 failed: file identity != daemon identity (stale port reuse)"
|
|
304
|
+
assert session.ws_calls == []
|
|
305
|
+
|
|
306
|
+
@pytest.mark.asyncio
|
|
307
|
+
async def test_passes_and_returns_target_on_full_agreement(self):
|
|
308
|
+
read = make_fs(
|
|
309
|
+
{
|
|
310
|
+
BINDING_PATH: binding("A"),
|
|
311
|
+
CONNECTIONS_DIR + "/A.json": conn_file(identity_id="A"),
|
|
312
|
+
"/tok/A": "tok",
|
|
313
|
+
}
|
|
314
|
+
)
|
|
315
|
+
session = FakeSession(
|
|
316
|
+
healthz_by_url={"http://127.0.0.1:9001/healthz": {"identity_id": "A"}}
|
|
317
|
+
)
|
|
318
|
+
target, reason = await probe_and_gate(session, {}, read, BINDING_PATH, CONNECTIONS_DIR)
|
|
319
|
+
assert reason == ""
|
|
320
|
+
assert target is not None
|
|
321
|
+
assert target.identity_id == "A"
|
|
322
|
+
assert session.ws_calls == []
|
|
323
|
+
|
|
324
|
+
|
|
325
|
+
# ---------------------------------------------------------------------------
|
|
326
|
+
# probe_and_gate_bound: FIXED bound_identity, never re-derived from
|
|
327
|
+
# env/binding_path (BINDING-STABILITY fold, task-3 review)
|
|
328
|
+
# ---------------------------------------------------------------------------
|
|
329
|
+
|
|
330
|
+
|
|
331
|
+
class TestProbeAndGateBound:
|
|
332
|
+
@pytest.mark.asyncio
|
|
333
|
+
async def test_holds_with_reason_when_bound_identity_blank(self):
|
|
334
|
+
# No env/binding_path args exist on this entrypoint at all - a blank
|
|
335
|
+
# bound_identity must hold, not raise or fall back to a census.
|
|
336
|
+
session = FakeSession()
|
|
337
|
+
target, reason = await probe_and_gate_bound(session, None, make_fs({}), CONNECTIONS_DIR)
|
|
338
|
+
assert target is None
|
|
339
|
+
assert reason == "unwired: no binding (refusing to guess)"
|
|
340
|
+
assert session.get_calls == []
|
|
341
|
+
assert session.ws_calls == []
|
|
342
|
+
|
|
343
|
+
@pytest.mark.asyncio
|
|
344
|
+
async def test_holds_when_connection_file_absent_for_bound_identity(self):
|
|
345
|
+
read = make_fs({}) # connections_dir/A.json intentionally absent
|
|
346
|
+
session = FakeSession()
|
|
347
|
+
target, reason = await probe_and_gate_bound(session, "A", read, CONNECTIONS_DIR)
|
|
348
|
+
assert target is None
|
|
349
|
+
assert reason != ""
|
|
350
|
+
assert session.get_calls == []
|
|
351
|
+
|
|
352
|
+
@pytest.mark.asyncio
|
|
353
|
+
async def test_passes_and_returns_target_on_full_agreement(self):
|
|
354
|
+
read = make_fs(
|
|
355
|
+
{
|
|
356
|
+
CONNECTIONS_DIR + "/A.json": conn_file(identity_id="A"),
|
|
357
|
+
"/tok/A": "tok",
|
|
358
|
+
}
|
|
359
|
+
)
|
|
360
|
+
session = FakeSession(
|
|
361
|
+
healthz_by_url={"http://127.0.0.1:9001/healthz": {"identity_id": "A"}}
|
|
362
|
+
)
|
|
363
|
+
target, reason = await probe_and_gate_bound(session, "A", read, CONNECTIONS_DIR)
|
|
364
|
+
assert reason == ""
|
|
365
|
+
assert target is not None
|
|
366
|
+
assert target.identity_id == "A"
|
|
367
|
+
assert session.ws_calls == []
|
|
368
|
+
|
|
369
|
+
@pytest.mark.asyncio
|
|
370
|
+
async def test_refuses_on_gate_mismatch_and_never_opens_ws(self):
|
|
371
|
+
read = make_fs(
|
|
372
|
+
{
|
|
373
|
+
CONNECTIONS_DIR + "/A.json": conn_file(identity_id="A"),
|
|
374
|
+
"/tok/A": "tok",
|
|
375
|
+
}
|
|
376
|
+
)
|
|
377
|
+
session = FakeSession(
|
|
378
|
+
healthz_by_url={"http://127.0.0.1:9001/healthz": {"identity_id": "B"}}
|
|
379
|
+
)
|
|
380
|
+
target, reason = await probe_and_gate_bound(session, "A", read, CONNECTIONS_DIR)
|
|
381
|
+
assert target is None
|
|
382
|
+
assert reason == "check 1 failed: file identity != daemon identity (stale port reuse)"
|
|
383
|
+
assert session.ws_calls == []
|
|
384
|
+
|
|
385
|
+
@pytest.mark.asyncio
|
|
386
|
+
async def test_never_re_derives_bound_identity_from_a_rewritten_binding(self):
|
|
387
|
+
# The whole point of this entrypoint: it takes NO binding_path/env
|
|
388
|
+
# argument, so there is nothing here that could re-read a rewritten
|
|
389
|
+
# binding file even if one existed. A caller that fixes
|
|
390
|
+
# bound_identity="A" once and keeps calling this function with that
|
|
391
|
+
# same value stays anchored to A no matter what a binding.json
|
|
392
|
+
# elsewhere on disk now says - proven by the fact this test never
|
|
393
|
+
# constructs a binding file at all, yet resolution still succeeds
|
|
394
|
+
# for the fixed identity across repeated calls.
|
|
395
|
+
files = {
|
|
396
|
+
CONNECTIONS_DIR + "/A.json": conn_file(identity_id="A"),
|
|
397
|
+
"/tok/A": "tok",
|
|
398
|
+
}
|
|
399
|
+
read = make_fs(files)
|
|
400
|
+
session = FakeSession(
|
|
401
|
+
healthz_by_url={"http://127.0.0.1:9001/healthz": {"identity_id": "A"}}
|
|
402
|
+
)
|
|
403
|
+
for _ in range(3):
|
|
404
|
+
target, reason = await probe_and_gate_bound(session, "A", read, CONNECTIONS_DIR)
|
|
405
|
+
assert reason == ""
|
|
406
|
+
assert target.identity_id == "A"
|
|
407
|
+
|
|
408
|
+
@pytest.mark.asyncio
|
|
409
|
+
async def test_port_move_and_token_rotation_pick_up_new_values_for_fixed_identity(self):
|
|
410
|
+
# Per-attempt re-resolution survives the refactor: a fixed
|
|
411
|
+
# bound_identity still re-reads the connection file and token file
|
|
412
|
+
# from scratch on every call.
|
|
413
|
+
files = {
|
|
414
|
+
CONNECTIONS_DIR + "/A.json": conn_file(
|
|
415
|
+
bridge_url="ws://127.0.0.1:9001/v1/ws", token_file="/tok/A", identity_id="A"
|
|
416
|
+
),
|
|
417
|
+
"/tok/A": "token-v1",
|
|
418
|
+
}
|
|
419
|
+
read = make_fs(files)
|
|
420
|
+
session = FakeSession(
|
|
421
|
+
healthz_by_url={"http://127.0.0.1:9001/healthz": {"identity_id": "A"}}
|
|
422
|
+
)
|
|
423
|
+
|
|
424
|
+
target1, reason1 = await probe_and_gate_bound(session, "A", read, CONNECTIONS_DIR)
|
|
425
|
+
assert reason1 == ""
|
|
426
|
+
assert target1.bridge_url == "http://127.0.0.1:9001"
|
|
427
|
+
assert target1.token == "token-v1"
|
|
428
|
+
|
|
429
|
+
files[CONNECTIONS_DIR + "/A.json"] = conn_file(
|
|
430
|
+
bridge_url="ws://127.0.0.1:9077/v1/ws", token_file="/tok/A", identity_id="A"
|
|
431
|
+
)
|
|
432
|
+
files["/tok/A"] = "token-v2"
|
|
433
|
+
session._healthz_by_url["http://127.0.0.1:9077/healthz"] = {"identity_id": "A"}
|
|
434
|
+
|
|
435
|
+
target2, reason2 = await probe_and_gate_bound(session, "A", read, CONNECTIONS_DIR)
|
|
436
|
+
assert reason2 == ""
|
|
437
|
+
assert target2.bridge_url == "http://127.0.0.1:9077"
|
|
438
|
+
assert target2.token == "token-v2"
|
|
439
|
+
|
|
440
|
+
|
|
441
|
+
# ---------------------------------------------------------------------------
|
|
442
|
+
# should_drop_inbound: echo guard
|
|
443
|
+
# ---------------------------------------------------------------------------
|
|
444
|
+
|
|
445
|
+
|
|
446
|
+
class TestShouldDropInbound:
|
|
447
|
+
def test_drops_own_sender(self):
|
|
448
|
+
assert should_drop_inbound({"sender_id": "A", "text": "hi"}, "A") is True
|
|
449
|
+
|
|
450
|
+
def test_keeps_other_sender(self):
|
|
451
|
+
assert should_drop_inbound({"sender_id": "B", "text": "hi"}, "A") is False
|
|
452
|
+
|
|
453
|
+
def test_keeps_when_sender_id_missing(self):
|
|
454
|
+
assert should_drop_inbound({"text": "hi"}, "A") is False
|
|
455
|
+
|
|
456
|
+
def test_keeps_when_bound_identity_blank(self):
|
|
457
|
+
assert should_drop_inbound({"sender_id": "A", "text": "hi"}, "") is False
|
|
458
|
+
|
|
459
|
+
def test_keeps_when_sender_id_blank(self):
|
|
460
|
+
assert should_drop_inbound({"sender_id": "", "text": "hi"}, "A") is False
|
|
461
|
+
|
|
462
|
+
|
|
463
|
+
# ---------------------------------------------------------------------------
|
|
464
|
+
# ChannelStatusManager
|
|
465
|
+
# ---------------------------------------------------------------------------
|
|
466
|
+
|
|
467
|
+
|
|
468
|
+
class FakeClock:
|
|
469
|
+
def __init__(self, t=0.0):
|
|
470
|
+
self.t = t
|
|
471
|
+
|
|
472
|
+
def __call__(self):
|
|
473
|
+
return self.t
|
|
474
|
+
|
|
475
|
+
def advance(self, dt):
|
|
476
|
+
self.t += dt
|
|
477
|
+
|
|
478
|
+
|
|
479
|
+
class Recorder:
|
|
480
|
+
def __init__(self):
|
|
481
|
+
self.frames = []
|
|
482
|
+
|
|
483
|
+
async def __call__(self, frame):
|
|
484
|
+
self.frames.append(frame)
|
|
485
|
+
|
|
486
|
+
|
|
487
|
+
class TestChannelStatusManager:
|
|
488
|
+
@pytest.mark.asyncio
|
|
489
|
+
async def test_first_on_typing_emits_working(self):
|
|
490
|
+
clock = FakeClock()
|
|
491
|
+
send = Recorder()
|
|
492
|
+
mgr = ChannelStatusManager(send, now=clock)
|
|
493
|
+
await mgr.on_typing("ch1")
|
|
494
|
+
assert send.frames == [{"type": "status", "channel_id": "ch1", "state": "working"}]
|
|
495
|
+
|
|
496
|
+
@pytest.mark.asyncio
|
|
497
|
+
async def test_repeated_on_typing_within_refresh_window_does_not_reemit(self):
|
|
498
|
+
clock = FakeClock()
|
|
499
|
+
send = Recorder()
|
|
500
|
+
mgr = ChannelStatusManager(send, refresh_s=10.0, idle_after_s=20.0, now=clock)
|
|
501
|
+
await mgr.on_typing("ch1")
|
|
502
|
+
clock.advance(5.0)
|
|
503
|
+
await mgr.on_typing("ch1")
|
|
504
|
+
clock.advance(4.9)
|
|
505
|
+
await mgr.on_typing("ch1")
|
|
506
|
+
assert send.frames == [{"type": "status", "channel_id": "ch1", "state": "working"}]
|
|
507
|
+
|
|
508
|
+
@pytest.mark.asyncio
|
|
509
|
+
async def test_on_typing_reemits_working_after_refresh_window(self):
|
|
510
|
+
clock = FakeClock()
|
|
511
|
+
send = Recorder()
|
|
512
|
+
mgr = ChannelStatusManager(send, refresh_s=10.0, idle_after_s=20.0, now=clock)
|
|
513
|
+
await mgr.on_typing("ch1")
|
|
514
|
+
clock.advance(10.0)
|
|
515
|
+
await mgr.on_typing("ch1")
|
|
516
|
+
assert send.frames == [
|
|
517
|
+
{"type": "status", "channel_id": "ch1", "state": "working"},
|
|
518
|
+
{"type": "status", "channel_id": "ch1", "state": "working"},
|
|
519
|
+
]
|
|
520
|
+
|
|
521
|
+
@pytest.mark.asyncio
|
|
522
|
+
async def test_on_reply_posted_emits_idle_and_clears_state(self):
|
|
523
|
+
clock = FakeClock()
|
|
524
|
+
send = Recorder()
|
|
525
|
+
mgr = ChannelStatusManager(send, now=clock)
|
|
526
|
+
await mgr.on_typing("ch1")
|
|
527
|
+
await mgr.on_reply_posted("ch1")
|
|
528
|
+
assert send.frames[-1] == {"type": "status", "channel_id": "ch1", "state": "idle"}
|
|
529
|
+
# State is cleared: a subsequent on_typing must emit "working" again
|
|
530
|
+
# immediately, not treat this as a within-window refresh no-op.
|
|
531
|
+
send.frames.clear()
|
|
532
|
+
await mgr.on_typing("ch1")
|
|
533
|
+
assert send.frames == [{"type": "status", "channel_id": "ch1", "state": "working"}]
|
|
534
|
+
|
|
535
|
+
@pytest.mark.asyncio
|
|
536
|
+
async def test_tick_emits_idle_after_silence_deadline(self):
|
|
537
|
+
clock = FakeClock()
|
|
538
|
+
send = Recorder()
|
|
539
|
+
mgr = ChannelStatusManager(send, refresh_s=10.0, idle_after_s=20.0, now=clock)
|
|
540
|
+
await mgr.on_typing("ch1")
|
|
541
|
+
clock.advance(19.9)
|
|
542
|
+
await mgr.tick()
|
|
543
|
+
assert send.frames == [{"type": "status", "channel_id": "ch1", "state": "working"}] # no idle yet
|
|
544
|
+
clock.advance(0.2)
|
|
545
|
+
await mgr.tick()
|
|
546
|
+
assert send.frames[-1] == {"type": "status", "channel_id": "ch1", "state": "idle"}
|
|
547
|
+
|
|
548
|
+
@pytest.mark.asyncio
|
|
549
|
+
async def test_tick_is_a_no_op_when_channel_never_started_typing(self):
|
|
550
|
+
clock = FakeClock()
|
|
551
|
+
send = Recorder()
|
|
552
|
+
mgr = ChannelStatusManager(send, now=clock)
|
|
553
|
+
await mgr.tick()
|
|
554
|
+
assert send.frames == []
|
|
555
|
+
|
|
556
|
+
@pytest.mark.asyncio
|
|
557
|
+
async def test_typing_resets_the_idle_deadline(self):
|
|
558
|
+
clock = FakeClock()
|
|
559
|
+
send = Recorder()
|
|
560
|
+
mgr = ChannelStatusManager(send, refresh_s=10.0, idle_after_s=20.0, now=clock)
|
|
561
|
+
await mgr.on_typing("ch1")
|
|
562
|
+
clock.advance(15.0)
|
|
563
|
+
await mgr.on_typing("ch1") # refreshes the 20s deadline (and re-emits: 15s >= 10s window)
|
|
564
|
+
clock.advance(15.0) # 15s since last typing < 20s idle_after_s -> should still be "working"
|
|
565
|
+
await mgr.tick()
|
|
566
|
+
assert all(f["state"] != "idle" for f in send.frames)
|
|
567
|
+
|
|
568
|
+
@pytest.mark.asyncio
|
|
569
|
+
async def test_per_channel_independence_interleaved(self):
|
|
570
|
+
clock = FakeClock()
|
|
571
|
+
send = Recorder()
|
|
572
|
+
mgr = ChannelStatusManager(send, refresh_s=10.0, idle_after_s=20.0, now=clock)
|
|
573
|
+
await mgr.on_typing("ch1")
|
|
574
|
+
clock.advance(5.0)
|
|
575
|
+
await mgr.on_typing("ch2")
|
|
576
|
+
clock.advance(16.0) # ch1 at t=21 (>20s since t=0) -> idle; ch2 at t=21 (16s since t=5) -> still working
|
|
577
|
+
await mgr.tick()
|
|
578
|
+
ch1_frames = [f for f in send.frames if f["channel_id"] == "ch1"]
|
|
579
|
+
ch2_frames = [f for f in send.frames if f["channel_id"] == "ch2"]
|
|
580
|
+
assert ch1_frames[-1]["state"] == "idle"
|
|
581
|
+
assert all(f["state"] == "working" for f in ch2_frames)
|
|
582
|
+
|
|
583
|
+
@pytest.mark.asyncio
|
|
584
|
+
async def test_states_are_limited_to_idle_or_working(self):
|
|
585
|
+
clock = FakeClock()
|
|
586
|
+
send = Recorder()
|
|
587
|
+
mgr = ChannelStatusManager(send, refresh_s=1.0, idle_after_s=2.0, now=clock)
|
|
588
|
+
await mgr.on_typing("ch1")
|
|
589
|
+
clock.advance(1.0)
|
|
590
|
+
await mgr.on_typing("ch1")
|
|
591
|
+
await mgr.on_reply_posted("ch1")
|
|
592
|
+
await mgr.on_typing("ch1")
|
|
593
|
+
clock.advance(3.0)
|
|
594
|
+
await mgr.tick()
|
|
595
|
+
assert all(f["state"] in ("idle", "working") for f in send.frames)
|
|
596
|
+
|
|
597
|
+
@pytest.mark.asyncio
|
|
598
|
+
async def test_on_typing_never_emits_idle_strip_flash(self):
|
|
599
|
+
# Task-2 brief mutation #3: an implementation that (incorrectly) emits
|
|
600
|
+
# idle-then-working inside on_typing for a "fresh" channel would flash
|
|
601
|
+
# the UI strip. on_typing must NEVER emit "idle", ever.
|
|
602
|
+
clock = FakeClock()
|
|
603
|
+
send = Recorder()
|
|
604
|
+
mgr = ChannelStatusManager(send, now=clock)
|
|
605
|
+
await mgr.on_typing("ch1")
|
|
606
|
+
clock.advance(100.0)
|
|
607
|
+
await mgr.on_typing("ch1")
|
|
608
|
+
assert all(f["state"] != "idle" for f in send.frames)
|
package/dist/bin.d.ts
CHANGED
|
@@ -1,5 +1,6 @@
|
|
|
1
1
|
#!/usr/bin/env node
|
|
2
2
|
import { type ResolveNodeDeps } from "./service/resolve-node.js";
|
|
3
|
+
import { type Harness } from "./service/wire.js";
|
|
3
4
|
export declare function nodeMajorOk(version: string): boolean;
|
|
4
5
|
/**
|
|
5
6
|
* P0b (daemon-stability): a redacted fingerprint of a bearer token for logs —
|
|
@@ -14,3 +15,4 @@ export declare function tokenFingerprint(token: string): string;
|
|
|
14
15
|
export declare function parseVerb(argv: string[]): "install" | "uninstall" | "status" | "wire" | "unwire" | null;
|
|
15
16
|
export declare function slugFromCredentialPath(credentialPath: string): string;
|
|
16
17
|
export declare function chooseInstallExecPath(runningExecPath: string, override: string | undefined, deps: ResolveNodeDeps): string;
|
|
18
|
+
export declare function realCopyConnector(srcDir: string, dstDir: string, harness: Harness): void;
|