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/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: {}
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;