Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
73 changes: 73 additions & 0 deletions docs/guide/runtime-setup.mdx
Original file line number Diff line number Diff line change
Expand Up @@ -49,6 +49,9 @@ Plugin-side settings:
- <code>connection.allowUnsafeRemoteWs</code>
- <code>connection.connectTimeoutMs</code>
- <code>connection.autoAcceptFiles</code>
- <code>connection.filesFolder</code>
- <code>connection.outboundFolder</code>
- <code>connection.outboundFolderOnClient</code>
- <code>streaming.nativeTransport</code> and related live-reply throttling fields
- <code>messageTtlSeconds</code> and <code>filePolicy</code>
- <code>dmPolicy</code>, <code>allowFrom</code>, <code>groupPolicy</code>
Expand All @@ -59,6 +62,76 @@ Runtime-side startup flags:
- local storage/layout: <code>--database</code>, <code>--files-folder</code>, <code>--temp-folder</code>, <code>--log-file</code>
- runtime process behavior: <code>--device-name</code>, <code>--maintenance</code>, <code>--mute</code>, <code>--mark-read</code>

### Locating received files (`connection.filesFolder`)

<Note>
<code>connection.filesFolder</code> is the path where <strong>OpenClaw</strong> reads files the runtime received. When the runtime is started with <code>--files-folder</code>, it reports received files by <em>name only</em> (not a full path), and the plugin joins that name to <code>connection.filesFolder</code> (default <code>~/.simplex/files</code>) to read the bytes. Without <code>--files-folder</code> the runtime reports absolute paths and this setting is unused.
</Note>

The name-only reporting is deliberate, and it is what lets the runtime and OpenClaw refer to the same files from <em>different</em> locations. How you set <code>connection.filesFolder</code> depends on the topology:

- <strong>Shared filesystem</strong> (OpenClaw and the runtime see the same paths — e.g. both on one host, or both in one container): set <code>connection.filesFolder</code> to the same path as <code>--files-folder</code>.
- <strong>Containerized runtime with a shared volume</strong>: 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 <code>--files-folder</code> to the runtime's mount and <code>connection.filesFolder</code> 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`)

<Note>
<code>connection.outboundFolder</code> is the directory <strong>OpenClaw</strong> 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 <em>its own</em> 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.
</Note>

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 <em>to</em> 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 <code>connection.outboundFolder</code>:

```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 <code>connection.outboundFolderOnClient</code> — the same directory as the runtime sees it. OpenClaw writes to <code>outboundFolder</code> but rewrites the directory prefix to <code>outboundFolderOnClient</code> 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.
Expand Down
3 changes: 3 additions & 0 deletions docs/reference/config.mdx
Original file line number Diff line number Diff line change
Expand Up @@ -128,6 +128,9 @@ If <code>accounts</code> 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. |
Expand Down
24 changes: 24 additions & 0 deletions openclaw.plugin.json
Original file line number Diff line number Diff line change
Expand Up @@ -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,
Expand Down Expand Up @@ -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,
Expand Down
2 changes: 1 addition & 1 deletion package.json
Original file line number Diff line number Diff line change
Expand Up @@ -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": {
Expand Down
22 changes: 4 additions & 18 deletions pnpm-lock.yaml

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

4 changes: 4 additions & 0 deletions src/actions/message-actions.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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";
Expand Down Expand Up @@ -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]) };
}

Expand Down
51 changes: 51 additions & 0 deletions src/channel/events/simplex-inbound-files.test.ts
Original file line number Diff line number Diff line change
@@ -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(() => ({
Expand Down Expand Up @@ -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();
Expand Down Expand Up @@ -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);
Expand Down
Loading
Loading