@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.
@@ -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
- "name": c.get("name") or c["channel_id"],
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 base behaviour."""
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
- _TURN_AUTHZ.record(channel_id, sender_id, tools_allowed)
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
- chat_name=meta.get("name", channel_id),
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
- user_name=sender_id,
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
- event._securecoms_session_was_busy = self._session_was_busy(event)
913
- await self.handle_message(event)
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 granted
949
- tools. Anything else, including an unknown source, gets the no-tools
950
- override."""
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
- if _TURN_AUTHZ.tools_allowed(chat_id, user_id):
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
- from gateway.session_context import get_session_env
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
- ctx.register_platform(
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)