Files

13 KiB

openHop Plugins

The plugin system installs and supervises external applications around the Repeater. Plugins are not imported into the Repeater process. They talk to existing interfaces (REST, WebSocket/SSE, Companion frame server, static web).

Trust and access

Plugins are trusted applications, not sandboxed code. A separate process and virtualenv keep Python dependencies separate; they do not create a filesystem, network, or credential boundary. Install only plugins and dependencies you trust. Native plugins currently run under the repeater service account; container plugins run under the configured container account. Never run a plugin manager as root merely to work around installation permissions.

The current Repeater API tokens are administrator-equivalent. A token that can read status can also administer plugins, install uploaded wheels, and read plugin settings. Do not hand these tokens to untrusted or telemetry-only consumers. Scoped integration credentials would require a separate authorization feature.

Schema-2 catalogue checksums approve the plugin wheel itself. Python dependencies resolved by pip are not covered by that checksum and can change on installation or environment rebuild. The curated catalogue is not a claim that the complete transitive dependency set is hash-locked. Deployments requiring reproducibility should use a controlled, pinned dependency source.

Layout

Default root (under Repeater storage):

/var/lib/openhop_repeater/plugins/
└── openhop.nomad/
    ├── releases/0.1.0/
    │   ├── openhop-plugin.json
    │   ├── ui/                 # optional application UI assets
    │   └── venv/               # isolated Python environment
    ├── current -> releases/0.1.0
    ├── data/                   # persistent plugin-owned data
    ├── logs/plugin.log
    └── state.json              # manager metadata only (id, version, enabled)
  • Code and virtualenv are version-specific.
  • The installed wheel is retained with each release so its virtualenv can be rebuilt automatically if a future container image changes Python versions.
  • data/ is not version-specific and is never interpreted by the manager.
  • IPC socket default: /var/lib/openhop_repeater/plugin-manager.sock.

Override with config:

plugins:
  enabled: true
  # root: "/var/lib/openhop_repeater/plugins"
  # socket: "/var/lib/openhop_repeater/plugin-manager.sock"

Manifest (openhop-plugin.json)

Schema version 1:

{
  "schema": 1,
  "id": "openhop.nomad",
  "name": "NOMAD Bridge",
  "version": "0.1.0",
  "runtime": {
    "type": "python",
    "entrypoint": "meshcore-nomad-bridge"
  }
}

UI-only application plugin:

{
  "schema": 1,
  "id": "openhop.console",
  "name": "openHop Console",
  "version": "1.0.0",
  "ui": {
    "type": "application",
    "entry": "ui/index.html"
  }
}

A plugin may declare both runtime and ui.

The UI entry must be inside a dedicated asset subtree such as ui/index.html. A release-root entry (index.html), dot/parent path segments, and reserved runtime/storage subtrees (venv, data, logs, releases, current) are rejected. Existing plugins using these invalid layouts must be repackaged; serving the whole release directory is intentionally no longer supported.

Plugin manager service

Native installs:

sudo cp openhop-plugin-manager.service /etc/systemd/system/
sudo systemctl daemon-reload
sudo systemctl enable --now openhop-plugin-manager

The manager starts enabled service plugins on boot and supervises crashes (restarts with a simple crash-loop limit: 5 unexpected exits in 60s → FAILED).

Repeater does not require the manager. If the socket is missing, plugin API calls return HTTP 503 and normal Repeater operation continues.

The Docker image runs Repeater and the manager under a small process supervisor, with tini as PID 1 to reap orphaned children. It forwards shutdown signals to both services and their plugin processes, and restarts the manager if it exits unexpectedly while Repeater remains running. Set OPENHOP_PLUGIN_MANAGER=0 to run Repeater without the manager.

Native upgrade prerequisite

Existing native installations must run the updated manage.sh upgrade once as an administrator to replace the privileged OTA helper. Installing only a new Python wheel cannot safely replace an existing root-owned helper. Subsequent OTA upgrades through the refreshed helper install the manager unit from the installed package; no source checkout is required on the host.

The helper runs from a trusted directory with a clean environment and isolated Python module lookup. It rejects writable/non-root-owned execution trees instead of silently granting existing code root ownership. If this check fails, an administrator must restore the application environment from a trusted package. Do not bypass the check or make the venv service-writable.

Operation safety and limits

The manager serves bounded concurrent IPC requests, so a slow installation does not monopolize the socket. Mutations of the same plugin are serialized; conflicting requests return a busy response rather than interleaving state. Local wheel inputs are copied to manager-owned staging before installation.

Installation has a longer IPC budget than ordinary status requests. If transport is lost after dispatch, HTTP 504 with outcome: unknown means the operation may still finish: it does not mean cancellation or rollback. Inspect plugin status before retrying. An uploaded source is retained when completion is ambiguous so an accepted install cannot lose its input.

Venv creation and pip execution are time-limited, installer output is bounded, and plugin stdout/stderr are drained into bounded logs while the plugin runs. Stop/disable terminate the supervised process group, including ordinary child workers. This is process supervision, not containment of malicious applications that deliberately detach into new sessions or write other files.

Failed Python-version rebuilds restore the previous venv and remain retryable; the new marker is accepted only after the new environment and entrypoint exist. A rebuild-intent record is reconciled before version checks after a manager restart, so interruption during replacement cannot silently accept a partial venv. This provides process-interruption recovery, not guaranteed filesystem durability across power loss; interruption during cleanup can leave an unused backup.

Archive extraction limits expanded member size and total size and rejects symlink-backed destinations. Catalogue JSON and wheel response bodies have size limits and elapsed-time budgets; configured catalogue URLs must use HTTPS. DNS resolution and urllib connection/header setup are not guaranteed to obey a strict whole-request wall-clock deadline.

Install a local wheel

Via API (authenticated):

curl -X POST -H "Authorization: Bearer $TOKEN" \
  -F "wheel=@openhop_nomad_plugin-0.1.0-py3-none-any.whl" \
  http://127.0.0.1:8000/api/plugins/install

Or JSON with a host path:

curl -X POST -H "Authorization: Bearer $TOKEN" \
  -H "Content-Type: application/json" \
  -d '{"wheel_path":"/tmp/plugin.whl"}' \
  http://127.0.0.1:8000/api/plugins/install

Installation creates an isolated venv under the plugin release directory and never mutates the Repeater virtualenv.

Enable / disable / lifecycle

# Enable (also starts service plugins)
curl -X POST -H "Authorization: Bearer $TOKEN" \
  -H "Content-Type: application/json" \
  -d '{"id":"openhop.nomad"}' \
  http://127.0.0.1:8000/api/plugins/enable

curl -X POST .../api/plugins/disable   -d '{"id":"openhop.nomad"}'
curl -X POST .../api/plugins/start     -d '{"id":"openhop.nomad"}'
curl -X POST .../api/plugins/stop      -d '{"id":"openhop.nomad"}'
curl -X POST .../api/plugins/restart   -d '{"id":"openhop.nomad"}'

List / status / logs:

curl -H "Authorization: Bearer $TOKEN" http://127.0.0.1:8000/api/plugins/
curl -H "Authorization: Bearer $TOKEN" http://127.0.0.1:8000/api/plugins/openhop.nomad
curl -H "Authorization: Bearer $TOKEN" \
  "http://127.0.0.1:8000/api/plugins/logs?id=openhop.nomad&tail=100"

Plugin configuration

Each plugin owns $OPENHOP_PLUGIN_DATA/config.json. The manager treats it as an opaque JSON object — it does not validate plugin-specific keys.

Plugins can declare editor defaults in the manifest:

{
  "config": {
    "defaults": {
      "meshcore_host": "127.0.0.1",
      "nomad_url": "http://127.0.0.1:8080"
    }
  }
}

Optionally ship config.default.json next to openhop-plugin.json in the wheel (share/openhop/plugins/<id>/). On install, if data/config.json is missing, the manager seeds it from those defaults. The settings UI shows defaults when no saved config exists, and offers Reset to defaults.

API:

# Read
curl -H "Authorization: Bearer $TOKEN" \
  "http://127.0.0.1:8000/api/plugins/settings?id=openhop.nomad"

# Write (optionally restart service plugins)
curl -X POST -H "Authorization: Bearer $TOKEN" \
  -H "Content-Type: application/json" \
  -d '{
    "id": "openhop.nomad",
    "restart": true,
    "config": {
      "nomad_url": "http://192.168.0.170:8080",
      "nomad_model": "qwen2.5:3b-instruct",
      "meshcore_host": "127.0.0.1",
      "meshcore_port": 5001
    }
  }' \
  http://127.0.0.1:8000/api/plugins/settings

The Plugins page in the web UI provides a Settings dialog for the same file.

Service plugin environment

Started processes receive:

Variable Example
OPENHOP_PLUGIN_ID openhop.nomad
OPENHOP_PLUGIN_DATA /var/lib/openhop_repeater/plugins/openhop.nomad/data

Plugin-specific configuration (for example NOMAD config.json) lives under $OPENHOP_PLUGIN_DATA and is owned by the plugin.

Application UI plugins

Enabled UI plugins are served by Repeater at:

/plugins/{id}/
/plugins/{id}/assets/...

Path traversal is rejected. Missing paths fall back to the manifest ui.entry document (SPA-friendly). Disabled plugins are not exposed.

Uninstall

Default keeps data/:

curl -X DELETE -H "Authorization: Bearer $TOKEN" \
  http://127.0.0.1:8000/api/plugins/openhop.nomad

Delete data as well:

curl -X DELETE -H "Authorization: Bearer $TOKEN" \
  "http://127.0.0.1:8000/api/plugins/openhop.nomad?delete_data=true"

NOMAD acceptance

  1. Build the openhop-nomad-plugin wheel.
  2. Install via /api/plugins/install.
  3. Enable a Repeater Companion frame server reachable from the plugin. In the standard Docker image, 127.0.0.1:5001 reaches the same container.
  4. Write $OPENHOP_PLUGIN_DATA/config.json with nomad_url / nomad_model.
  5. Enable the plugin — meshcore-nomad-bridge should report RUNNING.
  6. Confirm its logs show a successful Companion connection, not only a running retry loop. A RUNNING process can still be reconnecting to a disabled frame server.

Future work (not in this version)

  • Web Component dashboard widgets and settings panels
  • Permission scopes, signing, non-Python runtimes
  • Plugin SDK

Plugin catalogue

Repeater browses the curated static catalogue from openHop's R2-backed endpoint. Update checks read only that catalogue and do not query the GitHub API. When a user installs an approved plugin, Repeater downloads the exact wheel directly from the plugin's GitHub Release.

Default catalogue URL:

https://repeater-plugins.openhop.dev/catalogue.json

Override with:

plugins:
  catalogue_url: "https://repeater-plugins.openhop.dev/catalogue.json"

Catalogue schema 2 records each plugin's currently approved version, exact GitHub Release wheel_url, and lowercase sha256. The manager validates that the URL belongs to the plugin repository, follows only GitHub's HTTPS release asset redirect, verifies the checksum before installation, and confirms the wheel manifest ID and version match the catalogue. Publishing a newer GitHub Release does not make it available until the catalogue maintainers approve its metadata in the R2 catalogue.

Schema 1 repository-only catalogues remain readable for compatibility with custom deployments, but the default openHop catalogue uses schema 2 and needs no GitHub token.

# List catalogue (annotated with installed / latest when available)
curl -H "Authorization: Bearer $TOKEN" \
  http://127.0.0.1:8000/api/plugins/catalogue

# Install the currently approved catalogue version (enabled by default)
curl -X POST -H "Authorization: Bearer $TOKEN" \
  -H "Content-Type: application/json" \
  -d '{"id":"openhop.nomad"}' \
  http://127.0.0.1:8000/api/plugins/catalogue_install

# Check for updates
curl -H "Authorization: Bearer $TOKEN" \
  "http://127.0.0.1:8000/api/plugins/updates?id=openhop.nomad"

# Apply update
curl -X POST -H "Authorization: Bearer $TOKEN" \
  -H "Content-Type: application/json" \
  -d '{"id":"openhop.nomad"}' \
  http://127.0.0.1:8000/api/plugins/update

R2 catalogue or GitHub Release download outages do not affect already-installed plugins or Repeater startup. Local .whl install continues to work unchanged.