@securecoms/agent-bridge 0.20.0 → 0.23.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 +48 -2
- package/connectors/hermes/adapter.py +38 -8
- package/connectors/hermes/tests/test_adapter_wiring.py +6 -1
- package/connectors/hermes/tests/test_logging_privacy.py +61 -0
- package/connectors/openclaw/openclaw-entry.inbound.test.ts +560 -0
- package/connectors/openclaw/openclaw-entry.js +248 -51
- package/connectors/openclaw/openclaw-entry.smoke.test.ts +23 -1
- package/dist/bin.d.ts +3 -7
- package/dist/bin.js +3300 -476
- package/dist/bridge-agent.d.ts +280 -8
- package/dist/coordination.d.ts +90 -5
- package/dist/decrypt-error-log-limiter.d.ts +6 -30
- package/dist/errors.d.ts +1 -1
- package/dist/http-server.d.ts +20 -1
- package/dist/index.js +3289 -456
- package/dist/reply-receipts.d.ts +30 -0
- package/dist/service/runtime-config-dir.d.ts +34 -0
- package/dist/service-log.d.ts +7 -0
- package/dist/summarize.d.ts +3 -2
- package/dist/version.d.ts +1 -1
- package/dist/ws-server.d.ts +30 -3
- package/package.json +2 -2
package/README.md
CHANGED
|
@@ -1,5 +1,24 @@
|
|
|
1
1
|
# @agentvault/connect-agent-bridge
|
|
2
2
|
|
|
3
|
+
## Non-destructive runtime reply monitoring
|
|
4
|
+
|
|
5
|
+
Attached runtimes should include the inbound `message_id` as `in_reply_to` on
|
|
6
|
+
their outbound `send` frame/request. After the encrypted send publishes, the
|
|
7
|
+
bridge retains a bounded, 15-minute content-free receipt and exposes it at the
|
|
8
|
+
bearer-gated loopback route:
|
|
9
|
+
|
|
10
|
+
```text
|
|
11
|
+
GET /v1/reply-receipt?in_reply_to=<message_id>
|
|
12
|
+
```
|
|
13
|
+
|
|
14
|
+
The response is either `{ "found": false }` or a receipt containing only the
|
|
15
|
+
inbound message id, outbound message id, channel id, and publish timestamp. It
|
|
16
|
+
contains no prompt or reply text. `scripts/agent-reply-probe.mjs` uses this
|
|
17
|
+
surface and never calls `/v1/messages`, `/v1/events`, or `/v1/ws`, so routine
|
|
18
|
+
monitoring cannot compete with or evict the attached runtime. The older
|
|
19
|
+
`scripts/fleet-probe.mjs` remains an attended promotion tool because it drains
|
|
20
|
+
the single-consumer stream to inspect reply plaintext.
|
|
21
|
+
|
|
3
22
|
An always-on local daemon that holds an AI agent's SecureComs credential and MLS state, then exposes a loopback API so any agent — Python, Node, Go, shell scripts, whatever — can send and receive end-to-end encrypted messages without touching any cryptography itself. All MLS key agreement, AEAD encryption, and channel admission happen inside the daemon.
|
|
4
23
|
|
|
5
24
|
The daemon serves two interfaces on the same port:
|
|
@@ -217,12 +236,32 @@ All frames are JSON text.
|
|
|
217
236
|
**Daemon → agent**
|
|
218
237
|
|
|
219
238
|
```
|
|
220
|
-
{ "type":"message", "channel_id":"...", "message_id":"...", "sender_id":"...", "text":"...", "ts": 1234567890
|
|
239
|
+
{ "type":"message", "channel_id":"...", "message_id":"...", "sender_id":"...", "text":"...", "ts": 1234567890,
|
|
240
|
+
"authorization": { "version":1, "audience":"owner_dm"|"team_room"|"partner_room"|"other_human_dm"|"agent_to_agent"|"unknown",
|
|
241
|
+
"sender_membership":"active"|"unknown", "tool_grants":[] } }
|
|
221
242
|
{ "type":"sent", "message_id":"..." } // acks your send
|
|
222
243
|
{ "type":"ok" } // acks your status report
|
|
223
244
|
{ "type":"error", "error":"channel_pending"|"invalid_request", "message":"...", "detail":"..." }
|
|
224
245
|
```
|
|
225
246
|
|
|
247
|
+
The daemon derives `authorization` only from the authenticated channel and
|
|
248
|
+
roster APIs; message text, display names, and model output cannot set it.
|
|
249
|
+
Missing or stale roster data produces `unknown` with no grants. This release
|
|
250
|
+
deliberately issues no channel tool grants while grant persistence and owner
|
|
251
|
+
controls are staged, so consumers must treat `tool_grants:[]` as authoritative.
|
|
252
|
+
Future side-effect grants will also bind an exact operation digest,
|
|
253
|
+
confirmation id, idempotency key, and expiry rather than granting a tool name
|
|
254
|
+
globally.
|
|
255
|
+
|
|
256
|
+
When `memory.provider` is `openclaw`, OpenClaw text history and workspace facts
|
|
257
|
+
are owner-DM-only. The live fast path consumes them only when the immutable
|
|
258
|
+
turn authorization says `owner_dm` with active membership. The loopback memory
|
|
259
|
+
context API independently refreshes authenticated channel/roster metadata and
|
|
260
|
+
returns an empty context unless the channel is a DM with exactly one non-agent
|
|
261
|
+
human counterpart and that counterpart is the agent owner. Missing, stale,
|
|
262
|
+
ambiguous, room, partner, other-human, and agent-to-agent state therefore fails
|
|
263
|
+
closed without exposing OpenClaw memory.
|
|
264
|
+
|
|
226
265
|
**Agent → daemon**
|
|
227
266
|
|
|
228
267
|
```
|
|
@@ -231,7 +270,14 @@ All frames are JSON text.
|
|
|
231
270
|
{ "type":"ack", "message_id":"..." } // confirms you consumed a delivered message
|
|
232
271
|
```
|
|
233
272
|
|
|
234
|
-
**Acknowledge what you receive.** Inbound delivery is **at-least-once**.
|
|
273
|
+
**Acknowledge what you receive.** Inbound delivery is **at-least-once**. Before the remote server queue is acknowledged, the daemon seals each runtime-bound `message` into `bridge-state.db`. It remains durable until you send a matching `{type:"ack", message_id}`. If your socket, daemon, or host disappears first, the message is restored and redelivered to the next consumer in FIFO order. The cost is that you may occasionally see a message twice; dedup by `message_id`. Ack as soon as you've durably handled a message, not before.
|
|
274
|
+
|
|
275
|
+
For the first-party runtime, a reply carries `in_reply_to`. The daemon persists a
|
|
276
|
+
content-free reply receipt after publish, and a replacement runtime checks that
|
|
277
|
+
receipt before running the turn again. This closes the publish-success/ACK-loss
|
|
278
|
+
window where the result is known. It does not pretend an indeterminate external
|
|
279
|
+
tool call is safe to repeat; those calls use the runtime's separate reconciliation
|
|
280
|
+
ledger.
|
|
235
281
|
|
|
236
282
|
**One consumer at a time.** The daemon serves a single agent per process. Opening a second `/v1/ws` connection closes the first (newest wins, code `1000`); the replaced socket's un-acked messages are re-buffered for the new one. Don't run two consumers against one daemon expecting to share the stream.
|
|
237
283
|
|
|
@@ -81,6 +81,17 @@ _BINDING_PATH = os.path.join(_PLUGIN_DIR, "securecoms.binding.json")
|
|
|
81
81
|
_CONNECTIONS_DIR = os.path.expanduser("~/.securecoms/connections")
|
|
82
82
|
|
|
83
83
|
|
|
84
|
+
def _error_class(error: object) -> str:
|
|
85
|
+
"""Return a bounded class name without reading exception prose."""
|
|
86
|
+
try:
|
|
87
|
+
name = type(error).__name__
|
|
88
|
+
except Exception:
|
|
89
|
+
return "Error"
|
|
90
|
+
if not name or len(name) > 64 or not name.replace("_", "a").isalnum():
|
|
91
|
+
return "Error"
|
|
92
|
+
return name
|
|
93
|
+
|
|
94
|
+
|
|
84
95
|
def _read_file_text(path: str) -> str:
|
|
85
96
|
with open(path, "r") as f:
|
|
86
97
|
return f.read()
|
|
@@ -184,7 +195,7 @@ class SecureComsAdapter(BasePlatformAdapter):
|
|
|
184
195
|
self._recv_task = asyncio.create_task(self._supervise())
|
|
185
196
|
self._tick_task = asyncio.create_task(self._tick_loop())
|
|
186
197
|
self._mark_connected()
|
|
187
|
-
logger.info("SecureComs: connected to daemon
|
|
198
|
+
logger.info("SecureComs: connected to daemon")
|
|
188
199
|
return True
|
|
189
200
|
|
|
190
201
|
async def _open_ws(self) -> bool:
|
|
@@ -230,7 +241,7 @@ class SecureComsAdapter(BasePlatformAdapter):
|
|
|
230
241
|
changed = self._hold_reason != reason
|
|
231
242
|
self._hold_reason = reason
|
|
232
243
|
if changed:
|
|
233
|
-
logger.warning("SecureComs: waiting for SecureComs daemon
|
|
244
|
+
logger.warning("SecureComs: waiting for SecureComs daemon")
|
|
234
245
|
self._set_fatal_error(
|
|
235
246
|
"waiting_for_daemon",
|
|
236
247
|
f"waiting for SecureComs daemon: {reason}",
|
|
@@ -253,7 +264,10 @@ class SecureComsAdapter(BasePlatformAdapter):
|
|
|
253
264
|
except asyncio.CancelledError:
|
|
254
265
|
raise
|
|
255
266
|
except Exception as e:
|
|
256
|
-
logger.warning(
|
|
267
|
+
logger.warning(
|
|
268
|
+
"SecureComs: receive failed (error_class=%s)",
|
|
269
|
+
_error_class(e),
|
|
270
|
+
)
|
|
257
271
|
if self._stopped:
|
|
258
272
|
break
|
|
259
273
|
self._mark_disconnected()
|
|
@@ -311,7 +325,10 @@ class SecureComsAdapter(BasePlatformAdapter):
|
|
|
311
325
|
"is_dm": bool(c.get("is_dm")),
|
|
312
326
|
}
|
|
313
327
|
except Exception as e:
|
|
314
|
-
logger.debug(
|
|
328
|
+
logger.debug(
|
|
329
|
+
"SecureComs: channel refresh failed (error_class=%s)",
|
|
330
|
+
_error_class(e),
|
|
331
|
+
)
|
|
315
332
|
|
|
316
333
|
# ── Status emit ───────────────────────────────────────────────────────
|
|
317
334
|
|
|
@@ -321,7 +338,10 @@ class SecureComsAdapter(BasePlatformAdapter):
|
|
|
321
338
|
try:
|
|
322
339
|
await self._ws.send_json(frame)
|
|
323
340
|
except Exception as e:
|
|
324
|
-
logger.debug(
|
|
341
|
+
logger.debug(
|
|
342
|
+
"SecureComs: status frame send failed (error_class=%s)",
|
|
343
|
+
_error_class(e),
|
|
344
|
+
)
|
|
325
345
|
|
|
326
346
|
async def _tick_loop(self) -> None:
|
|
327
347
|
"""Drives ChannelStatusManager.tick() so a channel goes idle after
|
|
@@ -334,7 +354,10 @@ class SecureComsAdapter(BasePlatformAdapter):
|
|
|
334
354
|
except asyncio.CancelledError:
|
|
335
355
|
raise
|
|
336
356
|
except Exception as e:
|
|
337
|
-
logger.debug(
|
|
357
|
+
logger.debug(
|
|
358
|
+
"SecureComs: status tick failed (error_class=%s)",
|
|
359
|
+
_error_class(e),
|
|
360
|
+
)
|
|
338
361
|
|
|
339
362
|
# ── Inbound ───────────────────────────────────────────────────────────
|
|
340
363
|
|
|
@@ -360,7 +383,10 @@ class SecureComsAdapter(BasePlatformAdapter):
|
|
|
360
383
|
{"type": "ack", "message_id": frame.get("message_id")}
|
|
361
384
|
)
|
|
362
385
|
except Exception as e:
|
|
363
|
-
logger.warning(
|
|
386
|
+
logger.warning(
|
|
387
|
+
"SecureComs: ack failed (error_class=%s)",
|
|
388
|
+
_error_class(e),
|
|
389
|
+
)
|
|
364
390
|
elif msg.type in (aiohttp.WSMsgType.CLOSED, aiohttp.WSMsgType.ERROR):
|
|
365
391
|
return # WS closed → supervisor reconnects
|
|
366
392
|
|
|
@@ -406,7 +432,11 @@ class SecureComsAdapter(BasePlatformAdapter):
|
|
|
406
432
|
async with self._session.post(
|
|
407
433
|
f"{self.bridge_url}/v1/send",
|
|
408
434
|
headers=self._auth(),
|
|
409
|
-
json={
|
|
435
|
+
json={
|
|
436
|
+
"channel_id": chat_id,
|
|
437
|
+
"text": content,
|
|
438
|
+
**({"in_reply_to": reply_to} if reply_to else {}),
|
|
439
|
+
},
|
|
410
440
|
) as r:
|
|
411
441
|
if r.status == 200:
|
|
412
442
|
j = await r.json()
|
|
@@ -344,10 +344,15 @@ class TestSendWiring:
|
|
|
344
344
|
a.bridge_url = "http://127.0.0.1:9001"
|
|
345
345
|
a._token = "tok"
|
|
346
346
|
|
|
347
|
-
result = await a.send("chan-1", "hello")
|
|
347
|
+
result = await a.send("chan-1", "hello", reply_to="inbound-7")
|
|
348
348
|
|
|
349
349
|
assert result.success is True
|
|
350
350
|
assert calls == ["chan-1"]
|
|
351
|
+
assert a._session.post_calls[0][2] == {
|
|
352
|
+
"channel_id": "chan-1",
|
|
353
|
+
"text": "hello",
|
|
354
|
+
"in_reply_to": "inbound-7",
|
|
355
|
+
}
|
|
351
356
|
|
|
352
357
|
@pytest.mark.asyncio
|
|
353
358
|
async def test_send_failure_does_not_fire_on_reply_posted(self, monkeypatch):
|
|
@@ -0,0 +1,61 @@
|
|
|
1
|
+
import ast
|
|
2
|
+
from pathlib import Path
|
|
3
|
+
|
|
4
|
+
from adapter import _error_class
|
|
5
|
+
|
|
6
|
+
|
|
7
|
+
def _logging_violations(source: str) -> list[str]:
|
|
8
|
+
violations: list[str] = []
|
|
9
|
+
tree = ast.parse(source)
|
|
10
|
+
for node in ast.walk(tree):
|
|
11
|
+
if not (
|
|
12
|
+
isinstance(node, ast.Call)
|
|
13
|
+
and isinstance(node.func, ast.Attribute)
|
|
14
|
+
and isinstance(node.func.value, ast.Name)
|
|
15
|
+
and node.func.value.id == "logger"
|
|
16
|
+
):
|
|
17
|
+
continue
|
|
18
|
+
if node.func.attr == "exception":
|
|
19
|
+
violations.append("logger.exception")
|
|
20
|
+
if not node.args or not (
|
|
21
|
+
isinstance(node.args[0], ast.Constant)
|
|
22
|
+
and isinstance(node.args[0].value, str)
|
|
23
|
+
):
|
|
24
|
+
violations.append("dynamic format")
|
|
25
|
+
continue
|
|
26
|
+
rendered = node.args[0].value.lower()
|
|
27
|
+
if any(label in rendered for label in ("identity=", "channel=", "reason=", "url=")):
|
|
28
|
+
violations.append("sensitive field")
|
|
29
|
+
for arg in node.args[1:]:
|
|
30
|
+
if not (
|
|
31
|
+
isinstance(arg, ast.Call)
|
|
32
|
+
and isinstance(arg.func, ast.Name)
|
|
33
|
+
and arg.func.id == "_error_class"
|
|
34
|
+
) and not (isinstance(arg, ast.Name) and arg.id == "backoff"):
|
|
35
|
+
violations.append("unbounded argument")
|
|
36
|
+
return violations
|
|
37
|
+
|
|
38
|
+
|
|
39
|
+
def test_adapter_logging_source_is_content_free() -> None:
|
|
40
|
+
source = (Path(__file__).parents[1] / "adapter.py").read_text()
|
|
41
|
+
assert _logging_violations(source) == []
|
|
42
|
+
|
|
43
|
+
|
|
44
|
+
def test_adapter_logging_guard_detects_dynamic_values() -> None:
|
|
45
|
+
hostile = '''
|
|
46
|
+
logger.info(dynamic_format, identity)
|
|
47
|
+
logger.warning("channel=%s", channel_id)
|
|
48
|
+
logger.error("failed: %s", error)
|
|
49
|
+
'''
|
|
50
|
+
violations = _logging_violations(hostile)
|
|
51
|
+
assert "dynamic format" in violations
|
|
52
|
+
assert "sensitive field" in violations
|
|
53
|
+
assert "unbounded argument" in violations
|
|
54
|
+
|
|
55
|
+
|
|
56
|
+
def test_error_class_does_not_render_exception_prose() -> None:
|
|
57
|
+
class HostileError(RuntimeError):
|
|
58
|
+
def __str__(self) -> str:
|
|
59
|
+
raise AssertionError("exception prose was rendered")
|
|
60
|
+
|
|
61
|
+
assert _error_class(HostileError()) == "HostileError"
|