Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
1 change: 1 addition & 0 deletions .github/workflows/verify.yml
Original file line number Diff line number Diff line change
Expand Up @@ -46,6 +46,7 @@ jobs:
}] and
.services.openwebui.environment.WEBUI_AUTH == "True" and
.services.openwebui.environment.ENABLE_SIGNUP == "False" and
.services.openwebui.image == "ghcr.io/open-webui/open-webui:v0.11.0@sha256:72c0ba641ba75e7aa52655cb242570906ececd09b1140fb736483038a22b3228" and
any(
.services.openwebui.volumes[];
.target == "/run/secrets/openwebui-mcp-bearer" and
Expand Down
17 changes: 16 additions & 1 deletion Makefile
Original file line number Diff line number Diff line change
Expand Up @@ -24,11 +24,12 @@ COMPOSE := docker-compose
COMPOSE_PROD := docker-compose -f docker-compose.yml -f docker-compose.prod.yml
PROFILES := --profile openwebui

.PHONY: help start stop restart kill-ports up down logs ps build rebuild test pytest smoke ansible-e2e ansible-gh2mcp ansible-github-qa ansible-github-test ansible-tools-e2e gh2mcp-status reload-gateway reload-skills clean prod-up prod-down setup-github install-env2mcp generate-demo-repos generate-demo-repos-github
.PHONY: help start stop restart kill-ports up subactor-up subactor-configure down logs ps build rebuild test pytest smoke ansible-e2e ansible-gh2mcp ansible-github-qa ansible-github-test ansible-tools-e2e gh2mcp-status reload-gateway reload-skills clean prod-up prod-down setup-github install-env2mcp generate-demo-repos generate-demo-repos-github

help:
@echo "MCP Skills - Makefile targets"
@echo " make start - kill host ports, build and start full stack (with OpenWebUI)"
@echo " make subactor-up - start OpenWebUI and preconfigure the scoped Subactor MCP"
@echo " make stop - stop all containers"
@echo " make restart - stop + start"
@echo " make kill-ports - free host ports: $(PORTS)"
Expand Down Expand Up @@ -103,6 +104,20 @@ restart: stop start
up:
$(COMPOSE) $(PROFILES) up -d

subactor-up: up
@for attempt in {1..60}; do \
if $(COMPOSE) $(PROFILES) exec -T openwebui python -c \
"import urllib.request; urllib.request.urlopen('http://127.0.0.1:8080/health', timeout=3)" >/dev/null 2>&1; then \
break; \
fi; \
if [ "$$attempt" = 60 ]; then echo "OpenWebUI did not become healthy" >&2; exit 1; fi; \
sleep 2; \
done
@$(MAKE) subactor-configure

subactor-configure:
@./scripts/configure-openwebui-subactor.sh

down: stop

logs:
Expand Down
18 changes: 16 additions & 2 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -323,6 +323,9 @@ Oficjalny frontend (docker image) podłączony do MCP Gateway:

Domyślnie OpenWebUI wymaga logowania, nie pozwala na samodzielną rejestrację i
nasłuchuje tylko na `127.0.0.1`. Gateway również jest związany z loopbackiem.
Obraz OpenWebUI jest domyślnie przypięty do wieloarchitekturowego digestu
wydania `v0.11.0`; kontrolowane podniesienie wersji jest możliwe przez
`OPENWEBUI_IMAGE`.
Przed uruchomieniem profilu utwórz trzy pliki o prawach `0600`:

```bash
Expand All @@ -340,13 +343,24 @@ Pliki można trzymać poza repozytorium, ustawiając
ale publiczny bind powinien być wystawiany wyłącznie przez uwierzytelnione TLS
proxy; surowy MCP i Control nie powinny być publikowane do Internetu.

Po uruchomieniu można idempotentnie predefiniować zawężone połączenie Subactor:
Gotowy profil Subactor uruchamia stos, czeka na health OpenWebUI i idempotentnie
zapisuje zawężone połączenie MCP:

```bash
make subactor-up
```

Konfigurator sam wykrywa nie-loopbackowy listener Control na porcie `8088`,
sprawdza `/healthz`, uwierzytelnia `tools/list` dedykowanym sekretem MCP i
odmawia zapisu, jeżeli serwer wystawia coś innego niż `cli.status`, `cli.plan`
i `cli.execute`. Adres można jawnie nadpisać, jeśli autodetekcja nie jest
dostępna:

```bash
SUBACTOR_ACCOUNT_ID=softreck \
SUBACTOR_PROVIDER=chatgpt \
SUBACTOR_TOOL_ID=codex \
SUBACTOR_CONTROL_URL=http://172.17.0.1:8088 \
SUBACTOR_CONTROL_URL=http://10.240.0.1:8088 \
./scripts/configure-openwebui-subactor.sh
```

Expand Down
2 changes: 1 addition & 1 deletion docker-compose.yml
Original file line number Diff line number Diff line change
Expand Up @@ -256,7 +256,7 @@ services:

# OpenWebUI - frontend dla użytkowników końcowych (OpenAI-compat klient)
openwebui:
image: ${OPENWEBUI_IMAGE:-ghcr.io/open-webui/open-webui:main}
image: ${OPENWEBUI_IMAGE:-ghcr.io/open-webui/open-webui:v0.11.0@sha256:72c0ba641ba75e7aa52655cb242570906ececd09b1140fb736483038a22b3228}
container_name: openwebui
environment:
- OPENAI_API_BASE_URL=http://mcp-gateway:9000/v1
Expand Down
79 changes: 77 additions & 2 deletions scripts/configure-openwebui-subactor.sh
Original file line number Diff line number Diff line change
Expand Up @@ -4,14 +4,46 @@ set -euo pipefail
account_id="${SUBACTOR_ACCOUNT_ID:-softreck}"
provider="${SUBACTOR_PROVIDER:-chatgpt}"
tool_id="${SUBACTOR_TOOL_ID:-codex}"
control_url="${SUBACTOR_CONTROL_URL:-http://172.17.0.1:8088}"
control_port="${SUBACTOR_CONTROL_PORT:-8088}"
connection_id="${SUBACTOR_OPENWEBUI_CONNECTION_ID:-subactor-${account_id}-${provider}-${tool_id}}"

discover_control_url() {
command -v ss >/dev/null 2>&1 || return 1

local listener host
while IFS= read -r listener; do
host="${listener%:${control_port}}"
host="${host#[}"
host="${host%]}"
case "$host" in
""|"*"|"0.0.0.0"|"::"|"127."*|"::1") continue ;;
esac
printf 'http://%s:%s\n' "$host" "$control_port"
return 0
done < <(ss -H -ltn "sport = :${control_port}" | awk '{print $4}')

return 1
}

control_url="${SUBACTOR_CONTROL_URL:-}"
control_discovered=false
if [[ -z "$control_url" ]]; then
control_url="$(discover_control_url || true)"
control_discovered=true
fi
if [[ -z "$control_url" ]]; then
printf '%s\n' \
"Unable to discover a non-loopback Subactor Control listener on port ${control_port}." \
"Set SUBACTOR_CONTROL_URL to an address reachable from the OpenWebUI container." >&2
exit 1
fi

docker compose exec -T \
-e SUBACTOR_ACCOUNT_ID="$account_id" \
-e SUBACTOR_PROVIDER="$provider" \
-e SUBACTOR_TOOL_ID="$tool_id" \
-e SUBACTOR_CONTROL_URL="$control_url" \
-e SUBACTOR_CONTROL_DISCOVERED="$control_discovered" \
-e SUBACTOR_OPENWEBUI_CONNECTION_ID="$connection_id" \
openwebui sh -lc '
export WEBUI_SECRET_KEY="$(tr -d "\r\n" < /run/secrets/openwebui-session)"
Expand All @@ -23,6 +55,7 @@ import os
import re
from pathlib import Path
from urllib.parse import urlsplit
from urllib.request import Request, urlopen

from open_webui.models.config import Config

Expand Down Expand Up @@ -54,9 +87,49 @@ async def configure() -> None:
if len(token) < 32:
raise SystemExit("OpenWebUI MCP bearer secret is missing or too short")

health_url = f"{control_url}/healthz"
try:
with urlopen(Request(health_url, method="GET"), timeout=5) as response:
health = json.load(response)
if response.status != 200 or health.get("status") != "ok":
raise RuntimeError("unexpected health response")
except Exception as exc:
raise SystemExit(
f"Subactor Control is not reachable from OpenWebUI at {health_url}: {exc}"
) from exc

url = (
f"{control_url}/mcp/accounts/{account_id}/providers/{provider}/tools/{tool_id}"
)
discovery_request = Request(
url,
data=json.dumps(
{"jsonrpc": "2.0", "id": 1, "method": "tools/list"}
).encode("utf-8"),
headers={
"Authorization": f"Bearer {token}",
"Content-Type": "application/json",
},
method="POST",
)
try:
with urlopen(discovery_request, timeout=5) as response:
discovery = json.load(response)
except Exception as exc:
raise SystemExit(
f"Subactor MCP preflight failed at {url}: {exc}"
) from exc
tool_names = sorted(
tool.get("name")
for tool in discovery.get("result", {}).get("tools", [])
if isinstance(tool, dict) and isinstance(tool.get("name"), str)
)
expected_tools = ["cli.execute", "cli.plan", "cli.status"]
if tool_names != expected_tools:
raise SystemExit(
f"Subactor MCP exposed an unexpected tool boundary: {tool_names}"
)

connection = {
"auth_type": "bearer",
"config": {
Expand Down Expand Up @@ -98,8 +171,10 @@ async def configure() -> None:
{
"configured": True,
"connection_id": connection_id,
"control_discovered": os.environ["SUBACTOR_CONTROL_DISCOVERED"]
== "true",
"url": url,
"tools": ["cli.status", "cli.plan", "cli.execute"],
"tools": tool_names,
"token_logged": False,
},
sort_keys=True,
Expand Down
98 changes: 98 additions & 0 deletions tests/test_openwebui_configurator.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,98 @@
from __future__ import annotations

import os
import subprocess
from pathlib import Path


ROOT = Path(__file__).resolve().parents[1]
SCRIPT = ROOT / "scripts" / "configure-openwebui-subactor.sh"


def executable(path: Path, content: str) -> None:
path.write_text(content, encoding="utf-8")
path.chmod(0o755)


def mock_environment(tmp_path: Path, listener: str) -> tuple[dict[str, str], Path]:
bin_dir = tmp_path / "bin"
bin_dir.mkdir()
capture = tmp_path / "docker-arguments.txt"
executable(
bin_dir / "ss",
"#!/usr/bin/env bash\n" f"printf '%s\\n' '{listener}'\n",
)
executable(
bin_dir / "docker",
"""#!/usr/bin/env bash
printf '%s\n' "$@" > "$SUBACTOR_TEST_DOCKER_CAPTURE"
cat >/dev/null
printf '%s\n' '{"configured":true}'
""",
)
env = os.environ.copy()
env.update(
{
"PATH": f"{bin_dir}:{env['PATH']}",
"SUBACTOR_TEST_DOCKER_CAPTURE": str(capture),
}
)
env.pop("SUBACTOR_CONTROL_URL", None)
return env, capture


def test_discovers_non_loopback_control_listener(tmp_path: Path) -> None:
env, capture = mock_environment(
tmp_path,
"LISTEN 0 2048 10.240.0.1:8088 0.0.0.0:*",
)

result = subprocess.run(
[str(SCRIPT)],
cwd=ROOT,
env=env,
check=False,
capture_output=True,
text=True,
)

assert result.returncode == 0, result.stderr
arguments = capture.read_text(encoding="utf-8").splitlines()
assert "SUBACTOR_CONTROL_URL=http://10.240.0.1:8088" in arguments
assert "SUBACTOR_CONTROL_DISCOVERED=true" in arguments


def test_refuses_to_persist_an_undiscovered_endpoint(tmp_path: Path) -> None:
env, capture = mock_environment(tmp_path, "")

result = subprocess.run(
[str(SCRIPT)],
cwd=ROOT,
env=env,
check=False,
capture_output=True,
text=True,
)

assert result.returncode == 1
assert "Unable to discover" in result.stderr
assert not capture.exists()


def test_explicit_control_url_skips_discovery(tmp_path: Path) -> None:
env, capture = mock_environment(tmp_path, "")
env["SUBACTOR_CONTROL_URL"] = "https://control.subactor.internal"

result = subprocess.run(
[str(SCRIPT)],
cwd=ROOT,
env=env,
check=False,
capture_output=True,
text=True,
)

assert result.returncode == 0, result.stderr
arguments = capture.read_text(encoding="utf-8").splitlines()
assert "SUBACTOR_CONTROL_URL=https://control.subactor.internal" in arguments
assert "SUBACTOR_CONTROL_DISCOVERED=false" in arguments