@securecoms/agent-bridge 0.20.0 → 0.23.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 +48 -2
- package/connectors/hermes/adapter.py +38 -8
- package/connectors/hermes/tests/test_adapter_wiring.py +6 -1
- package/connectors/hermes/tests/test_logging_privacy.py +61 -0
- package/connectors/openclaw/openclaw-entry.inbound.test.ts +560 -0
- package/connectors/openclaw/openclaw-entry.js +248 -51
- package/connectors/openclaw/openclaw-entry.smoke.test.ts +23 -1
- package/dist/bin.d.ts +3 -7
- package/dist/bin.js +3300 -476
- package/dist/bridge-agent.d.ts +280 -8
- package/dist/coordination.d.ts +90 -5
- package/dist/decrypt-error-log-limiter.d.ts +6 -30
- package/dist/errors.d.ts +1 -1
- package/dist/http-server.d.ts +20 -1
- package/dist/index.js +3289 -456
- package/dist/reply-receipts.d.ts +30 -0
- package/dist/service/runtime-config-dir.d.ts +34 -0
- package/dist/service-log.d.ts +7 -0
- package/dist/summarize.d.ts +3 -2
- package/dist/version.d.ts +1 -1
- package/dist/ws-server.d.ts +30 -3
- package/package.json +2 -2
|
@@ -0,0 +1,560 @@
|
|
|
1
|
+
// packages/connect-agent-bridge/connectors/openclaw/openclaw-entry.inbound.test.ts
|
|
2
|
+
//
|
|
3
|
+
// P0 containment Task 8: the OpenClaw connector's INBOUND path.
|
|
4
|
+
//
|
|
5
|
+
// 1. Real sender + per-channel session: the frame's sender_id and
|
|
6
|
+
// channel_id reach OpenClaw; two channels never share a session key.
|
|
7
|
+
// 2. Owner-only command authority: CommandAuthorized is true ONLY when the
|
|
8
|
+
// daemon's authorization block says audience "owner_dm" with an
|
|
9
|
+
// "active" sender (the same predicate the bridge itself uses for
|
|
10
|
+
// mayReadOpenClawMemoryForTurn). Group rooms are ChatType "group".
|
|
11
|
+
// 3. At-least-once without re-running turns: message_id dedup, reply-receipt
|
|
12
|
+
// reconciliation before any dispatch, no ack on handler failure, no
|
|
13
|
+
// silent drop when the runtime is not registered.
|
|
14
|
+
//
|
|
15
|
+
// Drives the REAL plugin.gateway.startAccount path (same harness shape as
|
|
16
|
+
// openclaw-entry.gate.test.ts) with a fake OpenClaw runtime that records what
|
|
17
|
+
// the connector hands it, a fake loopback WebSocket that records acks, and a
|
|
18
|
+
// fake fetch for /healthz, /v1/reply-receipt, /v1/send and /v1/status.
|
|
19
|
+
|
|
20
|
+
import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
|
|
21
|
+
import { mkdtempSync, writeFileSync } from "node:fs";
|
|
22
|
+
import { tmpdir } from "node:os";
|
|
23
|
+
import { join } from "node:path";
|
|
24
|
+
import { buildConnectionFile, writeConnectionFile } from "../../src/service/connection-file.js";
|
|
25
|
+
|
|
26
|
+
const BOUND_ID = "agent-identity";
|
|
27
|
+
const OWNER_ID = "owner-human";
|
|
28
|
+
const MEMBER_ID = "team-member";
|
|
29
|
+
const PORT = 7098;
|
|
30
|
+
const TOKEN_VALUE = "inbound-test-token";
|
|
31
|
+
const ORIGINAL_CONN_FILE_ENV = process.env.SECURECOMS_CONNECTION_FILE;
|
|
32
|
+
|
|
33
|
+
type Auth = { version: 1; audience: string; sender_membership: string; tool_grants: unknown[] };
|
|
34
|
+
const auth = (audience: string, sender_membership = "active"): Auth => ({
|
|
35
|
+
version: 1, audience, sender_membership, tool_grants: [],
|
|
36
|
+
});
|
|
37
|
+
|
|
38
|
+
function waitFor(predicate: () => boolean, timeout = 3000): Promise<void> {
|
|
39
|
+
const start = Date.now();
|
|
40
|
+
return new Promise((resolve, reject) => {
|
|
41
|
+
const check = () => {
|
|
42
|
+
if (predicate()) return resolve();
|
|
43
|
+
if (Date.now() - start > timeout) return reject(new Error("waitFor: condition never became true"));
|
|
44
|
+
setTimeout(check, 5);
|
|
45
|
+
};
|
|
46
|
+
check();
|
|
47
|
+
});
|
|
48
|
+
}
|
|
49
|
+
|
|
50
|
+
type FakeWs = {
|
|
51
|
+
url: string;
|
|
52
|
+
sent: Array<Record<string, unknown>>;
|
|
53
|
+
closed: boolean;
|
|
54
|
+
_emit: (type: string, ev?: unknown) => void;
|
|
55
|
+
};
|
|
56
|
+
|
|
57
|
+
function makeFakeWebSocket() {
|
|
58
|
+
const instances: FakeWs[] = [];
|
|
59
|
+
class FakeWebSocket {
|
|
60
|
+
url: string;
|
|
61
|
+
sent: Array<Record<string, unknown>> = [];
|
|
62
|
+
closed = false;
|
|
63
|
+
listeners: Record<string, Array<(ev?: unknown) => void>> = {};
|
|
64
|
+
constructor(url: string) {
|
|
65
|
+
this.url = url;
|
|
66
|
+
instances.push(this);
|
|
67
|
+
}
|
|
68
|
+
addEventListener(type: string, cb: (ev?: unknown) => void) {
|
|
69
|
+
(this.listeners[type] ??= []).push(cb);
|
|
70
|
+
}
|
|
71
|
+
send(data: string) {
|
|
72
|
+
this.sent.push(JSON.parse(data));
|
|
73
|
+
}
|
|
74
|
+
close() {
|
|
75
|
+
if (this.closed) return;
|
|
76
|
+
this.closed = true;
|
|
77
|
+
this._emit("close");
|
|
78
|
+
}
|
|
79
|
+
_emit(type: string, ev?: unknown) {
|
|
80
|
+
for (const cb of this.listeners[type] ?? []) cb(ev ?? {});
|
|
81
|
+
}
|
|
82
|
+
}
|
|
83
|
+
return { FakeWebSocket, instances };
|
|
84
|
+
}
|
|
85
|
+
|
|
86
|
+
/** A fake OpenClaw runtime recording every call the connector makes. */
|
|
87
|
+
function makeRuntime() {
|
|
88
|
+
const routes: Array<Record<string, any>> = [];
|
|
89
|
+
const contexts: Array<Record<string, any>> = [];
|
|
90
|
+
const recorded: Array<Record<string, any>> = [];
|
|
91
|
+
const envelopes: Array<Record<string, any>> = [];
|
|
92
|
+
const dispatches: Array<Record<string, any>> = [];
|
|
93
|
+
let dispatchImpl: (args: any) => Promise<void> = async () => {};
|
|
94
|
+
const runtime = {
|
|
95
|
+
channel: {
|
|
96
|
+
routing: {
|
|
97
|
+
resolveAgentRoute: (args: any) => {
|
|
98
|
+
routes.push(args);
|
|
99
|
+
// Mirror OpenClaw's default dmScope="main": every direct peer
|
|
100
|
+
// collapses into ONE session key. The connector must not rely on
|
|
101
|
+
// this key for isolation.
|
|
102
|
+
return { agentId: "main", sessionKey: "agent:main:main" };
|
|
103
|
+
},
|
|
104
|
+
},
|
|
105
|
+
session: {
|
|
106
|
+
resolveStorePath: () => "/tmp/openclaw-store-unused",
|
|
107
|
+
readSessionUpdatedAt: () => undefined,
|
|
108
|
+
recordInboundSession: async (args: any) => { recorded.push(args); },
|
|
109
|
+
},
|
|
110
|
+
reply: {
|
|
111
|
+
resolveEnvelopeFormatOptions: () => ({}),
|
|
112
|
+
formatAgentEnvelope: (args: any) => { envelopes.push(args); return `ENV:${args.body}`; },
|
|
113
|
+
finalizeInboundContext: (ctx: any) => { contexts.push(ctx); return ctx; },
|
|
114
|
+
dispatchReplyWithBufferedBlockDispatcher: async (args: any) => {
|
|
115
|
+
dispatches.push(args);
|
|
116
|
+
await dispatchImpl(args);
|
|
117
|
+
},
|
|
118
|
+
},
|
|
119
|
+
},
|
|
120
|
+
error: () => {},
|
|
121
|
+
};
|
|
122
|
+
return {
|
|
123
|
+
runtime,
|
|
124
|
+
routes,
|
|
125
|
+
contexts,
|
|
126
|
+
recorded,
|
|
127
|
+
envelopes,
|
|
128
|
+
dispatches,
|
|
129
|
+
setDispatch(fn: (args: any) => Promise<void>) { dispatchImpl = fn; },
|
|
130
|
+
};
|
|
131
|
+
}
|
|
132
|
+
|
|
133
|
+
describe("openclaw-entry — inbound sender, session, authority, dedup (P0 Task 8)", () => {
|
|
134
|
+
let home: string;
|
|
135
|
+
let logs: string[];
|
|
136
|
+
let controller: AbortController;
|
|
137
|
+
let receiptFound: boolean;
|
|
138
|
+
let receiptStatus: number;
|
|
139
|
+
let receiptBadBody: boolean;
|
|
140
|
+
let receiptLookups: string[];
|
|
141
|
+
let sends: Array<Record<string, unknown>>;
|
|
142
|
+
|
|
143
|
+
beforeEach(() => {
|
|
144
|
+
home = mkdtempSync(join(tmpdir(), "sc-inbound-"));
|
|
145
|
+
const tokenFile = join(home, "bridge-token");
|
|
146
|
+
writeFileSync(tokenFile, TOKEN_VALUE, { mode: 0o600 });
|
|
147
|
+
writeConnectionFile(home, buildConnectionFile({
|
|
148
|
+
identityId: BOUND_ID,
|
|
149
|
+
port: PORT,
|
|
150
|
+
tokenFile,
|
|
151
|
+
slug: "inbound-test",
|
|
152
|
+
label: "Inbound Test",
|
|
153
|
+
instanceId: "inbound-test-instance",
|
|
154
|
+
now: new Date().toISOString(),
|
|
155
|
+
}));
|
|
156
|
+
process.env.SECURECOMS_CONNECTION_FILE = join(home, ".securecoms", "connections", `${BOUND_ID}.json`);
|
|
157
|
+
|
|
158
|
+
logs = [];
|
|
159
|
+
receiptFound = false;
|
|
160
|
+
receiptStatus = 200;
|
|
161
|
+
receiptBadBody = false;
|
|
162
|
+
receiptLookups = [];
|
|
163
|
+
sends = [];
|
|
164
|
+
vi.stubGlobal("fetch", vi.fn(async (url: string, init?: RequestInit) => {
|
|
165
|
+
const u = String(url);
|
|
166
|
+
if (u.endsWith("/healthz")) {
|
|
167
|
+
return { ok: true, json: async () => ({ identity_id: BOUND_ID, joined_channels: 2 }) };
|
|
168
|
+
}
|
|
169
|
+
if (u.includes("/v1/reply-receipt")) {
|
|
170
|
+
receiptLookups.push(new URL(u).searchParams.get("in_reply_to") ?? "");
|
|
171
|
+
if (receiptBadBody) {
|
|
172
|
+
return {
|
|
173
|
+
ok: true,
|
|
174
|
+
status: 200,
|
|
175
|
+
json: async () => { throw new SyntaxError("Unexpected token in JSON"); },
|
|
176
|
+
};
|
|
177
|
+
}
|
|
178
|
+
return { ok: receiptStatus === 200, status: receiptStatus, json: async () => ({ found: receiptFound }) };
|
|
179
|
+
}
|
|
180
|
+
if (u.endsWith("/v1/send")) {
|
|
181
|
+
sends.push(JSON.parse(String(init?.body)));
|
|
182
|
+
return { ok: true, json: async () => ({ message_id: "reply-1" }) };
|
|
183
|
+
}
|
|
184
|
+
if (u.endsWith("/v1/status")) return { ok: true, json: async () => ({ ok: true }) };
|
|
185
|
+
throw new Error(`unexpected fetch: ${u}`);
|
|
186
|
+
}));
|
|
187
|
+
controller = new AbortController();
|
|
188
|
+
});
|
|
189
|
+
|
|
190
|
+
afterEach(async () => {
|
|
191
|
+
controller.abort();
|
|
192
|
+
vi.unstubAllGlobals();
|
|
193
|
+
vi.unmock("node:os");
|
|
194
|
+
if (ORIGINAL_CONN_FILE_ENV === undefined) delete process.env.SECURECOMS_CONNECTION_FILE;
|
|
195
|
+
else process.env.SECURECOMS_CONNECTION_FILE = ORIGINAL_CONN_FILE_ENV;
|
|
196
|
+
vi.resetModules();
|
|
197
|
+
});
|
|
198
|
+
|
|
199
|
+
async function start(runtime: unknown) {
|
|
200
|
+
vi.resetModules();
|
|
201
|
+
vi.doMock("node:os", async (importOriginal) => {
|
|
202
|
+
const actual = await importOriginal<typeof import("node:os")>();
|
|
203
|
+
return { ...actual, homedir: () => home };
|
|
204
|
+
});
|
|
205
|
+
const mod = await import("./openclaw-entry.js");
|
|
206
|
+
let plugin: any;
|
|
207
|
+
mod.default.register({ runtime, registerChannel: ({ plugin: p }: { plugin: unknown }) => { plugin = p; } });
|
|
208
|
+
const { FakeWebSocket, instances } = makeFakeWebSocket();
|
|
209
|
+
vi.stubGlobal("WebSocket", FakeWebSocket);
|
|
210
|
+
const account = plugin.config.resolveAccount({}, "default");
|
|
211
|
+
const done = plugin.gateway.startAccount({
|
|
212
|
+
account,
|
|
213
|
+
cfg: {},
|
|
214
|
+
log: (m: string) => logs.push(String(m)),
|
|
215
|
+
abortSignal: controller.signal,
|
|
216
|
+
});
|
|
217
|
+
await waitFor(() => instances.length >= 1);
|
|
218
|
+
instances[0]._emit("open");
|
|
219
|
+
return { instances, done };
|
|
220
|
+
}
|
|
221
|
+
|
|
222
|
+
function deliver(ws: FakeWs, frame: Record<string, unknown>) {
|
|
223
|
+
ws._emit("message", { data: JSON.stringify({ type: "message", ts: 1_700_000_000_000, ...frame }) });
|
|
224
|
+
}
|
|
225
|
+
|
|
226
|
+
const acksOf = (ws: FakeWs) => ws.sent.filter((f) => f.type === "ack").map((f) => f.message_id);
|
|
227
|
+
|
|
228
|
+
// ---------------------------------------------------------------- 1 ----
|
|
229
|
+
|
|
230
|
+
it("passes the frame's REAL sender and channel, with a distinct session key per channel", async () => {
|
|
231
|
+
const rt = makeRuntime();
|
|
232
|
+
const { instances } = await start(rt.runtime);
|
|
233
|
+
const ws = instances[0];
|
|
234
|
+
|
|
235
|
+
deliver(ws, { channel_id: "chan-A", message_id: "m-A", sender_id: MEMBER_ID, sender_name: "Tess", text: "hi A", authorization: auth("team_room") });
|
|
236
|
+
deliver(ws, { channel_id: "chan-B", message_id: "m-B", sender_id: OWNER_ID, text: "hi B", authorization: auth("owner_dm") });
|
|
237
|
+
await waitFor(() => acksOf(ws).length === 2);
|
|
238
|
+
|
|
239
|
+
const [a, b] = rt.contexts;
|
|
240
|
+
expect(a.SenderId).toBe(MEMBER_ID);
|
|
241
|
+
expect(a.From).toBe(`securecoms:${MEMBER_ID}`);
|
|
242
|
+
expect(a.SenderName).toBe("Tess");
|
|
243
|
+
expect(b.SenderId).toBe(OWNER_ID);
|
|
244
|
+
expect(b.SenderName).toBe(OWNER_ID); // no roster name -> falls back to the id, never "Owner"
|
|
245
|
+
expect(rt.contexts.some((c) => c.SenderId === "securecoms:owner")).toBe(false);
|
|
246
|
+
expect(rt.envelopes.map((e) => e.from)).toEqual(["Tess", OWNER_ID]);
|
|
247
|
+
|
|
248
|
+
// Route + session are keyed by the CHANNEL, not by a shared owner peer.
|
|
249
|
+
expect(rt.routes.map((r) => r.peer.id)).toEqual(["chan-A", "chan-B"]);
|
|
250
|
+
expect(a.SessionKey).toContain("chan-A");
|
|
251
|
+
expect(b.SessionKey).toContain("chan-B");
|
|
252
|
+
expect(a.SessionKey).not.toBe(b.SessionKey);
|
|
253
|
+
// The runtime's collapsed DM key is never used for isolation.
|
|
254
|
+
expect(rt.contexts.map((c) => c.SessionKey)).not.toContain("agent:main:main");
|
|
255
|
+
expect(rt.recorded.map((r) => r.sessionKey)).toEqual([a.SessionKey, b.SessionKey]);
|
|
256
|
+
});
|
|
257
|
+
|
|
258
|
+
it("two DMs from different humans do not share a session", async () => {
|
|
259
|
+
const rt = makeRuntime();
|
|
260
|
+
const { instances } = await start(rt.runtime);
|
|
261
|
+
const ws = instances[0];
|
|
262
|
+
deliver(ws, { channel_id: "dm-1", message_id: "d1", sender_id: OWNER_ID, text: "x", authorization: auth("owner_dm") });
|
|
263
|
+
deliver(ws, { channel_id: "dm-2", message_id: "d2", sender_id: MEMBER_ID, text: "y", authorization: auth("other_human_dm") });
|
|
264
|
+
await waitFor(() => acksOf(ws).length === 2);
|
|
265
|
+
expect(rt.contexts[0].ChatType).toBe("direct");
|
|
266
|
+
expect(rt.contexts[1].ChatType).toBe("direct");
|
|
267
|
+
expect(rt.contexts[0].SessionKey).not.toBe(rt.contexts[1].SessionKey);
|
|
268
|
+
});
|
|
269
|
+
|
|
270
|
+
it("replies go back to the channel the message arrived on, correlated by in_reply_to", async () => {
|
|
271
|
+
const rt = makeRuntime();
|
|
272
|
+
rt.setDispatch(async (args) => { await args.dispatcherOptions.deliver({ text: "pong" }); });
|
|
273
|
+
const { instances } = await start(rt.runtime);
|
|
274
|
+
deliver(instances[0], { channel_id: "chan-R", message_id: "m-R", sender_id: MEMBER_ID, text: "ping", authorization: auth("team_room") });
|
|
275
|
+
await waitFor(() => acksOf(instances[0]).includes("m-R"));
|
|
276
|
+
expect(sends).toEqual([{ channel_id: "chan-R", text: "pong", in_reply_to: "m-R" }]);
|
|
277
|
+
});
|
|
278
|
+
|
|
279
|
+
// ---------------------------------------------------------------- 2 ----
|
|
280
|
+
|
|
281
|
+
it.each([
|
|
282
|
+
["owner_dm", "active", true, "direct"],
|
|
283
|
+
["owner_dm", "unknown", false, "direct"],
|
|
284
|
+
["other_human_dm", "active", false, "direct"],
|
|
285
|
+
["team_room", "active", false, "group"],
|
|
286
|
+
["partner_room", "active", false, "group"],
|
|
287
|
+
["agent_to_agent", "active", false, "group"],
|
|
288
|
+
["unknown", "unknown", false, "group"],
|
|
289
|
+
])("audience=%s membership=%s -> CommandAuthorized=%s, ChatType=%s", async (audience, membership, authorized, chatType) => {
|
|
290
|
+
const rt = makeRuntime();
|
|
291
|
+
const { instances } = await start(rt.runtime);
|
|
292
|
+
deliver(instances[0], { channel_id: "c", message_id: "m", sender_id: OWNER_ID, text: "/reset", authorization: auth(audience, membership) });
|
|
293
|
+
await waitFor(() => acksOf(instances[0]).includes("m"));
|
|
294
|
+
expect(rt.contexts).toHaveLength(1);
|
|
295
|
+
expect(rt.contexts[0].CommandAuthorized).toBe(authorized);
|
|
296
|
+
expect(rt.contexts[0].ChatType).toBe(chatType);
|
|
297
|
+
});
|
|
298
|
+
|
|
299
|
+
it("a frame with NO authorization block (older daemon) is never command-authorized", async () => {
|
|
300
|
+
const rt = makeRuntime();
|
|
301
|
+
const { instances } = await start(rt.runtime);
|
|
302
|
+
deliver(instances[0], { channel_id: "c", message_id: "m", sender_id: OWNER_ID, text: "/reset" });
|
|
303
|
+
await waitFor(() => acksOf(instances[0]).includes("m"));
|
|
304
|
+
expect(rt.contexts[0].CommandAuthorized).toBe(false);
|
|
305
|
+
expect(rt.contexts[0].ChatType).toBe("group");
|
|
306
|
+
});
|
|
307
|
+
|
|
308
|
+
it("a malformed authorization block (audience owner_dm but wrong version / non-object) is not authorized", async () => {
|
|
309
|
+
const rt = makeRuntime();
|
|
310
|
+
const { instances } = await start(rt.runtime);
|
|
311
|
+
deliver(instances[0], { channel_id: "c1", message_id: "m1", sender_id: OWNER_ID, text: "x", authorization: { ...auth("owner_dm"), version: 2 } });
|
|
312
|
+
deliver(instances[0], { channel_id: "c2", message_id: "m2", sender_id: OWNER_ID, text: "x", authorization: "owner_dm" });
|
|
313
|
+
await waitFor(() => acksOf(instances[0]).length === 2);
|
|
314
|
+
expect(rt.contexts.map((c) => c.CommandAuthorized)).toEqual([false, false]);
|
|
315
|
+
});
|
|
316
|
+
|
|
317
|
+
// ---------------------------------------------------------------- 3 ----
|
|
318
|
+
|
|
319
|
+
it("a redelivered message_id is acked again but never dispatched twice", async () => {
|
|
320
|
+
const rt = makeRuntime();
|
|
321
|
+
const { instances } = await start(rt.runtime);
|
|
322
|
+
const ws = instances[0];
|
|
323
|
+
deliver(ws, { channel_id: "c", message_id: "dup", sender_id: OWNER_ID, text: "run tool", authorization: auth("owner_dm") });
|
|
324
|
+
await waitFor(() => acksOf(ws).length === 1);
|
|
325
|
+
deliver(ws, { channel_id: "c", message_id: "dup", sender_id: OWNER_ID, text: "run tool", authorization: auth("owner_dm") });
|
|
326
|
+
await waitFor(() => acksOf(ws).length === 2);
|
|
327
|
+
expect(rt.dispatches).toHaveLength(1);
|
|
328
|
+
expect(acksOf(ws)).toEqual(["dup", "dup"]);
|
|
329
|
+
});
|
|
330
|
+
|
|
331
|
+
it("a concurrent duplicate while the first turn is still running is not dispatched twice", async () => {
|
|
332
|
+
const rt = makeRuntime();
|
|
333
|
+
let release!: () => void;
|
|
334
|
+
rt.setDispatch(() => new Promise<void>((r) => { release = r; }));
|
|
335
|
+
const { instances } = await start(rt.runtime);
|
|
336
|
+
const ws = instances[0];
|
|
337
|
+
const frame = { channel_id: "c", message_id: "slow", sender_id: OWNER_ID, text: "t", authorization: auth("owner_dm") };
|
|
338
|
+
deliver(ws, frame);
|
|
339
|
+
await waitFor(() => rt.dispatches.length === 1);
|
|
340
|
+
deliver(ws, frame);
|
|
341
|
+
await new Promise((r) => setTimeout(r, 30));
|
|
342
|
+
expect(rt.dispatches).toHaveLength(1);
|
|
343
|
+
release();
|
|
344
|
+
await waitFor(() => acksOf(ws).includes("slow"));
|
|
345
|
+
expect(rt.dispatches).toHaveLength(1);
|
|
346
|
+
});
|
|
347
|
+
|
|
348
|
+
it("a message whose reply the daemon already published (reply receipt) is acked without dispatch", async () => {
|
|
349
|
+
const rt = makeRuntime();
|
|
350
|
+
receiptFound = true;
|
|
351
|
+
const { instances } = await start(rt.runtime);
|
|
352
|
+
deliver(instances[0], { channel_id: "c", message_id: "answered", sender_id: OWNER_ID, text: "t", authorization: auth("owner_dm") });
|
|
353
|
+
await waitFor(() => acksOf(instances[0]).includes("answered"));
|
|
354
|
+
expect(receiptLookups).toEqual(["answered"]);
|
|
355
|
+
expect(rt.dispatches).toHaveLength(0);
|
|
356
|
+
});
|
|
357
|
+
|
|
358
|
+
it("the reply receipt is checked BEFORE dispatch on first sight", async () => {
|
|
359
|
+
const rt = makeRuntime();
|
|
360
|
+
const order: string[] = [];
|
|
361
|
+
rt.setDispatch(async () => { order.push(`dispatch:${receiptLookups.length}`); });
|
|
362
|
+
const { instances } = await start(rt.runtime);
|
|
363
|
+
deliver(instances[0], { channel_id: "c", message_id: "fresh", sender_id: OWNER_ID, text: "t", authorization: auth("owner_dm") });
|
|
364
|
+
await waitFor(() => acksOf(instances[0]).includes("fresh"));
|
|
365
|
+
expect(order).toEqual(["dispatch:1"]);
|
|
366
|
+
});
|
|
367
|
+
|
|
368
|
+
it("a failed reply-receipt lookup neither dispatches nor acks; the socket is closed for redelivery", async () => {
|
|
369
|
+
const rt = makeRuntime();
|
|
370
|
+
receiptStatus = 500;
|
|
371
|
+
const { instances } = await start(rt.runtime);
|
|
372
|
+
const ws = instances[0];
|
|
373
|
+
deliver(ws, { channel_id: "c", message_id: "r500", sender_id: OWNER_ID, text: "t", authorization: auth("owner_dm") });
|
|
374
|
+
await waitFor(() => ws.closed);
|
|
375
|
+
expect(rt.dispatches).toHaveLength(0);
|
|
376
|
+
expect(acksOf(ws)).toEqual([]);
|
|
377
|
+
});
|
|
378
|
+
|
|
379
|
+
it("a 200 reply-receipt response with a non-JSON body is a ReceiptLookupError, not a handler failure: it is neither dispatched nor acked, the socket is closed for redelivery, and it does NOT count toward MAX_INBOUND_ATTEMPTS", async () => {
|
|
380
|
+
const rt = makeRuntime();
|
|
381
|
+
receiptBadBody = true;
|
|
382
|
+
const { instances } = await start(rt.runtime);
|
|
383
|
+
const ws = instances[0];
|
|
384
|
+
deliver(ws, { channel_id: "c", message_id: "badbody", sender_id: OWNER_ID, text: "t", authorization: auth("owner_dm") });
|
|
385
|
+
await waitFor(() => ws.closed);
|
|
386
|
+
expect(rt.dispatches).toHaveLength(0);
|
|
387
|
+
expect(acksOf(ws)).toEqual([]);
|
|
388
|
+
expect(logs.some((m) => /giving up/i.test(m))).toBe(false);
|
|
389
|
+
});
|
|
390
|
+
|
|
391
|
+
it("a non-JSON 200 reply-receipt body never exhausts the poison cap (it is not a handler failure)", async () => {
|
|
392
|
+
const mod = await import("./openclaw-entry.js");
|
|
393
|
+
const cap = mod.MAX_INBOUND_ATTEMPTS as number;
|
|
394
|
+
expect(cap).toBeGreaterThan(1); // vacuity guard: the loop below must actually run
|
|
395
|
+
let invoked = 0;
|
|
396
|
+
const channel = new mod.LoopbackChannel({
|
|
397
|
+
boundIdentity: BOUND_ID,
|
|
398
|
+
log: (m: string) => logs.push(m),
|
|
399
|
+
onMessage: async () => { invoked++; },
|
|
400
|
+
});
|
|
401
|
+
const { FakeWebSocket } = makeFakeWebSocket();
|
|
402
|
+
channel.bridgeUrl = `http://127.0.0.1:${PORT}`;
|
|
403
|
+
channel.token = TOKEN_VALUE;
|
|
404
|
+
channel._ownIdentity = BOUND_ID;
|
|
405
|
+
vi.stubGlobal("fetch", vi.fn(async (url: string) => {
|
|
406
|
+
if (String(url).includes("/v1/reply-receipt")) {
|
|
407
|
+
return { ok: true, status: 200, json: async () => { throw new SyntaxError("bad body"); } };
|
|
408
|
+
}
|
|
409
|
+
throw new Error(`unexpected fetch: ${url}`);
|
|
410
|
+
}));
|
|
411
|
+
const frame = { type: "message", channel_id: "c", message_id: "badbody-poison", sender_id: OWNER_ID, text: "t" };
|
|
412
|
+
for (let i = 0; i < cap + 2; i++) {
|
|
413
|
+
const ws = new FakeWebSocket("ws://x") as unknown as FakeWs;
|
|
414
|
+
channel._ws = ws;
|
|
415
|
+
await channel._onInboundFrame(frame);
|
|
416
|
+
expect(acksOf(ws)).toEqual([]); // never given up on
|
|
417
|
+
}
|
|
418
|
+
expect(invoked).toBe(0); // never dispatched (the lookup itself failed each time)
|
|
419
|
+
expect(logs.some((m) => /giving up/i.test(m))).toBe(false);
|
|
420
|
+
});
|
|
421
|
+
|
|
422
|
+
it("a handler failure is NOT acked: the socket is closed so the daemon re-buffers the frame", async () => {
|
|
423
|
+
const rt = makeRuntime();
|
|
424
|
+
rt.setDispatch(async () => { throw new Error("model exploded"); });
|
|
425
|
+
const { instances } = await start(rt.runtime);
|
|
426
|
+
const ws = instances[0];
|
|
427
|
+
deliver(ws, { channel_id: "c", message_id: "boom", sender_id: OWNER_ID, text: "t", authorization: auth("owner_dm") });
|
|
428
|
+
await waitFor(() => ws.closed);
|
|
429
|
+
expect(rt.dispatches).toHaveLength(1);
|
|
430
|
+
expect(acksOf(ws)).toEqual([]);
|
|
431
|
+
});
|
|
432
|
+
|
|
433
|
+
it("after a handler failure, the redelivered copy IS dispatched again (not deduped as seen)", async () => {
|
|
434
|
+
const rt = makeRuntime();
|
|
435
|
+
let calls = 0;
|
|
436
|
+
rt.setDispatch(async () => { calls++; if (calls === 1) throw new Error("transient"); });
|
|
437
|
+
const { instances } = await start(rt.runtime);
|
|
438
|
+
const frame = { channel_id: "c", message_id: "retry", sender_id: OWNER_ID, text: "t", authorization: auth("owner_dm") };
|
|
439
|
+
deliver(instances[0], frame);
|
|
440
|
+
await waitFor(() => instances[0].closed);
|
|
441
|
+
await waitFor(() => instances.length >= 2, 10_000); // reconnect after backoff
|
|
442
|
+
instances[1]._emit("open");
|
|
443
|
+
deliver(instances[1], frame);
|
|
444
|
+
await waitFor(() => acksOf(instances[1]).includes("retry"));
|
|
445
|
+
expect(calls).toBe(2);
|
|
446
|
+
});
|
|
447
|
+
|
|
448
|
+
it("a poison message is given up on (acked, logged) after the attempt cap instead of looping forever", async () => {
|
|
449
|
+
const rt = makeRuntime();
|
|
450
|
+
rt.setDispatch(async () => { throw new Error("always fails"); });
|
|
451
|
+
const mod = await import("./openclaw-entry.js");
|
|
452
|
+
const cap = mod.MAX_INBOUND_ATTEMPTS as number;
|
|
453
|
+
expect(cap).toBeGreaterThan(1);
|
|
454
|
+
const { LoopbackChannel } = mod;
|
|
455
|
+
// Drive the channel directly (no reconnect backoff) to count attempts.
|
|
456
|
+
let n = 0;
|
|
457
|
+
const channel = new LoopbackChannel({
|
|
458
|
+
boundIdentity: BOUND_ID,
|
|
459
|
+
log: (m: string) => logs.push(m),
|
|
460
|
+
onMessage: async () => { n++; throw new Error("always fails"); },
|
|
461
|
+
});
|
|
462
|
+
const { FakeWebSocket } = makeFakeWebSocket();
|
|
463
|
+
channel.bridgeUrl = `http://127.0.0.1:${PORT}`;
|
|
464
|
+
channel.token = TOKEN_VALUE;
|
|
465
|
+
channel._ownIdentity = BOUND_ID;
|
|
466
|
+
const frame = { type: "message", channel_id: "c", message_id: "poison", sender_id: OWNER_ID, text: "t" };
|
|
467
|
+
const sockets: FakeWs[] = [];
|
|
468
|
+
for (let i = 0; i < cap; i++) {
|
|
469
|
+
const ws = new FakeWebSocket("ws://x") as unknown as FakeWs;
|
|
470
|
+
sockets.push(ws);
|
|
471
|
+
channel._ws = ws;
|
|
472
|
+
await channel._onInboundFrame(frame);
|
|
473
|
+
}
|
|
474
|
+
expect(n).toBe(cap);
|
|
475
|
+
for (const ws of sockets.slice(0, -1)) expect(acksOf(ws)).toEqual([]);
|
|
476
|
+
expect(acksOf(sockets[cap - 1])).toEqual(["poison"]);
|
|
477
|
+
expect(logs.some((m) => /giving up/i.test(m))).toBe(true);
|
|
478
|
+
// A later redelivery of the given-up id is acked without another attempt.
|
|
479
|
+
const ws = new FakeWebSocket("ws://x") as unknown as FakeWs;
|
|
480
|
+
channel._ws = ws;
|
|
481
|
+
await channel._onInboundFrame(frame);
|
|
482
|
+
expect(n).toBe(cap);
|
|
483
|
+
expect(acksOf(ws)).toEqual(["poison"]);
|
|
484
|
+
});
|
|
485
|
+
|
|
486
|
+
it("runtime NOT registered: the message is neither dropped-and-acked nor dispatched; it stays unacked for redelivery", async () => {
|
|
487
|
+
const { instances } = await start(undefined);
|
|
488
|
+
const ws = instances[0];
|
|
489
|
+
deliver(ws, { channel_id: "c", message_id: "early", sender_id: OWNER_ID, text: "t", authorization: auth("owner_dm") });
|
|
490
|
+
await waitFor(() => ws.closed);
|
|
491
|
+
expect(acksOf(ws)).toEqual([]);
|
|
492
|
+
expect(logs.some((m) => /dropping/i.test(m))).toBe(false);
|
|
493
|
+
});
|
|
494
|
+
|
|
495
|
+
it("runtime-not-ready never exhausts the poison cap (it is not a handler failure)", async () => {
|
|
496
|
+
const mod = await import("./openclaw-entry.js");
|
|
497
|
+
const cap = mod.MAX_INBOUND_ATTEMPTS as number;
|
|
498
|
+
expect(cap).toBeGreaterThan(1); // vacuity guard: the loop below must actually run
|
|
499
|
+
let invoked = 0;
|
|
500
|
+
const channel = new mod.LoopbackChannel({
|
|
501
|
+
boundIdentity: BOUND_ID,
|
|
502
|
+
log: () => {},
|
|
503
|
+
onMessage: async () => { invoked++; throw new mod.RuntimeNotReadyError(); },
|
|
504
|
+
});
|
|
505
|
+
const { FakeWebSocket } = makeFakeWebSocket();
|
|
506
|
+
channel.bridgeUrl = `http://127.0.0.1:${PORT}`;
|
|
507
|
+
channel.token = TOKEN_VALUE;
|
|
508
|
+
const frame = { type: "message", channel_id: "c", message_id: "wait", sender_id: OWNER_ID, text: "t" };
|
|
509
|
+
for (let i = 0; i < cap + 2; i++) {
|
|
510
|
+
const ws = new FakeWebSocket("ws://x") as unknown as FakeWs;
|
|
511
|
+
channel._ws = ws;
|
|
512
|
+
await channel._onInboundFrame(frame);
|
|
513
|
+
expect(acksOf(ws)).toEqual([]);
|
|
514
|
+
expect(ws.closed).toBe(true); // left for redelivery, not stranded in-flight
|
|
515
|
+
}
|
|
516
|
+
expect(invoked).toBe(cap + 2);
|
|
517
|
+
});
|
|
518
|
+
|
|
519
|
+
it("a failure-forced reconnect keeps backing off: a WS open does not reset backoff until a turn succeeds", async () => {
|
|
520
|
+
const mod = await import("./openclaw-entry.js");
|
|
521
|
+
const { FakeWebSocket, instances } = makeFakeWebSocket();
|
|
522
|
+
vi.stubGlobal("WebSocket", FakeWebSocket);
|
|
523
|
+
let fail = true;
|
|
524
|
+
const channel = new mod.LoopbackChannel({
|
|
525
|
+
boundIdentity: "no-such-identity-for-backoff-test",
|
|
526
|
+
log: () => {},
|
|
527
|
+
onMessage: async () => { if (fail) throw new Error("turn failed"); },
|
|
528
|
+
});
|
|
529
|
+
channel.bridgeUrl = `http://127.0.0.1:${PORT}`;
|
|
530
|
+
channel.wsUrl = `ws://127.0.0.1:${PORT}/v1/ws`;
|
|
531
|
+
channel.token = TOKEN_VALUE;
|
|
532
|
+
try {
|
|
533
|
+
channel._connect();
|
|
534
|
+
instances[0]._emit("open");
|
|
535
|
+
expect(channel._attempt).toBe(0);
|
|
536
|
+
await channel._onInboundFrame({ type: "message", channel_id: "c", message_id: "f1", sender_id: OWNER_ID, text: "t" });
|
|
537
|
+
expect(instances[0].closed).toBe(true); // close handler scheduled a backed-off reconnect
|
|
538
|
+
const afterClose = channel._attempt;
|
|
539
|
+
expect(afterClose).toBeGreaterThan(0);
|
|
540
|
+
channel._connect();
|
|
541
|
+
instances[1]._emit("open");
|
|
542
|
+
expect(channel._attempt).toBe(afterClose); // NOT reset while the turn keeps failing
|
|
543
|
+
fail = false;
|
|
544
|
+
await channel._onInboundFrame({ type: "message", channel_id: "c", message_id: "f2", sender_id: OWNER_ID, text: "t" });
|
|
545
|
+
channel._connect();
|
|
546
|
+
instances[2]._emit("open");
|
|
547
|
+
expect(channel._attempt).toBe(0); // a successful turn restores the normal reset
|
|
548
|
+
} finally {
|
|
549
|
+
await channel.stop();
|
|
550
|
+
}
|
|
551
|
+
});
|
|
552
|
+
|
|
553
|
+
it("our own echoed sends are acked and never dispatched", async () => {
|
|
554
|
+
const rt = makeRuntime();
|
|
555
|
+
const { instances } = await start(rt.runtime);
|
|
556
|
+
deliver(instances[0], { channel_id: "c", message_id: "self", sender_id: BOUND_ID, text: "t", authorization: auth("agent_to_agent") });
|
|
557
|
+
await waitFor(() => acksOf(instances[0]).includes("self"));
|
|
558
|
+
expect(rt.dispatches).toHaveLength(0);
|
|
559
|
+
});
|
|
560
|
+
});
|