Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
29 commits
Select commit Hold shift + click to select a range
3605b96
[verified] Harden NOMAD bridge runtime
fizzlepoof Sep 4, 2026
de76d0b
[verified] Pace NOMAD reply chunks
fizzlepoof Sep 4, 2026
a995fcd
merge: sync NOMAD runtime PR with dev
yellowcooln Sep 11, 2026
1bb9356
fix: complete bounded NOMAD transport and global reply pacing
yellowcooln Sep 11, 2026
f392980
fix: default NOMAD Docker installs to container DNS endpoint
yellowcooln Sep 11, 2026
9c7d1d0
fix: align Companion defaults and CI with Repeater
yellowcooln Sep 11, 2026
d25eca0
Merge pull request #2 from fizzlepoof/harden-production-runtime
yellowcooln Sep 11, 2026
4c9254d
Refresh NOMAD config UI with offline artwork and contextual help
yellowcooln Sep 11, 2026
616e756
fix(ui): open help from setting names and move logo to header
yellowcooln Sep 11, 2026
dcdcec1
Restore optional bounded per-sender conversation memory
yellowcooln Sep 11, 2026
49dca16
Add clickable Connectivity setting help
yellowcooln Sep 11, 2026
07dc4ea
Add subtle info cues to clickable setting names
yellowcooln Sep 11, 2026
5187dab
Add single-use Companion advert controls and allow-empty sender access
yellowcooln Sep 11, 2026
13f1271
Keep NOMAD advert build at requested version 0.1.3
yellowcooln Sep 11, 2026
c809fdc
Clarify empty sender list and whitelist UI help
yellowcooln Sep 11, 2026
7758678
Version NOMAD local test build as 0.1.3+test.1
yellowcooln Sep 11, 2026
6b75889
Restore version 0.1.3 and simplify configuration introduction
yellowcooln Sep 12, 2026
405b2e3
Simplify Companion advert wording
yellowcooln Sep 12, 2026
af8d112
Disable one-shot mode by default
yellowcooln Sep 12, 2026
356a14b
Fix wheel smoke check for one-shot disabled default
yellowcooln Sep 12, 2026
e79b5ca
Improve radio reply packing and knowledge-aware concise prompts
yellowcooln Sep 12, 2026
92bf7b1
Run Chromium browser regression tests in Python 3.12 CI
yellowcooln Sep 12, 2026
b7d15c3
feat: add Companion preferences and tabbed plugin settings
yellowcooln Sep 12, 2026
27c4835
fix: preserve legacy config defaults and prepare test.2 wheel
yellowcooln Sep 13, 2026
387919f
chore: bump plugin version to 0.1.3+test9
yellowcooln Sep 14, 2026
132d0f5
chore: keep PR version at 0.1.3
yellowcooln Sep 14, 2026
b42b53a
feat: add contacts address-book control and UI
yellowcooln Sep 17, 2026
89613ff
fix: update companion tab test for 5-tab layout
yellowcooln Sep 17, 2026
d763612
feat: prepare NOMAD Bridge 1.0.0 release candidate
yellowcooln Sep 23, 2026
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
91 changes: 91 additions & 0 deletions .github/workflows/ci.yml
Original file line number Diff line number Diff line change
@@ -0,0 +1,91 @@
name: PR checks
on:
pull_request:
branches: [main, dev]
permissions:
contents: read
concurrency:
group: nomad-pr-${{ github.event.pull_request.number }}
cancel-in-progress: true
jobs:
test:
runs-on: ubuntu-latest
timeout-minutes: 10
strategy:
fail-fast: false
matrix:
python: ['3.10', '3.12']
steps:
- uses: actions/checkout@11d5960a326750d5838078e36cf38b85af677262 # v4
with:
persist-credentials: false
- uses: actions/setup-python@a26af69be951a213d495a4c3e4e4022e16d87065 # v5
with:
python-version: ${{ matrix.python }}
- run: python -m pip install -e '.[dev]' build
- name: Install browser test dependencies (Python 3.12 only)
if: matrix.python == '3.12'
run: |
python -m pip install playwright==1.62.0
python -m playwright install --with-deps chromium
- run: python -m pytest
# Explicit correctness rules: do not inherit developer-machine lint defaults.
- if: matrix.python == '3.12'
run: python -m ruff check --select E4,E7,E9,F .
- if: matrix.python == '3.12'
run: python -m build --wheel
- name: Install wheel and smoke-test packaged entrypoint and assets
if: matrix.python == '3.12'
shell: bash
run: |
# No event inputs are interpolated into this shell script.
checkout_dir="$PWD"
smoke_dir="$(mktemp -d)"
trap 'rm -rf "$smoke_dir"' EXIT
python -m venv "$smoke_dir/venv"
"$smoke_dir/venv/bin/python" -m pip install "$checkout_dir"/dist/*.whl
cd "$smoke_dir"
"$smoke_dir/venv/bin/python" -I - "$checkout_dir" <<'PY'
import json
import os
from importlib.metadata import distribution
from pathlib import Path
import sys

import meshcore_nomad_bridge
from meshcore_nomad_bridge.config import Settings

checkout = Path(sys.argv[1]).resolve()
source = Path(meshcore_nomad_bridge.__file__).resolve()
assert source.is_relative_to(Path(sys.prefix).resolve()), source
assert not source.is_relative_to(checkout), source
dist = distribution("openhop-nomad-plugin")
entry, = [ep for ep in dist.entry_points
if ep.group == "console_scripts" and ep.name == "meshcore-nomad-bridge"]
assert entry.value == "meshcore_nomad_bridge.main:main"
assert callable(entry.load()) # Import only: never start the bridge or radio.
assert os.access(Path(sys.prefix) / "bin" / entry.name, os.X_OK)
assets = Path(sys.prefix) / "share/openhop/plugins/openhop.nomad"
manifest = json.loads((assets / "openhop-plugin.json").read_text())
config_path = assets / "config.default.json"
defaults = json.loads(config_path.read_text())
assert manifest["id"] == "openhop.nomad"
assert manifest["version"] == dist.version
assert manifest["runtime"]["entrypoint"] == entry.name
assert manifest["config"]["defaults"] == defaults
for relative in ("openhop-plugin.json", "config.default.json",
"ui/index.html", "ui/app.js", "ui/styles.css"):
assert (assets / relative).read_bytes() == (checkout / relative).read_bytes()
assert (assets / manifest["ui"]["entry"]).is_file()
data_dir = Path.cwd() / "plugin-data"
data_dir.mkdir()
(data_dir / "config.json").write_bytes(config_path.read_bytes())
os.environ.clear()
os.environ["OPENHOP_PLUGIN_DATA"] = str(data_dir)
settings = Settings.from_env()
assert settings.nomad_url == "http://nomad_admin:8080"
assert (settings.meshcore_host, settings.meshcore_port) == ("127.0.0.1", 5050)
assert settings.one_shot is False
assert settings.allowed_sender_prefixes == ()
print(f"Installed wheel {dist.version}: entrypoint, defaults and UI assets OK ({source})")
PY
263 changes: 240 additions & 23 deletions README.md

Large diffs are not rendered by default.

18 changes: 12 additions & 6 deletions config.default.json
Original file line number Diff line number Diff line change
@@ -1,18 +1,24 @@
{
"meshcore_host": "127.0.0.1",
"meshcore_port": 5001,
"nomad_url": "http://127.0.0.1:8080",
"meshcore_port": 5050,
"nomad_url": "http://nomad_admin:8080",
"nomad_model": "qwen2.5:3b-instruct",
"nomad_collection": null,
"nomad_timeout_seconds": 120,
"one_shot": true,
"max_concurrent_requests": 2,
"one_shot": false,
"max_concurrent_requests": 1,
"max_pending_requests": 1,
"max_requests_per_sender": 2,
"max_requests_global": 4,
"rate_limit_window_seconds": 60,
"allowed_sender_prefixes": [],
"busy_wait_seconds": 5,
"max_reply_chunks": 4,
"max_chunk_bytes": 145,
"max_chunk_bytes": 80,
"reply_chunk_delay_seconds": 2.0,
"max_prompt_bytes": 1000,
"radio_prompt_enabled": true,
"radio_prompt_template": "You are answering a question received over a low-bandwidth MeshCore radio network.\nGive the most useful answer first.\nBe concise.\nUse plain text.\nDo not use Markdown tables.\nAvoid unnecessary introductions.\nAim for fewer than 400 characters when practical.\n\nUser question:\n{question}",
"radio_prompt_template": "You are a local AI assistant running through Project NOMAD, answering over MeshCore radio. Use relevant knowledge-base material supplied with the request. Only claim a specific guide, document, or file is available when that material confirms it.\nGive the direct answer first in one short paragraph, ideally under 250 characters.\nUse plain text only: no Markdown, bold, italics, headings, tables, or numbered lists.\nOmit introductions, repeated questions, and filler. For procedures, give only the essential steps in short sentences.\nPrefer common words and simple punctuation. Do not sacrifice accuracy or essential safety details to shorten the answer.\nDo not invent names, sources, URLs, or access instructions. If unsure, say so briefly or ask one short clarifying question.\n\nUser question:\n{question}",
"duplicate_ttl_seconds": 600,
"log_level": "INFO"
}
2 changes: 1 addition & 1 deletion meshcore_nomad_bridge/__init__.py
Original file line number Diff line number Diff line change
Expand Up @@ -2,4 +2,4 @@

__all__ = ["__version__"]

__version__ = "0.1.2"
__version__ = "1.0.0"
107 changes: 107 additions & 0 deletions meshcore_nomad_bridge/advert_control.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,107 @@
"""Bounded one-use advert mailbox via existing authenticated plugin settings.

Only the manager writes config.json. Only this process writes runtime.json.
A random process-local challenge expires after 30 seconds and is consumed before
sending. Saved config, restarts, and repeated POSTs cannot replay an RF action.
"""

from __future__ import annotations

import asyncio
import json
import logging
import os
import secrets
import time
from pathlib import Path

from .companion_control import CompanionControl
from .contacts_control import ContactsControl

logger = logging.getLogger(__name__)


class AdvertControl:
def __init__(self, data_dir: Path, meshcore, *, endpoint: str = "") -> None:
self.data_dir = data_dir
self.meshcore = meshcore
self.endpoint = endpoint
self.result = None
self.companion = CompanionControl(data_dir, meshcore, endpoint)
self.contacts = ContactsControl(data_dir, meshcore, endpoint)
self._rotate()

def _rotate(self):
self.token = secrets.token_hex(32)
self.deadline = time.monotonic() + 30

def _publish(self):
snapshot = {
"companion": self.companion.snapshot(),
"contacts": self.contacts.snapshot(),
"advert": {
"token": self.token,
"updated_at": time.time(),
"connected": bool(self.meshcore.connected),
"result": self.result,
"endpoint": self.endpoint,
}
}
self.data_dir.mkdir(parents=True, exist_ok=True)
temporary = self.data_dir / ".nomad-runtime.tmp"
with temporary.open("w", encoding="utf-8") as stream:
json.dump(snapshot, stream)
os.replace(temporary, self.data_dir / "runtime.json")

def _request(self):
try:
with (self.data_dir / "config.json").open("rb") as stream:
raw = stream.read(256 * 1024 + 1)
if len(raw) > 256 * 1024:
return None
config = json.loads(raw)
except (OSError, ValueError):
return None
return config.get("advert_request") if isinstance(config, dict) else None

async def tick(self):
await self.companion.tick(self._publish)
await self.contacts.tick(self._publish)
if time.monotonic() >= self.deadline:
self._rotate()
request = self._request()
if (
isinstance(request, dict)
and request.get("token") == self.token
and request.get("mode") in ("zero-hop", "flood")
and isinstance(request.get("id"), str)
and 1 <= len(request["id"]) <= 80
):
deadline = self.deadline
self._rotate() # consume before any await or transmission
self.result = {"id": request["id"], "status": "pending", "mode": request["mode"]}
self._publish() # failure prevents transmission, not replay
try:
self.result["status"] = await self.meshcore.send_advert(
request["mode"],
may_send=lambda: time.monotonic() < deadline and self._request() == request,
)
except asyncio.CancelledError:
self.result["status"] = "unknown"
self._publish()
raise
except Exception:
logger.exception("Advert acceptance unknown; request will not be retried")
self.result["status"] = "unknown"
self._publish()

async def run(self, stop_event):
while not stop_event.is_set():
try:
await self.tick()
except (OSError, ValueError):
logger.exception("Unable to update advert control mailbox")
try:
await asyncio.wait_for(stop_event.wait(), timeout=0.5)
except asyncio.TimeoutError:
pass
79 changes: 79 additions & 0 deletions meshcore_nomad_bridge/companion_control.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,79 @@
"""One-use Companion settings actions in the manager-owned config mailbox."""

from __future__ import annotations

import asyncio
import json
import logging
import secrets
import time

from .companion_settings import validate_patch

logger = logging.getLogger(__name__)


class CompanionControl:
def __init__(self, data_dir, meshcore, endpoint):
self.data_dir = data_dir
self.meshcore = meshcore
self.endpoint = endpoint
self.result = None
self._rotate()

def _rotate(self):
self.token = secrets.token_hex(32)
self.deadline = time.monotonic() + 30

def snapshot(self):
return {
"token": self.token,
"updated_at": time.time(),
"connected": bool(self.meshcore.connected),
"endpoint": self.endpoint,
"result": self.result,
}

def _request(self):
try:
with (self.data_dir / "config.json").open("rb") as stream:
raw = stream.read(256 * 1024 + 1)
if len(raw) > 256 * 1024:
return None
config = json.loads(raw)
return config.get("companion_request") if isinstance(config, dict) else None
except (OSError, ValueError):
return None

async def tick(self, publish):
if time.monotonic() >= self.deadline:
self._rotate()
request = self._request()
if not (
isinstance(request, dict)
and request.get("token") == self.token
and isinstance(request.get("id"), str)
and 1 <= len(request["id"]) <= 80
):
return
try:
validate_patch(request.get("patch"))
except ValueError:
return
deadline = self.deadline
self._rotate() # consume before awaiting lock or touching the socket
self.result = {"id": request["id"], "status": "pending"}
publish() # publication failure prevents action
try:
result = await self.meshcore.companion_settings(
request["patch"],
may_send=lambda: time.monotonic() < deadline and self._request() == request,
)
self.result = {**result, "id": request["id"]}
except asyncio.CancelledError:
self.result = {"id": request["id"], "status": "unknown"}
publish()
raise
except Exception:
logger.exception("Companion settings outcome unknown; no retry")
self.result = {"id": request["id"], "status": "unknown"}
Loading
Loading