@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,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;