dsh-rustxng
Use a self-hosted RustXNG instance from
DeepSeek Harness — and answer the
built-in web_search with it.
RustXNG is a SearXNG-compatible metasearch engine written in Rust: one query fans out to
over a hundred search engines (119 enabled in 0.28's default standard profile),
deduplicates the results and returns them without ads or tracking. This
plugin connects DSH to a RustXNG you run yourself (Windows, macOS or Linux)
and adds:
| Contribution | What it gives you |
|---|---|
| Search provider | A rustxng provider on the web seam. Point web.searchProvider at it and the built-in web_search is answered by your instance — no DeepSeek search API key involved. |
| Settings card | A card in Plugins with two live switches and the instance URL. No restart, no hand-edited YAML. |
| Tools | rustxng_search (engine, category, language and time-range scoped) and rustxng_status (reachability, version, engine count, provider state). |
| Fetch provider (optional) | A charset-aware web_fetch: resolves BOM → HTTP header → <meta charset> before decoding, so Shift_JIS pages that declare their encoding only in <meta> stop coming back as �. Does not use RustXNG. |
| Process supervision (optional) | Start the binary when the instance does not answer, and stop it again after an idle timeout (default 60 min, editable in the card). Only a process this plugin started is stopped — this is also the only thing that cleans up after a Harness that was killed. |
| Binary fetcher | node scripts/install-binary.mjs downloads and verifies the right release binary for your OS and CPU. |
Nothing in DSH is patched: the plugin uses the documented ctx.web.registerSearchProvider()
API and the ordinary web.searchProvider settings field, so it survives Harness upgrades.
Do you need this plugin at all?
If you use rustdsh (rdsh), let rustdsh handle search. Since v0.15.0 it checks whether
rustxng / SEARXNG_URL actually answers at start-up and, only when it does, passes
DSH_WEB_SEARCH_PROVIDER=rdsh-rustxng to the delegated DSH so the bundled
rdsh-web-search-searxng provider takes web_search over (web_search_bridge, on by
default).
| Your situation | Use |
|---|---|
You start DSH through rdsh (rdsh tui, rdsh web, …) and want web_search to hit a local engine |
rustdsh's own bridge. Nothing to configure; stop it with rdsh features off web_search_bridge |
You start dsh directly (desktop app, systemd, …) and want web_search replaced |
This plugin. rdsh only injects into the children it launches, so a plain dsh boot is untouched |
You need web_fetch to stop garbling pages whose charset is declared only in <meta> |
This plugin (webFetch.enabled) — rustdsh's bridge is search only |
You want the agent to call RustXNG as a tool (rustxng_search / rustxng_status) |
This plugin. |
| You want the binary downloaded, started and idle-stopped for you | This plugin (autoStart); rustdsh never downloads anything on behalf of the bridge |
| You use both and want one instance for both | Point rdsh at this plugin's instance: RUSTXNG_URL=http://127.0.0.1:8888 |
Give search to exactly one of them. With both, two providers are registered (rustxng and
rdsh-rustxng) and two sidecars can end up running. Letting rustdsh own it means turning this
plugin's webSearch.enabled off; letting this plugin own it means
rdsh features off web_search_bridge (or RDSH_WEB_SEARCH_BRIDGE=0).
Delegating search to rustdsh does not change web_fetch. Use this plugin's
webFetch.enabled when you want the fetch side replaced too. See
If you also use rdsh for sharing one instance and one binary.
Requirements
- DeepSeek Harness
>= 0.2.0-rc.2 < 0.3.0(hosttools,webandsettingsservices). Verified on 0.2.0-rc.2 and 0.2.1-rc.2; see docs/PLATFORMS.md. - Node.js 20+ (the plugin uses the global
fetch) - A running RustXNG instance — see below
1. Get RustXNG
Easiest: let the plugin fetch a release binary.
node scripts/install-binary.mjs # into $DSH_HOME/bin (or ~/.dsh/bin)
node scripts/install-binary.mjs --dir ./bin --musl # Linux without glibc
The script picks the asset for your platform, verifies the published SHA-256, and prints the start command.
Or download it yourself from the releases page:
| Platform | Asset |
|---|---|
| Windows x64 | rustxng-v<ver>-x86_64-pc-windows-msvc.exe |
| macOS arm64 | rustxng-v<ver>-aarch64-apple-darwin |
| macOS x64 | rustxng-v<ver>-x86_64-apple-darwin |
| Linux x64 (glibc) | rustxng-v<ver>-x86_64-unknown-linux-gnu |
| Linux x64 (musl) | rustxng-v<ver>-x86_64-unknown-linux-musl |
Start it:
rustxng --port 8888 # listens on 127.0.0.1:8888 by default
curl 'http://127.0.0.1:8888/healthz' # → OK
RustXNG serves its own search UI at http://127.0.0.1:8888/; this plugin does not replace that page, it just consumes the same JSON API.
2. Install the plugin
# registry install (recommended)
dsh plugin --profile web add dsh-rustxng
# or from a checkout of this repository
git clone https://github.com/<owner>/dsh-rustxng.git
# then: Plugin Manager → install bundle → the checkout directory
Installing from a checkout links the directory, so Node resolves the plugin's real path
(.../dsh-rustxng). The plugin copes with that: if @deepseek-ai/schemastery is not
resolvable from there, it loads the schema builder through the running Harness's own entry
point instead of silently dropping the schema.
3. Turn it on
Open Plugins → RustXNG and switch on Use RustXNG for web_search. Or set it in your
profile patch (~/.dsh/profiles/<profile>/cordis.patch.yml):
- id: dsh-rustxng
config:
baseUrl: http://127.0.0.1:8888
webSearch:
enabled: true
autoSelect: true
From then on web_search is answered by RustXNG. The enabled and autoSelect switches
and the instance URL are volatile: the settings card changes them live, and turning the
feature off hands web.searchProvider back to whatever it was before.
Configuration
| Key | Default | Meaning |
|---|---|---|
enabled |
true |
Master switch; false unloads the tools and unregisters the provider. |
baseUrl |
http://127.0.0.1:8888 |
Where RustXNG listens. Volatile (editable in the card). |
timeoutMs |
20000 |
Timeout for one search. |
defaultLimit |
12 |
Sources handed to web_search (1–50). |
language |
'' |
Search language (ja, en, …); empty = instance default. |
timeRange |
'' |
day / week / month / year; empty = no filter. |
safeSearch |
-1 |
-1 = instance default, else 0 / 1 / 2. |
categories |
[] |
Category filters applied to every search (general, it, …). |
exposeTools |
true |
Register rustxng_search / rustxng_status. |
webSearch.enabled |
false |
Replace the built-in web_search. Volatile. |
webSearch.autoSelect |
true |
Write web.searchProvider = 'rustxng' for you. Volatile. |
webFetch.enabled |
false |
Replace web_fetch with the charset-aware fetcher. Volatile. |
webFetch.autoSelect |
true |
Write web.fetchProvider = 'rustxng-http' for you. Volatile. |
autoStart.enabled |
true (shipped patch) |
Start the binary when the instance does not answer. Volatile. Off in the schema; the bundle turns it on. |
autoStart.binaryPath |
'' |
Explicit binary path; empty searches $DSH_HOME/bin, ~/.local/bin and PATH. |
autoStart.idleMinutes |
60 |
Stop the started process after this many minutes without a search (0 = never). Volatile. |
autoStart.download |
true |
Fetch a release binary into $DSH_HOME/bin when none is found (SHA-256 verified). |
autoStart.version |
'' |
Pin the download to one release (e.g. 0.20.1). Empty follows the latest release. |
debug |
false |
Log each provider decision. |
Optional: charset-aware fetch
The built-in HTTP fetcher reads charset from the Content-Type header only. Many Japanese
sites (JRA, municipal sites, older corporate sites) send text/html with the encoding
declared only in <meta charset="Shift_JIS">, so the page arrives as replacement
characters. Turning on webFetch.enabled registers a small provider that resolves
BOM → HTTP header → <meta> and decodes with TextDecoder (Node ships the legacy
encodings), with a size cap and the same html/text classification.
- id: dsh-rustxng
config:
webFetch:
enabled: true
autoSelect: true
- id: web
config:
searchProvider: rustxng
fetchProvider: rustxng-http # replaces the whole row, so list both fields
It does not touch RustXNG: it is plain HTTP plus decoding. Leave it off if you prefer the built-in fetcher.
On hosts that hide the
websettings row (DSH 0.2.0-rc.2 publishes only entries with a volatile field),autoSelectcannot write the selection — it needs the same context rules as the search side. Set both fields in the profile patch, exactly as above; the card's switches then still control whether the plugin is active.rustxng_statussays so:Fetch provider: enabled, but the selection was not written (…).
First run: nothing is installed yet
The plugin does not bundle the binary and ships with the replacement off, so a fresh install answers like this until one of the two paths below is taken:
RustXNG could not be reached: fetch failed (connect ECONNREFUSED 127.0.0.1:8888)
Start it (for example `rustxng --port 8888`), or point the `baseUrl` setting at your instance.
Easiest: the shipped bundle already has Start RustXNG automatically on, so the first search fetches and starts the instance; nothing to click. On the next start it
downloads the matching release (SHA-256 verified, ~22-27 MB) into $DSH_HOME/bin, starts it,
and stops it again after the idle timeout. Nothing else to install.
Manual: npx dsh-rustxng-fetch drops the binary in the same place; run it yourself, or
wire it into launchd / systemd and leave auto-start off.
The download never depends on GitHub's API: if the "latest release" lookup is rate-limited it
falls back to a pinned version, and autoStart.binaryPath (or --version) needs no lookup at
all.
About the automatic selection
autoSelect writes web.searchProvider = 'rustxng' for you, but a plugin cannot do that
while it is mounting: the loader runs apply() inside its own transaction and rejects the
settings write with "HMR transactions cannot be nested". 0.3.0 therefore retries the write
from a context that is allowed to write — a tool call (rustxng_status / rustxng_search) or
a live settings change from the card — and reports the outcome in rustxng_status.
If it still cannot be written (for example on a host that refuses plugin-initiated writes), set the field once by hand; it is remembered:
- id: web
config:
searchProvider: rustxng
fetchProvider: http
Optional: start and stop the instance
If you would rather not run RustXNG as a service, let the plugin own the process:
- id: dsh-rustxng
config:
autoStart:
enabled: true
binaryPath: /home/you/.dsh/bin/rustxng # empty = $DSH_HOME/bin, ~/.local/bin, PATH
idleMinutes: 60 # 0 = keep it until DSH unloads the plugin
Behaviour:
- It starts the binary only when the instance does not already answer, and waits for
/healthzbefore using it. Concurrent callers share one attempt, so two starts cannot race for the port. - It stops only a process it started — a systemd unit or a manually started instance is never touched.
- On plugin unload (a normal DSH shutdown) it stops the process immediately.
- A Harness that is killed never runs that cleanup, so the idle timeout finishes the job:
after
idleMinuteswithout a search the process is stopped, and the next search starts it again. Reachability probes do not count as activity. rustxng_statusreportsAuto-start: on — managed (pid …), idle timeout 60 min (12 min idle)andSelection: web.searchProvider: rustxng (this plugin), web.fetchProvider: http.- The
rustxng_searchtool starts the instance too, so a tool-only setup (no replacement ofweb_search) also works from the first call.rustxng_statusnever starts it — it only reports. - Which version is downloaded: always the latest release. The tag is resolved through
GitHub's release API and, when that is rate-limited, through the
/releases/latestredirect;PINNED_VERSIONinlib/install-binary.jsis only the offline fallback, and it holds the last release this plugin was verified against. SetautoStart.versionfor a reproducible version. - The environment is inherited: the child sees your environment, so
RUSTXNG_PROFILE(standard/full/clean, 0.28+) andSEARXNG_SETTINGS_PATHalso apply to a managed instance. This plugin passes no--profile, so the default staysstandard. - What leaves the machine: with the defaults, RustXNG asks the engines directly from your
host and needs no API key. 0.28 adds opt-in DeepSeek backends (a
deepseeksearch engine and[fetch] provider = "deepseek"), both off by default and requiringDEEPSEEK_API_KEY; once enabled, queries (search) or page URLs (fetch) are sent to DeepSeek, so the "no tracking" line above describes the default configuration only. - How it stops: four paths, because none of them covers everything.
- the plugin's disposer, when DSH unloads the plugin;
process.on('exit'), for a normal exit;SIGTERM/SIGINT/SIGHUPhandlers, because a signal ends Node without emittingexit. These stop the child and then re-raise the signal with the default disposition — only when nothing else is listening, so a Harness that shuts itself down keeps control;- the instance's own idle timer: the spawned process is given
RUSTXNG_IDLE_TIMEOUT(the same number of minutes), so builds that support it exit by themselves. Path 4 is the only one that survives aSIGKILLed host, which is why it is set wheneveridleMinutesis above 0. It needs a build that understands the variable — verified with RustXNG 0.24.0; 0.4.x ignores it, and the plugin's own watchdog (path 3's timer, 30 s checks) covers those. WithidleMinutes: 0nothing is scheduled at all, so aSIGKILLed host leaves the instance running — the trade-off of asking for "never stop it". Under a desktop app or a systemd unit the child also shares the host's cgroup and may disappear with it first — same outcome, but do not rely on that in a plain shell.
Set idleMinutes: 0 when using a service manager instead; the two mechanisms are independent.
Tools
// rustxng_search
{ "query": "rust async runtime", "limit": 8, "engine": "arxiv", "time_range": "year" }
{ "query": "東京 天気", "category": "news", "language": "ja" }
rustxng_status reports whether the instance answers, its version, how many engines are
enabled, whether the provider is registered, and whether web.searchProvider points at it —
run it first when a search fails.
If you also use rdsh
rustdsh touches RustXNG in two places, and they are not the same thing:
rdsh search-web --engine rustxng— a shell command for rdsh's own search (SEARXNG_URL→RUSTXNG_URL→ a localrustxng→127.0.0.1:8888);rdsh rustxng installputs the binary in~/.local/bin.web_search_bridge(v0.15.0+, on by default) — injectsDSH_WEB_SEARCH_PROVIDER=rdsh-rustxnginto a DSH that rdsh launches, so the bundledrdsh-web-search-searxngprovider servesweb_search. A plaindshboot sees none of it.
So search is already handled in a rustdsh-driven session: leave this plugin's
webSearch.enabled off there. Turn it on — and stop the bridge with
rdsh features off web_search_bridge — when you want the Harness-side replacement from a
direct dsh start, the model-facing tools, or managed download/start/idle-stop.
web_fetch is outside the bridge either way. If you run both, one instance can serve both:
- point rdsh at the plugin's instance with
RUSTXNG_URL=http://127.0.0.1:8888, or - let the plugin reuse rdsh's binary: the search order is
autoStart.binaryPath→$DSH_HOME/bin→~/.local/bin→PATH, andrustxng_statusprints which file is actually running (Auto-start: on — managed (pid …) [rustxng]).
Set autoStart.binaryPath when you have several builds and want a specific one.
Platform support
| Platform | Status |
|---|---|
| Windows 10/11 x64 | Verified: release binary + this plugin (search, provider replacement, settings card) on DSH 0.2.1-rc.2 |
| Linux x64 (glibc / musl) | Verified on a standalone Ubuntu 26.04 server (DSH 0.2.0-rc.2: replacement + card) and on WSL2 (both release builds 12/12) |
| macOS arm64 / x64 | Verified on macOS 26.6.2 / M1 Max with DSH 0.2.1-rc.3 (Electron desktop app): 0.1.3 answered rustxng_search in ~3 s across 6-10 engines; 0.2.1 adds first-run download and auto-start |
See docs/PLATFORMS.md for how each platform is expected to be verified, binary placement, and the platform-specific notes (PATH, service managers, WSL, musl).
Upstream versions
This plugin talks only to RustXNG's SearXNG-compatible surface (/healthz, /search?format=json,
/config, --bind/--port/RUSTXNG_IDLE_TIMEOUT) and does not check a version, so a new
upstream release is normally a no-op here. The rules for when it is not — the two verification
tiers, what PINNED_VERSION means, and the five upstream changes that trigger a smoke run — are
in docs/UPSTREAM.md, together with a log of the changes already handled
(the default port moving to 29483, and the Google engine removals).
Troubleshooting
| Symptom | Fix |
|---|---|
RustXNG is not answering at … |
Start the instance, or fix baseUrl in the card. rustxng_status shows the probe error. |
web_search still fails with a DeepSeek 401 |
The provider is not selected: check the card's first switch, or web.searchProvider on the official Web search page. On DSH 0.2.0-rc.2 that field is not writable from a plugin — set it in the profile patch (see above) or with DSH_WEB_SEARCH_PROVIDER=rustxng. |
| Card missing from Plugins | The plugin row is not active — check Plugin Manager, and that the DSH version matches dsh.compatibility. On a linked checkout the schema comes through the Harness installation: rustxng_status prints Config schema: loaded via … or the resolution error. 0.1.2 resolves symlinked launchers such as <prefix>/bin/dsh. |
rustxng_status says Replace web_search: no (default) although it is on |
Fixed in 0.1.2 (live config values were compared as primitives). |
Wanted to register a provider but web is missing |
The profile has no @deepseek-ai/dsh-web; this plugin needs it for the replacement (the tools still work). |
| Results look thin | A few engines rate-limit per IP; rustxng_status / RustXNG's /stats show which are suspended. !engine query still works. |
The log says skipped engine=google … unknown engine type |
RustXNG 0.27 removed the Google engines, and an older settings.toml still lists them. Start-up continues with the remaining engines; delete those [[engines]] blocks when convenient. |
| A hand-started binary answers on 29483, not 8888 | RustXNG 0.27+ defaults to 29483. Start it with --port 8888 to match this plugin's default baseUrl, or set baseUrl to http://127.0.0.1:29483. Managed starts are unaffected — the plugin passes --port itself. |
A Japanese page comes back as ���� / �N |
The site declares its encoding only in <meta>. Turn on webFetch.enabled (see Optional: charset-aware fetch), or read it with curl … | iconv -f SHIFT_JIS -t UTF-8. |
Development
node tests/smoke.mjs # payload normalisation + platform mapping (+ live search if an instance answers)
node tests/apply.test.mjs # host half against a stub instance: provider gate, selection, tools, live values
node tests/resolve.test.mjs # host-package resolution through a symlinked launcher (Linux npm-global layout)
node tests/tools.test.mjs # the search tool triggers auto-start; status stays read-only
node tests/install.test.mjs # release asset naming, SHA-256 verification, the pinned fallback
node tests/process.test.mjs # idle rule, binary discovery, a real start/health/idle-stop cycle
node tests/signal.test.mjs # SIGTERM takes the child with it (POSIX; skips on Windows)
node scripts/contract-smoke.mjs # the four upstream surfaces against a release (see docs/UPSTREAM.md)
apply.test.mjs and resolve.test.mjs need no running instance and no network.
Layout:
index.js host plugin: Config, provider + tools wiring, selection hand-over,
symlink-aware host-package resolution
lib/rustxng-client.js HTTP client: query building, payload normalisation, timeouts
lib/web-provider.js the `rustxng` seam provider and the web.searchProvider hand-over
lib/fetch-provider.js optional charset-aware fetch provider (BOM → header → <meta>)
lib/tools.js rustxng_search / rustxng_status
lib/client.js settings card (Plugins → RustXNG)
scripts/install-binary.mjs cross-platform release downloader with SHA-256 check
scripts/verify-linux.sh 12-item instance verification for POSIX hosts
License and attribution
MIT — see LICENSE.
RustXNG itself is AGPL-3.0-or-later and is not bundled here: the plugin talks to a
separate process over HTTP, and scripts/install-binary.mjs downloads it from its own
release page. If you redistribute a modified RustXNG, AGPL §13 applies to that work, not to
this plugin.
No comments yet. Be the first to write one.