@securecoms/agent-bridge 0.21.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 +91 -3
- package/connectors/hermes/adapter.py +63 -7
- package/connectors/hermes/tests/test_adapter_wiring.py +23 -0
- package/connectors/hermes/tests/test_logging_privacy.py +61 -0
- package/connectors/openclaw/openclaw-entry.inbound.test.ts +561 -0
- package/connectors/openclaw/openclaw-entry.js +270 -47
- package/connectors/openclaw/openclaw-entry.smoke.test.ts +26 -0
- package/dist/bin.d.ts +3 -7
- package/dist/bin.js +7626 -954
- package/dist/bridge-agent.d.ts +846 -30
- package/dist/coordination.d.ts +112 -5
- package/dist/decrypt-error-log-limiter.d.ts +6 -30
- package/dist/errors.d.ts +1 -1
- package/dist/http-server.d.ts +24 -5
- package/dist/index.js +7608 -927
- package/dist/reply-intent.d.ts +68 -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/voice/fast-client.d.ts +12 -2
- package/dist/ws-server.d.ts +37 -3
- package/package.json +4 -1
|
@@ -61,6 +61,74 @@ let _runtime = null;
|
|
|
61
61
|
// Active channel instances keyed by accountId.
|
|
62
62
|
const _channels = new Map();
|
|
63
63
|
|
|
64
|
+
// ---------------------------------------------------------------------------
|
|
65
|
+
// Inbound delivery contract (P0 containment Task 8).
|
|
66
|
+
//
|
|
67
|
+
// The daemon delivers at-least-once: a `message` frame stays in-flight until
|
|
68
|
+
// we `ack` it, and is re-buffered for the next consumer when the socket drops.
|
|
69
|
+
// So:
|
|
70
|
+
// - a message_id we already handled is acked again and NEVER re-dispatched
|
|
71
|
+
// (bounded `seen` set, same shape as the native runtime's data plane);
|
|
72
|
+
// - on first sight we ask the daemon's content-free reply-receipt ledger
|
|
73
|
+
// (GET /v1/reply-receipt) whether a reply was already published for it —
|
|
74
|
+
// a replacement host must not re-run the model/tools for an answered turn;
|
|
75
|
+
// - a failed turn is NOT acked. We close the socket so the daemon re-buffers
|
|
76
|
+
// the frame for a clean retry (leaving it in-flight on a live socket would
|
|
77
|
+
// strand it until the next reconnect);
|
|
78
|
+
// - a runtime that is not registered yet is the same "retry later" case,
|
|
79
|
+
// never a silent drop;
|
|
80
|
+
// - a message that keeps failing is given up on after MAX_INBOUND_ATTEMPTS
|
|
81
|
+
// handler failures (acked + logged), so one poison message cannot re-run
|
|
82
|
+
// tools forever. Runtime-not-ready and a failed receipt lookup do NOT
|
|
83
|
+
// count toward that cap: neither ran the turn, and giving up on them
|
|
84
|
+
// would be a silent drop of a message nobody processed.
|
|
85
|
+
// ---------------------------------------------------------------------------
|
|
86
|
+
|
|
87
|
+
export const MAX_INBOUND_ATTEMPTS = 3;
|
|
88
|
+
const SEEN_MAX = 5000;
|
|
89
|
+
|
|
90
|
+
/** Thrown by the inbound handler when register() has not injected a runtime. */
|
|
91
|
+
export class RuntimeNotReadyError extends Error {
|
|
92
|
+
constructor() {
|
|
93
|
+
super("OpenClaw runtime not registered yet");
|
|
94
|
+
this.name = "RuntimeNotReadyError";
|
|
95
|
+
}
|
|
96
|
+
}
|
|
97
|
+
|
|
98
|
+
/** Thrown when the reply-receipt lookup cannot answer (never a handler failure). */
|
|
99
|
+
class ReceiptLookupError extends Error {
|
|
100
|
+
constructor(detail) {
|
|
101
|
+
super(`reply receipt lookup failed: ${detail}`);
|
|
102
|
+
this.name = "ReceiptLookupError";
|
|
103
|
+
}
|
|
104
|
+
}
|
|
105
|
+
|
|
106
|
+
/**
|
|
107
|
+
* The agent's OWNER, as attested by the daemon's authorization block. This is
|
|
108
|
+
* the exact predicate the bridge uses for owner-scoped OpenClaw memory
|
|
109
|
+
* (BridgeAgent.mayReadOpenClawMemoryForTurn): audience "owner_dm" with an
|
|
110
|
+
* "active" sender. The block is derived by the daemon from server-authenticated
|
|
111
|
+
* roster data, never from message plaintext. Anything else — including a
|
|
112
|
+
* missing block from an older daemon, an unknown version, or the owner
|
|
113
|
+
* speaking in a group room (the daemon does not classify that as owner) —
|
|
114
|
+
* is NOT the owner. Fail closed.
|
|
115
|
+
*/
|
|
116
|
+
export function isOwnerAuthorization(authorization) {
|
|
117
|
+
return (
|
|
118
|
+
authorization !== null &&
|
|
119
|
+
typeof authorization === "object" &&
|
|
120
|
+
authorization.version === 1 &&
|
|
121
|
+
authorization.audience === "owner_dm" &&
|
|
122
|
+
authorization.sender_membership === "active"
|
|
123
|
+
);
|
|
124
|
+
}
|
|
125
|
+
|
|
126
|
+
/** "direct" only for a DM the daemon classified as one; everything else is a group. */
|
|
127
|
+
export function chatTypeFor(authorization) {
|
|
128
|
+
const audience = authorization && typeof authorization === "object" ? authorization.audience : undefined;
|
|
129
|
+
return audience === "owner_dm" || audience === "other_human_dm" ? "direct" : "group";
|
|
130
|
+
}
|
|
131
|
+
|
|
64
132
|
// ---------------------------------------------------------------------------
|
|
65
133
|
// Account / identity / connection-file resolution.
|
|
66
134
|
// ---------------------------------------------------------------------------
|
|
@@ -188,6 +256,15 @@ export class LoopbackChannel {
|
|
|
188
256
|
this._stopped = false;
|
|
189
257
|
this._attempt = 0;
|
|
190
258
|
this._ownIdentity = null;
|
|
259
|
+
// Inbound dedup/retry state (see "Inbound delivery contract" above).
|
|
260
|
+
this._seen = new Set();
|
|
261
|
+
this._inFlight = new Set();
|
|
262
|
+
this._failures = new Map();
|
|
263
|
+
this._blockSeq = new Map(); // SEC-06 #18: inReplyTo -> { seq, pending } (see _blockKey)
|
|
264
|
+
// Consecutive failed turns. While > 0, a WS open does NOT reset the
|
|
265
|
+
// reconnect backoff, so a failing turn is retried with growing delay
|
|
266
|
+
// instead of a ~250 ms redelivery loop.
|
|
267
|
+
this._failStreak = 0;
|
|
191
268
|
}
|
|
192
269
|
|
|
193
270
|
_setState(s) {
|
|
@@ -226,10 +303,7 @@ export class LoopbackChannel {
|
|
|
226
303
|
// like the no-token case below, never attempt the gate with an
|
|
227
304
|
// undefined fileIdentity, never open a WS.
|
|
228
305
|
this._setState("idle");
|
|
229
|
-
this.log(
|
|
230
|
-
`[securecoms] no connection file at ~/.securecoms/connections/${this.boundIdentity}.json yet; ` +
|
|
231
|
-
"the daemon authors it on startup — ensure the daemon is upgraded and running",
|
|
232
|
-
);
|
|
306
|
+
this.log("[SecureComs] no connection file at configured location; waiting for daemon");
|
|
233
307
|
this._scheduleReconnect();
|
|
234
308
|
return;
|
|
235
309
|
}
|
|
@@ -248,9 +322,9 @@ export class LoopbackChannel {
|
|
|
248
322
|
let h;
|
|
249
323
|
try {
|
|
250
324
|
h = await (await fetch(`${this.bridgeUrl}/healthz`)).json();
|
|
251
|
-
} catch
|
|
325
|
+
} catch {
|
|
252
326
|
this._setState("error");
|
|
253
|
-
this.log(
|
|
327
|
+
this.log("[SecureComs] daemon unreachable");
|
|
254
328
|
this._scheduleReconnect();
|
|
255
329
|
return;
|
|
256
330
|
}
|
|
@@ -262,14 +336,14 @@ export class LoopbackChannel {
|
|
|
262
336
|
});
|
|
263
337
|
if (!gate.ok) {
|
|
264
338
|
this._setState("error");
|
|
265
|
-
this.log(
|
|
339
|
+
this.log("[SecureComs] refusing to attach: identity gate failed");
|
|
266
340
|
this._scheduleReconnect();
|
|
267
341
|
return;
|
|
268
342
|
}
|
|
269
343
|
|
|
270
344
|
this.deviceId = h.identity_id;
|
|
271
345
|
this.sessionCount = h.joined_channels ?? 0;
|
|
272
|
-
this.log(
|
|
346
|
+
this.log("[SecureComs] daemon identity gate passed");
|
|
273
347
|
// Echo-suppression anchor — set ONLY after the gate passes.
|
|
274
348
|
this._ownIdentity = this.boundIdentity;
|
|
275
349
|
// Backoff is reset on a real WS open (see _connect's "open" handler), NOT
|
|
@@ -294,10 +368,11 @@ export class LoopbackChannel {
|
|
|
294
368
|
|
|
295
369
|
ws.addEventListener("open", () => {
|
|
296
370
|
this._setState("ready");
|
|
297
|
-
this.log(
|
|
371
|
+
this.log("[SecureComs] connected to daemon WebSocket");
|
|
298
372
|
// Backoff resets only on a real WS open — a passing gate alone isn't
|
|
299
|
-
// enough (see comment in _probeAndGate)
|
|
300
|
-
|
|
373
|
+
// enough (see comment in _probeAndGate) — and not while turns are
|
|
374
|
+
// failing: a close we forced for redelivery must still back off.
|
|
375
|
+
if (this._failStreak === 0) this._attempt = 0;
|
|
301
376
|
});
|
|
302
377
|
|
|
303
378
|
ws.addEventListener("message", (ev) => {
|
|
@@ -306,22 +381,7 @@ export class LoopbackChannel {
|
|
|
306
381
|
frame = JSON.parse(typeof ev.data === "string" ? ev.data : ev.data.toString());
|
|
307
382
|
} catch { return; }
|
|
308
383
|
if (frame.type !== "message") return;
|
|
309
|
-
|
|
310
|
-
if (frame.sender_id && this._ownIdentity && frame.sender_id === this._ownIdentity) {
|
|
311
|
-
this._ack(frame.message_id);
|
|
312
|
-
return;
|
|
313
|
-
}
|
|
314
|
-
Promise.resolve(
|
|
315
|
-
this.onMessage?.(frame.text ?? "", {
|
|
316
|
-
conversationId: frame.channel_id,
|
|
317
|
-
messageId: frame.message_id,
|
|
318
|
-
senderId: frame.sender_id,
|
|
319
|
-
timestamp: frame.ts,
|
|
320
|
-
}),
|
|
321
|
-
)
|
|
322
|
-
.catch((e) => this.log(`[SecureComs] onMessage error: ${e?.message ?? e}`))
|
|
323
|
-
// at-least-once: ack after the turn completes so a crash mid-handle replays.
|
|
324
|
-
.finally(() => this._ack(frame.message_id));
|
|
384
|
+
void this._onInboundFrame(frame);
|
|
325
385
|
});
|
|
326
386
|
|
|
327
387
|
ws.addEventListener("close", () => {
|
|
@@ -333,17 +393,155 @@ export class LoopbackChannel {
|
|
|
333
393
|
this._scheduleReconnect();
|
|
334
394
|
});
|
|
335
395
|
|
|
336
|
-
ws.addEventListener("error", (
|
|
337
|
-
this.log(
|
|
396
|
+
ws.addEventListener("error", () => {
|
|
397
|
+
this.log("[SecureComs] WebSocket error");
|
|
338
398
|
});
|
|
339
399
|
}
|
|
340
400
|
|
|
401
|
+
// Acks go to the CURRENT socket, not the one the frame arrived on: if that
|
|
402
|
+
// socket was replaced mid-turn, the daemon re-buffered the frame onto the
|
|
403
|
+
// new one, and only an ack there clears it.
|
|
341
404
|
_ack(messageId) {
|
|
342
405
|
try { this._ws?.send(JSON.stringify({ type: "ack", message_id: messageId })); } catch { /* ignore */ }
|
|
343
406
|
}
|
|
344
407
|
|
|
408
|
+
_remember(messageId) {
|
|
409
|
+
if (this._seen.size >= SEEN_MAX) {
|
|
410
|
+
const oldest = this._seen.values().next();
|
|
411
|
+
if (!oldest.done) this._seen.delete(oldest.value);
|
|
412
|
+
}
|
|
413
|
+
this._seen.add(messageId);
|
|
414
|
+
}
|
|
415
|
+
|
|
416
|
+
// Close the current socket so the daemon re-buffers every un-acked frame
|
|
417
|
+
// for redelivery. The close handler re-probes and re-gates as usual.
|
|
418
|
+
_requestRedelivery() {
|
|
419
|
+
try { this._ws?.close(); } catch { /* already closing */ }
|
|
420
|
+
}
|
|
421
|
+
|
|
422
|
+
async _hasReplyReceipt(messageId) {
|
|
423
|
+
let r;
|
|
424
|
+
try {
|
|
425
|
+
r = await fetch(`${this.bridgeUrl}/v1/reply-receipt?in_reply_to=${encodeURIComponent(messageId)}`, {
|
|
426
|
+
headers: { authorization: `Bearer ${this.token}` },
|
|
427
|
+
});
|
|
428
|
+
} catch {
|
|
429
|
+
throw new ReceiptLookupError("daemon unreachable");
|
|
430
|
+
}
|
|
431
|
+
if (!r.ok) throw new ReceiptLookupError(`HTTP ${r.status}`);
|
|
432
|
+
let body;
|
|
433
|
+
try {
|
|
434
|
+
body = await r.json();
|
|
435
|
+
} catch {
|
|
436
|
+
// A 200 with a non-JSON/unparseable body is a daemon-side lookup
|
|
437
|
+
// failure, not a handler failure: it must not count toward
|
|
438
|
+
// MAX_INBOUND_ATTEMPTS (that would eventually ack-and-drop a message
|
|
439
|
+
// that was never actually processed).
|
|
440
|
+
throw new ReceiptLookupError("invalid JSON body");
|
|
441
|
+
}
|
|
442
|
+
return body?.found === true;
|
|
443
|
+
}
|
|
444
|
+
|
|
445
|
+
/**
|
|
446
|
+
* Handle one inbound `message` frame under the at-least-once contract (see
|
|
447
|
+
* "Inbound delivery contract" at the top of this file). Never throws.
|
|
448
|
+
*/
|
|
449
|
+
async _onInboundFrame(frame) {
|
|
450
|
+
const messageId = frame.message_id;
|
|
451
|
+
if (typeof messageId !== "string" || messageId === "") {
|
|
452
|
+
this.log("[SecureComs] inbound frame without message_id; ignoring");
|
|
453
|
+
return;
|
|
454
|
+
}
|
|
455
|
+
// Never feed our own sends back into the agent (no echo loop).
|
|
456
|
+
if (frame.sender_id && this._ownIdentity && frame.sender_id === this._ownIdentity) {
|
|
457
|
+
this._remember(messageId);
|
|
458
|
+
this._ack(messageId);
|
|
459
|
+
return;
|
|
460
|
+
}
|
|
461
|
+
if (this._seen.has(messageId)) {
|
|
462
|
+
// Already handled (or reconciled via a reply receipt): ack the
|
|
463
|
+
// redelivered copy, never run the turn twice.
|
|
464
|
+
this._ack(messageId);
|
|
465
|
+
return;
|
|
466
|
+
}
|
|
467
|
+
if (this._inFlight.has(messageId)) {
|
|
468
|
+
// Redelivered onto a replacement socket while the original turn is
|
|
469
|
+
// still running. The original's completion acks on the current socket.
|
|
470
|
+
return;
|
|
471
|
+
}
|
|
472
|
+
|
|
473
|
+
this._inFlight.add(messageId);
|
|
474
|
+
try {
|
|
475
|
+
if (await this._hasReplyReceipt(messageId)) {
|
|
476
|
+
this._remember(messageId);
|
|
477
|
+
this._ack(messageId);
|
|
478
|
+
return;
|
|
479
|
+
}
|
|
480
|
+
await this.onMessage?.(typeof frame.text === "string" ? frame.text : "", {
|
|
481
|
+
conversationId: frame.channel_id,
|
|
482
|
+
messageId,
|
|
483
|
+
senderId: frame.sender_id,
|
|
484
|
+
senderName: frame.sender_name,
|
|
485
|
+
authorization: frame.authorization,
|
|
486
|
+
timestamp: frame.ts,
|
|
487
|
+
});
|
|
488
|
+
this._failures.delete(messageId);
|
|
489
|
+
this._failStreak = 0;
|
|
490
|
+
this._remember(messageId);
|
|
491
|
+
this._ack(messageId);
|
|
492
|
+
} catch (err) {
|
|
493
|
+
this._failStreak++;
|
|
494
|
+
if (err instanceof RuntimeNotReadyError || err instanceof ReceiptLookupError) {
|
|
495
|
+
this.log("[SecureComs] inbound message not processed yet (runtime not ready or reply-receipt lookup failed); leaving unacked for redelivery");
|
|
496
|
+
this._requestRedelivery();
|
|
497
|
+
return;
|
|
498
|
+
}
|
|
499
|
+
const attempts = (this._failures.get(messageId) ?? 0) + 1;
|
|
500
|
+
if (attempts >= MAX_INBOUND_ATTEMPTS) {
|
|
501
|
+
this._failures.delete(messageId);
|
|
502
|
+
this.log("[SecureComs] inbound turn failed MAX_INBOUND_ATTEMPTS times; giving up and acking the message");
|
|
503
|
+
this._remember(messageId);
|
|
504
|
+
this._ack(messageId);
|
|
505
|
+
return;
|
|
506
|
+
}
|
|
507
|
+
if (this._failures.size >= SEEN_MAX) {
|
|
508
|
+
const oldest = this._failures.keys().next();
|
|
509
|
+
if (!oldest.done) this._failures.delete(oldest.value);
|
|
510
|
+
}
|
|
511
|
+
this._failures.set(messageId, attempts);
|
|
512
|
+
this.log("[SecureComs] inbound turn failed; leaving unacked for redelivery");
|
|
513
|
+
this._requestRedelivery();
|
|
514
|
+
} finally {
|
|
515
|
+
this._inFlight.delete(messageId);
|
|
516
|
+
}
|
|
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
|
+
|
|
345
542
|
/** Send a reply into a specific SecureComs channel via the daemon. */
|
|
346
543
|
async send(channelId, text, inReplyTo) {
|
|
544
|
+
const block = inReplyTo ? this._blockKey(inReplyTo, text) : null;
|
|
347
545
|
const r = await fetch(`${this.bridgeUrl}/v1/send`, {
|
|
348
546
|
method: "POST",
|
|
349
547
|
headers: { "content-type": "application/json", authorization: `Bearer ${this.token}` },
|
|
@@ -351,9 +549,14 @@ export class LoopbackChannel {
|
|
|
351
549
|
channel_id: channelId,
|
|
352
550
|
text,
|
|
353
551
|
...(inReplyTo ? { in_reply_to: inReplyTo } : {}),
|
|
552
|
+
...(block ? { idempotency_key: block.key } : {}),
|
|
354
553
|
}),
|
|
355
554
|
});
|
|
356
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
|
+
}
|
|
357
560
|
return r.json();
|
|
358
561
|
}
|
|
359
562
|
|
|
@@ -390,17 +593,33 @@ export class LoopbackChannel {
|
|
|
390
593
|
|
|
391
594
|
async function _handleInbound({ text, metadata, channel, account, cfg }) {
|
|
392
595
|
const core = _runtime;
|
|
393
|
-
if (!core)
|
|
394
|
-
|
|
395
|
-
const conv = metadata.conversationId ?? "default";
|
|
396
|
-
const senderId = "securecoms:owner";
|
|
596
|
+
if (!core) throw new RuntimeNotReadyError();
|
|
397
597
|
|
|
598
|
+
const conv = metadata.conversationId;
|
|
599
|
+
if (typeof conv !== "string" || conv === "") {
|
|
600
|
+
throw new Error("inbound frame has no channel_id");
|
|
601
|
+
}
|
|
602
|
+
// The REAL sender, as the daemon decrypted and attributed it. Never a
|
|
603
|
+
// hard-coded owner: who sent a message and whether they may run commands
|
|
604
|
+
// are separate facts, and only the daemon's authorization block speaks to
|
|
605
|
+
// the second.
|
|
606
|
+
const senderId = typeof metadata.senderId === "string" && metadata.senderId !== "" ? metadata.senderId : "unknown";
|
|
607
|
+
const senderName = typeof metadata.senderName === "string" && metadata.senderName !== "" ? metadata.senderName : senderId;
|
|
608
|
+
const chatType = chatTypeFor(metadata.authorization);
|
|
609
|
+
const commandAuthorized = isOwnerAuthorization(metadata.authorization);
|
|
610
|
+
|
|
611
|
+
// Route by the CHANNEL, so each SecureComs room/DM is its own conversation.
|
|
398
612
|
const route = core.channel.routing.resolveAgentRoute({
|
|
399
613
|
cfg,
|
|
400
614
|
channel: "securecoms",
|
|
401
615
|
accountId: account.accountId,
|
|
402
|
-
peer: { kind:
|
|
616
|
+
peer: { kind: chatType, id: conv },
|
|
403
617
|
});
|
|
618
|
+
// Session isolation is enforced HERE, not delegated to OpenClaw's dmScope:
|
|
619
|
+
// under the default dmScope every direct peer collapses into the agent's
|
|
620
|
+
// main session, which would merge two different humans' DMs (and the
|
|
621
|
+
// owner's) into one history. One session per SecureComs channel, always.
|
|
622
|
+
const sessionKey = `agent:${route.agentId}:securecoms:${chatType}:${conv}`;
|
|
404
623
|
|
|
405
624
|
const storePath = core.channel.session.resolveStorePath(cfg?.session?.store, {
|
|
406
625
|
agentId: route.agentId,
|
|
@@ -409,12 +628,12 @@ async function _handleInbound({ text, metadata, channel, account, cfg }) {
|
|
|
409
628
|
const envelopeOptions = core.channel.reply.resolveEnvelopeFormatOptions(cfg);
|
|
410
629
|
const previousTimestamp = core.channel.session.readSessionUpdatedAt({
|
|
411
630
|
storePath,
|
|
412
|
-
sessionKey
|
|
631
|
+
sessionKey,
|
|
413
632
|
});
|
|
414
633
|
|
|
415
634
|
const body = core.channel.reply.formatAgentEnvelope({
|
|
416
635
|
channel: "SecureComs",
|
|
417
|
-
from:
|
|
636
|
+
from: senderName,
|
|
418
637
|
timestamp: new Date(metadata.timestamp).getTime(),
|
|
419
638
|
previousTimestamp,
|
|
420
639
|
envelope: envelopeOptions,
|
|
@@ -425,13 +644,13 @@ async function _handleInbound({ text, metadata, channel, account, cfg }) {
|
|
|
425
644
|
Body: body,
|
|
426
645
|
RawBody: text,
|
|
427
646
|
CommandBody: text,
|
|
428
|
-
From: senderId
|
|
647
|
+
From: `securecoms:${senderId}`,
|
|
429
648
|
To: `securecoms:agent:${account.accountId}`,
|
|
430
|
-
SessionKey:
|
|
649
|
+
SessionKey: sessionKey,
|
|
431
650
|
AccountId: account.accountId,
|
|
432
|
-
ChatType:
|
|
651
|
+
ChatType: chatType,
|
|
433
652
|
ConversationLabel: "SecureComs",
|
|
434
|
-
SenderName:
|
|
653
|
+
SenderName: senderName,
|
|
435
654
|
SenderId: senderId,
|
|
436
655
|
Provider: "securecoms",
|
|
437
656
|
Surface: "securecoms",
|
|
@@ -439,14 +658,15 @@ async function _handleInbound({ text, metadata, channel, account, cfg }) {
|
|
|
439
658
|
Timestamp: new Date(metadata.timestamp).getTime(),
|
|
440
659
|
OriginatingChannel: "securecoms",
|
|
441
660
|
OriginatingTo: `securecoms:agent:${account.accountId}`,
|
|
442
|
-
//
|
|
443
|
-
// (MLS membership
|
|
444
|
-
|
|
661
|
+
// Command execution is the OWNER's alone, as attested by the daemon's
|
|
662
|
+
// authorization block (isOwnerAuthorization). MLS membership proves a
|
|
663
|
+
// sender is in the room, not that they own this agent.
|
|
664
|
+
CommandAuthorized: commandAuthorized,
|
|
445
665
|
});
|
|
446
666
|
|
|
447
667
|
await core.channel.session.recordInboundSession({
|
|
448
668
|
storePath,
|
|
449
|
-
sessionKey
|
|
669
|
+
sessionKey,
|
|
450
670
|
ctx: ctxPayload,
|
|
451
671
|
onRecordError: (err) => core.error?.(`[SecureComs] session record failed: ${String(err)}`),
|
|
452
672
|
});
|
|
@@ -495,7 +715,7 @@ const secureComsPlugin = {
|
|
|
495
715
|
aliases: ["sc", "secure-coms"],
|
|
496
716
|
},
|
|
497
717
|
capabilities: {
|
|
498
|
-
chatTypes: ["direct", "room"],
|
|
718
|
+
chatTypes: ["direct", "group", "room"],
|
|
499
719
|
},
|
|
500
720
|
config: {
|
|
501
721
|
listAccountIds,
|
|
@@ -535,15 +755,18 @@ const secureComsPlugin = {
|
|
|
535
755
|
boundIdentity: account.boundIdentity,
|
|
536
756
|
sc: cfg?.channels?.securecoms,
|
|
537
757
|
log: _log,
|
|
758
|
+
// Throws on failure (including runtime-not-ready) so the channel
|
|
759
|
+
// leaves the frame unacked for redelivery — never a silent drop.
|
|
538
760
|
onMessage: async (text, metadata) => {
|
|
539
761
|
if (!_runtime) {
|
|
540
|
-
_log?.("[SecureComs] runtime not ready —
|
|
541
|
-
|
|
762
|
+
_log?.("[SecureComs] runtime not ready — leaving inbound message unacked for redelivery");
|
|
763
|
+
throw new RuntimeNotReadyError();
|
|
542
764
|
}
|
|
543
765
|
try {
|
|
544
766
|
await _handleInbound({ text, metadata, channel, account, cfg });
|
|
545
767
|
} catch (err) {
|
|
546
768
|
_log?.(`[SecureComs] inbound dispatch error: ${String(err)}`);
|
|
769
|
+
throw err;
|
|
547
770
|
}
|
|
548
771
|
},
|
|
549
772
|
// Errors are recoverable (probe/gate/backoff retries automatically —
|
|
@@ -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
|
});
|
package/dist/bin.d.ts
CHANGED
|
@@ -3,13 +3,9 @@ import { type ResolveNodeDeps } from "./service/resolve-node.js";
|
|
|
3
3
|
import { type Harness } from "./service/wire.js";
|
|
4
4
|
export declare function nodeMajorOk(version: string): boolean;
|
|
5
5
|
/**
|
|
6
|
-
*
|
|
7
|
-
*
|
|
8
|
-
*
|
|
9
|
-
* expect" without the full secret ever hitting stderr (see the tokenFile
|
|
10
|
-
* comment at the top of this file). Mirrors the install-path token hint's
|
|
11
|
-
* philosophy of pointing at the token FILE rather than printing the secret
|
|
12
|
-
* (see service/install.ts's tokenHint, which returns the bridge-token path).
|
|
6
|
+
* A redacted bearer-token hint for explicit foreground install output: a short
|
|
7
|
+
* prefix plus the total length, never the raw value. Long-running service
|
|
8
|
+
* diagnostics do not call this helper or expose even the hint.
|
|
13
9
|
*/
|
|
14
10
|
export declare function tokenFingerprint(token: string): string;
|
|
15
11
|
export declare function parseVerb(argv: string[]): "install" | "uninstall" | "status" | "wire" | "unwire" | null;
|