@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 +16 -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 +174 -1
- package/connectors/hermes/tests/test_adapter_wiring.py +429 -0
- package/connectors/openclaw/openclaw-entry.js +40 -3
- 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
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")] -->
|
|
Binary file
|
|
Binary file
|
|
@@ -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
|
-
|
|
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).
|
|
1351
|
-
//
|
|
1352
|
-
//
|
|
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,
|
package/dist/bridge-agent.d.ts
CHANGED
|
@@ -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
|
package/dist/http-server.d.ts
CHANGED
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);
|
package/dist/service/wire.d.ts
CHANGED
|
@@ -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.
|
|
1
|
+
export declare const BRIDGE_VERSION = "0.40.0";
|