@securecoms/agent-bridge 0.23.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 +83 -2
- package/connectors/hermes/adapter.py +30 -0
- package/connectors/hermes/tests/test_adapter_wiring.py +23 -0
- package/connectors/openclaw/openclaw-entry.inbound.test.ts +2 -1
- package/connectors/openclaw/openclaw-entry.js +30 -0
- package/connectors/openclaw/openclaw-entry.smoke.test.ts +26 -0
- package/dist/bin.js +4860 -545
- package/dist/bridge-agent.d.ts +630 -21
- package/dist/coordination.d.ts +24 -2
- package/dist/errors.d.ts +1 -1
- package/dist/http-server.d.ts +4 -4
- package/dist/index.js +4879 -564
- package/dist/reply-intent.d.ts +68 -0
- package/dist/version.d.ts +1 -1
- package/dist/voice/fast-client.d.ts +12 -2
- package/dist/ws-server.d.ts +13 -4
- package/package.json +4 -1
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"
|
|
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":"..." } //
|
|
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
|
-
|
|
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
|
});
|