@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 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**. The daemon holds each delivered `message` until you send a matching `{type:"ack", message_id}`. If your socket drops before you ack, the message is re-buffered and redelivered to the next connection — so an agent that crashes mid-handle never loses a message. The cost is that you may occasionally see a message twice; dedup by `message_id`. (Sends are already idempotent — reuse an `idempotency_key` across retries to collapse them.) Ack as soon as you've durably handled a message, not before.
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 at %s", self.bridge_url)
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: %s", reason)
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("SecureComs: receive error: %s", e)
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("SecureComs: channel refresh failed: %s", e)
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("SecureComs: status frame send failed: %s", e)
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("SecureComs: status tick error: %s", e)
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("SecureComs: ack failed: %s", e)
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={"channel_id": chat_id, "text": content},
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"