diff --git a/plugins/searxng-search/README.md b/plugins/searxng-search/README.md new file mode 100644 index 0000000..90c3083 --- /dev/null +++ b/plugins/searxng-search/README.md @@ -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: +- OpenCode plugin config: +- SearXNG search API: +- 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": "", "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. | diff --git a/plugins/searxng-search/SEARXNG-SETUP.md b/plugins/searxng-search/SEARXNG-SETUP.md new file mode 100644 index 0000000..d4fbfae --- /dev/null +++ b/plugins/searxng-search/SEARXNG-SETUP.md @@ -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: +- SearXNG settings reference: +- 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: "" + 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.