searxng-search: document the plugin and how the instance was deployed

Two docs next to the plugin: README.md for the tool, SEARXNG-SETUP.md for the
Docker deployment behind it. Both are written from measurements against the
running instance, not from a guide.

The setup doc exists because the deployment decisions were only recorded as
comments inside settings.yml, which means they are unfindable unless you
already know to look there. It also records the parts that are not guessable:
why the port is 39217, why limiter is off, why the UI locale is zh-Hans-CN
rather than zh-CN, and which engine is broken how.

Corrections found while writing it:

- The rebuild steps pointed at searxng/searxng-docker, which is superseded. That
  repository now holds only a deprecation notice and its docker-compose.yml raw
  URL 404s. The templates moved to searxng/master/container/. Verified both
  local files are byte-identical to the current templates, so the deployment
  was always the current one -- only the documented URL was wrong.
- The plugin README claimed the entrypoint comes from package.json. This
  directory has no package.json; the entrypoint falls back to index.ts. Restarted
  the service with both .md files in place to confirm they do not affect
  resolution.

Documents the two SearXNG behaviours that fail silently rather than loudly,
both verified by reading the engine label on each result:

- An unknown engine name is ignored and the default engine set is searched
  instead, with no error. engines=mojeek returns 10 bing results and looks like
  a success, as does a typo. The only reliable signal is whether the requested
  engine appears in results[].engine.
- /config is not a way to check this. It reported disabled: false for every
  engine while baidu and sogou were in fact being dropped, because it reflects
  upstream defaults and not the local disabled: true overrides.

Also records that ctx.options is always {} for a directory-discovered plugin,
so the four options in PluginConfig cannot be supplied without moving the
plugin out of plugins/ and registering it in opencode.jsonc. Verified by
writing JSON.stringify(ctx.options) to a file from inside setup().

No secret_key value is in either file; settings.yml stays outside this
repository under ~/Docker.
This commit is contained in:
2026-10-08 13:29:02 +08:00
parent 13d353e96d
commit 9e10925023
2 changed files with 464 additions and 0 deletions
+179
View File
@@ -0,0 +1,179 @@
# searxng-search
An OpenCode V2 plugin that adds a `searxng_search` tool backed by a
self-hosted SearXNG instance.
It does **not** replace OpenCode's built-in `websearch` provider. It registers
one extra, opt-in tool, so nothing existing changes behaviour. Replacing a
built-in provider means taking over a key the user chose; adding a tool does
not.
- OpenCode V2 plugin docs: <https://opencode.ai/v2/docs/build/plugins/>
- OpenCode plugin config: <https://opencode.ai/v2/docs/plugins>
- SearXNG search API: <https://docs.searxng.org/dev/search_api.html>
- How the paired instance was deployed: [`SEARXNG-SETUP.md`](SEARXNG-SETUP.md)
## Requirements
1. A reachable SearXNG instance with `json` in `search.formats`
(disabled upstream by default). See `SEARXNG-SETUP.md`.
2. `@opencode/plugin` installed at **your exact OpenCode version**:
```sh
opencode --version # e.g. v2.0.24
cd ~/.config/opencode
npm i @opencode/plugin@2.0.24 # must match
```
3. A service restart. The running service caches module resolution, so a
freshly installed package is invisible until then:
```sh
opencode service restart
```
Check the log if the tool does not appear:
```sh
grep -i "loading plugin" ~/.local/share/opencode/log/opencode.log | tail
```
A line like this is good:
```
loading plugin id=/home/you/.config/opencode/plugins/searxng-search
```
A `Cannot find package '@opencode/plugin'` error means the package is
**missing**, not that the docs are wrong. See the stale-package trap in
`../../skills/opencode-plugins/SKILL.md`.
## How it is loaded
By directory discovery, not by config. OpenCode loads every immediate
subdirectory of `~/.config/opencode/plugins/` as a plugin package and resolves
an entrypoint inside it. This directory has no `package.json`, so the entrypoint
falls back to `index.ts`:
```
loading plugin id=/home/you/.config/opencode/plugins/searxng-search
entrypoint=file:///.../plugins/searxng-search/index.ts
```
Sibling files such as this `README.md` do not affect that resolution — verified
by restarting the service with the docs in place. There is deliberately nothing
to add to `opencode.jsonc`.
That has a consequence worth knowing before you try to configure it:
> **`ctx.options` is always `{}` for a discovered plugin.**
Options reach a plugin only through an object entry in the `plugins` array in
`opencode.json(c)`, which is keyed by package or path. A directory that was
found by scanning has no such entry, so it receives no options at all. This was
verified by writing `JSON.stringify(ctx.options)` to a file from inside
`setup()` — the file contained `{}`.
The `baseUrl` / `engines` / `timeoutMs` / `maxSnippetChars` options in
`PluginConfig` are therefore **inert** as shipped. Two working alternatives:
| Approach | How |
| --- | --- |
| Environment variable (easiest) | `export SEARXNG_URL=http://host:39217` before starting OpenCode |
| Register explicitly | Move this directory out of `plugins/`, then add a `{"package": "<path>", "options": {...}}` entry to `opencode.jsonc` |
To keep the `engines` default editable without moving anything, change
`DEFAULT_ENGINES` in `index.ts` and push — see "Push every change" in
`../../skills/opencode-plugins/SKILL.md`.
If you do register it explicitly, remove the directory from `plugins/`
first. Otherwise the plugin loads twice: once by discovery, once by config,
and the tool is registered against the same name twice.
## The tool
Effective name: **`searxng_search`** (tool name + `_` + namespace).
| Argument | Type | Notes |
| --- | --- | --- |
| `query` | string, required | The search query. |
| `engines` | string | Comma-separated. Omit for the instance default. |
| `category` | string | Only sent when you pass it — see the additive trap below. |
| `language` | string | e.g. `zh-CN`, `en-US`. Omit for the instance default. |
| `safesearch` | 0 \| 1 \| 2 | Omit for the instance setting. |
| `pageno` | number | From 1. Engines without paging are dropped above page 1. |
| `time_range` | string | `day`, `week`, `month`, `year`. Engines without it are dropped entirely. |
At `setup()` the plugin reads the instance's `/config` and bakes what the
instance actually supports into each argument's `description`, so the model is
told which engines lose paging or time filtering instead of discovering it by
returning fewer results.
### Two SearXNG behaviours that silently mislead
Both are handled, both are worth understanding because they fail silently.
**1. `categories` is additive, not a filter.** If you pass both `engines` and
`category`, SearXNG takes the union: the named engines *plus* every engine
enabled by default in that category. A careful `engines=bing,360search` becomes
`bing,360search` + whatever the category contributes. The plugin therefore
sends `categories` only when the caller explicitly asked for a category, never
alongside an engine list it chose itself.
**2. An unknown engine name falls back instead of erroring.** If `engines=`
names something the active engine list does not contain, SearXNG ignores the
name and searches with the **default engine set**. No error, no
`unresponsive_engines` entry, and results that look like a success. Verified on
this instance:
```
engines=bing -> bing x10
engines=360search -> 360search x7
engines=baidu -> 0 results, unresponsive_engines empty
engines=mojeek -> bing x10 <-- fell back, mojeek is not active
engines=duckduckgo -> bing x10 <-- fell back
engines=nosuchengine -> bing x10 <-- typo: also fell back, no warning
```
Three signatures, and only the third means the engine was really used:
| Observation | Meaning |
| --- | --- |
| 0 results **and** empty `unresponsive_engines` | Engine is disabled or not in the active list |
| Results, but none of them from the requested name | Fell back to the default engine set |
| Results including the requested name | Genuinely used |
`/config` is **not** a reliable way to check this. On this instance it reports
`disabled: false` for every engine while `baidu` and `sogou` are in fact being
dropped, because the listing reflects upstream defaults rather than the
`disabled: true` overrides in `settings.yml`. Diagnose with the table above.
## Measured state of the paired instance
SearXNG 2026.10.7-6671d89be, `docker.io/searxng/searxng:latest` (built
2026-10-07), `http://127.0.0.1:39217`.
| Engine | Status |
| --- | --- |
| `bing` | Active. Needs `base_url: https://cn.bing.com`; `www.bing.com` blocks scraping from this network. |
| `360search` | Active. |
| `yandex`, `naver` | Active and reachable, but no useful Chinese index — left out of the plugin's default engine list. |
| `baidu` | Disabled. Answers with a CAPTCHA and suspends the client IP for an hour. |
| `sogou` | Disabled. The upstream engine raises `AttributeError` in this release. |
| `mojeek`, `ecosia`, `marginalia` | Dropped by `keep_only`; not reachable from this network. |
`bing` supports neither paging nor `time_range`, so on this instance `pageno > 1`
and `time_range` both narrow the result to nothing useful. The tool says so in
the argument descriptions rather than returning a silently short list.
## Troubleshooting
| Symptom | Check |
| --- | --- |
| Tool absent from the catalog | `grep -i "loading plugin" ~/.local/share/opencode/log/opencode.log \| tail` |
| `Cannot find package '@opencode/plugin'` | Package missing or wrong version. Match `opencode --version` exactly. |
| Installed it but still missing | `opencode service restart` — the service caches module resolution. |
| `fetch failed` / no results at all | `curl -s "http://127.0.0.1:39217/config" \| head -c 200` — is it up? |
| JSON format rejected | `json` must be in `search.formats` in `settings.yml`. |
| Results from an unexpected engine | The fallback in the table above. Check which engines actually appear. |
| Everything works, then stops after an edit | The running service loaded the old file. Restart it. |
+285
View File
@@ -0,0 +1,285 @@
# Deploying the SearXNG instance
How the instance behind the `searxng_search` tool was built, and how to rebuild
or repair it. Everything here was measured on this machine, not copied from a
guide.
- SearXNG Docker install: <https://docs.searxng.org/admin/installation-docker.html>
- SearXNG settings reference: <https://docs.searxng.org/admin/settings/>
- Plugin that consumes it: [`README.md`](README.md)
## What is running
| | |
| --- | --- |
| Host directory | `/home/xiaoshuai/Docker/searxng/` |
| Containers | `searxng-core`, `searxng-valkey` |
| Image | `docker.io/searxng/searxng:latest` |
| SearXNG version | `2026.10.7-6671d89be` (image built 2026-10-07) |
| Listen | `0.0.0.0:39217` — reachable from the LAN, no authentication |
| Files | `docker-compose.yml`, `.env`, `core-config/settings.yml` |
`docker-compose.yml` and `.env.example` are the **current official templates,
unmodified** — both were diffed against
`searxng/master/container/` and are byte-identical. Every decision this
deployment makes lives in `.env` and `core-config/settings.yml`, so re-syncing
the templates never clobbers local choices.
## Files
```
/home/xiaoshuai/Docker/searxng/
├── docker-compose.yml # official template, unmodified
├── .env # version, bind address, port
├── .env.example # from the template, commented out
└── core-config/
└── settings.yml # mounted at /etc/searxng/
```
`core-config/` is bind-mounted over `/etc/searxng/`, so editing `settings.yml`
on the host changes the running instance. Restart to apply:
```sh
cd /home/xiaoshuai/Docker/searxng
docker compose restart core
```
> `settings.yml` contains `server.secret_key`. It lives under `~/Docker`, which
> is **not** inside the git repository that tracks the OpenCode skills and
> plugins. Do not move it into that repository.
## Rebuilding from scratch
```sh
mkdir -p /home/xiaoshuai/Docker/searxng/core-config
cd /home/xiaoshuai/Docker/searxng
# 1. The official templates, from the searxng main repo.
curl -fsSL -O https://raw.githubusercontent.com/searxng/searxng/master/container/docker-compose.yml
curl -fsSL -O https://raw.githubusercontent.com/searxng/searxng/master/container/.env.example
# 2. Bind address and port. 0.0.0.0 exposes it to the LAN with no auth.
cp -i .env.example .env
cat > .env <<'EOF'
SEARXNG_VERSION=latest
SEARXNG_HOST=0.0.0.0
SEARXNG_PORT=39217
EOF
# 3. Config. Copy the body of the "settings.yml" section below.
# Generate a real secret first:
openssl rand -hex 32
# paste it into server.secret_key
docker compose up -d
docker compose logs -f core
```
Pin `SEARXNG_VERSION` to a dated tag such as `2026.10.7-6671d89be` if you need
reproducible restarts; `latest` moves.
> **Do not use the `searxng/searxng-docker` repository.** It is superseded and
> now contains only a deprecation notice — its `docker-compose.yml` is gone and
> the raw URL 404s. The templates now live in the main repository under
> `container/`, which is what the "Compose instancing" section of the
> installation docs links to. Both files here were diffed against those
> templates and are byte-identical.
### Keeping the templates current
```sh
cd /home/xiaoshuai/Docker/searxng
docker compose down
curl -fsSLO https://raw.githubusercontent.com/searxng/searxng/master/container/docker-compose.yml
curl -fsSLO https://raw.githubusercontent.com/searxng/searxng/master/container/.env.example
docker compose up -d
```
`core-config/` is bind-mounted, so `settings.yml` survives this. Review the
refreshed `.env.example` before overwriting `.env` — new variables may be
needed, and your `SEARXNG_HOST` / `SEARXNG_PORT` will be lost if you replace the
file without editing it.
## The port is unusual on purpose
`SEARXNG_PORT=39217` appears in three places and must agree:
1. `.env` — read by the host side of the port mapping
`${SEARXNG_HOST:+...}${SEARXNG_PORT:-8080}:${SEARXNG_PORT:-8080}`
2. `.env` again — `env_file` passes it into the container, and SearXNG binds
to it, so the container port is also 39217
3. Nothing in `settings.yml` — port is not a SearXNG setting
`0.0.0.0` is a deliberate choice: the instance is for the LAN and has no
authentication. Set `SEARXNG_HOST=127.0.0.1` to restrict it to this machine,
which is the right call if it is not actually being used from other devices.
## settings.yml
```yaml
# Read the documentation before extending the defaults:
# https://docs.searxng.org/admin/settings/
#
# This machine cannot reach DuckDuckGo / Brave / Wikipedia / Google /
# Startpage / Qwant / Yahoo (network timeouts), so the default engine set
# returns nothing. Only engines verified reachable from this host are kept.
use_default_settings:
engines:
keep_only:
- baidu
- bing
- sogou
- "360search"
- mojeek
- yandex
- naver
- ecosia
- marginalia
general:
instance_name: "SearXNG (LAN)"
server:
secret_key: "<output of openssl rand -hex 32>"
image_proxy: true
# No reverse proxy in front, so SearXNG cannot read the real client IP.
# The limiter would reject every request -> keep it off while exposed to LAN.
limiter: false
search:
safe_search: 0
default_lang: "zh-CN"
# Enable the JSON interface: disabled upstream by default, and the OpenCode
# plugin needs it to read results. The SearXNG docs warn that exposing the
# JSON format is an abuse risk, which is why this instance stays on the LAN
# and the limiter stays off (no reverse proxy here forwards the client IP).
formats:
- html
- json
ui:
# must match a key in searx/data/locales.json (not "zh-CN")
default_locale: "zh-Hans-CN"
engines:
# bing and sogou ship as disabled: true upstream -> enable them here
# www.bing.com 302-redirects and blocks scraping from this network,
# the cn. endpoint works (upstream comment: "for instances hosted in China").
- name: bing
disabled: false
base_url: https://cn.bing.com
# sogou: upstream engine raises AttributeError (searx/engines/sogou.py:56,
# `resp.next_request`) in this release -> unusable, so it stays disabled.
- name: sogou
disabled: true
# baidu: answers with a CAPTCHA and suspends the IP for 1h, which would
# break every search that touches it. Left enabled only as a manual option.
- name: baidu
disabled: true
```
### Why each non-obvious line is there
**`json` in `search.formats`.** Off upstream. Without it the plugin gets HTTP
403 and the whole tool is dead. The SearXNG docs call exposing the JSON format
an abuse risk, which is the other reason this instance stays on the LAN.
**`limiter: false`.** The limiter needs the real client IP from
`X-Forwarded-For`. There is no reverse proxy here, so every request looks like
it comes from the same address and the limiter rejects all of them. Turning it
off is correct only because the instance is not exposed to the internet. If you
put a reverse proxy in front, turn the limiter back on and re-check.
**`default_locale: "zh-Hans-CN"`.** Must be a key in the image's
`searx/data/locales.json`. `"zh-CN"` is a search language, not a UI locale, and
is rejected here.
**`safe_search: 0`.** Off, so results are not filtered.
## Engines
Measured on this network, not assumed.
| Engine | Kept? | Why |
| --- | --- | --- |
| `bing` | yes | Works, but only via `base_url: https://cn.bing.com`. `www.bing.com` 302-redirects and blocks scraping. |
| `360search` | yes | Works. |
| `yandex` | yes | Reachable, but has almost no Chinese index, so it returns unrelated pages on a Chinese query. Left enabled, excluded from the plugin's defaults. |
| `naver` | yes | Same as yandex. |
| `baidu` | kept, disabled | Returns a CAPTCHA and suspends the client IP for one hour, which would break every search that touches it. |
| `sogou` | kept, disabled | The upstream engine raises `AttributeError` at `searx/engines/sogou.py:56` (`resp.next_request`) in this release. |
| `mojeek`, `ecosia`, `marginalia` | listed, dropped | Not reachable from this network. |
| `google`, `duckduckgo`, `wikipedia`, `brave`, `startpage`, `qwant`, `yahoo` | excluded | Network timeouts. |
The comments in `settings.yml` carry the same reasoning, so the file explains
itself after you stop remembering.
### Diagnosing an engine
`/config` is not trustworthy for this. It reported `disabled: false` for every
engine while `baidu` and `sogou` were in fact being dropped, because the listing
reflects upstream defaults and not the `disabled: true` overrides here.
Ask a search instead, and look at which engines the results are actually from:
```sh
q=$(python3 -c "import urllib.parse;print(urllib.parse.quote('test query'))")
for e in bing 360search baidu sogou yandex; do
curl -s -m 20 "http://127.0.0.1:39217/search?q=$q&format=json&engines=$e" \
| python3 -c "
import json,sys,collections
d=json.load(sys.stdin)
c=collections.Counter(r.get('engine') for r in d.get('results',[]))
print(f' {\"$e\":<10} ->', dict(c) or '0 results', d.get('unresponsive_engines') or '')
"
done
```
Read the output like this:
| Output | Meaning |
| --- | --- |
| `0 results`, no `unresponsive_engines` | Engine is disabled or not in the active list |
| Results, but none from the requested name | **Fell back to the default engine set.** The name was not recognized and no error was raised |
| Results including the requested name | Genuinely used |
The middle row is the trap. `engines=mojeek` returns 10 `bing` results and looks
like a clean success. Anything that trusts an engine name without checking the
result labels will quietly search the wrong engines.
## The valkey container
`searxng-valkey` comes from the official template and backs SearXNG's
limiter/bot-detection store (SearXNG replaced Redis with Valkey). With
`limiter: false` nothing writes to it, so it currently sits idle. It is kept
because the template defines it and removing it is a local deviation that has to
be re-remembered if the limiter is ever turned back on.
## Verify a deployment
```sh
# containers up
docker ps --filter name=searxng
# the instance answers
curl -s -o /dev/null -w "config http=%{http_code}\n" http://127.0.0.1:39217/config
# JSON interface works
curl -s "http://127.0.0.1:39217/search?q=test&format=json" | head -c 200
# from another machine on the LAN
curl -s -o /dev/null -w "lan http=%{http_code}\n" http://192.168.0.45:39217/
# no load errors after a restart
opencode service restart
grep -i "loading plugin" ~/.local/share/opencode/log/opencode.log | tail -3
```
## Security posture
- No authentication, bound to `0.0.0.0` — anyone on the LAN can search and
reach the JSON API. Do not port-forward this.
- `server.secret_key` is a real random value in `settings.yml`. Rotate it if the
file is ever shared.
- `settings.yml` is outside the skills/plugins git repository on purpose.