From 262eca87f9802359c581738bd48cf8e43971220d Mon Sep 17 00:00:00 2001 From: lundog <8041501+lundog@users.noreply.github.com> Date: Wed, 24 Jun 2026 00:57:53 -0600 Subject: [PATCH 1/5] Fix inbound media handling and file-receive race --- .../events/simplex-inbound-files.test.ts | 25 +++++ src/channel/events/simplex-inbound-files.ts | 94 +++++++++++++++++-- src/channel/events/simplex-monitor.ts | 31 +++++- 3 files changed, 139 insertions(+), 11 deletions(-) diff --git a/src/channel/events/simplex-inbound-files.test.ts b/src/channel/events/simplex-inbound-files.test.ts index a843eea..4d97e5a 100644 --- a/src/channel/events/simplex-inbound-files.test.ts +++ b/src/channel/events/simplex-inbound-files.test.ts @@ -4,8 +4,10 @@ import { setSimplexRuntime } from "../runtime.js"; import { dispatchInbound, finalizePendingFile, + markFileAccepted, type PendingInboundFile, queuePendingFile, + shouldRetryFileAccept, } from "./simplex-inbound-files.js"; const simplexClientMock = vi.hoisted(() => ({ @@ -135,6 +137,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..39ed68b 100644 --- a/src/channel/events/simplex-inbound-files.ts +++ b/src/channel/events/simplex-inbound-files.ts @@ -1,8 +1,11 @@ +import { readFile } from "node:fs/promises"; +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 +14,22 @@ import { getSimplexRuntime } from "../runtime.js"; const PENDING_FILE_TIMEOUT_MS = 90_000; +/** + * Where simplex-chat saves received files when started without --files-folder. + */ +const DEFAULT_INBOUND_DIR = "/tmp"; + +/** + * Directory where the simplex-chat runtime saves received files. The WS API + * reports received files with a path relative to this directory + * (`fileSource.filePath` is typically just the file name), so the plugin needs + * it to locate the file on disk. Defaults to /tmp, where simplex-chat saves + * files when no --files-folder is configured. + */ +function resolveSimplexInboundDir(): string { + return DEFAULT_INBOUND_DIR; +} + type SimplexReplyPayload = { text?: string; mediaUrl?: string; @@ -37,6 +56,7 @@ export type PendingInboundFile = { type QueuedPendingInboundFile = { pending: PendingInboundFile; timeout: ReturnType; + accepted: boolean; }; const pendingFiles = new Map(); @@ -45,6 +65,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 +78,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 +90,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 +140,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 +156,47 @@ 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; + // The WS API reports received files relative to the runtime's + // --files-folder (fileSource.filePath is typically just the file name). + // Resolve against the configured inbound dir (default: /tmp, where + // simplex-chat saves files when no --files-folder is configured). + if (rawPath && !path.isAbsolute(rawPath)) { + rawPath = path.join( + resolveSimplexInboundDir(), + 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. /tmp/... 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 +222,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; } } From d8e6b9eccccdd56435ea34bef3fb30c824f14699 Mon Sep 17 00:00:00 2001 From: lundog <8041501+lundog@users.noreply.github.com> Date: Sat, 27 Jun 2026 18:10:32 -0600 Subject: [PATCH 2/5] Add native in-process SimpleX transport (mode: native) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit SimpleX deprecated its WebRTC TypeScript client in favor of the native `simplex-chat` Node.js library, which embeds the SimpleX core in-process. This adds it as an opt-in transport alongside the existing external WebSocket-runtime mode, so a bot can run with no sidecar — no separate simplex-chat process, Docker/systemd, or wsUrl. Set mode: "native". External mode is unchanged: a SimplexTransport interface is extracted from the existing WS client, SimplexWsClient implements it, and SimplexClient selects the implementation by account mode. Transport & lifecycle: - SimplexCoreClient drives the embedded core (init -> ensure user -> startChat -> address); it speaks the same string-command / JSON-event protocol as the WS runtime, so everything above the transport is unchanged. - One embedded core per account: withSimplexClient refuses to open a second connection to the same database, and startup rejects two native accounts that resolve to the same db.filePrefix. - Diagnostics/security are mode-aware (no WS-endpoint checks in native). Config (additive, backward compatible): - connection.mode gains "native"; adds db (filePrefix/encryptionKey), profile (displayName/fullName/image/peerType), and addressSettings (autoAccept/welcomeMessage/businessAddress). Manifest regenerated. - db.filePrefix defaults to /simplex/, per account. Profile & address: - Account-level profile resolved and reconciled on connect; the avatar is downscaled to a small JPEG (using the image/jpg media type SimpleX renders). The address is auto-created with welcome message + auto-accept and reconciled only when settings change. CLI: - The openclaw simplex ... commands (invite, address, requests, groups, files, connect, runtime) dispatch to the running gateway in native mode so they reach the in-process core, with identical terminal output; external behavior is untouched. Licensing: - simplex-chat is AGPL-3.0 and is NOT a dependency of this plugin. Default and external-mode installs never fetch it. Native mode is an explicit operator opt-in (npm install simplex-chat in the plugin's project), loaded lazily via dynamic import. Adds unit tests (transport guard, core lifecycle, profile/address reconcile, account defaults and duplicate-prefix guard) and a native-setup guide covering config, profile, address settings, and a database durability/reset runbook. --- docs/docs.json | 1 + docs/guide/native-setup.mdx | 198 +++++++++++ openclaw.plugin.json | 110 +++++- package.json | 5 + src/channel/diagnostics/simplex-status.ts | 28 +- src/channel/events/simplex-inbound-files.ts | 23 +- src/channel/events/simplex-monitor.ts | 16 + src/channel/plugin.ts | 5 +- src/channel/security/simplex-security.ts | 33 +- src/channel/simplex-bot-profile.ts | 137 ++++++++ src/cli/plugin-cli-wrappers.ts | 139 ++++++++ src/cli/plugin-cli.ts | 44 +-- src/config/accounts.test.ts | 63 ++++ src/config/accounts.ts | 75 +++- src/config/config-schema.ts | 31 +- src/simplex/runtime/client.ts | 38 +- src/simplex/runtime/core-client.test.ts | 238 +++++++++++++ src/simplex/runtime/core-client.ts | 344 +++++++++++++++++++ src/simplex/runtime/transport-types.ts | 30 ++ src/simplex/runtime/transport.test.ts | 45 +++ src/simplex/runtime/transport.ts | 31 ++ src/simplex/runtime/ws-client.ts | 10 +- src/simplex/services/runtime-capabilities.ts | 46 ++- src/types/config.ts | 44 ++- 24 files changed, 1638 insertions(+), 96 deletions(-) create mode 100644 docs/guide/native-setup.mdx create mode 100644 src/channel/simplex-bot-profile.ts create mode 100644 src/cli/plugin-cli-wrappers.ts create mode 100644 src/simplex/runtime/core-client.test.ts create mode 100644 src/simplex/runtime/core-client.ts create mode 100644 src/simplex/runtime/transport-types.ts diff --git a/docs/docs.json b/docs/docs.json index 7d60c99..3ffaf14 100644 --- a/docs/docs.json +++ b/docs/docs.json @@ -22,6 +22,7 @@ "guide/how-it-works", "guide/architecture", "guide/runtime-setup", + "guide/native-setup", "guide/security-model", "guide/migration", "guide/troubleshooting" diff --git a/docs/guide/native-setup.mdx b/docs/guide/native-setup.mdx new file mode 100644 index 0000000..b0baa18 --- /dev/null +++ b/docs/guide/native-setup.mdx @@ -0,0 +1,198 @@ +--- +title: Native runtime (embedded core) +description: Run the SimpleX channel with the embedded native simplex-chat core, without a separate WebSocket runtime. +--- + +By default this plugin connects to a **separately-run** `simplex-chat` process +over WebSocket (see [Runtime setup](/guide/runtime-setup)). As an alternative, +it can embed the SimpleX core **in-process** using the +[`simplex-chat`](https://www.npmjs.com/package/simplex-chat) native package — no +separate process to install, run, or supervise. + + + The `simplex-chat` package is licensed **AGPL-3.0**, while this plugin is MIT. + It is **not a dependency** of this plugin and is never installed by default — + you install it yourself to enable native mode (see below). Installing and + running it brings the combined work under AGPL terms (including its network-use + clause). Review the licensing implications for your deployment before enabling + native mode. + + +## Install simplex-chat (native mode only) + +Native mode requires the `simplex-chat` package, which is **not installed by +default** — default installs stay pure-JS and AGPL-free. Install it into the same +project (`node_modules` tree) where the plugin runs, **using that project's +package manager**. + +OpenClaw installs plugins with **npm** (the managed plugin project under +`~/.openclaw/npm/projects/<…>` has a `package-lock.json`), so install it there +with npm — which also runs the native build automatically: + +```bash +# in the plugin's OpenClaw project directory +npm install simplex-chat +``` + + + Do not run `pnpm` in an npm-managed project (or vice versa). pnpm will try to + take the project over — moving the npm-installed plugin to `node_modules/.ignored` + and blocking build scripts — which breaks OpenClaw's view of the plugin. Match + the package manager the project already uses. + + +`simplex-chat` ships a platform-specific native addon (built via `node-gyp` on +install), so the host needs a build toolchain (a C/C++ compiler, `make`, and +Python) unless a prebuilt binary is available for your platform. If +`simplex-chat` is missing when an account is set to `mode: "native"`, the channel +fails to start with a clear message telling you to install it; external mode is +unaffected. + +## Configure an account for native mode + +Setting `connection.mode` to `"native"` is enough — everything else has a +default. The minimal config is just: + +```jsonc +{ + "channels": { + "openclaw-simplex": { + "connection": { "mode": "native" } + } + } +} +``` + +By default the database lives under OpenClaw's state directory at +`/simplex/` — the core creates +`_chat.db` and `_agent.db` there. Override any of it as needed: + +```jsonc +{ + "channels": { + "openclaw-simplex": { + "connection": { + "mode": "native", + "db": { "filePrefix": "/path/to/.openclaw/simplex/my-prefix" }, // optional override + "profile": { + "displayName": "My OpenClaw bot", // optional; defaults to the account name + "fullName": "", + "image": "/path/to/.openclaw/workspace/avatars/my-avatar.jpg", // optional; data URI, http(s) URL, or absolute file path + "peerType": "bot" // optional; "bot" (default) or "human" + }, + "addressSettings": { + "autoAccept": true, + "welcomeMessage": "Connected. Send me a message." + } + } + } + } +} +``` + +On first start the plugin creates the bot user and a long-term address (printed +to the logs), then auto-accepts incoming contact requests. Subsequent starts +reuse the identity stored in the database files. + +| Field | Required | Notes | +| --- | --- | --- | +| `mode` | yes | `"native"` selects the embedded core. Omit or `"external"` keeps the WebSocket runtime. | +| `db.filePrefix` | no | Path prefix for the two SQLite files. Defaults to `/simplex/`. | +| `db.encryptionKey` | no | SQLCipher key; omit for an unencrypted database. | +| `profile.displayName` | no | Bot display name. Defaults to the account name, then `"OpenClaw"`. | +| `profile.fullName` | no | Optional full name (defaults to empty). | +| `profile.image` | no | Avatar as a data URI, http(s) URL, or file path (use an **absolute** path; relative paths resolve against the gateway's working directory). Downscaled to a small JPEG. Use a **square** image — SimpleX renders avatars square, so non-square images are stretched. | +| `profile.peerType` | no | `"bot"` (default) or `"human"`. | +| `addressSettings.autoAccept` | no | Default `true`. Auto-accept incoming contact requests (see below). | +| `addressSettings.welcomeMessage` | no | Auto-reply text shown to people connecting via the address. | +| `addressSettings.businessAddress` | no | Default `false`. `true` makes connections **business chats** (groups) instead of 1:1 contacts. | + +A SimpleX account has exactly one identity (one address and profile that every +contact sees), so the profile is **account-level config** — it is not derived +from agent identity (multiple agents can share one account, and agent identity is +per-conversation). Set `connection.profile` to give the account a specific face. + +The profile is **reconciled on every start**: the plugin compares the desired +display name, avatar, and peer type against the live profile and updates it if it +has drifted — so editing `connection.profile` propagates on the next restart. +Address settings (`addressSettings`) are reconciled the same way. + +### Address settings + +- **`welcomeMessage`** is the address **auto-reply** (text only). It is stored on + the address as a `MsgContent` and shown to anyone who opens your link — they + see it *before* tapping Connect — and the core sends it automatically on + connection. The plugin doesn't send it per message. +- **`businessAddress`** changes the conversation model. With `false` (default), a + new contact becomes a normal **1:1 direct chat**. With `true`, each connection + creates a **business chat**, which in SimpleX is a *group* (the customer and + bot are members and you can add more participants, e.g. human agents). Because + business chats are groups, they are governed by your **group** policy + (`groupPolicy`, group allowlist, require-mention) rather than `dmPolicy`. +- **`autoAccept`** (default `true`) accepts incoming contact requests + automatically. With `autoAccept: false`, a person who connects stays in a + "request sent" state until you accept it. Pending requests are recorded by the + gateway (silently — no log line); list and act on them with: + + ```sh + openclaw simplex requests list + openclaw simplex requests accept --contact-request-id + openclaw simplex requests reject --contact-request-id + ``` + + In native mode these commands run against the gateway holding the embedded + core, so the gateway must be running. + +## How it differs from external mode + +- No external `simplex-chat` process, sidecar, Docker container, or `wsUrl`. +- The core runs in the OpenClaw process, so there is no network transport to + secure and no reconnect loop — the channel reports the core as started/stopped. +- Everything else (policy enforcement, pairing, allowlists, group policy, + actions, files, reactions) is identical to external mode: native mode only + swaps the transport. +- The embedded core is owned by the gateway process, so the `openclaw simplex …` + CLI commands (invite, address, requests, groups, files, connect, runtime) are + dispatched to the running gateway in native mode instead of opening their own + connection. They work the same as in external mode (including terminal QR + output); they just require the gateway to be running. + +Policy, allowlist, and message handling behave exactly as documented elsewhere — +see [Security model](/guide/security-model) and [How it works](/guide/how-it-works). + +## Treat the database as durable identity + +The native database (`_chat.db` / `_agent.db`) **is** the bot's +identity and its contact map. SimpleX is identifier-free by design: contacts are +referenced by a **local, sequential id** (e.g. `4`) that is only stable for the +life of that database. OpenClaw keys per-contact state (sessions, transcripts, +pairing approvals, allowlist entries) on that id. + + + Do not delete the database casually. If you delete it and let users reconnect, + SimpleX reuses the low ids (a new contact becomes `4` again), and OpenClaw will + treat that new person as the **old** contact — inheriting their session + history, pairing approval, and allowlist membership. Back up the database files + instead. + + +If you must reset the database, perform it as a deliberate runbook and purge the +matching OpenClaw state for the channel/account afterwards, before users +reconnect: + +```sh +# 1. Revoke stale pairing approvals (a reused id would inherit approval) +openclaw pairing list +openclaw pairing remove + +# 2. Delete stale sessions/transcripts so a new contact can't see old context +openclaw sessions list +# delete the openclaw-simplex sessions (see `openclaw sessions --help`, +# or the sessions.delete gateway method) + +# 3. If using allowlist policy, prune allowFrom / groupAllowFrom entries that +# reference the old numeric ids +``` + +This applies to external mode too (the external runtime's database has the same +local-id behavior); native mode just makes the database an operator-managed file. diff --git a/openclaw.plugin.json b/openclaw.plugin.json index e3b8c04..d69c7f5 100644 --- a/openclaw.plugin.json +++ b/openclaw.plugin.json @@ -224,7 +224,7 @@ "properties": { "mode": { "type": "string", - "const": "external" + "enum": ["external", "native"] }, "wsUrl": { "type": "string", @@ -258,6 +258,59 @@ "type": "integer", "exclusiveMinimum": 0, "maximum": 9007199254740991 + }, + "db": { + "type": "object", + "properties": { + "type": { + "type": "string", + "const": "sqlite" + }, + "filePrefix": { + "type": "string", + "minLength": 1 + }, + "encryptionKey": { + "type": "string" + } + }, + "required": ["filePrefix"], + "additionalProperties": false + }, + "profile": { + "type": "object", + "properties": { + "displayName": { + "type": "string", + "minLength": 1 + }, + "fullName": { + "type": "string" + }, + "image": { + "type": "string" + }, + "peerType": { + "type": "string", + "enum": ["bot", "human"] + } + }, + "additionalProperties": false + }, + "addressSettings": { + "type": "object", + "properties": { + "autoAccept": { + "type": "boolean" + }, + "welcomeMessage": { + "type": "string" + }, + "businessAddress": { + "type": "boolean" + } + }, + "additionalProperties": false } }, "additionalProperties": false @@ -461,7 +514,7 @@ "properties": { "mode": { "type": "string", - "const": "external" + "enum": ["external", "native"] }, "wsUrl": { "type": "string", @@ -495,6 +548,59 @@ "type": "integer", "exclusiveMinimum": 0, "maximum": 9007199254740991 + }, + "db": { + "type": "object", + "properties": { + "type": { + "type": "string", + "const": "sqlite" + }, + "filePrefix": { + "type": "string", + "minLength": 1 + }, + "encryptionKey": { + "type": "string" + } + }, + "required": ["filePrefix"], + "additionalProperties": false + }, + "profile": { + "type": "object", + "properties": { + "displayName": { + "type": "string", + "minLength": 1 + }, + "fullName": { + "type": "string" + }, + "image": { + "type": "string" + }, + "peerType": { + "type": "string", + "enum": ["bot", "human"] + } + }, + "additionalProperties": false + }, + "addressSettings": { + "type": "object", + "properties": { + "autoAccept": { + "type": "boolean" + }, + "welcomeMessage": { + "type": "string" + }, + "businessAddress": { + "type": "boolean" + } + }, + "additionalProperties": false } }, "additionalProperties": false diff --git a/package.json b/package.json index db29633..fe00b51 100644 --- a/package.json +++ b/package.json @@ -54,6 +54,11 @@ "ws": "^8.20.0", "zod": "^4.4.3" }, + "pnpm": { + "onlyBuiltDependencies": [ + "simplex-chat" + ] + }, "peerDependencies": { "openclaw": ">=2026.5.27" }, diff --git a/src/channel/diagnostics/simplex-status.ts b/src/channel/diagnostics/simplex-status.ts index 38e9b71..b983343 100644 --- a/src/channel/diagnostics/simplex-status.ts +++ b/src/channel/diagnostics/simplex-status.ts @@ -9,6 +9,24 @@ import { import type { ResolvedSimplexAccount } from "../../types/config.js"; import { resolveSimplexHealthState } from "../shared/simplex-common.js"; +/** + * WS endpoint security only applies to the external runtime. In native mode the + * core runs in-process (no network endpoint), so report no transport warnings. + */ +function resolveEndpointSecurity(account: ResolvedSimplexAccount) { + if (account.mode === "native") { + return { + valid: true, + redactedUrl: "", + warnings: [] as string[], + blockingWarnings: [] as string[], + }; + } + return describeSimplexWsEndpointSecurity(account.wsUrl, { + allowUnsafeRemoteWs: account.config.connection?.allowUnsafeRemoteWs, + }); +} + export function buildSimplexStatus(): NonNullable< ChannelPlugin["status"] > { @@ -51,14 +69,11 @@ export function buildSimplexStatus(): NonNullable< return issues; }), buildChannelSummary: ({ snapshot, account }) => { - const connection = account.config.connection; const configured = snapshot.configured ?? account.configured; const running = snapshot.running ?? false; const connected = snapshot.connected ?? false; const lastError = snapshot.lastError ?? null; - const security = describeSimplexWsEndpointSecurity(account.wsUrl, { - allowUnsafeRemoteWs: connection?.allowUnsafeRemoteWs, - }); + const security = resolveEndpointSecurity(account); return { configured, running, @@ -106,10 +121,7 @@ export function buildSimplexStatus(): NonNullable< { text: `SimpleX channels: ${probe.experimentalChannels.state}` }, ], buildAccountSnapshot: async ({ account, runtime, probe }) => { - const connection = account.config.connection; - const security = describeSimplexWsEndpointSecurity(account.wsUrl, { - allowUnsafeRemoteWs: connection?.allowUnsafeRemoteWs, - }); + const security = resolveEndpointSecurity(account); return { accountId: account.accountId, name: account.name, diff --git a/src/channel/events/simplex-inbound-files.ts b/src/channel/events/simplex-inbound-files.ts index 39ed68b..c4e0722 100644 --- a/src/channel/events/simplex-inbound-files.ts +++ b/src/channel/events/simplex-inbound-files.ts @@ -15,16 +15,15 @@ import { getSimplexRuntime } from "../runtime.js"; const PENDING_FILE_TIMEOUT_MS = 90_000; /** - * Where simplex-chat saves received files when started without --files-folder. + * Default location where simplex-chat saves received files when started without + * `--files-folder`. */ const DEFAULT_INBOUND_DIR = "/tmp"; /** - * Directory where the simplex-chat runtime saves received files. The WS API - * reports received files with a path relative to this directory - * (`fileSource.filePath` is typically just the file name), so the plugin needs - * it to locate the file on disk. Defaults to /tmp, where simplex-chat saves - * files when no --files-folder is configured. + * Base directory for resolving relative inbound file paths. The runtime reports + * received files relative to its files-folder (`fileSource.filePath` is usually + * just the file name), so relative paths are resolved against this default. */ function resolveSimplexInboundDir(): string { return DEFAULT_INBOUND_DIR; @@ -157,15 +156,11 @@ export async function finalizePendingFile(params: { clearTimeout(queued.timeout); const { pending } = queued; let rawPath = params.filePath?.trim() || undefined; - // The WS API reports received files relative to the runtime's - // --files-folder (fileSource.filePath is typically just the file name). - // Resolve against the configured inbound dir (default: /tmp, where - // simplex-chat saves files when no --files-folder is configured). + // The runtime reports received files relative to its --files-folder + // (fileSource.filePath is typically just the file name), so resolve relative + // paths against the default inbound dir (/tmp). if (rawPath && !path.isAbsolute(rawPath)) { - rawPath = path.join( - resolveSimplexInboundDir(), - rawPath - ); + rawPath = path.join(resolveSimplexInboundDir(), rawPath); } let mediaPath: string | undefined; let mediaType: string | undefined; diff --git a/src/channel/events/simplex-monitor.ts b/src/channel/events/simplex-monitor.ts index b5a0df8..9976823 100644 --- a/src/channel/events/simplex-monitor.ts +++ b/src/channel/events/simplex-monitor.ts @@ -1,6 +1,7 @@ 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 { assertUniqueNativeDbPrefixes } from "../../config/accounts.js"; import { SIMPLEX_CHANNEL_ID } from "../../constants.js"; import { formatSimplexChatRef } from "../../simplex/runtime/api.js"; import { SimplexClient } from "../../simplex/runtime/client.js"; @@ -12,6 +13,7 @@ import type { SimplexChatEvent } from "../../types/simplex.js"; import { resolveSimplexMediaMaxBytes } from "../media/simplex-media.js"; import { buildAndSendSimplexMessages } from "../messaging/simplex-send.js"; import { getSimplexRuntime } from "../runtime.js"; +import { resolveSimplexBotProfile } from "../simplex-bot-profile.js"; import { connectSimplexWithRetry } from "../transport/simplex-connect.js"; import { isInboundSimplexChatItem, @@ -45,6 +47,11 @@ export async function startSimplexMonitor(params: SimplexMonitorOpts): Promise<{ client: SimplexClient; }> { const { account, cfg, runtime, statusSink } = params; + if (account.mode === "native") { + // Fail fast before opening the embedded core if another enabled native + // account would share the same database. + assertUniqueNativeDbPrefixes(cfg); + } const client = new SimplexClient({ account, logger: { @@ -52,6 +59,15 @@ export async function startSimplexMonitor(params: SimplexMonitorOpts): Promise<{ warn: (message) => runtime?.error?.(message), error: (message) => runtime?.error?.(message), }, + ...(account.mode === "native" + ? { + nativeProfileResolver: () => + resolveSimplexBotProfile({ + account, + logger: { warn: (message) => runtime?.error?.(message) }, + }), + } + : {}), }); let initialConnectComplete = false; diff --git a/src/channel/plugin.ts b/src/channel/plugin.ts index d7e17a4..8f18e0f 100644 --- a/src/channel/plugin.ts +++ b/src/channel/plugin.ts @@ -120,9 +120,8 @@ export const simplexPlugin: SimplexPlugin = { enabled: account.enabled, configured: account.configured, mode: account.mode, - application: { - wsUrl: account.wsUrl, - }, + application: + account.mode === "native" ? { db: account.db?.filePrefix } : { wsUrl: account.wsUrl }, }), }, allowlist: buildDmGroupAccountAllowlistAdapter({ diff --git a/src/channel/security/simplex-security.ts b/src/channel/security/simplex-security.ts index ac17b26..7cd07a1 100644 --- a/src/channel/security/simplex-security.ts +++ b/src/channel/security/simplex-security.ts @@ -160,21 +160,26 @@ export function collectSimplexSecurityAuditFindings(params: { }); } - const endpoint = describeSimplexWsEndpointSecurity(account.wsUrl, { - allowUnsafeRemoteWs: account.config.connection?.allowUnsafeRemoteWs, - }); - for (const warning of endpoint.warnings) { - findings.push({ - checkId: - endpoint.blockingWarnings.length > 0 - ? "simplex.ws-endpoint-blocked" - : "simplex.ws-endpoint-warning", - severity: endpoint.blockingWarnings.length > 0 ? "critical" : "warn", - title: "SimpleX WebSocket endpoint weakens transport privacy", - detail: warning, - remediation: - "Prefer ws://127.0.0.1, a private sidecar network, or wss:// behind authenticated network controls.", + // The WebSocket endpoint audit only applies to the external runtime. In + // native mode the core runs in-process, so there is no network endpoint to + // assess (account.wsUrl is just an unused default). + if (account.mode !== "native") { + const endpoint = describeSimplexWsEndpointSecurity(account.wsUrl, { + allowUnsafeRemoteWs: account.config.connection?.allowUnsafeRemoteWs, }); + for (const warning of endpoint.warnings) { + findings.push({ + checkId: + endpoint.blockingWarnings.length > 0 + ? "simplex.ws-endpoint-blocked" + : "simplex.ws-endpoint-warning", + severity: endpoint.blockingWarnings.length > 0 ? "critical" : "warn", + title: "SimpleX WebSocket endpoint weakens transport privacy", + detail: warning, + remediation: + "Prefer ws://127.0.0.1, a private sidecar network, or wss:// behind authenticated network controls.", + }); + } } return findings; diff --git a/src/channel/simplex-bot-profile.ts b/src/channel/simplex-bot-profile.ts new file mode 100644 index 0000000..1713102 --- /dev/null +++ b/src/channel/simplex-bot-profile.ts @@ -0,0 +1,137 @@ +import type { ResolvedSimplexAccount, SimplexBotProfile } from "../types/config.js"; +import { getSimplexRuntime } from "./runtime.js"; + +/** + * SimpleX embeds the avatar in the profile "info" sent to contacts, which has a + * strict size limit (the core rejects oversized profiles with "large info"). So + * the avatar is always downscaled to a small JPEG thumbnail before use. + */ +const MAX_AVATAR_BYTES = 12 * 1024; +const AVATAR_STEPS: ReadonlyArray = [ + [192, 70], + [128, 60], + [96, 50], +]; + +type ProfileLogger = { warn?: (message: string) => void }; + +/** Load the raw bytes of an avatar reference (data URI, http(s) URL, or file path). */ +async function loadImageBytes(value: string): Promise { + if (value.toLowerCase().startsWith("data:")) { + const comma = value.indexOf(","); + if (comma === -1) return undefined; + const isBase64 = /;base64/i.test(value.slice(0, comma)); + const data = value.slice(comma + 1); + return isBase64 ? Buffer.from(data, "base64") : Buffer.from(decodeURIComponent(data)); + } + const core = getSimplexRuntime(); + if (/^https?:\/\//i.test(value)) { + const fetched = await core.channel.media.readRemoteMediaBuffer({ + url: value, + maxBytes: 8 * 1024 * 1024, + filePathHint: value, + }); + return Buffer.from(fetched.buffer); + } + const { readFile } = await import("node:fs/promises"); + return await readFile(value); +} + +/** + * Resolve an avatar reference into a small base64 JPEG data URI suitable for a + * SimpleX profile. Best-effort: returns undefined (and warns) on any failure so + * profile setup never blocks — and, critically, never sends an oversized image + * that would make the whole profile update fail. + */ +async function resolveAvatarImage( + raw: string, + logger?: ProfileLogger +): Promise { + const value = raw.trim(); + if (!value) return undefined; + const core = getSimplexRuntime(); + + let source: Buffer | undefined; + try { + source = await loadImageBytes(value); + } catch (err) { + logger?.warn?.(`SimpleX avatar could not be loaded (${value}): ${String(err)}`); + return undefined; + } + if (!source || source.byteLength === 0) return undefined; + + // SimpleX renders avatars in a fixed square, so a non-square image is + // stretched. We can only scale (no crop is exposed by the runtime), so warn + // and recommend a square source instead of silently distorting it. + try { + const meta = await core.media.getImageMetadata(source); + if (meta?.width && meta?.height) { + const ratio = Math.max(meta.width, meta.height) / Math.min(meta.width, meta.height); + if (ratio > 1.05) { + logger?.warn?.( + `SimpleX avatar is not square (${meta.width}x${meta.height}); SimpleX renders avatars square, so it will appear stretched. Use a square image.` + ); + } + } + } catch { + // metadata is advisory only + } + + for (const [maxSide, quality] of AVATAR_STEPS) { + try { + const jpeg = Buffer.from( + await core.media.resizeToJpeg({ + buffer: source, + maxSide, + quality, + withoutEnlargement: true, + }) + ); + if (jpeg.byteLength <= MAX_AVATAR_BYTES) { + // SimpleX expects the "image/jpg" media type in the data URI; the + // standard "image/jpeg" label is not rendered by SimpleX clients. + return `data:image/jpg;base64,${jpeg.toString("base64")}`; + } + } catch (err) { + logger?.warn?.(`SimpleX avatar could not be processed: ${String(err)}`); + return undefined; + } + } + logger?.warn?.("SimpleX avatar is too large even after downscaling; skipping image."); + return undefined; +} + +/** + * Resolve the bot profile for a native account. + * + * A SimpleX account has exactly one identity (one database, one address, one + * profile that every contact sees), so the profile is an account-level concern. + * It is intentionally NOT derived from agent identity: multiple agents can share + * one account via routing, and agent identity is per-conversation, so it cannot + * be projected onto the single per-account profile. Set `connection.profile` + * explicitly to give the account a specific face. + * + * Precedence: + * displayName: profile.displayName -> account name -> "OpenClaw" + * image: profile.image (downscaled to a small JPEG) -> none + * peerType: profile.peerType -> "bot" + */ +export async function resolveSimplexBotProfile(params: { + account: ResolvedSimplexAccount; + logger?: ProfileLogger; +}): Promise { + const { account, logger } = params; + const configured = account.profile; + + const displayName = configured?.displayName?.trim() || account.name?.trim() || "OpenClaw"; + const fullName = configured?.fullName ?? ""; + const peerType = configured?.peerType ?? "bot"; + + let image: string | undefined; + const rawImage = configured?.image?.trim(); + if (rawImage) { + image = await resolveAvatarImage(rawImage, logger); + } + + return { displayName, fullName, peerType, ...(image ? { image } : {}) }; +} diff --git a/src/cli/plugin-cli-wrappers.ts b/src/cli/plugin-cli-wrappers.ts new file mode 100644 index 0000000..a8acca8 --- /dev/null +++ b/src/cli/plugin-cli-wrappers.ts @@ -0,0 +1,139 @@ +import type { OpenClawConfig } from "openclaw/plugin-sdk/channel-core"; +import { callGatewayFromCli } from "openclaw/plugin-sdk/gateway-runtime"; +import { resolveSimplexAccount } from "../config/accounts.js"; +import * as connectLinks from "../simplex/services/connect-links.js"; +import * as contactRequests from "../simplex/services/contact-requests.js"; +import * as groups from "../simplex/services/groups.js"; +import * as invites from "../simplex/services/invites.js"; +import * as runtimeOps from "../simplex/services/runtime-operations.js"; +import * as runtimeStatus from "../simplex/services/runtime-status.js"; + +/** + * Native-mode CLI routing. + * + * The `openclaw simplex …` CLI calls these service functions directly, which + * open their own SimpleX client. That works in `mode: "external"` (a shared + * WebSocket runtime), but in `mode: "native"` the embedded core lives inside the + * gateway process — a second connection is refused by the transport guard. + * + * These wrappers keep the exact service signatures but, for native accounts, + * dispatch to the equivalent gateway method (which runs in-gateway against the + * live core) via `callGatewayFromCli`. External accounts keep the original + * local behavior unchanged. The CLI command handlers are untouched — only their + * imports point here — and the gateway responses are supersets of the service + * results, so existing rendering (JSON + terminal QR) works for both modes. + */ + +type ServiceParams = { cfg: OpenClawConfig; accountId?: string | null }; + +function isNativeAccount(params: ServiceParams): boolean { + return ( + resolveSimplexAccount({ cfg: params.cfg, accountId: params.accountId ?? null }).mode === + "native" + ); +} + +/** Service params minus the in-process-only fields become gateway method params. */ +function toGatewayParams(params: ServiceParams): Record { + const { cfg: _cfg, logger: _logger, ...rest } = params as ServiceParams & { logger?: unknown }; + return rest as Record; +} + +function nativeAware

( + local: (params: P) => Promise, + gatewayMethod: string +): (params: P) => Promise { + return async (params: P): Promise => { + if (!isNativeAccount(params)) { + return local(params); + } + const result = await callGatewayFromCli(gatewayMethod, {}, toGatewayParams(params), { + progress: false, + }); + // Gateway responses are supersets of the service results (same fields plus + // extras like qrDataUrl), so the CLI's existing rendering works unchanged. + return result as R; + }; +} + +export const createSimplexInvite = nativeAware( + invites.createSimplexInvite, + "simplex.invite.create" +); +export const listSimplexInvites = nativeAware(invites.listSimplexInvites, "simplex.invite.list"); +export const revokeSimplexInvite = nativeAware( + invites.revokeSimplexInvite, + "simplex.invite.revoke" +); + +export const listSimplexContactRequests = nativeAware( + contactRequests.listSimplexContactRequests, + "simplex.requests.list" +); +export const acceptSimplexContactRequest = nativeAware( + contactRequests.acceptSimplexContactRequest, + "simplex.requests.accept" +); +export const rejectSimplexContactRequest = nativeAware( + contactRequests.rejectSimplexContactRequest, + "simplex.requests.reject" +); + +export const createSimplexGroup = nativeAware(groups.createSimplexGroup, "simplex.groups.create"); +export const createSimplexGroupLink = nativeAware( + groups.createSimplexGroupLink, + "simplex.groups.link.create" +); +export const listSimplexGroupLink = nativeAware( + groups.listSimplexGroupLink, + "simplex.groups.link.list" +); +export const revokeSimplexGroupLink = nativeAware( + groups.revokeSimplexGroupLink, + "simplex.groups.link.revoke" +); + +export const blockSimplexGroupMember = nativeAware( + runtimeOps.blockSimplexGroupMember, + "simplex.groups.member.block" +); +export const deleteSimplexGroupMemberMessages = nativeAware( + runtimeOps.deleteSimplexGroupMemberMessages, + "simplex.groups.member.deleteMessages" +); +export const listSimplexRuntimeUsers = nativeAware( + runtimeOps.listSimplexRuntimeUsers, + "simplex.runtime.users" +); +export const receiveSimplexFile = nativeAware( + runtimeOps.receiveSimplexFile, + "simplex.files.receive" +); +export const cancelSimplexFile = nativeAware(runtimeOps.cancelSimplexFile, "simplex.files.cancel"); +export const checkSimplexContactVerification = nativeAware( + runtimeOps.checkSimplexContactVerification, + "simplex.verification.check" +); +export const showSimplexContactVerification = nativeAware( + runtimeOps.showSimplexContactVerification, + "simplex.verification.show" +); +export const showSimplexRuntimeActiveUser = nativeAware( + runtimeOps.showSimplexRuntimeActiveUser, + "simplex.runtime.activeUser" +); + +export const doctorSimplexRuntime = nativeAware( + runtimeStatus.doctorSimplexRuntime, + "simplex.runtime.doctor" +); +export const getSimplexRuntimeStatus = nativeAware( + runtimeStatus.getSimplexRuntimeStatus, + "simplex.runtime.status" +); + +export const connectSimplexLink = nativeAware(connectLinks.connectSimplexLink, "simplex.connect"); +export const planSimplexConnectionLink = nativeAware( + connectLinks.planSimplexConnectionLink, + "simplex.connect.plan" +); diff --git a/src/cli/plugin-cli.ts b/src/cli/plugin-cli.ts index 4a19609..be05f1b 100644 --- a/src/cli/plugin-cli.ts +++ b/src/cli/plugin-cli.ts @@ -1,41 +1,35 @@ import { renderQrTerminal } from "openclaw/plugin-sdk/media-runtime"; import type { OpenClawPluginApi } from "openclaw/plugin-sdk/plugin-entry"; import { LEGACY_SIMPLEX_PLUGIN_ID, SIMPLEX_PLUGIN_ID } from "../constants.js"; -import { - connectSimplexLink, - planSimplexConnectionLink, -} from "../simplex/services/connect-links.js"; +import { runMigration } from "./migration.js"; +// Native-aware wrappers: same signatures as the underlying services, but for +// `mode: "native"` accounts they dispatch to the in-gateway gateway methods +// (the embedded core can't be opened a second time from this CLI process). +// External accounts keep the original local behavior. See ./plugin-cli-wrappers. import { acceptSimplexContactRequest, - listSimplexContactRequests, - rejectSimplexContactRequest, -} from "../simplex/services/contact-requests.js"; -import { - createSimplexGroup, - createSimplexGroupLink, - listSimplexGroupLink, - revokeSimplexGroupLink, -} from "../simplex/services/groups.js"; -import { - createSimplexInvite, - listSimplexInvites, - revokeSimplexInvite, -} from "../simplex/services/invites.js"; -import { blockSimplexGroupMember, cancelSimplexFile, checkSimplexContactVerification, + connectSimplexLink, + createSimplexGroup, + createSimplexGroupLink, + createSimplexInvite, deleteSimplexGroupMemberMessages, + doctorSimplexRuntime, + getSimplexRuntimeStatus, + listSimplexContactRequests, + listSimplexGroupLink, + listSimplexInvites, listSimplexRuntimeUsers, + planSimplexConnectionLink, receiveSimplexFile, + rejectSimplexContactRequest, + revokeSimplexGroupLink, + revokeSimplexInvite, showSimplexContactVerification, showSimplexRuntimeActiveUser, -} from "../simplex/services/runtime-operations.js"; -import { - doctorSimplexRuntime, - getSimplexRuntimeStatus, -} from "../simplex/services/runtime-status.js"; -import { runMigration } from "./migration.js"; +} from "./plugin-cli-wrappers.js"; import { type RuntimeServiceOptions, runRuntimeServiceInstallCli } from "./runtime-service.js"; export { migrateConfigObject, migrateStateFiles } from "./migration.js"; diff --git a/src/config/accounts.test.ts b/src/config/accounts.test.ts index c2d53d7..09af461 100644 --- a/src/config/accounts.test.ts +++ b/src/config/accounts.test.ts @@ -2,6 +2,7 @@ import { DEFAULT_ACCOUNT_ID } from "openclaw/plugin-sdk/account-id"; import type { OpenClawConfig } from "openclaw/plugin-sdk/channel-core"; import { describe, expect, it } from "vitest"; import { + assertUniqueNativeDbPrefixes, hasMeaningfulSimplexConfig, listSimplexAccountIds, resolveDefaultSimplexAccountId, @@ -130,6 +131,68 @@ describe("simplex accounts", () => { expect(account.wsUrl).toBe("ws://127.0.0.1:5225"); }); + it("defaults the native db file prefix under the state dir and counts as configured", () => { + const cfg = { + channels: { + "openclaw-simplex": { + connection: { + mode: "native", + }, + }, + }, + } as OpenClawConfig; + + const account = resolveSimplexAccount({ cfg, accountId: "default" }); + expect(account.mode).toBe("native"); + expect(account.configured).toBe(true); + expect(account.db?.filePrefix).toMatch(/[/\\]simplex[/\\]default$/); + }); + + it("honors an explicit native db file prefix and encryption key", () => { + const cfg = { + channels: { + "openclaw-simplex": { + connection: { + mode: "native", + db: { filePrefix: "/data/simplex-bot", encryptionKey: "secret" }, + }, + }, + }, + } as OpenClawConfig; + + const account = resolveSimplexAccount({ cfg, accountId: "default" }); + expect(account.db?.filePrefix).toBe("/data/simplex-bot"); + expect(account.db?.encryptionKey).toBe("secret"); + }); + + it("rejects two native accounts sharing the same db file prefix", () => { + const cfg = { + channels: { + "openclaw-simplex": { + accounts: { + a: { connection: { mode: "native", db: { filePrefix: "/data/shared" } } }, + b: { connection: { mode: "native", db: { filePrefix: "/data/shared" } } }, + }, + }, + }, + } as OpenClawConfig; + expect(() => assertUniqueNativeDbPrefixes(cfg)).toThrow(/unique db\.filePrefix/); + }); + + it("allows native accounts with distinct (and defaulted) db file prefixes", () => { + const cfg = { + channels: { + "openclaw-simplex": { + accounts: { + a: { connection: { mode: "native", db: { filePrefix: "/data/a" } } }, + b: { connection: { mode: "native" } }, // defaults to /simplex/b + }, + }, + }, + } as OpenClawConfig; + expect(() => assertUniqueNativeDbPrefixes(cfg)).not.toThrow(); + }); + it("does not treat an empty channel section as configured", () => { const cfg = { channels: { diff --git a/src/config/accounts.ts b/src/config/accounts.ts index 1a937d4..7aceb91 100644 --- a/src/config/accounts.ts +++ b/src/config/accounts.ts @@ -1,3 +1,5 @@ +import os from "node:os"; +import path from "node:path"; import { DEFAULT_ACCOUNT_ID, normalizeAccountId } from "openclaw/plugin-sdk/account-id"; import type { OpenClawConfig } from "openclaw/plugin-sdk/channel-core"; import { SIMPLEX_CHANNEL_ID } from "../constants.js"; @@ -7,12 +9,40 @@ import type { SimplexAccountConfig, SimplexChannelConfig } from "./config-schema const DEFAULT_WS_HOST = "127.0.0.1"; const DEFAULT_WS_PORT = 5225; +/** + * OpenClaw's state directory (mutable data), overridable via OPENCLAW_STATE_DIR, + * default ~/.openclaw — mirrors the gateway's own resolution. Used to anchor the + * default native database location so it lands beside the rest of OpenClaw's + * state regardless of the gateway's working directory. + */ +function resolveOpenClawStateDir(): string { + const override = process.env.OPENCLAW_STATE_DIR?.trim(); + if (override) { + return override; + } + return path.join(os.homedir(), ".openclaw"); +} + +/** + * Default native db file prefix for an account. The core creates + * `_chat.db` and `_agent.db`, so we namespace per account under + * the state dir to keep multiple accounts isolated. + */ +function resolveDefaultNativeFilePrefix(accountId: string): string { + return path.join(resolveOpenClawStateDir(), "simplex", accountId || DEFAULT_ACCOUNT_ID); +} + function hasMeaningfulConnectionConfig(connection: SimplexConnectionConfig | undefined): boolean { if (!connection) { return false; } return Boolean( - connection.wsUrl?.trim() || connection.wsHost?.trim() || connection.wsPort !== undefined + connection.wsUrl?.trim() || + connection.wsHost?.trim() || + connection.wsPort !== undefined || + // Native mode is self-configuring: the database path defaults under the + // OpenClaw state dir, so selecting the mode is enough. + connection.mode === "native" ); } @@ -110,18 +140,30 @@ export function resolveSimplexAccount(params: { const baseEnabled = params.cfg.channels?.[SIMPLEX_CHANNEL_ID]?.enabled !== false; const enabled = baseEnabled && merged.enabled !== false; const connection = merged.connection ?? {}; + const mode = connection.mode === "native" ? "native" : "external"; const wsUrl = resolveWsUrl(connection); const wsHost = resolveWsHost(connection); const wsPort = resolveWsPort(connection); + const db = connection.db?.filePrefix?.trim() + ? { + filePrefix: connection.db.filePrefix.trim(), + ...(connection.db.encryptionKey ? { encryptionKey: connection.db.encryptionKey } : {}), + } + : mode === "native" + ? { filePrefix: resolveDefaultNativeFilePrefix(accountId) } + : undefined; return { accountId, enabled, name: merged.name?.trim() || undefined, configured: hasMeaningfulConfig, - mode: "external", + mode, wsUrl, wsHost, wsPort, + ...(db ? { db } : {}), + ...(connection.profile ? { profile: connection.profile } : {}), + ...(connection.addressSettings ? { addressSettings: connection.addressSettings } : {}), config: merged, }; } @@ -131,3 +173,32 @@ export function listEnabledSimplexAccounts(cfg: OpenClawConfig): ResolvedSimplex .map((accountId) => resolveSimplexAccount({ cfg, accountId })) .filter((account) => account.enabled); } + +/** + * Guard against two enabled native accounts resolving to the same db.filePrefix. + * The embedded core cannot be opened twice on the same SQLite database, so a + * shared prefix would corrupt or lock it. Throws with the conflicting accounts. + */ +export function assertUniqueNativeDbPrefixes(cfg: OpenClawConfig): void { + const accountsByPrefix = new Map(); + for (const account of listEnabledSimplexAccounts(cfg)) { + const prefix = account.mode === "native" ? account.db?.filePrefix : undefined; + if (!prefix) { + continue; + } + const ids = accountsByPrefix.get(prefix) ?? []; + ids.push(account.accountId); + accountsByPrefix.set(prefix, ids); + } + const conflicts = [...accountsByPrefix.entries()].filter(([, ids]) => ids.length > 1); + if (conflicts.length === 0) { + return; + } + const detail = conflicts + .map(([prefix, ids]) => `${prefix} (accounts: ${ids.join(", ")})`) + .join("; "); + throw new Error( + `SimpleX native accounts must each use a unique db.filePrefix — the embedded core ` + + `cannot be opened twice on the same database. Conflicting prefixes: ${detail}` + ); +} diff --git a/src/config/config-schema.ts b/src/config/config-schema.ts index 66e1e59..fbf2025 100644 --- a/src/config/config-schema.ts +++ b/src/config/config-schema.ts @@ -29,9 +29,34 @@ const SimplexActionConfigSchema = z const SimplexReactionLevelSchema = z.enum(["off", "ack", "minimal", "extensive"]).optional(); +const SimplexNativeDbSchema = z + .object({ + type: z.literal("sqlite").optional(), + filePrefix: z.string().min(1), + encryptionKey: z.string().optional(), + }) + .strict(); + +const SimplexNativeProfileSchema = z + .object({ + displayName: z.string().min(1).optional(), + fullName: z.string().optional(), + image: z.string().optional(), + peerType: z.enum(["bot", "human"]).optional(), + }) + .strict(); + +const SimplexNativeAddressSettingsSchema = z + .object({ + autoAccept: z.boolean().optional(), + welcomeMessage: z.string().optional(), + businessAddress: z.boolean().optional(), + }) + .strict(); + const SimplexConnectionSchema = z .object({ - mode: z.literal("external").optional(), + mode: z.enum(["external", "native"]).optional(), wsUrl: z.string().url().optional(), wsHost: z.string().optional(), wsPort: z.number().int().positive().optional(), @@ -40,6 +65,10 @@ const SimplexConnectionSchema = z connectTimeoutMs: z.number().int().positive().optional(), commandTimeoutMs: z.number().int().positive().optional(), directoryTimeoutMs: z.number().int().positive().optional(), + // native mode (embedded simplex-chat core) + db: SimplexNativeDbSchema.optional(), + profile: SimplexNativeProfileSchema.optional(), + addressSettings: SimplexNativeAddressSettingsSchema.optional(), }) .strict(); diff --git a/src/simplex/runtime/client.ts b/src/simplex/runtime/client.ts index 8a5350b..5395378 100644 --- a/src/simplex/runtime/client.ts +++ b/src/simplex/runtime/client.ts @@ -1,4 +1,4 @@ -import type { ResolvedSimplexAccount } from "../../types/config.js"; +import type { ResolvedSimplexAccount, SimplexBotProfile } from "../../types/config.js"; import type { SimplexComposedMessage, SimplexDeleteMode, @@ -38,6 +38,7 @@ import { buildUpdateGroupProfileCommand, INVITE_COMMANDS, } from "./commands.js"; +import { SimplexCoreClient } from "./core-client.js"; import { extractSimplexLink } from "./links.js"; import { readSimplexArrayField, @@ -46,11 +47,14 @@ import { unwrapSimplexCommandResponse, } from "./responses.js"; import { assertSimplexWsEndpointAllowed } from "./security.js"; -import { type SimplexConnectionState, SimplexWsClient } from "./ws-client.js"; +import type { SimplexConnectionState, SimplexTransport } from "./transport-types.js"; +import { SimplexWsClient } from "./ws-client.js"; type SimplexClientParams = { account: ResolvedSimplexAccount; logger?: SimplexLogger; + /** Native mode only: resolves the bot profile to apply at connect time. */ + nativeProfileResolver?: () => Promise; }; type SimplexCommandOptions = { @@ -58,19 +62,27 @@ type SimplexCommandOptions = { }; export class SimplexClient { - private readonly ws: SimplexWsClient; + private readonly ws: SimplexTransport; constructor(params: SimplexClientParams) { - assertSimplexWsEndpointAllowed({ - wsUrl: params.account.wsUrl, - allowUnsafeRemoteWs: params.account.config.connection?.allowUnsafeRemoteWs, - }); - this.ws = new SimplexWsClient({ - url: params.account.wsUrl, - connectTimeoutMs: params.account.config.connection?.connectTimeoutMs, - commandTimeoutMs: params.account.config.connection?.commandTimeoutMs, - logger: params.logger, - }); + if (params.account.mode === "native") { + this.ws = new SimplexCoreClient({ + account: params.account, + logger: params.logger, + ...(params.nativeProfileResolver ? { profileResolver: params.nativeProfileResolver } : {}), + }); + } else { + assertSimplexWsEndpointAllowed({ + wsUrl: params.account.wsUrl, + allowUnsafeRemoteWs: params.account.config.connection?.allowUnsafeRemoteWs, + }); + this.ws = new SimplexWsClient({ + url: params.account.wsUrl, + connectTimeoutMs: params.account.config.connection?.connectTimeoutMs, + commandTimeoutMs: params.account.config.connection?.commandTimeoutMs, + logger: params.logger, + }); + } } onEvent(handler: (event: SimplexRuntimeEvent) => void): () => void { diff --git a/src/simplex/runtime/core-client.test.ts b/src/simplex/runtime/core-client.test.ts new file mode 100644 index 0000000..6143e4a --- /dev/null +++ b/src/simplex/runtime/core-client.test.ts @@ -0,0 +1,238 @@ +import { beforeEach, describe, expect, it, vi } from "vitest"; +import type { ResolvedSimplexAccount } from "../../types/config.js"; +import { SimplexCoreClient } from "./core-client.js"; + +// Shared, hoisted mock state for the (optional) native package. +const h = vi.hoisted(() => { + const calls: string[] = []; + const chat = { + started: false, + handlers: [] as ((e: unknown) => void)[], + user: undefined as + | { + userId: number; + profile?: { + displayName?: string; + fullName?: string; + image?: string; + peerType?: "bot" | "human"; + }; + } + | undefined, + address: undefined as + | { autoAccept?: boolean; autoReply?: unknown; businessAddress?: boolean } + | undefined, + onAny(handler: (e: unknown) => void) { + this.handlers.push(handler); + }, + async startChat() { + calls.push("startChat"); + this.started = true; + }, + async stopChat() { + calls.push("stopChat"); + }, + async close() { + calls.push("close"); + }, + async sendChatCmd(cmd: string) { + calls.push(`cmd:${cmd}`); + return { type: "cmdOk", echoed: cmd }; + }, + async apiGetActiveUser() { + return this.user; + }, + async apiCreateActiveUser(profile: Record) { + calls.push("createUser"); + this.user = { userId: 1, profile: { ...(profile as Record) } }; + return this.user; + }, + async apiUpdateProfile(_userId: number, profile: Record) { + calls.push("updateProfile"); + if (this.user) { + this.user.profile = { ...(profile as Record) }; + } + return {}; + }, + async apiGetUserAddress() { + return this.address; + }, + async apiCreateUserAddress() { + calls.push("createAddress"); + // Fresh addresses are not auto-accepting until settings are applied. + this.address = { autoAccept: false }; + }, + async apiSetAddressSettings( + _userId: number, + settings: { autoAccept?: boolean; welcomeMessage?: unknown; businessAddress?: boolean } + ) { + calls.push("setAddress"); + this.address = { + autoAccept: settings.autoAccept, + autoReply: settings.welcomeMessage, + businessAddress: settings.businessAddress, + }; + }, + }; + return { calls, chat }; +}); + +vi.mock("simplex-chat", () => ({ + api: { + ChatApi: { + init: vi.fn(async () => { + h.calls.push("init"); + return h.chat; + }), + }, + }, + core: { MigrationConfirmation: { YesUp: "yesUp" } }, + util: { + contactAddressStr: () => "https://smp.example/a#addr", + botAddressSettings: (address: { + autoAccept?: boolean; + autoReply?: unknown; + businessAddress?: boolean; + }) => ({ + autoAccept: Boolean(address?.autoAccept), + welcomeMessage: address?.autoReply, + businessAddress: address?.businessAddress, + }), + }, +})); + +function nativeAccount(overrides: Partial = {}): ResolvedSimplexAccount { + return { + accountId: "default", + enabled: true, + configured: true, + mode: "native", + wsUrl: "ws://127.0.0.1:5225", + wsHost: "127.0.0.1", + wsPort: 5225, + db: { filePrefix: "./test_bot" }, + config: {}, + ...overrides, + } as ResolvedSimplexAccount; +} + +describe("SimplexCoreClient", () => { + beforeEach(() => { + h.calls.length = 0; + h.chat.started = false; + h.chat.handlers.length = 0; + h.chat.user = undefined; + h.chat.address = undefined; + }); + + it("boots the core in the required order: init -> create user -> startChat -> create address", async () => { + const client = new SimplexCoreClient({ account: nativeAccount() }); + await client.connect(); + expect(h.calls).toEqual(["init", "createUser", "startChat", "createAddress", "setAddress"]); + }); + + it("reports connected state after connect", async () => { + const client = new SimplexCoreClient({ account: nativeAccount() }); + const states: boolean[] = []; + client.onConnectionState((s) => states.push(s.connected)); + await client.connect(); + expect(client.getConnectionState().connected).toBe(true); + expect(states.at(-1)).toBe(true); + }); + + it("wraps native responses in the { resp } envelope", async () => { + const client = new SimplexCoreClient({ account: nativeAccount() }); + await client.connect(); + const res = await client.sendCommand("/contacts"); + expect(res).toEqual({ resp: { type: "cmdOk", echoed: "/contacts" } }); + }); + + it("fans core events out to subscribers", async () => { + const client = new SimplexCoreClient({ account: nativeAccount() }); + const received: unknown[] = []; + client.onEvent((e) => received.push(e)); + await client.connect(); + h.chat.handlers[0]?.({ type: "newChatItems", chatItems: [] }); + h.chat.handlers[0]?.({ notAnEvent: true }); // ignored (no string type) + expect(received).toEqual([{ type: "newChatItems", chatItems: [] }]); + }); + + it("does not re-create or re-configure when user, profile, and address already match", async () => { + // Fallback desired profile (no resolver, no config) is displayName "OpenClaw", + // and default address settings are autoAccept=true, businessAddress=false. + h.chat.user = { + userId: 1, + profile: { displayName: "OpenClaw", fullName: "", peerType: "bot" }, + }; + h.chat.address = { autoAccept: true, businessAddress: false }; + const client = new SimplexCoreClient({ account: nativeAccount() }); + await client.connect(); + expect(h.calls).not.toContain("createUser"); + expect(h.calls).not.toContain("createAddress"); + expect(h.calls).not.toContain("updateProfile"); + expect(h.calls).not.toContain("setAddress"); + expect(h.calls).toContain("startChat"); + }); + + it("applies address settings on a fresh address, then skips them once they match", async () => { + const client = new SimplexCoreClient({ account: nativeAccount() }); + await client.connect(); + // Fresh address (autoAccept=false) drifts from desired (autoAccept=true) -> set once. + expect(h.calls).toContain("createAddress"); + expect(h.calls).toContain("setAddress"); + + // Second connect on the now-configured address must not re-set. + h.calls.length = 0; + h.chat.started = false; + await client.connect(); + expect(h.calls).not.toContain("setAddress"); + }); + + it("applies the resolved profile (name, peerType, image) when creating the user", async () => { + const client = new SimplexCoreClient({ + account: nativeAccount(), + profileResolver: async () => ({ + displayName: "Custom Bot", + fullName: "Custom", + peerType: "human", + image: "data:image/png;base64,AAAA", + }), + }); + await client.connect(); + expect(h.calls).toContain("createUser"); + expect(h.calls).not.toContain("updateProfile"); + expect(h.chat.user?.profile).toMatchObject({ + displayName: "Custom Bot", + fullName: "Custom", + peerType: "human", + image: "data:image/png;base64,AAAA", + }); + }); + + it("updates the profile when it drifts from the resolved desired profile", async () => { + h.chat.user = { + userId: 1, + profile: { displayName: "old name", fullName: "", peerType: "bot" }, + }; + h.chat.address = {}; + const client = new SimplexCoreClient({ + account: nativeAccount(), + profileResolver: async () => ({ displayName: "new name", fullName: "", peerType: "bot" }), + }); + await client.connect(); + expect(h.calls).not.toContain("createUser"); + expect(h.calls).toContain("updateProfile"); + expect(h.chat.user?.profile?.displayName).toBe("new name"); + }); + + it("defaults peerType to bot when the resolver omits it via fallback", async () => { + const client = new SimplexCoreClient({ account: nativeAccount() }); + await client.connect(); + expect(h.chat.user?.profile?.peerType).toBe("bot"); + }); + + it("throws when native mode has no db.filePrefix", async () => { + const client = new SimplexCoreClient({ account: nativeAccount({ db: undefined }) }); + await expect(client.connect()).rejects.toThrow(/no db\.filePrefix/); + }); +}); diff --git a/src/simplex/runtime/core-client.ts b/src/simplex/runtime/core-client.ts new file mode 100644 index 0000000..db608de --- /dev/null +++ b/src/simplex/runtime/core-client.ts @@ -0,0 +1,344 @@ +import type { ResolvedSimplexAccount, SimplexBotProfile } from "../../types/config.js"; +import type { + SimplexLogger, + SimplexRuntimeEvent, + SimplexRuntimeResponse, +} from "../../types/simplex.js"; +import type { SimplexConnectionState, SimplexTransport } from "./transport-types.js"; + +/** + * Embedded native SimpleX core transport. + * + * Backs `SimplexClient` when an account uses `mode: "native"`, replacing the + * external WebSocket runtime with the in-process `simplex-chat` native core. + * `ChatApi.sendChatCmd(cmd)` and its event stream speak the same string-command + * / JSON-event protocol the WS runtime did, so everything above this transport + * is unchanged. + * + * `simplex-chat` is an OPTIONAL dependency (it is AGPL-3.0; this plugin is MIT), + * loaded lazily via dynamic import only when native mode is actually used, so + * default installs never fetch or load it. The module specifier is held in a + * variable so the type checker does not require the package to be installed. + */ + +// Minimal shape of the bits of the native package this transport uses. +interface NativeChatApi { + readonly started: boolean; + onAny(handler: (event: unknown) => void): void; + startChat(): Promise; + stopChat(): Promise; + close(): Promise; + sendChatCmd(cmd: string): Promise; + apiGetActiveUser(): Promise< + | { + userId: number; + profile?: { + displayName?: string; + fullName?: string; + image?: string; + peerType?: "bot" | "human"; + }; + } + | undefined + >; + apiCreateActiveUser(profile: Record): Promise<{ userId: number }>; + apiUpdateProfile(userId: number, profile: Record): Promise; + apiGetUserAddress(userId: number): Promise<{ connLinkContact?: unknown } | undefined>; + apiCreateUserAddress(userId: number): Promise; + apiSetAddressSettings( + userId: number, + settings: { autoAccept?: boolean; welcomeMessage?: unknown; businessAddress?: boolean } + ): Promise; +} + +interface NativeModule { + api: { ChatApi: { init(db: unknown, confirm: unknown): Promise } }; + core: { MigrationConfirmation: { YesUp: unknown } }; + util: { + contactAddressStr(link: unknown): string; + botAddressSettings(link: unknown): { + autoAccept?: boolean; + welcomeMessage?: unknown; + businessAddress?: boolean; + }; + }; +} + +/** Extract the plain text of a welcome message (stored as MsgContent or a string). */ +function welcomeMessageText(welcome: unknown): string | undefined { + if (typeof welcome === "string") return welcome || undefined; + if (welcome && typeof welcome === "object" && "text" in welcome) { + const text = (welcome as { text?: unknown }).text; + return typeof text === "string" ? text || undefined : undefined; + } + return undefined; +} + +async function loadNativeModule(): Promise { + // Non-literal specifier: keeps tsc from requiring the optional package at + // build time, and surfaces a friendly error if it is missing at runtime. + const moduleName = "simplex-chat"; + try { + return (await import(moduleName)) as unknown as NativeModule; + } catch (err) { + throw new Error( + 'SimpleX mode "native" requires the optional dependency "simplex-chat" ' + + "(AGPL-3.0). Install it in the plugin's project (`npm install simplex-chat`) on a platform with a " + + `prebuilt or buildable native addon. Original error: ${String(err)}` + ); + } +} + +type SimplexCoreClientParams = { + account: ResolvedSimplexAccount; + logger?: SimplexLogger; + /** + * Resolves the desired bot profile (display name, avatar, peer type) at + * connect time. Supplied by the monitor, which has the config + runtime to + * derive defaults from agent identity. Falls back to config-only when absent. + */ + profileResolver?: () => Promise; +}; + +export class SimplexCoreClient implements SimplexTransport { + private readonly account: ResolvedSimplexAccount; + private readonly logger?: SimplexLogger; + private readonly profileResolver?: () => Promise; + private chat: NativeChatApi | undefined; + private connectPromise: Promise | null = null; + private readonly eventHandlers = new Set<(event: SimplexRuntimeEvent) => void>(); + private readonly connectionHandlers = new Set<(state: SimplexConnectionState) => void>(); + private lastConnectionState: SimplexConnectionState = { + connected: false, + at: Date.now(), + expected: true, + error: null, + }; + + constructor(params: SimplexCoreClientParams) { + this.account = params.account; + this.logger = params.logger; + this.profileResolver = params.profileResolver; + } + + onEvent(handler: (event: SimplexRuntimeEvent) => void): () => void { + this.eventHandlers.add(handler); + return () => { + this.eventHandlers.delete(handler); + }; + } + + onConnectionState(handler: (state: SimplexConnectionState) => void): () => void { + this.connectionHandlers.add(handler); + return () => { + this.connectionHandlers.delete(handler); + }; + } + + getConnectionState(): SimplexConnectionState { + return { ...this.lastConnectionState }; + } + + async connect(): Promise { + if (this.chat?.started) { + return; + } + if (this.connectPromise) { + await this.connectPromise; + return; + } + const attempt = (async () => { + try { + const db = this.account.db; + if (!db?.filePrefix) { + throw new Error( + `SimpleX account "${this.account.accountId}" uses mode "native" but has no db.filePrefix configured` + ); + } + const { api, core, util } = await loadNativeModule(); + const chat = await api.ChatApi.init( + { type: "sqlite", filePrefix: db.filePrefix, encryptionKey: db.encryptionKey }, + core.MigrationConfirmation.YesUp + ); + // Fan every core event out to subscribers (same role as WS "message"). + chat.onAny((event) => this.handleEvent(event as SimplexRuntimeEvent)); + this.chat = chat; + // The native core requires an active user to EXIST before startChat() + // (it errors with `noActiveUser` otherwise); profile updates and the + // address can only happen once the core is running. + const desired = await this.resolveDesiredProfile(); + this.logger?.info?.( + `SimpleX bot profile resolved: "${desired.displayName}" (peerType: ${desired.peerType}, image: ${desired.image ? "yes" : "none"})` + ); + await this.ensureUser(desired); + await chat.startChat(); + await this.reconcileProfile(desired); + await this.ensureAddress(util); + this.logger?.info?.(`SimpleX native core started (db: ${db.filePrefix})`); + this.emitConnectionState({ connected: true, at: Date.now(), error: null }); + } catch (err) { + const error = err instanceof Error ? err : new Error(String(err)); + this.chat = undefined; + this.emitConnectionState({ + connected: false, + at: Date.now(), + expected: false, + error: error.message, + }); + throw error; + } + })(); + const inFlight = attempt.finally(() => { + if (this.connectPromise === inFlight) { + this.connectPromise = null; + } + }); + this.connectPromise = inFlight; + await inFlight; + } + + async close(): Promise { + const chat = this.chat; + this.chat = undefined; + if (chat) { + await chat.stopChat().catch(() => undefined); + await chat.close().catch(() => undefined); + } + this.emitConnectionState({ connected: false, at: Date.now(), expected: true, error: null }); + } + + /** + * Drop-in for `SimplexWsClient.sendCommand`: wraps the native `ChatResponse` + * in the `{ resp }` envelope `unwrapSimplexCommandResponse` expects. + */ + async sendCommand(cmd: string): Promise { + if (!this.chat?.started) { + await this.connect(); + } + if (!this.chat) { + throw new Error("SimpleX native core not connected"); + } + const resp = (await this.chat.sendChatCmd(cmd)) as { type: string; [key: string]: unknown }; + return { resp }; + } + + /** Desired profile, from the injected resolver or a config-only fallback. */ + private async resolveDesiredProfile(): Promise { + if (this.profileResolver) { + try { + return await this.profileResolver(); + } catch (err) { + this.logger?.warn?.(`SimpleX profile resolution failed, using fallback: ${String(err)}`); + } + } + const p = this.account.profile; + // The avatar is intentionally omitted from this fallback: image handling + // (load, downscale, re-encode) lives in the profile resolver, so the + // config-only path never sends a raw or oversized image. + return { + displayName: p?.displayName?.trim() || this.account.name?.trim() || "OpenClaw", + fullName: p?.fullName ?? "", + peerType: p?.peerType ?? "bot", + }; + } + + private profileFields(profile: SimplexBotProfile): Record { + return { + displayName: profile.displayName, + fullName: profile.fullName, + peerType: profile.peerType, + ...(profile.image ? { image: profile.image } : {}), + }; + } + + /** Create the active user if none exists. Must run BEFORE startChat(). */ + private async ensureUser(desired: SimplexBotProfile): Promise { + if (!this.chat) return; + const existing = await this.chat.apiGetActiveUser(); + if (existing) return; + await this.chat.apiCreateActiveUser(this.profileFields(desired)); + } + + /** Update the profile to match desired if it has drifted. Must run AFTER startChat(). */ + private async reconcileProfile(desired: SimplexBotProfile): Promise { + if (!this.chat) return; + const user = await this.chat.apiGetActiveUser(); + if (!user) return; + const cur = user.profile; + const drifted = + (cur?.displayName ?? "") !== desired.displayName || + (cur?.fullName ?? "") !== desired.fullName || + (cur?.image ?? undefined) !== (desired.image ?? undefined) || + (cur?.peerType ?? "bot") !== desired.peerType; + if (drifted) { + await this.chat.apiUpdateProfile(user.userId, this.profileFields(desired)); + this.logger?.info?.(`SimpleX bot profile updated (${desired.displayName})`); + } + } + + private async ensureAddress(util: NativeModule["util"]): Promise { + if (!this.chat) return; + const user = await this.chat.apiGetActiveUser(); + if (!user) return; + let address = await this.chat.apiGetUserAddress(user.userId); + if (!address) { + await this.chat.apiCreateUserAddress(user.userId); + address = await this.chat.apiGetUserAddress(user.userId); + } + const settings = this.account.addressSettings; + const desired = { + autoAccept: settings?.autoAccept ?? true, + welcomeMessage: settings?.welcomeMessage, + businessAddress: settings?.businessAddress ?? false, + }; + // Only update settings when they differ from what's stored, mirroring the + // library's bot.run, so we don't make an interactive network call on every + // restart. Welcome message is compared by text (it is stored as MsgContent). + let current: ReturnType | undefined; + if (address) { + try { + current = util.botAddressSettings(address); + } catch { + current = undefined; + } + } + const drifted = + !current || + (current.autoAccept ?? true) !== desired.autoAccept || + (current.businessAddress ?? false) !== desired.businessAddress || + welcomeMessageText(current.welcomeMessage) !== welcomeMessageText(desired.welcomeMessage); + if (drifted) { + await this.chat.apiSetAddressSettings(user.userId, desired); + this.logger?.info?.("SimpleX bot address settings updated"); + } + if (address?.connLinkContact) { + try { + this.logger?.info?.( + `SimpleX bot address: ${util.contactAddressStr(address.connLinkContact)}` + ); + } catch { + // address link formatting is best-effort + } + } + } + + private handleEvent(event: SimplexRuntimeEvent): void { + if (!event || typeof event.type !== "string") { + return; + } + for (const handler of this.eventHandlers) { + try { + handler(event); + } catch (err) { + this.logger?.error?.(`SimpleX native event handler error: ${String(err)}`); + } + } + } + + private emitConnectionState(state: SimplexConnectionState): void { + this.lastConnectionState = { ...state }; + for (const handler of this.connectionHandlers) { + handler(state); + } + } +} diff --git a/src/simplex/runtime/transport-types.ts b/src/simplex/runtime/transport-types.ts new file mode 100644 index 0000000..4bfb8a0 --- /dev/null +++ b/src/simplex/runtime/transport-types.ts @@ -0,0 +1,30 @@ +import type { SimplexRuntimeEvent, SimplexRuntimeResponse } from "../../types/simplex.js"; + +/** + * Connection state reported by a transport. For the external WebSocket runtime + * this tracks socket open/close; for the embedded native core it tracks + * core started/stopped (native has no socket to drop, so it never emits an + * unexpected disconnect). + */ +export type SimplexConnectionState = { + connected: boolean; + at: number; + expected?: boolean; + error?: string | null; +}; + +/** + * Common surface shared by every SimpleX transport. `SimplexClient` depends + * only on this interface, so the underlying transport (external WebSocket + * runtime via `SimplexWsClient`, or embedded native core via + * `SimplexCoreClient`) can be selected by account mode without any change to + * the command/event layers above it. + */ +export interface SimplexTransport { + onEvent(handler: (event: SimplexRuntimeEvent) => void): () => void; + onConnectionState(handler: (state: SimplexConnectionState) => void): () => void; + getConnectionState(): SimplexConnectionState; + connect(): Promise; + close(): Promise; + sendCommand(cmd: string, timeoutMs?: number): Promise; +} diff --git a/src/simplex/runtime/transport.test.ts b/src/simplex/runtime/transport.test.ts index 9515bf3..048e64e 100644 --- a/src/simplex/runtime/transport.test.ts +++ b/src/simplex/runtime/transport.test.ts @@ -34,6 +34,7 @@ import type { SimplexClient } from "./client.js"; import { getActiveSimplexClient, registerActiveSimplexClient, + SimplexNativeCoreUnavailableError, unregisterActiveSimplexClient, withSimplexClient, } from "./transport.js"; @@ -61,6 +62,24 @@ function account( }; } +function nativeAccount( + accountId = "native", + filePrefix = `/tmp/${accountId}` +): ResolvedSimplexAccount { + return { + accountId, + name: accountId, + enabled: true, + configured: true, + mode: "native", + wsUrl: "ws://127.0.0.1:5225", + wsHost: "127.0.0.1", + wsPort: 5225, + db: { filePrefix }, + config: { connection: { mode: "native", db: { filePrefix } } }, + }; +} + function activeClient(source: string): TestSimplexClient { return { source, @@ -171,4 +190,30 @@ describe("simplex runtime transport", () => { expect(clientMock.constructed).toBe(1); expect(clientMock.instances[0]).toMatchObject({ connected: 1, closed: 1 }); }); + + it("refuses to open a transient core for native mode without an active client", async () => { + const cfg = nativeAccount("native-guard"); + + await expect( + withSimplexClient({ account: cfg, run: async () => "ran" }) + ).rejects.toBeInstanceOf(SimplexNativeCoreUnavailableError); + + // Critically, it must NOT construct a second embedded core. + expect(clientMock.constructed).toBe(0); + }); + + it("uses the registered active client for native mode instead of a new core", async () => { + const cfg = nativeAccount("native-active"); + const client = activeClient("active-native"); + registeredAccounts.push({ account: cfg, client }); + await registerActiveSimplexClient(cfg, client); + + const result = await withSimplexClient({ + account: cfg, + run: async (runtimeClient) => (runtimeClient as TestSimplexClient).source, + }); + + expect(result).toBe("active-native"); + expect(clientMock.constructed).toBe(0); + }); }); diff --git a/src/simplex/runtime/transport.ts b/src/simplex/runtime/transport.ts index 5864a94..de396aa 100644 --- a/src/simplex/runtime/transport.ts +++ b/src/simplex/runtime/transport.ts @@ -2,12 +2,39 @@ import type { ResolvedSimplexAccount } from "../../types/config.js"; import type { SimplexLogger } from "../../types/simplex.js"; import { SimplexClient } from "./client.js"; +/** + * Thrown when a native-mode operation needs the embedded core but no active + * client is registered in this process. Opening a transient second core would + * mean two connections to the same SQLite database (e.g. a CLI/status probe + * running while the gateway holds the core), which can lock or corrupt it. The + * embedded core is owned by the running gateway, so out-of-process callers must + * not spin up their own. + */ +export class SimplexNativeCoreUnavailableError extends Error { + readonly accountId: string; + constructor(account: ResolvedSimplexAccount) { + super( + `SimpleX native core for account "${account.accountId}" is not running in this process. ` + + "The embedded core is owned by the gateway; opening a second connection to " + + `${account.db?.filePrefix ?? "the database"} could lock or corrupt it. ` + + "Run this while the channel is active in the gateway process." + ); + this.name = "SimplexNativeCoreUnavailableError"; + this.accountId = account.accountId; + } +} + type SharedSimplexClientKey = `${string}|${number}|${number}`; const activeSimplexClients = new Map(); const activeSimplexClientsByKey = new Map(); function sharedClientKey(account: ResolvedSimplexAccount): SharedSimplexClientKey { + if (account.mode === "native") { + // One embedded core per database; two native accounts with distinct DBs + // must not share a client (and must never open the same SQLite twice). + return `native|${account.db?.filePrefix ?? account.accountId}|0` as SharedSimplexClientKey; + } const timeoutMs = account.config.connection?.connectTimeoutMs ?? 15_000; const commandTimeoutMs = account.config.connection?.commandTimeoutMs ?? 20_000; return `${account.wsUrl}|${timeoutMs}|${commandTimeoutMs}`; @@ -56,6 +83,10 @@ export async function withSimplexClient(params: { if (sharedActiveClient) { return await params.run(sharedActiveClient); } + // Native mode: never open a transient second core against the same database. + if (params.account.mode === "native") { + throw new SimplexNativeCoreUnavailableError(params.account); + } const client = new SimplexClient({ account: params.account, logger: params.logger }); try { await client.connect(); diff --git a/src/simplex/runtime/ws-client.ts b/src/simplex/runtime/ws-client.ts index a119427..24979c3 100644 --- a/src/simplex/runtime/ws-client.ts +++ b/src/simplex/runtime/ws-client.ts @@ -5,13 +5,9 @@ import type { SimplexRuntimeEvent, SimplexRuntimeResponse, } from "../../types/simplex.js"; +import type { SimplexConnectionState, SimplexTransport } from "./transport-types.js"; -export type SimplexConnectionState = { - connected: boolean; - at: number; - expected?: boolean; - error?: string | null; -}; +export type { SimplexConnectionState } from "./transport-types.js"; type SimplexWsClientOptions = { url: string; @@ -27,7 +23,7 @@ type PendingCommand = { timeout: NodeJS.Timeout; }; -export class SimplexWsClient { +export class SimplexWsClient implements SimplexTransport { private readonly url: string; private readonly connectTimeoutMs: number; private readonly commandTimeoutMs: number; diff --git a/src/simplex/services/runtime-capabilities.ts b/src/simplex/services/runtime-capabilities.ts index d0fdf92..28e8bf9 100644 --- a/src/simplex/services/runtime-capabilities.ts +++ b/src/simplex/services/runtime-capabilities.ts @@ -1,7 +1,7 @@ import type { ResolvedSimplexAccount } from "../../types/config.js"; import { readSimplexRuntimeVersion } from "../runtime/account.js"; import type { SimplexClient } from "../runtime/client.js"; -import { withSimplexClient } from "../runtime/transport.js"; +import { SimplexNativeCoreUnavailableError, withSimplexClient } from "../runtime/transport.js"; import { isSimplexEmptyRuntimeListError, readSimplexActiveUserInfo, @@ -453,16 +453,52 @@ async function collectWithClient( }; } +/** All-unknown report, used when the runtime can't be probed (e.g. native core not accessible). */ +function unavailableCapabilityReport(detail: string): SimplexRuntimeCapabilityReport { + const base = { runtimeVersion: null as string | null, detail }; + return { + runtimeVersion: null, + version: valueProbe("unknown", { ...base, value: null }), + activeUser: valueProbe("unknown", { ...base, value: null }), + users: countProbe("unknown", { ...base, count: null }), + contacts: countProbe("unknown", { ...base, count: null }), + groups: countProbe("unknown", { ...base, count: null }), + liveMessages: probe("unknown", base), + ttl: probe("unknown", base), + verification: probe("unknown", base), + moderation: probe("unknown", base), + files: probe("unknown", base), + experimentalChannels: probe("unknown", base), + }; +} + export async function probeSimplexRuntimeCapabilities( params: SimplexRuntimeCapabilityProbeOptions ): Promise { if (params.client) { return await collectWithClient({ ...params, client: params.client }); } - return await withSimplexClient({ - account: params.account, - run: async (client) => await collectWithClient({ ...params, client }), - }); + try { + return await withSimplexClient({ + account: params.account, + run: async (client) => await collectWithClient({ ...params, client }), + }); + } catch (err) { + if (err instanceof SimplexNativeCoreUnavailableError) { + // Native core is owned by the gateway; an out-of-process probe can't + // open a second connection. Report capabilities as unknown rather than + // failing the status read. + return { + activeUser: null, + address: null, + contacts: [], + groups: [], + users: [], + capabilities: unavailableCapabilityReport(err.message), + }; + } + throw err; + } } export function collectSimplexCapabilityIssues(params: { diff --git a/src/types/config.ts b/src/types/config.ts index 61a8798..4f8b861 100644 --- a/src/types/config.ts +++ b/src/types/config.ts @@ -1,7 +1,39 @@ import type { SimplexAccountConfig } from "../config/config-schema.js"; +export type SimplexConnectionMode = "external" | "native"; + +/** Fully-resolved bot profile applied to the embedded native user. */ +export type SimplexBotProfile = { + displayName: string; + fullName: string; + /** base64 data URI, or undefined for no avatar. */ + image?: string; + peerType: "bot" | "human"; +}; + +export type SimplexNativeDbConfig = { + type?: "sqlite"; + filePrefix: string; + encryptionKey?: string; +}; + +export type SimplexNativeProfileConfig = { + displayName?: string; + fullName?: string; + /** Avatar: data URI, http(s) URL, or local file path. Converted to a base64 data URI. */ + image?: string; + /** SimpleX peer type shown to contacts. Defaults to "bot" when omitted. */ + peerType?: "bot" | "human"; +}; + +export type SimplexNativeAddressSettings = { + autoAccept?: boolean; + welcomeMessage?: string; + businessAddress?: boolean; +}; + export type SimplexConnectionConfig = { - mode?: "external"; + mode?: SimplexConnectionMode; wsUrl?: string; wsHost?: string; wsPort?: number; @@ -10,6 +42,10 @@ export type SimplexConnectionConfig = { connectTimeoutMs?: number; commandTimeoutMs?: number; directoryTimeoutMs?: number; + // native mode (embedded simplex-chat core) + db?: SimplexNativeDbConfig; + profile?: SimplexNativeProfileConfig; + addressSettings?: SimplexNativeAddressSettings; }; export type SimplexStreamingConfig = { @@ -29,9 +65,13 @@ export type ResolvedSimplexAccount = { enabled: boolean; name?: string; configured: boolean; - mode: "external"; + mode: SimplexConnectionMode; wsUrl: string; wsHost: string; wsPort: number; + // present when mode === "native" + db?: SimplexNativeDbConfig; + profile?: SimplexNativeProfileConfig; + addressSettings?: SimplexNativeAddressSettings; config: SimplexAccountConfig; }; From c733625ce1a4f5c931071d8fefceb14c67ca0c20 Mon Sep 17 00:00:00 2001 From: lundog <8041501+lundog@users.noreply.github.com> Date: Sun, 28 Jun 2026 18:20:41 -0600 Subject: [PATCH 3/5] bump ws to fix vulnerability scan --- package.json | 2 +- pnpm-lock.yaml | 293 ++++++++++++++++++++++++++++++++++++++++++++----- 2 files changed, 269 insertions(+), 26 deletions(-) diff --git a/package.json b/package.json index fe00b51..dfdabf8 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" }, "pnpm": { diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index d18f701..2d03599 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 @@ -1319,126 +1319,251 @@ packages: cpu: [arm] os: [android] + '@rollup/rollup-android-arm-eabi@4.62.2': + resolution: {integrity: sha512-6o7ZLZK+BeenkZCFNDXqpbjw9bD6nuWonvS/lwQJp7NoVVxm6p3qE7qQ5jGuBjiFsgvqjD8mZAU5oWxTmbOeOg==} + cpu: [arm] + os: [android] + '@rollup/rollup-android-arm64@4.60.2': resolution: {integrity: sha512-OqZTwDRDchGRHHm/hwLOL7uVPB9aUvI0am/eQuWMNyFHf5PSEQmyEeYYheA0EPPKUO/l0uigCp+iaTjoLjVoHg==} cpu: [arm64] os: [android] + '@rollup/rollup-android-arm64@4.62.2': + resolution: {integrity: sha512-BaH7BllCACHoH1LguOU56UItGfUWjujlO65kS9LAodViaN4bwIKd7oeW/ZHJ/4ljr/7MIiENnNy3HJ0zXv8Zkw==} + cpu: [arm64] + os: [android] + '@rollup/rollup-darwin-arm64@4.60.2': resolution: {integrity: sha512-UwRE7CGpvSVEQS8gUMBe1uADWjNnVgP3Iusyda1nSRwNDCsRjnGc7w6El6WLQsXmZTbLZx9cecegumcitNfpmA==} cpu: [arm64] os: [darwin] + '@rollup/rollup-darwin-arm64@4.62.2': + resolution: {integrity: sha512-v39RCCvj4He82I9sFmk+M1VZ0PLM9sfsLVikjfx2hYBNALhrrOR2D3JjQA6AhlaSOgcR+RzrKY7e1+bT6SUO/A==} + cpu: [arm64] + os: [darwin] + '@rollup/rollup-darwin-x64@4.60.2': resolution: {integrity: sha512-gjEtURKLCC5VXm1I+2i1u9OhxFsKAQJKTVB8WvDAHF+oZlq0GTVFOlTlO1q3AlCTE/DF32c16ESvfgqR7343/g==} cpu: [x64] os: [darwin] + '@rollup/rollup-darwin-x64@4.62.2': + resolution: {integrity: sha512-yl0y2vq3S3lHeuXhEdss6TWfKW8vkujImO12tn4ZkG/4oghr09LvdYm2RElVjokTQiUvDUGXLGsYeLqUMCKpGA==} + cpu: [x64] + os: [darwin] + '@rollup/rollup-freebsd-arm64@4.60.2': resolution: {integrity: sha512-Bcl6CYDeAgE70cqZaMojOi/eK63h5Me97ZqAQoh77VPjMysA/4ORQBRGo3rRy45x4MzVlU9uZxs8Uwy7ZaKnBw==} cpu: [arm64] os: [freebsd] + '@rollup/rollup-freebsd-arm64@4.62.2': + resolution: {integrity: sha512-tT4pvt4qXD+vEoezupCWi+a1F0vvDiksiHc+PxRlYTOH1I6/X4id9jPxTP+Fg+545euaFT1jJVs4CEdHZAU1vw==} + cpu: [arm64] + os: [freebsd] + '@rollup/rollup-freebsd-x64@4.60.2': resolution: {integrity: sha512-LU+TPda3mAE2QB0/Hp5VyeKJivpC6+tlOXd1VMoXV/YFMvk/MNk5iXeBfB4MQGRWyOYVJ01625vjkr0Az98OJQ==} cpu: [x64] os: [freebsd] + '@rollup/rollup-freebsd-x64@4.62.2': + resolution: {integrity: sha512-6nU5F2wCW+qvCBhTn1pdIU3bzsIoF7EUwsCDRxilWGprQR6yd508YnH9+OKFCwpfS8pjZqDUmnCAr7exax0XCg==} + cpu: [x64] + os: [freebsd] + '@rollup/rollup-linux-arm-gnueabihf@4.60.2': resolution: {integrity: sha512-2QxQrM+KQ7DAW4o22j+XZ6RKdxjLD7BOWTP0Bv0tmjdyhXSsr2Ul1oJDQqh9Zf5qOwTuTc7Ek83mOFaKnodPjg==} cpu: [arm] os: [linux] + '@rollup/rollup-linux-arm-gnueabihf@4.62.2': + resolution: {integrity: sha512-n1GJHPOvpIfhi3TmrCeh6S6URt9BFCt0KQE3qvexyGCTAKpR4Lg+eWvNZEqu7epxwus/8ElT3hacYEucm49SZg==} + cpu: [arm] + os: [linux] + '@rollup/rollup-linux-arm-musleabihf@4.60.2': resolution: {integrity: sha512-TbziEu2DVsTEOPif2mKWkMeDMLoYjx95oESa9fkQQK7r/Orta0gnkcDpzwufEcAO2BLBsD7mZkXGFqEdMRRwfw==} cpu: [arm] os: [linux] + '@rollup/rollup-linux-arm-musleabihf@4.62.2': + resolution: {integrity: sha512-JqgflS8wEB+UXV/vS1RpRbifGBeN4D5lz8D8oOFbFZw4vedvdOgCFAjfBmIMdW3yL10XpQQ0Ambepw6MXrhOnA==} + cpu: [arm] + os: [linux] + '@rollup/rollup-linux-arm64-gnu@4.60.2': resolution: {integrity: sha512-bO/rVDiDUuM2YfuCUwZ1t1cP+/yqjqz+Xf2VtkdppefuOFS2OSeAfgafaHNkFn0t02hEyXngZkxtGqXcXwO8Rg==} cpu: [arm64] os: [linux] + '@rollup/rollup-linux-arm64-gnu@4.62.2': + resolution: {integrity: sha512-wnFJkogWvN4jm/hQRF2UBaeUmk20j5+DmHvoyWii2b8HJDyvz1MF2OU/6ynXt2KR63rbZLWkFpoytpdc/yBuSA==} + cpu: [arm64] + os: [linux] + '@rollup/rollup-linux-arm64-musl@4.60.2': resolution: {integrity: sha512-hr26p7e93Rl0Za+JwW7EAnwAvKkehh12BU1Llm9Ykiibg4uIr2rbpxG9WCf56GuvidlTG9KiiQT/TXT1yAWxTA==} cpu: [arm64] os: [linux] + '@rollup/rollup-linux-arm64-musl@4.62.2': + resolution: {integrity: sha512-HVu2bp0zhvJ8xHEV9+UUs7S90VadmBSY3LcIMvozbPo4AuMGDWlz3ymHLHZPX4hR67TKTt8Qp5PJ5RBg/i+RMQ==} + cpu: [arm64] + os: [linux] + '@rollup/rollup-linux-loong64-gnu@4.60.2': resolution: {integrity: sha512-pOjB/uSIyDt+ow3k/RcLvUAOGpysT2phDn7TTUB3n75SlIgZzM6NKAqlErPhoFU+npgY3/n+2HYIQVbF70P9/A==} cpu: [loong64] os: [linux] + '@rollup/rollup-linux-loong64-gnu@4.62.2': + resolution: {integrity: sha512-mQqqAV8QaoSgr9I2fKDLY2BAVvmKjWoGiu/cSYQonsLvtqwEn1E4QYfnCOcp5zoEqNhsDYin1s6jx/VJmrxlZg==} + cpu: [loong64] + os: [linux] + '@rollup/rollup-linux-loong64-musl@4.60.2': resolution: {integrity: sha512-2/w+q8jszv9Ww1c+6uJT3OwqhdmGP2/4T17cu8WuwyUuuaCDDJ2ojdyYwZzCxx0GcsZBhzi3HmH+J5pZNXnd+Q==} cpu: [loong64] os: [linux] + '@rollup/rollup-linux-loong64-musl@4.62.2': + resolution: {integrity: sha512-IxKLoxCQ2IWi6bT2akyDUBGsOImDKB+sPp4EsTmwFQ/fMwpCKm8uLSSgP/Kx/QYUgKis6SEZ5/Nlhup0DIA0PQ==} + cpu: [loong64] + os: [linux] + '@rollup/rollup-linux-ppc64-gnu@4.60.2': resolution: {integrity: sha512-11+aL5vKheYgczxtPVVRhdptAM2H7fcDR5Gw4/bTcteuZBlH4oP9f5s9zYO9aGZvoGeBpqXI/9TZZihZ609wKw==} cpu: [ppc64] os: [linux] + '@rollup/rollup-linux-ppc64-gnu@4.62.2': + resolution: {integrity: sha512-Mk5ha2RQSgyFfmYYLkBpPnUk8D8FriBxesO1u9O75X0mHgXL1UQcH5Itl2lurWL2tj0RxV9b9tJgipac0hRY9A==} + cpu: [ppc64] + os: [linux] + '@rollup/rollup-linux-ppc64-musl@4.60.2': resolution: {integrity: sha512-i16fokAGK46IVZuV8LIIwMdtqhin9hfYkCh8pf8iC3QU3LpwL+1FSFGej+O7l3E/AoknL6Dclh2oTdnRMpTzFQ==} cpu: [ppc64] os: [linux] + '@rollup/rollup-linux-ppc64-musl@4.62.2': + resolution: {integrity: sha512-CjvEnqJL/0/TQ3TXX3OPIJ/kmBellrWd4heXUmHeJlTnmwjKpSJzoehLaL6Xk0ZnMHBu9dZuFADNOrtjF4v+2w==} + cpu: [ppc64] + os: [linux] + '@rollup/rollup-linux-riscv64-gnu@4.60.2': resolution: {integrity: sha512-49FkKS6RGQoriDSK/6E2GkAsAuU5kETFCh7pG4yD/ylj9rKhTmO3elsnmBvRD4PgJPds5W2PkhC82aVwmUcJ7A==} cpu: [riscv64] os: [linux] + '@rollup/rollup-linux-riscv64-gnu@4.62.2': + resolution: {integrity: sha512-1SiZbzwdkaDURsew/tSOrooKiYy7EQGT6m8ufavAi9NEyQb/6VuIxFXAL1fqa4iZe3g4NbNk4P7J32z2tw5Mgg==} + cpu: [riscv64] + os: [linux] + '@rollup/rollup-linux-riscv64-musl@4.60.2': resolution: {integrity: sha512-mjYNkHPfGpUR00DuM1ZZIgs64Hpf4bWcz9Z41+4Q+pgDx73UwWdAYyf6EG/lRFldmdHHzgrYyge5akFUW0D3mQ==} cpu: [riscv64] os: [linux] + '@rollup/rollup-linux-riscv64-musl@4.62.2': + resolution: {integrity: sha512-nQts12zJ3NQRoE6uYljOH89v7szzLDvG2JD/vsX+vGXU8w/At1GowTZ5/7qeFQ8m7L55rpR8Okugnuo5bgjy2Q==} + cpu: [riscv64] + os: [linux] + '@rollup/rollup-linux-s390x-gnu@4.60.2': resolution: {integrity: sha512-ALyvJz965BQk8E9Al/JDKKDLH2kfKFLTGMlgkAbbYtZuJt9LU8DW3ZoDMCtQpXAltZxwBHevXz5u+gf0yA0YoA==} cpu: [s390x] os: [linux] + '@rollup/rollup-linux-s390x-gnu@4.62.2': + resolution: {integrity: sha512-E9/ll019jhPIJgpzfZoIkBGhcz+kKNgVWYRY0zr9srBdPPFVpvOKW8VaJKUbeK+eZXyQF9ltME+Kk6affeaPgg==} + cpu: [s390x] + os: [linux] + '@rollup/rollup-linux-x64-gnu@4.60.2': resolution: {integrity: sha512-UQjrkIdWrKI626Du8lCQ6MJp/6V1LAo2bOK9OTu4mSn8GGXIkPXk/Vsp4bLHCd9Z9Iz2OTEaokUE90VweJgIYQ==} cpu: [x64] os: [linux] + '@rollup/rollup-linux-x64-gnu@4.62.2': + resolution: {integrity: sha512-5BqxR/pshjey51iliyzTD5Xi3EN0aLmQ2lZ3lvefVV9c82BvrLo2/6OT55iifpWBufs6kdwWbuOKS841DrmK9A==} + cpu: [x64] + os: [linux] + '@rollup/rollup-linux-x64-musl@4.60.2': resolution: {integrity: sha512-bTsRGj6VlSdn/XD4CGyzMnzaBs9bsRxy79eTqTCBsA8TMIEky7qg48aPkvJvFe1HyzQ5oMZdg7AnVlWQSKLTnw==} cpu: [x64] os: [linux] + '@rollup/rollup-linux-x64-musl@4.62.2': + resolution: {integrity: sha512-uNN83XxQrRAh/w0/pmAfibcwyb6YWt4gP+dpnQKPVJshAloQ785ii8CT8ZCIxkGg9opVsvAlGhFitSm6D1Jjpg==} + cpu: [x64] + os: [linux] + '@rollup/rollup-openbsd-x64@4.60.2': resolution: {integrity: sha512-6d4Z3534xitaA1FcMWP7mQPq5zGwBmGbhphh2DwaA1aNIXUu3KTOfwrWpbwI4/Gr0uANo7NTtaykFyO2hPuFLg==} cpu: [x64] os: [openbsd] + '@rollup/rollup-openbsd-x64@4.62.2': + resolution: {integrity: sha512-srjEIxSH3LRnJN6THczDHWQplqEMFiAJrTab0msUryh9kwNpkICf3Ea6q6MN/2cZwRFUNx5w+h6Hpi4QuHS6Zg==} + cpu: [x64] + os: [openbsd] + '@rollup/rollup-openharmony-arm64@4.60.2': resolution: {integrity: sha512-NetAg5iO2uN7eB8zE5qrZ3CSil+7IJt4WDFLcC75Ymywq1VZVD6qJ6EvNLjZ3rEm6gB7XW5JdT60c6MN35Z85Q==} cpu: [arm64] os: [openharmony] + '@rollup/rollup-openharmony-arm64@4.62.2': + resolution: {integrity: sha512-8hOJnxgbyObnCm5AlRA3A931xX19xq80RjVTKgJOvEKWqJruP/Uf12IbAOaDjjEXYRewwHLfmF0YRIdK3OwKWA==} + cpu: [arm64] + os: [openharmony] + '@rollup/rollup-win32-arm64-msvc@4.60.2': resolution: {integrity: sha512-NCYhOotpgWZ5kdxCZsv6Iudx0wX8980Q/oW4pNFNihpBKsDbEA1zpkfxJGC0yugsUuyDZ7gL37dbzwhR0VI7pQ==} cpu: [arm64] os: [win32] + '@rollup/rollup-win32-arm64-msvc@4.62.2': + resolution: {integrity: sha512-mmF4AY1i0hG/bLWUctUq59gtmgaSIRa3cu/A3JFRp/sCNEme2bgDEiDS22P9FbnJB8NJNF4jPJiSP5RHQpUTDg==} + cpu: [arm64] + os: [win32] + '@rollup/rollup-win32-ia32-msvc@4.60.2': resolution: {integrity: sha512-RXsaOqXxfoUBQoOgvmmijVxJnW2IGB0eoMO7F8FAjaj0UTywUO/luSqimWBJn04WNgUkeNhh7fs7pESXajWmkg==} cpu: [ia32] os: [win32] + '@rollup/rollup-win32-ia32-msvc@4.62.2': + resolution: {integrity: sha512-DZgkknc6jhHrk46V25vbAM0zZkyP0nSDkJB8/dRkLTxv470dOmWDqGoEJl/9A0dFfS7yE3REOwNDxpHwSLSt0Q==} + cpu: [ia32] + os: [win32] + '@rollup/rollup-win32-x64-gnu@4.60.2': resolution: {integrity: sha512-qdAzEULD+/hzObedtmV6iBpdL5TIbKVztGiK7O3/KYSf+HIzU257+MX1EXJcyIiDbMAqmbwaufcYPvyRryeZtA==} cpu: [x64] os: [win32] + '@rollup/rollup-win32-x64-gnu@4.62.2': + resolution: {integrity: sha512-T6xr6ucWSFto+VGajA8YH26LdpHRuP4YLHEKAtCWvJDOlnmWcDZVCI2Jmjr+IFHDlt2zRaTAKE4tfjTaWLgJBg==} + cpu: [x64] + os: [win32] + '@rollup/rollup-win32-x64-msvc@4.60.2': resolution: {integrity: sha512-Nd/SgG27WoA9e+/TdK74KnHz852TLa94ovOYySo/yMPuTmpckK/jIF2jSwS3g7ELSKXK13/cVdmg1Z/DaCWKxA==} cpu: [x64] os: [win32] + '@rollup/rollup-win32-x64-msvc@4.62.2': + resolution: {integrity: sha512-BfzEnDJOt9T8M989/lA37EcJgat01wLRnoi5dQf3QzOH7jzpqTAzdDbVfRljVr5r+jzKqpbHeyOfAaXxAd0PAA==} + cpu: [x64] + os: [win32] + '@shikijs/core@3.23.0': resolution: {integrity: sha512-NSWQz0riNb67xthdm5br6lAkvpDJRTgB36fxlo37ZzM2yq0PQFFzbd8psqC2XMPgCzo1fW6cVi18+ArJ44wqgA==} @@ -1643,6 +1768,9 @@ packages: '@types/estree@1.0.8': resolution: {integrity: sha512-dWHzHa2WqEXI/O1E9OjrocMTKJl2mSrEolh1Iomrv6U+JuNwaHXsXx9bLu5gG7BUWFIN0skIQJQ/L1rIex4X6w==} + '@types/estree@1.0.9': + resolution: {integrity: sha512-GhdPgy1el4/ImP05X05Uw4cw2/M93BCUmnEvWZNStlCzEKME4Fkk+YpoA5OiHNQmoS7Cafb8Xa3Pya8m1Qrzeg==} + '@types/hast@3.0.4': resolution: {integrity: sha512-WPs+bbQw5aCj+x6laNGWLH3wviHtoCv/P3+otBhbOhJgG8qtpdAMlTCxLtsTWA7LH1Oh/bFCHsBn0TPS5m30EQ==} @@ -3828,6 +3956,11 @@ packages: engines: {node: ^10 || ^12 || ^13.7 || ^14 || >=15.0.1} hasBin: true + nanoid@3.3.15: + resolution: {integrity: sha512-y7Wygv/7mEOvxTuEQDB8StXdMRBWf1kR/tlhAzBRUFkB2jfcLOAxO/SHmOO2zgz1pVgK29/kyupn059/bCHdjA==} + engines: {node: ^10 || ^12 || ^13.7 || ^14 || >=15.0.1} + hasBin: true + napi-build-utils@2.0.0: resolution: {integrity: sha512-GEbrYkbfF7MoNaoh2iGG84Mnf/WZfB0GdGEsM8wz7Expx/LlWf5U8t9nvJKXSp3qr5IsEbK04cBGhol/KwOsWA==} @@ -4212,8 +4345,8 @@ packages: postcss-value-parser@4.2.0: resolution: {integrity: sha512-1NNCs6uurfkVbeXG4S8JFT9t19m45ICnif8zWLd5oPSZ50QnwMfK+H3jv408d4jw/7Bttv5axS5IiHoLaVNHeQ==} - postcss@8.5.13: - resolution: {integrity: sha512-qif0+jGGZoLWdHey3UFHHWP0H7Gbmsk8T5VEqyYFbWqPr1XqvLGBbk/sl8V5exGmcYJklJOhOQq1pV9IcsiFag==} + postcss@8.5.16: + resolution: {integrity: sha512-vuwillviilfKZsg0VGj5R/YwwcHx4SLsIOI/7K6mQkWx+l5cUHTjj5g0AasTBcyXsbfTgrwsUNmVUb5xVwyPwg==} engines: {node: ^10 || ^12 || >=14} postcss@8.5.6: @@ -4551,6 +4684,11 @@ packages: engines: {node: '>=18.0.0', npm: '>=8.0.0'} hasBin: true + rollup@4.62.2: + resolution: {integrity: sha512-RFnrW4lhXA3s3eqHDZvN654g8OTjzRfqpIRJYczCGB6HzphckVAi/Qh4tbPUbRuDi7s1Llv8g/NspLkttY3gTA==} + engines: {node: '>=18.0.0', npm: '>=8.0.0'} + hasBin: true + router@2.2.0: resolution: {integrity: sha512-nLTrUKm2UyiL7rlhapu/Zl45FwNgkZGaCpZbIHajDYgwlJCOzLSk+cIPAnsEqV955GjILJnKbdQC1nVPz+gAYQ==} engines: {node: '>= 18'} @@ -4935,6 +5073,10 @@ packages: resolution: {integrity: sha512-pn99VhoACYR8nFHhxqix+uvsbXineAasWm5ojXoN8xEwK5Kd3/TrhNn1wByuD52UxWRLy8pu+kRMniEi6Eq9Zg==} engines: {node: '>=12.0.0'} + tinyglobby@0.2.17: + resolution: {integrity: sha512-wXR/dYpcqKmfWpEdZjiKJOwCNFndD0DMnrW/cYjVGttEkBfVgcLFHoNrlj47mjOVic9yyNu65alsgF4NQyTa2g==} + engines: {node: '>=12.0.0'} + tinyrainbow@3.1.0: resolution: {integrity: sha512-Bf+ILmBgretUrdJxzXM0SgXLZ3XfiaUuOj/IKQHuTXip+05Xn+uyEYdVg0kYDipTBcLrCVyUzAPz7QmArb0mmw==} engines: {node: '>=14.0.0'} @@ -5381,18 +5523,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'} @@ -7173,78 +7303,153 @@ snapshots: '@rollup/rollup-android-arm-eabi@4.60.2': optional: true + '@rollup/rollup-android-arm-eabi@4.62.2': + optional: true + '@rollup/rollup-android-arm64@4.60.2': optional: true + '@rollup/rollup-android-arm64@4.62.2': + optional: true + '@rollup/rollup-darwin-arm64@4.60.2': optional: true + '@rollup/rollup-darwin-arm64@4.62.2': + optional: true + '@rollup/rollup-darwin-x64@4.60.2': optional: true + '@rollup/rollup-darwin-x64@4.62.2': + optional: true + '@rollup/rollup-freebsd-arm64@4.60.2': optional: true + '@rollup/rollup-freebsd-arm64@4.62.2': + optional: true + '@rollup/rollup-freebsd-x64@4.60.2': optional: true + '@rollup/rollup-freebsd-x64@4.62.2': + optional: true + '@rollup/rollup-linux-arm-gnueabihf@4.60.2': optional: true + '@rollup/rollup-linux-arm-gnueabihf@4.62.2': + optional: true + '@rollup/rollup-linux-arm-musleabihf@4.60.2': optional: true + '@rollup/rollup-linux-arm-musleabihf@4.62.2': + optional: true + '@rollup/rollup-linux-arm64-gnu@4.60.2': optional: true + '@rollup/rollup-linux-arm64-gnu@4.62.2': + optional: true + '@rollup/rollup-linux-arm64-musl@4.60.2': optional: true + '@rollup/rollup-linux-arm64-musl@4.62.2': + optional: true + '@rollup/rollup-linux-loong64-gnu@4.60.2': optional: true + '@rollup/rollup-linux-loong64-gnu@4.62.2': + optional: true + '@rollup/rollup-linux-loong64-musl@4.60.2': optional: true + '@rollup/rollup-linux-loong64-musl@4.62.2': + optional: true + '@rollup/rollup-linux-ppc64-gnu@4.60.2': optional: true + '@rollup/rollup-linux-ppc64-gnu@4.62.2': + optional: true + '@rollup/rollup-linux-ppc64-musl@4.60.2': optional: true + '@rollup/rollup-linux-ppc64-musl@4.62.2': + optional: true + '@rollup/rollup-linux-riscv64-gnu@4.60.2': optional: true + '@rollup/rollup-linux-riscv64-gnu@4.62.2': + optional: true + '@rollup/rollup-linux-riscv64-musl@4.60.2': optional: true + '@rollup/rollup-linux-riscv64-musl@4.62.2': + optional: true + '@rollup/rollup-linux-s390x-gnu@4.60.2': optional: true + '@rollup/rollup-linux-s390x-gnu@4.62.2': + optional: true + '@rollup/rollup-linux-x64-gnu@4.60.2': optional: true + '@rollup/rollup-linux-x64-gnu@4.62.2': + optional: true + '@rollup/rollup-linux-x64-musl@4.60.2': optional: true + '@rollup/rollup-linux-x64-musl@4.62.2': + optional: true + '@rollup/rollup-openbsd-x64@4.60.2': optional: true + '@rollup/rollup-openbsd-x64@4.62.2': + optional: true + '@rollup/rollup-openharmony-arm64@4.60.2': optional: true + '@rollup/rollup-openharmony-arm64@4.62.2': + optional: true + '@rollup/rollup-win32-arm64-msvc@4.60.2': optional: true + '@rollup/rollup-win32-arm64-msvc@4.62.2': + optional: true + '@rollup/rollup-win32-ia32-msvc@4.60.2': optional: true + '@rollup/rollup-win32-ia32-msvc@4.62.2': + optional: true + '@rollup/rollup-win32-x64-gnu@4.60.2': optional: true + '@rollup/rollup-win32-x64-gnu@4.62.2': + optional: true + '@rollup/rollup-win32-x64-msvc@4.60.2': optional: true + '@rollup/rollup-win32-x64-msvc@4.62.2': + optional: true + '@shikijs/core@3.23.0': dependencies: '@shikijs/types': 3.23.0 @@ -7556,6 +7761,8 @@ snapshots: '@types/estree@1.0.8': {} + '@types/estree@1.0.9': {} + '@types/hast@3.0.4': dependencies: '@types/unist': 3.0.3 @@ -9332,7 +9539,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 @@ -10310,6 +10517,8 @@ snapshots: nanoid@3.3.12: {} + nanoid@3.3.15: {} + napi-build-utils@2.0.0: optional: true @@ -10711,9 +10920,9 @@ snapshots: postcss-value-parser@4.2.0: {} - postcss@8.5.13: + postcss@8.5.16: dependencies: - nanoid: 3.3.12 + nanoid: 3.3.15 picocolors: 1.1.1 source-map-js: 1.2.1 @@ -10813,7 +11022,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 @@ -11255,6 +11464,37 @@ snapshots: '@rollup/rollup-win32-x64-msvc': 4.60.2 fsevents: 2.3.3 + rollup@4.62.2: + dependencies: + '@types/estree': 1.0.9 + optionalDependencies: + '@rollup/rollup-android-arm-eabi': 4.62.2 + '@rollup/rollup-android-arm64': 4.62.2 + '@rollup/rollup-darwin-arm64': 4.62.2 + '@rollup/rollup-darwin-x64': 4.62.2 + '@rollup/rollup-freebsd-arm64': 4.62.2 + '@rollup/rollup-freebsd-x64': 4.62.2 + '@rollup/rollup-linux-arm-gnueabihf': 4.62.2 + '@rollup/rollup-linux-arm-musleabihf': 4.62.2 + '@rollup/rollup-linux-arm64-gnu': 4.62.2 + '@rollup/rollup-linux-arm64-musl': 4.62.2 + '@rollup/rollup-linux-loong64-gnu': 4.62.2 + '@rollup/rollup-linux-loong64-musl': 4.62.2 + '@rollup/rollup-linux-ppc64-gnu': 4.62.2 + '@rollup/rollup-linux-ppc64-musl': 4.62.2 + '@rollup/rollup-linux-riscv64-gnu': 4.62.2 + '@rollup/rollup-linux-riscv64-musl': 4.62.2 + '@rollup/rollup-linux-s390x-gnu': 4.62.2 + '@rollup/rollup-linux-x64-gnu': 4.62.2 + '@rollup/rollup-linux-x64-musl': 4.62.2 + '@rollup/rollup-openbsd-x64': 4.62.2 + '@rollup/rollup-openharmony-arm64': 4.62.2 + '@rollup/rollup-win32-arm64-msvc': 4.62.2 + '@rollup/rollup-win32-ia32-msvc': 4.62.2 + '@rollup/rollup-win32-x64-gnu': 4.62.2 + '@rollup/rollup-win32-x64-msvc': 4.62.2 + fsevents: 2.3.3 + router@2.2.0: dependencies: debug: 4.4.3 @@ -11857,6 +12097,11 @@ snapshots: fdir: 6.5.0(picomatch@4.0.4) picomatch: 4.0.4 + tinyglobby@0.2.17: + dependencies: + fdir: 6.5.0(picomatch@4.0.4) + picomatch: 4.0.4 + tinyrainbow@3.1.0: {} to-data-view@1.1.0: {} @@ -12171,9 +12416,9 @@ snapshots: esbuild: 0.27.7 fdir: 6.5.0(picomatch@4.0.4) picomatch: 4.0.4 - postcss: 8.5.13 - rollup: 4.60.2 - tinyglobby: 0.2.16 + postcss: 8.5.16 + rollup: 4.62.2 + tinyglobby: 0.2.17 optionalDependencies: '@types/node': 25.6.0 fsevents: 2.3.3 @@ -12308,8 +12553,6 @@ snapshots: ws@8.18.3: {} - ws@8.20.0: {} - ws@8.21.0: {} xml-naming@0.1.0: {} From 7aed77fc1ec9eb49bf6569173744024d52c47c76 Mon Sep 17 00:00:00 2001 From: lundog <8041501+lundog@users.noreply.github.com> Date: Mon, 29 Jun 2026 13:42:03 -0600 Subject: [PATCH 4/5] Add custom SMP/XFTP servers for native mode MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Lets a native-mode account route over specific SMP/XFTP servers instead of the built-in presets — the last open item called out in the PR description. Verified end-to-end against a self-hosted SMP server (inbound and outbound). Config (additive, backward compatible): - connection.servers gains smp[] and xftp[], each a full server URI (smp://@host / xftp://@host). When set, the list replaces the corresponding preset servers. Manifest regenerated. Behavior: - Applied after startChat via the core's /smp and /xftp commands (multiple servers space-separated). External mode is untouched. - Fail-fast: a rejected configuration throws and aborts startup rather than silently falling back to the defaults — selecting custom servers means opting out of the presets (self-hosting, privacy, compliance), so the error is surfaced immediately. The startup error names the offending list (SMP/XFTP) and includes the core's structured reason. Adds unit tests (commands sent space-joined after startChat, skipped when unset, startup fails on rejection) and a native-setup guide section covering config, the replace-presets/fail-fast semantics, and a reachability note (private-IP servers are only usable by LAN clients that configure them, since private message routing otherwise proxies through a public server that cannot reach a private host). --- docs/guide/native-setup.mdx | 46 ++++++++++++++++--- openclaw.plugin.json | 40 +++++++++++++++++ src/config/accounts.ts | 1 + src/config/config-schema.ts | 8 ++++ src/simplex/runtime/core-client.test.ts | 45 +++++++++++++++++++ src/simplex/runtime/core-client.ts | 60 +++++++++++++++++++++++++ src/types/config.ts | 11 +++++ 7 files changed, 205 insertions(+), 6 deletions(-) diff --git a/docs/guide/native-setup.mdx b/docs/guide/native-setup.mdx index b0baa18..439acf4 100644 --- a/docs/guide/native-setup.mdx +++ b/docs/guide/native-setup.mdx @@ -73,16 +73,21 @@ By default the database lives under OpenClaw's state directory at "openclaw-simplex": { "connection": { "mode": "native", - "db": { "filePrefix": "/path/to/.openclaw/simplex/my-prefix" }, // optional override + "db": { "filePrefix": "/path/to/.openclaw/simplex/my-prefix" }, // optional "profile": { - "displayName": "My OpenClaw bot", // optional; defaults to the account name - "fullName": "", + "displayName": "Friendly Bot", // optional; defaults to the account name + "fullName": "", // optional "image": "/path/to/.openclaw/workspace/avatars/my-avatar.jpg", // optional; data URI, http(s) URL, or absolute file path - "peerType": "bot" // optional; "bot" (default) or "human" + "peerType": "bot" // optional; "bot" (default) or "human" }, "addressSettings": { - "autoAccept": true, - "welcomeMessage": "Connected. Send me a message." + "autoAccept": true, // optional; defaults to true + "welcomeMessage": "Connected. Send me a message.", // optional + "businessAddress": false // optional; defaults to false + }, + "servers": { + "smp": ["smp://@smp.example.com"], // optional + "xftp": ["xftp://@xftp.example.com"] // optional } } } @@ -106,6 +111,8 @@ reuse the identity stored in the database files. | `addressSettings.autoAccept` | no | Default `true`. Auto-accept incoming contact requests (see below). | | `addressSettings.welcomeMessage` | no | Auto-reply text shown to people connecting via the address. | | `addressSettings.businessAddress` | no | Default `false`. `true` makes connections **business chats** (groups) instead of 1:1 contacts. | +| `servers.smp` | no | Messaging (SMP) server URIs. Replaces the default SMP server list when set. | +| `servers.xftp` | no | File-transfer (XFTP) server URIs. Replaces the default XFTP server list when set. | A SimpleX account has exactly one identity (one address and profile that every contact sees), so the profile is **account-level config** — it is not derived @@ -143,6 +150,33 @@ Address settings (`addressSettings`) are reconciled the same way. In native mode these commands run against the gateway holding the embedded core, so the gateway must be running. +### Custom SMP/XFTP servers + +By default the embedded core uses SimpleX's preset public servers. Set +`connection.servers` (shown above) to route through your own (or specific) +servers instead. Each entry is a full server URI including the server's +fingerprint, e.g. `smp://@host`. On start the plugin applies these +via the core's `/smp` and `/xftp` commands (space-separated for multiple +servers). When set, your list **replaces** the corresponding preset servers. + + + Setting `connection.servers` is treated as a hard requirement: if the core + rejects the configuration (e.g. a malformed URI or wrong fingerprint), the + channel **fails to start** rather than silently falling back to the default + servers — selecting custom servers means opting out of the presets, so the + error is surfaced immediately. The startup log names the offending list + (`SMP`/`XFTP`) and includes the core's reason. + + + + Custom servers must be **reachable from the gateway**, and from every client + that connects to the bot. A server on a private address (e.g. `10.x.x.x`) is + only usable by clients on that LAN that also have the server configured, since + SimpleX's private message routing will otherwise try to reach it through a + public proxy that cannot route to a private host. For general use, host the + server on a publicly reachable address. + + ## How it differs from external mode - No external `simplex-chat` process, sidecar, Docker container, or `wsUrl`. diff --git a/openclaw.plugin.json b/openclaw.plugin.json index d69c7f5..cc6f278 100644 --- a/openclaw.plugin.json +++ b/openclaw.plugin.json @@ -311,6 +311,26 @@ } }, "additionalProperties": false + }, + "servers": { + "type": "object", + "properties": { + "smp": { + "type": "array", + "items": { + "type": "string", + "minLength": 1 + } + }, + "xftp": { + "type": "array", + "items": { + "type": "string", + "minLength": 1 + } + } + }, + "additionalProperties": false } }, "additionalProperties": false @@ -601,6 +621,26 @@ } }, "additionalProperties": false + }, + "servers": { + "type": "object", + "properties": { + "smp": { + "type": "array", + "items": { + "type": "string", + "minLength": 1 + } + }, + "xftp": { + "type": "array", + "items": { + "type": "string", + "minLength": 1 + } + } + }, + "additionalProperties": false } }, "additionalProperties": false diff --git a/src/config/accounts.ts b/src/config/accounts.ts index 7aceb91..bc214e9 100644 --- a/src/config/accounts.ts +++ b/src/config/accounts.ts @@ -164,6 +164,7 @@ export function resolveSimplexAccount(params: { ...(db ? { db } : {}), ...(connection.profile ? { profile: connection.profile } : {}), ...(connection.addressSettings ? { addressSettings: connection.addressSettings } : {}), + ...(connection.servers ? { servers: connection.servers } : {}), config: merged, }; } diff --git a/src/config/config-schema.ts b/src/config/config-schema.ts index fbf2025..8fc757d 100644 --- a/src/config/config-schema.ts +++ b/src/config/config-schema.ts @@ -54,6 +54,13 @@ const SimplexNativeAddressSettingsSchema = z }) .strict(); +const SimplexNativeServersSchema = z + .object({ + smp: z.array(z.string().min(1)).optional(), + xftp: z.array(z.string().min(1)).optional(), + }) + .strict(); + const SimplexConnectionSchema = z .object({ mode: z.enum(["external", "native"]).optional(), @@ -69,6 +76,7 @@ const SimplexConnectionSchema = z db: SimplexNativeDbSchema.optional(), profile: SimplexNativeProfileSchema.optional(), addressSettings: SimplexNativeAddressSettingsSchema.optional(), + servers: SimplexNativeServersSchema.optional(), }) .strict(); diff --git a/src/simplex/runtime/core-client.test.ts b/src/simplex/runtime/core-client.test.ts index 6143e4a..d0dbed0 100644 --- a/src/simplex/runtime/core-client.test.ts +++ b/src/simplex/runtime/core-client.test.ts @@ -235,4 +235,49 @@ describe("SimplexCoreClient", () => { const client = new SimplexCoreClient({ account: nativeAccount({ db: undefined }) }); await expect(client.connect()).rejects.toThrow(/no db\.filePrefix/); }); + + it("sends /smp and /xftp (space-joined) after startChat when servers are configured", async () => { + const client = new SimplexCoreClient({ + account: nativeAccount({ + servers: { + smp: ["smp://abc@s1.example", "smp://def@s2.example"], + xftp: ["xftp://ghi@x1.example"], + }, + }), + }); + await client.connect(); + const startIdx = h.calls.indexOf("startChat"); + const smpIdx = h.calls.indexOf("cmd:/smp smp://abc@s1.example smp://def@s2.example"); + const xftpIdx = h.calls.indexOf("cmd:/xftp xftp://ghi@x1.example"); + expect(smpIdx).toBeGreaterThan(startIdx); + expect(xftpIdx).toBeGreaterThan(startIdx); + }); + + it("does not send server commands when servers are not configured", async () => { + const client = new SimplexCoreClient({ account: nativeAccount() }); + await client.connect(); + expect(h.calls.some((c) => c.startsWith("cmd:/smp"))).toBe(false); + expect(h.calls.some((c) => c.startsWith("cmd:/xftp"))).toBe(false); + }); + + it("fails startup (does not fall back to defaults) when the core rejects a custom server command", async () => { + const original = h.chat.sendChatCmd; + h.chat.sendChatCmd = async (cmd: string) => { + h.calls.push(`cmd:${cmd}`); + const err = new Error("Chat command error (see chatError property)") as Error & { + chatError?: unknown; + }; + err.chatError = { type: "error", errorType: { type: "commandError" } }; + throw err; + }; + try { + const client = new SimplexCoreClient({ + account: nativeAccount({ servers: { smp: ["smp://abc@s1.example"] } }), + }); + await expect(client.connect()).rejects.toThrow(/custom SMP server configuration failed/); + expect(client.getConnectionState().connected).toBe(false); + } finally { + h.chat.sendChatCmd = original; + } + }); }); diff --git a/src/simplex/runtime/core-client.ts b/src/simplex/runtime/core-client.ts index db608de..952a599 100644 --- a/src/simplex/runtime/core-client.ts +++ b/src/simplex/runtime/core-client.ts @@ -74,6 +74,25 @@ function welcomeMessageText(welcome: unknown): string | undefined { return undefined; } +/** + * Render a native chat-command error for logs. The library throws an Error + * whose message is the opaque "Chat command error (see chatError property)" and + * attaches the structured reason on a `chatError` field; include it so a + * rejected command is diagnosable. + */ +function describeChatError(err: unknown): string { + const base = err instanceof Error ? err.message : String(err); + const chatError = (err as { chatError?: unknown })?.chatError; + if (chatError === undefined) return base; + let serialized: string; + try { + serialized = JSON.stringify(chatError); + } catch { + serialized = String(chatError); + } + return `${base} ${serialized}`; +} + async function loadNativeModule(): Promise { // Non-literal specifier: keeps tsc from requiring the optional package at // build time, and surfaces a friendly error if it is missing at runtime. @@ -172,6 +191,7 @@ export class SimplexCoreClient implements SimplexTransport { ); await this.ensureUser(desired); await chat.startChat(); + await this.configureServers(); await this.reconcileProfile(desired); await this.ensureAddress(util); this.logger?.info?.(`SimpleX native core started (db: ${db.filePrefix})`); @@ -322,6 +342,46 @@ export class SimplexCoreClient implements SimplexTransport { } } + /** + * Apply custom SMP/XFTP servers. Maps to the console `/smp` and `/xftp` + * commands the core exposes via `sendChatCmd` (which dispatch to + * `SetUserProtoServers`). Must run AFTER startChat(). Multiple servers are + * space-separated (the `/smp` and `/xftp` parsers split on whitespace). + * + * A rejected configuration throws and aborts startup rather than falling back + * to the default servers: an operator who sets `connection.servers` is opting + * out of the presets (self-hosting, privacy, compliance), so silently routing + * over the defaults would violate that intent. Errors here are deterministic + * (bad URI / fingerprint), so failing fast surfaces them immediately. + */ + private async configureServers(): Promise { + if (!this.chat) return; + const servers = this.account.servers; + if (!servers) return; + const groups: Array<{ kind: "smp" | "xftp"; uris: string[] }> = [ + { kind: "smp", uris: (servers.smp ?? []).map((s) => s.trim()).filter(Boolean) }, + { kind: "xftp", uris: (servers.xftp ?? []).map((s) => s.trim()).filter(Boolean) }, + ]; + for (const { kind, uris } of groups) { + if (uris.length === 0) continue; + const cmd = `/${kind} ${uris.join(" ")}`; + try { + await this.chat.sendChatCmd(cmd); + } catch (err) { + // The native library throws on a chat-command error and tucks the + // structured reason on a `chatError` property; surface it so a rejected + // server config is diagnosable instead of an opaque one-liner. + const detail = describeChatError(err); + throw new Error( + `SimpleX custom ${kind.toUpperCase()} server configuration failed: ${detail}. ` + + `Verify each "${kind}" entry in connection.servers is a full ` + + `${kind}://@host URI reachable from the gateway.` + ); + } + this.logger?.info?.(`SimpleX custom ${kind.toUpperCase()} servers applied (${uris.length})`); + } + } + private handleEvent(event: SimplexRuntimeEvent): void { if (!event || typeof event.type !== "string") { return; diff --git a/src/types/config.ts b/src/types/config.ts index 4f8b861..2617d16 100644 --- a/src/types/config.ts +++ b/src/types/config.ts @@ -32,6 +32,15 @@ export type SimplexNativeAddressSettings = { businessAddress?: boolean; }; +/** + * Custom SMP/XFTP servers for native mode (experimental). Each entry is a full + * server URI, e.g. `smp://@host` / `xftp://@host`. + */ +export type SimplexNativeServersConfig = { + smp?: string[]; + xftp?: string[]; +}; + export type SimplexConnectionConfig = { mode?: SimplexConnectionMode; wsUrl?: string; @@ -46,6 +55,7 @@ export type SimplexConnectionConfig = { db?: SimplexNativeDbConfig; profile?: SimplexNativeProfileConfig; addressSettings?: SimplexNativeAddressSettings; + servers?: SimplexNativeServersConfig; }; export type SimplexStreamingConfig = { @@ -73,5 +83,6 @@ export type ResolvedSimplexAccount = { db?: SimplexNativeDbConfig; profile?: SimplexNativeProfileConfig; addressSettings?: SimplexNativeAddressSettings; + servers?: SimplexNativeServersConfig; config: SimplexAccountConfig; }; From 71959292f471fac17ad4c708615f1b91eebc8339 Mon Sep 17 00:00:00 2001 From: lundog <8041501+lundog@users.noreply.github.com> Date: Mon, 29 Jun 2026 17:09:02 -0600 Subject: [PATCH 5/5] Resolve relative inbound file paths against the configured files-folder MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit When the external runtime is started with --files-folder, it reports each received file by name only (not a full path), expecting the client to know its own files-folder. The plugin was joining that bare name to a hardcoded /tmp, so media staging failed with ENOENT whenever --files-folder was set, and the file never reached OpenClaw's media/inbound store. (Without --files-folder the runtime reports absolute paths — Downloads on desktop, /tmp in headless containers — which already worked and are unaffected.) Resolve relative inbound paths against a new connection.filesFolder instead, defaulting to ~/.simplex/files to match the default the bundled runtime service launches simplex-chat with. Absolute paths bypass resolution entirely, so native mode (whose embedded core always reports absolute paths) is unaffected. This also makes the split-container Docker topology work: the sidecar and OpenClaw mount one shared volume at different paths, and the runtime's name-only reporting lets connection.filesFolder map the sidecar's write-path to OpenClaw's read-path. An absolute path could not, since the sidecar's directory does not exist inside the OpenClaw container. - connection.filesFolder config (schema/types/manifest), with ~ expansion. - resolveSimplexInboundDir() extracted and unit-tested (default, custom folder, ~ expansion). - Diagnostic log of the path exactly as the runtime reported it (absolute vs. relative) for observability. - Docs: connection.filesFolder in config reference, and a "Locating received files" section in the runtime-setup guide covering the shared-volume case. Builds on the media/inbound staging added earlier in this branch; absolute-path deployments see no behavior change. Co-Authored-By: Claude Opus 4.8 --- docs/guide/runtime-setup.mdx | 28 +++++++++++ docs/reference/config.mdx | 1 + openclaw.plugin.json | 8 +++ .../events/simplex-inbound-files.test.ts | 26 ++++++++++ src/channel/events/simplex-inbound-files.ts | 49 ++++++++++++++----- src/config/config-schema.ts | 1 + src/types/config.ts | 6 +++ 7 files changed, 107 insertions(+), 12 deletions(-) diff --git a/docs/guide/runtime-setup.mdx b/docs/guide/runtime-setup.mdx index 40bb959..92c54c0 100644 --- a/docs/guide/runtime-setup.mdx +++ b/docs/guide/runtime-setup.mdx @@ -49,6 +49,7 @@ Plugin-side settings: - connection.allowUnsafeRemoteWs - connection.connectTimeoutMs - connection.autoAcceptFiles +- connection.filesFolder - streaming.nativeTransport and related live-reply throttling fields - messageTtlSeconds and filePolicy - dmPolicy, allowFrom, groupPolicy @@ -59,6 +60,33 @@ 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 (both processes on one host or in one container): set connection.filesFolder to the same path as --files-folder. +- Separate containers with a shared volume: each container usually mounts the volume at a different path, and that is fine — set --files-folder to the sidecar's mount and connection.filesFolder to OpenClaw's mount of the same volume. An absolute path would break here, because the sidecar's directory does not exist inside the OpenClaw container; 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. + ### 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..bedde1e 100644 --- a/docs/reference/config.mdx +++ b/docs/reference/config.mdx @@ -128,6 +128,7 @@ 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`. Ignored in native mode (the core reports absolute paths). | | `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 cc6f278..5f1bfb9 100644 --- a/openclaw.plugin.json +++ b/openclaw.plugin.json @@ -244,6 +244,10 @@ "autoAcceptFiles": { "type": "boolean" }, + "filesFolder": { + "type": "string", + "minLength": 1 + }, "connectTimeoutMs": { "type": "integer", "exclusiveMinimum": 0, @@ -554,6 +558,10 @@ "autoAcceptFiles": { "type": "boolean" }, + "filesFolder": { + "type": "string", + "minLength": 1 + }, "connectTimeoutMs": { "type": "integer", "exclusiveMinimum": 0, diff --git a/src/channel/events/simplex-inbound-files.test.ts b/src/channel/events/simplex-inbound-files.test.ts index 4d97e5a..a639d0e 100644 --- a/src/channel/events/simplex-inbound-files.test.ts +++ b/src/channel/events/simplex-inbound-files.test.ts @@ -1,3 +1,5 @@ +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"; @@ -7,6 +9,7 @@ import { markFileAccepted, type PendingInboundFile, queuePendingFile, + resolveSimplexInboundDir, shouldRetryFileAccept, } from "./simplex-inbound-files.js"; @@ -84,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(); diff --git a/src/channel/events/simplex-inbound-files.ts b/src/channel/events/simplex-inbound-files.ts index c4e0722..9d26b56 100644 --- a/src/channel/events/simplex-inbound-files.ts +++ b/src/channel/events/simplex-inbound-files.ts @@ -1,4 +1,5 @@ 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"; @@ -15,18 +16,34 @@ import { getSimplexRuntime } from "../runtime.js"; const PENDING_FILE_TIMEOUT_MS = 90_000; /** - * Default location where simplex-chat saves received files when started without - * `--files-folder`. + * 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_DIR = "/tmp"; +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. The runtime reports - * received files relative to its files-folder (`fileSource.filePath` is usually - * just the file name), so relative paths are resolved against this default. + * 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. */ -function resolveSimplexInboundDir(): string { - return DEFAULT_INBOUND_DIR; +export function resolveSimplexInboundDir(account: ResolvedSimplexAccount): string { + const configured = account.config.connection?.filesFolder?.trim(); + return expandHome(configured || DEFAULT_INBOUND_FILES_FOLDER); } type SimplexReplyPayload = { @@ -156,11 +173,19 @@ export async function finalizePendingFile(params: { clearTimeout(queued.timeout); const { pending } = queued; 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 typically just the file name), so resolve relative - // paths against the default inbound dir (/tmp). + // (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(), rawPath); + rawPath = path.join(resolveSimplexInboundDir(pending.account), rawPath); } let mediaPath: string | undefined; let mediaType: string | undefined; @@ -168,7 +193,7 @@ export async function finalizePendingFile(params: { 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. /tmp/... or ~/.simplex/files/...) lives outside the store, + // (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 }); diff --git a/src/config/config-schema.ts b/src/config/config-schema.ts index 8fc757d..c98ef3c 100644 --- a/src/config/config-schema.ts +++ b/src/config/config-schema.ts @@ -69,6 +69,7 @@ const SimplexConnectionSchema = z wsPort: z.number().int().positive().optional(), allowUnsafeRemoteWs: z.boolean().optional(), autoAcceptFiles: z.boolean().optional(), + filesFolder: 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 2617d16..3ead8fd 100644 --- a/src/types/config.ts +++ b/src/types/config.ts @@ -48,6 +48,12 @@ 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. Defaults to + * `~/.simplex/files`. Ignored in native mode (the core reports absolute paths). + */ + filesFolder?: string; connectTimeoutMs?: number; commandTimeoutMs?: number; directoryTimeoutMs?: number;