@securecoms/agent-bridge 0.37.0 → 0.39.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.
@@ -51,9 +51,9 @@
51
51
  * from a file.
52
52
  */
53
53
 
54
- import { readFileSync } from "node:fs";
54
+ import { closeSync, constants as fsConstants, fstatSync, openSync, readFileSync, readSync } from "node:fs";
55
55
  import { homedir } from "node:os";
56
- import { join } from "node:path";
56
+ import { basename, dirname, isAbsolute, join, normalize } from "node:path";
57
57
  import { evaluateGate, resolveBoundIdentity, backoffDelayMs } from "./gate.js";
58
58
 
59
59
  // Runtime injected by register() — used by the inbound handler.
@@ -104,6 +104,213 @@ export function voiceGuidanceOf(frame) {
104
104
  const g = frame.guidance.trim().slice(0, VOICE_GUIDANCE_MAX_CHARS);
105
105
  return g || undefined;
106
106
  }
107
+ // Display names (R3). sender_name and a relayed voice turn's speaker name are
108
+ // member-chosen strings that reach the LLM prompt (SenderName and the
109
+ // envelope's "from"), so they must not be able to start a new line or fake
110
+ // prompt structure. A label only: identity (SenderId/From), the session key
111
+ // and every authorization decision stay on sender_id and the daemon's
112
+ // authorization block. The Hermes connector's twin is
113
+ // securecoms_transport.display_name/sender_label; both are pinned to the same
114
+ // cases by ../display-name-fixtures.json. Residual drift: \p{...} classes come
115
+ // from the JS engine's Unicode tables and the twin's from Python unicodedata,
116
+ // so a character assigned in a newer Unicode version can be "unassigned,
117
+ // dropped" in one runtime and kept in the other.
118
+ export const DISPLAY_NAME_MAX = 64;
119
+ /** Characters of the sender id shown in a label's trusted suffix. */
120
+ export const LABEL_ID_CHARS = 8;
121
+ // These C0/C1 controls become a space (as Python isspace and JS \s do), every
122
+ // other \p{C} is dropped, every \p{Z} becomes a space.
123
+ const NAME_SPACE = /[\p{Z}\t\n\v\f\r\u001c-\u001f\u0085]/u;
124
+ // \p{C}, plus letters that render as nothing (Hangul fillers, blank braille)
125
+ // so a blank-looking name falls back to the id.
126
+ const NAME_DROP = /[\p{C}\u115f\u1160\u3164\uffa0\u2800]/u;
127
+ // Stripped from the NAME part of a label, after NFKC folds compatibility
128
+ // lookalikes (fullwidth, small, superscript forms) to their ASCII originals:
129
+ // every opening/closing/initial/final punctuation character (Ps/Pe/Pi/Pf, so
130
+ // any bracket or quote Unicode classifies as one) plus "<>|" and the backtick.
131
+ // That keeps a name from faking the trusted " (owner)" / " (<id>)" suffix, a
132
+ // tag or a table cell with any bracket Unicode classifies as such. A glyph
133
+ // that merely looks like a bracket but is classified otherwise is not caught.
134
+ const LABEL_STRUCTURE = /[\p{Ps}\p{Pe}\p{Pi}\p{Pf}<>|`]/gu;
135
+
136
+ /**
137
+ * Whitespace (newlines, tabs, Unicode line/paragraph separators) becomes one
138
+ * space; other control, format (bidi, zero-width), surrogate, private-use and
139
+ * unassigned characters and invisible fillers are dropped; runs of spaces
140
+ * collapse; the result is trimmed and capped at DISPLAY_NAME_MAX code points.
141
+ * "" for a non-string or nothing left.
142
+ */
143
+ export function displayName(raw) {
144
+ if (typeof raw !== "string") return "";
145
+ let out = "";
146
+ for (const ch of raw) {
147
+ if (NAME_SPACE.test(ch)) out += " ";
148
+ else if (!NAME_DROP.test(ch)) out += ch;
149
+ }
150
+ return Array.from(out.split(" ").filter(Boolean).join(" ")).slice(0, DISPLAY_NAME_MAX).join("").trimEnd();
151
+ }
152
+
153
+ function labelName(raw) {
154
+ return typeof raw === "string" ? displayName(raw.normalize("NFKC").replace(LABEL_STRUCTURE, "")) : "";
155
+ }
156
+
157
+ /** The daemon's v1 block for an active sender, else undefined. */
158
+ function validAuthorization(authorization) {
159
+ if (authorization === null || typeof authorization !== "object") return undefined;
160
+ if (authorization.version !== 1 || authorization.sender_membership !== "active") return undefined;
161
+ return authorization;
162
+ }
163
+
164
+ /**
165
+ * name + a suffix built only from trusted data: " (owner)" when the daemon's
166
+ * block says so, else " (<first LABEL_ID_CHARS of the id>)". No usable name:
167
+ * the id itself (plus " (owner)" for the owner).
168
+ */
169
+ function label(rawName, rawId, isOwner) {
170
+ const name = labelName(rawName);
171
+ const id = labelName(rawId);
172
+ if (name) return `${name} (${isOwner ? "owner" : Array.from(id).slice(0, LABEL_ID_CHARS).join("") || "unknown"})`;
173
+ if (id && isOwner) return `${id} (owner)`;
174
+ return id;
175
+ }
176
+
177
+ /** The frame sender's label (the relay itself on a relayed voice turn). Owner only on an owner_dm block. */
178
+ export function ownLabel({ senderName, senderId, authorization } = {}) {
179
+ return label(senderName, senderId, validAuthorization(authorization)?.audience === "owner_dm");
180
+ }
181
+
182
+ /**
183
+ * A relayed voice turn's speaker label, or "" when there is no speaker with a
184
+ * usable identityId. Owner only on the daemon's relay_speaker "owner" mark,
185
+ * never on what the speaker claims.
186
+ */
187
+ export function speakerLabel(speaker, authorization) {
188
+ if (speaker === null || typeof speaker !== "object" || Array.isArray(speaker) || !labelName(speaker.identityId)) return "";
189
+ return label(speaker.displayName, speaker.identityId, validAuthorization(authorization)?.relay_speaker === "owner");
190
+ }
191
+
192
+ /**
193
+ * Who the model is told spoke this turn: the relayed speaker when there is
194
+ * one, else the sender. Display data only, never an authorization or session
195
+ * input.
196
+ */
197
+ export function senderLabel({ speaker, senderName, senderId, authorization } = {}) {
198
+ return speakerLabel(speaker, authorization) || ownLabel({ senderName, senderId, authorization });
199
+ }
200
+
201
+ // Inbound attachments. For an artifact_share the daemon decrypts the file to
202
+ // <attachmentsDir>/<channel_id>/<message_id>-<name> and puts
203
+ // {filename, mime, size, path, sha256} on the frame. The frame's fields are
204
+ // claims; only a file that passes ALL of these reaches OpenClaw:
205
+ // - path: a string, absolute and already normalized (normalize() collapses
206
+ // any "." or ".." segment, so an unnormalized path, including any ".."
207
+ // traversal, fails the equality), no C0/C1 control or bidi character,
208
+ // parent dir == the frame's channel id inside a dir named "attachments",
209
+ // every kept file in one dir;
210
+ // - filename (when present): no control or bidi character;
211
+ // - the file itself (opened O_NOFOLLOW|O_NONBLOCK): a regular file of at most
212
+ // MAX_INBOUND_ATTACHMENT_BYTES whose first bytes are a PNG, JPEG, GIF or
213
+ // WEBP signature, and the claimed mime equals that sniffed type.
214
+ // Images only: no SVG (script), audio, video or documents. Anything else is
215
+ // dropped; at most MAX_INBOUND_ATTACHMENTS are kept.
216
+ export const MAX_INBOUND_ATTACHMENTS = 8;
217
+ export const MAX_INBOUND_ATTACHMENT_BYTES = 5 * 1024 * 1024;
218
+ const FORBIDDEN_CHARS = /[\u0000-\u001f\u007f-\u009f\u202a-\u202e\u2066-\u2069]/;
219
+
220
+ /** The image type from a file's leading bytes, or undefined. */
221
+ export function sniffImageMime(b) {
222
+ if (!b || b.length < 3) return undefined;
223
+ if (b.length >= 8 && b[0] === 0x89 && b[1] === 0x50 && b[2] === 0x4e && b[3] === 0x47 &&
224
+ b[4] === 0x0d && b[5] === 0x0a && b[6] === 0x1a && b[7] === 0x0a) return "image/png";
225
+ if (b[0] === 0xff && b[1] === 0xd8 && b[2] === 0xff) return "image/jpeg";
226
+ if (b.length >= 6 && b[0] === 0x47 && b[1] === 0x49 && b[2] === 0x46 && b[3] === 0x38 &&
227
+ (b[4] === 0x37 || b[4] === 0x39) && b[5] === 0x61) return "image/gif";
228
+ if (b.length >= 12 && b[0] === 0x52 && b[1] === 0x49 && b[2] === 0x46 && b[3] === 0x46 &&
229
+ b[8] === 0x57 && b[9] === 0x45 && b[10] === 0x42 && b[11] === 0x50) return "image/webp";
230
+ return undefined;
231
+ }
232
+
233
+ /** The sniffed image mime of a regular file within the size cap, else undefined. */
234
+ function _sniffFile(path) {
235
+ let fd;
236
+ try {
237
+ // O_NONBLOCK: a FIFO planted at the path must not hang the gateway in
238
+ // open(); fstat below then drops it as not a regular file.
239
+ fd = openSync(path, fsConstants.O_RDONLY | (fsConstants.O_NOFOLLOW ?? 0) | (fsConstants.O_NONBLOCK ?? 0));
240
+ const st = fstatSync(fd);
241
+ if (!st.isFile() || st.size > MAX_INBOUND_ATTACHMENT_BYTES) return undefined;
242
+ const head = Buffer.alloc(12);
243
+ const n = readSync(fd, head, 0, 12, 0);
244
+ return sniffImageMime(head.subarray(0, n));
245
+ } catch {
246
+ return undefined;
247
+ } finally {
248
+ if (fd !== undefined) try { closeSync(fd); } catch { /* ignore */ }
249
+ }
250
+ }
251
+
252
+ /** Validate a frame's attachments: { kept: [{path, mime, filename?}], dropped }. */
253
+ export function scanInboundAttachments(frame) {
254
+ const raw = frame?.attachments;
255
+ const channelId = frame?.channel_id;
256
+ if (!Array.isArray(raw)) return { kept: [], dropped: 0 };
257
+ if (typeof channelId !== "string" || channelId === "") return { kept: [], dropped: raw.length };
258
+ const kept = [];
259
+ let dir;
260
+ for (const a of raw) {
261
+ if (kept.length >= MAX_INBOUND_ATTACHMENTS) break;
262
+ if (a === null || typeof a !== "object") continue;
263
+ const { path, mime, filename } = a;
264
+ if (typeof path !== "string" || typeof mime !== "string") continue;
265
+ if (filename !== undefined && (typeof filename !== "string" || FORBIDDEN_CHARS.test(filename))) continue;
266
+ if (FORBIDDEN_CHARS.test(path) || !isAbsolute(path) || normalize(path) !== path) continue;
267
+ const parent = dirname(path);
268
+ if (basename(parent) !== channelId || basename(dirname(parent)) !== "attachments") continue;
269
+ if (dir !== undefined && parent !== dir) continue;
270
+ const sniffed = _sniffFile(path);
271
+ if (sniffed === undefined || sniffed !== mime.toLowerCase()) continue;
272
+ dir ??= parent;
273
+ kept.push({ path, mime: sniffed, ...(typeof filename === "string" ? { filename } : {}) });
274
+ }
275
+ return { kept, dropped: raw.length - kept.length };
276
+ }
277
+
278
+ /** The attachments of a frame that pass every check (see above). */
279
+ export function inboundAttachmentsOf(frame) {
280
+ return scanInboundAttachments(frame).kept;
281
+ }
282
+
283
+ // OpenClaw's own placeholder body for a media-only message (as its bundled
284
+ // channels use), so a turn with no caption still has a body.
285
+ function mediaPlaceholder(mime) {
286
+ const kind = mime.split("/", 1)[0];
287
+ if (kind === "image" || kind === "video" || kind === "audio") return `<media:${kind}>`;
288
+ return "<media:document>";
289
+ }
290
+
291
+ /**
292
+ * The inbound media ctx fields, in the shape of OpenClaw's plugin-sdk
293
+ * buildAgentMediaPayload (src/plugin-sdk/agent-media-payload.ts). OpenClaw
294
+ * reads a local media file only inside its allowed roots (its own media/,
295
+ * workspace, tmp dirs) plus ctx.MediaWorkspaceDir, the same field its webchat
296
+ * sets for offloaded files. Setting it to the attachments' own per-channel dir
297
+ * lets OpenClaw read exactly these files without a config change or a copy.
298
+ */
299
+ export function inboundMediaFields(attachments) {
300
+ if (!Array.isArray(attachments) || attachments.length === 0) return {};
301
+ const paths = attachments.map((a) => a.path);
302
+ const types = attachments.map((a) => a.mime);
303
+ return {
304
+ MediaPath: paths[0],
305
+ MediaType: types[0],
306
+ MediaUrl: paths[0],
307
+ MediaPaths: paths,
308
+ MediaUrls: paths,
309
+ MediaTypes: types,
310
+ MediaWorkspaceDir: dirname(paths[0]),
311
+ };
312
+ }
313
+
107
314
  const SEEN_MAX = 5000;
108
315
 
109
316
  /** Thrown by the inbound handler when register() has not injected a runtime. */
@@ -671,14 +878,23 @@ export class LoopbackChannel {
671
878
  this._ack(messageId);
672
879
  return;
673
880
  }
881
+ const { kept: attachments, dropped } = scanInboundAttachments(frame);
882
+ if (dropped > 0) {
883
+ // Content-free: counts only, never a path, name or mime.
884
+ this._logSafe("[securecoms] inbound attachments dropped={d} kept={k}", { d: dropped, k: attachments.length });
885
+ }
674
886
  await this.onMessage?.(typeof frame.text === "string" ? frame.text : "", {
675
887
  conversationId: frame.channel_id,
676
888
  messageId,
677
889
  senderId: frame.sender_id,
678
890
  senderName: frame.sender_name,
891
+ senderIsAgent: frame.sender_is_agent,
892
+ // A relayed voice turn's real speaker: display data only (R3).
893
+ speaker: frame.speaker,
679
894
  authorization: frame.authorization,
680
895
  timestamp: frame.ts,
681
896
  voiceGuidance: voiceGuidanceOf(frame),
897
+ attachments,
682
898
  });
683
899
  this._failures.delete(messageId);
684
900
  this._failStreak = 0;
@@ -839,9 +1055,14 @@ export class LoopbackChannel {
839
1055
  // SecureComs channel the message arrived on (closure over metadata).
840
1056
  // ---------------------------------------------------------------------------
841
1057
 
842
- async function _handleInbound({ text, metadata, channel, account, cfg }) {
1058
+ async function _handleInbound({ text: rawText, metadata, channel, account, cfg }) {
843
1059
  const core = _runtime;
844
1060
  if (!core) throw new RuntimeNotReadyError();
1061
+ const attachments = Array.isArray(metadata.attachments) ? metadata.attachments : [];
1062
+ // An image with no caption still runs the turn: give it OpenClaw's media
1063
+ // placeholder body rather than an empty one.
1064
+ const caption = typeof rawText === "string" ? rawText : "";
1065
+ const text = caption.trim() === "" && attachments.length > 0 ? mediaPlaceholder(attachments[0].mime) : caption;
845
1066
 
846
1067
  const conv = metadata.conversationId;
847
1068
  if (typeof conv !== "string" || conv === "") {
@@ -852,7 +1073,19 @@ async function _handleInbound({ text, metadata, channel, account, cfg }) {
852
1073
  // are separate facts, and only the daemon's authorization block speaks to
853
1074
  // the second.
854
1075
  const senderId = typeof metadata.senderId === "string" && metadata.senderId !== "" ? metadata.senderId : "unknown";
855
- const senderName = typeof metadata.senderName === "string" && metadata.senderName !== "" ? metadata.senderName : senderId;
1076
+ // The label the model sees: sanitized, and on a relayed voice turn the
1077
+ // speaker's name (R3). Never used for identity, the session or authority.
1078
+ const senderName = senderLabel({
1079
+ speaker: metadata.speaker,
1080
+ senderName: metadata.senderName,
1081
+ senderId: metadata.senderId,
1082
+ authorization: metadata.authorization,
1083
+ }) || senderId;
1084
+ // A relayed speaker is a human (the relay stamps speaker only on a human
1085
+ // turn), so the labelled sender is not a bot even though the relay is.
1086
+ const senderIsBot = speakerLabel(metadata.speaker, metadata.authorization)
1087
+ ? false
1088
+ : typeof metadata.senderIsAgent === "boolean" ? metadata.senderIsAgent : undefined;
856
1089
  const chatType = chatTypeFor(metadata.authorization);
857
1090
  const commandAuthorized = isOwnerAuthorization(metadata.authorization);
858
1091
  if (metadata.authorization === null || typeof metadata.authorization !== "object") {
@@ -927,6 +1160,7 @@ async function _handleInbound({ text, metadata, channel, account, cfg }) {
927
1160
  ConversationLabel: "SecureComs",
928
1161
  SenderName: senderName,
929
1162
  SenderId: senderId,
1163
+ ...(senderIsBot === undefined ? {} : { SenderIsBot: senderIsBot }),
930
1164
  Provider: "securecoms",
931
1165
  Surface: "securecoms",
932
1166
  MessageSid: metadata.messageId,
@@ -941,6 +1175,7 @@ async function _handleInbound({ text, metadata, channel, account, cfg }) {
941
1175
  // this turn's extra system prompt (get-reply's extraSystemPromptParts)
942
1176
  // for any chat type.
943
1177
  ...(extraPrompt ? { GroupSystemPrompt: extraPrompt } : {}),
1178
+ ...inboundMediaFields(attachments),
944
1179
  });
945
1180
 
946
1181
  await core.channel.session.recordInboundSession({
@@ -95,9 +95,41 @@ export interface ReceiveDeps {
95
95
  mkdirp(dir: string): Promise<void>;
96
96
  writeFile(p: string, data: Uint8Array): Promise<void>;
97
97
  }
98
- /** Reduce any name to a safe basename inside the target dir. Strips path
98
+ /** Reduce any name to a safe basename inside the target dir. Strips control
99
+ * and bidi characters (review r1 M1: "invoice\u202Efdp.exe"), path
99
100
  * separators and `..`; falls back when nothing usable remains. */
100
101
  export declare function sanitizeFilename(name: string, fallback: string): string;
102
+ /**
103
+ * The image type from a file's leading bytes (PNG, JPEG, GIF, WEBP), or
104
+ * undefined. Review r1 H1: the reported mime of a received file is derived
105
+ * from its decrypted bytes, never the sender's claim. Twin of the connectors'
106
+ * sniffImageMime / sniff_image_mime.
107
+ */
108
+ export declare function sniffImageMime(b: Uint8Array): string | undefined;
109
+ /** A channel or message id that is not a canonical lowercase UUID would become
110
+ * a path segment of the attachments dir. Content-free: the id is never echoed. */
111
+ export declare class InvalidAttachmentTargetError extends Error {
112
+ constructor();
113
+ }
114
+ /** mkdir -p with 0700, then refuse the leaf if it is not a real directory
115
+ * (a symlink planted at <attachmentsDir>/<channel> is never written through). */
116
+ export declare function mkdirPrivateNoFollow(dir: string): Promise<void>;
117
+ /** Byte cap on a saved attachment's name: with the "<uuid>-" prefix and the
118
+ * ".<uuid>.part" temp suffix the entry stays far below NAME_MAX (255). */
119
+ export declare const MAX_FILENAME_BYTES = 150;
120
+ /** Truncate `name` to at most `maxBytes` UTF-8 bytes, cutting only between
121
+ * code points and keeping a short extension. */
122
+ export declare function truncateFilename(name: string, maxBytes?: number): string;
123
+ /**
124
+ * Review r1 M4: write `data` to `path` without following a symlink and
125
+ * without writing into an existing file. The bytes go to a fresh temp name
126
+ * opened O_CREAT|O_EXCL|O_NOFOLLOW (0600), then rename() moves it into place.
127
+ * rename replaces a directory ENTRY, never writes through a link at `path`,
128
+ * and keeps a redelivered message (same id, same path) idempotent: a plain
129
+ * exclusive create of `path` itself would fail the redelivery, and the
130
+ * fail-soft share path would ack it and lose the file.
131
+ */
132
+ export declare function writeFileExclusiveNoFollow(path: string, data: Uint8Array): Promise<void>;
101
133
  export declare function mimeForName(name: string): string;
102
134
  export interface SendDeps {
103
135
  /** Warm libsodium before any WASM-backed call. See src/sodium-ready.ts. */