@securecoms/agent-bridge 0.23.0 → 0.25.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,7 +274,7 @@ 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
  ```
@@ -279,6 +288,78 @@ window where the result is known. It does not pretend an indeterminate external
279
288
  tool call is safe to repeat; those calls use the runtime's separate reconciliation
280
289
  ledger.
281
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.
362
+
282
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.
283
364
 
284
365
  **Keepalive.** The daemon pings every 30 seconds and reaps a socket that misses a pong. Clients using the standard WebSocket API answer pings automatically.
@@ -123,6 +123,8 @@ class SecureComsAdapter(BasePlatformAdapter):
123
123
  self.ws_url: Optional[str] = None
124
124
  self._token: Optional[str] = None
125
125
 
126
+ # SEC-06 #18: reply_to -> {"seq": n, "pending": text|None}; see _block_key.
127
+ self._block_seq: Dict[str, Dict[str, Any]] = {}
126
128
  self._session = None # aiohttp.ClientSession - the CURRENT connected link
127
129
  self._ws = None
128
130
  self._recv_task: Optional[asyncio.Task] = None
@@ -419,6 +421,29 @@ class SecureComsAdapter(BasePlatformAdapter):
419
421
 
420
422
  # ── Outbound ──────────────────────────────────────────────────────────
421
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
+
422
447
  async def send(
423
448
  self,
424
449
  chat_id: str,
@@ -428,6 +453,7 @@ class SecureComsAdapter(BasePlatformAdapter):
428
453
  ) -> SendResult:
429
454
  if not self._session or self._session.closed:
430
455
  return SendResult(success=False, error="not connected")
456
+ block = self._block_key(reply_to, content) if reply_to else None
431
457
  try:
432
458
  async with self._session.post(
433
459
  f"{self.bridge_url}/v1/send",
@@ -436,9 +462,13 @@ class SecureComsAdapter(BasePlatformAdapter):
436
462
  "channel_id": chat_id,
437
463
  "text": content,
438
464
  **({"in_reply_to": reply_to} if reply_to else {}),
465
+ **({"idempotency_key": f"{reply_to}:{block['seq']}"} if block else {}),
439
466
  },
440
467
  ) as r:
441
468
  if r.status == 200:
469
+ if block:
470
+ block["seq"] += 1
471
+ block["pending"] = None
442
472
  j = await r.json()
443
473
  await self._status.on_reply_posted(chat_id)
444
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)
@@ -273,7 +273,8 @@ describe("openclaw-entry — inbound sender, session, authority, dedup (P0 Task
273
273
  const { instances } = await start(rt.runtime);
274
274
  deliver(instances[0], { channel_id: "chan-R", message_id: "m-R", sender_id: MEMBER_ID, text: "ping", authorization: auth("team_room") });
275
275
  await waitFor(() => acksOf(instances[0]).includes("m-R"));
276
- expect(sends).toEqual([{ channel_id: "chan-R", text: "pong", in_reply_to: "m-R" }]);
276
+ // SEC-06 #18 (PR #716 review MEDIUM-2): each block carries a stable key.
277
+ expect(sends).toEqual([{ channel_id: "chan-R", text: "pong", in_reply_to: "m-R", idempotency_key: "m-R:0" }]);
277
278
  });
278
279
 
279
280
  // ---------------------------------------------------------------- 2 ----
@@ -260,6 +260,7 @@ export class LoopbackChannel {
260
260
  this._seen = new Set();
261
261
  this._inFlight = new Set();
262
262
  this._failures = new Map();
263
+ this._blockSeq = new Map(); // SEC-06 #18: inReplyTo -> { seq, pending } (see _blockKey)
263
264
  // Consecutive failed turns. While > 0, a WS open does NOT reset the
264
265
  // reconnect backoff, so a failing turn is retried with growing delay
265
266
  // instead of a ~250 ms redelivery loop.
@@ -515,8 +516,32 @@ export class LoopbackChannel {
515
516
  }
516
517
  }
517
518
 
519
+ /**
520
+ * SEC-06 #18: a stable idempotency_key per reply block. The daemon matches a
521
+ * keyless retry only against the most recent part, so two identical blocks
522
+ * would collapse into one message. The key is `<inReplyTo>:<n>`: n advances
523
+ * after a confirmed send, and also when a new block arrives after an
524
+ * unconfirmed one (that block is abandoned). A retry of the unconfirmed block
525
+ * (same text) reuses its key, so the daemon never publishes it twice.
526
+ */
527
+ _blockKey(inReplyTo, text) {
528
+ let st = this._blockSeq.get(inReplyTo);
529
+ if (!st) {
530
+ st = { seq: 0, pending: undefined };
531
+ if (this._blockSeq.size >= SEEN_MAX) {
532
+ const oldest = this._blockSeq.keys().next();
533
+ if (!oldest.done) this._blockSeq.delete(oldest.value);
534
+ }
535
+ this._blockSeq.set(inReplyTo, st);
536
+ }
537
+ if (st.pending !== undefined && st.pending !== text) st.seq++;
538
+ st.pending = text;
539
+ return { key: `${inReplyTo}:${st.seq}`, st };
540
+ }
541
+
518
542
  /** Send a reply into a specific SecureComs channel via the daemon. */
519
543
  async send(channelId, text, inReplyTo) {
544
+ const block = inReplyTo ? this._blockKey(inReplyTo, text) : null;
520
545
  const r = await fetch(`${this.bridgeUrl}/v1/send`, {
521
546
  method: "POST",
522
547
  headers: { "content-type": "application/json", authorization: `Bearer ${this.token}` },
@@ -524,9 +549,14 @@ export class LoopbackChannel {
524
549
  channel_id: channelId,
525
550
  text,
526
551
  ...(inReplyTo ? { in_reply_to: inReplyTo } : {}),
552
+ ...(block ? { idempotency_key: block.key } : {}),
527
553
  }),
528
554
  });
529
555
  if (!r.ok) throw new Error(`send failed: ${r.status} ${(await r.text()).slice(0, 200)}`);
556
+ if (block) {
557
+ block.st.seq++;
558
+ block.st.pending = undefined;
559
+ }
530
560
  return r.json();
531
561
  }
532
562
 
@@ -47,6 +47,32 @@ describe("openclaw-entry", () => {
47
47
  channel_id: "channel-1",
48
48
  text: "reply text",
49
49
  in_reply_to: "inbound-1",
50
+ idempotency_key: "inbound-1:0",
50
51
  }]);
51
52
  });
53
+
54
+ it("SEC-06 #18: every reply block gets a stable idempotency_key (identical blocks stay two messages; a retry reuses its key)", async () => {
55
+ const keys: unknown[] = [];
56
+ let ok = true;
57
+ vi.stubGlobal("fetch", async (_url: string, init: RequestInit) => {
58
+ keys.push((JSON.parse(String(init.body)) as Record<string, unknown>).idempotency_key);
59
+ return ok
60
+ ? { ok: true, json: async () => ({ message_id: "m" }) }
61
+ : { ok: false, status: 500, text: async () => "boom" };
62
+ });
63
+ const { LoopbackChannel } = await import("./openclaw-entry.js");
64
+ const channel = new LoopbackChannel({ boundIdentity: "agent-1" });
65
+ channel.bridgeUrl = "http://127.0.0.1:7090";
66
+ channel.token = "token";
67
+ await channel.send("c", "ok", "in-1");
68
+ await channel.send("c", "ok", "in-1"); // a second, identical block
69
+ ok = false;
70
+ await expect(channel.send("c", "third", "in-1")).rejects.toThrow();
71
+ await expect(channel.send("c", "third", "in-1")).rejects.toThrow(); // retry of the same block
72
+ ok = true;
73
+ await channel.send("c", "fourth", "in-1"); // the unconfirmed block is abandoned
74
+ await channel.send("c", "other", "in-2");
75
+ await channel.send("c", "no correlation");
76
+ expect(keys).toEqual(["in-1:0", "in-1:1", "in-1:2", "in-1:2", "in-1:3", "in-2:0", undefined]);
77
+ });
52
78
  });