fix(svc-ai-agent-broker): select platform and relay path from server-side lists

py/partial-ssrf on PR #625 (agents.py:195, :250, server.py:123): the chat body's model now selects its entry from AGENT_PLATFORMS through agents.pick, so the agent host is built from the server-side key; a list or dict model answers 404 instead of a TypeError. The relay forwards only /v1/chat/completions and /v1/models with the broker's key; any other path answers 404 and logs relay_refused (the two paths are the ones the relay logs show in use).

py/empty-except (agents.py:201, :258): the health wait names its last error in the timeout, and the model-list fallback logs agent_model_fallback through the log function the server passes in.

Tests cover pick, six refused relay paths and both error paths; each fails against the old code.

🎯 Fix: 92 %
💥 Break: 4 %
💸 Cost: –
⏱️ Time: –
🛡️ Security: ~100 %
🏆 Score: 88
🚦 Gate: ✅ 90/5, cost and time <= 100, security 100 %

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
kevinveenbirkenbachandClaude Opus 5.5 committed 2026-11-05 17:18:22 +00:00
1 parent dcfb220dca
commit eeb9b22ae4
4 files changed
+102 -15

No files matched your search

+1 -1
View File
@@ -17,4 +17,4 @@ This role deploys the broker together with a container-API socket proxy in Compo
- **Bind-mount allowlist:** One `-allowbindmountfrom` carries all four CA-trust files the deployment binds into every container as a comma-separated value, and nothing else; the flag is a plain string the proxy splits on commas, so a repeated flag keeps only its last occurrence. `ensure_pool` re-POSTs the broker's own service spec to attach the pool network, so the spec's own mounts have to pass that check; dropping them answers the update with 403 and the broker crash-loops. An empty allowlist is not an option because it switches the restriction off for every call.
- **Lifecycle settings:** `agents.idle_stop`, `agents.idle_minutes`, `agents.max_running` and `agents.start_timeout` in `meta/services.yml` control idle stops, the running-agent bound and the start wait; an inventory overrides them under `applications.svc-ai-agent-broker.services.agent-broker.agents`. An agent serving a request is never swept.
- **Model and context:** `agents.model` names the gateway alias every agent runs on and defaults to the gateway's chat model, so agents can run on a larger model than Open WebUI's default. The context window of that alias comes from the model's `context` in the inventory and is written into the agent config as Hermes `model.context_length` and OpenClaw `contextWindow`. An agent prompt runs to tens of thousands of tokens, so a CPU-only local model spends minutes per turn on it; point `agents.model` at a remote model for interactive use. Hermes refuses a window below 64000 tokens and answers HTTP 500 on every prompt, so a platform whose minimum the selected alias misses is not offered at all: it is left out of `/v1/models` and answers 404, and the deploy says which platform it dropped and why. The rest of the broker keeps working, which is what makes a small model and OpenClaw a valid pairing. An alias that declares no window is reported instead of filtered, because an agent that does not know its window runs.
- **Model relay:** Agents call `http://agent-broker:<port>/llm/v1` with their own key; the broker forwards the call to LiteLLM with the broker's virtual key, sets the OpenAI `user` field to the owner and logs a JSON `relay` event with owner, platform, path and upstream status.
- **Model relay:** Agents call `http://agent-broker:<port>/llm/v1` with their own key; the broker forwards the call to LiteLLM with the broker's virtual key, sets the OpenAI `user` field to the owner and logs a JSON `relay` event with owner, platform, path and upstream status. Only `/v1/chat/completions` and `/v1/models` are relayed; any other path answers 404 and logs a `relay_refused` event, so an agent cannot reach the gateway's other routes with the broker's key.
@@ -18,12 +18,23 @@ WRITE_CONFIG = (
'mkdir -p "$(dirname "$AGENT_CONFIG_PATH")" && '
'printf "%s" "$AGENT_CONFIG" > "$AGENT_CONFIG_PATH" && exec "$@"'
)
RELAY_PATHS = ("/v1/chat/completions", "/v1/models")
class CapacityError(RuntimeError):
pass
def pick(requested, allowed):
"""Return the entry of ``allowed`` equal to ``requested``, or None.
Args:
requested: value a request asks for.
allowed: server-side names or paths a request may select.
"""
return next((entry for entry in allowed if entry == requested), None)
def agent_name(platform, owner):
digest = hashlib.sha256(owner.encode()).hexdigest()[:16]
return f"agent-{platform}-{digest}"
@@ -98,6 +109,7 @@ class Agents:
idle_seconds: idle time before a stop.
max_running: bound on concurrently running agents.
start_timeout: seconds an agent gets to answer its health endpoint.
log: ``log(event, **fields)`` writing one broker log line.
"""
def __init__(
@@ -114,6 +126,7 @@ class Agents:
idle_seconds,
max_running,
start_timeout,
log,
):
self.backend = backend
self.engine = engine
@@ -127,6 +140,7 @@ class Agents:
self.idle_seconds = idle_seconds
self.max_running = max_running
self.start_timeout = start_timeout
self.log = log
self._locks = {}
self._locks_guard = threading.Lock()
self._busy = {}
@@ -191,6 +205,7 @@ class Agents:
spec = self.platforms[platform]
url = f"http://{name}:{spec['port']}{spec['health']}"
deadline = time.monotonic() + self.start_timeout
last_error = None
while time.monotonic() < deadline:
request = urllib.request.Request(url)
request.add_header("Authorization", f"Bearer {key}")
@@ -198,11 +213,12 @@ class Agents:
with urllib.request.urlopen(request, timeout=5) as response: # noqa: S310 - http:// URL built from the agent name and port
if response.status < 400:
return
except (urllib.error.URLError, OSError):
pass
except (urllib.error.URLError, OSError) as error:
last_error = error
time.sleep(2)
raise TimeoutError(
f"{name} did not answer {spec['health']} within {self.start_timeout}s"
f" (last error: {last_error})"
)
def ensure(self, platform, owner):
@@ -255,8 +271,10 @@ class Agents:
listed = json.loads(response.read()).get("data") or []
if listed:
model = listed[0]["id"]
except (urllib.error.URLError, OSError, ValueError, KeyError):
pass
except (urllib.error.URLError, OSError, ValueError, KeyError) as error:
self.log(
"agent_model_fallback", platform=platform, model=model, error=str(error)
)
self._agent_models[platform] = model
return model
@@ -38,7 +38,7 @@ import urllib.parse
from http.server import BaseHTTPRequestHandler, ThreadingHTTPServer
from access import AccessError, Keycloak
from agents import Agents, CapacityError
from agents import RELAY_PATHS, Agents, CapacityError, pick
from engine import ComposeBackend, Engine, EngineError
from swarm import SwarmBackend
@@ -53,6 +53,11 @@ USER_ID_HEADER = "X-OpenWebUI-User-Id"
USER_EMAIL_HEADER = "X-OpenWebUI-User-Email"
HOP_BY_HOP = {"connection", "transfer-encoding", "keep-alive", "content-length"}
def log(event, **fields):
print(json.dumps({"ts": time.time(), "event": event, **fields}), flush=True)
ENGINE = Engine(ENV["ENGINE_SOCKET"], timeout=120)
MAX_RUNNING = int(ENV["MAX_RUNNING"])
BACKEND = (
@@ -77,6 +82,7 @@ AGENTS = Agents(
idle_seconds=int(ENV["IDLE_MINUTES"]) * 60,
max_running=MAX_RUNNING,
start_timeout=int(ENV["START_TIMEOUT"]),
log=log,
)
ACCESS = Keycloak(
ENV["KEYCLOAK_URL"],
@@ -87,10 +93,6 @@ ACCESS = Keycloak(
)
def log(event, **fields):
print(json.dumps({"ts": time.time(), "event": event, **fields}), flush=True)
def bearer(headers):
value = headers.get("Authorization") or ""
return value[7:] if value.startswith("Bearer ") else ""
@@ -181,9 +183,10 @@ class Handler(BaseHTTPRequestHandler):
except ValueError:
self._error(400, "body is not JSON")
return
platform = payload.get("model")
if platform not in PLATFORMS:
self._error(404, f"unknown agent platform '{platform}'")
requested = payload.get("model")
platform = pick(requested, PLATFORMS)
if platform is None:
self._error(404, f"unknown agent platform '{requested}'")
return
try:
allowed = ACCESS.allows(email, PLATFORMS[platform]["group"])
@@ -227,6 +230,12 @@ class Handler(BaseHTTPRequestHandler):
self._error(401, "unknown agent key")
return
owner, platform = entry
requested = self.path[len("/llm") :]
path = pick(requested, RELAY_PATHS)
if path is None:
log("relay_refused", owner=owner, platform=platform, path=requested)
self._error(404, "path is not relayed")
return
body = self._body()
if body and self.command == "POST":
try:
@@ -240,7 +249,6 @@ class Handler(BaseHTTPRequestHandler):
"Authorization": f"Bearer {LITELLM_KEY}",
"Content-Type": self.headers.get("Content-Type") or "application/json",
}
path = self.path[len("/llm") :]
status = self._proxy(
LITELLM.hostname, LITELLM.port, path, body or None, headers
)
@@ -3,6 +3,8 @@ from __future__ import annotations
import json
import sys
import unittest
import urllib.error
from unittest import mock
import yaml
@@ -68,7 +70,7 @@ class FakeBackend:
detail["running"] = False
def make_agents(backend=None, idle_stop=True, context=0):
def make_agents(backend=None, idle_stop=True, context=0, log=None):
return agents.Agents(
backend=backend,
engine=FakeEngine(),
@@ -82,9 +84,68 @@ def make_agents(backend=None, idle_stop=True, context=0):
idle_seconds=60,
max_running=2,
start_timeout=10,
log=log or (lambda event, **fields: None),
)
class TestPick(unittest.TestCase):
def test_a_known_platform_resolves_to_the_configured_name(self):
requested = json.loads('{"model": "hermes"}')["model"]
picked = agents.pick(requested, PLATFORMS)
self.assertEqual(picked, "hermes")
self.assertIs(picked, next(iter(PLATFORMS)))
def test_an_unknown_or_unhashable_platform_resolves_to_none(self):
for requested in ("openclaw", None, 7, ["hermes"], {"hermes": 1}):
with self.subTest(requested=requested):
self.assertIsNone(agents.pick(requested, PLATFORMS))
def test_the_relay_passes_the_paths_the_agents_use(self):
for path in ("/v1/chat/completions", "/v1/models"):
with self.subTest(path=path):
self.assertEqual(agents.pick(path, agents.RELAY_PATHS), path)
def test_the_relay_refuses_every_other_gateway_route(self):
for path in (
"/key/generate",
"/v1/../key/generate",
"/v1/chat/completions?x=1",
"/v1/embeddings",
"/api/tags",
"",
):
with self.subTest(path=path):
self.assertIsNone(agents.pick(path, agents.RELAY_PATHS))
class TestAgentCalls(unittest.TestCase):
def test_an_agent_that_never_answers_names_its_last_error(self):
broker = make_agents()
refused = urllib.error.URLError("connection refused")
with (
mock.patch.object(agents.urllib.request, "urlopen", side_effect=refused),
mock.patch.object(agents.time, "monotonic", side_effect=[0, 0, 100]),
mock.patch.object(agents.time, "sleep"),
self.assertRaises(TimeoutError) as caught,
):
broker._wait_healthy("hermes", "agent-hermes-1", "k")
self.assertIn("connection refused", str(caught.exception))
def test_an_unlisted_agent_model_falls_back_and_is_logged(self):
events = []
broker = make_agents(log=lambda event, **fields: events.append((event, fields)))
refused = urllib.error.URLError("connection refused")
with mock.patch.object(agents.urllib.request, "urlopen", side_effect=refused):
model = broker.agent_model("hermes", "http://agent-hermes-1:8642", "k")
self.assertEqual(model, "hermes-agent")
self.assertEqual(len(events), 1)
event, fields = events[0]
self.assertEqual(event, "agent_model_fallback")
self.assertEqual(fields["platform"], "hermes")
self.assertEqual(fields["model"], "hermes-agent")
self.assertIn("connection refused", fields["error"])
class TestNaming(unittest.TestCase):
def test_one_owner_one_name_two_owners_two_names(self):
self.assertEqual(