@securecoms/agent-bridge 0.38.0 → 0.40.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 +417 -13
- package/connectors/hermes/securecoms_authz.py +112 -11
- package/connectors/hermes/securecoms_transport.py +109 -0
- package/connectors/hermes/tests/test_adapter_wiring.py +683 -7
- 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 +29 -0
- package/connectors/openclaw/openclaw-entry.inbound.test.ts +136 -4
- package/connectors/openclaw/openclaw-entry.js +151 -4
- package/connectors/openclaw/openclaw-entry.outbound.test.ts +217 -0
- package/dist/bin.d.ts +2 -0
- package/dist/bin.js +65 -3
- package/dist/bridge-agent.d.ts +5 -0
- package/dist/http-server.d.ts +1 -0
- package/dist/index.js +17 -1
- package/dist/service/wire.d.ts +24 -0
- package/dist/version.d.ts +1 -1
- package/package.json +1 -1
|
@@ -140,6 +140,17 @@ _ACCEPT_POLL_S = 0.02
|
|
|
140
140
|
# One warning per process when this Hermes lacks the acceptance signal.
|
|
141
141
|
_TURN_TRACKING_WARNED = False
|
|
142
142
|
|
|
143
|
+
# H0: Hermes session key -> [(ledger token, runner key)] for every ungranted
|
|
144
|
+
# event dispatched into that session and not yet released. Module-level, like
|
|
145
|
+
# _TURN_AUTHZ, so a replaced adapter instance or a task cancelled before its
|
|
146
|
+
# first step cannot strand a count: the next completion that finds the session
|
|
147
|
+
# idle releases it, whichever adapter instance observes it.
|
|
148
|
+
_ATTACHED: Dict[Any, list] = {}
|
|
149
|
+
# One warning per process each: an idle input Hermes does not expose (so no
|
|
150
|
+
# count can be released), and H0.2b's shared room sessions.
|
|
151
|
+
_IDLE_INPUTS_WARNED = False
|
|
152
|
+
_SHARED_ROOMS_WARNED = False
|
|
153
|
+
|
|
143
154
|
# Hermes's Signal adapter mapping (_MEDIA_TYPE_BY_MIME_PREFIX): first matching
|
|
144
155
|
# prefix wins; anything else is a DOCUMENT so Hermes surfaces the file path.
|
|
145
156
|
_MEDIA_TYPE_BY_MIME_PREFIX = (("audio/", "VOICE"), ("image/", "PHOTO"), ("video/", "VIDEO"))
|
|
@@ -267,6 +278,56 @@ def _new_client_session():
|
|
|
267
278
|
return aiohttp.ClientSession()
|
|
268
279
|
|
|
269
280
|
|
|
281
|
+
# Item A (R4): cron / proactive delivery to the owner DM. `agent-bridge wire
|
|
282
|
+
# --harness hermes` sets the cron home channel (CRON_HOME_CHANNEL_ENV, the
|
|
283
|
+
# platform's cron_deliver_env_var) to the literal OWNER_TARGET. Both Hermes
|
|
284
|
+
# cron lanes (the live adapter.send and the out-of-process
|
|
285
|
+
# _standalone_send) resolve it through the bridge's bearer-gated
|
|
286
|
+
# GET /v1/owner-dm, which answers only from the owner-signed manifest in
|
|
287
|
+
# force (OQ-A1). There is no fallback: no manifest means no send. A proactive
|
|
288
|
+
# send carries no in_reply_to and no idempotency_key (OQ-A2: Hermes passes no
|
|
289
|
+
# run id, and job_id is stable across runs, so a job_id key would suppress
|
|
290
|
+
# every later run of the job).
|
|
291
|
+
CRON_HOME_CHANNEL_ENV = "SECURECOMS_HOME_CHANNEL"
|
|
292
|
+
OWNER_TARGET = "owner"
|
|
293
|
+
# Answers that name a target rather than a channel are never a channel id.
|
|
294
|
+
_OWNER_DM_NON_CHANNEL_ANSWERS = ("owner", "default")
|
|
295
|
+
# Fixed failure texts: no channel id, no message text. Wording approved by Eric
|
|
296
|
+
# 2026-11-12 (E-4); OWNER_DM_UNKNOWN_ERROR is byte-equal to the OpenClaw
|
|
297
|
+
# connector's.
|
|
298
|
+
OWNER_DM_UNKNOWN_ERROR = (
|
|
299
|
+
"SecureComs could not find the owner's direct message. "
|
|
300
|
+
"The agent needs its signed owner settings first."
|
|
301
|
+
)
|
|
302
|
+
PROACTIVE_MEDIA_ERROR = "SecureComs can send only text in a scheduled message. The file was not sent."
|
|
303
|
+
PROACTIVE_UNAVAILABLE_ERROR = "SecureComs could not reach the agent bridge for a scheduled message."
|
|
304
|
+
_OWNER_DM_TIMEOUT_S = 5.0
|
|
305
|
+
|
|
306
|
+
|
|
307
|
+
async def _owner_dm_channel_id(session, bridge_url: str, token: str) -> Optional[str]:
|
|
308
|
+
"""The owner DM channel id from GET /v1/owner-dm, or None on ANY failure
|
|
309
|
+
(non-200, network error, malformed body, an empty or non-string id, or an
|
|
310
|
+
answer that is itself a target name). Never raises."""
|
|
311
|
+
|
|
312
|
+
async def _get() -> Any:
|
|
313
|
+
async with session.get(
|
|
314
|
+
f"{bridge_url}/v1/owner-dm", headers={"Authorization": f"Bearer {token}"}
|
|
315
|
+
) as r:
|
|
316
|
+
if r.status != 200:
|
|
317
|
+
return None
|
|
318
|
+
return await r.json()
|
|
319
|
+
|
|
320
|
+
try:
|
|
321
|
+
body = await asyncio.wait_for(_get(), timeout=_OWNER_DM_TIMEOUT_S)
|
|
322
|
+
except Exception as e:
|
|
323
|
+
logger.debug("SecureComs: owner DM lookup failed (error_class=%s)", _error_class(e))
|
|
324
|
+
return None
|
|
325
|
+
cid = body.get("channel_id") if isinstance(body, dict) else None
|
|
326
|
+
if not isinstance(cid, str) or not cid or cid in _OWNER_DM_NON_CHANNEL_ANSWERS:
|
|
327
|
+
return None
|
|
328
|
+
return cid
|
|
329
|
+
|
|
330
|
+
|
|
270
331
|
class SecureComsAdapter(BasePlatformAdapter):
|
|
271
332
|
"""Hermes platform adapter fronting the local SecureComs daemon."""
|
|
272
333
|
|
|
@@ -306,6 +367,16 @@ class SecureComsAdapter(BasePlatformAdapter):
|
|
|
306
367
|
self._send_status_frame, now=time.monotonic
|
|
307
368
|
)
|
|
308
369
|
|
|
370
|
+
# H0.2b: Hermes shares ONE room session across every sender when
|
|
371
|
+
# group_sessions_per_user is off; every room turn is then ungranted.
|
|
372
|
+
self._room_sessions_shared = self._group_sessions_shared()
|
|
373
|
+
|
|
374
|
+
def _group_sessions_shared(self) -> bool:
|
|
375
|
+
extra = getattr(self.config, "extra", None)
|
|
376
|
+
if not isinstance(extra, dict):
|
|
377
|
+
return True
|
|
378
|
+
return extra.get("group_sessions_per_user", True) is not True
|
|
379
|
+
|
|
309
380
|
@property
|
|
310
381
|
def name(self) -> str:
|
|
311
382
|
return "SecureComs"
|
|
@@ -327,6 +398,14 @@ class SecureComsAdapter(BasePlatformAdapter):
|
|
|
327
398
|
"""
|
|
328
399
|
del is_reconnect # accepted for contract parity; nothing to preserve
|
|
329
400
|
self._stopped = False
|
|
401
|
+
self._room_sessions_shared = self._group_sessions_shared()
|
|
402
|
+
global _SHARED_ROOMS_WARNED
|
|
403
|
+
if self._room_sessions_shared and not _SHARED_ROOMS_WARNED:
|
|
404
|
+
_SHARED_ROOMS_WARNED = True
|
|
405
|
+
logger.warning(
|
|
406
|
+
"SecureComs: group_sessions_per_user is off, so a room shares one Hermes session "
|
|
407
|
+
"across senders; every room turn runs without tools"
|
|
408
|
+
)
|
|
330
409
|
|
|
331
410
|
# BINDING-STABILITY fold: resolve bound_identity here, ONCE, for the
|
|
332
411
|
# entire process life of this adapter instance. Every subsequent
|
|
@@ -523,7 +602,9 @@ class SecureComsAdapter(BasePlatformAdapter):
|
|
|
523
602
|
if r.status == 200:
|
|
524
603
|
for c in await r.json():
|
|
525
604
|
self._channels[c["channel_id"]] = {
|
|
526
|
-
|
|
605
|
+
# Member-chosen and reaches the prompt (chat_name,
|
|
606
|
+
# get_chat_info): sanitized once, here (R3).
|
|
607
|
+
"name": securecoms_transport.display_name(c.get("name")) or c["channel_id"],
|
|
527
608
|
"is_dm": bool(c.get("is_dm")),
|
|
528
609
|
}
|
|
529
610
|
|
|
@@ -708,15 +789,127 @@ class SecureComsAdapter(BasePlatformAdapter):
|
|
|
708
789
|
|
|
709
790
|
async def on_processing_complete(self, event: Any, outcome: Any) -> None:
|
|
710
791
|
"""Hermes lifecycle hook (base.py:3448): record the outcome on the
|
|
711
|
-
event for _await_acceptance, then keep the
|
|
792
|
+
event for _await_acceptance, run the H0 release check, then keep the
|
|
793
|
+
base behaviour."""
|
|
712
794
|
try:
|
|
713
795
|
event._securecoms_outcome = getattr(outcome, "name", None) or "UNKNOWN"
|
|
714
796
|
except Exception:
|
|
715
797
|
pass
|
|
798
|
+
try:
|
|
799
|
+
self._settle_on_completion(event)
|
|
800
|
+
except Exception as e:
|
|
801
|
+
logger.warning(
|
|
802
|
+
"SecureComs: ungranted-turn release check failed; its sender stays without tools "
|
|
803
|
+
"(error_class=%s)",
|
|
804
|
+
_error_class(e),
|
|
805
|
+
)
|
|
716
806
|
base = getattr(super(), "on_processing_complete", None)
|
|
717
807
|
if callable(base):
|
|
718
808
|
await base(event, outcome)
|
|
719
809
|
|
|
810
|
+
# ── H0: ungranted turns in flight ─────────────────────────────────────
|
|
811
|
+
#
|
|
812
|
+
# Hermes v2026.9.24 (read on the Mini): on_processing_complete fires once
|
|
813
|
+
# per started task, for the event that started it only (base.py:4520-4540),
|
|
814
|
+
# before the debounce flush and the pending pop. An event can reach a turn
|
|
815
|
+
# without starting it (redirect, steer, base merge, runner FIFO, debounce),
|
|
816
|
+
# and an event that DID start its own base task can still be parked by the
|
|
817
|
+
# runner behind a pending sentinel (run_inbound.py:690-697) or swapped for
|
|
818
|
+
# an orphaned FIFO head, so its own completion does not prove its text has
|
|
819
|
+
# run. Every ungranted event's count is therefore attached to its Hermes
|
|
820
|
+
# session at dispatch and comes down only when the event never reached
|
|
821
|
+
# Hermes, or at a moment its session is idle: no other running task, no
|
|
822
|
+
# command guard, nothing pending or debounced, an empty runner FIFO and no
|
|
823
|
+
# runner turn. Never on a grant. Anything unreadable counts as busy.
|
|
824
|
+
|
|
825
|
+
def _session_key_of(self, event: Any) -> Optional[str]:
|
|
826
|
+
key_fn = getattr(self, "_event_session_key", None)
|
|
827
|
+
if not callable(key_fn):
|
|
828
|
+
return None
|
|
829
|
+
try:
|
|
830
|
+
key = key_fn(event)
|
|
831
|
+
except Exception:
|
|
832
|
+
return None
|
|
833
|
+
return key if isinstance(key, str) and key else None
|
|
834
|
+
|
|
835
|
+
def _runner_key_of(self, event: Any) -> Optional[str]:
|
|
836
|
+
runner = getattr(self, "gateway_runner", None)
|
|
837
|
+
key_fn = getattr(runner, "_session_key_for_source", None)
|
|
838
|
+
if not callable(key_fn):
|
|
839
|
+
return None
|
|
840
|
+
try:
|
|
841
|
+
key = key_fn(event.source)
|
|
842
|
+
except Exception:
|
|
843
|
+
return None
|
|
844
|
+
return key if isinstance(key, str) and key else None
|
|
845
|
+
|
|
846
|
+
@staticmethod
|
|
847
|
+
def _warn_idle_inputs_missing() -> None:
|
|
848
|
+
global _IDLE_INPUTS_WARNED
|
|
849
|
+
if _IDLE_INPUTS_WARNED:
|
|
850
|
+
return
|
|
851
|
+
_IDLE_INPUTS_WARNED = True
|
|
852
|
+
logger.warning(
|
|
853
|
+
"SecureComs: this Hermes does not expose its session idle state; a sender whose "
|
|
854
|
+
"turn ran without tools stays without tools"
|
|
855
|
+
)
|
|
856
|
+
|
|
857
|
+
def _session_idle(self, session_key: str, completing: Any, runner_keys=()) -> bool:
|
|
858
|
+
"""True only when nothing in this Hermes session can still be
|
|
859
|
+
processing an event's text. ``completing`` is the task whose
|
|
860
|
+
on_processing_complete is running (it is ending, so it is not busy)."""
|
|
861
|
+
try:
|
|
862
|
+
active = getattr(self, "_active_sessions", None)
|
|
863
|
+
tasks = getattr(self, "_session_tasks", None)
|
|
864
|
+
pending = getattr(self, "_pending_messages", None)
|
|
865
|
+
store_fn = getattr(self, "_text_debounce_store", None)
|
|
866
|
+
runner = getattr(self, "gateway_runner", None)
|
|
867
|
+
peek = getattr(runner, "_peek_session_state", None)
|
|
868
|
+
if (
|
|
869
|
+
not isinstance(active, dict) or not isinstance(tasks, dict) or not isinstance(pending, dict)
|
|
870
|
+
or not callable(store_fn) or not callable(peek)
|
|
871
|
+
):
|
|
872
|
+
self._warn_idle_inputs_missing()
|
|
873
|
+
return False
|
|
874
|
+
if session_key in pending:
|
|
875
|
+
return False
|
|
876
|
+
store = store_fn()
|
|
877
|
+
if not isinstance(store, dict) or session_key in store:
|
|
878
|
+
return False
|
|
879
|
+
task = tasks.get(session_key)
|
|
880
|
+
if task is not None and task is not completing and not task.done():
|
|
881
|
+
return False
|
|
882
|
+
if session_key in active and task is None:
|
|
883
|
+
return False # a guard with no owner task: a command is running
|
|
884
|
+
for key in {session_key, *(k for k in runner_keys if k)}:
|
|
885
|
+
state = peek(key)
|
|
886
|
+
if state is None:
|
|
887
|
+
continue
|
|
888
|
+
if state.turn.agent is not None:
|
|
889
|
+
return False
|
|
890
|
+
if state.conversation.queued_events:
|
|
891
|
+
return False
|
|
892
|
+
return True
|
|
893
|
+
except Exception:
|
|
894
|
+
return False
|
|
895
|
+
|
|
896
|
+
def _release_if_idle(self, session_key: Optional[str], completing: Any, runner_key: Optional[str] = None) -> None:
|
|
897
|
+
"""Release every count attached to this session once it is idle."""
|
|
898
|
+
if session_key is None:
|
|
899
|
+
return
|
|
900
|
+
waiting = _ATTACHED.get(session_key)
|
|
901
|
+
if not waiting:
|
|
902
|
+
return
|
|
903
|
+
runner_keys = tuple(rk for _, rk in waiting) + (runner_key,)
|
|
904
|
+
if self._session_idle(session_key, completing, runner_keys):
|
|
905
|
+
for token, _ in _ATTACHED.pop(session_key, []):
|
|
906
|
+
_TURN_AUTHZ.end_ungranted(*token)
|
|
907
|
+
|
|
908
|
+
def _settle_on_completion(self, event: Any) -> None:
|
|
909
|
+
"""H0.2 (d)+(e): at every completion in a session, release what is
|
|
910
|
+
attached to it if the session is idle."""
|
|
911
|
+
self._release_if_idle(self._session_key_of(event), asyncio.current_task(), self._runner_key_of(event))
|
|
912
|
+
|
|
720
913
|
async def _await_acceptance(self, event: Any) -> str:
|
|
721
914
|
""""accepted" once Hermes holds the turn durably (marker persisted) or
|
|
722
915
|
has finished it; "cancelled" when Hermes cancelled it before that
|
|
@@ -834,6 +1027,10 @@ class SecureComsAdapter(BasePlatformAdapter):
|
|
|
834
1027
|
# (Hermes keys group sessions per user), never a DM: guessing DM would
|
|
835
1028
|
# put a room's messages into a direct-chat session.
|
|
836
1029
|
chat_type = "dm" if meta.get("is_dm") is True else "group"
|
|
1030
|
+
if securecoms_authz.block_says_dm(frame):
|
|
1031
|
+
# Audit r2: the daemon's block marks a DM whatever this cache says
|
|
1032
|
+
# (a cache miss or a failed refresh must not skip the H0.2c split).
|
|
1033
|
+
chat_type = "dm"
|
|
837
1034
|
|
|
838
1035
|
global _FRAMES_WITHOUT_AUTHORIZATION, _ATTACHMENTS_DROPPED
|
|
839
1036
|
if not isinstance(frame.get("authorization"), dict):
|
|
@@ -845,7 +1042,20 @@ class SecureComsAdapter(BasePlatformAdapter):
|
|
|
845
1042
|
"Upgrade @securecoms/agent-bridge"
|
|
846
1043
|
)
|
|
847
1044
|
tools_allowed = securecoms_authz.tools_allowed(frame)
|
|
848
|
-
|
|
1045
|
+
if tools_allowed and self._room_sessions_shared and chat_type != "dm":
|
|
1046
|
+
tools_allowed = False # H0.2b: one room session for every sender
|
|
1047
|
+
# H0.2c: Hermes keys a DM session by chat only. Every non-owner DM turn
|
|
1048
|
+
# runs in its own session (thread NON_OWNER_THREAD), so its text never
|
|
1049
|
+
# enters the transcript the owner's granted turns run in.
|
|
1050
|
+
thread_id = None
|
|
1051
|
+
if chat_type == "dm":
|
|
1052
|
+
thread_id = securecoms_authz.dm_thread(frame)
|
|
1053
|
+
if thread_id is not None:
|
|
1054
|
+
# Defence in depth only: no non-owner or unclassified frame passes
|
|
1055
|
+
# securecoms_authz.tools_allowed, and the ledger forces these
|
|
1056
|
+
# threads off. Removing this line is an equivalent mutant (N4).
|
|
1057
|
+
tools_allowed = False
|
|
1058
|
+
_TURN_AUTHZ.record(channel_id, sender_id, tools_allowed, thread_id)
|
|
849
1059
|
if not tools_allowed and not _TOOL_VETO_ACTIVE:
|
|
850
1060
|
# This Hermes cannot be made to refuse tool calls, so a turn the
|
|
851
1061
|
# daemon did not grant tools never reaches it. Acked at once by
|
|
@@ -856,11 +1066,25 @@ class SecureComsAdapter(BasePlatformAdapter):
|
|
|
856
1066
|
|
|
857
1067
|
source = self.build_source(
|
|
858
1068
|
chat_id=channel_id,
|
|
859
|
-
|
|
1069
|
+
# The room name is member-chosen and reaches the prompt: same
|
|
1070
|
+
# sanitizer as the sender label, falling back to the channel id.
|
|
1071
|
+
chat_name=securecoms_transport.display_name(meta.get("name")) or channel_id,
|
|
860
1072
|
chat_type=chat_type,
|
|
1073
|
+
# user_id (Hermes's session and the tool ledger key) is the
|
|
1074
|
+
# relay-attributed sender_id; user_name is a sanitized label of
|
|
1075
|
+
# that same sender (R3). thread_id: H0.2c above.
|
|
861
1076
|
user_id=sender_id,
|
|
862
|
-
|
|
1077
|
+
thread_id=thread_id,
|
|
1078
|
+
user_name=securecoms_transport.own_label(frame),
|
|
863
1079
|
)
|
|
1080
|
+
speaker = securecoms_transport.speaker_label(frame)
|
|
1081
|
+
if speaker and isinstance(text, str):
|
|
1082
|
+
# A relayed voice room: one Hermes session (the relay's) holds
|
|
1083
|
+
# several speakers, and Hermes pins user_name in the per-session
|
|
1084
|
+
# system prompt, so the real speaker rides each turn's text, the
|
|
1085
|
+
# way Hermes's own shared sessions prefix "[sender name]". The
|
|
1086
|
+
# label's name part cannot contain "[]()" (R3, M2).
|
|
1087
|
+
text = f"[{speaker}] {text}"
|
|
864
1088
|
if not tools_allowed and isinstance(text, str):
|
|
865
1089
|
# An ungranted sender must never drive gateway commands. EVERY
|
|
866
1090
|
# ungranted text is escaped: Hermes strips leading @url:/@file:
|
|
@@ -909,10 +1133,51 @@ class SecureComsAdapter(BasePlatformAdapter):
|
|
|
909
1133
|
prompts = [p for p in (voice_prompt_of(frame), None if tools_allowed else securecoms_authz.NO_TOOLS_TURN_NOTE) if p]
|
|
910
1134
|
if prompts:
|
|
911
1135
|
event.channel_prompt = "\n\n".join(prompts)
|
|
912
|
-
|
|
913
|
-
|
|
1136
|
+
token = None
|
|
1137
|
+
session_key = None
|
|
1138
|
+
if not tools_allowed:
|
|
1139
|
+
# H0.2 (a): this key has no tools until no turn in its session can
|
|
1140
|
+
# still be processing this event's text. Attached to the session
|
|
1141
|
+
# now; released only when the session is idle.
|
|
1142
|
+
token = (channel_id, sender_id, thread_id, self._room_sessions_shared and chat_type != "dm")
|
|
1143
|
+
_TURN_AUTHZ.begin_ungranted(*token)
|
|
1144
|
+
session_key = self._session_key_of(event)
|
|
1145
|
+
if session_key is None:
|
|
1146
|
+
logger.warning(
|
|
1147
|
+
"SecureComs: an ungranted turn's Hermes session is unknown; its sender stays without tools"
|
|
1148
|
+
)
|
|
1149
|
+
else:
|
|
1150
|
+
_ATTACHED.setdefault(session_key, []).append((token, self._runner_key_of(event)))
|
|
1151
|
+
reached = False
|
|
1152
|
+
try:
|
|
1153
|
+
event._securecoms_session_was_busy = self._session_was_busy(event)
|
|
1154
|
+
reached = True
|
|
1155
|
+
await self.handle_message(event)
|
|
1156
|
+
except BaseException:
|
|
1157
|
+
if token is not None and not reached:
|
|
1158
|
+
self._detach(session_key, token) # never reached Hermes
|
|
1159
|
+
_TURN_AUTHZ.end_ungranted(*token)
|
|
1160
|
+
elif token is not None:
|
|
1161
|
+
self._release_if_idle(session_key, None)
|
|
1162
|
+
raise
|
|
1163
|
+
if token is not None:
|
|
1164
|
+
# (b)/(c): released now only if nothing in the session can still
|
|
1165
|
+
# be processing it; else at a later idle completion (d)/(e).
|
|
1166
|
+
self._release_if_idle(session_key, None)
|
|
914
1167
|
return event
|
|
915
1168
|
|
|
1169
|
+
@staticmethod
|
|
1170
|
+
def _detach(session_key: Optional[str], token: tuple) -> None:
|
|
1171
|
+
waiting = _ATTACHED.get(session_key) if session_key is not None else None
|
|
1172
|
+
if not waiting:
|
|
1173
|
+
return
|
|
1174
|
+
for i, (t, _) in enumerate(waiting):
|
|
1175
|
+
if t is token:
|
|
1176
|
+
del waiting[i]
|
|
1177
|
+
break
|
|
1178
|
+
if not waiting:
|
|
1179
|
+
_ATTACHED.pop(session_key, None)
|
|
1180
|
+
|
|
916
1181
|
def _may_look_up(self, channel_id: str) -> bool:
|
|
917
1182
|
"""Negative cache for unknown channels: True at most once per
|
|
918
1183
|
_UNKNOWN_CHANNEL_RETRY_S per channel (bounded map, oldest evicted)."""
|
|
@@ -945,12 +1210,14 @@ class SecureComsAdapter(BasePlatformAdapter):
|
|
|
945
1210
|
def toolsets_for_source(self, source) -> Optional[list]:
|
|
946
1211
|
"""Hermes per-turn toolset override (gateway run_turn
|
|
947
1212
|
_resolve_enabled_toolsets_for_source). None keeps the configured
|
|
948
|
-
toolsets: only for a (chat, sender) whose latest turn was
|
|
949
|
-
tools
|
|
950
|
-
|
|
1213
|
+
toolsets: only for a (chat, sender, thread) whose latest turn was
|
|
1214
|
+
granted tools and that has no ungranted turn still in flight (H0).
|
|
1215
|
+
Anything else, including an unknown source and every turn in the
|
|
1216
|
+
non-owner DM thread, gets the no-tools override."""
|
|
951
1217
|
chat_id = getattr(source, "chat_id", None)
|
|
952
1218
|
user_id = getattr(source, "user_id", None)
|
|
953
|
-
|
|
1219
|
+
thread_id = getattr(source, "thread_id", None)
|
|
1220
|
+
if _TURN_AUTHZ.tools_allowed(chat_id, user_id, thread_id):
|
|
954
1221
|
return None
|
|
955
1222
|
return list(securecoms_authz.NO_TOOLS_TOOLSETS)
|
|
956
1223
|
|
|
@@ -988,6 +1255,15 @@ class SecureComsAdapter(BasePlatformAdapter):
|
|
|
988
1255
|
) -> SendResult:
|
|
989
1256
|
if not self._session or self._session.closed:
|
|
990
1257
|
return SendResult(success=False, error="not connected")
|
|
1258
|
+
if chat_id == OWNER_TARGET:
|
|
1259
|
+
# Cron live lane (Item A): the home channel literal resolves only
|
|
1260
|
+
# from the manifest in force; a proactive send answers no turn, so
|
|
1261
|
+
# it carries no in_reply_to and no idempotency_key.
|
|
1262
|
+
resolved = await _owner_dm_channel_id(self._session, self.bridge_url, self._token)
|
|
1263
|
+
if resolved is None:
|
|
1264
|
+
return SendResult(success=False, error=OWNER_DM_UNKNOWN_ERROR)
|
|
1265
|
+
chat_id = resolved
|
|
1266
|
+
reply_to = None
|
|
991
1267
|
block = self._block_key(reply_to, content) if reply_to else None
|
|
992
1268
|
try:
|
|
993
1269
|
async with self._session.post(
|
|
@@ -1019,11 +1295,39 @@ class SecureComsAdapter(BasePlatformAdapter):
|
|
|
1019
1295
|
await self._status.on_typing(chat_id)
|
|
1020
1296
|
|
|
1021
1297
|
async def send_image(self, chat_id: str, image_url: str, caption: str = "", **kwargs) -> SendResult:
|
|
1298
|
+
if chat_id == OWNER_TARGET:
|
|
1299
|
+
# A proactive send never carries a file (Item A, live lane).
|
|
1300
|
+
return SendResult(success=False, error=PROACTIVE_MEDIA_ERROR)
|
|
1022
1301
|
# The daemon's text send is the only modality wired today; degrade to
|
|
1023
1302
|
# sending the URL + caption as text rather than failing the turn.
|
|
1024
1303
|
text = f"{caption}\n{image_url}".strip() if caption else image_url
|
|
1025
1304
|
return await self.send(chat_id, text)
|
|
1026
1305
|
|
|
1306
|
+
# Item A, live lane. Hermes routes a cron run's media through the adapter's
|
|
1307
|
+
# media methods, whose base stubs degrade to a notice posted through
|
|
1308
|
+
# emit_warning / emit_media_warning (-> self.send, which resolves OWNER_TARGET).
|
|
1309
|
+
# We refuse the owner literal at those two chokepoints, so a proactive send
|
|
1310
|
+
# never carries a file or a path. DO NOT override the base send_document /
|
|
1311
|
+
# send_voice / send_video / send_animation / send_image_file here: Hermes's
|
|
1312
|
+
# _send_live_adapter_media treats "type(adapter).<m> is BasePlatformAdapter.<m>"
|
|
1313
|
+
# as "not implemented natively", and an override makes a normal channel
|
|
1314
|
+
# delegate to the base stub, which posts a fallback notice and reports a
|
|
1315
|
+
# false success. send_image above is the one pre-existing override.
|
|
1316
|
+
async def emit_warning(self, *args, **kwargs):
|
|
1317
|
+
return await self._owner_refused_warning("emit_warning", args, kwargs)
|
|
1318
|
+
|
|
1319
|
+
async def emit_media_warning(self, *args, **kwargs):
|
|
1320
|
+
return await self._owner_refused_warning("emit_media_warning", args, kwargs)
|
|
1321
|
+
|
|
1322
|
+
async def _owner_refused_warning(self, method_name, args, kwargs):
|
|
1323
|
+
target = kwargs["chat_id"] if "chat_id" in kwargs else (args[0] if args else None)
|
|
1324
|
+
if target == OWNER_TARGET:
|
|
1325
|
+
return SendResult(success=False, error=PROACTIVE_MEDIA_ERROR)
|
|
1326
|
+
base = getattr(super(), method_name, None)
|
|
1327
|
+
if base is None:
|
|
1328
|
+
return SendResult(success=False, error=PROACTIVE_MEDIA_ERROR)
|
|
1329
|
+
return await base(*args, **kwargs)
|
|
1330
|
+
|
|
1027
1331
|
async def get_chat_info(self, chat_id: str) -> Dict[str, Any]:
|
|
1028
1332
|
meta = self._channels.get(chat_id)
|
|
1029
1333
|
if not meta:
|
|
@@ -1053,6 +1357,71 @@ def check_requirements() -> bool:
|
|
|
1053
1357
|
return os.path.isfile(os.getenv("SECURECOMS_CONNECTION_FILE") or _BINDING_PATH)
|
|
1054
1358
|
|
|
1055
1359
|
|
|
1360
|
+
async def _standalone_send(
|
|
1361
|
+
pconfig,
|
|
1362
|
+
chat_id: str,
|
|
1363
|
+
message: str,
|
|
1364
|
+
*,
|
|
1365
|
+
thread_id: Optional[str] = None,
|
|
1366
|
+
media_files: Optional[list] = None,
|
|
1367
|
+
force_document: bool = False,
|
|
1368
|
+
**_ignored,
|
|
1369
|
+
) -> Dict[str, Any]:
|
|
1370
|
+
"""Hermes PlatformEntry.standalone_sender_fn: a cron run delivered with no
|
|
1371
|
+
live adapter in this process (tools/send_message_tool.py:575-591). Returns
|
|
1372
|
+
{"success": True, "message_id": ...} or {"error": <fixed text>}.
|
|
1373
|
+
|
|
1374
|
+
The daemon's address and bearer come the way connect() gets them: the
|
|
1375
|
+
binding resolves the identity, then probe_and_gate_bound reads the
|
|
1376
|
+
connection and token files and runs the two-anchor gate. `pconfig` holds
|
|
1377
|
+
nothing for SecureComs. OWNER_TARGET resolves via GET /v1/owner-dm (a
|
|
1378
|
+
failure is an error, never a guess); any other chat id passes through.
|
|
1379
|
+
No in_reply_to, no idempotency_key (OQ-A2). A non-empty media_files is
|
|
1380
|
+
refused before anything is read or posted (no turn, no grant: item B).
|
|
1381
|
+
thread_id and force_document are ignored.
|
|
1382
|
+
"""
|
|
1383
|
+
del pconfig, thread_id, force_document, _ignored
|
|
1384
|
+
if media_files:
|
|
1385
|
+
return {"error": PROACTIVE_MEDIA_ERROR}
|
|
1386
|
+
bound_identity = resolve_bound_identity(os.environ, _read_file_text, _BINDING_PATH)
|
|
1387
|
+
if not bound_identity:
|
|
1388
|
+
return {"error": PROACTIVE_UNAVAILABLE_ERROR}
|
|
1389
|
+
session = _new_client_session()
|
|
1390
|
+
try:
|
|
1391
|
+
target, _reason = await securecoms_transport.probe_and_gate_bound(
|
|
1392
|
+
session, bound_identity, _read_file_text, _CONNECTIONS_DIR
|
|
1393
|
+
)
|
|
1394
|
+
if target is None:
|
|
1395
|
+
return {"error": PROACTIVE_UNAVAILABLE_ERROR}
|
|
1396
|
+
channel_id = chat_id
|
|
1397
|
+
if chat_id == OWNER_TARGET:
|
|
1398
|
+
channel_id = await _owner_dm_channel_id(session, target.bridge_url, target.token)
|
|
1399
|
+
if channel_id is None:
|
|
1400
|
+
return {"error": OWNER_DM_UNKNOWN_ERROR}
|
|
1401
|
+
async with session.post(
|
|
1402
|
+
f"{target.bridge_url}/v1/send",
|
|
1403
|
+
headers={"Authorization": f"Bearer {target.token}"},
|
|
1404
|
+
json={"channel_id": channel_id, "text": message},
|
|
1405
|
+
) as r:
|
|
1406
|
+
if 200 <= r.status < 300:
|
|
1407
|
+
# Posted: a body we cannot parse never turns that into an error.
|
|
1408
|
+
try:
|
|
1409
|
+
j = await r.json()
|
|
1410
|
+
except Exception:
|
|
1411
|
+
j = None
|
|
1412
|
+
mid = j.get("message_id") if isinstance(j, dict) else None
|
|
1413
|
+
return {"success": True, "message_id": str(mid) if isinstance(mid, (str, int)) and mid != "" else None}
|
|
1414
|
+
return {"error": f"SecureComs scheduled send failed (HTTP {int(r.status)})."}
|
|
1415
|
+
except Exception as e:
|
|
1416
|
+
logger.debug("SecureComs: scheduled send failed (error_class=%s)", _error_class(e))
|
|
1417
|
+
return {"error": PROACTIVE_UNAVAILABLE_ERROR}
|
|
1418
|
+
finally:
|
|
1419
|
+
try:
|
|
1420
|
+
await session.close()
|
|
1421
|
+
except Exception:
|
|
1422
|
+
pass
|
|
1423
|
+
|
|
1424
|
+
|
|
1056
1425
|
def _securecoms_pre_tool_call(**_kwargs):
|
|
1057
1426
|
"""Hermes pre_tool_call hook: block every tool call in a SecureComs turn
|
|
1058
1427
|
that the daemon did not grant tools, and any call whose turn identity is
|
|
@@ -1097,7 +1466,21 @@ def _install_tool_veto(ctx) -> bool:
|
|
|
1097
1466
|
):
|
|
1098
1467
|
return False
|
|
1099
1468
|
from gateway import session_context
|
|
1100
|
-
|
|
1469
|
+
|
|
1470
|
+
# Read the turn's task-local ContextVars directly: Hermes's own
|
|
1471
|
+
# get_session_env falls back to os.environ, which a concurrent turn or
|
|
1472
|
+
# the process can set. Unset (_UNSET) reads as None, never as a value.
|
|
1473
|
+
var_map = getattr(session_context, "_VAR_MAP", None)
|
|
1474
|
+
unset = getattr(session_context, "_UNSET", None)
|
|
1475
|
+
names = ("HERMES_SESSION_PLATFORM", "HERMES_SESSION_CHAT_ID", "HERMES_SESSION_USER_ID", "HERMES_SESSION_THREAD_ID")
|
|
1476
|
+
if unset is None or not isinstance(var_map, dict) or not all(
|
|
1477
|
+
callable(getattr(var_map.get(n), "get", None)) for n in names
|
|
1478
|
+
):
|
|
1479
|
+
return False
|
|
1480
|
+
|
|
1481
|
+
def get_session_env(name, default=""):
|
|
1482
|
+
value = var_map[name].get()
|
|
1483
|
+
return None if value is unset else value
|
|
1101
1484
|
|
|
1102
1485
|
engaged = getattr(session_context, "session_context_engaged", None)
|
|
1103
1486
|
_SESSION_ENGAGED = engaged if callable(engaged) else None
|
|
@@ -1127,6 +1510,9 @@ def _install_tool_veto(ctx) -> bool:
|
|
|
1127
1510
|
return False
|
|
1128
1511
|
|
|
1129
1512
|
|
|
1513
|
+
_CRON_REGISTER_KWARGS = ("cron_deliver_env_var", "standalone_sender_fn")
|
|
1514
|
+
|
|
1515
|
+
|
|
1130
1516
|
def register(ctx):
|
|
1131
1517
|
"""Plugin entry point: called by the Hermes plugin system."""
|
|
1132
1518
|
global _TOOL_VETO_ACTIVE
|
|
@@ -1135,7 +1521,7 @@ def register(ctx):
|
|
|
1135
1521
|
logger.warning(
|
|
1136
1522
|
"SecureComs: tool veto inactive; turns without a tool grant will be refused"
|
|
1137
1523
|
)
|
|
1138
|
-
|
|
1524
|
+
platform_kwargs = dict(
|
|
1139
1525
|
name="securecoms",
|
|
1140
1526
|
label="SecureComs",
|
|
1141
1527
|
adapter_factory=lambda cfg: SecureComsAdapter(cfg),
|
|
@@ -1153,3 +1539,21 @@ def register(ctx):
|
|
|
1153
1539
|
"never sees plaintext. Respond normally and conversationally."
|
|
1154
1540
|
),
|
|
1155
1541
|
)
|
|
1542
|
+
# Item A: cron delivery. The env var holds the cron home channel (`wire`
|
|
1543
|
+
# sets OWNER_TARGET); the standalone sender delivers a cron run when no
|
|
1544
|
+
# live adapter is in this process.
|
|
1545
|
+
cron_kwargs = dict(
|
|
1546
|
+
cron_deliver_env_var=CRON_HOME_CHANNEL_ENV,
|
|
1547
|
+
standalone_sender_fn=_standalone_send,
|
|
1548
|
+
)
|
|
1549
|
+
try:
|
|
1550
|
+
ctx.register_platform(**platform_kwargs, **cron_kwargs)
|
|
1551
|
+
except TypeError as e:
|
|
1552
|
+
# A Hermes older than PlatformEntry.cron_deliver_env_var /
|
|
1553
|
+
# standalone_sender_fn rejects them as unexpected keywords. Register
|
|
1554
|
+
# without them so chat keeps working; only cron delivery is missing.
|
|
1555
|
+
# Any other TypeError (or any other exception) propagates unchanged.
|
|
1556
|
+
if not any(name in str(e) for name in _CRON_REGISTER_KWARGS):
|
|
1557
|
+
raise
|
|
1558
|
+
logger.warning("SecureComs: this Hermes has no cron delivery hooks; scheduled messages are off")
|
|
1559
|
+
ctx.register_platform(**platform_kwargs)
|