docs+plugin: HTTP-only transport cleanup, install guide, review fixes
CI / Gateway plugin tests (push) Successful in 5m19s
CI / Kotlin tests (android host + desktop) (push) Successful in 7m3s

- docs/install.md: new end-to-end guide for non-technical users
  (gateway install, app install, LAN/TLS/remote connection, push,
  options, troubleshooting); docs/setup.md now points to it
- README: new 'Install the gateway' section; pairing section updated
  for HTTP transport (8791, QR scan on Android)
- rename IRIS_WS_HOST -> IRIS_HTTP_HOST (clean rename, no compat
  fallback); drop dead DEFAULT_PORT=8790
- setup.py: advertise https:// in the printed/QR server URL when
  IRIS_HTTP_CERT is set
- ws_probe.py/e2e.py: default --url http://127.0.0.1:8791, env
  IRIS_WS_URL -> IRIS_HTTP_URL, honor explicit port + https scheme
- plugin.yaml: IRIS_HTTP_* env names, description no longer says
  'WebSocket server'
- docs 03/09/12/19: fix stale WS-era refs (ws_server.py cites,
  8790 smoke test, WSS->HTTPS, 'HTTP fallback' reframed as the
  only transport)
- AGENTS.md: symlink name android -> iris (matches actual install)
- test: adapter reads IRIS_HTTP_HOST/CERT/KEY from env; legacy
  IRIS_WS_* names are not consulted (95/95 pass)
This commit is contained in:
ARIA committed 2026-08-24 22:22:02 +02:00
1 parent a61b47a947
commit b1c9bac7d8
18 files changed
+472 -317

No files matched your search

+4 -5
View File
@@ -45,14 +45,14 @@ Configuration in config.yaml::
enabled: true
extra:
host: 127.0.0.1
port: 8790
http_port: 8791
home_channel: default
push_backend: fcm
outbox_retention_hours: 72
max_upload_bytes: 104857600
Or via environment variables (overrides config.yaml; secrets live in .env):
IRIS_TOKEN, IRIS_WS_HOST, IRIS_WS_PORT, IRIS_HOME_CHANNEL,
IRIS_TOKEN, IRIS_HTTP_HOST, IRIS_HTTP_PORT, IRIS_HOME_CHANNEL,
IRIS_PUSH_BACKEND, IRIS_FCM_SERVICE_ACCOUNT, NTFY_TOPIC, ...
"""
@@ -175,9 +175,8 @@ class IrisAdapter(
extra = getattr(config, "extra", {}) or {}
# Connection settings (env vars override config.yaml). The bind host
# is shared with the (legacy) WS-era env var name for compatibility.
self.host = os.getenv("IRIS_WS_HOST", "").strip() or extra.get("host", DEFAULT_HOST)
# Connection settings (env vars override config.yaml).
self.host = os.getenv("IRIS_HTTP_HOST", "").strip() or extra.get("host", DEFAULT_HOST)
# docs/19: HTTP transport (the only device-facing transport; optional TLS).
self.http_port = _parse_port(
os.getenv("IRIS_HTTP_PORT", "") or str(extra.get("http_port", DEFAULT_HTTP_PORT))
+1 -2
View File
@@ -1,8 +1,7 @@
"""Platform defaults (config.yaml ``extra`` / env fallbacks)."""
DEFAULT_HOST = "127.0.0.1"
DEFAULT_PORT = 8790
DEFAULT_HTTP_PORT = 8791 # docs/19: HTTP fallback leg
DEFAULT_HTTP_PORT = 8791 # docs/19: HTTP is the only transport
DEFAULT_HOME_CHANNEL = "default"
DEFAULT_HOME_CHANNEL_NAME = "Default"
DEFAULT_PUSH_BACKEND = "ntfy"
+15 -15
View File
@@ -4,9 +4,9 @@ kind: platform
version: 0.1.0
description: >
Native Android / Desktop client gateway adapter for Hermes Agent.
Runs a WebSocket server inside the gateway; the app connects with a
pairing token. Supports streaming, reasoning, structured tool events,
channels/threads, media, FTS5 search, and FCM/ntfy push.
Runs an HTTP server (optional TLS) inside the gateway; the app connects
with a pairing token. Supports streaming, reasoning, structured tool
events, channels/threads, media, FTS5 search, and FCM/ntfy push.
author: Iris x Hermes
# ``requires_env`` / ``optional_env`` entries are surfaced in the
# ``hermes config`` / ``hermes gateway setup`` UI via the platform-plugin
@@ -17,13 +17,13 @@ requires_env:
prompt: "Iris pairing token"
password: true
optional_env:
- name: IRIS_WS_HOST
description: "WS bind host (default 127.0.0.1; use 0.0.0.0 for LAN)"
prompt: "WS host"
- name: IRIS_HTTP_HOST
description: "HTTP bind host (default 127.0.0.1; use 0.0.0.0 for LAN)"
prompt: "HTTP host"
password: false
- name: IRIS_WS_PORT
description: "WS port (default 8790)"
prompt: "WS port"
- name: IRIS_HTTP_PORT
description: "HTTP port (default 8791)"
prompt: "HTTP port"
password: false
- name: IRIS_HOME_CHANNEL
description: "Default chat id for cron/notification delivery (default: default)"
@@ -61,11 +61,11 @@ optional_env:
description: "ntfy auth token for a private topic (trust boundary)"
prompt: "ntfy auth token"
password: true
- name: IRIS_WS_CERT
description: "TLS cert path for WSS (optional)"
prompt: "WSS cert"
- name: IRIS_HTTP_CERT
description: "TLS cert path for HTTPS (optional)"
prompt: "HTTPS cert"
password: false
- name: IRIS_WS_KEY
description: "TLS key path for WSS (optional)"
prompt: "WSS key"
- name: IRIS_HTTP_KEY
description: "TLS key path for HTTPS (optional)"
prompt: "HTTPS key"
password: false
+9 -9
View File
@@ -21,7 +21,6 @@ from .defaults import (
DEFAULT_HOME_CHANNEL_NAME,
DEFAULT_HOST,
DEFAULT_HTTP_PORT,
DEFAULT_PORT,
DEFAULT_PUSH_BACKEND,
)
from .pairing import (
@@ -89,7 +88,7 @@ def _env_enablement() -> dict | None:
# clobber user YAML. Unset keys fall through to config.yaml / adapter
# defaults.
seed: dict[str, Any] = {}
host = os.getenv("IRIS_WS_HOST", "").strip()
host = os.getenv("IRIS_HTTP_HOST", "").strip()
if host:
seed["host"] = host
http_port_raw = os.getenv("IRIS_HTTP_PORT", "").strip()
@@ -111,7 +110,7 @@ def _parse_port(raw: str) -> int:
try:
return int((raw or "").strip())
except (ValueError, TypeError):
return DEFAULT_PORT
return DEFAULT_HTTP_PORT
# ---------------------------------------------------------------------------
@@ -342,10 +341,8 @@ def interactive_setup() -> None:
# off a lost/compromised device before continuing with the config.
_offer_device_removal()
host = prompt("Bind host", default=get_env_value("IRIS_WS_HOST") or DEFAULT_HOST)
save_env_value("IRIS_WS_HOST", host or DEFAULT_HOST)
# _parse_port falls back to DEFAULT_PORT (8790) for empty input, so the
# HTTP default must be applied explicitly (docs/19: 8791).
host = prompt("Bind host", default=get_env_value("IRIS_HTTP_HOST") or DEFAULT_HOST)
save_env_value("IRIS_HTTP_HOST", host or DEFAULT_HOST)
http_port_raw = (get_env_value("IRIS_HTTP_PORT") or "").strip()
port = prompt(
"HTTP port",
@@ -370,8 +367,11 @@ def interactive_setup() -> None:
# replaced by the default-route LAN IP so the QR points somewhere a phone
# can actually reach (the user can still override the Server URL in-app).
advertised = advertise_host(host or DEFAULT_HOST)
url = pairing_url(advertised, _parse_port(port))
pairing = qr_payload(advertised, _parse_port(port), token)
# Advertise https when TLS is configured, so the printed/QR Server URL
# matches the scheme the gateway actually serves.
secure = bool((get_env_value("IRIS_HTTP_CERT") or "").strip())
url = pairing_url(advertised, _parse_port(port), secure=secure)
pairing = qr_payload(advertised, _parse_port(port), token, secure=secure)
print_info("Pair your device (enter this on the app's Connect screen):")
print_info(f"Pairing URL: {pairing}")
print_info(f"Server URL: {url}")
+1 -1
View File
@@ -70,7 +70,7 @@ FAIL per scenario plus a summary table; exits 0 if no FAIL, 1 otherwise::
hermes-agent/.venv/bin/python gateway-plugin/tests/e2e.py
hermes-agent/.venv/bin/python gateway-plugin/tests/e2e.py --skip 3,5,7
hermes-agent/.venv/bin/python gateway-plugin/tests/e2e.py --url ws://host:8790/ws
hermes-agent/.venv/bin/python gateway-plugin/tests/e2e.py --url http://host:8791
The token is read from `$IRIS_TOKEN`, else `hermes-agent/.env`, else
`~/.hermes/.env`. The gateway must already be running (the driver never
+5 -5
View File
@@ -9,7 +9,7 @@ Usage::
hermes-agent/.venv/bin/python gateway-plugin/tests/e2e.py
hermes-agent/.venv/bin/python gateway-plugin/tests/e2e.py --skip 3,5,7
hermes-agent/.venv/bin/python gateway-plugin/tests/e2e.py --url ws://host:8790/ws
hermes-agent/.venv/bin/python gateway-plugin/tests/e2e.py --url http://host:8791
The token is read from $IRIS_TOKEN, else hermes-agent/.env, else
~/.hermes/.env. The gateway must already be running (this driver never
@@ -40,7 +40,7 @@ REPO = HERE.parent.parent
PY = REPO / "hermes-agent" / ".venv" / "bin" / "python"
PROBE = HERE / "ws_probe.py"
HERMES = REPO / "hermes-agent" / ".venv" / "bin" / "hermes"
DEFAULT_URL = "ws://127.0.0.1:8790/ws"
DEFAULT_URL = "http://127.0.0.1:8791"
PASS, PARTIAL, SKIP, FAIL = "PASS", "PARTIAL", "SKIP", "FAIL"
@@ -309,8 +309,8 @@ def s13_http_fallback(env, url, token):
health + POST /v1/frame + SSE /v1/events (no WS involved). The user echo
must land on the SSE stream promptly after the POST (< 1.5 s on LAN)."""
u = urlparse(url)
scheme = "https" if u.scheme == "wss" else "http"
http_port = os.getenv("IRIS_HTTP_PORT", "8791")
scheme = "https" if u.scheme in ("wss", "https") else "http"
http_port = u.port or int(os.getenv("IRIS_HTTP_PORT", "8791"))
http_url = f"{scheme}://{u.hostname or '127.0.0.1'}:{http_port}"
rc, out, _ = run_probe(env, url, token, "--http", "--http-url", http_url,
"--send", "Reply with exactly: e2e http fallback OK",
@@ -352,7 +352,7 @@ def main() -> int:
p = argparse.ArgumentParser(
description=__doc__, formatter_class=argparse.RawDescriptionHelpFormatter
)
p.add_argument("--url", default=os.getenv("IRIS_WS_URL", DEFAULT_URL))
p.add_argument("--url", default=os.getenv("IRIS_HTTP_URL", DEFAULT_URL))
p.add_argument("--token", default="")
p.add_argument("--skip", default="",
help="comma-separated scenario numbers to skip (e.g. 3,5,7)")
+50 -2
View File
@@ -100,11 +100,11 @@ def adapter(plugin, monkeypatch):
monkeypatch.setenv("IRIS_TOKEN", TOKEN)
# Clear any IRIS transport overrides leaked into the process env by earlier
# tests (e.g. test_interactive_setup_prints_qr runs the real
# interactive_setup(), which save_env_value()s IRIS_HTTP_PORT/IRIS_WS_HOST).
# interactive_setup(), which save_env_value()s IRIS_HTTP_PORT/IRIS_HTTP_HOST).
# Without this, a later adapter would bind the leaked port (8791) instead of
# the ephemeral 0 below, colliding with a live gateway on that port.
monkeypatch.delenv("IRIS_HTTP_PORT", raising=False)
monkeypatch.delenv("IRIS_WS_HOST", raising=False)
monkeypatch.delenv("IRIS_HTTP_HOST", raising=False)
from gateway.platform_registry import PlatformEntry, platform_registry
# Platform("iris") resolves only once the platform is registered
@@ -139,6 +139,54 @@ def adapter(plugin, monkeypatch):
pass
def test_adapter_reads_http_env_overrides(plugin, monkeypatch, tmp_path):
"""IRIS_HTTP_HOST / IRIS_HTTP_CERT / IRIS_HTTP_KEY are read from the env
(overriding config.yaml extra), and the legacy IRIS_WS_* names are NOT
consulted (HTTP-only transport, docs/19)."""
monkeypatch.setenv("IRIS_TOKEN", TOKEN)
monkeypatch.setenv("IRIS_HTTP_HOST", "192.168.1.50")
monkeypatch.setenv("IRIS_HTTP_CERT", str(tmp_path / "cert.pem"))
monkeypatch.setenv("IRIS_HTTP_KEY", str(tmp_path / "key.pem"))
# Legacy WS-era names must be ignored, even if present.
monkeypatch.setenv("IRIS_WS_HOST", "10.9.9.9")
monkeypatch.setenv("IRIS_WS_CERT", str(tmp_path / "old.pem"))
from gateway.platform_registry import platform_registry
if not platform_registry.is_registered("iris"):
from gateway.platform_registry import PlatformEntry
platform_registry.register(
PlatformEntry(
name="iris",
label="Android",
adapter_factory=lambda cfg: None,
check_fn=lambda: True,
)
)
config = SimpleNamespace(
extra={"host": "127.0.0.1", "http_port": 0},
home_channel=None,
)
a = plugin.adapter.IrisAdapter(config)
try:
assert a.host == "192.168.1.50" # env wins over extra
assert a.http_cert == str(tmp_path / "cert.pem")
assert a.http_key == str(tmp_path / "key.pem")
# Legacy names are not read: host/cert are not the old values.
assert a.host != "10.9.9.9"
assert a.http_cert != str(tmp_path / "old.pem")
finally:
try:
a._devices.close()
except Exception:
pass
try:
a._outbox.close()
except Exception:
pass
class HttpTestClient:
"""Mimics the old WS client interface over the HTTP transport (docs/19).
+9 -5
View File
@@ -12,7 +12,9 @@ Usage::
--send "hello"
Options:
--url ws://host:port/ws (default ws://127.0.0.1:8790/ws)
--url http(s)://host:port (default http://127.0.0.1:8791)
(legacy ws(s)://host:8790/ws URLs are still accepted and
converted to the HTTP base automatically)
--token IRIS_TOKEN (default: $IRIS_TOKEN)
--device device_id (default: probe-<rand>)
--send TEXT send this message after pairing (default: "hello")
@@ -666,7 +668,7 @@ def run_http(args, base: str) -> int:
def main() -> int:
p = argparse.ArgumentParser(description=__doc__)
p.add_argument("--url", default=os.getenv("IRIS_WS_URL", "ws://127.0.0.1:8790/ws"))
p.add_argument("--url", default=os.getenv("IRIS_HTTP_URL", "http://127.0.0.1:8791"))
p.add_argument("--token", default=os.getenv("IRIS_TOKEN", ""))
p.add_argument("--device", default=f"probe-{uuid.uuid4().hex[:8]}")
p.add_argument("--send", default="hello")
@@ -766,15 +768,17 @@ def main() -> int:
if args.assert_read_receipt and not args.send:
p.error("--assert-read-receipt requires --send (the receipt must follow the sent message)")
# HTTP is the only transport (docs/19): derive the http(s) base from the
# --url (ws://host:8790/ws -> http://host:8791) unless --http-url is given.
# --url (legacy ws(s)://host:8790/ws -> http(s)://host:8791) unless
# --http-url is given.
if args.http_url:
base = args.http_url
else:
from urllib.parse import urlparse
u = urlparse(args.url)
scheme = "https" if u.scheme == "wss" else "http"
base = f"{scheme}://{u.hostname or '127.0.0.1'}:8791"
scheme = "https" if u.scheme in ("wss", "https") else "http"
port = u.port or 8791
base = f"{scheme}://{u.hostname or '127.0.0.1'}:{port}"
return run_http(args, base)