@securecoms/agent-bridge 0.37.0 → 0.39.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 +33 -0
- package/connectors/display-name-fixtures.json +605 -0
- package/connectors/hermes/__pycache__/securecoms_gate.cpython-310.pyc +0 -0
- package/connectors/hermes/__pycache__/securecoms_transport.cpython-310.pyc +0 -0
- package/connectors/hermes/adapter.py +665 -42
- package/connectors/hermes/securecoms_authz.py +112 -11
- package/connectors/hermes/securecoms_transport.py +206 -1
- package/connectors/hermes/tests/test_adapter_wiring.py +1136 -2
- package/connectors/hermes/tests/test_busy_session.py +1436 -0
- package/connectors/hermes/tests/test_securecoms_authz.py +194 -0
- package/connectors/hermes/tests/test_securecoms_transport.py +156 -0
- package/connectors/openclaw/openclaw-entry.contract.test.ts +96 -0
- package/connectors/openclaw/openclaw-entry.inbound.test.ts +363 -4
- package/connectors/openclaw/openclaw-entry.js +239 -4
- package/dist/attachments.d.ts +33 -1
- package/dist/bin.js +4415 -1014
- package/dist/bridge-agent.d.ts +65 -0
- package/dist/index.js +4387 -986
- package/dist/version.d.ts +1 -1
- package/package.json +1 -1
|
@@ -258,3 +258,197 @@ class TestPreToolCallDecision:
|
|
|
258
258
|
raise RuntimeError("no session")
|
|
259
259
|
|
|
260
260
|
assert authz.pre_tool_call_decision(boom, authz.TurnAuthzLedger())["action"] == "block"
|
|
261
|
+
|
|
262
|
+
|
|
263
|
+
# ---------------------------------------------------------------------------
|
|
264
|
+
# H0 (audit r1 #3, r2 #1): an ungranted turn still in flight for a (chat,
|
|
265
|
+
# user) key must block tools for that key, whatever a later grant records.
|
|
266
|
+
# Before H0 the ledger kept only the LATEST decision, so a guest's relay turn
|
|
267
|
+
# that was still running borrowed the owner's grant the moment the owner's
|
|
268
|
+
# relay turn recorded True for the same key.
|
|
269
|
+
# ---------------------------------------------------------------------------
|
|
270
|
+
|
|
271
|
+
|
|
272
|
+
def _bound(chat_id, user_id, platform="securecoms"):
|
|
273
|
+
values = {
|
|
274
|
+
"HERMES_SESSION_PLATFORM": platform,
|
|
275
|
+
"HERMES_SESSION_CHAT_ID": chat_id,
|
|
276
|
+
"HERMES_SESSION_USER_ID": user_id,
|
|
277
|
+
}
|
|
278
|
+
return lambda name, default="": values.get(name, default)
|
|
279
|
+
|
|
280
|
+
|
|
281
|
+
class TestUngrantedInFlight:
|
|
282
|
+
def test_a_guest_turn_in_flight_cannot_borrow_the_owners_grant(self):
|
|
283
|
+
ledger = authz.TurnAuthzLedger()
|
|
284
|
+
ledger.record("room", "relay", False)
|
|
285
|
+
ledger.begin_ungranted("room", "relay") # the guest's relay turn is running
|
|
286
|
+
ledger.record("room", "relay", True) # the owner's relay turn is dispatched
|
|
287
|
+
assert ledger.tools_allowed("room", "relay") is False
|
|
288
|
+
assert authz.pre_tool_call_decision(_bound("room", "relay"), ledger) == {
|
|
289
|
+
"action": "block",
|
|
290
|
+
"message": authz.TOOL_VETO_MESSAGE,
|
|
291
|
+
}
|
|
292
|
+
ledger.end_ungranted("room", "relay")
|
|
293
|
+
assert ledger.tools_allowed("room", "relay") is True
|
|
294
|
+
assert authz.pre_tool_call_decision(_bound("room", "relay"), ledger) is None
|
|
295
|
+
|
|
296
|
+
def test_two_concurrent_ungranted_turns_need_two_releases(self):
|
|
297
|
+
ledger = authz.TurnAuthzLedger()
|
|
298
|
+
ledger.begin_ungranted("room", "relay")
|
|
299
|
+
ledger.begin_ungranted("room", "relay")
|
|
300
|
+
ledger.record("room", "relay", True)
|
|
301
|
+
ledger.end_ungranted("room", "relay")
|
|
302
|
+
assert ledger.tools_allowed("room", "relay") is False
|
|
303
|
+
ledger.end_ungranted("room", "relay")
|
|
304
|
+
assert ledger.tools_allowed("room", "relay") is True
|
|
305
|
+
|
|
306
|
+
def test_a_release_never_goes_below_zero(self):
|
|
307
|
+
ledger = authz.TurnAuthzLedger()
|
|
308
|
+
ledger.record("room", "relay", True)
|
|
309
|
+
ledger.end_ungranted("room", "relay") # stray release: nothing to release
|
|
310
|
+
ledger.begin_ungranted("room", "relay")
|
|
311
|
+
assert ledger.tools_allowed("room", "relay") is False
|
|
312
|
+
|
|
313
|
+
def test_a_later_grant_never_releases_an_in_flight_count(self):
|
|
314
|
+
ledger = authz.TurnAuthzLedger()
|
|
315
|
+
ledger.begin_ungranted("room", "relay")
|
|
316
|
+
for _ in range(3):
|
|
317
|
+
ledger.record("room", "relay", True)
|
|
318
|
+
assert ledger.tools_allowed("room", "relay") is False
|
|
319
|
+
assert ledger.ungranted_in_flight("room", "relay") == 1
|
|
320
|
+
|
|
321
|
+
def test_eviction_never_drops_an_in_flight_entry(self):
|
|
322
|
+
ledger = authz.TurnAuthzLedger(max_entries=2)
|
|
323
|
+
ledger.begin_ungranted("room", "relay")
|
|
324
|
+
ledger.record("room", "relay", True)
|
|
325
|
+
for i in range(10): # fill well past max_entries
|
|
326
|
+
ledger.record(f"c{i}", "u", True)
|
|
327
|
+
ledger.record("room", "relay", True)
|
|
328
|
+
assert ledger.tools_allowed("room", "relay") is False
|
|
329
|
+
assert ledger.ungranted_in_flight("room", "relay") == 1
|
|
330
|
+
|
|
331
|
+
def test_an_owner_dm_key_is_unaffected_by_a_room_key(self):
|
|
332
|
+
ledger = authz.TurnAuthzLedger()
|
|
333
|
+
ledger.record("dm", "owner", True)
|
|
334
|
+
ledger.begin_ungranted("room", "owner")
|
|
335
|
+
ledger.begin_ungranted("room", "relay")
|
|
336
|
+
assert ledger.tools_allowed("dm", "owner") is True
|
|
337
|
+
|
|
338
|
+
def test_a_shared_room_session_count_blocks_every_sender_in_that_chat(self):
|
|
339
|
+
# H0.2b: with group_sessions_per_user off, one room session is shared
|
|
340
|
+
# by every sender, so a count blocks the whole chat.
|
|
341
|
+
ledger = authz.TurnAuthzLedger()
|
|
342
|
+
ledger.record("room", "owner", True)
|
|
343
|
+
ledger.begin_ungranted("room", "guest", shared_session=True)
|
|
344
|
+
assert ledger.tools_allowed("room", "owner") is False
|
|
345
|
+
ledger.end_ungranted("room", "guest", shared_session=True)
|
|
346
|
+
assert ledger.tools_allowed("room", "owner") is True
|
|
347
|
+
|
|
348
|
+
|
|
349
|
+
class TestNonOwnerThread:
|
|
350
|
+
"""H0.2c (re-ruled): every non-owner DM turn runs in the sc-non-owner
|
|
351
|
+
thread session; the thread is part of the ledger key."""
|
|
352
|
+
|
|
353
|
+
def test_the_thread_is_part_of_the_key(self):
|
|
354
|
+
ledger = authz.TurnAuthzLedger()
|
|
355
|
+
ledger.record("dm", "relay", True) # the owner's relayed voice
|
|
356
|
+
assert ledger.tools_allowed("dm", "relay") is True
|
|
357
|
+
assert ledger.tools_allowed("dm", "relay", authz.NON_OWNER_THREAD) is False
|
|
358
|
+
ledger.begin_ungranted("dm", "relay", authz.NON_OWNER_THREAD) # a guest's relayed text
|
|
359
|
+
assert ledger.tools_allowed("dm", "relay") is True # no chat-wide DM block
|
|
360
|
+
assert ledger.ungranted_in_flight("dm", "relay") == 0
|
|
361
|
+
assert ledger.ungranted_in_flight("dm", "relay", authz.NON_OWNER_THREAD) == 1
|
|
362
|
+
|
|
363
|
+
def test_a_record_under_the_non_owner_thread_is_never_a_grant(self):
|
|
364
|
+
ledger = authz.TurnAuthzLedger()
|
|
365
|
+
ledger.record("dm", "relay", True, authz.NON_OWNER_THREAD)
|
|
366
|
+
assert ledger.tools_allowed("dm", "relay", authz.NON_OWNER_THREAD) is False
|
|
367
|
+
|
|
368
|
+
def test_the_constant_has_no_key_separator(self):
|
|
369
|
+
assert authz.NON_OWNER_THREAD == "sc-non-owner" and ":" not in authz.NON_OWNER_THREAD
|
|
370
|
+
|
|
371
|
+
def test_a_guest_session_tool_call_is_blocked_even_with_an_owner_relay_grant(self):
|
|
372
|
+
ledger = authz.TurnAuthzLedger()
|
|
373
|
+
ledger.record("dm", "relay", True)
|
|
374
|
+
env = _bound_thread("dm", "relay", authz.NON_OWNER_THREAD)
|
|
375
|
+
assert authz.pre_tool_call_decision(env, ledger)["action"] == "block"
|
|
376
|
+
assert authz.pre_tool_call_decision(_bound_thread("dm", "relay", ""), ledger) is None
|
|
377
|
+
|
|
378
|
+
def test_an_unset_session_var_blocks(self):
|
|
379
|
+
ledger = authz.TurnAuthzLedger()
|
|
380
|
+
ledger.record("dm", "relay", True)
|
|
381
|
+
for missing in ("HERMES_SESSION_CHAT_ID", "HERMES_SESSION_USER_ID", "HERMES_SESSION_THREAD_ID"):
|
|
382
|
+
values = {"HERMES_SESSION_PLATFORM": "securecoms", "HERMES_SESSION_CHAT_ID": "dm",
|
|
383
|
+
"HERMES_SESSION_USER_ID": "relay", "HERMES_SESSION_THREAD_ID": ""}
|
|
384
|
+
values[missing] = None # unset in this task (direct ContextVar read)
|
|
385
|
+
assert authz.pre_tool_call_decision(lambda n, d="": values[n], ledger)["action"] == "block", missing
|
|
386
|
+
|
|
387
|
+
@staticmethod
|
|
388
|
+
def _frame(**kw):
|
|
389
|
+
block = auth(kw.pop("audience", "other_human_dm"), grants=[])
|
|
390
|
+
block.update(kw)
|
|
391
|
+
return frame(block)
|
|
392
|
+
|
|
393
|
+
def test_owner_turn_classification(self):
|
|
394
|
+
assert authz.is_owner_turn(frame(auth("owner_dm"))) is True
|
|
395
|
+
assert authz.is_owner_turn(frame(auth("owner_dm", grants=[]))) is True # the owner's ungranted turn
|
|
396
|
+
assert authz.is_owner_turn(self._frame(relay_speaker="owner")) is True
|
|
397
|
+
assert authz.is_owner_turn(self._frame()) is False # a guest's relayed voice
|
|
398
|
+
assert authz.is_owner_turn(frame(auth("agent_to_agent"))) is False
|
|
399
|
+
assert authz.is_owner_turn(frame()) is False # no block
|
|
400
|
+
assert authz.is_owner_turn(frame(auth("owner_dm", version=2))) is False
|
|
401
|
+
assert authz.is_owner_turn(frame(auth("owner_dm", membership="unknown"))) is False
|
|
402
|
+
assert authz.is_owner_turn(self._frame(relay_speaker="Owner")) is False
|
|
403
|
+
|
|
404
|
+
|
|
405
|
+
def _bound_thread(chat_id, user_id, thread_id):
|
|
406
|
+
values = {"HERMES_SESSION_PLATFORM": "securecoms", "HERMES_SESSION_CHAT_ID": chat_id,
|
|
407
|
+
"HERMES_SESSION_USER_ID": user_id, "HERMES_SESSION_THREAD_ID": thread_id}
|
|
408
|
+
return lambda name, default="": values.get(name, default)
|
|
409
|
+
|
|
410
|
+
|
|
411
|
+
class TestUnclassifiedThread:
|
|
412
|
+
"""Audit r2 (ruled): a DM frame whose block cannot be classified runs in a
|
|
413
|
+
THIRD session, sc-unclassified: never the owner's, never the guests'."""
|
|
414
|
+
|
|
415
|
+
def test_dm_thread_classification(self):
|
|
416
|
+
assert authz.dm_thread(frame(auth("owner_dm"))) is None
|
|
417
|
+
assert authz.dm_thread(frame(auth("other_human_dm", grants=[], ))) == authz.NON_OWNER_THREAD
|
|
418
|
+
stale = auth("owner_dm", membership="unknown") # the owner during a stale roster
|
|
419
|
+
assert authz.dm_thread(frame(stale)) == authz.UNCLASSIFIED_THREAD
|
|
420
|
+
assert authz.dm_thread(frame()) == authz.UNCLASSIFIED_THREAD # no block
|
|
421
|
+
assert authz.dm_thread(frame(auth("owner_dm", version=2))) == authz.UNCLASSIFIED_THREAD
|
|
422
|
+
assert authz.dm_thread(frame("not a block")) == authz.UNCLASSIFIED_THREAD
|
|
423
|
+
|
|
424
|
+
def test_the_constant_is_distinct_and_has_no_key_separator(self):
|
|
425
|
+
assert authz.UNCLASSIFIED_THREAD == "sc-unclassified"
|
|
426
|
+
assert authz.UNCLASSIFIED_THREAD not in (authz.NON_OWNER_THREAD, "")
|
|
427
|
+
|
|
428
|
+
def test_a_record_under_the_unclassified_thread_is_never_a_grant(self):
|
|
429
|
+
ledger = authz.TurnAuthzLedger()
|
|
430
|
+
ledger.record("dm", "owner", True, authz.UNCLASSIFIED_THREAD)
|
|
431
|
+
assert ledger.tools_allowed("dm", "owner", authz.UNCLASSIFIED_THREAD) is False
|
|
432
|
+
|
|
433
|
+
|
|
434
|
+
class TestBlockSaysDm:
|
|
435
|
+
"""Audit r2: the daemon's block marks a DM (owner_dm / other_human_dm are
|
|
436
|
+
set only when meta.is_dm, and so is relay_speaker), whatever the
|
|
437
|
+
connector's channel cache says."""
|
|
438
|
+
|
|
439
|
+
def test_dm_audiences_and_relay_mark(self):
|
|
440
|
+
assert authz.block_says_dm(frame(auth("owner_dm"))) is True
|
|
441
|
+
assert authz.block_says_dm(frame(auth("other_human_dm", grants=[]))) is True
|
|
442
|
+
relay = auth("agent_to_agent", grants=[])
|
|
443
|
+
relay["relay_speaker"] = "owner"
|
|
444
|
+
assert authz.block_says_dm(frame(relay)) is True
|
|
445
|
+
|
|
446
|
+
def test_room_audiences_and_no_block_say_nothing(self):
|
|
447
|
+
for aud in ("team_room", "partner_room", "agent_to_agent", "unknown"):
|
|
448
|
+
assert authz.block_says_dm(frame(auth(aud, grants=[]))) is False
|
|
449
|
+
assert authz.block_says_dm(frame()) is False
|
|
450
|
+
|
|
451
|
+
def test_a_stale_block_still_says_dm(self):
|
|
452
|
+
# The DM signal is a split signal only (never a grant), so it is read
|
|
453
|
+
# from the raw block even when the block is not valid for tools.
|
|
454
|
+
assert authz.block_says_dm(frame(auth("owner_dm", membership="unknown"))) is True
|
|
@@ -10,9 +10,12 @@
|
|
|
10
10
|
|
|
11
11
|
import json
|
|
12
12
|
import os
|
|
13
|
+
import subprocess
|
|
14
|
+
import sys
|
|
13
15
|
|
|
14
16
|
import pytest
|
|
15
17
|
|
|
18
|
+
import securecoms_transport
|
|
16
19
|
from securecoms_transport import (
|
|
17
20
|
ChannelStatusManager,
|
|
18
21
|
ResolvedTarget,
|
|
@@ -606,3 +609,156 @@ class TestChannelStatusManager:
|
|
|
606
609
|
clock.advance(100.0)
|
|
607
610
|
await mgr.on_typing("ch1")
|
|
608
611
|
assert all(f["state"] != "idle" for f in send.frames)
|
|
612
|
+
|
|
613
|
+
|
|
614
|
+
PNG = b"\x89PNG\r\n\x1a\n\x00\x00\x00\r"
|
|
615
|
+
JPEG = b"\xff\xd8\xff\xe0\x00\x10JFIF\x00\x01"
|
|
616
|
+
GIF = b"GIF89a\x01\x00\x01\x00\x00\x00"
|
|
617
|
+
WEBP = b"RIFF\x10\x00\x00\x00WEBPVP8 "
|
|
618
|
+
|
|
619
|
+
|
|
620
|
+
class TestInboundAttachments:
|
|
621
|
+
@pytest.fixture(autouse=True)
|
|
622
|
+
def _dir(self, tmp_path):
|
|
623
|
+
self.root = tmp_path
|
|
624
|
+
self.dir = tmp_path / "attachments" / "chan"
|
|
625
|
+
self.dir.mkdir(parents=True)
|
|
626
|
+
|
|
627
|
+
def _att(self, name, data=JPEG, mime="image/jpeg", directory=None):
|
|
628
|
+
d = directory or self.dir
|
|
629
|
+
d.mkdir(parents=True, exist_ok=True)
|
|
630
|
+
p = d / f"m-{name}"
|
|
631
|
+
p.write_bytes(data)
|
|
632
|
+
return {"filename": name, "mime": mime, "size": len(data), "path": str(p), "sha256": "x"}
|
|
633
|
+
|
|
634
|
+
def _frame(self, attachments):
|
|
635
|
+
return {"type": "message", "channel_id": "chan", "attachments": attachments}
|
|
636
|
+
|
|
637
|
+
def test_each_allowed_image_type_passes_with_the_sniffed_lowercase_mime(self):
|
|
638
|
+
for name, data, mime in [("a.png", PNG, "image/png"), ("b.jpg", JPEG, "image/jpeg"),
|
|
639
|
+
("c.gif", GIF, "image/gif"), ("d.webp", WEBP, "image/webp")]:
|
|
640
|
+
a = self._att(name, data, mime.upper())
|
|
641
|
+
assert securecoms_transport.inbound_attachments(self._frame([a])) == [(a["path"], mime)]
|
|
642
|
+
|
|
643
|
+
def test_non_images_mismatches_and_oversize_are_dropped(self):
|
|
644
|
+
big = PNG + b"\x00" * securecoms_transport.MAX_INBOUND_ATTACHMENT_BYTES
|
|
645
|
+
bad = [
|
|
646
|
+
self._att("v.mp3", b"ID3\x04\x00\x00\x00\x00\x00\x00\x00\x00", "audio/mpeg"),
|
|
647
|
+
self._att("d.pdf", b"%PDF-1.7\n....", "application/pdf"),
|
|
648
|
+
self._att("x.svg", b"<svg xmlns='http://www.w3.org/2000/svg'><script/></svg>", "image/svg+xml"),
|
|
649
|
+
self._att("png-as.jpg", PNG, "image/jpeg"),
|
|
650
|
+
self._att("t.png", b"just some text", "image/png"),
|
|
651
|
+
self._att("big.png", big, "image/png"),
|
|
652
|
+
]
|
|
653
|
+
for b in bad:
|
|
654
|
+
assert securecoms_transport.inbound_attachments(self._frame([b])) == [], b["filename"]
|
|
655
|
+
assert securecoms_transport.scan_inbound_attachments(self._frame(bad)) == ([], len(bad))
|
|
656
|
+
|
|
657
|
+
def test_bad_paths_and_names_are_dropped(self):
|
|
658
|
+
good = self._att("ok.jpg")
|
|
659
|
+
outside = self._att("id.jpg", directory=self.root / "secrets" / "chan")
|
|
660
|
+
other = self._att("o.jpg", directory=self.root / "attachments" / "other")
|
|
661
|
+
bad = [
|
|
662
|
+
{**good, "path": "relative/attachments/chan/m-ok.jpg"},
|
|
663
|
+
{**good, "path": f"{self.dir}/../chan/m-ok.jpg"},
|
|
664
|
+
{**good, "path": f"{self.dir}/./m-ok.jpg"},
|
|
665
|
+
{**good, "path": f"{self.dir}/"}, # empty basename (normpath strips the "/")
|
|
666
|
+
{**good, "path": outside["path"]},
|
|
667
|
+
{**good, "path": other["path"]},
|
|
668
|
+
{**good, "path": f"{self.dir}/x\0.jpg"},
|
|
669
|
+
{**good, "path": self._att("rtlgpj.jpg")["path"]},
|
|
670
|
+
{**good, "path": self._att("nl\n.jpg")["path"]},
|
|
671
|
+
{**good, "filename": "invfdp.jpg"},
|
|
672
|
+
{**good, "filename": 5},
|
|
673
|
+
{**good, "path": 42},
|
|
674
|
+
{**good, "mime": 7},
|
|
675
|
+
None,
|
|
676
|
+
"string",
|
|
677
|
+
]
|
|
678
|
+
for b in bad:
|
|
679
|
+
assert securecoms_transport.inbound_attachments(self._frame([b])) == [], b
|
|
680
|
+
assert len(securecoms_transport.inbound_attachments(self._frame(bad + [good]))) == 1
|
|
681
|
+
|
|
682
|
+
def test_the_basename_guard_alone_rejects_an_empty_basename(self, monkeypatch):
|
|
683
|
+
# normpath already strips a trailing "/", so neutralize it to prove the
|
|
684
|
+
# basename guard is a real second check, not dead code.
|
|
685
|
+
good = self._att("ok.jpg")
|
|
686
|
+
monkeypatch.setattr(securecoms_transport.os.path, "normpath", lambda p: p)
|
|
687
|
+
assert securecoms_transport.inbound_attachments(self._frame([{**good, "path": f"{self.dir}/"}])) == []
|
|
688
|
+
|
|
689
|
+
def test_guards_only_normpath_or_isabs_catch(self, monkeypatch):
|
|
690
|
+
# Review r2 mutation survivors: a real image reachable through an
|
|
691
|
+
# unnormalized path whose literal parent is .../attachments/chan.
|
|
692
|
+
self._att("f.png", PNG, "image/png", directory=self.root / "y" / "attachments" / "chan")
|
|
693
|
+
dotted = f"{self.root}/attachments/chan/../../y/attachments/chan/m-f.png"
|
|
694
|
+
assert securecoms_transport.inbound_attachments(self._frame([{"path": dotted, "mime": "image/png"}])) == []
|
|
695
|
+
# A relative path that resolves to a real image from the cwd.
|
|
696
|
+
self._att("rel.png", PNG, "image/png")
|
|
697
|
+
monkeypatch.chdir(self.root)
|
|
698
|
+
rel = "attachments/chan/m-rel.png"
|
|
699
|
+
assert os.path.isfile(rel)
|
|
700
|
+
assert securecoms_transport.inbound_attachments(self._frame([{"path": rel, "mime": "image/png"}])) == []
|
|
701
|
+
|
|
702
|
+
def test_a_fifo_is_dropped_without_hanging(self):
|
|
703
|
+
# O_NONBLOCK: without it, open() on a FIFO with no writer blocks forever,
|
|
704
|
+
# so run the scan in a child with a hard timeout.
|
|
705
|
+
fifo = self.dir / "m-hang.png"
|
|
706
|
+
os.mkfifo(fifo)
|
|
707
|
+
frame = {"channel_id": "chan", "attachments": [{"path": str(fifo), "mime": "image/png"}]}
|
|
708
|
+
script = "import json, sys; import securecoms_transport as t; print(json.dumps(t.scan_inbound_attachments(json.loads(sys.stdin.read()))))"
|
|
709
|
+
hermes_dir = os.path.dirname(os.path.dirname(os.path.abspath(__file__)))
|
|
710
|
+
r = subprocess.run([sys.executable, "-c", script], input=json.dumps(frame), capture_output=True, text=True,
|
|
711
|
+
timeout=15, cwd=hermes_dir, env={**os.environ, "PYTHONPATH": hermes_dir})
|
|
712
|
+
assert r.returncode == 0, r.stderr
|
|
713
|
+
assert json.loads(r.stdout) == [[], 1]
|
|
714
|
+
|
|
715
|
+
def test_a_symlink_is_not_followed(self):
|
|
716
|
+
target = self._att("real.png", PNG, "image/png", directory=self.root / "elsewhere")
|
|
717
|
+
link = self.dir / "m-link.png"
|
|
718
|
+
os.symlink(target["path"], link)
|
|
719
|
+
assert securecoms_transport.inbound_attachments(self._frame([{"path": str(link), "mime": "image/png"}])) == []
|
|
720
|
+
|
|
721
|
+
def test_all_attachments_share_one_dir(self):
|
|
722
|
+
good = self._att("a.jpg")
|
|
723
|
+
other = self._att("b.jpg", directory=self.root / "x" / "attachments" / "chan")
|
|
724
|
+
assert [p for p, _ in securecoms_transport.inbound_attachments(self._frame([good, other]))] == [good["path"]]
|
|
725
|
+
|
|
726
|
+
def test_count_is_capped(self):
|
|
727
|
+
many = [self._att(f"p{i}.jpg") for i in range(20)]
|
|
728
|
+
assert len(securecoms_transport.inbound_attachments(self._frame(many))) == securecoms_transport.MAX_INBOUND_ATTACHMENTS == 8
|
|
729
|
+
assert securecoms_transport.MAX_INBOUND_ATTACHMENT_BYTES == 5 * 1024 * 1024
|
|
730
|
+
|
|
731
|
+
def test_no_list_or_no_channel_is_empty(self):
|
|
732
|
+
good = self._att("a.jpg")
|
|
733
|
+
assert securecoms_transport.inbound_attachments({"channel_id": "chan", "attachments": good}) == []
|
|
734
|
+
assert securecoms_transport.inbound_attachments({"attachments": [good]}) == []
|
|
735
|
+
assert securecoms_transport.inbound_attachments({"channel_id": "chan"}) == []
|
|
736
|
+
|
|
737
|
+
|
|
738
|
+
# R3: display names are member-chosen strings that reach the LLM prompt. The
|
|
739
|
+
# cases live in ONE fixture shared with the OpenClaw twin's vitest suite.
|
|
740
|
+
_FIXTURES = json.load(open(os.path.join(
|
|
741
|
+
os.path.dirname(os.path.dirname(os.path.dirname(os.path.abspath(__file__)))), "display-name-fixtures.json"),
|
|
742
|
+
encoding="utf-8"))
|
|
743
|
+
|
|
744
|
+
|
|
745
|
+
class TestDisplayName:
|
|
746
|
+
@pytest.mark.parametrize("raw,expected", _FIXTURES["display_name"], ids=lambda v: ascii(v)[:40])
|
|
747
|
+
def test_shared_fixture(self, raw, expected):
|
|
748
|
+
assert securecoms_transport.display_name(raw) == expected
|
|
749
|
+
|
|
750
|
+
def test_cap(self):
|
|
751
|
+
assert securecoms_transport.DISPLAY_NAME_MAX == 64
|
|
752
|
+
|
|
753
|
+
|
|
754
|
+
class TestSenderLabel:
|
|
755
|
+
@pytest.mark.parametrize("case,frame,expected", _FIXTURES["sender_label"], ids=[c[0] for c in _FIXTURES["sender_label"]])
|
|
756
|
+
def test_shared_fixture(self, case, frame, expected):
|
|
757
|
+
assert securecoms_transport.sender_label(frame) == expected
|
|
758
|
+
|
|
759
|
+
def test_own_label_ignores_the_speaker(self):
|
|
760
|
+
frame = {"sender_id": "relay-1234567890", "sender_name": "Room relay",
|
|
761
|
+
"speaker": {"identityId": "human-1", "displayName": "Dana"}}
|
|
762
|
+
assert securecoms_transport.own_label(frame) == "Room relay (relay-12)"
|
|
763
|
+
assert securecoms_transport.speaker_label(frame) == "Dana (human-1)"
|
|
764
|
+
assert securecoms_transport.speaker_label({"sender_id": "x"}) == ""
|
|
@@ -0,0 +1,96 @@
|
|
|
1
|
+
// packages/connect-agent-bridge/connectors/openclaw/openclaw-entry.contract.test.ts
|
|
2
|
+
//
|
|
3
|
+
// Bridge -> connector contract (review r1 LOW): a file the daemon's REAL
|
|
4
|
+
// materializeAttachment writes (real crypto, real fs writers) must pass BOTH
|
|
5
|
+
// connector validators unchanged — the OpenClaw scanInboundAttachments and the
|
|
6
|
+
// Hermes securecoms_transport.scan_inbound_attachments — including when the
|
|
7
|
+
// daemon's attachmentsDir carries a trailing "/". A non-image the daemon saves
|
|
8
|
+
// must be dropped by both.
|
|
9
|
+
|
|
10
|
+
import { describe, expect, it } from "vitest";
|
|
11
|
+
import { spawnSync } from "node:child_process";
|
|
12
|
+
import { mkdtempSync } from "node:fs";
|
|
13
|
+
import { tmpdir } from "node:os";
|
|
14
|
+
import { join } from "node:path";
|
|
15
|
+
import { computeFileDigest, decryptFile, encryptFile, base64ToBytes } from "@agentvault/crypto";
|
|
16
|
+
import {
|
|
17
|
+
materializeAttachment,
|
|
18
|
+
mkdirPrivateNoFollow,
|
|
19
|
+
writeFileExclusiveNoFollow,
|
|
20
|
+
type ArtifactShareBody,
|
|
21
|
+
} from "../../src/attachments.js";
|
|
22
|
+
import { inboundAttachmentsOf } from "./openclaw-entry.js";
|
|
23
|
+
|
|
24
|
+
const CH = "3f2b6c1a-8d4e-4f7a-9b2c-1d3e5f7a9b0c";
|
|
25
|
+
const PNG = Uint8Array.from([0x89, 0x50, 0x4e, 0x47, 0x0d, 0x0a, 0x1a, 0x0a, 0, 0, 0, 13, 1, 2, 3]);
|
|
26
|
+
const HERMES_DIR = join(__dirname, "..", "hermes");
|
|
27
|
+
|
|
28
|
+
async function materialize(plain: Uint8Array, name: string, attachmentsDir: string, messageId: string) {
|
|
29
|
+
const { encryptedData, fileKey, fileNonce, digest } = await encryptFile(plain);
|
|
30
|
+
const b64 = (u: Uint8Array) => Buffer.from(u).toString("base64");
|
|
31
|
+
const body: ArtifactShareBody = {
|
|
32
|
+
v: 1, type: "artifact_share", name, mime: "image/png", size: plain.length,
|
|
33
|
+
blobId: "0f3c2a1e-5b6d-4c7e-8f90-a1b2c3d4e5f6", fileKey: b64(fileKey), fileNonce: b64(fileNonce), digest,
|
|
34
|
+
};
|
|
35
|
+
return materializeAttachment(body, { attachmentsDir, channelId: CH, messageId }, {
|
|
36
|
+
ready: async () => {},
|
|
37
|
+
downloadBlob: async () => encryptedData,
|
|
38
|
+
decryptFile, computeFileDigest, base64ToBytes,
|
|
39
|
+
mkdirp: mkdirPrivateNoFollow,
|
|
40
|
+
writeFile: writeFileExclusiveNoFollow,
|
|
41
|
+
});
|
|
42
|
+
}
|
|
43
|
+
|
|
44
|
+
// Visible skip (review r2): the Hermes half needs python3 on the runner.
|
|
45
|
+
const HAS_PYTHON3 = spawnSync("python3", ["--version"]).status === 0;
|
|
46
|
+
|
|
47
|
+
function hermesScan(frame: unknown): { kept: Array<[string, string]>; dropped: number } {
|
|
48
|
+
const script = [
|
|
49
|
+
"import json, sys",
|
|
50
|
+
"import securecoms_transport as t",
|
|
51
|
+
"kept, dropped = t.scan_inbound_attachments(json.loads(sys.stdin.read()))",
|
|
52
|
+
"print(json.dumps({'kept': kept, 'dropped': dropped}))",
|
|
53
|
+
].join("\n");
|
|
54
|
+
const r = spawnSync("python3", ["-c", script], {
|
|
55
|
+
input: JSON.stringify(frame),
|
|
56
|
+
cwd: HERMES_DIR,
|
|
57
|
+
env: { ...process.env, PYTHONPATH: HERMES_DIR },
|
|
58
|
+
encoding: "utf8",
|
|
59
|
+
});
|
|
60
|
+
expect(r.status, r.stderr).toBe(0);
|
|
61
|
+
return JSON.parse(r.stdout);
|
|
62
|
+
}
|
|
63
|
+
|
|
64
|
+
describe("bridge materializeAttachment -> connector validators", () => {
|
|
65
|
+
for (const trailing of ["", "/"]) {
|
|
66
|
+
it(`a PNG the bridge writes passes both connectors (attachmentsDir trailing "${trailing}")`, async () => {
|
|
67
|
+
const dir = join(mkdtempSync(join(tmpdir(), "sc-contract-")), "attachments") + trailing;
|
|
68
|
+
const meta = await materialize(PNG, "IMG_3375.png", dir, "7c1d2e3f-4a5b-4c6d-8e9f-0a1b2c3d4e5f");
|
|
69
|
+
expect(meta.mime).toBe("image/png");
|
|
70
|
+
const frame = { channel_id: CH, attachments: [meta] };
|
|
71
|
+
|
|
72
|
+
expect(inboundAttachmentsOf(frame)).toEqual([{ path: meta.path, mime: "image/png", filename: "IMG_3375.png" }]);
|
|
73
|
+
|
|
74
|
+
});
|
|
75
|
+
|
|
76
|
+
it.skipIf(!HAS_PYTHON3)(`... and the Hermes validator (attachmentsDir trailing "${trailing}")`, async () => {
|
|
77
|
+
const dir = join(mkdtempSync(join(tmpdir(), "sc-contract-")), "attachments") + trailing;
|
|
78
|
+
const meta = await materialize(PNG, "IMG_3375.png", dir, "7c1d2e3f-4a5b-4c6d-8e9f-0a1b2c3d4e5f");
|
|
79
|
+
expect(hermesScan({ channel_id: CH, attachments: [meta] })).toEqual({ kept: [[meta.path, "image/png"]], dropped: 0 });
|
|
80
|
+
});
|
|
81
|
+
}
|
|
82
|
+
|
|
83
|
+
it("a non-image the bridge saves is dropped by both connectors", async () => {
|
|
84
|
+
const dir = join(mkdtempSync(join(tmpdir(), "sc-contract-")), "attachments");
|
|
85
|
+
const meta = await materialize(new TextEncoder().encode("%PDF-1.7\n...."), "r.pdf", dir, "8d2e3f4a-5b6c-4d7e-9f0a-1b2c3d4e5f6a");
|
|
86
|
+
expect(meta.mime).toBe("application/octet-stream");
|
|
87
|
+
const frame = { channel_id: CH, attachments: [meta] };
|
|
88
|
+
expect(inboundAttachmentsOf(frame)).toEqual([]);
|
|
89
|
+
});
|
|
90
|
+
|
|
91
|
+
it.skipIf(!HAS_PYTHON3)("... and the Hermes validator drops it too", async () => {
|
|
92
|
+
const dir = join(mkdtempSync(join(tmpdir(), "sc-contract-")), "attachments");
|
|
93
|
+
const meta = await materialize(new TextEncoder().encode("%PDF-1.7\n...."), "r.pdf", dir, "8d2e3f4a-5b6c-4d7e-9f0a-1b2c3d4e5f6a");
|
|
94
|
+
expect(hermesScan({ channel_id: CH, attachments: [meta] })).toEqual({ kept: [], dropped: 1 });
|
|
95
|
+
});
|
|
96
|
+
});
|