@securecoms/agent-bridge 0.39.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 CHANGED
@@ -973,6 +973,22 @@ copied after the owner's next typed message.
973
973
 
974
974
  ---
975
975
 
976
+ ## Release notes: 0.40.0
977
+
978
+ <!-- [COPY, approved by Eric 2026-11-12 ("Notes OK")] -->
979
+
980
+ #### What's new
981
+ - **Scheduled messages reach you.** Reminders, scheduled jobs and check-ins from your OpenClaw or Hermes agent now arrive in your direct messages with the agent. Before this release, OpenClaw showed "SecureComs Owner" as a place to send to, but sending there always failed.
982
+ - **Only to you.** A scheduled message goes to your direct messages only after your agent has your signed owner settings. Without them, nothing is sent.
983
+ - **Text only.** A scheduled message carries text only. If a scheduled job tries to send a file, the file is not sent.
984
+
985
+ #### Upgrade
986
+ Upgrade the bridge. Copy the new plugin files from the bridge's `connectors` folder. Then restart your agent.
987
+
988
+ For Hermes, run `agent-bridge wire --harness hermes` again. If you use a Hermes profile, add this line to that profile's `.env` file: `SECURECOMS_HOME_CHANNEL=owner`.
989
+
990
+ If you go back to 0.39.0, scheduled messages to "owner" fail again.
991
+
976
992
  ## Release notes: 0.39.0
977
993
 
978
994
  <!-- [COPY, approved by Eric 2026-11-12 ("notes OK")] -->
@@ -278,6 +278,56 @@ def _new_client_session():
278
278
  return aiohttp.ClientSession()
279
279
 
280
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
+
281
331
  class SecureComsAdapter(BasePlatformAdapter):
282
332
  """Hermes platform adapter fronting the local SecureComs daemon."""
283
333
 
@@ -1205,6 +1255,15 @@ class SecureComsAdapter(BasePlatformAdapter):
1205
1255
  ) -> SendResult:
1206
1256
  if not self._session or self._session.closed:
1207
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
1208
1267
  block = self._block_key(reply_to, content) if reply_to else None
1209
1268
  try:
1210
1269
  async with self._session.post(
@@ -1236,11 +1295,39 @@ class SecureComsAdapter(BasePlatformAdapter):
1236
1295
  await self._status.on_typing(chat_id)
1237
1296
 
1238
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)
1239
1301
  # The daemon's text send is the only modality wired today; degrade to
1240
1302
  # sending the URL + caption as text rather than failing the turn.
1241
1303
  text = f"{caption}\n{image_url}".strip() if caption else image_url
1242
1304
  return await self.send(chat_id, text)
1243
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
+
1244
1331
  async def get_chat_info(self, chat_id: str) -> Dict[str, Any]:
1245
1332
  meta = self._channels.get(chat_id)
1246
1333
  if not meta:
@@ -1270,6 +1357,71 @@ def check_requirements() -> bool:
1270
1357
  return os.path.isfile(os.getenv("SECURECOMS_CONNECTION_FILE") or _BINDING_PATH)
1271
1358
 
1272
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
+
1273
1425
  def _securecoms_pre_tool_call(**_kwargs):
1274
1426
  """Hermes pre_tool_call hook: block every tool call in a SecureComs turn
1275
1427
  that the daemon did not grant tools, and any call whose turn identity is
@@ -1358,6 +1510,9 @@ def _install_tool_veto(ctx) -> bool:
1358
1510
  return False
1359
1511
 
1360
1512
 
1513
+ _CRON_REGISTER_KWARGS = ("cron_deliver_env_var", "standalone_sender_fn")
1514
+
1515
+
1361
1516
  def register(ctx):
1362
1517
  """Plugin entry point: called by the Hermes plugin system."""
1363
1518
  global _TOOL_VETO_ACTIVE
@@ -1366,7 +1521,7 @@ def register(ctx):
1366
1521
  logger.warning(
1367
1522
  "SecureComs: tool veto inactive; turns without a tool grant will be refused"
1368
1523
  )
1369
- ctx.register_platform(
1524
+ platform_kwargs = dict(
1370
1525
  name="securecoms",
1371
1526
  label="SecureComs",
1372
1527
  adapter_factory=lambda cfg: SecureComsAdapter(cfg),
@@ -1384,3 +1539,21 @@ def register(ctx):
1384
1539
  "never sees plaintext. Respond normally and conversationally."
1385
1540
  ),
1386
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)
@@ -2241,3 +2241,432 @@ class TestDeliveryLedger:
2241
2241
  def test_defaults(self):
2242
2242
  assert adapter._DEDUP_MAX_ENTRIES == 2048
2243
2243
  assert adapter._DEDUP_TTL_S == 600.0
2244
+
2245
+
2246
+ # ---------------------------------------------------------------------------
2247
+ # Item A (R4): cron / proactive delivery to the owner DM.
2248
+ #
2249
+ # Hermes v2026.9.24 (Mini, read-only) delivers a cron run two ways:
2250
+ # - live lane: adapter.send(chat_id=<home channel>, content, metadata={"job_id": ...})
2251
+ # (cron/scheduler_delivery.py:1447, :1611);
2252
+ # - out of process: PlatformEntry.standalone_sender_fn(pconfig, chat_id, message, *,
2253
+ # thread_id=None, media_files=None, force_document=False) -> dict with "success" or
2254
+ # "error" (tools/send_message_tool.py:575-591).
2255
+ # The home channel comes from the env var named by cron_deliver_env_var. `agent-bridge
2256
+ # wire --harness hermes` sets it to the literal "owner", which both lanes resolve through
2257
+ # the bridge's GET /v1/owner-dm (the signed manifest in force) and never send verbatim.
2258
+ # ---------------------------------------------------------------------------
2259
+
2260
+
2261
+ class _RoutedResp:
2262
+ def __init__(self, status, body):
2263
+ self.status = status
2264
+ self._body = body
2265
+
2266
+ async def json(self):
2267
+ if isinstance(self._body, Exception):
2268
+ raise self._body
2269
+ return self._body
2270
+
2271
+ async def text(self):
2272
+ return self._body if isinstance(self._body, str) else json.dumps(self._body)
2273
+
2274
+
2275
+ class _RoutedCall:
2276
+ """Awaitable (securecoms_transport's probe does `await session.get`) and an
2277
+ async context manager (`async with session.get(...)`)."""
2278
+
2279
+ def __init__(self, resp):
2280
+ self._resp = resp
2281
+
2282
+ def __await__(self):
2283
+ async def _r():
2284
+ if isinstance(self._resp, Exception):
2285
+ raise self._resp
2286
+ return self._resp
2287
+
2288
+ return _r().__await__()
2289
+
2290
+ async def __aenter__(self):
2291
+ if isinstance(self._resp, Exception):
2292
+ raise self._resp
2293
+ return self._resp
2294
+
2295
+ async def __aexit__(self, *exc):
2296
+ return False
2297
+
2298
+
2299
+ class RoutedSession:
2300
+ """Routes by path. owner_dm / send are (status, body) or an Exception."""
2301
+
2302
+ def __init__(self, owner_dm=(200, {"channel_id": "dm-1"}), send=(200, {"message_id": "m-9"}),
2303
+ healthz=None):
2304
+ self._owner_dm = owner_dm
2305
+ self._send = send
2306
+ self._healthz = healthz if healthz is not None else {"identity_id": "A"}
2307
+ self.closed = False
2308
+ self.get_calls = [] # (url, headers)
2309
+ self.post_calls = [] # (url, headers, json)
2310
+
2311
+ @staticmethod
2312
+ def _resp(spec):
2313
+ if isinstance(spec, Exception):
2314
+ return spec
2315
+ return _RoutedResp(*spec)
2316
+
2317
+ def get(self, url, headers=None):
2318
+ self.get_calls.append((url, headers))
2319
+ if url.endswith("/v1/owner-dm"):
2320
+ return _RoutedCall(self._resp(self._owner_dm))
2321
+ if url.endswith("/healthz"):
2322
+ return _RoutedCall(_RoutedResp(200, self._healthz))
2323
+ return _RoutedCall(ConnectionError("unrouted"))
2324
+
2325
+ def post(self, url, headers=None, json=None):
2326
+ self.post_calls.append((url, headers, json))
2327
+ if url.endswith("/v1/send"):
2328
+ return _RoutedCall(self._resp(self._send))
2329
+ return _RoutedCall(ConnectionError("unrouted"))
2330
+
2331
+ def sends(self):
2332
+ return [c for c in self.post_calls if c[0].endswith("/v1/send")]
2333
+
2334
+ async def close(self):
2335
+ self.closed = True
2336
+
2337
+
2338
+ BRIDGE = "http://127.0.0.1:9001"
2339
+ BEARER = {"Authorization": "Bearer tok-A"}
2340
+
2341
+
2342
+ def _connected_adapter(monkeypatch, session):
2343
+ a, _ = make_adapter(monkeypatch)
2344
+ a._session = session
2345
+ a.bridge_url = BRIDGE
2346
+ a._token = "tok-A"
2347
+ return a
2348
+
2349
+
2350
+ def _wired_fs(monkeypatch, session):
2351
+ """A wired host as the standalone sender sees it: binding, connection
2352
+ file and token file on disk, a fresh session per send."""
2353
+ files = {
2354
+ BINDING_PATH: binding("A"),
2355
+ os.path.join(CONNECTIONS_DIR, "A.json"): conn_file(),
2356
+ "/tok/A": "tok-A\n",
2357
+ }
2358
+ monkeypatch.setattr(adapter, "_read_file_text", make_fs(files))
2359
+ monkeypatch.setattr(adapter, "_BINDING_PATH", BINDING_PATH)
2360
+ monkeypatch.setattr(adapter, "_CONNECTIONS_DIR", CONNECTIONS_DIR)
2361
+ monkeypatch.delenv("SECURECOMS_CONNECTION_FILE", raising=False)
2362
+ monkeypatch.setattr(adapter, "_new_client_session", lambda: session)
2363
+
2364
+
2365
+ class TestCronRegistration:
2366
+ """A.3: register(ctx) hands Hermes the out-of-process cron seams."""
2367
+
2368
+ def test_register_platform_carries_cron_env_var_and_standalone_sender(self, monkeypatch, fresh_gate):
2369
+ _install_hermes_modules(monkeypatch)
2370
+ ctx = _FakeCtx()
2371
+ adapter.register(ctx)
2372
+ assert len(ctx.platforms) == 1
2373
+ kw = ctx.platforms[0]
2374
+ assert kw["cron_deliver_env_var"] == "SECURECOMS_HOME_CHANNEL"
2375
+ assert callable(kw["standalone_sender_fn"])
2376
+ assert kw["standalone_sender_fn"] is adapter._standalone_send
2377
+
2378
+
2379
+ class TestCronLiveLane:
2380
+ """A.6 live lane: adapter.send(chat_id="owner") resolves via GET /v1/owner-dm."""
2381
+
2382
+ @pytest.mark.asyncio
2383
+ async def test_owner_resolves_to_the_manifest_dm_and_never_posts_owner(self, monkeypatch):
2384
+ s = RoutedSession()
2385
+ a = _connected_adapter(monkeypatch, s)
2386
+ r = await a.send(chat_id="owner", content="x", metadata={"job_id": "j1"})
2387
+ assert r.success is True
2388
+ assert s.get_calls == [(f"{BRIDGE}/v1/owner-dm", BEARER)]
2389
+ assert s.sends() == [(f"{BRIDGE}/v1/send", BEARER, {"channel_id": "dm-1", "text": "x"})]
2390
+
2391
+ @pytest.mark.asyncio
2392
+ async def test_job_id_never_becomes_an_idempotency_key(self, monkeypatch):
2393
+ s = RoutedSession()
2394
+ a = _connected_adapter(monkeypatch, s)
2395
+ await a.send(chat_id="owner", content="x", metadata={"job_id": "j1"})
2396
+ await a.send(chat_id="owner", content="x", metadata={"job_id": "j1"})
2397
+ bodies = [c[2] for c in s.sends()]
2398
+ assert len(bodies) == 2
2399
+ for b in bodies:
2400
+ assert "idempotency_key" not in b
2401
+ assert "in_reply_to" not in b
2402
+ assert all("j1" not in str(v) for v in b.values())
2403
+
2404
+ @pytest.mark.asyncio
2405
+ async def test_owner_with_reply_to_still_posts_no_reply_receipt(self, monkeypatch):
2406
+ s = RoutedSession()
2407
+ a = _connected_adapter(monkeypatch, s)
2408
+ await a.send(chat_id="owner", content="x", reply_to="m-1")
2409
+ assert s.sends() == [(f"{BRIDGE}/v1/send", BEARER, {"channel_id": "dm-1", "text": "x"})]
2410
+
2411
+ @pytest.mark.asyncio
2412
+ @pytest.mark.parametrize("owner_dm", [
2413
+ (404, {"error": "owner_dm_unknown"}),
2414
+ (500, "boom"),
2415
+ (200, {"channel_id": "owner"}),
2416
+ (200, {"channel_id": "default"}),
2417
+ (200, {"channel_id": ""}),
2418
+ (200, {"channel_id": 7}),
2419
+ (200, ["dm-1"]),
2420
+ (200, ValueError("not json")),
2421
+ ConnectionError("refused"),
2422
+ ], ids=["404", "500", "answer-owner", "answer-default", "empty", "non-string", "list-body",
2423
+ "bad-json", "network"])
2424
+ async def test_unresolved_owner_posts_nothing_and_fails_with_the_fixed_error(self, monkeypatch, owner_dm):
2425
+ s = RoutedSession(owner_dm=owner_dm)
2426
+ a = _connected_adapter(monkeypatch, s)
2427
+ r = await a.send(chat_id="owner", content="secret text", metadata={"job_id": "j1"})
2428
+ assert r.success is False
2429
+ assert r.error == adapter.OWNER_DM_UNKNOWN_ERROR
2430
+ assert s.post_calls == []
2431
+
2432
+ @pytest.mark.asyncio
2433
+ async def test_an_explicit_channel_id_is_unchanged(self, monkeypatch):
2434
+ s = RoutedSession()
2435
+ a = _connected_adapter(monkeypatch, s)
2436
+ cid = "5b7f3a2e-0000-4000-8000-000000000001"
2437
+ r = await a.send(chat_id=cid, content="x", metadata={"job_id": "j1"})
2438
+ assert r.success is True
2439
+ assert s.get_calls == []
2440
+ assert s.sends() == [(f"{BRIDGE}/v1/send", BEARER, {"channel_id": cid, "text": "x"})]
2441
+
2442
+
2443
+ class TestCronStandaloneSender:
2444
+ """A.6 standalone lane: _standalone_send, out of process, no live adapter."""
2445
+
2446
+ @pytest.mark.asyncio
2447
+ async def test_one_post_with_bearer_and_resolved_channel(self, monkeypatch):
2448
+ s = RoutedSession()
2449
+ _wired_fs(monkeypatch, s)
2450
+ r = await adapter._standalone_send(None, "owner", "x", thread_id="t-1", force_document=True)
2451
+ assert r == {"success": True, "message_id": "m-9"}
2452
+ assert (f"{BRIDGE}/v1/owner-dm", BEARER) in s.get_calls
2453
+ assert s.post_calls == [(f"{BRIDGE}/v1/send", BEARER, {"channel_id": "dm-1", "text": "x"})]
2454
+ assert s.closed is True
2455
+
2456
+ @pytest.mark.asyncio
2457
+ async def test_explicit_channel_passes_through_without_a_lookup(self, monkeypatch):
2458
+ s = RoutedSession()
2459
+ _wired_fs(monkeypatch, s)
2460
+ r = await adapter._standalone_send(None, "chan-7", "x")
2461
+ assert r["success"] is True
2462
+ assert all(not u.endswith("/v1/owner-dm") for u, _ in s.get_calls)
2463
+ assert s.post_calls == [(f"{BRIDGE}/v1/send", BEARER, {"channel_id": "chan-7", "text": "x"})]
2464
+
2465
+ @pytest.mark.asyncio
2466
+ async def test_owner_dm_404_posts_nothing_and_returns_the_error_dict(self, monkeypatch):
2467
+ s = RoutedSession(owner_dm=(404, {"error": "owner_dm_unknown"}))
2468
+ _wired_fs(monkeypatch, s)
2469
+ r = await adapter._standalone_send(None, "owner", "secret text")
2470
+ assert r == {"error": adapter.OWNER_DM_UNKNOWN_ERROR}
2471
+ assert s.post_calls == []
2472
+ assert s.closed is True
2473
+
2474
+ @pytest.mark.asyncio
2475
+ @pytest.mark.parametrize("send", [(500, "internal detail"), (403, {"error": "not_member"}),
2476
+ ConnectionError("refused")], ids=["500", "403", "network"])
2477
+ async def test_a_failed_send_returns_the_error_dict_not_success(self, monkeypatch, send):
2478
+ s = RoutedSession(send=send)
2479
+ _wired_fs(monkeypatch, s)
2480
+ r = await adapter._standalone_send(None, "owner", "secret text")
2481
+ assert set(r) == {"error"}
2482
+ assert isinstance(r["error"], str) and r["error"]
2483
+ assert "secret text" not in r["error"] and "dm-1" not in r["error"]
2484
+ assert "internal detail" not in r["error"]
2485
+
2486
+ @pytest.mark.asyncio
2487
+ async def test_the_body_never_carries_in_reply_to_or_an_idempotency_key(self, monkeypatch):
2488
+ s = RoutedSession()
2489
+ _wired_fs(monkeypatch, s)
2490
+ await adapter._standalone_send(None, "owner", "x")
2491
+ await adapter._standalone_send(None, "chan-7", "x")
2492
+ for _, _, body in s.post_calls:
2493
+ assert set(body) == {"channel_id", "text"}
2494
+
2495
+ @pytest.mark.asyncio
2496
+ async def test_media_files_post_nothing_and_return_the_fixed_error(self, monkeypatch):
2497
+ s = RoutedSession()
2498
+ _wired_fs(monkeypatch, s)
2499
+ r = await adapter._standalone_send(None, "owner", "x", media_files=["/x.png"])
2500
+ assert r == {"error": adapter.PROACTIVE_MEDIA_ERROR}
2501
+ assert s.post_calls == []
2502
+ assert s.get_calls == []
2503
+
2504
+ @pytest.mark.asyncio
2505
+ async def test_unwired_host_posts_nothing(self, monkeypatch):
2506
+ s = RoutedSession()
2507
+ _wired_fs(monkeypatch, s)
2508
+ monkeypatch.setattr(adapter, "_read_file_text", make_fs({}))
2509
+ r = await adapter._standalone_send(None, "owner", "x")
2510
+ assert set(r) == {"error"}
2511
+ assert s.post_calls == []
2512
+
2513
+ @pytest.mark.asyncio
2514
+ async def test_a_gate_mismatch_posts_nothing(self, monkeypatch):
2515
+ # The daemon at the connection file's address answers as another
2516
+ # identity: the two-anchor gate refuses, exactly as for connect().
2517
+ s = RoutedSession(healthz={"identity_id": "B"})
2518
+ _wired_fs(monkeypatch, s)
2519
+ r = await adapter._standalone_send(None, "owner", "x")
2520
+ assert set(r) == {"error"}
2521
+ assert s.post_calls == []
2522
+ assert s.closed is True
2523
+
2524
+ def test_the_fixed_errors_carry_no_channel_id_or_text(self):
2525
+ for e in (adapter.OWNER_DM_UNKNOWN_ERROR, adapter.PROACTIVE_MEDIA_ERROR, adapter.PROACTIVE_UNAVAILABLE_ERROR):
2526
+ assert isinstance(e, str) and e
2527
+ assert "{" not in e and "%" not in e
2528
+
2529
+
2530
+ class _OldHermesCtx(_FakeCtx):
2531
+ """register_platform as a Hermes whose PlatformEntry predates the cron
2532
+ fields: an unknown keyword raises TypeError, like a dataclass __init__."""
2533
+
2534
+ def __init__(self, error=None):
2535
+ super().__init__()
2536
+ self.calls = []
2537
+ self._error = error
2538
+
2539
+ def register_platform(self, **kw):
2540
+ self.calls.append(kw)
2541
+ if self._error is not None:
2542
+ raise self._error
2543
+ for name in ("cron_deliver_env_var", "standalone_sender_fn"):
2544
+ if name in kw:
2545
+ raise TypeError(f"PlatformEntry.__init__() got an unexpected keyword argument '{name}'")
2546
+ self.platforms.append(kw)
2547
+
2548
+
2549
+ class TestCronRegistrationFallback:
2550
+ """An older Hermes still loads the plugin (chat works); only cron is off."""
2551
+
2552
+ def test_old_hermes_registers_via_the_fallback(self, monkeypatch, fresh_gate, caplog):
2553
+ _install_hermes_modules(monkeypatch)
2554
+ ctx = _OldHermesCtx()
2555
+ with caplog.at_level("WARNING", logger=adapter.logger.name):
2556
+ adapter.register(ctx)
2557
+ assert len(ctx.calls) == 2
2558
+ assert len(ctx.platforms) == 1
2559
+ kw = ctx.platforms[0]
2560
+ assert kw["name"] == "securecoms"
2561
+ assert callable(kw["adapter_factory"])
2562
+ assert kw["check_fn"] is adapter.check_requirements
2563
+ assert "cron_deliver_env_var" not in kw and "standalone_sender_fn" not in kw
2564
+ assert any("no cron delivery hooks" in r.getMessage() for r in caplog.records)
2565
+
2566
+ def test_current_hermes_registers_once_with_the_cron_kwargs(self, monkeypatch, fresh_gate, caplog):
2567
+ _install_hermes_modules(monkeypatch)
2568
+ ctx = _FakeCtx()
2569
+ with caplog.at_level("WARNING", logger=adapter.logger.name):
2570
+ adapter.register(ctx)
2571
+ assert len(ctx.platforms) == 1
2572
+ assert ctx.platforms[0]["standalone_sender_fn"] is adapter._standalone_send
2573
+ assert not any("no cron delivery hooks" in r.getMessage() for r in caplog.records)
2574
+
2575
+ @pytest.mark.parametrize("error", [TypeError("adapter_factory must be callable"), ValueError("standalone_sender_fn must be a coroutine function")],
2576
+ ids=["unrelated-TypeError", "ValueError-naming-a-cron-kwarg"])
2577
+ def test_any_other_error_propagates_without_a_retry(self, monkeypatch, fresh_gate, error):
2578
+ _install_hermes_modules(monkeypatch)
2579
+ ctx = _OldHermesCtx(error=error)
2580
+ with pytest.raises(type(error)):
2581
+ adapter.register(ctx)
2582
+ assert len(ctx.calls) == 1
2583
+
2584
+
2585
+ class TestCronStandaloneSuccessShapes:
2586
+ """Review r1 #2/#3: a 2xx is a sent message, whatever its body."""
2587
+
2588
+ @pytest.mark.asyncio
2589
+ @pytest.mark.parametrize("send", [(200, ValueError("not json")), (200, {}), (200, "ok"), (201, {"message_id": "m-2"})],
2590
+ ids=["200-bad-json", "200-no-id", "200-non-dict", "201"])
2591
+ async def test_a_2xx_is_success_and_never_the_string_none(self, monkeypatch, send):
2592
+ s = RoutedSession(send=send)
2593
+ _wired_fs(monkeypatch, s)
2594
+ r = await adapter._standalone_send(None, "owner", "x")
2595
+ assert r.get("success") is True
2596
+ assert "error" not in r
2597
+ assert r.get("message_id") != "None"
2598
+ assert len(s.post_calls) == 1
2599
+
2600
+
2601
+ # Review r1 #1: "a proactive send never carries a file, in any lane". On the
2602
+ # live lane Hermes degrades media through the base emit_warning /
2603
+ # emit_media_warning, so those two refuse the "owner" literal. The base
2604
+ # send_* media methods must NOT be overridden (Hermes identity check).
2605
+ _BASE_MEDIA_METHODS = ("send_image_file", "send_document", "send_voice", "send_video", "send_animation")
2606
+
2607
+
2608
+ @pytest.fixture
2609
+ def base_warnings(monkeypatch):
2610
+ """Give the stub base emit_warning / emit_media_warning, recording calls."""
2611
+ base = adapter.BasePlatformAdapter
2612
+ calls = []
2613
+
2614
+ async def emit_warning(self, chat_id, content, *, reply_to=None, metadata=None, logical_platform=None):
2615
+ calls.append(("emit_warning", chat_id, content, reply_to, metadata))
2616
+ return await self.send(chat_id, content)
2617
+
2618
+ async def emit_media_warning(self, chat_id, content, *, caption=None, reply_to=None, metadata=None, shown_metadata=None):
2619
+ calls.append(("emit_media_warning", chat_id, content, caption, shown_metadata))
2620
+ return await self.send(chat_id, content)
2621
+
2622
+ monkeypatch.setattr(base, "emit_warning", emit_warning, raising=False)
2623
+ monkeypatch.setattr(base, "emit_media_warning", emit_media_warning, raising=False)
2624
+ return calls
2625
+
2626
+
2627
+ class TestLiveLaneMediaToOwner:
2628
+ @pytest.mark.asyncio
2629
+ async def test_send_image_to_owner_posts_nothing(self, monkeypatch):
2630
+ s = RoutedSession()
2631
+ a = _connected_adapter(monkeypatch, s)
2632
+ r = await a.send_image("owner", "https://x/y.png", caption="c")
2633
+ assert r.success is False
2634
+ assert r.error == adapter.PROACTIVE_MEDIA_ERROR
2635
+ assert s.post_calls == [] and s.get_calls == []
2636
+
2637
+ @pytest.mark.asyncio
2638
+ async def test_send_image_to_a_channel_is_unchanged(self, monkeypatch):
2639
+ s = RoutedSession()
2640
+ a = _connected_adapter(monkeypatch, s)
2641
+ r = await a.send_image("chan-7", "https://x/y.png")
2642
+ assert r.success is True
2643
+ assert s.sends() == [(f"{BRIDGE}/v1/send", BEARER, {"channel_id": "chan-7", "text": "https://x/y.png"})]
2644
+
2645
+ @pytest.mark.parametrize("name", _BASE_MEDIA_METHODS)
2646
+ def test_base_media_methods_are_not_overridden(self, name):
2647
+ # Hermes _send_live_adapter_media: an override defeats the
2648
+ # "not implemented natively" check and yields a false success.
2649
+ assert getattr(adapter.SecureComsAdapter, name, None) is getattr(adapter.BasePlatformAdapter, name, None)
2650
+
2651
+ @pytest.mark.asyncio
2652
+ @pytest.mark.parametrize("name", ["emit_warning", "emit_media_warning"])
2653
+ @pytest.mark.parametrize("as_keyword", [False, True], ids=["positional", "keyword"])
2654
+ async def test_warning_to_owner_posts_nothing(self, monkeypatch, base_warnings, name, as_keyword):
2655
+ s = RoutedSession()
2656
+ a = _connected_adapter(monkeypatch, s)
2657
+ method = getattr(a, name)
2658
+ r = await (method(chat_id="owner", content="couldn't send /secret/r.pdf") if as_keyword
2659
+ else method("owner", "couldn't send /secret/r.pdf"))
2660
+ assert r.success is False and r.error == adapter.PROACTIVE_MEDIA_ERROR
2661
+ assert s.post_calls == [] and s.get_calls == []
2662
+ assert base_warnings == []
2663
+
2664
+ @pytest.mark.asyncio
2665
+ @pytest.mark.parametrize("name", ["emit_warning", "emit_media_warning"])
2666
+ async def test_warning_to_a_channel_delegates_unchanged(self, monkeypatch, base_warnings, name):
2667
+ s = RoutedSession()
2668
+ a = _connected_adapter(monkeypatch, s)
2669
+ r = await getattr(a, name)("chan-7", "notice", reply_to="m1", metadata={"k": 1})
2670
+ assert r.success is True
2671
+ assert [c[:3] for c in base_warnings] == [(name, "chan-7", "notice")]
2672
+ assert [c[2]["channel_id"] for c in s.sends()] == ["chan-7"]
@@ -86,6 +86,13 @@ const _channels = new Map();
86
86
 
87
87
  export const MAX_INBOUND_ATTEMPTS = 3;
88
88
 
89
+ // Outbound target ids the plugin advertises. They are NOT channel ids: both
90
+ // resolve to the owner DM through the bridge. Failure text wording approved by Eric 2026-11-12;
91
+ // keep it a single constant. It must never carry a channel id or text.
92
+ export const OUTBOUND_TARGET_IDS = ["owner", "default"];
93
+ export const OWNER_DM_UNKNOWN_ERROR =
94
+ "SecureComs could not find the owner's direct message. The agent needs its signed owner settings first.";
95
+
89
96
  /**
90
97
  * Voice calls (owner report 2026-10-02): prefixes the daemon's compiled-in
91
98
  * spoken-reply guidance on a live voice turn, so a turn the voice front desk
@@ -971,6 +978,28 @@ export class LoopbackChannel {
971
978
  return r.json();
972
979
  }
973
980
 
981
+ /**
982
+ * Resolve the owner DM channel id through the bridge (GET /v1/owner-dm), which
983
+ * reads it from the owner-signed manifest in force and nowhere else. Returns
984
+ * the id, or null on ANY failure (404, non-2xx, network error, malformed
985
+ * body, empty id, or an id that is itself an advertised target name). The
986
+ * caller must not send when this returns null: no fallback, no guess.
987
+ */
988
+ async ownerDmChannelId() {
989
+ try {
990
+ const r = await fetch(`${this.bridgeUrl}/v1/owner-dm`, {
991
+ headers: { authorization: `Bearer ${this.token}` },
992
+ });
993
+ if (!r.ok) return null;
994
+ const body = await r.json();
995
+ const id = body?.channel_id;
996
+ if (typeof id !== "string" || id === "" || OUTBOUND_TARGET_IDS.includes(id)) return null;
997
+ return id;
998
+ } catch {
999
+ return null;
1000
+ }
1001
+ }
1002
+
974
1003
  // Report agent operational status to the daemon (POST /v1/status). Drives the
975
1004
  // web "… is thinking…" strip (J1, #910). A port of the runtime's reporter
976
1005
  // (packages/connect-agent-runtime/src/status-reporter.ts, #911):
@@ -1347,14 +1376,22 @@ const secureComsPlugin = {
1347
1376
  { id: "owner", label: "SecureComs Owner", accountId: "default" },
1348
1377
  { id: "default", label: "SecureComs Owner (default)", accountId: "default" },
1349
1378
  ],
1350
- // Proactive send (cron / cross-channel). Requires an explicit target channel
1351
- // id in `to`; without channel context we can't guess which SecureComs
1352
- // conversation to use, so this errors rather than mis-routing.
1379
+ // Proactive send (cron / cross-channel). "owner" and "default" are the
1380
+ // advertised targets: they resolve to the owner DM through the bridge's
1381
+ // GET /v1/owner-dm (owner-signed manifest in force) and fail closed, never
1382
+ // posting the literal name as a channel id. Any other `to` is an explicit
1383
+ // channel id, as before.
1353
1384
  sendText: async ({ text, to, accountId }) => {
1354
1385
  const ch = _channels.get(accountId ?? "default");
1355
1386
  if (!ch) return { ok: false, error: "SecureComs channel is not connected" };
1356
1387
  if (!to) return { ok: false, error: "SecureComs proactive send requires a target channel_id in `to`" };
1357
1388
  try {
1389
+ if (OUTBOUND_TARGET_IDS.includes(to)) {
1390
+ const resolved = await ch.ownerDmChannelId();
1391
+ if (!resolved) return { ok: false, error: OWNER_DM_UNKNOWN_ERROR };
1392
+ await ch.send(resolved, text);
1393
+ return { ok: true };
1394
+ }
1358
1395
  await ch.send(to, text);
1359
1396
  return { ok: true };
1360
1397
  } catch (err) {
@@ -0,0 +1,217 @@
1
+ // packages/connect-agent-bridge/connectors/openclaw/openclaw-entry.outbound.test.ts
2
+ //
3
+ // Item A (proactive delivery): the OpenClaw connector advertises the outbound
4
+ // targets "owner" and "default". They are not channel ids. They resolve through
5
+ // the bridge's bearer-gated GET /v1/owner-dm (the owner DM from the signed
6
+ // manifest in force), and fail closed: a 404, any non-2xx, a network error or a
7
+ // malformed body posts NOTHING and returns {ok:false, error: OWNER_DM_UNKNOWN_ERROR}.
8
+ // Any other `to` keeps the explicit-channel behaviour.
9
+ //
10
+ // Drives the REAL plugin.gateway.startAccount path with a fake loopback
11
+ // WebSocket and a fake fetch (same harness shape as openclaw-entry.inbound.test.ts).
12
+
13
+ import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
14
+ import { mkdtempSync, writeFileSync } from "node:fs";
15
+ import { tmpdir } from "node:os";
16
+ import { join } from "node:path";
17
+ import { buildConnectionFile, writeConnectionFile } from "../../src/service/connection-file.js";
18
+
19
+ const BOUND_ID = "agent-identity";
20
+ const PORT = 7097;
21
+ const TOKEN_VALUE = "outbound-test-token";
22
+ const ORIGINAL_CONN_FILE_ENV = process.env.SECURECOMS_CONNECTION_FILE;
23
+
24
+ function waitFor(predicate: () => boolean, timeout = 3000): Promise<void> {
25
+ const start = Date.now();
26
+ return new Promise((resolve, reject) => {
27
+ const check = () => {
28
+ if (predicate()) return resolve();
29
+ if (Date.now() - start > timeout) return reject(new Error("waitFor: condition never became true"));
30
+ setTimeout(check, 5);
31
+ };
32
+ check();
33
+ });
34
+ }
35
+
36
+ function makeFakeWebSocket() {
37
+ const instances: any[] = [];
38
+ class FakeWebSocket {
39
+ listeners: Record<string, Array<(ev?: unknown) => void>> = {};
40
+ constructor(public url: string) { instances.push(this); }
41
+ addEventListener(type: string, cb: (ev?: unknown) => void) { (this.listeners[type] ??= []).push(cb); }
42
+ send() {}
43
+ close() { this._emit("close"); }
44
+ _emit(type: string, ev?: unknown) { for (const cb of this.listeners[type] ?? []) cb(ev ?? {}); }
45
+ }
46
+ return { FakeWebSocket, instances };
47
+ }
48
+
49
+ type OwnerDm =
50
+ | { kind: "ok"; body: unknown }
51
+ | { kind: "status"; status: number; body?: unknown }
52
+ | { kind: "network" }
53
+ | { kind: "badjson" };
54
+
55
+ describe("openclaw-entry: outbound owner/default targets resolve through /v1/owner-dm", () => {
56
+ let home: string;
57
+ let controller: AbortController;
58
+ let sends: Array<Record<string, unknown>>;
59
+ let ownerDmCalls: Array<{ auth: string | null }>;
60
+ let ownerDm: OwnerDm;
61
+
62
+ beforeEach(() => {
63
+ home = mkdtempSync(join(tmpdir(), "sc-outbound-"));
64
+ const tokenFile = join(home, "bridge-token");
65
+ writeFileSync(tokenFile, TOKEN_VALUE, { mode: 0o600 });
66
+ writeConnectionFile(home, buildConnectionFile({
67
+ identityId: BOUND_ID, port: PORT, tokenFile, slug: "outbound-test", label: "Outbound Test",
68
+ instanceId: "outbound-test-instance", now: new Date().toISOString(),
69
+ }));
70
+ process.env.SECURECOMS_CONNECTION_FILE = join(home, ".securecoms", "connections", `${BOUND_ID}.json`);
71
+ sends = [];
72
+ ownerDmCalls = [];
73
+ ownerDm = { kind: "ok", body: { channel_id: "dm-1" } };
74
+ vi.stubGlobal("fetch", vi.fn(async (url: string, init?: RequestInit) => {
75
+ const u = String(url);
76
+ if (u.endsWith("/healthz")) return { ok: true, json: async () => ({ identity_id: BOUND_ID, joined_channels: 1 }) };
77
+ if (u.endsWith("/v1/owner-dm")) {
78
+ const h = (init?.headers ?? {}) as Record<string, string>;
79
+ ownerDmCalls.push({ auth: h.authorization ?? null });
80
+ if (ownerDm.kind === "network") throw new Error("ECONNREFUSED");
81
+ if (ownerDm.kind === "badjson") {
82
+ return { ok: true, status: 200, json: async () => { throw new SyntaxError("bad json"); }, text: async () => "<html>" };
83
+ }
84
+ if (ownerDm.kind === "status") {
85
+ return { ok: false, status: ownerDm.status, json: async () => ownerDm.kind === "status" ? ownerDm.body ?? {} : {}, text: async () => "" };
86
+ }
87
+ return { ok: true, status: 200, json: async () => ownerDm.body };
88
+ }
89
+ if (u.endsWith("/v1/send")) {
90
+ sends.push(JSON.parse(String(init?.body)));
91
+ return { ok: true, json: async () => ({ message_id: "m-1" }) };
92
+ }
93
+ if (u.endsWith("/v1/status")) return { ok: true, json: async () => ({ ok: true }) };
94
+ throw new Error(`unexpected fetch: ${u}`);
95
+ }));
96
+ controller = new AbortController();
97
+ });
98
+
99
+ afterEach(async () => {
100
+ controller.abort();
101
+ vi.unstubAllGlobals();
102
+ vi.unmock("node:os");
103
+ if (ORIGINAL_CONN_FILE_ENV === undefined) delete process.env.SECURECOMS_CONNECTION_FILE;
104
+ else process.env.SECURECOMS_CONNECTION_FILE = ORIGINAL_CONN_FILE_ENV;
105
+ vi.resetModules();
106
+ });
107
+
108
+ async function start() {
109
+ vi.resetModules();
110
+ vi.doMock("node:os", async (importOriginal) => {
111
+ const actual = await importOriginal<typeof import("node:os")>();
112
+ return { ...actual, homedir: () => home };
113
+ });
114
+ const mod = await import("./openclaw-entry.js");
115
+ let plugin: any;
116
+ mod.default.register({
117
+ runtime: { channel: {}, error: () => {} },
118
+ on: () => {},
119
+ registerTrustedToolPolicy: () => {},
120
+ registerChannel: ({ plugin: p }: { plugin: unknown }) => { plugin = p; },
121
+ });
122
+ const { FakeWebSocket, instances } = makeFakeWebSocket();
123
+ vi.stubGlobal("WebSocket", FakeWebSocket);
124
+ const account = plugin.config.resolveAccount({}, "default");
125
+ plugin.gateway.startAccount({ account, cfg: {}, log: () => {}, abortSignal: controller.signal }).catch(() => {});
126
+ await waitFor(() => instances.length >= 1);
127
+ instances[0]._emit("open");
128
+ return { plugin, mod };
129
+ }
130
+
131
+ const advertised = ["owner", "default"] as const;
132
+
133
+ it("keeps the advertised target labels unchanged", async () => {
134
+ const { plugin } = await start();
135
+ expect(plugin.outbound.targets).toEqual([
136
+ { id: "owner", label: "SecureComs Owner", accountId: "default" },
137
+ { id: "default", label: "SecureComs Owner (default)", accountId: "default" },
138
+ ]);
139
+ });
140
+
141
+ for (const to of advertised) {
142
+ it(`(a) to="${to}": resolves via /v1/owner-dm with the bearer and sends once to the resolved channel`, async () => {
143
+ const { plugin } = await start();
144
+ const res = await plugin.outbound.sendText({ text: "x", to, accountId: "default" });
145
+ expect(res).toEqual({ ok: true });
146
+ expect(ownerDmCalls).toEqual([{ auth: `Bearer ${TOKEN_VALUE}` }]);
147
+ expect(sends).toHaveLength(1);
148
+ expect(sends[0]).toEqual({ channel_id: "dm-1", text: "x" }); // no in_reply_to, no idempotency_key
149
+ });
150
+
151
+ it(`(b) to="${to}": a 404 posts nothing and returns the fixed error`, async () => {
152
+ const { plugin, mod } = await start();
153
+ ownerDm = { kind: "status", status: 404, body: { error: "owner_dm_unknown" } };
154
+ const res = await plugin.outbound.sendText({ text: "secret body", to, accountId: "default" });
155
+ expect(sends).toHaveLength(0);
156
+ expect(res.ok).toBe(false);
157
+ expect(res.error).toBe(mod.OWNER_DM_UNKNOWN_ERROR);
158
+ expect(res.error.length).toBeGreaterThan(0);
159
+ expect(res.error).not.toContain("secret body");
160
+ });
161
+ }
162
+
163
+ const failures: Array<[string, OwnerDm]> = [
164
+ ["a 500", { kind: "status", status: 500 }],
165
+ ["a 401", { kind: "status", status: 401 }],
166
+ ["a network error", { kind: "network" }],
167
+ ["a non-JSON 200 body", { kind: "badjson" }],
168
+ ["a 200 with no channel_id", { kind: "ok", body: {} }],
169
+ ["a 200 with an empty channel_id", { kind: "ok", body: { channel_id: "" } }],
170
+ ["a 200 with a non-string channel_id", { kind: "ok", body: { channel_id: 7 } }],
171
+ ["a 200 that resolves to the literal 'owner'", { kind: "ok", body: { channel_id: "owner" } }],
172
+ ["a 200 that resolves to the literal 'default'", { kind: "ok", body: { channel_id: "default" } }],
173
+ ];
174
+ for (const [name, mode] of failures) {
175
+ it(`(b) ${name} fails closed: no send, fixed error`, async () => {
176
+ const { plugin, mod } = await start();
177
+ ownerDm = mode;
178
+ const res = await plugin.outbound.sendText({ text: "x", to: "owner", accountId: "default" });
179
+ expect(sends).toHaveLength(0);
180
+ expect(res).toEqual({ ok: false, error: mod.OWNER_DM_UNKNOWN_ERROR });
181
+ });
182
+ }
183
+
184
+ it("(c) in no case does a send carry channel_id 'owner' or 'default'", async () => {
185
+ const { plugin } = await start();
186
+ const modes: OwnerDm[] = [
187
+ { kind: "ok", body: { channel_id: "dm-1" } },
188
+ { kind: "status", status: 404, body: { error: "owner_dm_unknown" } },
189
+ { kind: "status", status: 500 },
190
+ { kind: "network" },
191
+ { kind: "badjson" },
192
+ { kind: "ok", body: {} },
193
+ ];
194
+ for (const m of modes) {
195
+ ownerDm = m;
196
+ for (const to of advertised) await plugin.outbound.sendText({ text: "x", to, accountId: "default" });
197
+ }
198
+ expect(sends.length).toBeGreaterThan(0); // vacuity control: the ok mode did send
199
+ expect(sends.some((s) => s.channel_id === "owner" || s.channel_id === "default")).toBe(false);
200
+ });
201
+
202
+ it("any other `to` keeps the explicit-channel behaviour and never calls /v1/owner-dm", async () => {
203
+ const { plugin } = await start();
204
+ const res = await plugin.outbound.sendText({ text: "x", to: "chan-uuid-1", accountId: "default" });
205
+ expect(res).toEqual({ ok: true });
206
+ expect(ownerDmCalls).toHaveLength(0);
207
+ expect(sends).toEqual([{ channel_id: "chan-uuid-1", text: "x" }]);
208
+ });
209
+
210
+ it("no `to` still errors without a send or a lookup", async () => {
211
+ const { plugin } = await start();
212
+ const res = await plugin.outbound.sendText({ text: "x", accountId: "default" });
213
+ expect(res.ok).toBe(false);
214
+ expect(sends).toHaveLength(0);
215
+ expect(ownerDmCalls).toHaveLength(0);
216
+ });
217
+ });
package/dist/bin.d.ts CHANGED
@@ -29,6 +29,8 @@ export declare function slugFromCredentialPath(credentialPath: string): string;
29
29
  export declare function chooseInstallExecPath(runningExecPath: string, override: string | undefined, deps: ResolveNodeDeps): string;
30
30
  export declare function realGetHealth(port: number): DaemonHealth | null;
31
31
  export declare function realCopyConnector(srcDir: string, dstDir: string, harness: Harness): void;
32
+ export declare function realReadHermesEnv(p: string): string | null;
33
+ export declare function realAppendHermesEnv(p: string, text: string): void;
32
34
  /** The stderr line (with newline) a failed service verb prints. */
33
35
  export declare function formatServiceError(verb: string, e: unknown): string;
34
36
  /**
package/dist/bin.js CHANGED
@@ -44763,6 +44763,16 @@ var BridgeAgent = class _BridgeAgent {
44763
44763
  async consumeUploaded(read) {
44764
44764
  if (!this.outboxPin || !await consumeOutboxFile(this.outboxPin, read)) writeServiceLog("warn", "vault_outbox.consume_failed");
44765
44765
  }
44766
+ /**
44767
+ * Item A (OQ-A1): the owner DM channel id of the signed manifest in force, else null. In force means the owner pin is
44768
+ * set (same guard as applyManifest). No channelOwner, no listChannels heuristic, no fallback.
44769
+ */
44770
+ ownerDmChannelId() {
44771
+ const pin = this.cfg.ownerGrantPin?.pin ?? null;
44772
+ const inForce = pin !== null ? this.toolGrantManifest : null;
44773
+ const id = inForce?.owner_dm_channel_id;
44774
+ return typeof id === "string" && id !== "" ? id : null;
44775
+ }
44766
44776
  /** Audit 2026-10-30 MED 3: the pinned outbox path, served only on the authenticated GET /v1/vault-outbox. */
44767
44777
  vaultOutboxPath() {
44768
44778
  return this.outboxPin?.dir ?? null;
@@ -50172,7 +50182,9 @@ function capabilities(opts) {
50172
50182
  // version this daemon parses into BridgeMessage.vault_refs (never fetched
50173
50183
  // on receipt) and opens with the R4 pins on /v1/vault-read. A daemon
50174
50184
  // without this field skips and acks a link, so the agent never sees it.
50175
- vault_ref: 1
50185
+ vault_ref: 1,
50186
+ // Item A: GET /v1/owner-dm resolves the literal `owner`/`default` cron target from the signed manifest in force.
50187
+ owner_dm_route: 1
50176
50188
  };
50177
50189
  }
50178
50190
  var SEND_CAP = 64 * 1024;
@@ -50356,6 +50368,10 @@ function createHttpServer(agent, opts) {
50356
50368
  if (method === "GET" && path === "/v1/vault-outbox") {
50357
50369
  return json(res, 200, { path: agent.vaultOutboxPath() });
50358
50370
  }
50371
+ if (method === "GET" && path === "/v1/owner-dm") {
50372
+ const channelId = agent.ownerDmChannelId();
50373
+ return channelId === null ? json(res, 404, { error: "owner_dm_unknown" }) : json(res, 200, { channel_id: channelId });
50374
+ }
50359
50375
  if (method === "POST" && path === "/v1/send-attachment") {
50360
50376
  const parsed = sendAttachmentSchema.safeParse(JSON.parse(await readBody(req, OTHER_CAP)));
50361
50377
  if (!parsed.success) return jsonError(res, "invalid_request", parsed.error.issues);
@@ -51138,7 +51154,7 @@ async function handleInbound(sock, agent, data, inflight, flush, session) {
51138
51154
  var LOWER_UUID = /^[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}$/;
51139
51155
 
51140
51156
  // src/bin.ts
51141
- import { readFileSync as readFileSync17, mkdirSync as mkdirSync9, writeFileSync as writeFileSync5, rmSync as rmSync8, existsSync as existsSync7, realpathSync as realpathSync4, readdirSync as readdirSync4, statSync as statSync8, chmodSync as chmodSync7 } from "node:fs";
51157
+ import { readFileSync as readFileSync17, mkdirSync as mkdirSync9, writeFileSync as writeFileSync5, appendFileSync, rmSync as rmSync8, existsSync as existsSync7, realpathSync as realpathSync4, readdirSync as readdirSync4, statSync as statSync8, chmodSync as chmodSync7 } from "node:fs";
51142
51158
  import { join as join27, dirname as dirname14, isAbsolute as isAbsolute3, resolve as resolve4 } from "node:path";
51143
51159
  import { fileURLToPath as fileURLToPath4 } from "node:url";
51144
51160
  import { homedir as homedir5, userInfo } from "node:os";
@@ -51587,6 +51603,36 @@ function enableHermesConfig(home, deps) {
51587
51603
  message: `enabled ${HERMES_PLUGIN_NAME} in ${path} (the original was backed up alongside it as config.yaml.bak-wire-<ts>-<pid>).`
51588
51604
  };
51589
51605
  }
51606
+ var HERMES_HOME_CHANNEL_KEY = "SECURECOMS_HOME_CHANNEL";
51607
+ var HERMES_HOME_CHANNEL_LINE = `${HERMES_HOME_CHANNEL_KEY}=owner`;
51608
+ function hermesEnvPath(home) {
51609
+ return `${home}/.hermes/.env`;
51610
+ }
51611
+ var HOME_CHANNEL_ASSIGNMENT = new RegExp(`^\\s*(?:export\\s+)?${HERMES_HOME_CHANNEL_KEY}\\s*=`, "m");
51612
+ function homeChannelEnvAppendix(text) {
51613
+ const current = text ?? "";
51614
+ if (HOME_CHANNEL_ASSIGNMENT.test(current)) return null;
51615
+ const sep = current === "" || current.endsWith("\n") ? "" : "\n";
51616
+ return `${sep}${HERMES_HOME_CHANNEL_LINE}
51617
+ `;
51618
+ }
51619
+ function ensureHermesHomeChannel(home, deps) {
51620
+ const path = hermesEnvPath(home);
51621
+ try {
51622
+ const appendix = homeChannelEnvAppendix(deps.readHermesEnv(path));
51623
+ if (appendix === null) {
51624
+ deps.log(`[securecoms] ${HERMES_HOME_CHANNEL_KEY} is already set in ${path}; kept as is.`);
51625
+ return;
51626
+ }
51627
+ deps.appendHermesEnv(path, appendix);
51628
+ deps.log(`[securecoms] set ${HERMES_HOME_CHANNEL_LINE} in ${path} (scheduled messages go to the owner's DM).`);
51629
+ } catch {
51630
+ deps.log(
51631
+ `wire: could not update ${path}; scheduled messages need it. Add this line manually:
51632
+ ${HERMES_HOME_CHANNEL_LINE}`
51633
+ );
51634
+ }
51635
+ }
51590
51636
  function parseArg(argv, flag) {
51591
51637
  const i = argv.indexOf(flag);
51592
51638
  return i >= 0 ? argv[i + 1] : void 0;
@@ -51635,6 +51681,7 @@ function runWire(deps) {
51635
51681
  );
51636
51682
  return 1;
51637
51683
  }
51684
+ ensureHermesHomeChannel(deps.home, deps);
51638
51685
  }
51639
51686
  if (typeof entry.port === "number") {
51640
51687
  const h = deps.getHealth(entry.port);
@@ -52883,6 +52930,17 @@ function realBackupHermesConfig(p, originalText) {
52883
52930
  function realWriteHermesConfig(p, newText) {
52884
52931
  writeFileSync5(p, newText, "utf8");
52885
52932
  }
52933
+ function realReadHermesEnv(p) {
52934
+ try {
52935
+ return readFileSync17(p, "utf8");
52936
+ } catch (e) {
52937
+ if (e?.code === "ENOENT") return null;
52938
+ throw e;
52939
+ }
52940
+ }
52941
+ function realAppendHermesEnv(p, text) {
52942
+ appendFileSync(p, text, { encoding: "utf8", mode: 384 });
52943
+ }
52886
52944
  function realWireArgv() {
52887
52945
  if (process.argv.includes("--harness")) return process.argv;
52888
52946
  const detected = detectHarness(homedir5(), realStatDir);
@@ -52906,7 +52964,9 @@ function realWireDeps() {
52906
52964
  connectorsSrcDir: (h) => connectorsSrcDir(import.meta.url, h),
52907
52965
  readHermesConfig: realReadHermesConfig,
52908
52966
  backupHermesConfig: realBackupHermesConfig,
52909
- writeHermesConfig: realWriteHermesConfig
52967
+ writeHermesConfig: realWriteHermesConfig,
52968
+ readHermesEnv: realReadHermesEnv,
52969
+ appendHermesEnv: realAppendHermesEnv
52910
52970
  };
52911
52971
  }
52912
52972
  function runWireVerb(verb) {
@@ -53365,8 +53425,10 @@ export {
53365
53425
  formatServiceError,
53366
53426
  nodeMajorOk,
53367
53427
  parseVerb,
53428
+ realAppendHermesEnv,
53368
53429
  realCopyConnector,
53369
53430
  realGetHealth,
53431
+ realReadHermesEnv,
53370
53432
  runMode,
53371
53433
  slugFromCredentialPath,
53372
53434
  startThenServe,
@@ -2539,6 +2539,11 @@ export declare class BridgeAgent {
2539
2539
  }>;
2540
2540
  /** Audit 2026-10-30 MED 5: an uploaded file leaves the outbox (no plaintext lingers). Content-free log. */
2541
2541
  private consumeUploaded;
2542
+ /**
2543
+ * Item A (OQ-A1): the owner DM channel id of the signed manifest in force, else null. In force means the owner pin is
2544
+ * set (same guard as applyManifest). No channelOwner, no listChannels heuristic, no fallback.
2545
+ */
2546
+ ownerDmChannelId(): string | null;
2542
2547
  /** Audit 2026-10-30 MED 3: the pinned outbox path, served only on the authenticated GET /v1/vault-outbox. */
2543
2548
  vaultOutboxPath(): string | null;
2544
2549
  /** Download + verify + decrypt a vault file node. Returns the plaintext
@@ -58,6 +58,7 @@ export declare function capabilities(opts: {
58
58
  send_types: string[];
59
59
  webhook_push: boolean;
60
60
  vault_ref: number;
61
+ owner_dm_route: number;
61
62
  };
62
63
  export declare const memoryRecordSchema: z.ZodObject<{
63
64
  conversation_id: z.ZodString;
package/dist/index.js CHANGED
@@ -44477,6 +44477,16 @@ var BridgeAgent = class _BridgeAgent {
44477
44477
  async consumeUploaded(read) {
44478
44478
  if (!this.outboxPin || !await consumeOutboxFile(this.outboxPin, read)) writeServiceLog2("warn", "vault_outbox.consume_failed");
44479
44479
  }
44480
+ /**
44481
+ * Item A (OQ-A1): the owner DM channel id of the signed manifest in force, else null. In force means the owner pin is
44482
+ * set (same guard as applyManifest). No channelOwner, no listChannels heuristic, no fallback.
44483
+ */
44484
+ ownerDmChannelId() {
44485
+ const pin = this.cfg.ownerGrantPin?.pin ?? null;
44486
+ const inForce = pin !== null ? this.toolGrantManifest : null;
44487
+ const id = inForce?.owner_dm_channel_id;
44488
+ return typeof id === "string" && id !== "" ? id : null;
44489
+ }
44480
44490
  /** Audit 2026-10-30 MED 3: the pinned outbox path, served only on the authenticated GET /v1/vault-outbox. */
44481
44491
  vaultOutboxPath() {
44482
44492
  return this.outboxPin?.dir ?? null;
@@ -50032,7 +50042,9 @@ function capabilities(opts) {
50032
50042
  // version this daemon parses into BridgeMessage.vault_refs (never fetched
50033
50043
  // on receipt) and opens with the R4 pins on /v1/vault-read. A daemon
50034
50044
  // without this field skips and acks a link, so the agent never sees it.
50035
- vault_ref: 1
50045
+ vault_ref: 1,
50046
+ // Item A: GET /v1/owner-dm resolves the literal `owner`/`default` cron target from the signed manifest in force.
50047
+ owner_dm_route: 1
50036
50048
  };
50037
50049
  }
50038
50050
  var SEND_CAP = 64 * 1024;
@@ -50216,6 +50228,10 @@ function createHttpServer(agent, opts) {
50216
50228
  if (method === "GET" && path === "/v1/vault-outbox") {
50217
50229
  return json(res, 200, { path: agent.vaultOutboxPath() });
50218
50230
  }
50231
+ if (method === "GET" && path === "/v1/owner-dm") {
50232
+ const channelId = agent.ownerDmChannelId();
50233
+ return channelId === null ? json(res, 404, { error: "owner_dm_unknown" }) : json(res, 200, { channel_id: channelId });
50234
+ }
50219
50235
  if (method === "POST" && path === "/v1/send-attachment") {
50220
50236
  const parsed = sendAttachmentSchema.safeParse(JSON.parse(await readBody(req, OTHER_CAP)));
50221
50237
  if (!parsed.success) return jsonError(res, "invalid_request", parsed.error.issues);
@@ -60,6 +60,14 @@ export interface WireDeps extends HermesConfigIoDeps {
60
60
  * tests and never inspects the path for its own sake).
61
61
  */
62
62
  connectorsSrcDir: (harness: Harness) => string;
63
+ /** The Hermes profile env file's text; null when the file does not exist.
64
+ * Throws on any other read failure, so an unreadable file is never
65
+ * mistaken for an absent key (see ensureHermesHomeChannel). */
66
+ readHermesEnv: (path: string) => string | null;
67
+ /** Appends `text` to the env file (creating it 0600 when missing). An
68
+ * append never rewrites another line and never changes an existing
69
+ * file's mode. */
70
+ appendHermesEnv: (path: string, text: string) => void;
63
71
  }
64
72
  /**
65
73
  * Detects an installed harness by probing well-known home-relative dirs.
@@ -136,6 +144,22 @@ export declare function enableHermesConfig(home: string, deps: HermesConfigIoDep
136
144
  ok: boolean;
137
145
  message: string;
138
146
  };
147
+ /** The Hermes cron home-channel env var (the connector registers it as
148
+ * `cron_deliver_env_var`) and the value `wire` sets: the literal `owner`,
149
+ * which the connector resolves only through GET /v1/owner-dm (Item A). */
150
+ export declare const HERMES_HOME_CHANNEL_KEY = "SECURECOMS_HOME_CHANNEL";
151
+ export declare const HERMES_HOME_CHANNEL_LINE = "SECURECOMS_HOME_CHANNEL=owner";
152
+ /** The env file Hermes loads for the profile `wire` targets: `<HERMES_HOME>/.env`,
153
+ * with HERMES_HOME the default `~/.hermes` (the same profile scope as
154
+ * `hermesConfigPath`). */
155
+ export declare function hermesEnvPath(home: string): string;
156
+ /** The text to append so the env file sets the home channel, or null when the
157
+ * key is already assigned (any value, including an owner-set channel id or an
158
+ * empty one: `wire` never overwrites it). Pure. */
159
+ export declare function homeChannelEnvAppendix(text: string | null): string | null;
160
+ /** Sets the Hermes cron home channel when absent. Never fatal to `wire`: the
161
+ * connector is wired without it, and a failure prints the manual line. */
162
+ export declare function ensureHermesHomeChannel(home: string, deps: Pick<WireDeps, "readHermesEnv" | "appendHermesEnv" | "log">): void;
139
163
  /**
140
164
  * Installs the reference connector into the harness's plugin dir and drops
141
165
  * an explicit securecoms.binding.json naming the resolved agent's identity.
package/dist/version.d.ts CHANGED
@@ -1 +1 @@
1
- export declare const BRIDGE_VERSION = "0.39.0";
1
+ export declare const BRIDGE_VERSION = "0.40.0";
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@securecoms/agent-bridge",
3
- "version": "0.39.0",
3
+ "version": "0.40.0",
4
4
  "type": "module",
5
5
  "engines": {
6
6
  "node": ">=24"