From 085f6d5be8ce1a41611818b27ed12035573c848c Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=E8=B4=BE=E6=99=93=E6=BA=90?= Date: Mon, 17 Aug 2026 21:53:18 +0800 Subject: [PATCH] Add searxng-search plugin: self-hosted SearXNG web search Skill MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit A zero-dependency Python Skill that lets the agent run search queries against the user's own self-hosted SearXNG instance. Supports bearer / basic auth, TOML or JSON config, and the standard SearXNG filters (categories, engines, time range, language, safe search, pagination). Solves: web search inside an agent usually means a vendor API with credentials and quotas. Operators that already run SearXNG (a privacy-respecting metasearch engine) get a first-class search tool against their own instance — one config file, no vendor SDK, no tracking. Security model (matched to mcode-plugin-guide red lines): - HTTPS by default. Plain HTTP is accepted only for loopback hosts (127.0.0.1, ::1, localhost) or for non-loopback hosts when the config contains an explicit `allow_insecure_http = true` opt-in, which emits a strong warning. - Auth values can be referenced via `$ENV_VAR` / `${ENV_VAR}` in the config so the plaintext never lives in the file. On POSIX the script warns when the file is readable beyond the owner. - HTTP redirects are blocked entirely (custom `HTTPRedirectHandler`). The default urllib handler would forward the `Authorization` header to the redirect target, which can leak the token to a different origin and silently downgrade HTTPS to HTTP. Point `base_url` directly at the final endpoint, or front the instance with a same-origin reverse proxy. - 401 / 403 / 407 response bodies are not echoed to stderr because they frequently reflect back the credentials the server saw. Other 4xx / 5xx bodies run through a two-pass redactor: an exact-value secret registry (per request) plus shape-based fallbacks (Bearer / Basic / github_pat_ / gh[pousr]_ / token= / common env-var names). - Numeric config fields are type-validated at load time (`timeout = "30"` etc. fail fast with a clear message instead of surfacing a raw TypeError from urllib). - Response body is capped at 10 MiB to prevent runaway reads on a misbehaving or hijacked endpoint. Network (two levels, both disclosed in README, SKILL.md, and plugin.json): - Direct: the script makes a single request to the user's configured `base_url`. Nothing else. - Downstream: the user's SearXNG instance then forwards the query, language code, and selected categories to its own configured upstream engines (Google, Bing, DuckDuckGo, Brave, Baidu, etc., as enabled by the instance operator). The script does not see or control that hop. Tested: - `py -3 skills/searxng-search/scripts/run_tests.py` → 77 pass, 2 POSIX-only skipped (expected on Windows). - `node --test test/searxng-search.test.mjs` → 1 pass, 0 fail (the Node bridge that the repo's `npm test` will discover). - `node scripts/validate.mjs` → `OK plugin Fectivnfy112357/searxng-search`. Files (28): - `plugin.json`, `README.md`, `LICENSE` (MIT) - `skills/searxng-search/SKILL.md` and `.gitignore` - `skills/searxng-search/references/configuration.md` - `skills/searxng-search/scripts/search.py` (the runtime) - `skills/searxng-search/scripts/run_tests.py` (unittest entry) - `skills/searxng-search/scripts/tests/` (7 test modules + fixtures) - `test/searxng-search.test.mjs` (Node bridge for the repo's `node --test` discoverer) No installer. No write operations outside reading the local config file. No telemetry. No third-party services run by this script. No symlinks. No native binaries. No credentials or private endpoints are committed; the test suite is fully offline. --- .../Fectivnfy112357/searxng-search/LICENSE | 21 + .../Fectivnfy112357/searxng-search/README.md | 83 +++ .../searxng-search/plugin.json | 12 + .../skills/searxng-search/.gitignore | 2 + .../skills/searxng-search/SKILL.md | 101 +++ .../references/configuration.md | 344 ++++++++++ .../searxng-search/scripts/run_tests.py | 19 + .../skills/searxng-search/scripts/search.py | 618 ++++++++++++++++++ .../searxng-search/scripts/tests/__init__.py | 1 + .../searxng-search/scripts/tests/_fixtures.py | 59 ++ .../scripts/tests/test_auth_header.py | 149 +++++ .../scripts/tests/test_config.py | 199 ++++++ .../scripts/tests/test_invalid_response.py | 190 ++++++ .../scripts/tests/test_redaction.py | 174 +++++ .../scripts/tests/test_redirect.py | 198 ++++++ .../scripts/tests/test_timeout.py | 45 ++ .../scripts/tests/test_url_validation.py | 92 +++ .../test/searxng-search.test.mjs | 72 ++ 18 files changed, 2379 insertions(+) create mode 100644 plugins/Fectivnfy112357/searxng-search/LICENSE create mode 100644 plugins/Fectivnfy112357/searxng-search/README.md create mode 100644 plugins/Fectivnfy112357/searxng-search/plugin.json create mode 100644 plugins/Fectivnfy112357/searxng-search/skills/searxng-search/.gitignore create mode 100644 plugins/Fectivnfy112357/searxng-search/skills/searxng-search/SKILL.md create mode 100644 plugins/Fectivnfy112357/searxng-search/skills/searxng-search/references/configuration.md create mode 100644 plugins/Fectivnfy112357/searxng-search/skills/searxng-search/scripts/run_tests.py create mode 100644 plugins/Fectivnfy112357/searxng-search/skills/searxng-search/scripts/search.py create mode 100644 plugins/Fectivnfy112357/searxng-search/skills/searxng-search/scripts/tests/__init__.py create mode 100644 plugins/Fectivnfy112357/searxng-search/skills/searxng-search/scripts/tests/_fixtures.py create mode 100644 plugins/Fectivnfy112357/searxng-search/skills/searxng-search/scripts/tests/test_auth_header.py create mode 100644 plugins/Fectivnfy112357/searxng-search/skills/searxng-search/scripts/tests/test_config.py create mode 100644 plugins/Fectivnfy112357/searxng-search/skills/searxng-search/scripts/tests/test_invalid_response.py create mode 100644 plugins/Fectivnfy112357/searxng-search/skills/searxng-search/scripts/tests/test_redaction.py create mode 100644 plugins/Fectivnfy112357/searxng-search/skills/searxng-search/scripts/tests/test_redirect.py create mode 100644 plugins/Fectivnfy112357/searxng-search/skills/searxng-search/scripts/tests/test_timeout.py create mode 100644 plugins/Fectivnfy112357/searxng-search/skills/searxng-search/scripts/tests/test_url_validation.py create mode 100644 plugins/Fectivnfy112357/searxng-search/test/searxng-search.test.mjs diff --git a/plugins/Fectivnfy112357/searxng-search/LICENSE b/plugins/Fectivnfy112357/searxng-search/LICENSE new file mode 100644 index 0000000..b37f7cc --- /dev/null +++ b/plugins/Fectivnfy112357/searxng-search/LICENSE @@ -0,0 +1,21 @@ +MIT License + +Copyright (c) 2026 Fectivnfy112357 + +Permission is hereby granted, free of charge, to any person obtaining a copy +of this software and associated documentation files (the "Software"), to deal +in the Software without restriction, including without limitation the rights +to use, copy, modify, merge, publish, distribute, sublicense, and/or sell +copies of the Software, and to permit persons to whom the Software is +furnished to do so, subject to the following conditions: + +The above copyright notice and this permission notice shall be included in all +copies or substantial portions of the Software. + +THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR +IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, +FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE +AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER +LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, +OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE +SOFTWARE. diff --git a/plugins/Fectivnfy112357/searxng-search/README.md b/plugins/Fectivnfy112357/searxng-search/README.md new file mode 100644 index 0000000..cf2e58f --- /dev/null +++ b/plugins/Fectivnfy112357/searxng-search/README.md @@ -0,0 +1,83 @@ +# searxng-search + +Search the web through **your own self-hosted SearXNG instance** with a +zero-dependency Python script: categories, engines, time range, language and +safe-search filters, plus bearer/basic auth and TOML or JSON config. + +## The problem + +Web search inside an agent usually means a vendor API with credentials and +quotas. If you already run SearXNG (a privacy-respecting metasearch engine), +this skill gives the agent a first-class search tool against it — one config +file, no vendor SDK, no tracking. + +## Try it + +Install from `/plugins` → **Local**, configure your instance, then ask: + +```text +search the web for the latest SearXNG documentation +``` + +**Expected result**: a formatted list of results (title, URL, snippet) from +your SearXNG instance, filtered by the requested category / time range / +language. + +Direct usage: + +```text +python3 scripts/search.py -c news -t day "latest tech news" +python3 scripts/search.py -e google,duckduckgo -p 2 "rust programming" +python3 scripts/search.py -l zh-CN -n 10 "开源搜索引擎" +``` + +## Requirements + +- A **self-hosted SearXNG instance** reachable over HTTP(S) — you provide it; + no public instance is bundled. +- Python 3.11+ (TOML config; legacy JSON config works on older Python). +- A config file at `~/.config/agents/searxng.toml` (or `$XDG_CONFIG_HOME`); + see `skills/searxng-search/references/configuration.md` for all fields. + +## Data and network + +This skill has **two levels of network destinations**; the difference matters. + +### Direct destination (the script itself) + +The script makes a single HTTPS (or loopback HTTP) request to **your +configured SearXNG instance** — the `base_url` in your config. The +script does not contact any other host. It never follows HTTP +redirects: a 30x response is an error, because following a redirect +could forward the Authorization header to an unexpected host. Point +`base_url` directly at the final endpoint. + +### Downstream destinations (the SearXNG instance, not the script) + +Your SearXNG instance then forwards the **query string**, **language +code**, and **selected categories** to the upstream **engines** it has +been configured with (Google, Bing, DuckDuckGo, Brave, Baidu, etc., as +enabled by the instance operator). Those engines receive the request +content from your instance — the script does not see or control that +hop. + +**Practical implication:** anything you put in the search query +(personal context, project names, internal jargon) reaches the engines +your instance is configured to use. This is true of any SearXNG client, +not something this skill introduces. If that is a concern, configure +your instance to use only engines you trust, or self-host engines +locally. + +### Credentials and config + +- Credentials for your instance (bearer token / basic auth, if any) live + in your local config file. They are sent only to your instance — the + script never sends them anywhere else. +- Use `$ENV_VAR` references in the config instead of writing tokens in + plaintext, and `chmod 600` the file on POSIX systems. See + `skills/searxng-search/references/configuration.md`. +- No telemetry, no third-party services run by this script. + +## License + +MIT diff --git a/plugins/Fectivnfy112357/searxng-search/plugin.json b/plugins/Fectivnfy112357/searxng-search/plugin.json new file mode 100644 index 0000000..2131f1c --- /dev/null +++ b/plugins/Fectivnfy112357/searxng-search/plugin.json @@ -0,0 +1,12 @@ +{ + "$schema": "https://agent-plugins.org/schemas/1.0.0/plugin.schema.json", + "name": "searxng-search", + "version": "1.0.0", + "description": "Search the web through your own self-hosted SearXNG instance: categories, engines, time range, language and safe-search filters via a zero-dependency Python script, with bearer/basic auth and TOML or JSON config. The script contacts only the configured SearXNG instance (HTTPS by default; HTTP only for loopback or explicit opt-in); the instance then forwards the query, language, and categories to its own configured upstream engines (Google, Bing, DuckDuckGo, etc., as enabled by the instance operator). No telemetry, no third-party services run by this script.", + "author": { + "name": "Fectivnfy112357", + "url": "https://github.com/Fectivnfy112357" + }, + "license": "MIT", + "keywords": ["searxng", "search", "self-hosted", "privacy", "metasearch"] +} diff --git a/plugins/Fectivnfy112357/searxng-search/skills/searxng-search/.gitignore b/plugins/Fectivnfy112357/searxng-search/skills/searxng-search/.gitignore new file mode 100644 index 0000000..3673a99 --- /dev/null +++ b/plugins/Fectivnfy112357/searxng-search/skills/searxng-search/.gitignore @@ -0,0 +1,2 @@ +__pycache__/ +*.pyc diff --git a/plugins/Fectivnfy112357/searxng-search/skills/searxng-search/SKILL.md b/plugins/Fectivnfy112357/searxng-search/skills/searxng-search/SKILL.md new file mode 100644 index 0000000..1667aa9 --- /dev/null +++ b/plugins/Fectivnfy112357/searxng-search/skills/searxng-search/SKILL.md @@ -0,0 +1,101 @@ +--- +name: searxng-search +description: "Search the web using a self-hosted SearXNG instance. Use when users ask to search with SearXNG, or when web search is needed and a SearXNG instance is configured. Supports categories, engines, time range, and language filters." +license: MIT +allowed-tools: + - Bash(python3 scripts/*) +--- + +# SearXNG Search Skill + +Search the web using a SearXNG instance via its API. + +## Network destinations + +Two levels — both matter: + +- **Direct (this script):** the script makes a single request to your + configured `base_url`. HTTPS by default; plain HTTP is allowed only + for loopback hosts (127.0.0.1, ::1, localhost) or for non-loopback + hosts if the config contains `allow_insecure_http = true` (with a + warning). If `auth.type` is set, the Authorization header travels to + that single host and nowhere else. **Redirects are never followed**: a + 30x response is an error, because following a redirect could forward + the Authorization header to a host you did not configure. Point + `base_url` directly at the final endpoint (or front the instance + with a same-origin reverse proxy). +- **Downstream (your SearXNG instance):** the instance then forwards + the **query**, **language**, and **categories** to the engines it + itself has been configured with (Google, Bing, DuckDuckGo, Brave, + Baidu, etc., as enabled by the instance operator). This skill does + not control that hop — it is a property of any SearXNG client. If + query contents reaching the upstream engines is a concern, configure + your instance to use engines you trust, or self-host engines locally. + +## Configuration issues + +This skill depends on a local SearXNG config file. Keep setup details out of this +file and load [references/configuration.md](references/configuration.md) only when needed. + +If `scripts/search.py` reports configuration, auth, or instance setup errors, +read [references/configuration.md](references/configuration.md) before retrying. Common examples include: + +- `ERROR: Config file not found` +- `ERROR: Invalid TOML ...` / `ERROR: Invalid JSON ...` +- `ERROR: base_url is required` +- `ERROR: Environment variable ... is not set` +- `ERROR: auth.token required for bearer auth` +- `ERROR: auth.user and auth.pass required for basic auth` +- `ERROR: Unknown auth.type ...` +- `ERROR: HTTP 401` / `ERROR: HTTP 403` + +## Usage + +Run the search script: + +```bash +python3 scripts/search.py [OPTIONS] +``` + +### Options + +| Flag | Description | +|---|---| +| `-c, --categories` | Comma-separated categories (`general`, `news`, `images`, `videos`, `music`, `files`, `it`, `science`, `social media`) | +| `-e, --engines` | Comma-separated engines (`google`, `duckduckgo`, `bing`, etc.) | +| `-l, --language` | Language code (`en`, `zh-CN`, `ja`, etc.) | +| `-p, --page` | Page number (default: 1) | +| `-t, --time-range` | Time range: `day`, `month`, `year` | +| `-n, --max-results` | Max results to show (overrides config default) | +| `-s, --safesearch` | Safe search: `0` (off), `1` (moderate), `2` (strict) | + +### Examples + +```bash +# Basic search +python3 scripts/search.py "SearXNG documentation" + +# Search news from the last day +python3 scripts/search.py -c news -t day "latest tech news" + +# Search with specific engines, page 2 +python3 scripts/search.py -e google,duckduckgo -p 2 "rust programming" + +# Search in Chinese with more results +python3 scripts/search.py -l zh-CN -n 10 "开源搜索引擎" +``` + +## Best Practices + +- **Technical topics** (programming, software, science, IT, etc.): Always use **English** as both the query language and search language (`-l en`), regardless of the user's input language. Translate the query to English if needed. English results are more comprehensive and up-to-date for technical content. +- **Chinese lifestyle topics** (food, travel, shopping, local services, social trends, etc.): In addition to the default search, run a **second search** with `-e baidu,sogou -l zh-CN` using a Chinese query to capture China-specific results. Merge and deduplicate results before presenting to the user. + +## Workflow + +1. User asks to search for something +2. Determine the topic type: + - **Technical**: translate query to English if needed, search with `-l en` + - **Chinese lifestyle**: run the default search first, then an additional search with `-e baidu,sogou -l zh-CN` +3. Run `scripts/search.py` with the query and any relevant filters +4. Present results to the user in a readable format +5. If user wants more results, use `-p` for pagination or `-n` for more per page diff --git a/plugins/Fectivnfy112357/searxng-search/skills/searxng-search/references/configuration.md b/plugins/Fectivnfy112357/searxng-search/skills/searxng-search/references/configuration.md new file mode 100644 index 0000000..9798929 --- /dev/null +++ b/plugins/Fectivnfy112357/searxng-search/skills/searxng-search/references/configuration.md @@ -0,0 +1,344 @@ +# SearXNG configuration reference + +Use this document when you need to: + +- set up the skill for the first time +- change the SearXNG instance or auth settings +- debug configuration/auth/setup errors from `scripts/search.py` + +## Recommended: keep secrets out of the file + +The configuration file is world-readable on most systems. **Prefer +`$ENV_VAR` references for any secret** (bearer token, basic auth user +and password) and `chmod 600` the file so only your user can read it. +The script will exit with a clear error if a referenced environment +variable is not set; it does not silently send a literal `$NAME` value. + +Plaintext secrets in the config file are supported for testing but are +**not recommended** — see the [Plaintext secrets in the config](#plaintext-secrets-in-the-config) +section for the trade-offs and the file-permissions warning. + +## Config file locations + +The script reads configuration from: + +1. `$XDG_CONFIG_HOME/agents/searxng.toml` +2. If `XDG_CONFIG_HOME` is not set, it defaults to `~/.config/agents/searxng.toml` +3. If the TOML file does not exist, the script falls back to the legacy JSON file at `$XDG_CONFIG_HOME/agents/searxng.json` + +In practice, the common paths are: + +- `~/.config/agents/searxng.toml` +- `~/.config/agents/searxng.json` (legacy fallback) + +## File permissions + +On POSIX systems, restrict the config file to the owner. The script +emits a warning if the file is readable by group or others. + +```bash +# read/write for owner only +chmod 600 ~/.config/agents/searxng.toml +``` + +On Windows, use the file Properties → Security tab to limit read access +to your user account. The script does not currently detect overly +permissive ACLs on Windows. + +## Supported fields + +| Field | Type | Required | Description | +|---|---|---|---| +| `base_url` | `string` | **Yes** | Base URL of the SearXNG instance, for example `https://searx.example.com`. Plain HTTP is allowed only for loopback hosts, or for non-loopback hosts if `allow_insecure_http = true` is set. | +| `allow_insecure_http` | `bool` | No | Opt-in gate that allows plain `http://` to a non-loopback host. Triggers a strong warning at load time because the Authorization header would travel in cleartext. | +| `auth` | `object` | No | Authentication config | +| `auth.type` | `string` | If `auth` is set | Must be `"bearer"` or `"basic"` | +| `auth.token` | `string` | If `auth.type = "bearer"` | Bearer token value; supports `$ENV_VAR` or `${ENV_VAR}` (recommended) | +| `auth.user` | `string` | If `auth.type = "basic"` | Basic auth username; supports `$ENV_VAR` or `${ENV_VAR}` (recommended) | +| `auth.pass` | `string` | If `auth.type = "basic"` | Basic auth password; supports `$ENV_VAR` or `${ENV_VAR}` (recommended) | +| `headers` | `object` | No | Extra HTTP headers to send with the request; values support `$ENV_VAR` / `\${ENV_VAR}` references (recommended for keys) | +| `default_language` | `string` | No | Default language code, for example `en` or `zh-CN` | +| `default_categories` | `string[]` | No | Default categories, for example `["general", "news"]` | +| `default_engines` | `string[]` | No | Default engines, for example `["google", "duckduckgo"]` | +| `default_safesearch` | `number` | No | Default safe search level: `0`, `1`, or `2` | +| `default_time_range` | `string` | No | Default time range: `"day"`, `"month"`, or `"year"` | +| `default_max_results` | `number` | No | Default number of results to display; script default is `5` | +| `timeout` | `number` | No | Request timeout in seconds; script default is `30` | + +## Example TOML config: bearer auth with environment variable (recommended) + +```toml +base_url = "https://searx.example.com" +default_categories = ["general"] +default_engines = ["google", "duckduckgo", "brave"] +default_max_results = 10 + +[auth] +type = "bearer" +token = "$SEARXNG_TOKEN" + +[headers] +X-Custom-Header = "value" +``` + +Export the variable before running the script: + +```bash +export SEARXNG_TOKEN='your-token-here' +``` + +The script exits with `ERROR: Environment variable SEARXNG_TOKEN is not set` +if the variable is missing; it does not send a malformed `Bearer $NAME` +header. + +## Example TOML config: basic auth with environment variables (recommended) + +```toml +base_url = "https://searx.example.com" + +[auth] +type = "basic" +user = "$SEARXNG_USER" +pass = "$SEARXNG_PASS" +``` + +```bash +export SEARXNG_USER='admin' +export SEARXNG_PASS='password' +``` + +## Plaintext secrets in the config + +Plaintext values in the config file are supported. They are easier to +set up but live in a file that may be readable beyond the owner. + +If you must use them, restrict the file: + +```bash +chmod 600 ~/.config/agents/searxng.toml +``` + +The script will warn at load time if the file is readable by group or +others. + +```toml +base_url = "https://searx.example.com" +default_safesearch = 1 + +[auth] +type = "basic" +user = "admin" +pass = "password" +``` + +Treat the file as sensitive: do not commit it, do not share it, and +rotate the credentials if the file is ever exposed. + +## Legacy JSON fallback + +If `searxng.toml` does not exist, the script still supports the old JSON format. + +```json +{ + "base_url": "https://searx.example.com", + "default_categories": ["general"], + "default_engines": ["google", "duckduckgo"], + "default_max_results": 10, + "auth": { + "type": "bearer", + "token": "$SEARXNG_TOKEN" + }, + "headers": { + "X-Custom-Header": "value" + } +} +``` + +## Troubleshooting + +### `ERROR: Config file not found: ...` + +The config file is missing from the expected path. + +Checks: + +- confirm whether `XDG_CONFIG_HOME` is set +- create `~/.config/agents/searxng.toml` if you do not use a custom XDG path +- if you are still using the old JSON config, make sure the TOML file is absent and the JSON file exists at the fallback path + +Minimum working TOML: + +```toml +base_url = "https://your-searxng-instance.com" +``` + +### `ERROR: TOML config found at ... but this Python does not support TOML.` + +Your Python is too old for `tomllib`. + +Fix one of these: + +- use Python 3.11+ +- remove/rename `searxng.toml` and use the legacy JSON fallback instead + +### `ERROR: Invalid TOML in ...` / `ERROR: Invalid JSON in ...` + +The config file has syntax errors. + +Checks: + +- validate commas, quotes, and brackets in JSON +- validate table syntax (`[auth]`, `[headers]`) and string quoting in TOML +- make sure arrays look like `["general", "news"]` + +### `ERROR: base_url is required in ...` + +Add a `base_url` value. + +Example: + +```toml +base_url = "https://searx.example.com" +``` + +Use the SearXNG instance root URL. The script will normalize trailing slashes automatically. + +### `ERROR: base_url uses plain HTTP for non-loopback host ...` / `WARN: ... cleartext` + +The script refuses to send an `Authorization` header (or query +parameters) over plain HTTP to a non-loopback host. Pick one of: + +- Switch the instance to HTTPS (recommended). +- Point `base_url` at a loopback address (e.g. `http://127.0.0.1:8888`) + if you run SearXNG behind a local reverse proxy. +- Set `allow_insecure_http = true` in the config to opt in. The script + emits a warning each run, and you should rotate any credentials that + have been sent over plain HTTP. + +### `WARN: Config file ... is readable beyond the owner ...` + +The config file's POSIX mode is wider than `0600`, so other users on the +system can read it. + +Fix: + +```bash +chmod 600 ~/.config/agents/searxng.toml +``` + +On Windows, restrict read access in the file Properties → Security tab. +The script does not currently detect overly permissive ACLs on Windows. + +### `ERROR: Environment variable SOME_VAR is not set` + +You referenced an env var in `auth.token`, `auth.user`, or `auth.pass`, but it is not exported in the current shell. + +Fix: + +```bash +export SOME_VAR='value' +``` + +Then rerun the command from the same shell/session. + +### `ERROR: auth.token required for bearer auth` + +You set `auth.type = "bearer"` but did not provide `auth.token`. + +Example: + +```toml +[auth] +type = "bearer" +token = "$SEARXNG_TOKEN" +``` + +### `ERROR: auth.user and auth.pass required for basic auth` + +You set `auth.type = "basic"` but one or both credentials are missing. + +Example: + +```toml +[auth] +type = "basic" +user = "$SEARXNG_USER" +pass = "$SEARXNG_PASS" +``` + +### `ERROR: Unknown auth.type '...'` + +Only these auth modes are supported: + +- `bearer` +- `basic` + +Correct typos or remove `auth` entirely if the instance does not require authentication. + +### `ERROR: HTTP 401: ...` / `ERROR: HTTP 403: ...` + +The request reached the server, but auth or access control failed. + +Checks: + +- verify `auth.type` +- verify the bearer token or basic auth credentials +- verify any required custom headers under `headers` +- confirm your SearXNG instance allows API access from your environment + +### `ERROR: Request failed: ...` + +This is usually a network, DNS, TLS, or connectivity problem. + +Checks: + +- verify `base_url` +- make sure the instance is reachable from your machine +- check VPN, proxy, DNS, or certificate issues + +### `ERROR: HTTP 30x: redirect blocked: ...` + +The instance (or a load balancer in front of it) answered with a 30x +redirect. The script never follows redirects — urllib's default redirect +handler would forward the `Authorization` header to the redirect +target, so redirects are treated as errors on purpose. + +Checks: + +- point `base_url` directly at the final endpoint (the URL that + answers `/search?format=json` with 200) +- if your instance is behind a load balancer that 302s, front it with a + same-origin reverse proxy that terminates the redirect instead + +### `ERROR: Invalid JSON response from SearXNG` + +The endpoint responded, but not with valid JSON. This often means the URL points to the wrong place or a proxy/login page returned HTML. + +Checks: + +- verify `base_url` points to the SearXNG instance root +- confirm the instance supports `/search?format=json` +- check whether a reverse proxy or SSO page is intercepting the request + +### `ERROR: SearXNG returned error: ...` + +The server returned a structured API error. + +Checks: + +- inspect the error text +- verify query parameters and instance settings +- try the same instance in a browser or with `curl` to confirm the backend is healthy + +## Quick setup checklist + +1. Create `~/.config/agents/searxng.toml` +2. Set `base_url` (HTTPS preferred; loopback HTTP also accepted) +3. `chmod 600` the file so only your user can read it +4. Add auth only if your instance requires it; prefer `$ENV_VAR` references +5. Export any referenced environment variables +6. Run a smoke test: + +```bash +python3 scripts/search.py "SearXNG documentation" +``` diff --git a/plugins/Fectivnfy112357/searxng-search/skills/searxng-search/scripts/run_tests.py b/plugins/Fectivnfy112357/searxng-search/skills/searxng-search/scripts/run_tests.py new file mode 100644 index 0000000..c2901b6 --- /dev/null +++ b/plugins/Fectivnfy112357/searxng-search/skills/searxng-search/scripts/run_tests.py @@ -0,0 +1,19 @@ +#!/usr/bin/env python3 +"""Run searxng-search regression tests (stdlib unittest; no network, no gh). + +Usage: python run_tests.py (from the scripts/ directory) + python -m unittest discover -s tests -p "test_*.py" +""" +import os +import sys +import unittest + +HERE = os.path.dirname(os.path.abspath(__file__)) +sys.path.insert(0, HERE) # scripts/ dir so `import search` works + +if __name__ == "__main__": + loader = unittest.TestLoader() + suite = loader.discover(os.path.join(HERE, "tests"), pattern="test_*.py") + runner = unittest.TextTestRunner(verbosity=2) + result = runner.run(suite) + sys.exit(0 if result.wasSuccessful() else 1) diff --git a/plugins/Fectivnfy112357/searxng-search/skills/searxng-search/scripts/search.py b/plugins/Fectivnfy112357/searxng-search/skills/searxng-search/scripts/search.py new file mode 100644 index 0000000..4eb1092 --- /dev/null +++ b/plugins/Fectivnfy112357/searxng-search/skills/searxng-search/scripts/search.py @@ -0,0 +1,618 @@ +#!/usr/bin/env python3 +"""SearXNG Search Script - Search the web via a SearXNG instance. + +Reads a local TOML (or legacy JSON) config, builds a request against the +user's configured SearXNG instance, and prints the results as markdown. + +Security model: + - Default scheme: HTTPS. Plain HTTP is allowed only for loopback hosts + (127.0.0.1, ::1, localhost) or for non-loopback hosts if the config has + an explicit `allow_insecure_http = true` (with a strong warning). + - Auth values can be referenced via `$ENV_VAR` / `${ENV_VAR}` so the + plaintext never lives in the config file. + - All error output is run through `redact_secrets` so a token that + leaks into a server response body is masked before reaching stderr. + The set of *exact* secrets used for the current request is tracked + so that even short/non-shape-matching tokens are masked (shape-based + patterns are a fallback, not the primary defense). + - HTTP redirects are blocked entirely. A 30x response raises + HTTPError with a "redirect blocked" diagnostic. This prevents the + default urllib redirect handler from forwarding Authorization + headers to a different origin (e.g. an initial loopback URL + returning 302 to a different port), and prevents silent HTTPS→HTTP + downgrades. Configure base_url to point at the final endpoint, or + front the instance with a same-origin reverse proxy. + - HTTPError bodies for authentication-class status codes (401, 403, + 407) are NOT echoed to stderr. These responses frequently reflect + back the credentials the server saw, and the shape-based redaction + can be bypassed by tokens that don't match a known pattern. + - On POSIX, a config file readable beyond the owner (mode & 0o077) is + flagged with a warning. +""" +from __future__ import annotations + +import argparse +import base64 +import json +import os +import re +import sys +import urllib.error +import urllib.parse +import urllib.request + +try: + import tomllib +except ModuleNotFoundError: # pragma: no cover - Python < 3.11 + tomllib = None + + +# ---------- error / progress helpers ---------- + +# Per-process registry of exact credential strings used by the current +# request. `redact_secrets` masks these BEFORE applying shape-based +# patterns, so even a short token that doesn't match any known shape +# is removed from the transcript. Cleared on every call to +# `reset_active_secrets` (driven by `load_config`). +_active_secrets: set[str] = set() + + +def reset_active_secrets() -> None: + """Clear the per-request secret registry. + + Called by `load_config` so a config reload does not let an old + secret linger in the redaction set indefinitely. + """ + _active_secrets.clear() + + +def register_secret(value: str) -> None: + """Record `value` as a secret to mask in any subsequent error output. + + No-op for empty values. The set is deduplicated, so a token that + was registered once does not need to be re-registered on each call. + """ + if not value: + return + _active_secrets.add(value) + + +# Credential shapes that must never reach the transcript. Same pattern set +# as the github-explore skill (PR #8) so the agent learns one redaction +# grammar across skills. Order matters: the most specific label-shaped +# patterns run first so the whole token is masked in one pass, not +# partially replaced and then bypassed. +_SECRET_PATTERNS: list[tuple[re.Pattern[str], str]] = [ + (re.compile(r"(?i)(bearer\s+)[A-Za-z0-9._\-]{20,}"), r"\1***"), + (re.compile(r"(?i)(authorization:\s*basic\s+)[A-Za-z0-9+/=]+"), r"\1***"), + (re.compile(r"github_pat_[A-Za-z0-9_]+"), "github_pat_***"), + (re.compile(r"gh[pousr]_[A-Za-z0-9]{20,}"), "ghx_***"), + (re.compile(r"(?i)\b(token|password|passwd|secret|api[_-]?key)\b(\s*[:=]\s*)\S+"), r"\1\2***"), + (re.compile(r"(?i)\b(GH_TOKEN|GITHUB_TOKEN|SEARXNG_TOKEN|SEARXNG_USER|SEARXNG_PASS)\s*=\s*\S+"), r"\1=***"), +] + + +def redact_secrets(text: str) -> str: + """Mask credential substrings in `text`. + + Two-pass strategy: + 1. Exact substitution of every value registered via + `register_secret` (the actual token/password/basic-credential + used for the current request). This catches short or otherwise + shape-non-conforming secrets that pattern matching would miss. + 2. Shape-based pattern matching for stray credentials that escaped + the registry (e.g. embedded in a third-party server response + that the script never used). + + Best-effort: ordinary error text (URLs without embedded tokens, + JSON error bodies, rate-limit messages) passes through intact. + """ + if not text: + return text + out = text + # Pass 1: exact secret values from the active registry. Sort by + # length descending so a substring secret is replaced before its + # containing string (e.g. "abc" before "abcdef"). + for secret in sorted(_active_secrets, key=len, reverse=True): + if secret and secret in out: + out = out.replace(secret, "***") + # Pass 2: shape-based fallback. + for pat, repl in _SECRET_PATTERNS: + out = pat.sub(repl, out) + return out + + +def die(msg: str, code: int = 1) -> None: + """Print a redacted error to stderr and exit.""" + print(f"ERROR: {redact_secrets(msg)}", file=sys.stderr) + sys.exit(code) + + +def warn(msg: str) -> None: + """Print a redacted warning to stderr.""" + print(f"WARN: {redact_secrets(msg)}", file=sys.stderr) + + +# ---------- config loading ---------- + +def _require_int(config: dict, key: str, default: int) -> int: + """Return `config[key]` if it is an integer, otherwise exit clearly. + + TOML is typed, but a hand-edited config can contain `timeout = "30"` + or `default_max_results = "5"`. Passing such a value to urllib (or + using it to slice results) raises a raw `TypeError` traceback that + bypasses the redacted-error pipeline; fail with a clear message + instead. Booleans are rejected too (`True` is an `int` subclass). + """ + value = config.get(key, default) + if isinstance(value, bool) or not isinstance(value, int): + die( + f"config field {key!r} must be an integer, " + f"got {type(value).__name__} ({value!r})" + ) + return value + + +def check_file_permissions(path: str) -> None: + """Warn if `path` is readable beyond the owner (POSIX only). + + On platforms that don't expose POSIX mode bits (Windows, where + `os.stat().st_mode` is a synthetic mode not derived from real ACLs), + the check is skipped — restricting the file must be done via + Properties → Security. The docstring was previously aspirational; + the implementation now matches. + """ + if os.name != "posix": + return + try: + mode = os.stat(path).st_mode + except OSError: + return + if mode & 0o077: + warn( + f"Config file {path} is readable beyond the owner (mode {oct(mode & 0o777)}). " + "Run `chmod 600` (or platform equivalent) to restrict access." + ) + + +def load_config(): + config_dir = os.environ.get("XDG_CONFIG_HOME", os.path.expanduser("~/.config")) + config_dir = os.path.join(config_dir, "agents") + toml_file = os.path.join(config_dir, "searxng.toml") + json_file = os.path.join(config_dir, "searxng.json") + + if os.path.isfile(toml_file): + config_file = toml_file + if tomllib is None: + die( + f"TOML config found at {config_file}, but this Python does not support TOML. " + "Upgrade to Python 3.11+ or remove the TOML config to use legacy JSON fallback." + ) + + try: + with open(config_file, "rb") as f: + config = tomllib.load(f) + except tomllib.TOMLDecodeError as e: + die(f"Invalid TOML in {config_file}: {e}") + elif os.path.isfile(json_file): + config_file = json_file + try: + with open(config_file) as f: + config = json.load(f) + except json.JSONDecodeError as e: + die(f"Invalid JSON in {config_file}: {e}") + else: + die( + f"Config file not found: {toml_file} (legacy JSON fallback path: {json_file}). " + 'Create TOML config with at minimum: base_url = "https://your-searxng-instance.com"' + ) + + if not config.get("base_url"): + die(f"base_url is required in {config_file}") + + validate_base_url(config["base_url"], config) + check_file_permissions(config_file) + # Fail fast on wrong-typed numeric fields instead of surfacing a raw + # TypeError from urllib or from result slicing later. + _require_int(config, "timeout", 30) + _require_int(config, "default_max_results", 5) + # Drop any secrets left over from a previous run; the new config + # will re-register its own values during build_request. + reset_active_secrets() + return config + + +def resolve_env(value: str) -> str: + """Resolve `$ENV_VAR` or `${ENV_VAR}` references in a string. + + If the variable is unset, exit with a clear error. We do NOT fall back + to using the literal `$VAR` text — that would silently send a + malformed Authorization header. + """ + if not value: + return value + if value.startswith("$"): + var_name = value.lstrip("$").strip("{}") + resolved = os.environ.get(var_name) + if not resolved: + die(f"Environment variable {var_name} is not set") + return resolved + return value + + +# ---------- URL validation ---------- + +_LOOPBACK_HOSTS = frozenset({"127.0.0.1", "::1", "localhost", "0.0.0.0", "[::1]"}) + + +def validate_base_url(url: str, config: dict) -> None: + """Validate the configured base_url and exit on policy violation. + + Policy: + - HTTPS is always accepted. + - Plain HTTP to a loopback host is accepted silently (common dev + setup behind a local reverse proxy). + - Plain HTTP to a non-loopback host is rejected unless the config + contains `allow_insecure_http = true`. When the opt-in is set, a + strong warning is emitted because Authorization headers and query + strings will travel in cleartext. + - Schemes other than http/https are rejected. + """ + try: + parsed = urllib.parse.urlparse(url) + except ValueError as e: + die(f"base_url could not be parsed: {e}") + scheme = (parsed.scheme or "").lower() + host = (parsed.hostname or "").lower() + if scheme not in ("http", "https"): + die(f"base_url scheme must be http or https, got {scheme!r}") + if not host: + die("base_url is missing a host") + if scheme == "https": + return + # scheme == "http" + if host in _LOOPBACK_HOSTS: + return + if config.get("allow_insecure_http") is True: + warn( + f"base_url uses plain HTTP for non-loopback host {host!r}. " + "Authorization headers and search queries will travel in cleartext. " + "Switch to https:// unless you have a specific reason to keep HTTP." + ) + return + die( + f"base_url uses plain HTTP for non-loopback host {host!r}, which is " + "rejected by default (Authorization headers would travel in cleartext). " + "Use https://, point at a loopback address, or set `allow_insecure_http = true` " + "in the config (NOT recommended)." + ) + + +# ---------- request execution (safe opener) ---------- + +# Upper bound on how many bytes we will read from the instance before +# failing. SearXNG search responses are typically a few hundred KB at +# most; an unbounded read lets a misbehaving or hijacked endpoint exhaust +# memory/disk. The error body read is capped with the same constant. +MAX_RESPONSE_BYTES = 10 * 1024 * 1024 # 10 MiB + +# Status codes whose response body we suppress. These codes mean +# "authentication challenge" and the response body frequently reflects +# back the credentials the server saw, in arbitrary shapes that a +# pattern-based redactor cannot reliably cover. Combined with the +# exact-secret registry in `redact_secrets`, suppressing the body for +# these codes closes the residual leak surface. +_AUTH_ERROR_CODES = frozenset({401, 403, 407}) + + +class _SafeRedirectHandler(urllib.request.HTTPRedirectHandler): + """Block all 3xx responses with a clear diagnostic. + + Rationale: the default `urllib.request.HTTPRedirectHandler` will + transparently re-issue the request to a `Location:` URL, INCLUDING + the `Authorization` header from the original request. A misconfigured + `base_url` (e.g. an instance fronted by a load balancer that 302s to + a different port) would leak the Bearer/Basic token to a host the + user never explicitly trusted. It also permits silent HTTPS→HTTP + downgrades, which violates the same-origin HTTPS guarantee the + validator establishes for the initial request. + + Instead of trying to police every hop (same-origin? same-port? + same-scheme? didn't the user already pass validation for the *first* + URL?), we reject all redirects and tell the user to point `base_url` + at the final endpoint, or front the instance with a same-origin + reverse proxy. + """ + + _REDIRECT_LABELS = { + 301: "301 Moved Permanently", + 302: "302 Found", + 303: "303 See Other", + 307: "307 Temporary Redirect", + 308: "308 Permanent Redirect", + } + + def _block(self, req, code, msg, headers): + # Show a *safe* form of Location: scheme://host[:port]/path, no + # query or fragment, so the diagnostic is useful without + # potentially echoing embedded credentials. + loc_raw = headers.get("Location", "") if headers else "" + safe_loc = "" + if loc_raw: + try: + p = urllib.parse.urlparse(loc_raw) + host = p.hostname or "" + if p.port: + host = f"{host}:{p.port}" + safe_loc = f"{p.scheme}://{host}{p.path}" + except Exception: + safe_loc = "(unparseable)" + label = self._REDIRECT_LABELS.get(code, f"{code}") + raise urllib.error.HTTPError( + req.get_full_url(), + code, + f"redirect blocked: {label} to {safe_loc or '(none)'}. " + "Configure base_url to point directly at the final endpoint, " + "or front the instance with a same-origin reverse proxy. " + "This skill does not follow redirects because the " + "Authorization header would otherwise be forwarded to the " + "redirect target.", + headers, + None, + ) + + def http_error_301(self, req, fp, code, msg, headers): + return self._block(req, code, msg, headers) + + def http_error_302(self, req, fp, code, msg, headers): + return self._block(req, code, msg, headers) + + def http_error_303(self, req, fp, code, msg, headers): + return self._block(req, code, msg, headers) + + def http_error_307(self, req, fp, code, msg, headers): + return self._block(req, code, msg, headers) + + def http_error_308(self, req, fp, code, msg, headers): + return self._block(req, code, msg, headers) + + +def _build_opener(): + """Construct a urllib opener that uses `_SafeRedirectHandler`. + + We pass our handler to `build_opener` so it replaces the default + redirect handler (rather than chaining on top of it). All other + default handlers (HTTPSHandler, HTTPHandler, etc.) are kept. + """ + return urllib.request.build_opener(_SafeRedirectHandler()) + + +# ---------- request building ---------- + +def build_request(config: dict, args: argparse.Namespace) -> urllib.request.Request: + base_url = config["base_url"].rstrip("/") + + params = { + "q": args.query, + "format": "json", + "pageno": str(args.page), + } + + categories = args.categories or config.get("default_categories") + if categories: + params["categories"] = ",".join(categories) if isinstance(categories, list) else categories + + engines = args.engines or config.get("default_engines") + if engines: + params["engines"] = ",".join(engines) if isinstance(engines, list) else engines + + language = args.language or config.get("default_language") + if language: + params["language"] = language + + safesearch = args.safesearch if args.safesearch is not None else config.get("default_safesearch") + if safesearch is not None: + params["safesearch"] = str(safesearch) + + time_range = args.time_range or config.get("default_time_range") + if time_range: + params["time_range"] = time_range + + url = f"{base_url}/search?{urllib.parse.urlencode(params)}" + req = urllib.request.Request(url) + + auth = config.get("auth", {}) + auth_type = auth.get("type", "") + if auth_type == "bearer": + token = resolve_env(auth.get("token", "")) + if not token: + die("auth.token required for bearer auth") + register_secret(token) + req.add_header("Authorization", f"Bearer {token}") + elif auth_type == "basic": + user = resolve_env(auth.get("user", "")) + password = resolve_env(auth.get("pass", "")) + if not user or not password: + die("auth.user and auth.pass required for basic auth") + register_secret(user) + register_secret(password) + credentials = base64.b64encode(f"{user}:{password}".encode()).decode() + register_secret(credentials) # the base64 form may also be reflected by the server + req.add_header("Authorization", f"Basic {credentials}") + elif auth_type: + die(f"Unknown auth.type '{auth_type}'. Use 'bearer' or 'basic'.") + + headers = config.get("headers", {}) + for key, value in headers.items(): + if not isinstance(value, str): + die(f"config field {key!r} in headers must be a string, got {type(value).__name__}") + resolved = resolve_env(value) + if value.startswith("$"): + # An explicit $ENV_VAR reference marks this value as a + # secret: register the resolved value for exact redaction in + # any later error output, exactly like auth.* values. + register_secret(resolved) + req.add_header(key, resolved) + + if "User-Agent" not in headers and "user-agent" not in {h.lower() for h in headers}: + req.add_header( + "User-Agent", + "searxng-search-skill/1.0 (+https://github.com/Fectivnfy112357/MiniMax-Code-Plugins)", + ) + + return req + + +# ---------- result formatting ---------- + +def format_results(data: dict, max_results: int, page: int): + answers = data.get("answers", []) + if answers: + print("# Answers\n") + for a in answers: + answer = a if isinstance(a, str) else a.get("answer", "") + if answer: + print(f"> {answer}\n") + + for ib in data.get("infoboxes", []): + name = ib.get("infobox", "") + content = ib.get("content", "") + if name: + print(f"# Infobox: {name}\n") + if content: + print(f"{content}\n") + for attr in ib.get("attributes", []): + print(f"- **{attr['label']}:** {attr['value']}") + urls = ib.get("urls", []) + if urls: + print() + for u in urls: + official = " (official)" if u.get("official") else "" + print(f"- [{u['title']}{official}]({u['url']})") + print() + + results = data.get("results", []) + if not results: + print("No results found.") + return + + print("# Results\n") + for r in results[:max_results]: + title = r.get("title", "Untitled") + url = r.get("url", "") + content = r.get("content", "") + engines = r.get("engines", []) + category = r.get("category", "") + score = r.get("score", 0) + date = r.get("publishedDate") + + print(f"## [{title}]({url})") + if content: + print(f"\n{content}") + meta = [] + if engines: + meta.append(f"engines: {','.join(engines)}") + if category: + meta.append(f"category: {category}") + if score: + meta.append(f"score: {score}") + if date: + meta.append(f"date: {date}") + if meta: + print(f"\n*{' | '.join(meta)}*") + print() + + suggestions = data.get("suggestions", []) + if suggestions: + print(f"**Suggestions:** {', '.join(suggestions)}\n") + + corrections = data.get("corrections", []) + if corrections: + print(f"**Corrections:** {', '.join(corrections)}\n") + + total = len(results) + shown = min(max_results, total) + print("---") + print(f"Showing {shown} of {total} results (page {page})") + + +# ---------- main ---------- + +def main(): + parser = argparse.ArgumentParser(description="Search the web using SearXNG") + parser.add_argument("query", nargs="+", help="Search query") + parser.add_argument("-c", "--categories", help="Comma-separated categories (e.g. general,news,images)") + parser.add_argument("-e", "--engines", help="Comma-separated engines (e.g. google,duckduckgo)") + parser.add_argument("-l", "--language", help="Language code (e.g. en, zh-CN)") + parser.add_argument("-p", "--page", type=int, default=1, help="Page number (default: 1)") + parser.add_argument("-t", "--time-range", choices=["day", "month", "year"], help="Time range filter") + parser.add_argument("-n", "--max-results", type=int, help="Maximum results to display") + parser.add_argument("-s", "--safesearch", type=int, choices=[0, 1, 2], help="Safe search level: 0, 1, 2") + + args = parser.parse_args() + args.query = " ".join(args.query) + + config = load_config() + + if args.max_results is None: + args.max_results = config.get("default_max_results", 5) + + timeout = config.get("timeout", 30) + + req = build_request(config, args) + opener = _build_opener() + + try: + with opener.open(req, timeout=timeout) as resp: + raw = resp.read(MAX_RESPONSE_BYTES + 1) + if len(raw) > MAX_RESPONSE_BYTES: + die(f"SearXNG response exceeded the {MAX_RESPONSE_BYTES}-byte limit") + try: + body = raw.decode("utf-8") + except UnicodeDecodeError: + die("SearXNG returned a non-UTF-8 response body") + except urllib.error.HTTPError as e: + # Authentication-class responses (401/403/407) frequently + # reflect back the credentials the server saw. Even with + # exact-secret redaction, refusing to echo the body for these + # codes removes a residual leak surface. + if e.code in _AUTH_ERROR_CODES: + die( + f"HTTP {e.code}: body suppressed to avoid leaking " + "credentials (check base_url, auth credentials, and " + "SearXNG instance configuration)" + ) + # Redirect-class responses (30x) only reach us via + # `_SafeRedirectHandler`, which raises HTTPError with fp=None + # and a diagnostic in `e.msg` / `e.reason`. Show that diagnostic + # instead of an empty body so the user knows how to fix + # `base_url`. + if 300 <= e.code < 400: + diagnostic = getattr(e, "msg", None) or str(e) or "redirect blocked" + die(f"HTTP {e.code}: {diagnostic}") + try: + body = e.read(MAX_RESPONSE_BYTES + 1).decode() if e.fp else "" + except Exception: + body = "" + die(f"HTTP {e.code}: {body[:500]}") + except urllib.error.URLError as e: + die(f"Request failed: {e.reason}") + except TimeoutError: + die(f"Request timed out after {timeout}s") + + try: + data = json.loads(body) + except json.JSONDecodeError: + die(f"Invalid JSON response from SearXNG. Response: {body[:500]}") + + if not isinstance(data, dict): + die("Invalid JSON response from SearXNG: expected a JSON object at the top level") + + if "error" in data: + die(f"SearXNG returned error: {data['error']}") + + format_results(data, args.max_results, args.page) + + +if __name__ == "__main__": + main() diff --git a/plugins/Fectivnfy112357/searxng-search/skills/searxng-search/scripts/tests/__init__.py b/plugins/Fectivnfy112357/searxng-search/skills/searxng-search/scripts/tests/__init__.py new file mode 100644 index 0000000..7c719ba --- /dev/null +++ b/plugins/Fectivnfy112357/searxng-search/skills/searxng-search/scripts/tests/__init__.py @@ -0,0 +1 @@ +"""Marks `tests` as a Python package so unittest discovery works.""" diff --git a/plugins/Fectivnfy112357/searxng-search/skills/searxng-search/scripts/tests/_fixtures.py b/plugins/Fectivnfy112357/searxng-search/skills/searxng-search/scripts/tests/_fixtures.py new file mode 100644 index 0000000..92baa42 --- /dev/null +++ b/plugins/Fectivnfy112357/searxng-search/skills/searxng-search/scripts/tests/_fixtures.py @@ -0,0 +1,59 @@ +"""Shared test fixtures for the searxng-search test suite.""" +from __future__ import annotations + +import urllib.error + + +class FakeResponse: + """Minimal stand-in for the context manager returned by `urlopen`.""" + + def __init__(self, body: bytes = b"", status: int = 200) -> None: + self._body = body + self.status = status + + def read(self, size: int = -1) -> bytes: + if size is None or size < 0: + return self._body + return self._body[:size] + + def __enter__(self): + return self + + def __exit__(self, *exc): + return False + + +class FakeHTTPError(urllib.error.HTTPError): + """Fake HTTPError for testing the error path. Subclasses + `urllib.error.HTTPError` so the script's `except` clause matches. + """ + + class _FP: + def __init__(self, body: bytes): + self._body = body + + def read(self, size: int = -1) -> bytes: + if size is None or size < 0: + return self._body + return self._body[:size] + + def close(self) -> None: + """No-op so urllib's addbase (_TemporaryFileWrapper) GC + cleanup does not raise AttributeError when the FakeHTTPError + is garbage-collected.""" + return None + + def __init__(self, code: int, body: bytes = b"", reason: str = "HTTP Error") -> None: + super().__init__( + url="http://dummy.local/", + code=code, + msg=reason, + hdrs=None, + fp=FakeHTTPError._FP(body), + ) + self._body = body + + def read(self, size: int = -1) -> bytes: + if size is None or size < 0: + return self._body + return self._body[:size] diff --git a/plugins/Fectivnfy112357/searxng-search/skills/searxng-search/scripts/tests/test_auth_header.py b/plugins/Fectivnfy112357/searxng-search/skills/searxng-search/scripts/tests/test_auth_header.py new file mode 100644 index 0000000..9c26b5f --- /dev/null +++ b/plugins/Fectivnfy112357/searxng-search/skills/searxng-search/scripts/tests/test_auth_header.py @@ -0,0 +1,149 @@ +"""Review point 1+4: the Authorization header is constructed correctly for +bearer and basic auth, and the resolved values flow from the config into +the header without leaking into the URL. + +`build_request` is a pure function over `(config, args)`; we drive it +directly without touching the network. +""" +import argparse +import base64 +import io +import os +import sys +import unittest +from unittest import mock + +sys.path.insert(0, os.path.dirname(os.path.dirname(os.path.abspath(__file__)))) + +import search + + +def _args(**over): + base = dict( + query="q", categories=None, engines=None, language=None, + page=1, time_range=None, max_results=None, safesearch=None, + ) + base.update(over) + return argparse.Namespace(**base) + + +class TestBuildRequestAuthHeader(unittest.TestCase): + def test_bearer_header_carries_token(self): + config = {"base_url": "https://searx.example.com", "auth": {"type": "bearer", "token": "abc123"}} + req = search.build_request(config, _args()) + self.assertEqual(req.get_header("Authorization"), "Bearer abc123") + + def test_bearer_resolves_env_var(self): + config = {"base_url": "https://searx.example.com", "auth": {"type": "bearer", "token": "$SEARXNG_TOKEN"}} + with mock.patch.dict(os.environ, {"SEARXNG_TOKEN": "secret-value"}): + req = search.build_request(config, _args()) + self.assertEqual(req.get_header("Authorization"), "Bearer secret-value") + + def test_basic_header_is_base64_user_colon_pass(self): + config = { + "base_url": "https://searx.example.com", + "auth": {"type": "basic", "user": "admin", "pass": "pw"}, + } + req = search.build_request(config, _args()) + creds = req.get_header("Authorization") + self.assertTrue(creds.startswith("Basic ")) + encoded = creds.split(" ", 1)[1] + self.assertEqual(base64.b64decode(encoded).decode(), "admin:pw") + + def test_basic_resolves_env_vars(self): + config = { + "base_url": "https://searx.example.com", + "auth": {"type": "basic", "user": "$SEARXNG_USER", "pass": "$SEARXNG_PASS"}, + } + with mock.patch.dict(os.environ, {"SEARXNG_USER": "u1", "SEARXNG_PASS": "p1"}): + req = search.build_request(config, _args()) + creds = req.get_header("Authorization") + self.assertEqual(base64.b64decode(creds.split(" ", 1)[1]).decode(), "u1:p1") + + def test_no_auth_section_sends_no_authorization_header(self): + config = {"base_url": "https://searx.example.com"} + req = search.build_request(config, _args()) + self.assertIsNone(req.get_header("Authorization")) + + def test_token_does_not_leak_into_url(self): + config = {"base_url": "https://searx.example.com", "auth": {"type": "bearer", "token": "abc123"}} + req = search.build_request(config, _args()) + self.assertNotIn("abc123", req.full_url) + self.assertIn("q=q", req.full_url) + + +def _get_header_ci(req, name): + """urllib stores header names via `key.capitalize()` and `get_header` + is an exact dict lookup, so look the value up case-insensitively.""" + for key, value in req.header_items(): + if key.lower() == name.lower(): + return value + return None + + +class TestBuildRequestCustomHeaders(unittest.TestCase): + """Custom `headers` config values get the same treatment as auth.*: + `$ENV_VAR` references are resolved (instead of being sent as the + literal `$NAME` text), and env-referenced values are registered for + exact redaction in later error output.""" + + def test_plain_header_passes_through(self): + config = {"base_url": "https://searx.example.com", "headers": {"X-Custom": "value"}} + req = search.build_request(config, _args()) + self.assertEqual(_get_header_ci(req, "X-Custom"), "value") + + def test_env_var_header_is_resolved(self): + config = {"base_url": "https://searx.example.com", "headers": {"X-API-Key": "$SEARXNG_API_KEY"}} + with mock.patch.dict(os.environ, {"SEARXNG_API_KEY": "hunter2"}): + req = search.build_request(config, _args()) + self.assertEqual(_get_header_ci(req, "X-API-Key"), "hunter2") + + def test_env_var_header_registers_secret_for_redaction(self): + search.reset_active_secrets() + config = {"base_url": "https://searx.example.com", "headers": {"X-API-Key": "$SEARXNG_API_KEY"}} + with mock.patch.dict(os.environ, {"SEARXNG_API_KEY": "hunter2"}): + search.build_request(config, _args()) + out = search.redact_secrets("server echoed hunter2 in error body") + self.assertNotIn("hunter2", out) + self.assertIn("***", out) + search.reset_active_secrets() + + def test_missing_env_var_header_exits(self): + err = io.StringIO() + config = {"base_url": "https://searx.example.com", "headers": {"X-Key": "$MISSING_VAR"}} + with mock.patch.dict(os.environ, {}, clear=True), \ + mock.patch.object(sys, "stderr", err), \ + mock.patch.object(search.sys, "exit", side_effect=SystemExit) as e: + with self.assertRaises(SystemExit): + search.build_request(config, _args()) + self.assertIn("MISSING_VAR is not set", err.getvalue()) + + def test_non_string_header_value_exits(self): + err = io.StringIO() + config = {"base_url": "https://searx.example.com", "headers": {"X-Num": 42}} + with mock.patch.object(sys, "stderr", err), \ + mock.patch.object(search.sys, "exit", side_effect=SystemExit) as e: + with self.assertRaises(SystemExit): + search.build_request(config, _args()) + self.assertIn("X-Num", err.getvalue()) + self.assertIn("headers", err.getvalue()) + + +class TestUserAgent(unittest.TestCase): + def test_user_agent_mentions_real_repo(self): + req = search.build_request({"base_url": "https://searx.example.com"}, _args()) + ua = req.get_header("User-agent") + self.assertIn("Fectivnfy112357", ua) + self.assertNotIn("XYenon", ua) + + def test_user_agent_overridden_by_config_headers(self): + config = { + "base_url": "https://searx.example.com", + "headers": {"User-Agent": "my-agent/9.9"}, + } + req = search.build_request(config, _args()) + self.assertEqual(req.get_header("User-agent"), "my-agent/9.9") + + +if __name__ == "__main__": + unittest.main() diff --git a/plugins/Fectivnfy112357/searxng-search/skills/searxng-search/scripts/tests/test_config.py b/plugins/Fectivnfy112357/searxng-search/skills/searxng-search/scripts/tests/test_config.py new file mode 100644 index 0000000..b9a99dc --- /dev/null +++ b/plugins/Fectivnfy112357/searxng-search/skills/searxng-search/scripts/tests/test_config.py @@ -0,0 +1,199 @@ +"""Review point 3: config loading is robust to missing files, malformed +TOML/JSON, missing env vars, and overly-permissive file modes (POSIX). + +We exercise the real config-loading path with `tempfile.TemporaryDirectory` +so the tests cover the actual TOML/JSON parse + env-var resolve + perm-check +pipeline rather than a mock that drifts from the real code. + +`die` and `warn` print to stderr and call `sys.exit`. We capture stderr +directly to verify the message; the exit code is incidental. +""" +import io +import os +import sys +import tempfile +import unittest +from unittest import mock + +sys.path.insert(0, os.path.dirname(os.path.dirname(os.path.abspath(__file__)))) + +import search + + +def _capture(callable_): + """Run `callable_()` with `sys.exit` raising and stderr captured. + Return (exit_code, stderr_text).""" + err = io.StringIO() + rc = {"v": None} + def _exit(code=0): + rc["v"] = code + raise SystemExit(code) + with mock.patch.object(sys, "stderr", err), \ + mock.patch.object(search.sys, "exit", side_effect=_exit): + try: + callable_() + except SystemExit: + pass + return rc["v"], err.getvalue() + + +class TestLoadConfigErrors(unittest.TestCase): + def test_no_toml_no_json_exits(self): + with tempfile.TemporaryDirectory() as d, \ + mock.patch.dict(os.environ, {"XDG_CONFIG_HOME": d}, clear=True): + rc, err = _capture(search.load_config) + self.assertEqual(rc, 1) + self.assertIn("Config file not found", err) + + def test_invalid_toml_exits(self): + with tempfile.TemporaryDirectory() as d, \ + mock.patch.dict(os.environ, {"XDG_CONFIG_HOME": d}, clear=True), \ + mock.patch.object(search, "validate_base_url"), \ + mock.patch.object(search, "check_file_permissions"): + os.makedirs(os.path.join(d, "agents"), exist_ok=True) + with open(os.path.join(d, "agents", "searxng.toml"), "w") as f: + f.write("[auth\ntype = broken") + rc, err = _capture(search.load_config) + self.assertEqual(rc, 1) + self.assertIn("Invalid TOML", err) + + def test_missing_base_url_exits(self): + with tempfile.TemporaryDirectory() as d, \ + mock.patch.dict(os.environ, {"XDG_CONFIG_HOME": d}, clear=True), \ + mock.patch.object(search, "validate_base_url"), \ + mock.patch.object(search, "check_file_permissions"): + os.makedirs(os.path.join(d, "agents"), exist_ok=True) + with open(os.path.join(d, "agents", "searxng.toml"), "w") as f: + f.write('default_categories = ["general"]\n') + rc, err = _capture(search.load_config) + self.assertEqual(rc, 1) + self.assertIn("base_url is required", err) + + def test_valid_config_loads_with_url_check(self): + with tempfile.TemporaryDirectory() as d, \ + mock.patch.dict(os.environ, {"XDG_CONFIG_HOME": d}, clear=True), \ + mock.patch.object(search, "check_file_permissions") as cp: + os.makedirs(os.path.join(d, "agents"), exist_ok=True) + with open(os.path.join(d, "agents", "searxng.toml"), "w") as f: + f.write('base_url = "https://searx.example.com"\n') + config = search.load_config() + self.assertEqual(config["base_url"], "https://searx.example.com") + cp.assert_called_once() + + +class TestResolveEnv(unittest.TestCase): + def test_unset_env_var_exits_with_message(self): + with mock.patch.dict(os.environ, {}, clear=True): + rc, err = _capture(lambda: search.resolve_env("$SEARXNG_TOKEN")) + self.assertEqual(rc, 1) + self.assertIn("SEARXNG_TOKEN is not set", err) + + def test_brace_syntax_is_stripped(self): + with mock.patch.dict(os.environ, {"SEARXNG_TOKEN": "abc"}): + self.assertEqual(search.resolve_env("${SEARXNG_TOKEN}"), "abc") + + def test_plain_string_passes_through(self): + self.assertEqual(search.resolve_env("plain-literal"), "plain-literal") + + def test_empty_string_passes_through(self): + self.assertEqual(search.resolve_env(""), "") + + +class TestConfigFieldTypes(unittest.TestCase): + """Numeric config fields must be integers. A hand-edited TOML with + `timeout = "30"` used to raise a raw TypeError from urllib (and + `default_max_results = "5"` from result slicing); both now fail + fast with a clear message during load_config. + """ + + def _write(self, d, body): + os.makedirs(os.path.join(d, "agents"), exist_ok=True) + with open(os.path.join(d, "agents", "searxng.toml"), "w") as f: + f.write(body) + + def test_timeout_as_string_exits(self): + with tempfile.TemporaryDirectory() as d, \ + mock.patch.dict(os.environ, {"XDG_CONFIG_HOME": d}, clear=True), \ + mock.patch.object(search, "validate_base_url"), \ + mock.patch.object(search, "check_file_permissions"): + self._write(d, 'base_url = "https://searx.example.com"\ntimeout = "30"\n') + rc, err = _capture(search.load_config) + self.assertEqual(rc, 1) + self.assertIn("timeout", err) + self.assertIn("must be an integer", err) + + def test_default_max_results_as_string_exits(self): + with tempfile.TemporaryDirectory() as d, \ + mock.patch.dict(os.environ, {"XDG_CONFIG_HOME": d}, clear=True), \ + mock.patch.object(search, "validate_base_url"), \ + mock.patch.object(search, "check_file_permissions"): + self._write(d, 'base_url = "https://searx.example.com"\ndefault_max_results = "5"\n') + rc, err = _capture(search.load_config) + self.assertEqual(rc, 1) + self.assertIn("default_max_results", err) + self.assertIn("must be an integer", err) + + def test_boolean_is_rejected(self): + # `True` is an int subclass; treat it as a config typo, not a number. + with tempfile.TemporaryDirectory() as d, \ + mock.patch.dict(os.environ, {"XDG_CONFIG_HOME": d}, clear=True), \ + mock.patch.object(search, "validate_base_url"), \ + mock.patch.object(search, "check_file_permissions"): + self._write(d, 'base_url = "https://searx.example.com"\ntimeout = true\n') + rc, err = _capture(search.load_config) + self.assertEqual(rc, 1) + self.assertIn("timeout", err) + + def test_valid_integers_load_ok(self): + with tempfile.TemporaryDirectory() as d, \ + mock.patch.dict(os.environ, {"XDG_CONFIG_HOME": d}, clear=True), \ + mock.patch.object(search, "check_file_permissions"): + self._write(d, 'base_url = "https://searx.example.com"\ntimeout = 12\ndefault_max_results = 3\n') + config = search.load_config() + self.assertEqual(config["timeout"], 12) + self.assertEqual(config["default_max_results"], 3) + + +class TestFilePermissions(unittest.TestCase): + def test_world_readable_warns(self): + if os.name != "posix": + self.skipTest("POSIX-mode-bits check is skipped on non-POSIX platforms") + # Drive the check by patching os.stat so the assertion is portable + # across platforms (Windows doesn't preserve 0o600 reliably). + fake_stat = type("S", (), {"st_mode": 0o644})() + with mock.patch.object(search.os, "stat", return_value=fake_stat), \ + mock.patch.object(search, "warn", wraps=search.warn) as w: + search.check_file_permissions("/dummy/path") + self.assertTrue(w.called) + self.assertIn("readable beyond the owner", str(w.call_args)) + + def test_owner_only_does_not_warn(self): + if os.name != "posix": + self.skipTest("POSIX-mode-bits check is skipped on non-POSIX platforms") + fake_stat = type("S", (), {"st_mode": 0o600})() + with mock.patch.object(search.os, "stat", return_value=fake_stat), \ + mock.patch.object(search, "warn", wraps=search.warn) as w: + search.check_file_permissions("/dummy/path") + self.assertFalse(w.called) + + def test_missing_file_silently_skips(self): + with mock.patch.object(search.os, "stat", side_effect=OSError("missing")), \ + mock.patch.object(search, "warn", wraps=search.warn) as w: + search.check_file_permissions("/does/not/exist") + self.assertFalse(w.called) + + def test_skipped_on_non_posix(self): + # Even with a "world-readable" stat, the check is a no-op on + # non-POSIX because `st_mode` is a synthetic value on Windows + # and not derived from the file's real ACL. + if os.name == "posix": + self.skipTest("POSIX-only assertion") + fake_stat = type("S", (), {"st_mode": 0o644})() + with mock.patch.object(search.os, "stat", return_value=fake_stat), \ + mock.patch.object(search, "warn", wraps=search.warn) as w: + search.check_file_permissions("/dummy/path") + self.assertFalse(w.called) + + +if __name__ == "__main__": + unittest.main() diff --git a/plugins/Fectivnfy112357/searxng-search/skills/searxng-search/scripts/tests/test_invalid_response.py b/plugins/Fectivnfy112357/searxng-search/skills/searxng-search/scripts/tests/test_invalid_response.py new file mode 100644 index 0000000..719128f --- /dev/null +++ b/plugins/Fectivnfy112357/searxng-search/skills/searxng-search/scripts/tests/test_invalid_response.py @@ -0,0 +1,190 @@ +"""Review point 4 + P1 review round 2: error paths for non-JSON responses, +HTTPError body echo, SearXNG structured error response, and authentication +error body suppression. The script must exit non-zero and (in the body-echo +case) not leak credentials that the server reflected back. +""" +import argparse +import io +import os +import sys +import unittest +from unittest import mock + +sys.path.insert(0, os.path.dirname(os.path.dirname(os.path.abspath(__file__)))) + +import search +from _fixtures import FakeHTTPError, FakeResponse + + +def _args(**over): + base = dict( + query="q", categories=None, engines=None, language=None, + page=1, time_range=None, max_results=None, safesearch=None, + ) + base.update(over) + return argparse.Namespace(**base) + + +def _capture_main(argv, open_side_effect, load_config_mock=None): + """Invoke `search.main()` with patched argv / opener / load_config, + return (exit_code, stderr_text). + + `open_side_effect` is the value/exception that the mocked opener.open + will produce when main() calls it. + """ + if load_config_mock is None: + load_config_mock = mock.Mock(return_value={"base_url": "https://searx.example.com"}) + err = io.StringIO() + open_mock = mock.Mock(side_effect=open_side_effect) if isinstance(open_side_effect, BaseException) or ( + callable(open_side_effect) and not isinstance(open_side_effect, mock.Mock) + ) else mock.Mock(return_value=open_side_effect) + opener = mock.Mock() + opener.open = open_mock + with mock.patch.object(sys, "argv", ["search.py"] + argv), \ + mock.patch.object(sys, "stderr", err), \ + mock.patch.object(search, "load_config", load_config_mock), \ + mock.patch.object(search, "build_request", + mock.Mock(return_value=mock.Mock(get_header=mock.Mock(return_value=None)))), \ + mock.patch.object(search, "_build_opener", return_value=opener), \ + mock.patch.object(search.sys, "exit", side_effect=SystemExit) as e: + try: + search.main() + except SystemExit: + pass + return e.call_args[0][0] if e.call_args else None, err.getvalue() + + +class TestInvalidResponse(unittest.TestCase): + def test_non_json_response_exits_with_diagnostic(self): + body = b"oops not json" + rc, err = _capture_main(["q"], FakeResponse(body)) + self.assertEqual(rc, 1) + self.assertIn("Invalid JSON", err) + self.assertIn("oops not json", err) + + def test_searxng_structured_error_exits(self): + body = b'{"error": "instance disabled"}' + rc, err = _capture_main(["q"], FakeResponse(body)) + self.assertEqual(rc, 1) + self.assertIn("SearXNG returned error", err) + self.assertIn("instance disabled", err) + + def test_http_error_echoes_body_redacted(self): + # 500 is not auth-class, so body IS echoed. The "server" reflects + # back an Authorization header in its error body which must be + # masked via shape-based redaction. + body = b'{"detail": "got Authorization: Basic ' + (b"A" * 60) + b'"}' + rc, err = _capture_main(["q"], FakeHTTPError(500, body)) + self.assertEqual(rc, 1) + self.assertIn("HTTP 500", err) + # The reflected credential must be masked, not echoed. + self.assertNotIn(b"A" * 60, err.encode()) + self.assertIn("Authorization: Basic ***", err) + + def test_url_error_reports_reason(self): + from urllib.error import URLError + rc, err = _capture_main(["q"], URLError("dns failure")) + self.assertEqual(rc, 1) + self.assertIn("Request failed", err) + self.assertIn("dns failure", err) + + def test_timeout_error_reports_configured_timeout(self): + rc, err = _capture_main(["q"], TimeoutError("")) + # We can't easily test the actual timeout from the opener's + # perspective without hitting the network, but we can verify + # the error message uses the configured value. + self.assertEqual(rc, 1) + self.assertIn("Request timed out", err) + self.assertIn("30s", err) # default + + +class TestAuthErrorBodySuppression(unittest.TestCase): + """P1 review round 2: 401/403/407 bodies must not be echoed to stderr. + + These codes are the canonical "your credentials are wrong" responses, + and SearXNG-style instances have been observed to reflect the + Authorization header back in the body. Shape-based redaction can't + cover every variant; suppressing the body entirely for these codes + closes the leak surface. + """ + + def test_401_body_is_suppressed(self): + # Even if the body contains a long-shape bearer token, it must + # not appear in the error output. + body = b'{"detail": "got Bearer ghp_aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa"}' + rc, err = _capture_main(["q"], FakeHTTPError(401, body)) + self.assertEqual(rc, 1) + self.assertIn("HTTP 401", err) + self.assertIn("body suppressed", err) + # Body bytes must not leak. + self.assertNotIn(b"Bearer ghp_", err.encode()) + self.assertNotIn(b"got Bearer", err.encode()) + + def test_403_body_is_suppressed(self): + body = b'{"detail": "forbidden: bad credentials xyz123"}' + rc, err = _capture_main(["q"], FakeHTTPError(403, body)) + self.assertEqual(rc, 1) + self.assertIn("HTTP 403", err) + self.assertIn("body suppressed", err) + self.assertNotIn(b"xyz123", err.encode()) + self.assertNotIn(b"forbidden: bad", err.encode()) + + def test_407_body_is_suppressed(self): + # Proxy auth challenge — same class of leak. + body = b'{"detail": "Proxy-Authenticate: Basic realm=svc"}' + rc, err = _capture_main(["q"], FakeHTTPError(407, body)) + self.assertEqual(rc, 1) + self.assertIn("HTTP 407", err) + self.assertIn("body suppressed", err) + self.assertNotIn(b"Proxy-Authenticate", err.encode()) + + def test_500_body_is_NOT_suppressed(self): + # 500 is not auth-class; the body should still be echoed (and + # run through redact_secrets as usual). This guards against + # accidentally widening the suppression to all HTTPError codes. + body = b'{"error": "internal server error"}' + rc, err = _capture_main(["q"], FakeHTTPError(500, body)) + self.assertEqual(rc, 1) + self.assertIn("HTTP 500", err) + self.assertIn("internal server error", err) + self.assertNotIn("body suppressed", err) + + +class TestResponseShapeRobustness(unittest.TestCase): + """Robustness beyond the P1 review: a malformed-but-200 response must + produce a clean diagnostic, not a raw traceback that bypasses the + redacted-error pipeline.""" + + def test_json_array_root_exits_with_diagnostic(self): + # A proxy/login page or misconfigured endpoint can return a JSON + # array; the script used to crash with AttributeError on data.get(). + rc, err = _capture_main(["q"], FakeResponse(b'[{"x": 1}]')) + self.assertEqual(rc, 1) + self.assertIn("JSON object", err) + + def test_non_utf8_body_exits_with_diagnostic(self): + # A 200 response in latin-1/GBK used to raise an uncaught + # UnicodeDecodeError. + rc, err = _capture_main(["q"], FakeResponse('{"results": [{"title": "caf\u00e9"}]}'.encode("latin-1"))) + self.assertEqual(rc, 1) + self.assertIn("non-UTF-8", err) + + def test_oversized_response_exits_with_diagnostic(self): + with mock.patch.object(search, "MAX_RESPONSE_BYTES", 16): + rc, err = _capture_main(["q"], FakeResponse(b"x" * 100)) + self.assertEqual(rc, 1) + self.assertIn("limit", err) + self.assertNotIn(b"x" * 16, err.encode()) # body not echoed + + def test_oversized_http_error_body_is_capped(self): + # The HTTPError path reads the body with the same cap; a huge + # error body must not be slurped into memory or echoed wholesale. + with mock.patch.object(search, "MAX_RESPONSE_BYTES", 32): + rc, err = _capture_main(["q"], FakeHTTPError(500, b"z" * 1000)) + self.assertEqual(rc, 1) + self.assertIn("HTTP 500", err) + self.assertLess(len(err), 200) # truncated, not 1000 bytes + + +if __name__ == "__main__": + unittest.main() diff --git a/plugins/Fectivnfy112357/searxng-search/skills/searxng-search/scripts/tests/test_redaction.py b/plugins/Fectivnfy112357/searxng-search/skills/searxng-search/scripts/tests/test_redaction.py new file mode 100644 index 0000000..008196e --- /dev/null +++ b/plugins/Fectivnfy112357/searxng-search/skills/searxng-search/scripts/tests/test_redaction.py @@ -0,0 +1,174 @@ +"""Review point 4: on error, sensitive info that leaked into stderr is masked. + +`redact_secrets` covers 5 credential shapes. `die` and `warn` route +through it before printing. We also assert the `Authorization: Basic` +header value is masked in error output. + +P1 review round 2 (point 3): the exact-secret registry (`register_secret` / +`reset_active_secrets`) must mask short or otherwise shape-non-conforming +tokens that the pattern-based redaction cannot catch. +""" +import contextlib +import io +import os +import sys +import unittest +from unittest import mock + +sys.path.insert(0, os.path.dirname(os.path.dirname(os.path.abspath(__file__)))) + +import search + + +TOKEN = "ghp_1234567890abcdefghijklmnopqrstuvwxyz" +FPAT = "github_pat_ABCDEFGHIJKLMNOPQRSTUVWXYZ_abcdefghijklmnopqrstuvwxyz" +BEARER_FULL = "Bearer " + TOKEN +BASIC_FULL = "Authorization: Basic " + ("A" * 60) + + +class TestRedactSecrets(unittest.TestCase): + def setUp(self): + # Ensure each test starts with a clean registry. + search.reset_active_secrets() + + def tearDown(self): + search.reset_active_secrets() + + def test_redacts_credential_shapes(self): + cases = { + TOKEN: "ghx_***", + FPAT: "github_pat_***", + BEARER_FULL: "Bearer ***", + "token=supersecretvalue": "token=***", + "SEARXNG_TOKEN=ghp_abc": "SEARXNG_TOKEN=***", + } + for raw, expected in cases.items(): + self.assertEqual(search.redact_secrets(raw), expected, raw) + + def test_redacts_authorization_basic_header_value(self): + self.assertEqual(search.redact_secrets(BASIC_FULL), "Authorization: Basic ***") + + def test_leaves_ordinary_text_intact(self): + msg = "HTTP 401: unauthorized (attempt 3/3)" + self.assertEqual(search.redact_secrets(msg), msg) + + +class TestExactSecretRegistry(unittest.TestCase): + """P1 review round 2 (point 3): shape-based redaction cannot cover + every token variant. The active-request secret registry + (`register_secret`) provides exact-value replacement as a primary + defense; shape patterns are a fallback. + + These tests pin down the registry's contract: + - Short tokens that don't match any shape ARE masked after registration. + - A substring secret is replaced before its containing string + (longest-first). + - `reset_active_secrets` clears the registry. + - Empty / falsy values are no-ops. + - Duplicates don't grow the set. + """ + + def setUp(self): + search.reset_active_secrets() + + def tearDown(self): + search.reset_active_secrets() + + def test_short_token_is_masked_after_registration(self): + # 8 chars — well below the 20-char shape threshold for `gh[pousr]_`. + # Without the registry, this would pass through; with it, it must + # be replaced. + short = "abc12345" + search.register_secret(short) + out = search.redact_secrets(f"server saw header value {short} in trace") + self.assertNotIn(short, out) + self.assertIn("***", out) + self.assertIn("server saw header value *** in trace", out) + + def test_non_shape_token_is_masked_after_registration(self): + # A bespoke internal token format with no matching shape pattern. + bespoke = "internal-token-zzzz-9999" + search.register_secret(bespoke) + out = search.redact_secrets(f"upstream error: {bespoke} is not authorized") + self.assertNotIn(bespoke, out) + self.assertIn("upstream error: *** is not authorized", out) + + def test_unregistered_short_token_is_NOT_masked(self): + # Counter-test: if a token was never registered, we don't claim + # to mask it. The pattern-based fallback is best-effort and may + # miss it. This pins down the contract that the registry is the + # primary defense. + short = "abc12345" + out = search.redact_secrets(f"server saw {short} in trace") + self.assertIn(short, out) + + def test_substring_secret_replaced_before_container(self): + # "abc" is a substring of "abcdef". When both are registered, the + # longer one must be replaced first so the shorter one isn't + # masked by a leftover. + search.register_secret("abc") + search.register_secret("abcdef") + out = search.redact_secrets("value=abcdef in payload") + self.assertNotIn("abcdef", out) + # After both replacements: "value=*** in payload" (if "abcdef" is + # replaced first) or "value=***def in payload" (if "abc" is first + # but the leftover `def` is harmless). + # The contract is: no registered value may remain. + self.assertNotIn("abc", out) + self.assertNotIn("abcdef", out) + + def test_reset_clears_registry(self): + search.register_secret("abc12345") + out1 = search.redact_secrets("leak abc12345 here") + self.assertNotIn("abc12345", out1) + search.reset_active_secrets() + # After reset, the same value is no longer masked. + out2 = search.redact_secrets("leak abc12345 here") + self.assertIn("abc12345", out2) + + def test_empty_value_is_noop(self): + # Empty / falsy registrations must not pollute the registry or + # match unrelated text. + search.register_secret("") + search.register_secret(None) if False else None # type: ignore[arg-type] + out = search.redact_secrets("normal text with *** placeholders") + # No replacement should happen for an empty registered value + # (replace("", "***") would replace every empty position, so we + # guard against that). + self.assertNotEqual(out, "normal text with ******** placeholders") + + def test_duplicate_registration_is_idempotent(self): + # The set is deduplicated; repeated registration must not change + # output and must not change set size meaningfully. + for _ in range(5): + search.register_secret("abc12345") + out = search.redact_secrets("leak abc12345 here") + self.assertNotIn("abc12345", out) + self.assertIn("***", out) + + +class TestStderrRedaction(unittest.TestCase): + def setUp(self): + search.reset_active_secrets() + + def tearDown(self): + search.reset_active_secrets() + + def test_die_redacts_token_and_exits(self): + err = io.StringIO() + with mock.patch.object(sys, "stderr", err), self.assertRaises(SystemExit) as cm: + search.die("failed: " + TOKEN) + self.assertEqual(cm.exception.code, 1) + self.assertNotIn(TOKEN, err.getvalue()) + self.assertIn("***", err.getvalue()) + + def test_warn_redacts_authorization_basic(self): + err = io.StringIO() + with mock.patch.object(sys, "stderr", err): + search.warn("upstream sent: " + BASIC_FULL) + self.assertNotIn("A" * 60, err.getvalue()) + self.assertIn("Authorization: Basic ***", err.getvalue()) + + +if __name__ == "__main__": + unittest.main() diff --git a/plugins/Fectivnfy112357/searxng-search/skills/searxng-search/scripts/tests/test_redirect.py b/plugins/Fectivnfy112357/searxng-search/skills/searxng-search/scripts/tests/test_redirect.py new file mode 100644 index 0000000..783189f --- /dev/null +++ b/plugins/Fectivnfy112357/searxng-search/skills/searxng-search/scripts/tests/test_redirect.py @@ -0,0 +1,198 @@ +"""P1 review round 2 (point 1): HTTP redirects must not forward the +Authorization header to a different origin (or a different scheme). + +The default `urllib.request.HTTPRedirectHandler` re-issues a 30x response +to the `Location:` URL *with* the original Authorization header. A +misconfigured `base_url` (e.g. an instance fronted by a load balancer +that 302s to a different port) would leak the Bearer / Basic token to a +host the user never explicitly trusted, and would also permit a silent +HTTPS->HTTP downgrade. + +`_SafeRedirectHandler` rejects all 3xx with a clear diagnostic. These +tests assert the rejection path and the diagnostic content for every +supported status code, and assert the *Location:* URL printed to the +user does not echo credentials from the original request. +""" +import argparse +import io +import os +import sys +import unittest +import urllib.error +import urllib.request +from unittest import mock + +sys.path.insert(0, os.path.dirname(os.path.dirname(os.path.abspath(__file__)))) + +import search +from _fixtures import FakeResponse # noqa: F401 (parity with other test files) + + +class TestSafeRedirectHandlerBlocksAllRedirects(unittest.TestCase): + """Every supported 3xx code must raise HTTPError with a clear + diagnostic, and the diagnostic must mention the redirect so the user + knows to fix their `base_url` or front the instance with a same-origin + reverse proxy.""" + + def _assert_blocked(self, code: int, label_fragment: str, headers=None): + handler = search._SafeRedirectHandler() + req = urllib.request.Request("https://searx.example.com/search?q=x") + req.add_header("Authorization", "Bearer ghp_secretvalue_aaaaaaaaaaaaaaaa") + loc = headers if headers is not None else {"Location": "https://other.example.com:9000/search"} + method = getattr(handler, f"http_error_{code}") + with self.assertRaises(urllib.error.HTTPError) as cm: + method(req, mock.Mock(), code, "Found", loc) + self.assertEqual(cm.exception.code, code) + msg = str(cm.exception) + self.assertIn("redirect blocked", msg) + self.assertIn(label_fragment, msg) + # The diagnostic must show the redirect target so the user can fix + # their config — but stripped of any query/fragment that could carry + # credentials. + if "Location" in loc: + self.assertIn("other.example.com:9000", msg) + # Critical: the Authorization header value must NOT be echoed into + # the error message, even though it is on the request object. + self.assertNotIn("ghp_secretvalue_aaaaaaa", msg) + + def test_301_is_blocked(self): + self._assert_blocked(301, "301 Moved Permanently") + + def test_302_is_blocked(self): + self._assert_blocked(302, "302 Found") + + def test_303_is_blocked(self): + self._assert_blocked(303, "303 See Other") + + def test_307_is_blocked(self): + self._assert_blocked(307, "307 Temporary Redirect") + + def test_308_is_blocked(self): + self._assert_blocked(308, "308 Permanent Redirect") + + +class TestSafeRedirectHandlerDiagnosticShape(unittest.TestCase): + """The diagnostic must be useful (show scheme/host/port/path) but must + not echo the query string or fragment of the Location header. Those + are common places to embed tokens.""" + + def test_diagnostic_strips_query_and_fragment(self): + handler = search._SafeRedirectHandler() + req = urllib.request.Request("https://searx.example.com/search?q=x") + location = "https://other.example.com:9000/path?token=ghp_SECRETabcdefghij1234#frag" + with self.assertRaises(urllib.error.HTTPError) as cm: + handler.http_error_302(req, mock.Mock(), 302, "Found", {"Location": location}) + msg = str(cm.exception) + # Path is preserved. + self.assertIn("/path", msg) + # Query is stripped. + self.assertNotIn("token=", msg) + self.assertNotIn("ghp_SECRET", msg) + # Fragment is stripped. + self.assertNotIn("frag", msg) + + def test_diagnostic_handles_unparseable_location(self): + handler = search._SafeRedirectHandler() + req = urllib.request.Request("https://searx.example.com/search?q=x") + with mock.patch.object(search.urllib.parse, "urlparse", side_effect=ValueError("bad url")): + with self.assertRaises(urllib.error.HTTPError) as cm: + handler.http_error_302(req, mock.Mock(), 302, "Found", {"Location": "http://x"}) + msg = str(cm.exception) + self.assertIn("redirect blocked", msg) + self.assertIn("(unparseable)", msg) + + def test_diagnostic_handles_missing_location(self): + handler = search._SafeRedirectHandler() + req = urllib.request.Request("https://searx.example.com/search?q=x") + with self.assertRaises(urllib.error.HTTPError) as cm: + handler.http_error_302(req, mock.Mock(), 302, "Found", {}) + msg = str(cm.exception) + self.assertIn("redirect blocked", msg) + self.assertIn("(none)", msg) + + +class TestBuildOpenerUsesSafeHandler(unittest.TestCase): + """`_build_opener` must install the safe handler so the default urllib + redirect handler is NOT in play (which would forward Authorization).""" + + def test_opener_includes_safe_redirect_handler(self): + opener = search._build_opener() + # urllib stores handlers in `handlers` (list). We assert an instance + # of our safe class is registered. + safe_instances = [h for h in opener.handlers if isinstance(h, search._SafeRedirectHandler)] + self.assertEqual(len(safe_instances), 1, "_SafeRedirectHandler must be installed exactly once") + + def test_opener_does_not_install_default_redirect_handler(self): + opener = search._build_opener() + # If a generic HTTPRedirectHandler (not our subclass) is also + # installed, it would still chase redirects. urllib's build_opener + # REPLACES handlers passed to it, so only the safe one should be + # present. + from urllib.request import HTTPRedirectHandler + non_safe = [h for h in opener.handlers + if isinstance(h, HTTPRedirectHandler) and not isinstance(h, search._SafeRedirectHandler)] + self.assertEqual(non_safe, [], f"non-safe redirect handlers installed: {non_safe}") + + +class TestRedirectIntegrationInMain(unittest.TestCase): + """End-to-end: when the opener returns a 302, the script must exit + non-zero with the redirect diagnostic, and the Authorization header + must not be visible in the error output. + + This is the regression test for the *exact* bug the P1 review cited: + a loopback URL returning 302 to a different port, with the Bearer + token forwarded to the second origin. + """ + + def _run(self, base_url: str, auth_token: str = ""): + config = {"base_url": base_url} + if auth_token: + config["auth"] = {"type": "bearer", "token": auth_token} + + # Simulate a 302 from the opener: the safe handler would raise + # HTTPError("redirect blocked: ...") in real life, but here we + # mock the opener to short-circuit and raise the same error so + # the integration path through main() is exercised. + def _open_redirect(*a, **kw): + raise urllib.error.HTTPError( + url=base_url + "/search", + code=302, + msg="redirect blocked: 302 Found to https://other.example.com:9000/path", + hdrs=None, + fp=None, + ) + + err = io.StringIO() + open_mock = mock.Mock(side_effect=_open_redirect) + opener = mock.Mock() + opener.open = open_mock + + with mock.patch.object(sys, "argv", ["search.py", "q"]), \ + mock.patch.object(sys, "stderr", err), \ + mock.patch.object(search, "load_config", return_value=config), \ + mock.patch.object(search, "build_request", + mock.Mock(return_value=mock.Mock(get_header=mock.Mock(return_value=None)))), \ + mock.patch.object(search, "_build_opener", return_value=opener), \ + mock.patch.object(search.sys, "exit", side_effect=SystemExit) as e: + try: + search.main() + except SystemExit: + pass + return e.call_args[0][0] if e.call_args else None, err.getvalue() + + def test_main_exits_nonzero_on_redirect(self): + rc, err = self._run("https://searx.example.com") + self.assertEqual(rc, 1) + self.assertIn("redirect blocked", err) + + def test_main_does_not_echo_authorization_in_redirect_diagnostic(self): + # The Authorization header is on the request, but the error + # message must not echo it. + rc, err = self._run("https://searx.example.com", auth_token="ghp_aabbccddeeff00112233445566778899") + self.assertEqual(rc, 1) + self.assertIn("redirect blocked", err) + self.assertNotIn("ghp_aabbccddeeff", err) + + +if __name__ == "__main__": + unittest.main() diff --git a/plugins/Fectivnfy112357/searxng-search/skills/searxng-search/scripts/tests/test_timeout.py b/plugins/Fectivnfy112357/searxng-search/skills/searxng-search/scripts/tests/test_timeout.py new file mode 100644 index 0000000..2846869 --- /dev/null +++ b/plugins/Fectivnfy112357/searxng-search/skills/searxng-search/scripts/tests/test_timeout.py @@ -0,0 +1,45 @@ +"""Review point 4: the configured `timeout` is passed through to the +HTTP opener. We mock `_build_opener` (returns an opener whose `.open` +is the call we inspect) so the timeout kwarg is observable. +""" +import os +import sys +import unittest +from unittest import mock + +sys.path.insert(0, os.path.dirname(os.path.dirname(os.path.abspath(__file__)))) + +import search +from _fixtures import FakeResponse + + +def _run_main(load_config_return, argv=("search.py", "q")): + """Run `search.main()` with all network/config side effects mocked, + and return the opener.open mock so the test can inspect its call args.""" + open_mock = mock.Mock(return_value=FakeResponse(b'{"results":[]}')) + opener = mock.Mock() + opener.open = open_mock + with mock.patch.object(sys, "argv", list(argv)), \ + mock.patch.object(search, "load_config", return_value=load_config_return), \ + mock.patch.object(search, "build_request", return_value=mock.Mock()), \ + mock.patch.object(search, "_build_opener", return_value=opener), \ + mock.patch.object(search.sys, "exit", side_effect=SystemExit): + try: + search.main() + except SystemExit: + pass + return open_mock + + +class TestTimeoutConfig(unittest.TestCase): + def test_default_timeout_is_30(self): + u = _run_main({"base_url": "https://x"}) + self.assertEqual(u.call_args.kwargs.get("timeout"), 30) + + def test_custom_timeout_is_passed_through(self): + u = _run_main({"base_url": "https://x", "timeout": 7}) + self.assertEqual(u.call_args.kwargs.get("timeout"), 7) + + +if __name__ == "__main__": + unittest.main() diff --git a/plugins/Fectivnfy112357/searxng-search/skills/searxng-search/scripts/tests/test_url_validation.py b/plugins/Fectivnfy112357/searxng-search/skills/searxng-search/scripts/tests/test_url_validation.py new file mode 100644 index 0000000..a29f74a --- /dev/null +++ b/plugins/Fectivnfy112357/searxng-search/skills/searxng-search/scripts/tests/test_url_validation.py @@ -0,0 +1,92 @@ +"""Review point 1: base_url must default to HTTPS; plain HTTP is allowed +only for loopback hosts or for non-loopback hosts with explicit opt-in. + +`validate_base_url` exits on policy violation. We assert that: + - https to any host passes silently. + - http to loopback (127.0.0.1, ::1, localhost, 0.0.0.0) passes silently. + - http to a non-loopback host is rejected unless `allow_insecure_http = true`. + - when opted in, a warning is emitted and the call still passes. + - any other scheme (ftp, file, ...) is rejected. +""" +import contextlib +import io +import os +import sys +import unittest +from unittest import mock + +sys.path.insert(0, os.path.dirname(os.path.dirname(os.path.abspath(__file__)))) + +import search +from _fixtures import FakeResponse # noqa: F401 (imported for parity with other test files) + + +def _captured(callable_, *args, **kwargs): + """Run `callable_(args, kwargs)` and capture stderr/stdout. Returns + (returncode, stderr, stdout).""" + err, out = io.StringIO(), io.StringIO() + rc = {"v": None} + def _exit(code=0): + rc["v"] = code + raise SystemExit(code) + with mock.patch.object(search, "warn", wraps=search.warn) as _w, \ + mock.patch.object(search.sys, "stderr", err), \ + mock.patch.object(search.sys, "stdout", out), \ + mock.patch.object(search.sys, "exit", side_effect=_exit): + try: + callable_(*args, **kwargs) + except SystemExit: + pass + return rc["v"], err.getvalue(), out.getvalue() + + +class TestValidateBaseUrl(unittest.TestCase): + def test_https_to_any_host_passes(self): + rc, err, _ = _captured(search.validate_base_url, "https://searx.example.com", {}) + self.assertIsNone(rc, f"https should pass silently, got rc={rc} err={err!r}") + + def test_http_to_ipv4_loopback_passes(self): + rc, err, _ = _captured(search.validate_base_url, "http://127.0.0.1:8888", {}) + self.assertIsNone(rc, f"loopback ipv4 should pass, got rc={rc} err={err!r}") + + def test_http_to_localhost_passes(self): + rc, err, _ = _captured(search.validate_base_url, "http://localhost:8080", {}) + self.assertIsNone(rc, f"localhost should pass, got rc={rc} err={err!r}") + + def test_http_to_ipv6_loopback_passes(self): + rc, err, _ = _captured(search.validate_base_url, "http://[::1]:8080/", {}) + self.assertIsNone(rc, f"ipv6 loopback should pass, got rc={rc} err={err!r}") + + def test_http_to_non_loopback_is_rejected(self): + rc, err, _ = _captured(search.validate_base_url, "http://searx.example.com", {}) + self.assertEqual(rc, 1) + self.assertIn("rejected by default", err) + self.assertIn("searx.example.com", err) + self.assertIn("https://", err) + self.assertIn("loopback", err) + self.assertIn("allow_insecure_http", err) + + def test_http_to_non_loopback_with_opt_in_passes_with_warning(self): + rc, err, _ = _captured(search.validate_base_url, "http://searx.example.com", {"allow_insecure_http": True}) + self.assertIsNone(rc) + self.assertIn("cleartext", err) + self.assertIn("searx.example.com", err) + + def test_ftp_scheme_is_rejected(self): + rc, err, _ = _captured(search.validate_base_url, "ftp://searx.example.com/", {}) + self.assertEqual(rc, 1) + self.assertIn("scheme must be http or https", err) + + def test_empty_scheme_is_rejected(self): + rc, err, _ = _captured(search.validate_base_url, "searx.example.com", {}) + self.assertEqual(rc, 1) + self.assertIn("scheme", err) + + def test_missing_host_is_rejected(self): + rc, err, _ = _captured(search.validate_base_url, "https://", {}) + self.assertEqual(rc, 1) + self.assertIn("host", err) + + +if __name__ == "__main__": + unittest.main() diff --git a/plugins/Fectivnfy112357/searxng-search/test/searxng-search.test.mjs b/plugins/Fectivnfy112357/searxng-search/test/searxng-search.test.mjs new file mode 100644 index 0000000..0acf713 --- /dev/null +++ b/plugins/Fectivnfy112357/searxng-search/test/searxng-search.test.mjs @@ -0,0 +1,72 @@ +// Bridge from the repo's `node --test` to this plugin's Python regression +// suite. Lives inside the Plugin directory so the Plugin stays a portable, +// self-contained contribution: nothing outside plugins// is +// modified. `node --test` at the repository root discovers this file +// recursively. +// +// Spawns the first available Python interpreter (python3, python, py -3) +// and asserts a zero exit code. No network, no gh binary required — the +// Python suite is self-contained. +// +// Interpreter discovery: +// - On Windows, prefer `py -3` (the official Python launcher), then +// fall back to `python` and `python3`. +// - On POSIX, prefer `python3`, then `python`. +// - Each candidate is checked by `version --version` so a non-Python +// stub (a Windows command name collision, a `py` shim that is +// actually pip) fails fast and we move on to the next candidate. +// - If no interpreter is found, the test fails with an explicit +// diagnostic instead of producing a pass that misleads CI. +import { test } from 'node:test'; +import { strict as assert } from 'node:assert'; +import { spawnSync } from 'node:child_process'; +import { fileURLToPath } from 'node:url'; +import { dirname, join } from 'node:path'; + +const __dirname = dirname(fileURLToPath(import.meta.url)); +const SCRIPT_DIR = join( + __dirname, + '..', + 'skills', + 'searxng-search', + 'scripts', +); + +const isWindows = process.platform === 'win32'; + +const CANDIDATES = isWindows + ? [ + { cmd: 'py', args: ['-3', '--version'] }, + { cmd: 'py', args: ['--version'] }, + { cmd: 'python', args: ['--version'] }, + { cmd: 'python3', args: ['--version'] }, + ] + : [ + { cmd: 'python3', args: ['--version'] }, + { cmd: 'python', args: ['--version'] }, + ]; + +function findPython() { + for (const c of CANDIDATES) { + const r = spawnSync(c.cmd, c.args, { encoding: 'utf-8' }); + if (r.status === 0) { + return { cmd: c.cmd, prefix: c.cmd === 'py' ? ['-3'] : [] }; + } + } + return null; +} + +test('searxng-search Python regression tests', () => { + const py = findPython(); + assert.ok(py, `no usable Python interpreter found; tried: ${CANDIDATES.map((c) => `${c.cmd} ${c.args.join(' ')}`).join(', ')}`); + + const r = spawnSync(py.cmd, [...py.prefix, join(SCRIPT_DIR, 'run_tests.py')], { + cwd: SCRIPT_DIR, + encoding: 'utf-8', + }); + if (r.status !== 0) { + console.error(r.stdout); + console.error(r.stderr); + } + assert.equal(r.status, 0, 'python run_tests.py must exit 0'); +});