@securecoms/agent-bridge 0.21.0 → 0.24.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
@@ -213,6 +213,15 @@ HTTP 503
213
213
 
214
214
  Retry with exponential back-off. The channel becomes available once the owner's client adds the agent device to the MLS group.
215
215
 
216
+ **`POST /v1/send` — 409 group_superseded**
217
+
218
+ The channel's owner reset its secure session and this daemon still holds the previous MLS group. Nothing was sent or stored. The daemon asks the owner's client to re-admit it and never wipes its local state on this answer. Retry later with back-off; a reply sent with `in_reply_to` or `idempotency_key` is republished under the same message id once the daemon holds the new group. Until then the daemon holds that channel's inbound messages: they are not handed to the runtime and do not use up their delivery attempts, for at most 24 hours from the first refusal. Adoption and the re-admission request are skipped on FileSafe vault channels and on any channel the enclave channel listing does not show.
219
+
220
+ ```json
221
+ HTTP 409
222
+ {"error": "group_superseded", "message": "channel moved to a new secure group; not sent; retry after the agent rejoins"}
223
+ ```
224
+
216
225
  **`POST /v1/status` — state values**
217
226
 
218
227
  `state` must be one of `idle`, `working`, `stalled`, or `error`. `detail` is an optional free-form string.
@@ -241,7 +250,7 @@ All frames are JSON text.
241
250
  "sender_membership":"active"|"unknown", "tool_grants":[] } }
242
251
  { "type":"sent", "message_id":"..." } // acks your send
243
252
  { "type":"ok" } // acks your status report
244
- { "type":"error", "error":"channel_pending"|"invalid_request", "message":"...", "detail":"..." }
253
+ { "type":"error", "error":"channel_pending"|"invalid_request"|"group_superseded"|..., "message":"...", "detail":"..." }
245
254
  ```
246
255
 
247
256
  The daemon derives `authorization` only from the authenticated channel and
@@ -265,12 +274,91 @@ closed without exposing OpenClaw memory.
265
274
  **Agent → daemon**
266
275
 
267
276
  ```
268
- { "type":"send", "channel_id":"...", "text":"...", "idempotency_key":"..." } // idempotency_key optional
277
+ { "type":"send", "channel_id":"...", "text":"...", "in_reply_to":"...", "idempotency_key":"..." } // both optional
269
278
  { "type":"status", "channel_id":"...", "state":"idle"|"working"|"stalled"|"error", "detail":"..." }
270
279
  { "type":"ack", "message_id":"..." } // confirms you consumed a delivered message
271
280
  ```
272
281
 
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.
282
+ **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.
283
+
284
+ For the first-party runtime, a reply carries `in_reply_to`. The daemon persists a
285
+ content-free reply receipt after publish, and a replacement runtime checks that
286
+ receipt before running the turn again. This closes the publish-success/ACK-loss
287
+ window where the result is known. It does not pretend an indeterminate external
288
+ tool call is safe to repeat; those calls use the runtime's separate reconciliation
289
+ ledger.
290
+
291
+ **Replies are published at most once (SEC-06 #18).** A `send` that carries
292
+ `in_reply_to` (or, on `/v1/ws`, an `idempotency_key`) is an idempotent reply:
293
+
294
+ - Its `message_id` is a deterministic, keyed UUID of (agent identity, channel,
295
+ the inbound it answers, which part). The key is derived from the identity
296
+ seed, so other channel members cannot predict the id and publish under it
297
+ first. The voice fast path's reply id is keyed the same way. The
298
+ `idempotency_key` is never used as the `message_id` itself.
299
+ - Before the publish, the daemon writes a durable reply intent to
300
+ `bridge-state.db`: the reply body and the signed envelope about to be sent,
301
+ both sealed like the rest of the store and dropped once the publish is
302
+ confirmed.
303
+ - Every retry resends those exact envelope bytes, so a backend that already
304
+ accepted them answers with the original ack (even if the epoch has moved
305
+ since). A `message_in_flight` 409 (the original is still being processed)
306
+ is retried later with the same bytes, never re-encrypted: 3 in-process
307
+ retries with backoff, then the redelivery backoff and the delivery cap. The
308
+ reply is re-encrypted (same id, fresh nonce) only when the backend says the
309
+ earlier attempt was not accepted: a `replay_nonce` 409 on the exact bytes,
310
+ or `mls.stale_epoch`, which it answers only for an id it has not seen.
311
+ - `message_id_conflict` means the id slot is held by OTHER bytes, never that
312
+ our reply was delivered: the cached envelope always holds the last bytes
313
+ posted under the id, and a resend of those bytes gets the original ack. So
314
+ the slot belongs to another message (for example an old reply whose intent
315
+ was pruned): an unkeyed part moves to the next part number, and a keyed one
316
+ fails with `invalid_request`.
317
+ - If the inbound is redelivered while its reply has an intent (a 502 after the
318
+ backend committed, a crash before the receipt, a nack), the daemon
319
+ republishes that reply itself and acknowledges the inbound. The runtime is
320
+ not handed the message again, so the model does not run twice.
321
+ - A `rate_limited` publish (the daemon's room floor or a backend 429) is
322
+ retried in-process with backoff, up to 3 times, before `rate_limited` is
323
+ returned. The intent stays pending, so the redelivery republishes it.
324
+ - Parts are numbered from `bridge-state.db`, not from memory, so a restart
325
+ between two parts never reuses a published part's id. Intents are kept for
326
+ 7 days (at most 5000).
327
+ - **Send an `idempotency_key` per reply block.** Without one, a send whose text
328
+ equals the MOST RECENT part of the same `in_reply_to` is treated as a retry
329
+ of that part and returns its `message_id` without publishing. Two identical
330
+ consecutive blocks therefore become one message. With a key, each key is its
331
+ own part; resending the same key and text is a retry, and the same key with
332
+ different text is refused with `invalid_request`. The OpenClaw and Hermes
333
+ connectors send `<in_reply_to>:<n>` per block. `idempotency_key` is honoured
334
+ on both `/v1/ws` and `POST /v1/send`.
335
+ - **Trade-off: a crash in the middle of a multi-part reply.** If the runtime
336
+ published part 1 of a reply and then the runtime, daemon or host died before
337
+ it produced part 2, the redelivered message is answered from the intents:
338
+ part 1 is republished (or confirmed) and the message is acknowledged. Part 2
339
+ is never produced, because producing it means running the model again, which
340
+ would write a new, different reply around a part 1 that was already sent.
341
+ The daemon chooses "no duplicate" over "complete".
342
+ - An intent only answers a message the runtime was actually handed (one with a
343
+ delivery attempt). A reply that names a message still waiting in the buffer
344
+ does not stop that message reaching the runtime.
345
+ - **Replies to a message more than 7 days old.** Intents are pruned after 7
346
+ days (or past 5000 rows), and a pruned intent's id is still held by the
347
+ reply the backend stored. Replying to that same inbound again then collides
348
+ with the old reply:
349
+ - a connector key reused after the prune (for example `X:0` again) is
350
+ refused with `400 invalid_request`, and that block is lost;
351
+ - an unkeyed reply moves past the old reply's parts one number at a time, at
352
+ most 8 per send. If the old reply had more than 8 parts, sends fail until
353
+ enough collided parts have been skipped (each failed send skips up to 8).
354
+ - **Hermes: a block retried after a different block went out can be
355
+ published twice.** The Hermes connector reuses a block's key only while it
356
+ is the most recent unconfirmed block. If block A fails ambiguously (the
357
+ daemon may in fact have published it), then block B is sent, and then
358
+ Hermes retries A, the retry carries a new key and A appears twice. The
359
+ OpenClaw connector uses the same key rule; it does not retry a failed block
360
+ itself, so the same case arises there only if OpenClaw's own reply
361
+ dispatcher retries one.
274
362
 
275
363
  **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
364
 
@@ -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()
@@ -112,6 +123,8 @@ class SecureComsAdapter(BasePlatformAdapter):
112
123
  self.ws_url: Optional[str] = None
113
124
  self._token: Optional[str] = None
114
125
 
126
+ # SEC-06 #18: reply_to -> {"seq": n, "pending": text|None}; see _block_key.
127
+ self._block_seq: Dict[str, Dict[str, Any]] = {}
115
128
  self._session = None # aiohttp.ClientSession - the CURRENT connected link
116
129
  self._ws = None
117
130
  self._recv_task: Optional[asyncio.Task] = None
@@ -184,7 +197,7 @@ class SecureComsAdapter(BasePlatformAdapter):
184
197
  self._recv_task = asyncio.create_task(self._supervise())
185
198
  self._tick_task = asyncio.create_task(self._tick_loop())
186
199
  self._mark_connected()
187
- logger.info("SecureComs: connected to daemon at %s", self.bridge_url)
200
+ logger.info("SecureComs: connected to daemon")
188
201
  return True
189
202
 
190
203
  async def _open_ws(self) -> bool:
@@ -230,7 +243,7 @@ class SecureComsAdapter(BasePlatformAdapter):
230
243
  changed = self._hold_reason != reason
231
244
  self._hold_reason = reason
232
245
  if changed:
233
- logger.warning("SecureComs: waiting for SecureComs daemon: %s", reason)
246
+ logger.warning("SecureComs: waiting for SecureComs daemon")
234
247
  self._set_fatal_error(
235
248
  "waiting_for_daemon",
236
249
  f"waiting for SecureComs daemon: {reason}",
@@ -253,7 +266,10 @@ class SecureComsAdapter(BasePlatformAdapter):
253
266
  except asyncio.CancelledError:
254
267
  raise
255
268
  except Exception as e:
256
- logger.warning("SecureComs: receive error: %s", e)
269
+ logger.warning(
270
+ "SecureComs: receive failed (error_class=%s)",
271
+ _error_class(e),
272
+ )
257
273
  if self._stopped:
258
274
  break
259
275
  self._mark_disconnected()
@@ -311,7 +327,10 @@ class SecureComsAdapter(BasePlatformAdapter):
311
327
  "is_dm": bool(c.get("is_dm")),
312
328
  }
313
329
  except Exception as e:
314
- logger.debug("SecureComs: channel refresh failed: %s", e)
330
+ logger.debug(
331
+ "SecureComs: channel refresh failed (error_class=%s)",
332
+ _error_class(e),
333
+ )
315
334
 
316
335
  # ── Status emit ───────────────────────────────────────────────────────
317
336
 
@@ -321,7 +340,10 @@ class SecureComsAdapter(BasePlatformAdapter):
321
340
  try:
322
341
  await self._ws.send_json(frame)
323
342
  except Exception as e:
324
- logger.debug("SecureComs: status frame send failed: %s", e)
343
+ logger.debug(
344
+ "SecureComs: status frame send failed (error_class=%s)",
345
+ _error_class(e),
346
+ )
325
347
 
326
348
  async def _tick_loop(self) -> None:
327
349
  """Drives ChannelStatusManager.tick() so a channel goes idle after
@@ -334,7 +356,10 @@ class SecureComsAdapter(BasePlatformAdapter):
334
356
  except asyncio.CancelledError:
335
357
  raise
336
358
  except Exception as e:
337
- logger.debug("SecureComs: status tick error: %s", e)
359
+ logger.debug(
360
+ "SecureComs: status tick failed (error_class=%s)",
361
+ _error_class(e),
362
+ )
338
363
 
339
364
  # ── Inbound ───────────────────────────────────────────────────────────
340
365
 
@@ -360,7 +385,10 @@ class SecureComsAdapter(BasePlatformAdapter):
360
385
  {"type": "ack", "message_id": frame.get("message_id")}
361
386
  )
362
387
  except Exception as e:
363
- logger.warning("SecureComs: ack failed: %s", e)
388
+ logger.warning(
389
+ "SecureComs: ack failed (error_class=%s)",
390
+ _error_class(e),
391
+ )
364
392
  elif msg.type in (aiohttp.WSMsgType.CLOSED, aiohttp.WSMsgType.ERROR):
365
393
  return # WS closed → supervisor reconnects
366
394
 
@@ -393,6 +421,29 @@ class SecureComsAdapter(BasePlatformAdapter):
393
421
 
394
422
  # ── Outbound ──────────────────────────────────────────────────────────
395
423
 
424
+ _BLOCK_SEQ_MAX = 1000
425
+
426
+ def _block_key(self, reply_to: str, content: str) -> Dict[str, Any]:
427
+ """SEC-06 #18: a stable idempotency_key per reply block.
428
+
429
+ The daemon matches a keyless retry only against the most recent part,
430
+ so two identical blocks would collapse into one message. The key is
431
+ ``<reply_to>:<n>``: n advances after a confirmed send, and also when a
432
+ new block arrives after an unconfirmed one (that block is abandoned). A
433
+ retry of the unconfirmed block (same text) reuses its key, so the daemon
434
+ never publishes it twice.
435
+ """
436
+ st = self._block_seq.get(reply_to)
437
+ if st is None:
438
+ if len(self._block_seq) >= self._BLOCK_SEQ_MAX:
439
+ self._block_seq.pop(next(iter(self._block_seq)))
440
+ st = {"seq": 0, "pending": None}
441
+ self._block_seq[reply_to] = st
442
+ if st["pending"] is not None and st["pending"] != content:
443
+ st["seq"] += 1
444
+ st["pending"] = content
445
+ return st
446
+
396
447
  async def send(
397
448
  self,
398
449
  chat_id: str,
@@ -402,6 +453,7 @@ class SecureComsAdapter(BasePlatformAdapter):
402
453
  ) -> SendResult:
403
454
  if not self._session or self._session.closed:
404
455
  return SendResult(success=False, error="not connected")
456
+ block = self._block_key(reply_to, content) if reply_to else None
405
457
  try:
406
458
  async with self._session.post(
407
459
  f"{self.bridge_url}/v1/send",
@@ -410,9 +462,13 @@ class SecureComsAdapter(BasePlatformAdapter):
410
462
  "channel_id": chat_id,
411
463
  "text": content,
412
464
  **({"in_reply_to": reply_to} if reply_to else {}),
465
+ **({"idempotency_key": f"{reply_to}:{block['seq']}"} if block else {}),
413
466
  },
414
467
  ) as r:
415
468
  if r.status == 200:
469
+ if block:
470
+ block["seq"] += 1
471
+ block["pending"] = None
416
472
  j = await r.json()
417
473
  await self._status.on_reply_posted(chat_id)
418
474
  return SendResult(success=True, message_id=str(j.get("message_id")))
@@ -352,8 +352,31 @@ class TestSendWiring:
352
352
  "channel_id": "chan-1",
353
353
  "text": "hello",
354
354
  "in_reply_to": "inbound-7",
355
+ "idempotency_key": "inbound-7:0",
355
356
  }
356
357
 
358
+ @pytest.mark.asyncio
359
+ async def test_send_gives_each_reply_block_a_stable_idempotency_key(self, monkeypatch):
360
+ # SEC-06 #18 (PR #716 review MEDIUM-2): the daemon matches a keyless
361
+ # retry only against the most recent part, so two identical blocks
362
+ # collapse into one message. A per-block key keeps them apart, and the
363
+ # SAME key on a retry of an unconfirmed block keeps it from publishing twice.
364
+ a, _ = make_adapter(monkeypatch)
365
+ a.bridge_url = "http://127.0.0.1:9001"
366
+ a._token = "tok"
367
+ a._session = FakeSession(post_response=(200, {"message_id": "m"}))
368
+ await a.send("chan-1", "ok", reply_to="in-1")
369
+ await a.send("chan-1", "ok", reply_to="in-1") # a second, identical block
370
+ a._session._post_response = (500, "boom")
371
+ await a.send("chan-1", "third", reply_to="in-1") # fails, unconfirmed
372
+ await a.send("chan-1", "third", reply_to="in-1") # retry of the same block
373
+ a._session._post_response = (200, {"message_id": "m"})
374
+ await a.send("chan-1", "fourth", reply_to="in-1") # the previous block is abandoned
375
+ await a.send("chan-1", "other", reply_to="in-2")
376
+ await a.send("chan-1", "no correlation")
377
+ keys = [c[2].get("idempotency_key") for c in a._session.post_calls]
378
+ assert keys == ["in-1:0", "in-1:1", "in-1:2", "in-1:2", "in-1:3", "in-2:0", None]
379
+
357
380
  @pytest.mark.asyncio
358
381
  async def test_send_failure_does_not_fire_on_reply_posted(self, monkeypatch):
359
382
  a, _ = make_adapter(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"