Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
4 changes: 3 additions & 1 deletion .dockerignore
Original file line number Diff line number Diff line change
Expand Up @@ -24,12 +24,14 @@ Thumbs.db
# Local test data
data/

# Docs, CI config, and assets — not needed to build the image
# Docs, CI config, tests, and assets — not needed to build the image
README.md
SECURITY.md
LICENSE
icons/
templates/
ca_profile.xml
docker-compose.yml
.hadolint.yaml
install-websockets.sh
tests/
19 changes: 18 additions & 1 deletion .github/workflows/base-refresh.yml
Original file line number Diff line number Diff line change
Expand Up @@ -23,14 +23,31 @@ jobs:
- name: Resolve current ubuntu:24.04 digest
id: digest
run: |
CURRENT=$(skopeo inspect docker://ubuntu:24.04 --format '{{.Digest}}')
set -euo pipefail
# Use buildx, which is preinstalled on ubuntu-latest. The previous
# `skopeo inspect` had no install step and skopeo is NOT on the
# runner, so this step failed and left `digest` empty — which then
# either errored the comparison or produced a PR with a blank
# "new" digest. `set -e` makes any future failure loud instead of
# silently yielding an empty variable.
CURRENT=$(docker buildx imagetools inspect ubuntu:24.04 \
--format '{{.Manifest.Digest}}')
if [ -z "$CURRENT" ]; then
echo "::error::could not resolve ubuntu:24.04 digest (empty result)"
exit 1
fi
echo "digest=$CURRENT" >> "$GITHUB_OUTPUT"
echo "Upstream ubuntu:24.04 digest: $CURRENT"

- name: Extract pinned digest from Dockerfile
id: pinned
run: |
set -euo pipefail
PINNED=$(awk 'NR==1 {match($0, /@sha256:[0-9a-f]+/); print substr($0, RSTART+8, RLENGTH-8)}' Dockerfile)
if [ -z "$PINNED" ]; then
echo "::error::could not parse a @sha256: pin from line 1 of the Dockerfile"
exit 1
fi
echo "pinned=$PINNED" >> "$GITHUB_OUTPUT"
echo "Pinned digest in Dockerfile: $PINNED"

Expand Down
35 changes: 34 additions & 1 deletion .github/workflows/docker-publish.yml
Original file line number Diff line number Diff line change
Expand Up @@ -34,7 +34,40 @@ jobs:

- name: Lint shell scripts (shellcheck)
run: |
shellcheck entrypoint.sh install-websockets.sh
shellcheck entrypoint.sh install-websockets.sh tests/*.sh

- name: Verify Python sources compile
run: |
python3 -m py_compile healthcheck.py tests/test-setup-userid.py tests/ss_from_proc.py

- name: Readiness-gate regression tests (F2)
# Creates real TCP listeners and checks port_listening() matches an
# EXACT port. Catches a regression to a substring match, which would
# let an unrelated host service (e.g. :15225) satisfy the startup gate
# under host networking.
run: bash tests/test-port-gate.sh

- name: Installer interpreter tests (F1)
# install-websockets.sh must install into the gateway's virtualenv and
# verify with that same interpreter. `docker` and `pip` are stubbed, so
# this neither needs Docker nor touches the network. It skips (exit 77)
# on runners with no Hermes install, since the venv-detection path is
# only meaningful inside a Hermes container.
run: |
set +e
bash tests/test-installer-interpreter.sh
rc=$?
if [ "$rc" -eq 77 ]; then
echo "::notice title=Installer interpreter tests skipped::No Hermes interpreter on this runner."
elif [ "$rc" -ne 0 ]; then
exit "$rc"
fi

- name: First-run setup userId tests (F3)
# Drives the real setup block from entrypoint.sh against a fake
# WebSocket daemon: the /_address_settings userId must come from the
# daemon, and success must be confirmed by corrId correlation.
run: python3 tests/test-setup-userid.py

smoke:
# Builds the image and actually runs it with the same capability set
Expand Down
99 changes: 99 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -3,6 +3,105 @@
All notable changes to this project are documented here.
Versions are image releases; the `vX.Y.Z` git tag builds the same image.

## [Unreleased]

Remediation of the 2026-10-03 code review. No image behaviour change for
correctly-configured deployments; two of these fixes change failure modes from
"silently wrong" to "loudly wrong".

### Fixed

- **`install-websockets.sh` installed `websockets` where the gateway could not
import it.** The script used bare `pip` / `python3` via `docker exec`, but the
gateway runs `/app/venv/bin/python3` in a venv created with
`include-system-site-packages = false`. Packages landed in the system
interpreter (or a user's `~/.local`), which is **not on the venv's
`sys.path`** — so the script installed the dependency, verified it with the
same wrong interpreter, reported success, and left the SimpleX platform
unable to load. It now resolves the gateway interpreter explicitly
(`HERMES_VENV`, default `/app/venv`) and uses it for both the install and the
verification; the install is pinned to the image's `websockets` version
(`WEBSOCKETS_VERSION`, default `17.0.1`) instead of tracking latest; and a
missing venv produces a loud warning rather than a silent fallback.
- **The adapter check silently skipped.** It located the adapter with
`import plugins.platforms.simplex.adapter` under the wrong interpreter, then
fell back to `find /app/venv -path '*/simplex/adapter.py'` — which returns
nothing on a current Hermes layout, where the plugin lives in a source
checkout (`/app/hermes-agent-src`). The script printed "skipping DM send
verification" and exited 0, so the verification added in v1.3.0 never ran. It
now imports with the gateway interpreter and falls back to a filesystem-wide
search, and it says on stderr that an unverified state is not a verified one.
- **Readiness gates matched any port *containing* the number.** Both the
startup gate and the socat gate used `ss -tln | grep -q :5225`, an
unanchored substring test that also matches 15225, 52250, 52251…. Under
`network_mode: host` — which this project requires — `ss` lists the entire
host's listening sockets, so an unrelated service could satisfy the gate and
report the WebSocket API ready while the daemon was dead. A shared
`port_listening()` helper now anchors on an exact port on **both** code paths:
the `sport = :PORT` filter is re-verified with awk rather than trusted via
`grep -q .`, because an `ss` that does not understand the filter prints the
whole socket table and `grep -q .` would match any line.
- **First-run auto-accept targeted a hardcoded id and never checked the
result.** `/_address_settings 1 …` used a literal `1`; the daemon's contract
is `/_address_settings <userId> <json(settings)>` (bots/api/COMMANDS.md), and
the id is now read from the `/user` (`activeUser`) response, with a
`userContactLinkCreated` / `usersList` fallback. Success was detected by
searching any event for the substring `userContactLinkUpdated`; it now
correlates the `corrId` the daemon echoes (`Server.hs` wraps every response
as `{corrId, resp}`). When no id can be determined the step is skipped with a
warning rather than applied to a guessed profile.
- **`base-refresh.yml` could not succeed.** The digest step called `skopeo`,
which is not installed on `ubuntu-latest` and had no install step, so the
output could be empty — and with no `set -e` that failure was silent. It now
uses the preinstalled `docker buildx imagetools inspect`, sets
`set -euo pipefail`, and fails loudly on an empty digest instead of opening a
PR with a blank value.
- **README contradicted itself about the installer.** Line 196 still described
the removed `sed -i` adapter patching, fourteen lines below the note saying it
was removed. Rewritten to match, plus a new warning explaining why the
installer targets the gateway venv.
- **Unraid template floated `:latest`** while compose pinned a digest, so
Unraid's Update button could silently jump versions. Now pinned to `v1.3.0`
(a tag rather than a digest: the Unraid template schema does not reliably
round-trip a digest through `Repository`). README install instructions updated
to match.

### Added

- **`SECURITY.md`** — private vulnerability-reporting path, plus an explicit
statement of the deployment model: the WebSocket API has **no
authentication**, the default bind is loopback-only, and setting
`SIMPLEX_SOCAT_PORT` removes that protection.
- **Regression tests** (`tests/`, no Docker required) covering the two High and
one Medium code fixes, wired into the `lint` CI job so they cannot rot:
- `test-port-gate.sh` — creates **real** TCP listeners and asserts
`port_listening()` matches an exact port, including the decoy-port case and
the no-`ss` fail-closed case.
- `test-installer-interpreter.sh` — stubs `docker` and `pip` and asserts the
installer uses only absolute venv paths, pins the version, finds an adapter
outside the venv, and **fails** on an unrecognised adapter.
- `test-setup-userid.py` — drives the real setup block from `entrypoint.sh`
against a fake WebSocket daemon, including a wrong-`corrId` decoy that the
old substring match would have accepted as success.
- `check-docs.py` — version/digest pin agreement across README, compose, and
the Unraid template, and that documented env vars exist in code.
- `run-all.sh` — runs every gate.

### Verified

- All three behavioural test suites were checked against deliberately
re-introduced versions of each bug (mutation testing) and failed as expected,
then passed again on restore — so they detect the defects they guard.
- `shellcheck` clean across `entrypoint.sh`, `install-websockets.sh`, and
`tests/*.sh`.

### Not verified

- No container was built or booted for this batch (no Docker CLI available).
Runtime behaviour of the fixed entrypoint paths — socat bridge startup,
first-run setup against live SMP servers, real-silicon ARM64 — is unproven
here and remains covered only by the existing CI smoke matrix.

## [1.3.0] — 2026-10-01

Multi-arch release plus remediation of a full code review (2026-09-29).
Expand Down
27 changes: 23 additions & 4 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -193,7 +193,7 @@ Hermes Agent v0.16.0+ (v2026.6.5+) ships a SimpleX Chat platform plugin. **Herme

For older Hermes builds (v0.16.x–v0.19.x), the adapter had a bug ([upstream issue #46265](https://github.com/NousResearch/hermes-agent/issues/46265)): outbound DMs used the CLI shortcut format `@<id> text`, which the simplex-chat daemon silently rejects over WebSocket — it resolves `@<id>` as a display-name lookup, not a contactId lookup. Replies appeared in the Hermes WebUI but never reached the SimpleX app.

The one-command setup script below installs `websockets` and, **only when the bug is still present** (pre-0.20.0 Hermes), applies the two-line fix to the adapter. On Hermes 0.20.0+ it detects the native fix and skips the patch.
This one-command setup script installs `websockets` and **verifies** the adapter's DM send path already uses the structured `/_send … json` format. It never modifies the adapter: on an unrecognised version it **exits non-zero** and tells you to upgrade Hermes. See [One-command setup](#one-command-setup) below.

### One-command setup

Expand All @@ -203,10 +203,12 @@ docker exec hermes-webui /app/venv/bin/hermes gateway restart
```

The script:
1. Installs the `websockets` Python package (not bundled in the Hermes image)
1. Installs the `websockets` Python package (not bundled in the Hermes image), **pinned to the same version the bridge image ships** (`17.0.1`; override with `WEBSOCKETS_VERSION=`)
2. Verifies the adapter's DM send path already uses the structured `/_send … json` format, and **exits non-zero** if the adapter is an unrecognised version
3. Verifies the plugin is discoverable

> ⚠️ **The script installs into the gateway's virtualenv, not the system Python.** The Hermes gateway runs `/app/venv/bin/python3`, and that venv is created with `include-system-site-packages = false`. Installing `websockets` with a bare `pip` (or into `~/.local`) puts it somewhere the gateway **cannot import**, so the platform fails to load even though the install appeared to succeed. The script therefore resolves the venv interpreter explicitly (`/app/venv`, override with `HERMES_VENV=`) and uses it for both the install and the verification. If it cannot find that interpreter it says so loudly rather than silently falling back.

> **The script no longer patches the adapter.** Older versions of this script rewrote `adapter.py` in place with `sed -i` to work around [upstream issue #46265](https://github.com/NousResearch/hermes-agent/issues/46265). That patching was removed: it silently no-op'd once the adapter was refactored (so it "succeeded" while doing nothing), a bad edit could raise a `SyntaxError` that breaks the gateway's plugin import, and container rebuilds reverted it anyway. The upstream fix has shipped natively since Hermes 0.20.0 — upgrade rather than patch.

**Re-run after every Hermes container update or rebuild** — `websockets` is installed into the ephemeral container image and the auto-detect/patch step re-evaluates the installed adapter.
Expand Down Expand Up @@ -235,7 +237,7 @@ Or read `/mnt/user/appdata/simplex-bridge/bot_address.txt`.

Both containers require host networking — simplex-bridge **and** Hermes Agent must share the loopback interface.

This mirrors the [`docker-compose.yml`](docker-compose.yml) shipped in the repo, pinned to the immutable v1.2.0 release by digest:
This mirrors the [`docker-compose.yml`](docker-compose.yml) shipped in the repo, pinned to the immutable v1.3.0 release by digest:

```yaml
services:
Expand Down Expand Up @@ -278,7 +280,7 @@ volumes:
| Key | Value |
|-----|-------|
| Name | `simplex-bridge` |
| Repository | `ghcr.io/libre-7/simplex-bridge:latest` |
| Repository | `ghcr.io/libre-7/simplex-bridge:v1.3.0` |
| Network Type | **Host** |
| Post Arguments | (leave blank) |

Expand Down Expand Up @@ -323,6 +325,23 @@ Port 5225 for the WebSocket API. Host networking is required — no port mapping
**Q: Can I run multiple bots?**
Not within one container. The WebSocket port is fixed at 5225 — there is no port variable. To run multiple bots, run separate containers, each with its own `/data` volume and its own network namespace (e.g. separate hosts/VMs). If you use the bridge networking mode, distinct published ports via `SIMPLEX_SOCAT_PORT` are possible (use a port other than 5225; see Network Configuration) — but note that socat mode is unauthenticated; see the warnings in Network Configuration.

## Testing

The regression tests need neither Docker nor network access:

```bash
bash tests/run-all.sh
```

This runs shellcheck, syntax and parse checks, and four suites that cover the
startup readiness gate, the Hermes installer, the first-run setup handshake, and
documentation/pin consistency. `tests/test-installer-interpreter.sh` needs a
Hermes installation to exercise interpreter detection and **skips** (exit 77)
elsewhere; the rest run anywhere.

They are also wired into the `lint` CI job, so a regression fails the build
rather than waiting for a runtime symptom.

## Building from Source

```bash
Expand Down
64 changes: 64 additions & 0 deletions SECURITY.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,64 @@
# Security Policy

## Reporting a Vulnerability

**Please report security issues privately.** Do not open a public GitHub issue
for a vulnerability you have found.

Use GitHub's private reporting on the Security tab
(**Security → Report a vulnerability**), or email the maintainer via the address
listed on the repository profile.

Please include:

- What the issue is, and which component it affects (image, entrypoint,
`healthcheck.py`, `install-websockets.sh`, or CI)
- The image tag or commit you tested (`docker buildx imagetools inspect
ghcr.io/libre-7/simplex-bridge:<tag>`)
- Steps to reproduce, and the impact you believe it has
- Any proof-of-concept output you are willing to share

You can expect an acknowledgement within a few days. Confirmed issues are
fixed in a follow-up release, and the reporter is credited in the CHANGELOG
unless they prefer otherwise.

## Deployment model and threat surface

This project ships a **network service with no authentication of its own**.
Understanding that is the most important part of using it safely.

| Property | Status |
|---|---|
| WebSocket API authentication | **None.** Anyone who can reach the port can read and send as the bot. |
| Default bind | `127.0.0.1:5225` — loopback only. |
| Default exposure | None. Host networking keeps the port on the host's loopback interface. |
| `SIMPLEX_SOCAT_PORT` | **Disables the loopback-only default.** Publishes `0.0.0.0:<port>` with no authentication. |
| Container privileges | `cap_drop: ALL` plus only `CHOWN`, `SETUID`, `SETGID`; `no-new-privileges:true`; non-root runtime via `gosu`. |
| Supply chain | Digest-pinned base image; `simplex-chat` and `gosu` binaries SHA256-verified at build time; CI actions pinned by commit SHA; SBOM and provenance attestations published. |

### Guidance

- **Keep the default.** Use host networking and leave `SIMPLEX_SOCAT_PORT` empty.
Both the bridge and Hermes Agent must share loopback for this to work.
- **Never expose the port directly to an untrusted network.** If a remote client
must connect, put an authenticating reverse proxy in front of it. The README's
[Securing the socat port](README.md#securing-the-socat-port) section gives
working nginx basic-auth and firewall-allowlist recipes — and notes that Basic
auth over plain HTTP is base64, not encryption, so terminate TLS in front.
- **Verify what you are pulling.** Prefer a `sha-<commit>` tag or a
`@sha256:` digest. The `latest` tag moves with `main`.
- **Understand the bundled components.** The image redistributes `simplex-chat`
and `simplexmq`, both **AGPL-3.0**. Vulnerabilities in those binaries are
upstream's to fix; report them to
[simplex-chat/simplex-chat](https://github.com/simplex-chat/simplex-chat/issues)
as well. See the README's bundled-components table for versions and sources.

## Out of scope

- Exposure caused by a user deliberately setting `SIMPLEX_SOCAT_PORT` without a
firewall or authenticating proxy.
- Vulnerabilities in upstream `simplex-chat`, `simplexmq`, or `gosu` — please
report those upstream (though you are welcome to tell us how they affect this
image).
- Weaknesses in SimpleX Chat's own protocol design, which are inherent to the
upstream design rather than to this packaging.
Loading
Loading