mirror of
https://github.com/kevinveenbirkenbach/computer-playbook.git
synced 2026-11-10 12:47:04 +00:00
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:
1 parent
dcfb220dca
commit
eeb9b22ae4
4 files changed
+102
-15
No files matched your search
@@ -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(
|
||||
|
||||
Reference in new issue
Block a user