@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.
- package/README.md +33 -0
- package/connectors/display-name-fixtures.json +605 -0
- package/connectors/hermes/__pycache__/securecoms_gate.cpython-310.pyc +0 -0
- package/connectors/hermes/__pycache__/securecoms_transport.cpython-310.pyc +0 -0
- package/connectors/hermes/adapter.py +665 -42
- package/connectors/hermes/securecoms_authz.py +112 -11
- package/connectors/hermes/securecoms_transport.py +206 -1
- package/connectors/hermes/tests/test_adapter_wiring.py +1136 -2
- package/connectors/hermes/tests/test_busy_session.py +1436 -0
- package/connectors/hermes/tests/test_securecoms_authz.py +194 -0
- package/connectors/hermes/tests/test_securecoms_transport.py +156 -0
- package/connectors/openclaw/openclaw-entry.contract.test.ts +96 -0
- package/connectors/openclaw/openclaw-entry.inbound.test.ts +363 -4
- package/connectors/openclaw/openclaw-entry.js +239 -4
- package/dist/attachments.d.ts +33 -1
- package/dist/bin.js +4415 -1014
- package/dist/bridge-agent.d.ts +65 -0
- package/dist/index.js +4387 -986
- package/dist/version.d.ts +1 -1
- package/package.json +1 -1
|
@@ -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
|
-
|
|
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({
|
package/dist/attachments.d.ts
CHANGED
|
@@ -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
|
|
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. */
|