fix(npu): expose advisory gateway on docker bridge

This commit is contained in:
William Valentin
2026-06-04 16:19:22 -07:00
parent 59c5fd3e57
commit aeb3c9f8fb
5 changed files with 57 additions and 14 deletions
+3 -3
View File
@@ -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
+12 -6
View File
@@ -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
+26 -2
View File
@@ -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))
@@ -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
@@ -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]] = []