@securecoms/agent-bridge 0.21.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
@@ -270,7 +270,14 @@ closed without exposing OpenClaw memory.
270
270
  { "type":"ack", "message_id":"..." } // confirms you consumed a delivered message
271
271
  ```
272
272
 
273
- **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.
274
281
 
275
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.
276
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
 
@@ -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"