@securecoms/agent-bridge 0.5.0 → 0.6.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.
@@ -0,0 +1,602 @@
1
+ /**
2
+ * SecureComs OpenClaw Channel Plugin (daemon-backed connector).
3
+ *
4
+ * Registers SecureComs as a first-class OpenClaw messaging channel WITHOUT
5
+ * embedding any cryptography in OpenClaw. All MLS key agreement and AEAD
6
+ * encryption happen in a separate local process — the SecureComs Agent Bridge
7
+ * daemon — which holds the agent credential. This channel talks to that daemon
8
+ * over an authenticated loopback socket (127.0.0.1, bearer-token-gated): the
9
+ * daemon locally terminates the end-to-end encryption and hands the cleartext
10
+ * to the agent on the SAME machine. Cleartext never touches the network or the
11
+ * server; the wire hop (daemon <-> SecureComs relay) stays MLS end-to-end.
12
+ *
13
+ * This is the "universal daemon client" pattern wearing an OpenClaw channel
14
+ * shim. The daemon is identical for every framework; only this thin file is
15
+ * OpenClaw-specific. It mirrors the AgentVault plugin's OpenClaw glue
16
+ * (resolveAgentRoute -> finalizeInboundContext -> dispatchReplyWithBufferedBlockDispatcher)
17
+ * but replaces the gen2 MLS transport with a daemon loopback client.
18
+ *
19
+ * This is the REFERENCE CONNECTOR for the harness connector contract
20
+ * (docs/superpowers/specs/2026-07-19-harness-connector-contract-design.md §5).
21
+ * It implements the contract's lifecycle (§4.2) and two-anchor identity gate
22
+ * (§4.3) via ./gate.js (resolveBoundIdentity / evaluateGate / backoffDelayMs).
23
+ *
24
+ * Identity resolution (in order):
25
+ * 1. bound_identity — a POSITIVE wiring act only, never inferred from which
26
+ * connection files exist (spec §4.3 Check 2 / audit C1). Read from
27
+ * SECURECOMS_CONNECTION_FILE (daemon-supervised env form) or from
28
+ * securecoms.binding.json dropped alongside this file by `agent-bridge
29
+ * wire` (spec §6.2). No binding present -> the connector refuses to
30
+ * attach and waits (reason: "unwired"); it never guesses.
31
+ * 2. connection file — ~/.securecoms/connections/<bound_identity>.json,
32
+ * written by the daemon on every startup (spec §3). REQUIRED to attach:
33
+ * supplies bridge_url (WS) and token_file (a PATH, never a token
34
+ * value). Re-read on every connect attempt (§4.2 step 6 -> step 1) so a
35
+ * moved port is picked up on reconnect. If the file is absent or
36
+ * unparseable (daemon not started yet, or a stale binding) the
37
+ * connector HOLDS — same as the unwired/no-token case — and never
38
+ * attempts the two-anchor gate or opens a WS. It does NOT fall back to
39
+ * openclaw.json as an attach path: doing so would run the gate with an
40
+ * undefined fileIdentity and produce a misleading "check 1 failed /
41
+ * stale port reuse" log instead of an honest "waiting for the daemon."
42
+ *
43
+ * openclaw.json channels.securecoms (bridgeUrl / tokenFile) and the
44
+ * SECURECOMS_BRIDGE_URL / SECURECOMS_BRIDGE_TOKEN_FILE env vars are optional
45
+ * bootstrap hints only, never a standalone attach path — the connector does
46
+ * not read them for attaching; the connection file above is the sole source
47
+ * of bridge_url/token_file. There is no value-form token fallback anywhere:
48
+ * a token secret is never accepted as an environment or config VALUE (audit
49
+ * M2 — that is exactly the leak ADR-0008 E warns about: inherited by
50
+ * children, visible to `ps eww` and crash dumps). The token is always read
51
+ * from a file.
52
+ */
53
+
54
+ import { readFileSync } from "node:fs";
55
+ import { homedir } from "node:os";
56
+ import { join } from "node:path";
57
+ import { evaluateGate, resolveBoundIdentity, backoffDelayMs } from "./gate.js";
58
+
59
+ // Runtime injected by register() — used by the inbound handler.
60
+ let _runtime = null;
61
+ // Active channel instances keyed by accountId.
62
+ const _channels = new Map();
63
+
64
+ // ---------------------------------------------------------------------------
65
+ // Account / identity / connection-file resolution.
66
+ // ---------------------------------------------------------------------------
67
+
68
+ function _expand(p) {
69
+ return p && p.startsWith("~") ? p.replace(/^~/, homedir()) : p;
70
+ }
71
+
72
+ function _connectionFilePath(identityId) {
73
+ return join(homedir(), ".securecoms", "connections", `${identityId}.json`);
74
+ }
75
+
76
+ // Resolve bridge_url/token_file/token for a FIXED bound identity. Called both
77
+ // at initial resolveAccount() and, per spec §4.3, on EVERY connect attempt
78
+ // (never re-derives boundIdentity itself — that is fixed for the connector's
79
+ // life and is not this function's job).
80
+ //
81
+ // The connection file is REQUIRED to attach (see header comment). If it is
82
+ // absent or unparseable this returns `null` — a not-ready sentinel — rather
83
+ // than falling back to openclaw.json/env. Callers MUST treat `null` as "hold,
84
+ // never gate, never open a WS" (see _probeAndGate); running evaluateGate()
85
+ // with an undefined fileIdentity is exactly the bug this sentinel prevents.
86
+ // A missing token (file present, token file unreadable) is also "not ready
87
+ // yet" — returned as `{ ..., token: null }` — since the daemon may simply not
88
+ // have finished writing it.
89
+ function _resolveConnection(boundIdentity, sc) {
90
+ void sc; // openclaw.json/env are optional bootstrap hints only, not read here (see header comment)
91
+
92
+ let conn;
93
+ try {
94
+ conn = JSON.parse(readFileSync(_connectionFilePath(boundIdentity), "utf8"));
95
+ } catch {
96
+ // Connection file not present/readable yet (daemon not started, or a
97
+ // stale binding). No attach path without it — not-ready sentinel.
98
+ return null;
99
+ }
100
+
101
+ if (typeof conn.bridge_url !== "string" || conn.bridge_url === "" ||
102
+ typeof conn.token_file !== "string" || conn.token_file === "") {
103
+ // Present but PARTIAL connection file (parses as valid JSON but is
104
+ // missing/blank bridge_url or token_file — e.g. daemon mid-write, or a
105
+ // stale/corrupt file). Treat exactly like the absent/unparseable case:
106
+ // not-ready sentinel, never dereference the missing fields below.
107
+ return null;
108
+ }
109
+
110
+ const wsUrl = conn.bridge_url;
111
+ const httpBase = conn.bridge_url.replace(/^ws/, "http").replace(/\/v1\/ws$/, "");
112
+ const tokenFile = conn.token_file;
113
+ const fileIdentity = conn.identity_id;
114
+
115
+ let token = null;
116
+ if (tokenFile) {
117
+ try { token = readFileSync(_expand(tokenFile), "utf8").trim(); } catch { /* leave null -> not ready */ }
118
+ }
119
+
120
+ return { httpBase, wsUrl, token, tokenFile, fileIdentity };
121
+ }
122
+
123
+ function resolveAccount(cfg, accountId) {
124
+ const sc = cfg?.channels?.securecoms ?? {};
125
+ const id = accountId ?? "default";
126
+
127
+ // bound_identity: a positive wiring act ONLY (spec §4.3 Check 2 / audit
128
+ // C1) — never inferred from which connection files happen to exist. The
129
+ // fourth arg (listConnections) is an intentionally-unused seam; this
130
+ // connector never auto-discovers.
131
+ const boundIdentity = resolveBoundIdentity(
132
+ process.env,
133
+ (p) => readFileSync(_expand(p), "utf8"),
134
+ join(import.meta.dirname, "securecoms.binding.json"),
135
+ () => [],
136
+ );
137
+ if (!boundIdentity) {
138
+ // Unwired: start() must wait, never connect (no directory-census guess).
139
+ return { accountId: id, configured: false, reason: "unwired" };
140
+ }
141
+
142
+ // May be null (not-ready sentinel) if the connection file doesn't exist
143
+ // yet — the daemon may not have started. That's fine here: `configured`
144
+ // only reflects binding, not daemon reachability (see comment below); the
145
+ // probe/gate/backoff loop is what actually holds until a connection file
146
+ // shows up (see LoopbackChannel._probeAndGate).
147
+ const resolved = _resolveConnection(boundIdentity, sc);
148
+
149
+ return {
150
+ accountId: id,
151
+ boundIdentity,
152
+ fileIdentity: resolved?.fileIdentity,
153
+ bridgeUrl: resolved?.httpBase,
154
+ agentName: sc.agentName ?? "OpenClaw Agent",
155
+ // Wired == configured. Whether the daemon is reachable RIGHT NOW is a
156
+ // transient condition handled by the probe/gate/backoff loop below, not
157
+ // a reason to refuse to even start the channel.
158
+ configured: true,
159
+ };
160
+ }
161
+
162
+ function listAccountIds(cfg) {
163
+ return cfg?.channels?.securecoms ? ["default"] : [];
164
+ }
165
+
166
+ // ---------------------------------------------------------------------------
167
+ // Loopback client — plays the transport role (replaces gen2 SecureChannel).
168
+ // Receive: device-wide WS push from the daemon. Send: HTTP POST /v1/send.
169
+ // Dependency-free: Node 22 global WebSocket + global fetch.
170
+ // ---------------------------------------------------------------------------
171
+
172
+ class LoopbackChannel {
173
+ constructor({ boundIdentity, sc, onMessage, onStateChange, log }) {
174
+ // Fixed for the connector's life (spec §4.3): re-derived nowhere below.
175
+ this.boundIdentity = boundIdentity;
176
+ this.sc = sc ?? {};
177
+ this.onMessage = onMessage;
178
+ this.onStateChange = onStateChange;
179
+ this.log = log || (() => {});
180
+ this.state = "idle";
181
+ this.deviceId = undefined;
182
+ this.sessionCount = 0;
183
+ // Populated by _resolve() on each attempt — never captured permanently.
184
+ this.bridgeUrl = null;
185
+ this.wsUrl = null;
186
+ this.token = null;
187
+ this._ws = null;
188
+ this._stopped = false;
189
+ this._attempt = 0;
190
+ this._ownIdentity = null;
191
+ }
192
+
193
+ _setState(s) {
194
+ this.state = s;
195
+ try { this.onStateChange?.(s); } catch { /* ignore */ }
196
+ }
197
+
198
+ // Re-read the connection file + token file on EVERY attempt (spec §4.2
199
+ // step 6 -> step 1): a moved port (rewritten connection file) must be
200
+ // picked up on reconnect. boundIdentity is NEVER re-derived here — see
201
+ // constructor comment and spec §4.3 ("re-deriving the binding on reconnect
202
+ // would itself be a vulnerability").
203
+ _resolve() {
204
+ return _resolveConnection(this.boundIdentity, this.sc);
205
+ }
206
+
207
+ async start() {
208
+ await this._probeAndGate();
209
+ }
210
+
211
+ // Probe the daemon and run the two-anchor gate BEFORE opening any WS
212
+ // (spec §4.2 step 2-3, §4.3). Called on startup AND on every reconnect —
213
+ // the WS close handler below calls _scheduleReconnect(), never _connect()
214
+ // directly, so a moved/replaced daemon is re-verified on every attempt
215
+ // (audit C3). On any failure this schedules a backed-off retry and returns
216
+ // WITHOUT opening a WS; it never throws for conditions that can resolve
217
+ // themselves (unwired, daemon down, gate mismatch), so a transient hiccup
218
+ // never permanently kills the channel.
219
+ async _probeAndGate() {
220
+ if (this._stopped) return;
221
+
222
+ const acct = this._resolve();
223
+ if (!acct) {
224
+ // No connection file yet at all (daemon not started, or a stale
225
+ // binding). The connection file is REQUIRED to attach — hold exactly
226
+ // like the no-token case below, never attempt the gate with an
227
+ // undefined fileIdentity, never open a WS.
228
+ 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
+ );
233
+ this._scheduleReconnect();
234
+ return;
235
+ }
236
+ if (!acct.token) {
237
+ // Connection file exists but the token file isn't readable yet (daemon
238
+ // mid-startup). Keep waiting; never guess.
239
+ this._setState("idle");
240
+ this.log("[SecureComs] not yet resolvable (no connection file / token) — waiting");
241
+ this._scheduleReconnect();
242
+ return;
243
+ }
244
+ this.bridgeUrl = acct.httpBase;
245
+ this.wsUrl = acct.wsUrl;
246
+ this.token = acct.token;
247
+
248
+ let h;
249
+ try {
250
+ h = await (await fetch(`${this.bridgeUrl}/healthz`)).json();
251
+ } catch (e) {
252
+ this._setState("error");
253
+ this.log(`[SecureComs] daemon unreachable at ${this.bridgeUrl}: ${e?.message ?? e}`);
254
+ this._scheduleReconnect();
255
+ return;
256
+ }
257
+
258
+ const gate = evaluateGate({
259
+ fileIdentity: acct.fileIdentity,
260
+ healthzIdentity: h.identity_id,
261
+ boundIdentity: this.boundIdentity,
262
+ });
263
+ if (!gate.ok) {
264
+ this._setState("error");
265
+ this.log(`[SecureComs] refusing to attach: ${gate.reason}`);
266
+ this._scheduleReconnect();
267
+ return;
268
+ }
269
+
270
+ this.deviceId = h.identity_id;
271
+ this.sessionCount = h.joined_channels ?? 0;
272
+ this.log(`[SecureComs] daemon ok identity=${h.identity_id} joined=${h.joined_channels}`);
273
+ // Echo-suppression anchor — set ONLY after the gate passes.
274
+ this._ownIdentity = this.boundIdentity;
275
+ // Backoff is reset on a real WS open (see _connect's "open" handler), NOT
276
+ // here: a daemon that keeps passing the gate but whose WS handshake keeps
277
+ // failing (e.g. the port accepts TCP but never completes the WS upgrade)
278
+ // must still back off, not hammer at attempt=0 forever.
279
+ this._connect();
280
+ }
281
+
282
+ _scheduleReconnect() {
283
+ if (this._stopped) return;
284
+ const delay = backoffDelayMs(this._attempt++);
285
+ setTimeout(() => this._probeAndGate(), delay);
286
+ }
287
+
288
+ _connect() {
289
+ if (this._stopped) return;
290
+ // WHATWG WebSocket can't set headers, so the token rides the query string
291
+ // (the daemon accepts ?token=, loopback-only bind keeps it on this host).
292
+ const ws = new WebSocket(`${this.wsUrl}?token=${encodeURIComponent(this.token)}`);
293
+ this._ws = ws;
294
+
295
+ ws.addEventListener("open", () => {
296
+ this._setState("ready");
297
+ this.log(`[SecureComs] connected to daemon WS ${this.wsUrl}`);
298
+ // Backoff resets only on a real WS open — a passing gate alone isn't
299
+ // enough (see comment in _probeAndGate).
300
+ this._attempt = 0;
301
+ });
302
+
303
+ ws.addEventListener("message", (ev) => {
304
+ let frame;
305
+ try {
306
+ frame = JSON.parse(typeof ev.data === "string" ? ev.data : ev.data.toString());
307
+ } catch { return; }
308
+ 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));
325
+ });
326
+
327
+ ws.addEventListener("close", () => {
328
+ if (this._stopped) return;
329
+ this._setState("idle");
330
+ this.log("[SecureComs] daemon WS closed; reconnecting");
331
+ // Reconnect ALWAYS re-probes and re-gates (spec §4.2 step 6, audit C3)
332
+ // — never re-opens a WS directly from here.
333
+ this._scheduleReconnect();
334
+ });
335
+
336
+ ws.addEventListener("error", (e) => {
337
+ this.log(`[SecureComs] WS error: ${e?.message ?? "unknown"}`);
338
+ });
339
+ }
340
+
341
+ _ack(messageId) {
342
+ try { this._ws?.send(JSON.stringify({ type: "ack", message_id: messageId })); } catch { /* ignore */ }
343
+ }
344
+
345
+ /** Send a reply into a specific SecureComs channel via the daemon. */
346
+ async send(channelId, text) {
347
+ const r = await fetch(`${this.bridgeUrl}/v1/send`, {
348
+ method: "POST",
349
+ headers: { "content-type": "application/json", authorization: `Bearer ${this.token}` },
350
+ body: JSON.stringify({ channel_id: channelId, text }),
351
+ });
352
+ if (!r.ok) throw new Error(`send failed: ${r.status} ${(await r.text()).slice(0, 200)}`);
353
+ return r.json();
354
+ }
355
+
356
+ // Report agent operational status to the daemon (POST /v1/status). Drives the
357
+ // web "Thinking…" indicator (J1). Best-effort and FIRE-AND-FORGET: callers do
358
+ // NOT await it, so the status round-trip adds no latency to the turn. On this
359
+ // deployment an MLS publish (which /v1/status performs) costs ~4.4s, so any
360
+ // await here would add seconds before the reply — the exact lag we hit. No
361
+ // AbortSignal timeout either: a 2s cap fired before the ~4.4s publish landed,
362
+ // so the "working" status never published and the strip never showed. Let the
363
+ // background fetch run to completion; it swallows its own errors.
364
+ async reportStatus(channelId, state) {
365
+ try {
366
+ await fetch(`${this.bridgeUrl}/v1/status`, {
367
+ method: "POST",
368
+ headers: { "content-type": "application/json", authorization: `Bearer ${this.token}` },
369
+ body: JSON.stringify({ channel_id: channelId, state }),
370
+ });
371
+ } catch { /* best-effort: never break a turn on a status POST */ }
372
+ }
373
+
374
+ async stop() {
375
+ this._stopped = true;
376
+ try { this._ws?.close(); } catch { /* ignore */ }
377
+ this._setState("idle");
378
+ }
379
+ }
380
+
381
+ // ---------------------------------------------------------------------------
382
+ // Inbound handler — routes a decrypted message into the OpenClaw agent loop.
383
+ // Mirrors the AgentVault DM glue; replies are delivered back to the SAME
384
+ // SecureComs channel the message arrived on (closure over metadata).
385
+ // ---------------------------------------------------------------------------
386
+
387
+ async function _handleInbound({ text, metadata, channel, account, cfg }) {
388
+ const core = _runtime;
389
+ if (!core) return;
390
+
391
+ const conv = metadata.conversationId ?? "default";
392
+ const senderId = "securecoms:owner";
393
+
394
+ const route = core.channel.routing.resolveAgentRoute({
395
+ cfg,
396
+ channel: "securecoms",
397
+ accountId: account.accountId,
398
+ peer: { kind: "direct", id: senderId },
399
+ });
400
+
401
+ const storePath = core.channel.session.resolveStorePath(cfg?.session?.store, {
402
+ agentId: route.agentId,
403
+ });
404
+
405
+ const envelopeOptions = core.channel.reply.resolveEnvelopeFormatOptions(cfg);
406
+ const previousTimestamp = core.channel.session.readSessionUpdatedAt({
407
+ storePath,
408
+ sessionKey: route.sessionKey,
409
+ });
410
+
411
+ const body = core.channel.reply.formatAgentEnvelope({
412
+ channel: "SecureComs",
413
+ from: "Owner",
414
+ timestamp: new Date(metadata.timestamp).getTime(),
415
+ previousTimestamp,
416
+ envelope: envelopeOptions,
417
+ body: text,
418
+ });
419
+
420
+ const ctxPayload = core.channel.reply.finalizeInboundContext({
421
+ Body: body,
422
+ RawBody: text,
423
+ CommandBody: text,
424
+ From: senderId,
425
+ To: `securecoms:agent:${account.accountId}`,
426
+ SessionKey: route.sessionKey,
427
+ AccountId: account.accountId,
428
+ ChatType: "direct",
429
+ ConversationLabel: "SecureComs",
430
+ SenderName: "Owner",
431
+ SenderId: senderId,
432
+ Provider: "securecoms",
433
+ Surface: "securecoms",
434
+ MessageSid: metadata.messageId,
435
+ Timestamp: new Date(metadata.timestamp).getTime(),
436
+ OriginatingChannel: "securecoms",
437
+ 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,
441
+ });
442
+
443
+ await core.channel.session.recordInboundSession({
444
+ storePath,
445
+ sessionKey: ctxPayload.SessionKey ?? route.sessionKey,
446
+ ctx: ctxPayload,
447
+ onRecordError: (err) => core.error?.(`[SecureComs] session record failed: ${String(err)}`),
448
+ });
449
+
450
+ // J1 activity indicator: flip the agent to "working" for the duration of the
451
+ // reply turn (the LLM dispatch, the bulk of "thinking") and back to "idle"
452
+ // when it finishes OR throws. FIRE-AND-FORGET (not awaited): the status
453
+ // publish costs ~4.4s on this deployment, so awaiting it added that latency
454
+ // before the reply and after the turn. Ordering stays correct without the
455
+ // await because "working" is issued before a multi-second dispatch, giving it
456
+ // a large head-start over the reply's own publish — the ping→pong race that
457
+ // motivated awaiting doesn't occur when the reply itself takes seconds to
458
+ // publish. The web client clears the strip on the later "idle".
459
+ channel.reportStatus(conv, "working");
460
+ try {
461
+ await core.channel.reply.dispatchReplyWithBufferedBlockDispatcher({
462
+ ctx: ctxPayload,
463
+ cfg,
464
+ dispatcherOptions: {
465
+ deliver: async (payload) => {
466
+ // Drop chain-of-thought / reasoning blocks; only send finalized text.
467
+ if (payload?.kind === "thinking" || payload?.kind === "reasoning") return;
468
+ const t = (payload?.text ?? "").trim();
469
+ if (t) await channel.send(conv, t);
470
+ },
471
+ onError: (err, info) => core.error?.(`[SecureComs] ${info?.kind ?? "reply"} error: ${String(err)}`),
472
+ },
473
+ replyOptions: {},
474
+ });
475
+ } finally {
476
+ channel.reportStatus(conv, "idle");
477
+ }
478
+ }
479
+
480
+ // ---------------------------------------------------------------------------
481
+ // Channel plugin definition
482
+ // ---------------------------------------------------------------------------
483
+
484
+ const secureComsPlugin = {
485
+ id: "securecoms",
486
+ meta: {
487
+ id: "securecoms",
488
+ label: "SecureComs",
489
+ selectionLabel: "SecureComs (E2E Encrypted)",
490
+ blurb: "End-to-end encrypted messaging via the local SecureComs daemon.",
491
+ aliases: ["sc", "secure-coms"],
492
+ },
493
+ capabilities: {
494
+ chatTypes: ["direct", "room"],
495
+ },
496
+ config: {
497
+ listAccountIds,
498
+ resolveAccount,
499
+ },
500
+ gateway: {
501
+ probe: async (ctx) => {
502
+ const accountId = ctx?.accountId ?? "default";
503
+ const ch = _channels.get(accountId);
504
+ if (!ch) return { ok: false, status: "disconnected", error: "Channel not started" };
505
+ return { ok: ch.state === "ready", status: ch.state, deviceId: ch.deviceId, sessions: ch.sessionCount };
506
+ },
507
+ status: (ctx) => {
508
+ const accountId = ctx?.accountId ?? "default";
509
+ const ch = _channels.get(accountId);
510
+ if (!ch) return { connected: false, status: "not_started" };
511
+ return { connected: ch.state === "ready", status: ch.state, deviceId: ch.deviceId, sessions: ch.sessionCount, encrypted: true };
512
+ },
513
+ startAccount: async (ctx) => {
514
+ const { account, cfg, log, abortSignal } = ctx;
515
+ const _log = typeof log === "function" ? log : log?.info?.bind(log);
516
+
517
+ if (!account.configured) {
518
+ throw new Error(
519
+ "SecureComs channel is not wired to an agent identity " +
520
+ `(${account.reason ?? "unwired"}). Run \`agent-bridge wire --identity <id>\` ` +
521
+ "(or set SECURECOMS_CONNECTION_FILE) to bind this connector, then restart OpenClaw.",
522
+ );
523
+ }
524
+
525
+ _log?.(`[SecureComs] starting channel (identity=${account.boundIdentity})`);
526
+
527
+ // startAccount MUST stay pending while the channel runs; resolving signals
528
+ // "stopped" to the gateway (auto-restart). Block until abortSignal fires.
529
+ await new Promise((resolveP, reject) => {
530
+ const channel = new LoopbackChannel({
531
+ boundIdentity: account.boundIdentity,
532
+ sc: cfg?.channels?.securecoms,
533
+ log: _log,
534
+ onMessage: async (text, metadata) => {
535
+ if (!_runtime) {
536
+ _log?.("[SecureComs] runtime not ready — dropping inbound message");
537
+ return;
538
+ }
539
+ try {
540
+ await _handleInbound({ text, metadata, channel, account, cfg });
541
+ } catch (err) {
542
+ _log?.(`[SecureComs] inbound dispatch error: ${String(err)}`);
543
+ }
544
+ },
545
+ // Errors are recoverable (probe/gate/backoff retries automatically —
546
+ // see LoopbackChannel._probeAndGate), so no state here is treated as
547
+ // a permanent failure. The promise resolves only on abort.
548
+ onStateChange: (state) => {
549
+ _log?.(`[SecureComs] state → ${state}`);
550
+ },
551
+ });
552
+
553
+ _channels.set(account.accountId, channel);
554
+
555
+ abortSignal?.addEventListener("abort", async () => {
556
+ await channel.stop();
557
+ _channels.delete(account.accountId);
558
+ resolveP();
559
+ });
560
+
561
+ channel.start().catch(reject);
562
+ });
563
+
564
+ return { stop: async () => {} };
565
+ },
566
+ },
567
+ outbound: {
568
+ deliveryMode: "direct",
569
+ targets: [
570
+ { id: "owner", label: "SecureComs Owner", accountId: "default" },
571
+ { id: "default", label: "SecureComs Owner (default)", accountId: "default" },
572
+ ],
573
+ // Proactive send (cron / cross-channel). Requires an explicit target channel
574
+ // id in `to`; without channel context we can't guess which SecureComs
575
+ // conversation to use, so this errors rather than mis-routing.
576
+ sendText: async ({ text, to, accountId }) => {
577
+ const ch = _channels.get(accountId ?? "default");
578
+ if (!ch) return { ok: false, error: "SecureComs channel is not connected" };
579
+ if (!to) return { ok: false, error: "SecureComs proactive send requires a target channel_id in `to`" };
580
+ try {
581
+ await ch.send(to, text);
582
+ return { ok: true };
583
+ } catch (err) {
584
+ return { ok: false, error: String(err) };
585
+ }
586
+ },
587
+ },
588
+ };
589
+
590
+ // ---------------------------------------------------------------------------
591
+ // Plugin entry point
592
+ // ---------------------------------------------------------------------------
593
+
594
+ export default {
595
+ id: "securecoms",
596
+ name: "SecureComs",
597
+ description: "End-to-end encrypted messaging via the local SecureComs Agent Bridge daemon.",
598
+ register(api) {
599
+ _runtime = api.runtime;
600
+ api.registerChannel({ plugin: secureComsPlugin });
601
+ },
602
+ };
@@ -0,0 +1,30 @@
1
+ import { describe, it, expect } from "vitest";
2
+ import { readFileSync } from "node:fs";
3
+ import { join } from "node:path";
4
+
5
+ describe("openclaw-entry", () => {
6
+ it("loads as ESM and exposes the plugin entry without connecting", async () => {
7
+ const mod = await import("./openclaw-entry.js");
8
+ expect(mod.default ?? mod).toBeTruthy(); // exports the OpenClaw plugin factory/shape
9
+ });
10
+
11
+ it("exposes register() and the securecoms channel id, without opening a socket", async () => {
12
+ const mod = await import("./openclaw-entry.js");
13
+ const plugin = mod.default;
14
+ expect(plugin.id).toBe("securecoms");
15
+ expect(typeof plugin.register).toBe("function");
16
+ });
17
+
18
+ it("does not read a token value from the environment (audit M2)", () => {
19
+ const src = readFileSync(join(import.meta.dirname, "openclaw-entry.js"), "utf8");
20
+ expect(src).not.toMatch(/process\.env\.SECURECOMS_BRIDGE_TOKEN\b(?!_FILE)/);
21
+ });
22
+
23
+ it("never opens a WebSocket or fetches /healthz merely from being imported", () => {
24
+ const src = readFileSync(join(import.meta.dirname, "openclaw-entry.js"), "utf8");
25
+ // Only reachable inside class methods (_connect/_probeAndGate), never at module top level.
26
+ const topLevel = src.split("class LoopbackChannel")[0];
27
+ expect(topLevel).not.toMatch(/new WebSocket\(/);
28
+ expect(topLevel).not.toMatch(/\bfetch\(/);
29
+ });
30
+ });
@@ -0,0 +1,25 @@
1
+ {
2
+ "id": "securecoms",
3
+ "name": "SecureComs",
4
+ "description": "End-to-end encrypted messaging bridged from the local SecureComs Agent Bridge daemon. The daemon holds the credential and performs all MLS cryptography; this channel talks to it over an authenticated loopback socket (127.0.0.1, bearer-token-gated) after the daemon locally terminates the E2E encryption, and never touches keys. Cleartext exists only on this machine, never on the network or the server.",
5
+ "version": "0.1.0",
6
+ "kind": "channel",
7
+ "channels": ["securecoms"],
8
+ "outbound": {
9
+ "deliveryMode": "direct",
10
+ "targets": [
11
+ { "id": "owner", "label": "SecureComs Owner", "accountId": "default" },
12
+ { "id": "default", "label": "SecureComs Owner (default)", "accountId": "default" }
13
+ ]
14
+ },
15
+ "configSchema": {
16
+ "type": "object",
17
+ "additionalProperties": true,
18
+ "properties": {
19
+ "bridgeUrl": { "type": "string", "description": "SecureComs daemon loopback base URL (e.g. http://127.0.0.1:7079)" },
20
+ "tokenFile": { "type": "string", "description": "Path to the daemon bridge-token file" },
21
+ "agentName": { "type": "string", "description": "Display name for this agent" }
22
+ }
23
+ },
24
+ "uiHints": {}
25
+ }
@@ -0,0 +1,18 @@
1
+ {
2
+ "name": "securecoms-openclaw",
3
+ "version": "0.1.0",
4
+ "private": true,
5
+ "type": "module",
6
+ "description": "SecureComs channel for OpenClaw — bridges the local SecureComs Agent Bridge daemon (E2E MLS) over loopback. No crypto in OpenClaw.",
7
+ "openclaw": {
8
+ "extensions": ["./openclaw-entry.js"],
9
+ "channel": {
10
+ "id": "securecoms",
11
+ "label": "SecureComs",
12
+ "selectionLabel": "SecureComs (E2E Encrypted)",
13
+ "blurb": "End-to-end encrypted messaging via the local SecureComs daemon.",
14
+ "aliases": ["sc", "secure-coms"]
15
+ },
16
+ "install": { "defaultChoice": "local" }
17
+ }
18
+ }