@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.
@@ -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
  // ---------------------------------------------------------------------------
@@ -169,7 +237,7 @@ function listAccountIds(cfg) {
169
237
  // Dependency-free: Node 22 global WebSocket + global fetch.
170
238
  // ---------------------------------------------------------------------------
171
239
 
172
- class LoopbackChannel {
240
+ export class LoopbackChannel {
173
241
  constructor({ boundIdentity, sc, onMessage, onStateChange, log }) {
174
242
  // Fixed for the connector's life (spec §4.3): re-derived nowhere below.
175
243
  this.boundIdentity = boundIdentity;
@@ -188,6 +256,14 @@ 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
+ // Consecutive failed turns. While > 0, a WS open does NOT reset the
264
+ // reconnect backoff, so a failing turn is retried with growing delay
265
+ // instead of a ~250 ms redelivery loop.
266
+ this._failStreak = 0;
191
267
  }
192
268
 
193
269
  _setState(s) {
@@ -226,10 +302,7 @@ class LoopbackChannel {
226
302
  // like the no-token case below, never attempt the gate with an
227
303
  // undefined fileIdentity, never open a WS.
228
304
  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
- );
305
+ this.log("[SecureComs] no connection file at configured location; waiting for daemon");
233
306
  this._scheduleReconnect();
234
307
  return;
235
308
  }
@@ -248,9 +321,9 @@ class LoopbackChannel {
248
321
  let h;
249
322
  try {
250
323
  h = await (await fetch(`${this.bridgeUrl}/healthz`)).json();
251
- } catch (e) {
324
+ } catch {
252
325
  this._setState("error");
253
- this.log(`[SecureComs] daemon unreachable at ${this.bridgeUrl}: ${e?.message ?? e}`);
326
+ this.log("[SecureComs] daemon unreachable");
254
327
  this._scheduleReconnect();
255
328
  return;
256
329
  }
@@ -262,14 +335,14 @@ class LoopbackChannel {
262
335
  });
263
336
  if (!gate.ok) {
264
337
  this._setState("error");
265
- this.log(`[SecureComs] refusing to attach: ${gate.reason}`);
338
+ this.log("[SecureComs] refusing to attach: identity gate failed");
266
339
  this._scheduleReconnect();
267
340
  return;
268
341
  }
269
342
 
270
343
  this.deviceId = h.identity_id;
271
344
  this.sessionCount = h.joined_channels ?? 0;
272
- this.log(`[SecureComs] daemon ok identity=${h.identity_id} joined=${h.joined_channels}`);
345
+ this.log("[SecureComs] daemon identity gate passed");
273
346
  // Echo-suppression anchor — set ONLY after the gate passes.
274
347
  this._ownIdentity = this.boundIdentity;
275
348
  // Backoff is reset on a real WS open (see _connect's "open" handler), NOT
@@ -294,10 +367,11 @@ class LoopbackChannel {
294
367
 
295
368
  ws.addEventListener("open", () => {
296
369
  this._setState("ready");
297
- this.log(`[SecureComs] connected to daemon WS ${this.wsUrl}`);
370
+ this.log("[SecureComs] connected to daemon WebSocket");
298
371
  // Backoff resets only on a real WS open — a passing gate alone isn't
299
- // enough (see comment in _probeAndGate).
300
- this._attempt = 0;
372
+ // enough (see comment in _probeAndGate) — and not while turns are
373
+ // failing: a close we forced for redelivery must still back off.
374
+ if (this._failStreak === 0) this._attempt = 0;
301
375
  });
302
376
 
303
377
  ws.addEventListener("message", (ev) => {
@@ -306,22 +380,7 @@ class LoopbackChannel {
306
380
  frame = JSON.parse(typeof ev.data === "string" ? ev.data : ev.data.toString());
307
381
  } catch { return; }
308
382
  if (frame.type !== "message") return;
309
- // Never feed our own sends back into the agent (no echo loop).
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));
383
+ void this._onInboundFrame(frame);
325
384
  });
326
385
 
327
386
  ws.addEventListener("close", () => {
@@ -333,21 +392,139 @@ class LoopbackChannel {
333
392
  this._scheduleReconnect();
334
393
  });
335
394
 
336
- ws.addEventListener("error", (e) => {
337
- this.log(`[SecureComs] WS error: ${e?.message ?? "unknown"}`);
395
+ ws.addEventListener("error", () => {
396
+ this.log("[SecureComs] WebSocket error");
338
397
  });
339
398
  }
340
399
 
400
+ // Acks go to the CURRENT socket, not the one the frame arrived on: if that
401
+ // socket was replaced mid-turn, the daemon re-buffered the frame onto the
402
+ // new one, and only an ack there clears it.
341
403
  _ack(messageId) {
342
404
  try { this._ws?.send(JSON.stringify({ type: "ack", message_id: messageId })); } catch { /* ignore */ }
343
405
  }
344
406
 
407
+ _remember(messageId) {
408
+ if (this._seen.size >= SEEN_MAX) {
409
+ const oldest = this._seen.values().next();
410
+ if (!oldest.done) this._seen.delete(oldest.value);
411
+ }
412
+ this._seen.add(messageId);
413
+ }
414
+
415
+ // Close the current socket so the daemon re-buffers every un-acked frame
416
+ // for redelivery. The close handler re-probes and re-gates as usual.
417
+ _requestRedelivery() {
418
+ try { this._ws?.close(); } catch { /* already closing */ }
419
+ }
420
+
421
+ async _hasReplyReceipt(messageId) {
422
+ let r;
423
+ try {
424
+ r = await fetch(`${this.bridgeUrl}/v1/reply-receipt?in_reply_to=${encodeURIComponent(messageId)}`, {
425
+ headers: { authorization: `Bearer ${this.token}` },
426
+ });
427
+ } catch {
428
+ throw new ReceiptLookupError("daemon unreachable");
429
+ }
430
+ if (!r.ok) throw new ReceiptLookupError(`HTTP ${r.status}`);
431
+ let body;
432
+ try {
433
+ body = await r.json();
434
+ } catch {
435
+ // A 200 with a non-JSON/unparseable body is a daemon-side lookup
436
+ // failure, not a handler failure: it must not count toward
437
+ // MAX_INBOUND_ATTEMPTS (that would eventually ack-and-drop a message
438
+ // that was never actually processed).
439
+ throw new ReceiptLookupError("invalid JSON body");
440
+ }
441
+ return body?.found === true;
442
+ }
443
+
444
+ /**
445
+ * Handle one inbound `message` frame under the at-least-once contract (see
446
+ * "Inbound delivery contract" at the top of this file). Never throws.
447
+ */
448
+ async _onInboundFrame(frame) {
449
+ const messageId = frame.message_id;
450
+ if (typeof messageId !== "string" || messageId === "") {
451
+ this.log("[SecureComs] inbound frame without message_id; ignoring");
452
+ return;
453
+ }
454
+ // Never feed our own sends back into the agent (no echo loop).
455
+ if (frame.sender_id && this._ownIdentity && frame.sender_id === this._ownIdentity) {
456
+ this._remember(messageId);
457
+ this._ack(messageId);
458
+ return;
459
+ }
460
+ if (this._seen.has(messageId)) {
461
+ // Already handled (or reconciled via a reply receipt): ack the
462
+ // redelivered copy, never run the turn twice.
463
+ this._ack(messageId);
464
+ return;
465
+ }
466
+ if (this._inFlight.has(messageId)) {
467
+ // Redelivered onto a replacement socket while the original turn is
468
+ // still running. The original's completion acks on the current socket.
469
+ return;
470
+ }
471
+
472
+ this._inFlight.add(messageId);
473
+ try {
474
+ if (await this._hasReplyReceipt(messageId)) {
475
+ this._remember(messageId);
476
+ this._ack(messageId);
477
+ return;
478
+ }
479
+ await this.onMessage?.(typeof frame.text === "string" ? frame.text : "", {
480
+ conversationId: frame.channel_id,
481
+ messageId,
482
+ senderId: frame.sender_id,
483
+ senderName: frame.sender_name,
484
+ authorization: frame.authorization,
485
+ timestamp: frame.ts,
486
+ });
487
+ this._failures.delete(messageId);
488
+ this._failStreak = 0;
489
+ this._remember(messageId);
490
+ this._ack(messageId);
491
+ } catch (err) {
492
+ this._failStreak++;
493
+ if (err instanceof RuntimeNotReadyError || err instanceof ReceiptLookupError) {
494
+ this.log("[SecureComs] inbound message not processed yet (runtime not ready or reply-receipt lookup failed); leaving unacked for redelivery");
495
+ this._requestRedelivery();
496
+ return;
497
+ }
498
+ const attempts = (this._failures.get(messageId) ?? 0) + 1;
499
+ if (attempts >= MAX_INBOUND_ATTEMPTS) {
500
+ this._failures.delete(messageId);
501
+ this.log("[SecureComs] inbound turn failed MAX_INBOUND_ATTEMPTS times; giving up and acking the message");
502
+ this._remember(messageId);
503
+ this._ack(messageId);
504
+ return;
505
+ }
506
+ if (this._failures.size >= SEEN_MAX) {
507
+ const oldest = this._failures.keys().next();
508
+ if (!oldest.done) this._failures.delete(oldest.value);
509
+ }
510
+ this._failures.set(messageId, attempts);
511
+ this.log("[SecureComs] inbound turn failed; leaving unacked for redelivery");
512
+ this._requestRedelivery();
513
+ } finally {
514
+ this._inFlight.delete(messageId);
515
+ }
516
+ }
517
+
345
518
  /** Send a reply into a specific SecureComs channel via the daemon. */
346
- async send(channelId, text) {
519
+ async send(channelId, text, inReplyTo) {
347
520
  const r = await fetch(`${this.bridgeUrl}/v1/send`, {
348
521
  method: "POST",
349
522
  headers: { "content-type": "application/json", authorization: `Bearer ${this.token}` },
350
- body: JSON.stringify({ channel_id: channelId, text }),
523
+ body: JSON.stringify({
524
+ channel_id: channelId,
525
+ text,
526
+ ...(inReplyTo ? { in_reply_to: inReplyTo } : {}),
527
+ }),
351
528
  });
352
529
  if (!r.ok) throw new Error(`send failed: ${r.status} ${(await r.text()).slice(0, 200)}`);
353
530
  return r.json();
@@ -386,17 +563,33 @@ class LoopbackChannel {
386
563
 
387
564
  async function _handleInbound({ text, metadata, channel, account, cfg }) {
388
565
  const core = _runtime;
389
- if (!core) return;
390
-
391
- const conv = metadata.conversationId ?? "default";
392
- const senderId = "securecoms:owner";
566
+ if (!core) throw new RuntimeNotReadyError();
393
567
 
568
+ const conv = metadata.conversationId;
569
+ if (typeof conv !== "string" || conv === "") {
570
+ throw new Error("inbound frame has no channel_id");
571
+ }
572
+ // The REAL sender, as the daemon decrypted and attributed it. Never a
573
+ // hard-coded owner: who sent a message and whether they may run commands
574
+ // are separate facts, and only the daemon's authorization block speaks to
575
+ // the second.
576
+ const senderId = typeof metadata.senderId === "string" && metadata.senderId !== "" ? metadata.senderId : "unknown";
577
+ const senderName = typeof metadata.senderName === "string" && metadata.senderName !== "" ? metadata.senderName : senderId;
578
+ const chatType = chatTypeFor(metadata.authorization);
579
+ const commandAuthorized = isOwnerAuthorization(metadata.authorization);
580
+
581
+ // Route by the CHANNEL, so each SecureComs room/DM is its own conversation.
394
582
  const route = core.channel.routing.resolveAgentRoute({
395
583
  cfg,
396
584
  channel: "securecoms",
397
585
  accountId: account.accountId,
398
- peer: { kind: "direct", id: senderId },
586
+ peer: { kind: chatType, id: conv },
399
587
  });
588
+ // Session isolation is enforced HERE, not delegated to OpenClaw's dmScope:
589
+ // under the default dmScope every direct peer collapses into the agent's
590
+ // main session, which would merge two different humans' DMs (and the
591
+ // owner's) into one history. One session per SecureComs channel, always.
592
+ const sessionKey = `agent:${route.agentId}:securecoms:${chatType}:${conv}`;
400
593
 
401
594
  const storePath = core.channel.session.resolveStorePath(cfg?.session?.store, {
402
595
  agentId: route.agentId,
@@ -405,12 +598,12 @@ async function _handleInbound({ text, metadata, channel, account, cfg }) {
405
598
  const envelopeOptions = core.channel.reply.resolveEnvelopeFormatOptions(cfg);
406
599
  const previousTimestamp = core.channel.session.readSessionUpdatedAt({
407
600
  storePath,
408
- sessionKey: route.sessionKey,
601
+ sessionKey,
409
602
  });
410
603
 
411
604
  const body = core.channel.reply.formatAgentEnvelope({
412
605
  channel: "SecureComs",
413
- from: "Owner",
606
+ from: senderName,
414
607
  timestamp: new Date(metadata.timestamp).getTime(),
415
608
  previousTimestamp,
416
609
  envelope: envelopeOptions,
@@ -421,13 +614,13 @@ async function _handleInbound({ text, metadata, channel, account, cfg }) {
421
614
  Body: body,
422
615
  RawBody: text,
423
616
  CommandBody: text,
424
- From: senderId,
617
+ From: `securecoms:${senderId}`,
425
618
  To: `securecoms:agent:${account.accountId}`,
426
- SessionKey: route.sessionKey,
619
+ SessionKey: sessionKey,
427
620
  AccountId: account.accountId,
428
- ChatType: "direct",
621
+ ChatType: chatType,
429
622
  ConversationLabel: "SecureComs",
430
- SenderName: "Owner",
623
+ SenderName: senderName,
431
624
  SenderId: senderId,
432
625
  Provider: "securecoms",
433
626
  Surface: "securecoms",
@@ -435,14 +628,15 @@ async function _handleInbound({ text, metadata, channel, account, cfg }) {
435
628
  Timestamp: new Date(metadata.timestamp).getTime(),
436
629
  OriginatingChannel: "securecoms",
437
630
  OriginatingTo: `securecoms:agent:${account.accountId}`,
438
- // The owner is cryptographically authenticated by the SecureComs daemon
439
- // (MLS membership + bridge token); authorize command execution.
440
- CommandAuthorized: true,
631
+ // Command execution is the OWNER's alone, as attested by the daemon's
632
+ // authorization block (isOwnerAuthorization). MLS membership proves a
633
+ // sender is in the room, not that they own this agent.
634
+ CommandAuthorized: commandAuthorized,
441
635
  });
442
636
 
443
637
  await core.channel.session.recordInboundSession({
444
638
  storePath,
445
- sessionKey: ctxPayload.SessionKey ?? route.sessionKey,
639
+ sessionKey,
446
640
  ctx: ctxPayload,
447
641
  onRecordError: (err) => core.error?.(`[SecureComs] session record failed: ${String(err)}`),
448
642
  });
@@ -466,7 +660,7 @@ async function _handleInbound({ text, metadata, channel, account, cfg }) {
466
660
  // Drop chain-of-thought / reasoning blocks; only send finalized text.
467
661
  if (payload?.kind === "thinking" || payload?.kind === "reasoning") return;
468
662
  const t = (payload?.text ?? "").trim();
469
- if (t) await channel.send(conv, t);
663
+ if (t) await channel.send(conv, t, metadata.messageId);
470
664
  },
471
665
  onError: (err, info) => core.error?.(`[SecureComs] ${info?.kind ?? "reply"} error: ${String(err)}`),
472
666
  },
@@ -491,7 +685,7 @@ const secureComsPlugin = {
491
685
  aliases: ["sc", "secure-coms"],
492
686
  },
493
687
  capabilities: {
494
- chatTypes: ["direct", "room"],
688
+ chatTypes: ["direct", "group", "room"],
495
689
  },
496
690
  config: {
497
691
  listAccountIds,
@@ -531,15 +725,18 @@ const secureComsPlugin = {
531
725
  boundIdentity: account.boundIdentity,
532
726
  sc: cfg?.channels?.securecoms,
533
727
  log: _log,
728
+ // Throws on failure (including runtime-not-ready) so the channel
729
+ // leaves the frame unacked for redelivery — never a silent drop.
534
730
  onMessage: async (text, metadata) => {
535
731
  if (!_runtime) {
536
- _log?.("[SecureComs] runtime not ready — dropping inbound message");
537
- return;
732
+ _log?.("[SecureComs] runtime not ready — leaving inbound message unacked for redelivery");
733
+ throw new RuntimeNotReadyError();
538
734
  }
539
735
  try {
540
736
  await _handleInbound({ text, metadata, channel, account, cfg });
541
737
  } catch (err) {
542
738
  _log?.(`[SecureComs] inbound dispatch error: ${String(err)}`);
739
+ throw err;
543
740
  }
544
741
  },
545
742
  // Errors are recoverable (probe/gate/backoff retries automatically —
@@ -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
  });
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
- * P0b (daemon-stability): a redacted fingerprint of a bearer token for logs —
7
- * a short prefix plus the total length, never the raw value. Lets an
8
- * operator eyeball "yes, a token was loaded, and it looks like the one I
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;