@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 +40 -1
- package/connectors/hermes/adapter.py +5 -1
- package/connectors/hermes/tests/test_adapter_wiring.py +6 -1
- package/connectors/openclaw/openclaw-entry.js +8 -4
- package/connectors/openclaw/openclaw-entry.smoke.test.ts +23 -1
- package/dist/bin.js +506 -35
- package/dist/bridge-agent.d.ts +65 -0
- package/dist/index.js +501 -30
- package/dist/reply-receipts.d.ts +30 -0
- package/dist/service/runtime-config-dir.d.ts +35 -0
- package/dist/version.d.ts +1 -0
- package/dist/ws-server.d.ts +4 -2
- package/package.json +2 -2
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={
|
|
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({
|
|
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
|
});
|