From aeb3c9f8fbb96b07d02de7ccb55923535b707704 Mon Sep 17 00:00:00 2001 From: William Valentin Date: Thu, 4 Jun 2026 16:19:22 -0700 Subject: [PATCH] fix(npu): expose advisory gateway on docker bridge --- docs/swarm-infrastructure.md | 6 ++-- openvino-advisory-gateway/README.md | 18 ++++++++---- openvino-advisory-gateway/gateway.py | 28 +++++++++++++++++-- .../openvino-advisory-gateway.service | 7 +++-- .../tests/test_gateway.py | 12 ++++++++ 5 files changed, 57 insertions(+), 14 deletions(-) diff --git a/docs/swarm-infrastructure.md b/docs/swarm-infrastructure.md index 558a0be..2a8a46a 100644 --- a/docs/swarm-infrastructure.md +++ b/docs/swarm-infrastructure.md @@ -133,7 +133,7 @@ Host/user services: - `openvino-router-classifier.service` — `:18819`, local-only dry-run Atlas/Hermes message classifier; advisory only - `openvino-genai-npu-worker.service` — `:18820`, local-only bounded GenAI worker for small background generation jobs - `openvino-doc-image-triage.service` — `:18829`, local-only document/image triage HTTP wrapper with allowed-root enforcement -- `openvino-advisory-gateway.service` — `:18830`, local-only advisory envelope wrapper over classifier, GenAI, and doc/image triage; explicit no-authority contract +- `openvino-advisory-gateway.service` — `172.19.0.1:18830`, Docker-bridge advisory envelope wrapper over classifier, GenAI, and doc/image triage for `n8n-agent`; explicit no-authority contract Local-only OpenVINO NPU sidecars: @@ -143,9 +143,9 @@ Local-only OpenVINO NPU sidecars: | `18819` | router/classifier | live user service; dry-run only | no Hermes/Atlas routing, memory writes, service restarts, or outbound messages | | `18820` | bounded GenAI worker | live user service | background jobs only; not primary Atlas/Hermes model routing | | `18829` | document/image triage | live localhost server | allowed-root limited; no private directory processing unless explicitly approved; NPU stage is embeddings via `:18817` | -| `18830` | advisory gateway | live user service; host-local Hermes-accessible wrapper | returns `openvino_advisory_v1` envelopes only; no routing, memory writes, external sends, tool execution, restarts, or process-root broadening from request payloads; n8n bridge access intentionally disabled unless separately approved | +| `18830` | advisory gateway | live user service; bound to `172.19.0.1` for `n8n-agent` bridge access | returns `openvino_advisory_v1` envelopes only; no routing, memory writes, external sends, tool execution, restarts, or process-root broadening from request payloads; refuses wildcard binds | -These sidecars bind to `127.0.0.1` by default and must not be wired into live Atlas/Hermes routing, memory writes, broad private document processing, or primary model paths without explicit Will approval. Any NPU claim requires a positive `/sys/class/accel/accel0/device/npu_busy_time_us` delta before/after inference or service-reported equivalent. HTTP 200 alone is not proof. +These sidecars bind to `127.0.0.1` by default, except `openvino-advisory-gateway.service`, which is explicitly approved on the Docker bridge IP `172.19.0.1` so `n8n-agent` can call it. They must not be wired into live Atlas/Hermes routing, memory writes, broad private document processing, external sends, tool execution, service restarts, or primary model paths without explicit Will approval. Any NPU claim requires a positive `/sys/class/accel/accel0/device/npu_busy_time_us` delta before/after inference or service-reported equivalent. HTTP 200 alone is not proof. ### 5. Obsidian and RAG diff --git a/openvino-advisory-gateway/README.md b/openvino-advisory-gateway/README.md index d77c059..50b9405 100644 --- a/openvino-advisory-gateway/README.md +++ b/openvino-advisory-gateway/README.md @@ -1,8 +1,8 @@ # OpenVINO NPU advisory gateway -Local-only bounded wrapper for the classifier, GenAI worker, and doc/image triage sidecars. +Bounded Docker-bridge wrapper for the classifier, GenAI worker, and doc/image triage sidecars. -- HTTP bind: `127.0.0.1:18830` only; Docker/n8n bridge access is intentionally not enabled by default +- HTTP bind: `172.19.0.1:18830` for `n8n-agent` on the `swarm_default` Docker bridge - Service: `openvino-advisory-gateway.service` - Mode: advisory/shadow/draft only - Metadata log: `~/.local/state/openvino-advisory-gateway/events.sqlite` @@ -36,7 +36,7 @@ POST /v1/advisory/triage ### Classifier shadow call ```bash -curl -fsS http://127.0.0.1:18830/v1/advisory/classify \ +curl -fsS http://172.19.0.1:18830/v1/advisory/classify \ -H 'Content-Type: application/json' \ -d '{"trace_id":"smoke","text":"Urgent: inspect service health and systemd status."}' | jq . ``` @@ -46,7 +46,7 @@ curl -fsS http://127.0.0.1:18830/v1/advisory/classify \ Allowed jobs: `title`, `summary`, `notification`, `memory_candidate`. ```bash -curl -fsS http://127.0.0.1:18830/v1/advisory/generate \ +curl -fsS http://172.19.0.1:18830/v1/advisory/generate \ -H 'Content-Type: application/json' \ -d '{"job":"title","input":"Summarize a local health check.","max_new_tokens":24}' | jq . ``` @@ -54,7 +54,7 @@ curl -fsS http://127.0.0.1:18830/v1/advisory/generate \ ### Explicit-file doc/image triage ```bash -curl -fsS http://127.0.0.1:18830/v1/advisory/triage \ +curl -fsS http://172.19.0.1:18830/v1/advisory/triage \ -H 'Content-Type: application/json' \ -d '{"path":"/home/will/lab/swarm/openvino-doc-image-triage-npu/samples/synthetic_invoice.png","allowed_roots":["/home/will/lab/swarm/openvino-doc-image-triage-npu"]}' | jq . ``` @@ -75,7 +75,13 @@ systemctl --user enable --now openvino-advisory-gateway.service systemctl --user status openvino-advisory-gateway.service --no-pager ``` -`--allowed-root` may be repeated in the systemd unit when additional non-private fixture/review directories are approved. Keep the service bound to `127.0.0.1` unless Will explicitly approves a Docker-bridge exposure plan. +`--allowed-root` may be repeated in the systemd unit when additional non-private fixture/review directories are approved. Docker bridge exposure must use `--allow-docker-bridge` and the approved bridge IP `172.19.0.1`; the service still refuses wildcard binds such as `0.0.0.0`. + +From `n8n-agent`, verify bridge reachability with: + +```bash +docker exec n8n-agent wget -qO- -T 8 http://172.19.0.1:18830/healthz +``` ## Tests diff --git a/openvino-advisory-gateway/gateway.py b/openvino-advisory-gateway/gateway.py index a6da571..e226676 100644 --- a/openvino-advisory-gateway/gateway.py +++ b/openvino-advisory-gateway/gateway.py @@ -10,6 +10,7 @@ from __future__ import annotations import argparse import hashlib +import ipaddress import json import os import sqlite3 @@ -21,6 +22,7 @@ from typing import Any, Callable from urllib.parse import urlparse HOST = "127.0.0.1" +DOCKER_BRIDGE_HOST = "172.19.0.1" PORT = 18830 CLASSIFIER_URL = "http://127.0.0.1:18819/v1/classify" GENAI_URL = "http://127.0.0.1:18820/v1/worker/generate" @@ -40,6 +42,20 @@ AUTHORITY = { } +def validate_bind_host(host: str, *, allow_docker_bridge: bool = False) -> None: + """Restrict service exposure to localhost or the explicitly approved Docker bridge bind.""" + if host == "127.0.0.1": + return + if not allow_docker_bridge: + raise ValueError("refusing non-local bind without --allow-docker-bridge") + try: + addr = ipaddress.ip_address(host) + except ValueError as exc: + raise ValueError("bind host must be a literal IP address") from exc + if host != DOCKER_BRIDGE_HOST or not (addr.version == 4 and addr.is_private and not addr.is_loopback and not addr.is_unspecified): + raise ValueError(f"Docker bridge bind must use approved bridge IP {DOCKER_BRIDGE_HOST}") + + def sha256_text(text: str) -> str: return hashlib.sha256(text.encode("utf-8")).hexdigest() @@ -335,9 +351,17 @@ def main(argv: list[str] | None = None) -> int: parser.add_argument("--port", type=int, default=int(os.environ.get("NPU_ADVISORY_PORT", str(PORT)))) parser.add_argument("--log-db", default=str(DEFAULT_LOG_DB)) parser.add_argument("--allowed-root", action="append", dest="allowed_roots", default=None, help="Configured file root allowed for advisory doc/image triage. May be repeated.") + parser.add_argument( + "--allow-docker-bridge", + action="store_true", + default=os.environ.get("NPU_ADVISORY_ALLOW_DOCKER_BRIDGE", "").lower() in {"1", "true", "yes"}, + help="Permit binding to a private Docker bridge IP instead of 127.0.0.1.", + ) args = parser.parse_args(argv) - if args.host != "127.0.0.1": - raise SystemExit("refusing non-local bind") + try: + validate_bind_host(args.host, allow_docker_bridge=args.allow_docker_bridge) + except ValueError as exc: + raise SystemExit(str(exc)) from exc configured_roots = [Path(p).expanduser().resolve() for p in (args.allowed_roots or DEFAULT_ALLOWED_ROOTS)] logger = AdvisoryLogger(args.log_db) server = ThreadingHTTPServer((args.host, args.port), make_handler(logger, configured_roots)) diff --git a/openvino-advisory-gateway/openvino-advisory-gateway.service b/openvino-advisory-gateway/openvino-advisory-gateway.service index 5a5f00a..4773fc4 100644 --- a/openvino-advisory-gateway/openvino-advisory-gateway.service +++ b/openvino-advisory-gateway/openvino-advisory-gateway.service @@ -1,15 +1,16 @@ [Unit] -Description=OpenVINO NPU advisory gateway (local-only, port 18830) +Description=OpenVINO NPU advisory gateway (Docker bridge, port 18830) After=network.target openvino-router-classifier.service openvino-genai-npu-worker.service openvino-doc-image-triage.service Wants=openvino-router-classifier.service openvino-genai-npu-worker.service openvino-doc-image-triage.service [Service] Type=simple WorkingDirectory=/home/will/lab/swarm/openvino-advisory-gateway -Environment=NPU_ADVISORY_HOST=127.0.0.1 +Environment=NPU_ADVISORY_HOST=172.19.0.1 Environment=NPU_ADVISORY_PORT=18830 +Environment=NPU_ADVISORY_ALLOW_DOCKER_BRIDGE=true Environment=NPU_ADVISORY_LOG_DB=/home/will/.local/state/openvino-advisory-gateway/events.sqlite -ExecStart=/home/will/.venvs/npu/bin/python /home/will/lab/swarm/openvino-advisory-gateway/gateway.py --host 127.0.0.1 --port 18830 --allowed-root /home/will/lab/swarm/openvino-doc-image-triage-npu +ExecStart=/home/will/.venvs/npu/bin/python /home/will/lab/swarm/openvino-advisory-gateway/gateway.py --host 172.19.0.1 --port 18830 --allow-docker-bridge --allowed-root /home/will/lab/swarm/openvino-doc-image-triage-npu Restart=on-failure RestartSec=5 diff --git a/openvino-advisory-gateway/tests/test_gateway.py b/openvino-advisory-gateway/tests/test_gateway.py index ccd1b1c..303b28c 100644 --- a/openvino-advisory-gateway/tests/test_gateway.py +++ b/openvino-advisory-gateway/tests/test_gateway.py @@ -34,6 +34,18 @@ def test_authority_envelope_is_advisory_and_forbids_side_effects() -> None: assert env["npu_proof"] == {"required": True, "ok": True, "npu_busy_delta_us": 123} +def test_bind_host_requires_explicit_docker_bridge_approval() -> None: + gateway.validate_bind_host("127.0.0.1") + + with pytest.raises(ValueError, match="without --allow-docker-bridge"): + gateway.validate_bind_host("172.19.0.1") + + gateway.validate_bind_host("172.19.0.1", allow_docker_bridge=True) + + with pytest.raises(ValueError, match="approved bridge IP"): + gateway.validate_bind_host("0.0.0.0", allow_docker_bridge=True) + + def test_classify_calls_sidecar_and_logs_metadata_only(tmp_path: Path) -> None: calls: list[tuple[str, dict]] = []