@securecoms/agent-bridge 0.11.0 → 0.11.2

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/dist/config.js ADDED
@@ -0,0 +1,74 @@
1
+ import { readFileSync, statSync } from "node:fs";
2
+ import { dirname, join } from "node:path";
3
+ const REQUIRED = ["api_key", "identity_id", "device_id", "signing_private_key", "enclave_id", "api_base"];
4
+ export function loadConfig(opts) {
5
+ const raw = readFileSync(opts.credentialPath, "utf8");
6
+ // Perms check (advisory): warn to STDERR if not owner-only.
7
+ try {
8
+ const mode = statSync(opts.credentialPath).mode & 0o777;
9
+ if (mode & 0o077)
10
+ process.stderr.write(`[bridge] WARNING: credential file ${opts.credentialPath} permission ${mode.toString(8)} is not 0600 — tighten it (chmod 600).\n`);
11
+ }
12
+ catch { /* stat best-effort */ }
13
+ let json;
14
+ try {
15
+ json = JSON.parse(raw);
16
+ }
17
+ catch {
18
+ throw new Error("credential file is not valid JSON");
19
+ }
20
+ for (const k of REQUIRED)
21
+ if (typeof json[k] !== "string" || !json[k])
22
+ throw new Error(`credential file missing required field: ${k}`);
23
+ const storePath = opts.storePath ?? join(dirname(opts.credentialPath), "bridge-state.db");
24
+ // Optional agent name for the room @mention filter: prefer agent_name, else display_name.
25
+ const agentName = typeof json.agent_name === "string" && json.agent_name
26
+ ? json.agent_name
27
+ : typeof json.display_name === "string" && json.display_name
28
+ ? json.display_name
29
+ : undefined;
30
+ const rateLimitCapacity = typeof json.rate_limit_capacity === "number" ? json.rate_limit_capacity : 20;
31
+ const rateLimitRefillPerMinute = typeof json.rate_limit_refill_per_minute === "number" ? json.rate_limit_refill_per_minute : 20;
32
+ return {
33
+ apiKey: json.api_key, identityId: json.identity_id, deviceId: json.device_id,
34
+ signingPrivateKey: json.signing_private_key, enclaveId: json.enclave_id,
35
+ apiBase: json.api_base, storePath, agentName,
36
+ rateLimitCapacity, rateLimitRefillPerMinute,
37
+ };
38
+ }
39
+ function arg(argv, flag) {
40
+ const i = argv.indexOf(flag);
41
+ return i >= 0 ? argv[i + 1] : undefined;
42
+ }
43
+ function args(argv, flag) {
44
+ const out = [];
45
+ for (let i = 0; i < argv.length; i++)
46
+ if (argv[i] === flag && argv[i + 1])
47
+ out.push(argv[i + 1]);
48
+ return out;
49
+ }
50
+ export function parseDaemonArgs(argv, env) {
51
+ const tlsCert = arg(argv, "--tls-cert") ?? env.SECURECOMS_TLS_CERT;
52
+ const tlsKey = arg(argv, "--tls-key") ?? env.SECURECOMS_TLS_KEY;
53
+ return {
54
+ port: Number(arg(argv, "--port") ?? env.SECURECOMS_PORT ?? 7077),
55
+ bind: arg(argv, "--bind") ?? env.SECURECOMS_BIND ?? "127.0.0.1",
56
+ tokenFile: arg(argv, "--token-file") ?? env.SECURECOMS_TOKEN_FILE,
57
+ token: arg(argv, "--token") ?? env.SECURECOMS_LOCAL_TOKEN,
58
+ tls: Boolean(tlsCert && tlsKey),
59
+ tlsCert,
60
+ tlsKey,
61
+ allowOrigins: args(argv, "--allow-origin"),
62
+ };
63
+ }
64
+ export function isLoopback(host) {
65
+ return (host === "localhost" ||
66
+ host === "::1" ||
67
+ host === "[::1]" ||
68
+ /^127\./.test(host));
69
+ }
70
+ export function validateBind(bind, tls) {
71
+ if (!isLoopback(bind) && !tls) {
72
+ throw new Error(`refusing non-loopback bind '${bind}' without TLS — supply --tls-cert and --tls-key for cross-host, or bind a loopback address`);
73
+ }
74
+ }
@@ -0,0 +1,63 @@
1
+ // Multi-agent ROOM coordination floor — @mention filter.
2
+ //
3
+ // Ported faithfully from the gen2 OpenClaw plugin
4
+ // (packages/plugin/src/openclaw-entry.ts: _parseMentions / _shouldProcessRoomMessage /
5
+ // _ownRoomNames). Pure functions only — no rate limiter (a later slice), no globals,
6
+ // no console output. Semantics must match gen2 exactly.
7
+ /** Extract @mention names from plaintext. Returns lowercased names. */
8
+ export function parseMentions(text) {
9
+ const mentions = [];
10
+ // Match @word at a word boundary — supports multiple via sequential matching.
11
+ const re = /@(\w[\w]*)/gi;
12
+ let match;
13
+ while ((match = re.exec(text)) !== null) {
14
+ mentions.push(match[1].toLowerCase());
15
+ }
16
+ return mentions;
17
+ }
18
+ /**
19
+ * Lower-cased set of names that count as "this agent" in a room: framework
20
+ * agentName + its first word, accountId + its first word, and (when known) the
21
+ * AgentVault room display_name + its first word. First word = split on
22
+ * whitespace or "(".
23
+ */
24
+ export function ownRoomNames(agentName, accountId, roomDisplayName) {
25
+ const names = new Set();
26
+ const add = (n) => {
27
+ if (!n)
28
+ return;
29
+ const lower = n.toLowerCase();
30
+ names.add(lower);
31
+ names.add(lower.split(/[\s(]/)[0]);
32
+ };
33
+ add(agentName);
34
+ add(accountId);
35
+ add(roomDisplayName);
36
+ return names;
37
+ }
38
+ /**
39
+ * Whether this agent should process a room message based on its @mentions.
40
+ *
41
+ * Loop prevention is the rate-limiter's job (a later slice), so this filter does
42
+ * NOT gate on senderIsAgent — every message is processed unless it @mentions a
43
+ * *different* agent specifically:
44
+ * - no mentions → true (broadcast)
45
+ * - @all / @everyone → true
46
+ * - this agent is mentioned → true
47
+ * - else (a different agent is mentioned) → false
48
+ */
49
+ export function shouldProcessRoomMessage(plaintext, agentName, accountId, opts) {
50
+ const mentions = parseMentions(plaintext);
51
+ // No mentions → broadcast to all agents.
52
+ if (mentions.length === 0)
53
+ return true;
54
+ // @all / @everyone → all agents process.
55
+ if (mentions.includes("all") || mentions.includes("everyone"))
56
+ return true;
57
+ // This agent addressed by framework name / accountId / room display_name.
58
+ const ownNames = ownRoomNames(agentName, accountId, opts?.roomDisplayName);
59
+ if (mentions.some((m) => ownNames.has(m)))
60
+ return true;
61
+ // Has @mentions but none match this agent — skip.
62
+ return false;
63
+ }
package/dist/errors.js ADDED
@@ -0,0 +1,25 @@
1
+ export const BRIDGE_ERRORS = {
2
+ unauthorized: { http: 401, message: "missing or invalid bearer token" },
3
+ forbidden_origin: { http: 403, message: "origin or host not allowed" },
4
+ not_found: { http: 404, message: "no such route" },
5
+ invalid_request: { http: 400, message: "request failed validation" },
6
+ invalid_json: { http: 400, message: "request body is not valid JSON" },
7
+ body_too_large: { http: 400, message: "request body exceeds the size limit" },
8
+ channel_pending: { http: 503, message: "channel not yet admitted; retry shortly" },
9
+ rate_limited: { http: 429, message: "rate limit exceeded; retry shortly" },
10
+ internal: { http: 500, message: "internal error" },
11
+ };
12
+ /** The shared error payload (used as-is by HTTP, and spread into the WS frame). */
13
+ export function errorBody(code, detail) {
14
+ const body = { error: code, message: BRIDGE_ERRORS[code].message };
15
+ if (detail !== undefined)
16
+ body.detail = detail;
17
+ return body;
18
+ }
19
+ export function httpStatus(code) {
20
+ return BRIDGE_ERRORS[code].http;
21
+ }
22
+ /** WebSocket error frame: the shared body under a `{type:"error"}` envelope. */
23
+ export function wsErrorFrame(code, detail) {
24
+ return { type: "error", ...errorBody(code, detail) };
25
+ }
@@ -0,0 +1,220 @@
1
+ import { createServer as createHttp } from "node:http";
2
+ import { createServer as createHttps } from "node:https";
3
+ import { z } from "zod";
4
+ import { ChannelPendingError, RateLimitedError, InvalidPathError } from "./bridge-agent.js";
5
+ import { bearerFrom, tokenMatches, originHostAllowed } from "./auth.js";
6
+ import { errorBody, httpStatus } from "./errors.js";
7
+ export function writeSse(res, msgs) {
8
+ for (let i = 0; i < msgs.length; i++) {
9
+ if (res.destroyed || res.writableEnded)
10
+ return msgs.slice(i);
11
+ try {
12
+ res.write(`event: message\ndata: ${JSON.stringify(msgs[i])}\n\n`);
13
+ }
14
+ catch {
15
+ return msgs.slice(i);
16
+ }
17
+ }
18
+ return [];
19
+ }
20
+ /** The daemon's self-describing capability contract (A2A-style discovery). */
21
+ export function capabilities(opts) {
22
+ return {
23
+ api_version: "1",
24
+ receive_modes: ["longpoll", "sse", ...(opts.websocket ? ["websocket"] : [])],
25
+ send_types: ["text", "agent_status", "a2a"],
26
+ webhook_push: false, // B3: A2A-style push-notification config (ciphertext/wake-only)
27
+ };
28
+ }
29
+ const SEND_CAP = 64 * 1024;
30
+ const OTHER_CAP = 8 * 1024;
31
+ const a2aSchema = z.object({
32
+ a2a_version: z.string().min(1),
33
+ kind: z.enum(["message", "task", "artifact"]),
34
+ object: z.record(z.unknown()),
35
+ });
36
+ const sendAttachmentSchema = z.object({
37
+ channel_id: z.string().min(1),
38
+ path: z.string().min(1),
39
+ caption: z.string().optional(),
40
+ });
41
+ const sendSchema = z.object({
42
+ channel_id: z.string().min(1),
43
+ text: z.string().optional(),
44
+ idempotency_key: z.string().optional(),
45
+ a2a: a2aSchema.optional(),
46
+ }).refine((d) => typeof d.text === "string" || d.a2a !== undefined, {
47
+ message: "send requires either text or a2a",
48
+ });
49
+ const statusSchema = z.object({
50
+ channel_id: z.string().min(1),
51
+ state: z.enum(["idle", "working", "stalled", "error"]),
52
+ detail: z.string().optional(),
53
+ });
54
+ function json(res, status, body) {
55
+ const s = JSON.stringify(body);
56
+ res.writeHead(status, { "content-type": "application/json" });
57
+ res.end(s);
58
+ }
59
+ /** Render a canonical error to its mapped HTTP status + shared body. */
60
+ function jsonError(res, code, detail) {
61
+ json(res, httpStatus(code), errorBody(code, detail));
62
+ }
63
+ function readBody(req, cap) {
64
+ return new Promise((resolve, reject) => {
65
+ let size = 0;
66
+ let done = false;
67
+ const chunks = [];
68
+ const onData = (c) => {
69
+ if (done)
70
+ return;
71
+ size += c.length;
72
+ if (size > cap) {
73
+ // Stop consuming and let the response (400) flush; do NOT destroy the
74
+ // socket here, that races with the response write and surfaces to the
75
+ // client as "other side closed" instead of a clean 400.
76
+ done = true;
77
+ req.removeListener("data", onData);
78
+ req.pause();
79
+ reject(new Error("body_too_large"));
80
+ return;
81
+ }
82
+ chunks.push(c);
83
+ };
84
+ req.on("data", onData);
85
+ req.on("end", () => { if (!done) {
86
+ done = true;
87
+ resolve(Buffer.concat(chunks).toString("utf8"));
88
+ } });
89
+ req.on("error", (e) => { if (!done) {
90
+ done = true;
91
+ reject(e);
92
+ } });
93
+ });
94
+ }
95
+ export function createHttpServer(agent, opts) {
96
+ // SSE state is wired in the next task; declared here so closeAllSseStreams exists.
97
+ let closeSse = () => { };
98
+ let sse = null;
99
+ const tearDownSse = () => {
100
+ if (!sse)
101
+ return;
102
+ sse.unsubscribe();
103
+ clearInterval(sse.keepalive);
104
+ try {
105
+ sse.res.end();
106
+ }
107
+ catch { /* already closed */ }
108
+ sse = null;
109
+ };
110
+ closeSse = tearDownSse; // replaces the no-op placeholder
111
+ const handler = async (req, res) => {
112
+ try {
113
+ const url = new URL(req.url ?? "/", "http://localhost");
114
+ const path = url.pathname;
115
+ const method = req.method ?? "GET";
116
+ if (method === "GET" && path === "/healthz") {
117
+ return json(res, 200, { ...agent.health(), capabilities: capabilities(opts) });
118
+ }
119
+ // Auth + origin/host for everything else.
120
+ if (!tokenMatches(bearerFrom(req.headers.authorization), opts.token)) {
121
+ return jsonError(res, "unauthorized");
122
+ }
123
+ if (!originHostAllowed({ origin: req.headers.origin, host: req.headers.host }, opts.allowOrigins)) {
124
+ return jsonError(res, "forbidden_origin");
125
+ }
126
+ if (method === "GET" && path === "/v1/channels") {
127
+ return json(res, 200, await agent.listChannels());
128
+ }
129
+ // SecureComs-envelope latency percentiles (ms), messaging hops only —
130
+ // excludes LLM time. Behind the bearer (timing aggregates leak rate).
131
+ if (method === "GET" && path === "/v1/metrics") {
132
+ return json(res, 200, agent.metrics());
133
+ }
134
+ if (method === "POST" && path === "/v1/send") {
135
+ const parsed = sendSchema.safeParse(JSON.parse(await readBody(req, SEND_CAP)));
136
+ if (!parsed.success)
137
+ return jsonError(res, "invalid_request", parsed.error.issues);
138
+ const r = parsed.data.a2a
139
+ ? await agent.sendA2A(parsed.data.channel_id, parsed.data.a2a, parsed.data.idempotency_key)
140
+ // `text` is guaranteed present here by the sendSchema refine (text | a2a)
141
+ : await agent.send(parsed.data.channel_id, parsed.data.text, parsed.data.idempotency_key);
142
+ return json(res, 200, r);
143
+ }
144
+ if (method === "POST" && path === "/v1/send-attachment") {
145
+ const parsed = sendAttachmentSchema.safeParse(JSON.parse(await readBody(req, OTHER_CAP)));
146
+ if (!parsed.success)
147
+ return jsonError(res, "invalid_request", parsed.error.issues);
148
+ const r = await agent.sendAttachment(parsed.data.channel_id, parsed.data.path, parsed.data.caption);
149
+ return json(res, 200, r);
150
+ }
151
+ if (method === "POST" && path === "/v1/status") {
152
+ const parsed = statusSchema.safeParse(JSON.parse(await readBody(req, OTHER_CAP)));
153
+ if (!parsed.success)
154
+ return jsonError(res, "invalid_request", parsed.error.issues);
155
+ await agent.reportStatus(parsed.data.channel_id, parsed.data.state, parsed.data.detail);
156
+ return json(res, 200, { ok: true });
157
+ }
158
+ if (method === "GET" && path === "/v1/messages") {
159
+ const channel = url.searchParams.get("channel_id") ?? undefined;
160
+ const wait = Math.max(0, Math.min(50, Number(url.searchParams.get("wait") ?? 0)));
161
+ return json(res, 200, await agent.drain(channel, wait));
162
+ }
163
+ if (method === "GET" && path === "/v1/events") {
164
+ const channel = url.searchParams.get("channel_id") ?? undefined;
165
+ tearDownSse(); // single consumer: newest wins
166
+ res.writeHead(200, {
167
+ "content-type": "text/event-stream",
168
+ "cache-control": "no-cache",
169
+ connection: "keep-alive",
170
+ });
171
+ res.write(":connected\n\n"); // deterministic open signal (clients ignore comments)
172
+ const drainToClient = () => {
173
+ const left = writeSse(res, agent.takeBuffered(channel));
174
+ if (left.length)
175
+ agent.requeueFront(left);
176
+ };
177
+ const unsubscribe = agent.subscribe(drainToClient);
178
+ const keepalive = setInterval(() => { try {
179
+ res.write(":keepalive\n\n");
180
+ }
181
+ catch { /* gone */ } }, 15_000);
182
+ sse = { res, unsubscribe, keepalive };
183
+ drainToClient(); // flush anything already buffered
184
+ req.on("close", () => { if (sse?.res === res)
185
+ tearDownSse(); });
186
+ return; // keep the connection open
187
+ }
188
+ return jsonError(res, "not_found");
189
+ }
190
+ catch (e) {
191
+ if (e.message === "body_too_large") {
192
+ // The request stream was paused mid-body (readBody stops consuming at the
193
+ // cap). Send `Connection: close` so Node flushes the 400 and then closes
194
+ // this socket — discarding the unread body. This avoids both the
195
+ // keep-alive stall (a paused, unconsumed stream blocks the next pipelined
196
+ // request) and the response-truncation race that destroying the socket
197
+ // directly would cause. (review issue 1)
198
+ if (!res.headersSent) {
199
+ res.writeHead(httpStatus("body_too_large"), { "content-type": "application/json", connection: "close" });
200
+ }
201
+ res.end(JSON.stringify(errorBody("body_too_large")));
202
+ return;
203
+ }
204
+ if (e instanceof ChannelPendingError)
205
+ return jsonError(res, "channel_pending");
206
+ if (e instanceof RateLimitedError)
207
+ return jsonError(res, "rate_limited");
208
+ if (e instanceof InvalidPathError)
209
+ return jsonError(res, "invalid_request", e.message);
210
+ if (e instanceof SyntaxError)
211
+ return jsonError(res, "invalid_json");
212
+ process.stderr.write(`[bridge] request error: ${e?.message ?? e}\n`);
213
+ return jsonError(res, "internal");
214
+ }
215
+ };
216
+ const server = opts.tls
217
+ ? createHttps({ cert: opts.tls.cert, key: opts.tls.key }, (req, res) => void handler(req, res))
218
+ : createHttp((req, res) => void handler(req, res));
219
+ return { server, closeAllSseStreams: () => closeSse() };
220
+ }
package/dist/index.d.ts CHANGED
@@ -4,3 +4,4 @@ export { createHttpServer, writeSse, capabilities, type HttpServerOptions } from
4
4
  export { attachAgentWebSocket, type AgentWebSocketOptions } from "./ws-server.js";
5
5
  export { resolveToken, tokenMatches, originHostAllowed } from "./auth.js";
6
6
  export { BRIDGE_ERRORS, errorBody, httpStatus, wsErrorFrame, type BridgeErrorCode } from "./errors.js";
7
+ export { ensureSodiumReady } from "./sodium-ready.js";