Skip to content

fix(signal): preserve original filename for outbound attachments#38406

Open
bricolage wants to merge 4 commits intoopenclaw:mainfrom
bricolage:fix/signal-attachment-filename
Open

fix(signal): preserve original filename for outbound attachments#38406
bricolage wants to merge 4 commits intoopenclaw:mainfrom
bricolage:fix/signal-attachment-filename

Conversation

@bricolage
Copy link
Copy Markdown

@bricolage bricolage commented Mar 6, 2026

Problem

When an OpenClaw agent sends a file via Signal, the recipient sees a UUID filename (e.g. 8cccc293.pdf) instead of the original name (e.g. report.pdf).

Root Cause

saveMediaBuffer() generates a UUID-based path for every outbound file. signal-cli uses the path's basename as the displayed filename — so recipients always see a UUID.

Fix

Isolated entirely to src/media/outbound-attachment.ts. No changes to store.ts or any inbound callers.

When loadWebMedia() returns a known fileName, resolveOutboundAttachmentFromUrl() now:

  1. Saves the file normally via saveMediaBuffer() (UUID path, unchanged)
  2. Creates a uuid-named subdirectory alongside the saved file
  3. Creates a symlink inside it with the sanitized original name as the basename
  4. Returns that symlink path instead of the raw UUID path

signal-cli (and any future consumer) sees report.pdf as the basename. The real file stays at its UUID path — no collision risk, no inbound behavior change, no memory overhead.

Scope

  • src/media/outbound-attachment.ts — symlink approach, sanitizeBasename helper
  • src/signal/send.ts — reverted to original attachments = [resolved.path]

When sending files via Signal, the recipient saw a UUID filename
(e.g. 8cccc293.pdf) instead of the original (e.g. report.pdf).

Root cause: saveMediaBuffer() generated a fresh UUID for every
outbound file, discarding the original name. signal-cli then used
this UUID as the displayed filename.

Fix:
- Pass media.fileName through resolveOutboundAttachmentFromUrl()
  to saveMediaBuffer(), which already supports originalFilename and
  stores files as {sanitized}---{uuid}.ext
- In the Signal send pipeline, use signal-cli's data URI format
  (data:[mime];filename=[name];base64,[data]) so the display name
  is the clean original filename extracted via extractOriginalFilename()

Fixes: Signal attachment filename always showing as UUID
@openclaw-barnacle openclaw-barnacle bot added channel: signal Channel integration: signal size: XS labels Mar 6, 2026
@greptile-apps
Copy link
Copy Markdown
Contributor

greptile-apps bot commented Mar 6, 2026

Greptile Summary

This PR fixes a UX issue where Signal recipients saw a UUID-based filename (e.g. 8cccc293-c1cf-4a81-800f-96a626b17987.pdf) instead of the original name (e.g. report.pdf) for attachments sent by an OpenClaw agent.

The fix is split across two files:

  • src/media/outbound-attachment.ts: threads media.fileName into the existing saveMediaBuffer() originalFilename parameter, so the on-disk path becomes {sanitized}---{uuid}.ext instead of a bare UUID.
  • src/signal/send.ts: instead of handing signal-cli a raw file path (whose basename signal-cli displayed verbatim), the attachment is now sent as a signal-cli data URI — data:[mime];filename=[displayName];base64,[data] — using extractOriginalFilename() to recover the clean original name.

Issues found:

  • The dynamic await import("fs/promises") inside the function body should be a static import at the top of the file; there is no lazy-loading rationale for a Node.js built-in.
  • The entire file is read into memory and base64-encoded before being passed as an inline payload inside the JSON-RPC body. For files near the configurable maxBytes ceiling (default 8 MB) the base64 string is ~10.7 MB, which may exceed signal-cli's JSON-RPC buffer limits — worth verifying and potentially guarding with an explicit size check.

Confidence Score: 4/5

  • This PR is safe to merge for typical small-to-medium attachments; the inline base64 approach warrants a size-limit check before enabling for near-maximum file sizes.
  • The core logic is correct: the existing sanitizeFilename/extractOriginalFilename machinery handles the name-embedding round-trip, and the data URI approach is consistent with signal-cli's documented format. The only concerns are a non-idiomatic dynamic import (style) and the risk that very large files near maxBytes produce JSON-RPC payloads that could hit buffer limits in signal-cli — neither is a definite breakage for the common case but deserves attention before the feature is relied on at scale.
  • src/signal/send.ts — the base64 inline data URI payload size should be validated against signal-cli's JSON-RPC limits for files approaching maxBytes.

Last reviewed commit: c1771d8

When sending files via Signal, the recipient saw a UUID filename
(e.g. 8cccc293.pdf) instead of the original (e.g. report.pdf).

Root cause: saveMediaBuffer() generated a UUID-based path for every
outbound file. signal-cli used this basename as the displayed filename.

Fix: in resolveOutboundAttachmentFromUrl(), if the loaded media has a
known fileName, create a symlink in a uuid-named subdirectory with the
sanitized original name as its basename. signal-cli (and any other
consumer) then sees the correct filename via path.basename().

store.ts and all inbound callers are completely unaffected — the change
is isolated to the outbound attachment resolver.
Copy link
Copy Markdown

@chatgpt-codex-connector chatgpt-codex-connector bot left a comment

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

💡 Codex Review

Here are some automated review suggestions for this pull request.

Reviewed commit: 9fbda2058d

ℹ️ About Codex in GitHub

Codex has been enabled to automatically review pull requests in this repo. Reviews are triggered when you

  • Open a pull request for review
  • Mark a draft as ready
  • Comment "@codex review".

If Codex has suggestions, it will comment; otherwise it will react with 👍.

When you sign up for Codex through ChatGPT, Codex can also answer questions or update the PR, like "@codex address that feedback".

Comment on lines +44 to +45
const namedDir = path.join(path.dirname(saved.path), crypto.randomUUID());
await fs.mkdir(namedDir, { recursive: true, mode: 0o700 });
Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P2 Badge Keep outbound attachment aliases in cleanup-scanned paths

Creating a per-send subdirectory for the symlink alias causes media cleanup to miss these files: cleanOldMedia() only removes files one level deep (src/media/store.ts:95-120) and does not recurse into nested directories. With this change, each outbound attachment with a filename leaves behind a UUID directory and symlink that never get removed, so long-running Signal gateways will accumulate stale entries and inode usage over time.

Useful? React with 👍 / 👎.

resolveOutboundAttachmentFromUrl() creates a uuid-named subdirectory
containing a symlink with the original filename so signal-cli sees the
correct basename. cleanOldMedia() previously skipped subdirectories
inside channel dirs (inbound/, outbound/, etc.), leaving these alias
dirs and their symlinks to accumulate indefinitely.

Changes to removeExpiredFilesInDir():
- Switch from fs.stat() to fs.lstat() to identify symlinks explicitly
- Symlinks: expire based on target mtime (falls back to link mtime)
- Subdirectories: recurse one level, then rmdir if empty after cleanup

This keeps the media store tidy on long-running gateways.
@bricolage
Copy link
Copy Markdown
Author

Addressed in 5ebbe70. removeExpiredFilesInDir() now uses lstat to detect symlinks explicitly (rather than transparently following them), recurses one level into subdirectories, and calls rmdir after cleaning if the directory is empty. The uuid alias dirs and their symlinks will be collected on the next TTL pass.

Copy link
Copy Markdown

@chatgpt-codex-connector chatgpt-codex-connector bot left a comment

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

💡 Codex Review

Here are some automated review suggestions for this pull request.

Reviewed commit: 5ebbe7070c

ℹ️ About Codex in GitHub

Codex has been enabled to automatically review pull requests in this repo. Reviews are triggered when you

  • Open a pull request for review
  • Mark a draft as ready
  • Comment "@codex review".

If Codex has suggestions, it will comment; otherwise it will react with 👍.

When you sign up for Codex through ChatGPT, Codex can also answer questions or update the PR, like "@codex address that feedback".

Comment on lines +15 to +16
const sanitized = trimmed.replace(/[^\p{L}\p{N}._-]+/gu, "_");
return sanitized.replace(/_+/g, "_").replace(/^_|_$/g, "").slice(0, 200);
Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P2 Badge Reject dot segments when sanitizing attachment basenames

sanitizeBasename currently allows "." and ".." because dots are preserved and only underscores are trimmed. If loadWebMedia returns one of those names (for example from a remote Content-Disposition filename), path.join(namedDir, cleanName) resolves to the current/parent directory and fs.symlink throws (EEXIST/EISDIR), causing Signal send to fail even though the media file was already saved successfully. Treat these special basenames as invalid (or fall back to saved.path) before creating the alias.

Useful? React with 👍 / 👎.

sanitizeBasename() preserves dots, so a fileName of '.' or '..' would
pass the truthy check and cause path.join(namedDir, '.') to resolve to
the directory itself — making fs.symlink throw EISDIR/EEXIST and
failing the send even though the file was saved successfully.

Add an explicit check for '.' and '..' after sanitization and fall back
to the UUID path when either is encountered.
@bricolage
Copy link
Copy Markdown
Author

Fixed in 673a367 — added explicit cleanName !== "." and cleanName !== ".." guards after sanitization. Both cases now fall through to return saved.path unchanged.

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant