Improvements for DevUI (#5840)

This commit is contained in:
Evan Mattson
2026-05-15 00:05:27 +09:00
committed by GitHub
Unverified
parent ae666a4887
commit 0e12640c70
7 changed files with 244 additions and 45 deletions
+6
View File
@@ -34,6 +34,12 @@ devui ./agents
devui --entities my_agent.py
```
## Security Posture
DevUI is a development-only sample app, not a production hosting surface. Authentication is enabled by default.
Unauthenticated mode is allowed only on `localhost` / `127.0.0.1`; `0.0.0.0`, LAN IPs, and hostnames require
`DEVUI_AUTH_TOKEN` or `--auth-token`.
## Import Path
```python
+30 -13
View File
@@ -47,6 +47,9 @@ devui ./agents --port 8080
# → API: http://localhost:8080/v1/*
```
DevUI is auth-enabled by default. Localhost starts with a generated development token logged at startup; pass it as
`Authorization: Bearer <token>` for direct API calls.
When DevUI starts with no discovered entities, it displays a **sample entity gallery** with curated examples from the Agent Framework repository. You can download these samples, review them, and run them locally to get started quickly.
## Using MCP Tools
@@ -137,12 +140,14 @@ For convenience, DevUI provides an OpenAI Responses backend API. This means you
```bash
# Simple - use your entity name as the entity_id in metadata
curl -X POST http://localhost:8080/v1/responses \
-H "Authorization: Bearer <devui-token>" \
-H "Content-Type: application/json" \
-d @- << 'EOF'
{
"metadata": {"entity_id": "weather_agent"},
"input": "Hello world"
}
EOF
```
Or use the OpenAI Python SDK:
@@ -152,7 +157,7 @@ from openai import OpenAI
client = OpenAI(
base_url="http://localhost:8080/v1",
api_key="not-needed" # API key not required for local DevUI
api_key="<devui-token>"
)
response = client.responses.create(
@@ -201,6 +206,7 @@ DevUI provides an **OpenAI Proxy** feature for testing OpenAI models directly th
```bash
curl -X POST http://localhost:8080/v1/responses \
-H "Authorization: Bearer <devui-token>" \
-H "X-Proxy-Backend: openai" \
-d '{"model": "gpt-4.1-mini", "input": "Hello"}'
```
@@ -214,14 +220,14 @@ devui [directory] [options]
Options:
--port, -p Port (default: 8080)
--host Host (default: 127.0.0.1)
--host Host (default: 127.0.0.1; non-loopback hosts require auth)
--headless API only, no UI
--no-open Don't automatically open browser
--instrumentation Enable OpenTelemetry instrumentation
--reload Enable auto-reload
--mode developer|user (default: developer)
--auth Enable Bearer token authentication
--auth-token Custom authentication token
--no-auth Disable auth for loopback-only local development
--auth-token Custom authentication token (required for non-loopback hosts unless DEVUI_AUTH_TOKEN is set)
```
### UI Modes
@@ -233,8 +239,8 @@ Options:
# Development
devui ./agents
# Production (user-facing)
devui ./agents --mode user --auth
# Local-only no-auth development
devui ./agents --no-auth
```
## Key Endpoints
@@ -336,28 +342,39 @@ These custom extensions are clearly namespaced and can be safely ignored by stan
## Security
DevUI is designed as a **sample application for local development** and should not be exposed to untrusted networks without proper authentication.
DevUI is designed as a **sample application for local development** and is not intended for production use. For
production, or for features beyond this sample app, build a custom interface and API server using the Agent Framework SDK.
**For production deployments:**
Auth is enabled by default. Unauthenticated mode is allowed only when DevUI is bound to `localhost` or `127.0.0.1`.
Network-reachable binds such as `0.0.0.0`, LAN IPs, and hostnames require Bearer token authentication with an explicit
token.
**For shared development hosts:**
```bash
# User mode with authentication (recommended)
devui ./agents --mode user --auth --host 0.0.0.0
# Set a token explicitly before binding beyond loopback
DEVUI_AUTH_TOKEN="<secure-dev-token>" devui ./agents --mode user --host 0.0.0.0
# Or pass the token on the command line
devui ./agents --mode user --host 0.0.0.0 --auth-token "<secure-dev-token>"
```
This restricts developer APIs (reload, deployment, entity details) and requires Bearer token authentication.
Do not use `--no-auth` with `0.0.0.0`, LAN IPs, or hostnames. That configuration fails closed before startup.
**Security features:**
- User mode restricts developer-facing APIs
- Optional Bearer token authentication via `--auth`
- Bearer token authentication is enabled by default
- Unauthenticated mode is loopback-only (`localhost` / `127.0.0.1`)
- Non-loopback binds require `DEVUI_AUTH_TOKEN` or `--auth-token`
- Only loads entities from local directories or in-memory registration
- No remote code execution capabilities
- Binds to localhost (127.0.0.1) by default
**Best practices:**
- Use `--mode user --auth` for any deployment exposed to end users
- Do not use DevUI as a production deployment surface
- Use `--mode user` plus `DEVUI_AUTH_TOKEN` or `--auth-token` for shared development hosts
- Review all agent/workflow code before running
- Only load entities from trusted sources
- Use `.env` files for sensitive credentials (never commit them)
@@ -126,29 +126,6 @@ def serve(
if not isinstance(port, int) or not (1 <= port <= 65535):
raise ValueError(f"Invalid port: {port}. Must be integer between 1 and 65535")
# Security check: warn loudly when network-exposed without authentication.
if host not in ("127.0.0.1", "localhost") and not auth_enabled:
logger.warning("WARNING: Exposing DevUI to the network with --no-auth.")
logger.warning("Anyone on your network can read agent metadata and trigger requests.")
logger.warning("Drop --no-auth and DevUI will require Bearer tokens.")
# Refuse to auto-generate a token for network-exposed binds. Auto-generated tokens
# are fine for localhost convenience; for anything else, require an explicit token.
if auth_enabled and not auth_token:
import os
env_token = os.environ.get("DEVUI_AUTH_TOKEN")
if not env_token:
is_production = (
host not in ("127.0.0.1", "localhost")
or os.environ.get("CI") == "true"
or os.environ.get("KUBERNETES_SERVICE_HOST")
)
if is_production:
logger.error("Authentication required but no token provided.")
logger.error("Set DEVUI_AUTH_TOKEN env var or pass auth_token='...' to serve().")
raise ValueError("DEVUI_AUTH_TOKEN required when host is not localhost")
# Enable instrumentation if requested
if instrumentation_enabled:
from agent_framework.observability import enable_instrumentation
@@ -81,13 +81,15 @@ Examples:
parser.add_argument(
"--no-auth",
action="store_true",
help="Disable Bearer token authentication. DevUI is auth-enabled by default; use this to opt out.",
help=(
"Disable Bearer token authentication for loopback-only local development. Non-loopback hosts require auth."
),
)
parser.add_argument(
"--auth-token",
type=str,
help="Custom Bearer token. Auto-generated and logged at startup when omitted.",
help="Custom Bearer token. Required for non-loopback hosts when DEVUI_AUTH_TOKEN is not set.",
)
parser.add_argument("--version", action="version", version=f"Agent Framework DevUI {get_version()}")
@@ -89,7 +89,7 @@ class DevServer:
mode: Server mode - 'developer' (full access, verbose errors) or 'user' (restricted APIs, generic errors)
auth_enabled: Whether to require Bearer token auth on /v1/* endpoints. Defaults to True.
auth_token: Bearer token. If None and auth_enabled, falls back to the DEVUI_AUTH_TOKEN
environment variable, then to an auto-generated token (logged at startup).
environment variable. Loopback binds may use an auto-generated token logged at startup.
"""
self.entities_dir = entities_dir
self.port = port
@@ -106,7 +106,7 @@ class DevServer:
self.ui_enabled = ui_enabled
self.mode = mode
self.auth_enabled = auth_enabled
self.auth_token = self._resolve_auth_token(auth_enabled, auth_token)
self.auth_token = self._resolve_auth_token(host, auth_enabled, auth_token)
self.executor: AgentFrameworkExecutor | None = None
self.openai_executor: OpenAIExecutor | None = None
self.deployment_manager = DeploymentManager()
@@ -118,8 +118,14 @@ class DevServer:
"""Set in-memory entities to register on startup."""
self._pending_entities = entities
_AUTH_LOOPBACK_HOSTS = frozenset({"127.0.0.1", "localhost"})
_LOOPBACK_HOSTS = frozenset({"127.0.0.1", "localhost", "[::1]", "::1"})
@classmethod
def _is_auth_loopback_host(cls, host: str) -> bool:
"""Return True when unauthenticated DevUI may be limited to local loopback."""
return host.lower() in cls._AUTH_LOOPBACK_HOSTS
def _loopback_allowed_hosts(self) -> frozenset[str] | None:
"""Return the Host-header allowlist when bound to a loopback interface, else None.
@@ -131,16 +137,25 @@ class DevServer:
return None
return self._LOOPBACK_HOSTS
@staticmethod
def _resolve_auth_token(auth_enabled: bool, auth_token: str | None) -> str | None:
@classmethod
def _resolve_auth_token(cls, host: str, auth_enabled: bool, auth_token: str | None) -> str | None:
"""Resolve the active Bearer token. Returns None when auth is disabled."""
is_loopback = cls._is_auth_loopback_host(host)
if not auth_enabled:
if not is_loopback:
raise ValueError(
"DevUI authentication cannot be disabled for non-loopback hosts. "
"Bind to 127.0.0.1/localhost for no-auth local development, or enable auth and provide "
"DEVUI_AUTH_TOKEN or auth_token for network-reachable binds."
)
return None
if auth_token:
return auth_token
env_token = os.getenv("DEVUI_AUTH_TOKEN")
if env_token:
return env_token
if not is_loopback:
raise ValueError("DEVUI_AUTH_TOKEN or auth_token is required when DevUI is bound to a non-loopback host.")
generated = secrets.token_urlsafe(32)
logger.info("=" * 70)
logger.info("DevUI authentication enabled with auto-generated token:")
+7
View File
@@ -60,6 +60,9 @@ devui
This launches the UI with all example agents/workflows at http://localhost:8080
DevUI is auth-enabled by default. Copy the generated token from startup logs and pass it as
`Authorization: Bearer <token>` for direct API calls. Use `--no-auth` only for loopback-only local testing.
## 5. What You'll See
- A web interface for testing agents interactively
@@ -74,6 +77,7 @@ You can also test via API calls:
```bash
curl -X POST http://localhost:8080/v1/responses \
-H "Authorization: Bearer <devui-token>" \
-H "Content-Type: application/json" \
-d '{
"model": "weather_agent",
@@ -86,6 +90,7 @@ curl -X POST http://localhost:8080/v1/responses \
```bash
# Create a conversation
curl -X POST http://localhost:8080/v1/conversations \
-H "Authorization: Bearer <devui-token>" \
-H "Content-Type: application/json" \
-d '{"metadata": {"agent_id": "weather_agent"}}'
@@ -93,6 +98,7 @@ curl -X POST http://localhost:8080/v1/conversations \
# Use conversation ID in requests
curl -X POST http://localhost:8080/v1/responses \
-H "Authorization: Bearer <devui-token>" \
-H "Content-Type: application/json" \
-d '{
"model": "weather_agent",
@@ -102,6 +108,7 @@ curl -X POST http://localhost:8080/v1/responses \
# Continue the conversation
curl -X POST http://localhost:8080/v1/responses \
-H "Authorization: Bearer <devui-token>" \
-H "Content-Type: application/json" \
-d '{
"model": "weather_agent",
@@ -4,8 +4,10 @@
import asyncio
import inspect
import sys
import tempfile
from pathlib import Path
from typing import Any
import pytest
from conftest import MockAgent
@@ -492,7 +494,7 @@ def test_devserver_requires_auth_by_default(monkeypatch):
def test_devserver_auth_can_be_explicitly_disabled(monkeypatch):
"""Callers can opt out of auth with auth_enabled=False (escape hatch for tests / trusted hosts)."""
"""Callers can opt out of auth on loopback (escape hatch for tests / trusted local hosts)."""
monkeypatch.delenv("DEVUI_AUTH_TOKEN", raising=False)
server = _server_with_mock_agent(auth_enabled=False)
@@ -504,6 +506,106 @@ def test_devserver_auth_can_be_explicitly_disabled(monkeypatch):
assert response.status_code == 200
def test_devserver_rejects_non_loopback_no_auth(monkeypatch):
"""Non-loopback binds must not be network-reachable without authentication."""
monkeypatch.delenv("DEVUI_AUTH_TOKEN", raising=False)
with pytest.raises(ValueError, match="authentication cannot be disabled"):
DevServer(host="0.0.0.0", auth_enabled=False)
with pytest.raises(ValueError, match="authentication cannot be disabled"):
DevServer(host="devui.example", auth_enabled=False)
def test_devserver_rejects_non_loopback_without_explicit_token(monkeypatch):
"""Network-reachable auth requires an operator-provided token, not a generated token."""
monkeypatch.delenv("DEVUI_AUTH_TOKEN", raising=False)
with pytest.raises(ValueError, match="DEVUI_AUTH_TOKEN or auth_token"):
DevServer(host="0.0.0.0")
def test_devserver_allows_non_loopback_with_explicit_token(monkeypatch):
"""A network-reachable bind is allowed when auth has an explicit token."""
monkeypatch.delenv("DEVUI_AUTH_TOKEN", raising=False)
server = DevServer(host="0.0.0.0", auth_token="s3cret")
assert server.auth_enabled is True
assert server.auth_token == "s3cret"
def test_devserver_allows_non_loopback_with_env_token(monkeypatch):
"""A network-reachable bind is allowed when auth uses DEVUI_AUTH_TOKEN."""
monkeypatch.setenv("DEVUI_AUTH_TOKEN", "env-s3cret")
server = DevServer(host="0.0.0.0")
assert server.auth_enabled is True
assert server.auth_token == "env-s3cret"
def test_devserver_allows_loopback_no_auth(monkeypatch):
"""Unauthenticated DevUI remains available for local-only development and tests."""
monkeypatch.delenv("DEVUI_AUTH_TOKEN", raising=False)
for host in ("127.0.0.1", "localhost"):
server = DevServer(host=host, auth_enabled=False)
assert server.auth_enabled is False
assert server.auth_token is None
def test_devserver_loopback_auth_auto_generates_token(monkeypatch):
"""Loopback auth-enabled usage may still use a generated development token."""
monkeypatch.delenv("DEVUI_AUTH_TOKEN", raising=False)
server = DevServer(host="127.0.0.1")
assert server.auth_enabled is True
assert server.auth_token
def test_serve_rejects_non_loopback_no_auth(monkeypatch):
"""The public serve() helper must inherit the DevServer network-auth invariant."""
monkeypatch.delenv("DEVUI_AUTH_TOKEN", raising=False)
with pytest.raises(ValueError, match="authentication cannot be disabled"):
agent_framework_devui.serve(entities=[], host="0.0.0.0", auth_enabled=False, ui_enabled=False)
def test_serve_rejects_non_loopback_without_explicit_token(monkeypatch):
"""serve() must not maintain a weaker generated-token path for network binds."""
monkeypatch.delenv("DEVUI_AUTH_TOKEN", raising=False)
with pytest.raises(ValueError, match="DEVUI_AUTH_TOKEN or auth_token"):
agent_framework_devui.serve(entities=[], host="0.0.0.0", ui_enabled=False)
def test_serve_allows_non_loopback_with_explicit_token(monkeypatch):
"""serve() accepts a network bind when an explicit token is provided."""
import uvicorn
monkeypatch.delenv("DEVUI_AUTH_TOKEN", raising=False)
run_args = {}
def fake_run(_app, *, host, port, **_kwargs):
run_args["host"] = host
run_args["port"] = port
monkeypatch.setattr(uvicorn, "run", fake_run)
agent_framework_devui.serve(
entities=[],
host="0.0.0.0",
port=9090,
auth_token="s3cret",
auto_open=False,
ui_enabled=False,
)
assert run_args == {"host": "0.0.0.0", "port": 9090}
def test_devserver_accepts_request_with_valid_bearer_token(monkeypatch):
"""When auth is on, supplying the configured Bearer token grants access."""
monkeypatch.delenv("DEVUI_AUTH_TOKEN", raising=False)
@@ -567,8 +669,8 @@ def test_serve_defaults_to_auth_enabled():
)
def test_cli_enables_auth_by_default_and_supports_no_auth_optout():
"""`devui ./agents` must produce auth-enabled config; `--no-auth` is the explicit escape hatch."""
def test_cli_enables_auth_by_default_and_supports_loopback_no_auth_optout():
"""`devui ./agents` must produce auth-enabled config; `--no-auth` is the loopback-only escape hatch."""
from agent_framework_devui._cli import create_cli_parser
parser = create_cli_parser()
@@ -578,3 +680,76 @@ def test_cli_enables_auth_by_default_and_supports_no_auth_optout():
optout_args = parser.parse_args(["--no-auth"])
assert optout_args.no_auth is True
help_text = parser.format_help()
assert "loopback-only" in help_text
assert "Non-loopback hosts require auth" in help_text
def _run_cli_with_fake_uvicorn(monkeypatch, tmp_path: Path, *args: str) -> dict[str, Any]:
"""Run the DevUI CLI without binding a socket."""
import uvicorn
from agent_framework_devui import _cli
run_args: dict[str, Any] = {}
def fake_run(_app, *, host, port, **_kwargs):
run_args["host"] = host
run_args["port"] = port
monkeypatch.setattr(uvicorn, "run", fake_run)
monkeypatch.setattr(sys, "argv", ["devui", str(tmp_path), "--no-open", "--headless", *args])
_cli.main()
return run_args
def test_cli_allows_loopback_no_auth_without_binding_socket(monkeypatch, tmp_path):
"""`devui --no-auth` remains valid on the default loopback host."""
monkeypatch.delenv("DEVUI_AUTH_TOKEN", raising=False)
run_args = _run_cli_with_fake_uvicorn(monkeypatch, tmp_path, "--no-auth")
assert run_args == {"host": "127.0.0.1", "port": 8080}
def test_cli_rejects_non_loopback_no_auth_before_binding_socket(monkeypatch, tmp_path, capsys):
"""`devui --host 0.0.0.0 --no-auth` must fail through shared server validation."""
monkeypatch.delenv("DEVUI_AUTH_TOKEN", raising=False)
with pytest.raises(SystemExit) as exc_info:
_run_cli_with_fake_uvicorn(monkeypatch, tmp_path, "--host", "0.0.0.0", "--no-auth")
assert exc_info.value.code == 1
assert "authentication cannot be disabled" in capsys.readouterr().err
def test_cli_rejects_non_loopback_without_explicit_token_before_binding_socket(monkeypatch, tmp_path, capsys):
"""`devui --host 0.0.0.0` must fail when neither --auth-token nor DEVUI_AUTH_TOKEN is set."""
monkeypatch.delenv("DEVUI_AUTH_TOKEN", raising=False)
with pytest.raises(SystemExit) as exc_info:
_run_cli_with_fake_uvicorn(monkeypatch, tmp_path, "--host", "0.0.0.0")
assert exc_info.value.code == 1
assert "DEVUI_AUTH_TOKEN or auth_token" in capsys.readouterr().err
def test_cli_allows_non_loopback_with_auth_token_without_binding_socket(monkeypatch, tmp_path):
"""`devui --host 0.0.0.0 --auth-token ...` starts with token auth enabled."""
monkeypatch.delenv("DEVUI_AUTH_TOKEN", raising=False)
run_args = _run_cli_with_fake_uvicorn(monkeypatch, tmp_path, "--host", "0.0.0.0", "--auth-token", "s3cret")
assert run_args == {"host": "0.0.0.0", "port": 8080}
def test_cli_allows_non_loopback_with_env_token_without_binding_socket(monkeypatch, tmp_path):
"""`DEVUI_AUTH_TOKEN=... devui --host 0.0.0.0` starts with token auth enabled."""
monkeypatch.setenv("DEVUI_AUTH_TOKEN", "env-s3cret")
run_args = _run_cli_with_fake_uvicorn(monkeypatch, tmp_path, "--host", "0.0.0.0")
assert run_args == {"host": "0.0.0.0", "port": 8080}