From 81f64cf6e5480278afebe7809af6ffc32841cc6b Mon Sep 17 00:00:00 2001 From: lundog <8041501+lundog@users.noreply.github.com> Date: Wed, 1 Jul 2026 18:14:27 -0600 Subject: [PATCH 1/2] Fix inbound file handling and add cross-container file exchange MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Makes SimpleX file transfer work reliably in external mode, including when the simplex-chat runtime runs in a separate container sharing a volume rather than a filesystem. Transport-agnostic. Inbound: - Fix a file-receive race: queue the pending file and retry the accept on rcvFileDescrReady, cancelling on timeout, so a not-yet-ready XFTP descriptor no longer drops the file. - Stage received files into OpenClaw's media/inbound store so media tools and sandboxed workspaces can read them. - Resolve relative inbound paths against connection.filesFolder (default ~/.simplex/files, matching the runtime service) instead of a hardcoded /tmp. When the runtime runs with --files-folder it reports a bare file name, which is joined to filesFolder; absolute paths (no --files-folder) are read directly. - Log the reported inbound path (absolute vs relative) for diagnosability. Outbound: - Add connection.outboundFolder. When set, outbound media is staged into that directory before sending, and the staged path — valid inside the runtime's container when both sides mount the dir verbatim — is what travels over the WebSocket. Staged files are cleaned up after the send. Unset = legacy single-filesystem behavior (the local path is passed as-is). - Resolve media:/// store references to a physical path on send (e.g. re-sending a received file). Config connection gains filesFolder and outboundFolder (types, schema, manifest). Adds unit tests for inbound resolution, outbound staging/cleanup, the readable-path guard, and media reference handling; documents both settings. Co-Authored-By: Claude Opus 4.8 --- docs/guide/runtime-setup.mdx | 73 ++++++++ docs/reference/config.mdx | 3 + openclaw.plugin.json | 24 +++ src/actions/message-actions.ts | 4 + .../events/simplex-inbound-files.test.ts | 51 ++++++ src/channel/events/simplex-inbound-files.ts | 114 +++++++++++- src/channel/events/simplex-monitor.ts | 31 +++- src/channel/media/outbound-files.test.ts | 173 ++++++++++++++++++ src/channel/media/outbound-files.ts | 162 ++++++++++++++++ src/channel/media/simplex-media.ts | 67 ++++++- src/channel/messaging/simplex-send.ts | 4 + src/config/config-schema.ts | 3 + src/types/config.ts | 22 +++ 13 files changed, 716 insertions(+), 15 deletions(-) create mode 100644 src/channel/media/outbound-files.test.ts create mode 100644 src/channel/media/outbound-files.ts diff --git a/docs/guide/runtime-setup.mdx b/docs/guide/runtime-setup.mdx index 40bb959..edddbb1 100644 --- a/docs/guide/runtime-setup.mdx +++ b/docs/guide/runtime-setup.mdx @@ -49,6 +49,9 @@ Plugin-side settings: - connection.allowUnsafeRemoteWs - connection.connectTimeoutMs - connection.autoAcceptFiles +- connection.filesFolder +- connection.outboundFolder +- connection.outboundFolderOnClient - streaming.nativeTransport and related live-reply throttling fields - messageTtlSeconds and filePolicy - dmPolicy, allowFrom, groupPolicy @@ -59,6 +62,76 @@ Runtime-side startup flags: - local storage/layout: --database, --files-folder, --temp-folder, --log-file - runtime process behavior: --device-name, --maintenance, --mute, --mark-read +### Locating received files (`connection.filesFolder`) + + + connection.filesFolder is the path where OpenClaw reads files the runtime received. When the runtime is started with --files-folder, it reports received files by name only (not a full path), and the plugin joins that name to connection.filesFolder (default ~/.simplex/files) to read the bytes. Without --files-folder the runtime reports absolute paths and this setting is unused. + + +The name-only reporting is deliberate, and it is what lets the runtime and OpenClaw refer to the same files from different locations. How you set connection.filesFolder depends on the topology: + +- Shared filesystem (OpenClaw and the runtime see the same paths — e.g. both on one host, or both in one container): set connection.filesFolder to the same path as --files-folder. +- Containerized runtime with a shared volume: when the runtime runs in a container, its filesystem is separate from OpenClaw's (whether or not OpenClaw is containerized too), and each side usually mounts the shared volume at a different path — that is fine. Set --files-folder to the runtime's mount and connection.filesFolder to OpenClaw's mount of the same volume. An absolute path would break here, because the runtime's directory does not exist on OpenClaw's side; the name-only reporting is exactly what bridges the two mounts. + +```yaml +services: + simplex-chat: + volumes: + - simplex-files:/data/files + command: ["...", "--files-folder", "/data/files"] + openclaw: + volumes: + - simplex-files:/shared/simplex + # channels.openclaw-simplex.connection.filesFolder = /shared/simplex +volumes: + simplex-files: +``` + +The sidecar writes `/data/files/photo.jpg` and reports `photo.jpg`; OpenClaw reads it from `/shared/simplex/photo.jpg` — same volume, different mount paths, bridged by the file name. + +### Sending files (`connection.outboundFolder`) + + + connection.outboundFolder is the directory OpenClaw stages an outgoing file into before telling the runtime to send it. There is no runtime flag for this — on send OpenClaw passes a path that the runtime resolves on its own side, so the file must be readable there. When OpenClaw and the runtime share a filesystem (the default), leave this unset and the local file path is passed as-is; it only matters when the runtime is containerized. + + +This is the mirror image of inbound, with one crucial difference. Inbound is name-only, so the two sides may mount the shared directory at any paths. Outbound sends a full path to the runtime, so the path OpenClaw sends has to be valid on the runtime's side. There are two ways to arrange that. + +**Same path on both sides (simplest).** Mount the shared volume at the identical absolute path in both, and set only connection.outboundFolder: + +```yaml +services: + simplex-chat: + volumes: + - simplex-outbound:/tmp/simplex-outbound + openclaw: + volumes: + - simplex-outbound:/tmp/simplex-outbound + # channels.openclaw-simplex.connection.outboundFolder = /tmp/simplex-outbound +volumes: + simplex-outbound: +``` + +OpenClaw stages `pic.jpg` into `/tmp/simplex-outbound` and passes `/tmp/simplex-outbound/pic.jpg`; the runtime reads that same path, then the staged file is removed after the send. + +**Different paths (path translation).** When the two sides can't mount the volume at the same path, add connection.outboundFolderOnClient — the same directory as the runtime sees it. OpenClaw writes to outboundFolder but rewrites the directory prefix to outboundFolderOnClient before sending, so no verbatim path is needed: + +```yaml +services: + simplex-chat: + volumes: + - simplex-outbound:/data/.simplex/outbound # runtime's view + openclaw: + volumes: + - simplex-outbound:/srv/openclaw/outbound # OpenClaw's view + # connection.outboundFolder = /srv/openclaw/outbound + # connection.outboundFolderOnClient = /data/.simplex/outbound +volumes: + simplex-outbound: +``` + +OpenClaw stages `pic.jpg` into `/srv/openclaw/outbound` and sends `/data/.simplex/outbound/pic.jpg`; the runtime reads its own path. (This is also the case when OpenClaw runs on the host and the runtime in a container — set each side's path accordingly.) + ### Manual service files The CLI helper above is the preferred path. These examples show what it writes and are useful when you want to manage the service definition yourself. diff --git a/docs/reference/config.mdx b/docs/reference/config.mdx index 8b7b210..89eb01e 100644 --- a/docs/reference/config.mdx +++ b/docs/reference/config.mdx @@ -128,6 +128,9 @@ If accounts is set, each account entry can override the shared root | `connection.wsPort` | Optional port fallback when `wsUrl` is not set. Default: `5225`. | | `connection.allowUnsafeRemoteWs` | Allow plaintext remote WebSocket endpoints. Keep this false unless the endpoint is protected by a private network, firewall, or authenticated TLS proxy. | | `connection.autoAcceptFiles` | Whether incoming file transfers should be auto-accepted by the SimpleX runtime. | +| `connection.filesFolder` | Path where OpenClaw reads files the external runtime received. Only used when the runtime runs with `--files-folder` (it then reports file names only, and the plugin joins them to this path). Set it to OpenClaw's view of the runtime's files folder — the same path on a shared filesystem, or OpenClaw's mount of the shared volume across containers. Default: `~/.simplex/files`. | +| `connection.outboundFolder` | Directory OpenClaw stages outgoing files into so the external runtime can read them on send. Only needed when the runtime doesn't share a filesystem with OpenClaw (e.g. the runtime runs in a container). OpenClaw writes the file here and passes its path in the send command; if both sides mount the directory at the same path, that's all you need. Unset = the local file path is passed to the runtime as-is (single-filesystem default). | +| `connection.outboundFolderOnClient` | The `outboundFolder` directory as the runtime sees it, when the two sides mount the shared directory at *different* paths. When set (with `outboundFolder`), OpenClaw still writes to `outboundFolder` but rewrites the directory prefix to this before sending — so no matching/verbatim path is required. No effect without `outboundFolder`. | | `connection.connectTimeoutMs` | WebSocket connection timeout in milliseconds. | | `connection.commandTimeoutMs` | Default SimpleX WebSocket command timeout in milliseconds. | | `connection.directoryTimeoutMs` | Shorter timeout for advisory contact and group directory lookups. | diff --git a/openclaw.plugin.json b/openclaw.plugin.json index e3b8c04..f2d7e2f 100644 --- a/openclaw.plugin.json +++ b/openclaw.plugin.json @@ -244,6 +244,18 @@ "autoAcceptFiles": { "type": "boolean" }, + "filesFolder": { + "type": "string", + "minLength": 1 + }, + "outboundFolder": { + "type": "string", + "minLength": 1 + }, + "outboundFolderOnClient": { + "type": "string", + "minLength": 1 + }, "connectTimeoutMs": { "type": "integer", "exclusiveMinimum": 0, @@ -481,6 +493,18 @@ "autoAcceptFiles": { "type": "boolean" }, + "filesFolder": { + "type": "string", + "minLength": 1 + }, + "outboundFolder": { + "type": "string", + "minLength": 1 + }, + "outboundFolderOnClient": { + "type": "string", + "minLength": 1 + }, "connectTimeoutMs": { "type": "integer", "exclusiveMinimum": 0, diff --git a/src/actions/message-actions.ts b/src/actions/message-actions.ts index 118b51f..e3d904c 100644 --- a/src/actions/message-actions.ts +++ b/src/actions/message-actions.ts @@ -2,6 +2,7 @@ import { readStringArrayParam } from "openclaw/plugin-sdk/channel-actions"; import type { ChannelMessageActionName } from "openclaw/plugin-sdk/channel-contract"; import type { OpenClawConfig } from "openclaw/plugin-sdk/channel-core"; import { normalizePollInput, resolvePollMaxSelections } from "openclaw/plugin-sdk/poll-runtime"; +import { cleanupStagedOutboundFiles } from "../channel/media/outbound-files.js"; import { buildComposedMessages } from "../channel/media/simplex-media.js"; import { renderSimplexPollText } from "../channel/messaging/simplex-outbound.js"; import { resolveSimplexChatItemId } from "../simplex/runtime/api.js"; @@ -61,6 +62,9 @@ async function sendActionComposedMessages(params: { ttl: params.ttl ?? params.account.config.messageTtlSeconds, }), }); + // Files staged into a shared outbound dir are no longer needed once the send + // command has been issued (the runtime has consumed the path). + await cleanupStagedOutboundFiles(params.composedMessages); return { messageId: resolveSimplexChatItemId(chatItems[0]) }; } diff --git a/src/channel/events/simplex-inbound-files.test.ts b/src/channel/events/simplex-inbound-files.test.ts index a843eea..a639d0e 100644 --- a/src/channel/events/simplex-inbound-files.test.ts +++ b/src/channel/events/simplex-inbound-files.test.ts @@ -1,11 +1,16 @@ +import os from "node:os"; +import path from "node:path"; import type { PluginRuntime } from "openclaw/plugin-sdk/runtime-store"; import { afterEach, describe, expect, it, vi } from "vitest"; import { setSimplexRuntime } from "../runtime.js"; import { dispatchInbound, finalizePendingFile, + markFileAccepted, type PendingInboundFile, queuePendingFile, + resolveSimplexInboundDir, + shouldRetryFileAccept, } from "./simplex-inbound-files.js"; const simplexClientMock = vi.hoisted(() => ({ @@ -82,6 +87,29 @@ function pending(sendPayload = vi.fn(async () => undefined)): PendingInboundFile }; } +describe("resolveSimplexInboundDir", () => { + it("defaults the inbound files-folder to ~/.simplex/files", () => { + expect(resolveSimplexInboundDir(pending().account)).toBe( + path.join(os.homedir(), ".simplex/files") + ); + }); + + it("uses a configured files-folder when set", () => { + const account = pending().account; + account.config.connection = { + ...account.config.connection, + filesFolder: "/var/lib/simplex-files", + }; + expect(resolveSimplexInboundDir(account)).toBe("/var/lib/simplex-files"); + }); + + it("expands a leading ~ in a configured files-folder", () => { + const account = pending().account; + account.config.connection = { ...account.config.connection, filesFolder: "~/custom/files" }; + expect(resolveSimplexInboundDir(account)).toBe(path.join(os.homedir(), "custom/files")); + }); +}); + describe("simplex inbound live replies", () => { afterEach(() => { vi.useRealTimers(); @@ -135,6 +163,29 @@ describe("simplex inbound live replies", () => { expect(cancelFile).not.toHaveBeenCalled(); }); + it("tracks accept retries for queued pending files", async () => { + vi.useFakeTimers(); + installRuntime(async () => undefined); + const current = pending(); + current.fileId = 42; + current.client = fileClient(vi.fn(async () => undefined)); + + // Unknown file: nothing queued, nothing to retry. + expect(shouldRetryFileAccept(current.account.accountId, 42)).toBe(false); + + // Queued but not yet accepted (initial /freceive failed): retry wanted. + queuePendingFile({ pending: current, accountId: current.account.accountId, fileId: 42 }); + expect(shouldRetryFileAccept(current.account.accountId, 42)).toBe(true); + + // Accepted: no further retries. + markFileAccepted(current.account.accountId, 42); + expect(shouldRetryFileAccept(current.account.accountId, 42)).toBe(false); + + // Finalized: entry removed entirely. + await finalizePendingFile({ accountId: current.account.accountId, fileId: 42 }); + expect(shouldRetryFileAccept(current.account.accountId, 42)).toBe(false); + }); + it("replaces an older pending file timeout for the same key", async () => { vi.useFakeTimers(); installRuntime(async () => undefined); diff --git a/src/channel/events/simplex-inbound-files.ts b/src/channel/events/simplex-inbound-files.ts index 339fcee..9d26b56 100644 --- a/src/channel/events/simplex-inbound-files.ts +++ b/src/channel/events/simplex-inbound-files.ts @@ -1,8 +1,12 @@ +import { readFile } from "node:fs/promises"; +import os from "node:os"; +import path from "node:path"; import type { ChannelAccountSnapshot } from "openclaw/plugin-sdk/channel-contract"; import type { OpenClawConfig } from "openclaw/plugin-sdk/channel-core"; import type { RuntimeEnv } from "openclaw/plugin-sdk/runtime-env"; import type { SimplexClient } from "../../simplex/runtime/client.js"; import type { ResolvedSimplexAccount } from "../../types/config.js"; +import { resolveSimplexMediaMaxBytes } from "../media/simplex-media.js"; import { createSimplexLiveReplyController, type SimplexLiveReplyPayload, @@ -11,6 +15,37 @@ import { getSimplexRuntime } from "../runtime.js"; const PENDING_FILE_TIMEOUT_MS = 90_000; +/** + * Default files-folder used by the external runtime, matching the default the + * plugin's own `runtime` service launches `simplex-chat` with (`--files-folder + * ~/.simplex/files`). Used only to resolve relative inbound paths. + */ +const DEFAULT_INBOUND_FILES_FOLDER = "~/.simplex/files"; + +function expandHome(value: string): string { + if (value === "~") { + return os.homedir(); + } + if (value.startsWith("~/") || value.startsWith("~\\")) { + return path.join(os.homedir(), value.slice(2)); + } + return value; +} + +/** + * Base directory for resolving relative inbound file paths. When the runtime is + * started with `--files-folder`, it reports received files as a bare file name + * relative to that folder, so the plugin must join them against the same path + * to locate the bytes on disk. Set `connection.filesFolder` to match the + * runtime's `--files-folder`; it defaults to `~/.simplex/files` (the default the + * bundled `runtime` service uses). Absolute paths — what the runtime reports + * when no `--files-folder` is set — bypass this entirely. + */ +export function resolveSimplexInboundDir(account: ResolvedSimplexAccount): string { + const configured = account.config.connection?.filesFolder?.trim(); + return expandHome(configured || DEFAULT_INBOUND_FILES_FOLDER); +} + type SimplexReplyPayload = { text?: string; mediaUrl?: string; @@ -37,6 +72,7 @@ export type PendingInboundFile = { type QueuedPendingInboundFile = { pending: PendingInboundFile; timeout: ReturnType; + accepted: boolean; }; const pendingFiles = new Map(); @@ -45,6 +81,12 @@ function pendingKey(accountId: string, fileId: number): string { return `${accountId}:${fileId}`; } +export function isFileAutoAcceptEnabled(account: ResolvedSimplexAccount): boolean { + return ( + account.config.filePolicy?.autoAccept ?? account.config.connection?.autoAcceptFiles !== false + ); +} + export async function requestFileDownload(params: { fileId: number; account: ResolvedSimplexAccount; @@ -52,9 +94,7 @@ export async function requestFileDownload(params: { runtime: RuntimeEnv; }): Promise { const { fileId, account, client, runtime } = params; - const autoAccept = - account.config.filePolicy?.autoAccept ?? account.config.connection?.autoAcceptFiles !== false; - if (!autoAccept) { + if (!isFileAutoAcceptEnabled(account)) { return false; } try { @@ -66,6 +106,23 @@ export async function requestFileDownload(params: { return true; } +/** + * Whether a pending file still needs its download accepted. The initial + * /freceive issued on newChatItems can fail because the XFTP file + * description is not ready yet; the accept is retried on rcvFileDescrReady. + */ +export function shouldRetryFileAccept(accountId: string, fileId: number): boolean { + const queued = pendingFiles.get(pendingKey(accountId, fileId)); + return Boolean(queued && !queued.accepted); +} + +export function markFileAccepted(accountId: string, fileId: number): void { + const queued = pendingFiles.get(pendingKey(accountId, fileId)); + if (queued) { + queued.accepted = true; + } +} + export function queuePendingFile(params: { pending: PendingInboundFile; accountId: string; @@ -99,13 +156,14 @@ export function queuePendingFile(params: { }); }, PENDING_FILE_TIMEOUT_MS); timeout.unref?.(); - pendingFiles.set(key, { pending, timeout }); + pendingFiles.set(key, { pending, timeout, accepted: false }); } export async function finalizePendingFile(params: { accountId: string; fileId: number; filePath?: string; + fileName?: string; }): Promise { const queued = pendingFiles.get(pendingKey(params.accountId, params.fileId)); if (!queued) { @@ -114,10 +172,51 @@ export async function finalizePendingFile(params: { pendingFiles.delete(pendingKey(params.accountId, params.fileId)); clearTimeout(queued.timeout); const { pending } = queued; - const mediaPath = params.filePath?.trim() || undefined; + let rawPath = params.filePath?.trim() || undefined; + // Diagnostic: log the path exactly as the runtime reported it, before any + // resolution, so the inbound files-folder behavior is observable per + // transport — whether it is absolute or relative (filename-only), and the + // literal directory used (e.g. /tmp vs. a $TMPDIR-derived path). + pending.runtime.log?.( + `[${params.accountId}] SimpleX inbound file path: ${JSON.stringify(rawPath ?? null)}` + + (rawPath ? ` (${path.isAbsolute(rawPath) ? "absolute" : "relative"})` : "") + ); + // The runtime reports received files relative to its --files-folder + // (fileSource.filePath is then just the file name), so resolve relative paths + // against the configured files-folder (default ~/.simplex/files). + if (rawPath && !path.isAbsolute(rawPath)) { + rawPath = path.join(resolveSimplexInboundDir(pending.account), rawPath); + } + let mediaPath: string | undefined; let mediaType: string | undefined; - if (mediaPath) { - mediaType = await getSimplexRuntime().media.detectMime({ filePath: mediaPath }); + if (rawPath) { + const core = getSimplexRuntime(); + // Stage the file into OpenClaw's shared media store (media/inbound/*), + // like other bundled channels. The raw path from the SimpleX runtime + // (e.g. ~/Downloads/... or ~/.simplex/files/...) lives outside the store, + // so media tools and sandboxed workspaces would reject it. + try { + mediaType = await core.media.detectMime({ filePath: rawPath }); + const buffer = await readFile(rawPath); + const saved = await core.channel.media.saveMediaBuffer( + buffer, + mediaType, + "inbound", + resolveSimplexMediaMaxBytes({ + cfg: pending.cfg, + accountId: pending.account.accountId, + }), + params.fileName ?? path.basename(rawPath), + rawPath + ); + mediaPath = saved.path; + mediaType = saved.contentType ?? mediaType; + } catch (err) { + pending.runtime.error?.( + `[${params.accountId}] SimpleX inbound media staging failed, using raw path: ${String(err)}` + ); + mediaPath = rawPath; + } } await dispatchInbound({ pending, mediaPath, mediaType }); } @@ -143,6 +242,7 @@ export async function dispatchInbound(params: { const ctxPayload = { ...pending.ctxPayload, MediaPath: mediaPath, + MediaPaths: mediaPath ? [mediaPath] : undefined, MediaType: mediaType, MediaUrl: mediaPath, }; diff --git a/src/channel/events/simplex-monitor.ts b/src/channel/events/simplex-monitor.ts index fabc57e..b5a0df8 100644 --- a/src/channel/events/simplex-monitor.ts +++ b/src/channel/events/simplex-monitor.ts @@ -25,9 +25,12 @@ import { dispatchInbound, finalizePendingFile, hasPendingFile, + isFileAutoAcceptEnabled, + markFileAccepted, type PendingInboundFile, queuePendingFile, requestFileDownload, + shouldRetryFileAccept, } from "./simplex-inbound-files.js"; export type SimplexMonitorOpts = { @@ -163,8 +166,19 @@ async function handleSimplexEvent(params: { if (event.type === "rcvFileDescrReady") { const fileId = (event as { rcvFileTransfer?: { fileId?: unknown } })?.rcvFileTransfer?.fileId; - if (typeof fileId === "number" && Number.isInteger(fileId) && fileId > 0) { - await requestFileDownload({ fileId, account, client, runtime }); + if ( + typeof fileId === "number" && + Number.isInteger(fileId) && + fileId > 0 && + // Retry the accept for a queued pending file whose initial /freceive + // failed (issued on newChatItems, before the file description was + // ready). Skip files we never queued (e.g. oversize) or that were + // already accepted. + shouldRetryFileAccept(account.accountId, fileId) + ) { + if (await requestFileDownload({ fileId, account, client, runtime })) { + markFileAccepted(account.accountId, fileId); + } } return; } @@ -179,6 +193,7 @@ async function handleSimplexEvent(params: { accountId: account.accountId, fileId, filePath, + fileName: typeof file?.fileName === "string" ? file.fileName : undefined, }); } return; @@ -328,9 +343,17 @@ async function handleSimplexEvent(params: { ); continue; } - const accepted = await requestFileDownload({ fileId, account, client, runtime }); - if (accepted) { + if (isFileAutoAcceptEnabled(account)) { + // Queue BEFORE attempting the accept: at this point the file may + // still be an invitation whose XFTP file description is not ready, + // so /freceive can fail. The accept is retried on rcvFileDescrReady + // and the dispatch happens on rcvFileComplete with the downloaded + // file's absolute path. If the transfer never completes, the + // pending-file timeout dispatches without media. queuePendingFile({ pending, accountId: account.accountId, fileId }); + if (await requestFileDownload({ fileId, account, client, runtime })) { + markFileAccepted(account.accountId, fileId); + } continue; } } diff --git a/src/channel/media/outbound-files.test.ts b/src/channel/media/outbound-files.test.ts new file mode 100644 index 0000000..a5b3005 --- /dev/null +++ b/src/channel/media/outbound-files.test.ts @@ -0,0 +1,173 @@ +import { mkdtemp, readFile, rm, writeFile } from "node:fs/promises"; +import os from "node:os"; +import path from "node:path"; +import type { OpenClawConfig } from "openclaw/plugin-sdk/channel-core"; +import { afterEach, beforeEach, describe, expect, it } from "vitest"; +import { + cleanupStagedOutboundFiles, + isSimplexReadablePath, + resolveSimplexOutboundClientDir, + resolveSimplexOutboundDir, + stageOutboundBuffer, + stageOutboundLocalFile, + toClientOutboundPath, +} from "./outbound-files.js"; + +function cfg( + connection: Record, + account?: Record +): OpenClawConfig { + return { + channels: { + "openclaw-simplex": { + connection, + ...(account ? { accounts: { acct: { connection: account } } } : {}), + }, + }, + } as unknown as OpenClawConfig; +} + +describe("resolveSimplexOutboundDir", () => { + it("returns undefined when outboundFolder is not set (feature disabled)", () => { + expect(resolveSimplexOutboundDir({ cfg: cfg({}) })).toBeUndefined(); + }); + + it("reads connection.outboundFolder at the channel level", () => { + expect(resolveSimplexOutboundDir({ cfg: cfg({ outboundFolder: "/simplex/outbound" }) })).toBe( + "/simplex/outbound" + ); + }); + + it("lets the account override the channel", () => { + const c = cfg({ outboundFolder: "/simplex/outbound" }, { outboundFolder: "/other/out" }); + expect(resolveSimplexOutboundDir({ cfg: c, accountId: "acct" })).toBe("/other/out"); + }); + + it("expands a leading ~", () => { + expect(resolveSimplexOutboundDir({ cfg: cfg({ outboundFolder: "~/out" }) })).toBe( + path.join(os.homedir(), "out") + ); + }); +}); + +describe("resolveSimplexOutboundClientDir", () => { + it("returns undefined when not set", () => { + expect(resolveSimplexOutboundClientDir({ cfg: cfg({ outboundFolder: "/w" }) })).toBeUndefined(); + }); + + it("reads connection.outboundFolderOnClient (account overrides channel)", () => { + const c = cfg( + { outboundFolderOnClient: "/data/.simplex/outbound" }, + { outboundFolderOnClient: "/other/client" } + ); + expect(resolveSimplexOutboundClientDir({ cfg: c, accountId: "acct" })).toBe("/other/client"); + }); + + it("does NOT expand ~ (it's a path on the runtime's side)", () => { + expect(resolveSimplexOutboundClientDir({ cfg: cfg({ outboundFolderOnClient: "~/x" }) })).toBe( + "~/x" + ); + }); +}); + +describe("toClientOutboundPath", () => { + it("returns the path unchanged when no client dir (verbatim)", () => { + expect(toClientOutboundPath("/w/out/a.jpg", "/w/out")).toBe("/w/out/a.jpg"); + }); + + it("swaps the outbound-dir prefix for the client dir", () => { + expect(toClientOutboundPath("/w/out/a.jpg", "/w/out", "/data/.simplex/outbound")).toBe( + "/data/.simplex/outbound/a.jpg" + ); + }); +}); + +describe("isSimplexReadablePath", () => { + it("is true only for paths inside the outbound dir", () => { + expect(isSimplexReadablePath("/simplex/outbound/a.jpg", "/simplex/outbound")).toBe(true); + expect(isSimplexReadablePath("/simplex/outbound", "/simplex/outbound")).toBe(false); + expect(isSimplexReadablePath("/tmp/a.jpg", "/simplex/outbound")).toBe(false); + // guards against a sibling prefix match (/simplex/outbound-2) + expect(isSimplexReadablePath("/simplex/outbound-2/a.jpg", "/simplex/outbound")).toBe(false); + }); +}); + +describe("staging + cleanup", () => { + let dir: string; + beforeEach(async () => { + dir = await mkdtemp(path.join(os.tmpdir(), "sx-outbound-")); + }); + afterEach(async () => { + await rm(dir, { recursive: true, force: true }); + }); + + it("stages a buffer with a unique name and cleans it up after send", async () => { + const staged = await stageOutboundBuffer({ + outboundDir: dir, + buffer: new TextEncoder().encode("hello"), + fileName: "pic.jpg", + }); + expect(staged.startsWith(dir)).toBe(true); + expect(staged.endsWith("-pic.jpg")).toBe(true); + expect(await readFile(staged, "utf8")).toBe("hello"); + + await cleanupStagedOutboundFiles([ + { fileSource: { filePath: staged }, msgContent: { type: "file", text: "" }, mentions: {} }, + ]); + await expect(readFile(staged)).rejects.toThrow(); + }); + + it("with a client dir: writes on disk in outboundDir, sends the translated path, cleans up the real file", async () => { + const clientDir = "/data/.simplex/outbound"; + const sent = await stageOutboundBuffer({ + outboundDir: dir, + clientDir, + buffer: new TextEncoder().encode("hi"), + fileName: "pic.jpg", + }); + // the path sent to the runtime is under the client dir, not the write dir + expect(sent.startsWith(clientDir)).toBe(true); + expect(sent.startsWith(dir)).toBe(false); + // the actual bytes live under the local write dir, same basename + const onDisk = path.join(dir, path.basename(sent)); + expect(await readFile(onDisk, "utf8")).toBe("hi"); + + // cleanup is keyed by the sent path but deletes the on-disk file + await cleanupStagedOutboundFiles([ + { fileSource: { filePath: sent }, msgContent: { type: "file", text: "" }, mentions: {} }, + ]); + await expect(readFile(onDisk)).rejects.toThrow(); + }); + + it("creates the outbound dir if it does not exist yet", async () => { + const nested = path.join(dir, "does/not/exist/yet"); + const staged = await stageOutboundBuffer({ + outboundDir: nested, + buffer: new TextEncoder().encode("x"), + fileName: "a.bin", + }); + expect(staged.startsWith(nested)).toBe(true); + expect(await readFile(staged, "utf8")).toBe("x"); + }); + + it("copies a local file into the outbound dir", async () => { + const src = path.join(dir, "src.txt"); + await writeFile(src, "data"); + const staged = await stageOutboundLocalFile({ outboundDir: dir, sourcePath: src }); + expect(staged).not.toBe(src); + expect(await readFile(staged, "utf8")).toBe("data"); + }); + + it("leaves non-staged (pre-existing) outbound files alone on cleanup", async () => { + const preexisting = path.join(dir, "keep.jpg"); + await writeFile(preexisting, "keep"); + await cleanupStagedOutboundFiles([ + { + fileSource: { filePath: preexisting }, + msgContent: { type: "file", text: "" }, + mentions: {}, + }, + ]); + expect(await readFile(preexisting, "utf8")).toBe("keep"); + }); +}); diff --git a/src/channel/media/outbound-files.ts b/src/channel/media/outbound-files.ts new file mode 100644 index 0000000..028167a --- /dev/null +++ b/src/channel/media/outbound-files.ts @@ -0,0 +1,162 @@ +import { randomUUID } from "node:crypto"; +import { copyFile, mkdir, unlink, writeFile } from "node:fs/promises"; +import os from "node:os"; +import path from "node:path"; +import type { OpenClawConfig } from "openclaw/plugin-sdk/channel-core"; +import { SIMPLEX_CHANNEL_ID } from "../../constants.js"; +import type { SimplexComposedMessage } from "../../types/simplex.js"; + +/** + * Shared outbound directory support (external mode, containerized runtime). + * + * When the SimpleX runtime runs in a container, it has a filesystem separate + * from OpenClaw's (whether or not OpenClaw is itself containerized), so they + * exchange files through a shared volume rather than a shared filesystem. On + * send, the path in `fileSource.filePath` is resolved by the *runtime*, so it + * must be valid on the runtime's side. + * + * - `connection.outboundFolder` — a directory OpenClaw can write to. When set, + * outbound media is staged there before the send command is issued. OpenClaw + * owns this directory (it writes it), so it is created if missing — unlike the + * inbound files-folder, which the runtime owns and the plugin only reads. + * - `connection.outboundFolderOnClient` — optional. When both sides mount the + * shared volume at the *same* path, leave it unset and the staged path is sent + * as-is (verbatim). When they mount it at *different* paths, set this to the + * directory as the runtime sees it; the plugin still stages into + * `outboundFolder` but rewrites the directory prefix to this before sending, + * so no verbatim path is required. Has no effect without `outboundFolder`. + * + * Staged files are tracked (keyed by the path sent to the runtime, mapped to the + * actual on-disk path) and deleted after the send. When `outboundFolder` is + * unset this module is inert and the local path is passed as-is. Native mode + * never sets it (the embedded core shares the gateway's filesystem). + */ + +// sent path (as it appears in fileSource.filePath) -> actual on-disk path to delete +const STAGED_FILES = new Map(); + +function expandHome(value: string): string { + if (value === "~") { + return os.homedir(); + } + if (value.startsWith("~/") || value.startsWith("~\\")) { + return path.join(os.homedir(), value.slice(2)); + } + return value; +} + +/** + * The shared outbound directory OpenClaw writes to (`connection.outboundFolder`), + * or undefined when the feature is disabled. Account config overrides channel. + * `~` is expanded (this is an OpenClaw-local path). + */ +export function resolveSimplexOutboundDir(params: { + cfg: OpenClawConfig; + accountId?: string | null; +}): string | undefined { + const channel = params.cfg.channels?.[SIMPLEX_CHANNEL_ID]; + const account = params.accountId ? channel?.accounts?.[params.accountId] : undefined; + const configured = + account?.connection?.outboundFolder?.trim() || channel?.connection?.outboundFolder?.trim(); + return configured ? expandHome(configured) : undefined; +} + +/** + * The outbound directory as the *runtime* sees it (`connection.outboundFolderOnClient`), + * or undefined. Not `~`-expanded — it's a path on the runtime's filesystem, not + * OpenClaw's. Only meaningful alongside `outboundFolder`. + */ +export function resolveSimplexOutboundClientDir(params: { + cfg: OpenClawConfig; + accountId?: string | null; +}): string | undefined { + const channel = params.cfg.channels?.[SIMPLEX_CHANNEL_ID]; + const account = params.accountId ? channel?.accounts?.[params.accountId] : undefined; + return ( + account?.connection?.outboundFolderOnClient?.trim() || + channel?.connection?.outboundFolderOnClient?.trim() || + undefined + ); +} + +/** + * Translate a path under `outboundDir` (where OpenClaw wrote it) to the path the + * runtime sees under `clientDir`, preserving any sub-path. Returns the input + * unchanged when `clientDir` is unset (verbatim / same-path deployments). + */ +export function toClientOutboundPath( + onDiskPath: string, + outboundDir: string, + clientDir?: string +): string { + if (!clientDir) { + return onDiskPath; + } + return path.join(clientDir, path.relative(outboundDir, onDiskPath)); +} + +/** + * Whether a local path is already inside the shared outbound dir (so its bytes + * are already where the runtime can read them and no copy is needed). + */ +export function isSimplexReadablePath(filePath: string, outboundDir: string): boolean { + const prefix = outboundDir.endsWith(path.sep) ? outboundDir : outboundDir + path.sep; + return filePath.startsWith(prefix); +} + +function stagedFileName(fileName?: string): string { + const base = fileName ? path.basename(fileName) : ""; + return base ? `${randomUUID()}-${base}` : randomUUID(); +} + +/** Write a media buffer into the shared outbound dir; returns the path to send. */ +export async function stageOutboundBuffer(params: { + outboundDir: string; + clientDir?: string; + buffer: Uint8Array; + fileName?: string; +}): Promise { + const onDisk = path.join(params.outboundDir, stagedFileName(params.fileName)); + await mkdir(params.outboundDir, { recursive: true }); + await writeFile(onDisk, params.buffer); + const sent = toClientOutboundPath(onDisk, params.outboundDir, params.clientDir); + STAGED_FILES.set(sent, onDisk); + return sent; +} + +/** Copy a local file into the shared outbound dir; returns the path to send. */ +export async function stageOutboundLocalFile(params: { + outboundDir: string; + clientDir?: string; + sourcePath: string; +}): Promise { + const onDisk = path.join(params.outboundDir, stagedFileName(params.sourcePath)); + await mkdir(params.outboundDir, { recursive: true }); + await copyFile(params.sourcePath, onDisk); + const sent = toClientOutboundPath(onDisk, params.outboundDir, params.clientDir); + STAGED_FILES.set(sent, onDisk); + return sent; +} + +/** + * Delete any staged outbound files referenced by the given composed messages. + * Call after the send completes. Only files staged by this module are deleted + * (looked up by the sent path, deleting the mapped on-disk file); pre-existing + * files in the outbound dir are left alone. + */ +export async function cleanupStagedOutboundFiles( + composedMessages: SimplexComposedMessage[] +): Promise { + for (const message of composedMessages) { + const filePath = message.fileSource?.filePath; + if (filePath && STAGED_FILES.has(filePath)) { + const onDisk = STAGED_FILES.get(filePath); + STAGED_FILES.delete(filePath); + if (onDisk) { + await unlink(onDisk).catch(() => { + // Best-effort: the file may already be gone. + }); + } + } + } +} diff --git a/src/channel/media/simplex-media.ts b/src/channel/media/simplex-media.ts index 459af51..b9e3cb6 100644 --- a/src/channel/media/simplex-media.ts +++ b/src/channel/media/simplex-media.ts @@ -1,9 +1,18 @@ import path from "node:path"; import type { OpenClawConfig } from "openclaw/plugin-sdk/channel-core"; import { resolveChannelMediaMaxBytes } from "openclaw/plugin-sdk/media-runtime"; +import { resolveMediaBufferPath } from "openclaw/plugin-sdk/media-store"; import { SIMPLEX_CHANNEL_ID } from "../../constants.js"; import type { SimplexComposedMessage, SimplexMsgContent } from "../../types/simplex.js"; import { getSimplexRuntime } from "../runtime.js"; +import { + isSimplexReadablePath, + resolveSimplexOutboundClientDir, + resolveSimplexOutboundDir, + stageOutboundBuffer, + stageOutboundLocalFile, + toClientOutboundPath, +} from "./outbound-files.js"; const DEFAULT_MAX_BYTES = 5 * 1024 * 1024; @@ -25,6 +34,8 @@ export function resolveSimplexMediaMaxBytes(params: { async function resolveMediaPath(params: { mediaUrl: string; maxBytes: number; + outboundDir?: string; + outboundClientDir?: string; }): Promise<{ path: string; contentType?: string; fileName?: string }> { const core = getSimplexRuntime(); const mediaUrlLower = params.mediaUrl.toLowerCase(); @@ -34,6 +45,17 @@ async function resolveMediaPath(params: { maxBytes: params.maxBytes, filePathHint: params.mediaUrl, }); + // Shared outbound dir configured: write the buffer where the runtime can + // read it (containerized runtime), instead of OpenClaw's media store. + if (params.outboundDir) { + const staged = await stageOutboundBuffer({ + outboundDir: params.outboundDir, + clientDir: params.outboundClientDir, + buffer: fetched.buffer, + fileName: fetched.fileName, + }); + return { path: staged, contentType: fetched.contentType, fileName: fetched.fileName }; + } const saved = await core.channel.media.saveMediaBuffer( fetched.buffer, fetched.contentType, @@ -43,9 +65,38 @@ async function resolveMediaPath(params: { ); return { path: saved.path, contentType: saved.contentType, fileName: fetched.fileName }; } - const contentType = await core.media.detectMime({ filePath: params.mediaUrl }); - const fileName = path.basename(params.mediaUrl); - return { path: params.mediaUrl, contentType, fileName }; + // media:/// references point into OpenClaw's media store (e.g. + // media://inbound/ to re-send a received file). Resolve to the physical + // path via the store's read-side helper, then treat it as a local path. + let localPath = params.mediaUrl; + if (mediaUrlLower.startsWith("media://")) { + const match = /^media:\/\/([^/]+)\/(.+)$/.exec(params.mediaUrl); + if (!match?.[1] || !match[2]) { + throw new Error(`Invalid media reference: ${params.mediaUrl}`); + } + localPath = await resolveMediaBufferPath(match[2], match[1]); + } + const contentType = await core.media.detectMime({ filePath: localPath }); + const fileName = path.basename(localPath); + if (params.outboundDir) { + // Already inside the shared dir: no copy needed, just translate the path to + // how the runtime sees it (a no-op when outboundClientDir is unset). + if (isSimplexReadablePath(localPath, params.outboundDir)) { + return { + path: toClientOutboundPath(localPath, params.outboundDir, params.outboundClientDir), + contentType, + fileName, + }; + } + // Local path the runtime cannot see: copy it into the shared outbound dir. + const staged = await stageOutboundLocalFile({ + outboundDir: params.outboundDir, + clientDir: params.outboundClientDir, + sourcePath: localPath, + }); + return { path: staged, contentType, fileName }; + } + return { path: localPath, contentType, fileName }; } function buildMediaMsgContent(params: { @@ -124,13 +175,21 @@ export async function buildComposedMessages(params: { cfg: params.cfg, accountId: params.accountId, }); + const outboundDir = resolveSimplexOutboundDir({ + cfg: params.cfg, + accountId: params.accountId, + }); + const outboundClientDir = resolveSimplexOutboundClientDir({ + cfg: params.cfg, + accountId: params.accountId, + }); for (let i = 0; i < mediaList.length; i += 1) { const mediaUrl = mediaList[i]; if (!mediaUrl) { continue; } - const resolved = await resolveMediaPath({ mediaUrl, maxBytes }); + const resolved = await resolveMediaPath({ mediaUrl, maxBytes, outboundDir, outboundClientDir }); const caption = i === 0 ? text : ""; const msgContent = buildMediaMsgContent({ text: caption, diff --git a/src/channel/messaging/simplex-send.ts b/src/channel/messaging/simplex-send.ts index d72cbb0..2bc2526 100644 --- a/src/channel/messaging/simplex-send.ts +++ b/src/channel/messaging/simplex-send.ts @@ -4,6 +4,7 @@ import { parseSimplexNumericId, resolveSimplexChatItemId } from "../../simplex/r import { withSimplexClient } from "../../simplex/runtime/transport.js"; import type { ResolvedSimplexAccount } from "../../types/config.js"; import type { SimplexComposedMessage } from "../../types/simplex.js"; +import { cleanupStagedOutboundFiles } from "../media/outbound-files.js"; import { buildComposedMessages } from "../media/simplex-media.js"; export async function sendSimplexComposedMessages(params: { @@ -27,6 +28,9 @@ export async function sendSimplexComposedMessages(params: { ttl: params.ttl, liveMessage: params.liveMessage, }); + // Files staged into a shared outbound dir are no longer needed once the send + // command has been issued (the runtime has consumed the path). + await cleanupStagedOutboundFiles(params.composedMessages); const messageId = resolveSimplexChatItemId(chatItems[0]); return { messageId, diff --git a/src/config/config-schema.ts b/src/config/config-schema.ts index 66e1e59..4cb38df 100644 --- a/src/config/config-schema.ts +++ b/src/config/config-schema.ts @@ -37,6 +37,9 @@ const SimplexConnectionSchema = z wsPort: z.number().int().positive().optional(), allowUnsafeRemoteWs: z.boolean().optional(), autoAcceptFiles: z.boolean().optional(), + filesFolder: z.string().min(1).optional(), + outboundFolder: z.string().min(1).optional(), + outboundFolderOnClient: z.string().min(1).optional(), connectTimeoutMs: z.number().int().positive().optional(), commandTimeoutMs: z.number().int().positive().optional(), directoryTimeoutMs: z.number().int().positive().optional(), diff --git a/src/types/config.ts b/src/types/config.ts index 61a8798..8a453fc 100644 --- a/src/types/config.ts +++ b/src/types/config.ts @@ -7,6 +7,28 @@ export type SimplexConnectionConfig = { wsPort?: number; allowUnsafeRemoteWs?: boolean; autoAcceptFiles?: boolean; + /** + * Files-folder the external runtime stores received files in (its + * `--files-folder`). Used to resolve relative inbound file paths (the WS API + * reports received files by name only when a files-folder is set). Defaults to + * `~/.simplex/files`. + */ + filesFolder?: string; + /** + * Directory writable by OpenClaw where outbound media is staged before + * sending, so the runtime can read it (e.g. a shared volume). When both sides + * mount that volume at the same path, the staged path is sent as-is. Unset = + * legacy single-filesystem behavior (pass the local path directly). + */ + outboundFolder?: string; + /** + * The `outboundFolder` directory as the external runtime sees it, when the two + * sides mount the shared volume at *different* paths. When set (with + * `outboundFolder`), the plugin stages into `outboundFolder` but rewrites the + * directory prefix to this before sending, so no verbatim path is needed. No + * effect without `outboundFolder`. + */ + outboundFolderOnClient?: string; connectTimeoutMs?: number; commandTimeoutMs?: number; directoryTimeoutMs?: number; From c1195adc9cb5887984a32b077503eed6c81f8ee8 Mon Sep 17 00:00:00 2001 From: lundog <8041501+lundog@users.noreply.github.com> Date: Wed, 1 Jul 2026 23:35:29 -0600 Subject: [PATCH 2/2] bump ws to fix vulnerability scan --- package.json | 2 +- pnpm-lock.yaml | 22 ++++------------------ 2 files changed, 5 insertions(+), 19 deletions(-) diff --git a/package.json b/package.json index db29633..03e5d57 100644 --- a/package.json +++ b/package.json @@ -51,7 +51,7 @@ "dependencies": { "@sinclair/typebox": "^0.34.49", "qrcode": "^1.5.4", - "ws": "^8.20.0", + "ws": "^8.21.0", "zod": "^4.4.3" }, "peerDependencies": { diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index d18f701..f496583 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -15,8 +15,8 @@ importers: specifier: ^1.5.4 version: 1.5.4 ws: - specifier: ^8.20.0 - version: 8.20.0 + specifier: ^8.21.0 + version: 8.21.0 zod: specifier: ^4.4.3 version: 4.4.3 @@ -5381,18 +5381,6 @@ packages: utf-8-validate: optional: true - ws@8.20.0: - resolution: {integrity: sha512-sAt8BhgNbzCtgGbt2OxmpuryO63ZoDk/sqaB/znQm94T4fCEsy/yV+7CdC1kJhOU9lboAEU7R3kquuycDoibVA==} - engines: {node: '>=10.0.0'} - peerDependencies: - bufferutil: ^4.0.1 - utf-8-validate: '>=5.0.2' - peerDependenciesMeta: - bufferutil: - optional: true - utf-8-validate: - optional: true - ws@8.21.0: resolution: {integrity: sha512-Vsp28b7DRcimFQvrqu2Wek3z1iYxDCWqHYB8Qsnk/S4RfaCQzPGPyBNuVjJV3cd6UiKtUtp6sNM77gWvzcCH+g==} engines: {node: '>=10.0.0'} @@ -9332,7 +9320,7 @@ snapshots: type-fest: 4.41.0 widest-line: 5.0.0 wrap-ansi: 9.0.2 - ws: 8.20.0 + ws: 8.21.0 yoga-layout: 3.2.1 optionalDependencies: '@types/react': 19.2.14 @@ -10813,7 +10801,7 @@ snapshots: chromium-bidi: 0.6.2(devtools-protocol@0.0.1312386) debug: 4.4.3 devtools-protocol: 0.0.1312386 - ws: 8.20.0 + ws: 8.21.0 transitivePeerDependencies: - bare-abort-controller - bare-buffer @@ -12308,8 +12296,6 @@ snapshots: ws@8.18.3: {} - ws@8.20.0: {} - ws@8.21.0: {} xml-naming@0.1.0: {}