@securecoms/agent-bridge 0.19.0 → 0.21.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
  ```
@@ -406,7 +406,11 @@ class SecureComsAdapter(BasePlatformAdapter):
406
406
  async with self._session.post(
407
407
  f"{self.bridge_url}/v1/send",
408
408
  headers=self._auth(),
409
- json={"channel_id": chat_id, "text": content},
409
+ json={
410
+ "channel_id": chat_id,
411
+ "text": content,
412
+ **({"in_reply_to": reply_to} if reply_to else {}),
413
+ },
410
414
  ) as r:
411
415
  if r.status == 200:
412
416
  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):
@@ -169,7 +169,7 @@ function listAccountIds(cfg) {
169
169
  // Dependency-free: Node 22 global WebSocket + global fetch.
170
170
  // ---------------------------------------------------------------------------
171
171
 
172
- class LoopbackChannel {
172
+ export class LoopbackChannel {
173
173
  constructor({ boundIdentity, sc, onMessage, onStateChange, log }) {
174
174
  // Fixed for the connector's life (spec §4.3): re-derived nowhere below.
175
175
  this.boundIdentity = boundIdentity;
@@ -343,11 +343,15 @@ class LoopbackChannel {
343
343
  }
344
344
 
345
345
  /** Send a reply into a specific SecureComs channel via the daemon. */
346
- async send(channelId, text) {
346
+ async send(channelId, text, inReplyTo) {
347
347
  const r = await fetch(`${this.bridgeUrl}/v1/send`, {
348
348
  method: "POST",
349
349
  headers: { "content-type": "application/json", authorization: `Bearer ${this.token}` },
350
- body: JSON.stringify({ channel_id: channelId, text }),
350
+ body: JSON.stringify({
351
+ channel_id: channelId,
352
+ text,
353
+ ...(inReplyTo ? { in_reply_to: inReplyTo } : {}),
354
+ }),
351
355
  });
352
356
  if (!r.ok) throw new Error(`send failed: ${r.status} ${(await r.text()).slice(0, 200)}`);
353
357
  return r.json();
@@ -466,7 +470,7 @@ async function _handleInbound({ text, metadata, channel, account, cfg }) {
466
470
  // Drop chain-of-thought / reasoning blocks; only send finalized text.
467
471
  if (payload?.kind === "thinking" || payload?.kind === "reasoning") return;
468
472
  const t = (payload?.text ?? "").trim();
469
- if (t) await channel.send(conv, t);
473
+ if (t) await channel.send(conv, t, metadata.messageId);
470
474
  },
471
475
  onError: (err, info) => core.error?.(`[SecureComs] ${info?.kind ?? "reply"} error: ${String(err)}`),
472
476
  },
@@ -1,7 +1,9 @@
1
- import { describe, it, expect } from "vitest";
1
+ import { afterEach, describe, it, expect, vi } from "vitest";
2
2
  import { readFileSync } from "node:fs";
3
3
  import { join } from "node:path";
4
4
 
5
+ afterEach(() => vi.unstubAllGlobals());
6
+
5
7
  describe("openclaw-entry", () => {
6
8
  it("loads as ESM and exposes the plugin entry without connecting", async () => {
7
9
  const mod = await import("./openclaw-entry.js");
@@ -27,4 +29,24 @@ describe("openclaw-entry", () => {
27
29
  expect(topLevel).not.toMatch(/new WebSocket\(/);
28
30
  expect(topLevel).not.toMatch(/\bfetch\(/);
29
31
  });
32
+
33
+ it("posts content-free inbound correlation with a runtime reply", async () => {
34
+ const requests: Array<Record<string, unknown>> = [];
35
+ vi.stubGlobal("fetch", async (_url: string, init: RequestInit) => {
36
+ requests.push(JSON.parse(String(init.body)) as Record<string, unknown>);
37
+ return { ok: true, json: async () => ({ message_id: "out-1" }) };
38
+ });
39
+ const { LoopbackChannel } = await import("./openclaw-entry.js");
40
+ const channel = new LoopbackChannel({ boundIdentity: "agent-1" });
41
+ channel.bridgeUrl = "http://127.0.0.1:7090";
42
+ channel.token = "token";
43
+
44
+ await channel.send("channel-1", "reply text", "inbound-1");
45
+
46
+ expect(requests).toEqual([{
47
+ channel_id: "channel-1",
48
+ text: "reply text",
49
+ in_reply_to: "inbound-1",
50
+ }]);
51
+ });
30
52
  });