@securecoms/agent-bridge 0.4.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.
- package/README.md +10 -0
- package/connectors/openclaw/gate.js +41 -0
- package/connectors/openclaw/gate.test.ts +54 -0
- package/connectors/openclaw/openclaw-entry.gate.test.ts +321 -0
- package/connectors/openclaw/openclaw-entry.js +602 -0
- package/connectors/openclaw/openclaw-entry.smoke.test.ts +30 -0
- package/connectors/openclaw/openclaw.plugin.json +25 -0
- package/connectors/openclaw/package.json +18 -0
- package/dist/bin.d.ts +4 -1
- package/dist/bin.js +1216 -76
- package/dist/bridge-agent.d.ts +178 -5
- package/dist/config.d.ts +1 -0
- package/dist/index.js +478 -41
- package/dist/preflight-upgrade.d.cts +2 -0
- package/dist/preflight.cjs +31 -8
- package/dist/service/config.d.ts +1 -0
- package/dist/service/connection-file.d.ts +33 -0
- package/dist/service/fleet.d.ts +27 -0
- package/dist/service/install-prep.d.ts +69 -0
- package/dist/service/resolve-node.d.ts +13 -0
- package/dist/service/status.d.ts +25 -0
- package/dist/service/wire.d.ts +76 -0
- package/dist/ws-server.d.ts +1 -1
- package/package.json +5 -4
|
@@ -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
|
+
}
|