From 7c988ae3d09781c3141af93efc9b95c0c5c5de96 Mon Sep 17 00:00:00 2001
From: Jack Kingsman
Date: Mon, 23 Mar 2026 15:16:04 -0700
Subject: [PATCH 1/5] Initial bot safety warning pass
---
app/fanout/manager.py | 50 +++++-
app/routers/fanout.py | 45 +++--
app/routers/health.py | 17 +-
frontend/src/api.ts | 8 +
frontend/src/components/AppShell.tsx | 2 +
.../src/components/SecurityWarningModal.tsx | 165 ++++++++++++++++++
.../settings/SettingsFanoutSection.tsx | 5 +-
frontend/src/components/ui/dialog.tsx | 20 ++-
frontend/src/test/fanoutSection.test.tsx | 9 +
.../src/test/securityWarningModal.test.tsx | 98 +++++++++++
frontend/src/types.ts | 2 +
tests/e2e/helpers/api.ts | 3 +
tests/test_disable_bots.py | 103 ++++++++++-
tests/test_fanout_hitlist.py | 8 +-
14 files changed, 499 insertions(+), 36 deletions(-)
create mode 100644 frontend/src/components/SecurityWarningModal.tsx
create mode 100644 frontend/src/test/securityWarningModal.test.tsx
diff --git a/app/fanout/manager.py b/app/fanout/manager.py
index b393a89..d58abbf 100644
--- a/app/fanout/manager.py
+++ b/app/fanout/manager.py
@@ -82,6 +82,21 @@ class FanoutManager:
def __init__(self) -> None:
self._modules: dict[str, tuple[FanoutModule, dict]] = {} # id -> (module, scope)
self._restart_locks: dict[str, asyncio.Lock] = {}
+ self._bots_disabled_until_restart = False
+
+ def get_bots_disabled_source(self) -> str | None:
+ """Return why bot modules are unavailable, if at all."""
+ from app.config import settings as server_settings
+
+ if server_settings.disable_bots:
+ return "env"
+ if self._bots_disabled_until_restart:
+ return "until_restart"
+ return None
+
+ def bots_disabled_effective(self) -> bool:
+ """Return True when bot modules should be treated as unavailable."""
+ return self.get_bots_disabled_source() is not None
async def load_from_db(self) -> None:
"""Read enabled fanout_configs and instantiate modules."""
@@ -99,13 +114,14 @@ class FanoutManager:
config_blob = cfg["config"]
scope = cfg["scope"]
- # Skip bot modules when bots are disabled server-wide
- if config_type == "bot":
- from app.config import settings as server_settings
-
- if server_settings.disable_bots:
- logger.info("Skipping bot module %s (bots disabled by server config)", config_id)
- return
+ # Skip bot modules when bots are disabled server-wide or until restart.
+ if config_type == "bot" and self.bots_disabled_effective():
+ logger.info(
+ "Skipping bot module %s (bots disabled: %s)",
+ config_id,
+ self.get_bots_disabled_source(),
+ )
+ return
cls = _MODULE_TYPES.get(config_type)
if cls is None:
@@ -240,6 +256,26 @@ class FanoutManager:
}
return result
+ async def disable_bots_until_restart(self) -> str:
+ """Stop active bot modules and prevent them from starting again until restart."""
+ source = self.get_bots_disabled_source()
+ if source == "env":
+ return source
+
+ self._bots_disabled_until_restart = True
+
+ from app.repository.fanout import _configs_cache
+
+ bot_ids = [
+ config_id
+ for config_id in list(self._modules)
+ if _configs_cache.get(config_id, {}).get("type") == "bot"
+ ]
+ for config_id in bot_ids:
+ await self.remove_config(config_id)
+
+ return "until_restart"
+
# Module-level singleton
fanout_manager = FanoutManager()
diff --git a/app/routers/fanout.py b/app/routers/fanout.py
index 36c7d40..d5c8cb7 100644
--- a/app/routers/fanout.py
+++ b/app/routers/fanout.py
@@ -9,8 +9,8 @@ import string
from fastapi import APIRouter, HTTPException
from pydantic import BaseModel, Field
-from app.config import settings as server_settings
from app.fanout.bot_exec import _analyze_bot_signature
+from app.fanout.manager import fanout_manager
from app.repository.fanout import FanoutConfigRepository
logger = logging.getLogger(__name__)
@@ -325,6 +325,15 @@ def _enforce_scope(config_type: str, scope: dict) -> dict:
return {"messages": messages, "raw_packets": raw_packets}
+def _bot_system_disabled_detail() -> str | None:
+ source = fanout_manager.get_bots_disabled_source()
+ if source == "env":
+ return "Bot system disabled by server configuration (MESHCORE_DISABLE_BOTS)"
+ if source == "until_restart":
+ return "Bot system disabled until the server restarts"
+ return None
+
+
@router.get("")
async def list_fanout_configs() -> list[dict]:
"""List all fanout configs."""
@@ -340,8 +349,10 @@ async def create_fanout_config(body: FanoutConfigCreate) -> dict:
detail=f"Invalid type '{body.type}'. Must be one of: {', '.join(sorted(_VALID_TYPES))}",
)
- if body.type == "bot" and server_settings.disable_bots:
- raise HTTPException(status_code=403, detail="Bot system disabled by server configuration")
+ if body.type == "bot":
+ disabled_detail = _bot_system_disabled_detail()
+ if disabled_detail:
+ raise HTTPException(status_code=403, detail=disabled_detail)
normalized_config = _validate_and_normalize_config(body.type, body.config)
scope = _enforce_scope(body.type, body.scope)
@@ -356,8 +367,6 @@ async def create_fanout_config(body: FanoutConfigCreate) -> dict:
# Start the module if enabled
if cfg["enabled"]:
- from app.fanout.manager import fanout_manager
-
await fanout_manager.reload_config(cfg["id"])
logger.info("Created fanout config %s (type=%s, name=%s)", cfg["id"], body.type, body.name)
@@ -371,8 +380,10 @@ async def update_fanout_config(config_id: str, body: FanoutConfigUpdate) -> dict
if existing is None:
raise HTTPException(status_code=404, detail="Fanout config not found")
- if existing["type"] == "bot" and server_settings.disable_bots:
- raise HTTPException(status_code=403, detail="Bot system disabled by server configuration")
+ if existing["type"] == "bot":
+ disabled_detail = _bot_system_disabled_detail()
+ if disabled_detail:
+ raise HTTPException(status_code=403, detail=disabled_detail)
kwargs = {}
if body.name is not None:
@@ -390,8 +401,6 @@ async def update_fanout_config(config_id: str, body: FanoutConfigUpdate) -> dict
raise HTTPException(status_code=404, detail="Fanout config not found")
# Reload the module to pick up changes
- from app.fanout.manager import fanout_manager
-
await fanout_manager.reload_config(config_id)
logger.info("Updated fanout config %s", config_id)
@@ -406,10 +415,24 @@ async def delete_fanout_config(config_id: str) -> dict:
raise HTTPException(status_code=404, detail="Fanout config not found")
# Stop the module first
- from app.fanout.manager import fanout_manager
-
await fanout_manager.remove_config(config_id)
await FanoutConfigRepository.delete(config_id)
logger.info("Deleted fanout config %s", config_id)
return {"deleted": True}
+
+
+@router.post("/bots/disable-until-restart")
+async def disable_bots_until_restart() -> dict:
+ """Stop active bot modules and prevent them from running again until restart."""
+ source = await fanout_manager.disable_bots_until_restart()
+
+ from app.services.radio_runtime import radio_runtime as radio_manager
+ from app.websocket import broadcast_health
+
+ broadcast_health(radio_manager.is_connected, radio_manager.connection_info)
+ return {
+ "status": "ok",
+ "bots_disabled": True,
+ "bots_disabled_source": source,
+ }
diff --git a/app/routers/health.py b/app/routers/health.py
index 2ceb39e..7b62564 100644
--- a/app/routers/health.py
+++ b/app/routers/health.py
@@ -1,5 +1,5 @@
import os
-from typing import Any
+from typing import Any, Literal
from fastapi import APIRouter
from pydantic import BaseModel
@@ -37,6 +37,8 @@ class HealthResponse(BaseModel):
oldest_undecrypted_timestamp: int | None
fanout_statuses: dict[str, dict[str, str]] = {}
bots_disabled: bool = False
+ bots_disabled_source: Literal["env", "until_restart"] | None = None
+ basic_auth_enabled: bool = False
def _clean_optional_str(value: object) -> str | None:
@@ -46,6 +48,11 @@ def _clean_optional_str(value: object) -> str | None:
return cleaned or None
+def _read_optional_bool_setting(name: str) -> bool:
+ value = getattr(settings, name, False)
+ return value if isinstance(value, bool) else False
+
+
async def build_health_data(radio_connected: bool, connection_info: str | None) -> dict:
"""Build the health status payload used by REST endpoint and WebSocket broadcasts."""
app_build_info = get_app_build_info()
@@ -64,10 +71,14 @@ async def build_health_data(radio_connected: bool, connection_info: str | None)
# Fanout module statuses
fanout_statuses: dict[str, Any] = {}
+ bots_disabled_source = "env" if _read_optional_bool_setting("disable_bots") else None
try:
from app.fanout.manager import fanout_manager
fanout_statuses = fanout_manager.get_statuses()
+ manager_bots_disabled_source = fanout_manager.get_bots_disabled_source()
+ if manager_bots_disabled_source is not None:
+ bots_disabled_source = manager_bots_disabled_source
except Exception:
pass
@@ -118,7 +129,9 @@ async def build_health_data(radio_connected: bool, connection_info: str | None)
"database_size_mb": db_size_mb,
"oldest_undecrypted_timestamp": oldest_ts,
"fanout_statuses": fanout_statuses,
- "bots_disabled": settings.disable_bots,
+ "bots_disabled": bots_disabled_source is not None,
+ "bots_disabled_source": bots_disabled_source,
+ "basic_auth_enabled": _read_optional_bool_setting("basic_auth_enabled"),
}
diff --git a/frontend/src/api.ts b/frontend/src/api.ts
index 0c70c54..ab7644b 100644
--- a/frontend/src/api.ts
+++ b/frontend/src/api.ts
@@ -343,6 +343,14 @@ export const api = {
fetchJson<{ deleted: boolean }>(`/fanout/${id}`, {
method: 'DELETE',
}),
+ disableBotsUntilRestart: () =>
+ fetchJson<{
+ status: string;
+ bots_disabled: boolean;
+ bots_disabled_source: 'env' | 'until_restart';
+ }>('/fanout/bots/disable-until-restart', {
+ method: 'POST',
+ }),
// Statistics
getStatistics: () => fetchJson('/statistics'),
diff --git a/frontend/src/components/AppShell.tsx b/frontend/src/components/AppShell.tsx
index 1da675c..9b50faf 100644
--- a/frontend/src/components/AppShell.tsx
+++ b/frontend/src/components/AppShell.tsx
@@ -6,6 +6,7 @@ import { ConversationPane } from './ConversationPane';
import { NewMessageModal } from './NewMessageModal';
import { ContactInfoPane } from './ContactInfoPane';
import { ChannelInfoPane } from './ChannelInfoPane';
+import { SecurityWarningModal } from './SecurityWarningModal';
import { Toaster } from './ui/sonner';
import { Sheet, SheetContent, SheetDescription, SheetHeader, SheetTitle } from './ui/sheet';
import {
@@ -289,6 +290,7 @@ export function AppShell({
}}
/>
+
diff --git a/frontend/src/components/SecurityWarningModal.tsx b/frontend/src/components/SecurityWarningModal.tsx
new file mode 100644
index 0000000..9a3e9e1
--- /dev/null
+++ b/frontend/src/components/SecurityWarningModal.tsx
@@ -0,0 +1,165 @@
+import { useEffect, useState } from 'react';
+import { AlertTriangle } from 'lucide-react';
+
+import { api } from '../api';
+import type { HealthStatus } from '../types';
+import { Button } from './ui/button';
+import { Checkbox } from './ui/checkbox';
+import { Dialog, DialogContent, DialogDescription, DialogHeader, DialogTitle } from './ui/dialog';
+import { toast } from './ui/sonner';
+
+const STORAGE_KEY = 'meshcore_security_warning_acknowledged';
+
+function readAcknowledgedState(): boolean {
+ if (typeof window === 'undefined') {
+ return false;
+ }
+ try {
+ return window.localStorage.getItem(STORAGE_KEY) === 'true';
+ } catch {
+ return false;
+ }
+}
+
+function writeAcknowledgedState(): void {
+ if (typeof window === 'undefined') {
+ return;
+ }
+ try {
+ window.localStorage.setItem(STORAGE_KEY, 'true');
+ } catch {
+ // Best effort only; the warning will continue to show if localStorage is unavailable.
+ }
+}
+
+interface SecurityWarningModalProps {
+ health: HealthStatus | null;
+}
+
+export function SecurityWarningModal({ health }: SecurityWarningModalProps) {
+ const [acknowledged, setAcknowledged] = useState(readAcknowledgedState);
+ const [confirmedRisk, setConfirmedRisk] = useState(false);
+ const [disablingBots, setDisablingBots] = useState(false);
+ const [botsDisabledLocally, setBotsDisabledLocally] = useState(false);
+
+ const shouldWarn =
+ health !== null &&
+ health.bots_disabled !== true &&
+ health.basic_auth_enabled !== true &&
+ !botsDisabledLocally &&
+ !acknowledged;
+
+ useEffect(() => {
+ if (!shouldWarn) {
+ setConfirmedRisk(false);
+ }
+ }, [shouldWarn]);
+
+ if (!shouldWarn) {
+ return null;
+ }
+
+ return (
+
+ );
+}
diff --git a/frontend/src/components/settings/SettingsFanoutSection.tsx b/frontend/src/components/settings/SettingsFanoutSection.tsx
index 6e0abc1..a68a4a7 100644
--- a/frontend/src/components/settings/SettingsFanoutSection.tsx
+++ b/frontend/src/components/settings/SettingsFanoutSection.tsx
@@ -1817,8 +1817,9 @@ export function SettingsFanoutSection({
{health?.bots_disabled && (
- Bot system is disabled by server configuration (MESHCORE_DISABLE_BOTS). Bot integrations
- cannot be created or modified.
+ {health.bots_disabled_source === 'until_restart'
+ ? 'Bot system is disabled until the server restarts. Bot integrations cannot run, be created, or be modified right now.'
+ : 'Bot system is disabled by server configuration (MESHCORE_DISABLE_BOTS). Bot integrations cannot run, be created, or be modified.'}
)}
diff --git a/frontend/src/components/ui/dialog.tsx b/frontend/src/components/ui/dialog.tsx
index 802b213..575df20 100644
--- a/frontend/src/components/ui/dialog.tsx
+++ b/frontend/src/components/ui/dialog.tsx
@@ -29,10 +29,16 @@ const DialogOverlay = React.forwardRef<
));
DialogOverlay.displayName = DialogPrimitive.Overlay.displayName;
+interface DialogContentProps extends React.ComponentPropsWithoutRef<
+ typeof DialogPrimitive.Content
+> {
+ hideCloseButton?: boolean;
+}
+
const DialogContent = React.forwardRef<
React.ElementRef,
- React.ComponentPropsWithoutRef
->(({ className, children, ...props }, ref) => (
+ DialogContentProps
+>(({ className, children, hideCloseButton = false, ...props }, ref) => (
{children}
-
-
- Close
-
+ {!hideCloseButton && (
+
+
+ Close
+
+ )}
));
diff --git a/frontend/src/test/fanoutSection.test.tsx b/frontend/src/test/fanoutSection.test.tsx
index defd708..6a54d5f 100644
--- a/frontend/src/test/fanoutSection.test.tsx
+++ b/frontend/src/test/fanoutSection.test.tsx
@@ -114,6 +114,15 @@ describe('SettingsFanoutSection', () => {
});
});
+ it('shows restart-scoped bots disabled messaging when disabled until restart', async () => {
+ renderSection({
+ health: { ...baseHealth, bots_disabled: true, bots_disabled_source: 'until_restart' },
+ });
+ await waitFor(() => {
+ expect(screen.getByText(/disabled until the server restarts/i)).toBeInTheDocument();
+ });
+ });
+
it('hides bot option from add integration menu when bots_disabled', async () => {
renderSection({ health: { ...baseHealth, bots_disabled: true } });
await waitFor(() => {
diff --git a/frontend/src/test/securityWarningModal.test.tsx b/frontend/src/test/securityWarningModal.test.tsx
new file mode 100644
index 0000000..7bf26e8
--- /dev/null
+++ b/frontend/src/test/securityWarningModal.test.tsx
@@ -0,0 +1,98 @@
+import { render, screen } from '@testing-library/react';
+import userEvent from '@testing-library/user-event';
+import { beforeEach, describe, expect, it, vi } from 'vitest';
+
+const mocks = vi.hoisted(() => ({
+ disableBotsUntilRestart: vi.fn(),
+ toast: {
+ success: vi.fn(),
+ error: vi.fn(),
+ },
+}));
+
+vi.mock('../api', () => ({
+ api: {
+ disableBotsUntilRestart: mocks.disableBotsUntilRestart,
+ },
+}));
+
+vi.mock('../components/ui/sonner', () => ({
+ toast: mocks.toast,
+}));
+
+import { SecurityWarningModal } from '../components/SecurityWarningModal';
+import type { HealthStatus } from '../types';
+
+const baseHealth: HealthStatus = {
+ status: 'degraded',
+ radio_connected: false,
+ radio_initializing: false,
+ connection_info: null,
+ database_size_mb: 1.2,
+ oldest_undecrypted_timestamp: null,
+ fanout_statuses: {},
+ bots_disabled: false,
+ basic_auth_enabled: false,
+};
+
+describe('SecurityWarningModal', () => {
+ beforeEach(() => {
+ vi.clearAllMocks();
+ window.localStorage.clear();
+ mocks.disableBotsUntilRestart.mockResolvedValue({
+ status: 'ok',
+ bots_disabled: true,
+ bots_disabled_source: 'until_restart',
+ });
+ });
+
+ it('shows the warning when bots are enabled and basic auth is off', () => {
+ render();
+
+ expect(screen.getByText('Unprotected bot execution is enabled')).toBeInTheDocument();
+ expect(
+ screen.getByRole('button', { name: 'Do Not Warn Me On This Device Again' })
+ ).toBeDisabled();
+ });
+
+ it('does not show when bots are disabled', () => {
+ render();
+
+ expect(screen.queryByText('Unprotected bot execution is enabled')).not.toBeInTheDocument();
+ });
+
+ it('does not show when basic auth is enabled', () => {
+ render();
+
+ expect(screen.queryByText('Unprotected bot execution is enabled')).not.toBeInTheDocument();
+ });
+
+ it('persists dismissal only after the checkbox is acknowledged', async () => {
+ const user = userEvent.setup();
+
+ render();
+
+ const dismissButton = screen.getByRole('button', {
+ name: 'Do Not Warn Me On This Device Again',
+ });
+ await user.click(screen.getByLabelText('Acknowledge bot security risk'));
+ expect(dismissButton).toBeEnabled();
+
+ await user.click(dismissButton);
+
+ expect(window.localStorage.getItem('meshcore_security_warning_acknowledged')).toBe('true');
+ expect(screen.queryByText('Unprotected bot execution is enabled')).not.toBeInTheDocument();
+ });
+
+ it('disables bots until restart from the warning modal', async () => {
+ const user = userEvent.setup();
+
+ render();
+
+ await user.click(screen.getByRole('button', { name: 'Disable Bots Until Server Restart' }));
+
+ expect(mocks.disableBotsUntilRestart).toHaveBeenCalledTimes(1);
+ expect(mocks.toast.success).toHaveBeenCalledWith('Bots disabled until restart');
+ expect(screen.queryByText('Unprotected bot execution is enabled')).not.toBeInTheDocument();
+ });
+});
diff --git a/frontend/src/types.ts b/frontend/src/types.ts
index 062170d..95ea3b4 100644
--- a/frontend/src/types.ts
+++ b/frontend/src/types.ts
@@ -78,6 +78,8 @@ export interface HealthStatus {
oldest_undecrypted_timestamp: number | null;
fanout_statuses: Record;
bots_disabled: boolean;
+ bots_disabled_source?: 'env' | 'until_restart' | null;
+ basic_auth_enabled?: boolean;
}
export interface FanoutConfig {
diff --git a/tests/e2e/helpers/api.ts b/tests/e2e/helpers/api.ts
index ca0f998..36d25f7 100644
--- a/tests/e2e/helpers/api.ts
+++ b/tests/e2e/helpers/api.ts
@@ -23,6 +23,9 @@ export interface HealthStatus {
radio_connected: boolean;
radio_initializing: boolean;
connection_info: string | null;
+ bots_disabled?: boolean;
+ bots_disabled_source?: 'env' | 'until_restart' | null;
+ basic_auth_enabled?: boolean;
}
export function getHealth(): Promise {
diff --git a/tests/test_disable_bots.py b/tests/test_disable_bots.py
index 6f0d0ff..f607ba1 100644
--- a/tests/test_disable_bots.py
+++ b/tests/test_disable_bots.py
@@ -1,16 +1,17 @@
-"""Tests for the --disable-bots (MESHCORE_DISABLE_BOTS) startup flag.
+"""Tests for bot-disable enforcement.
Verifies that when disable_bots=True:
- POST /api/fanout with type=bot returns 403
- Health endpoint includes bots_disabled=True
"""
-from unittest.mock import MagicMock, patch
+from unittest.mock import AsyncMock, MagicMock, patch
import pytest
from fastapi import HTTPException
from app.config import Settings
+from app.repository.fanout import FanoutConfigRepository
from app.routers.fanout import FanoutConfigCreate, create_fanout_config
from app.routers.health import build_health_data
@@ -33,7 +34,9 @@ class TestDisableBotsFanoutEndpoint:
@pytest.mark.asyncio
async def test_bot_create_returns_403_when_disabled(self, test_db):
"""POST /api/fanout with type=bot returns 403."""
- with patch("app.routers.fanout.server_settings", MagicMock(disable_bots=True)):
+ with patch(
+ "app.routers.fanout.fanout_manager.get_bots_disabled_source", return_value="env"
+ ):
with pytest.raises(HTTPException) as exc_info:
await create_fanout_config(
FanoutConfigCreate(
@@ -50,7 +53,9 @@ class TestDisableBotsFanoutEndpoint:
@pytest.mark.asyncio
async def test_mqtt_create_allowed_when_bots_disabled(self, test_db):
"""Non-bot fanout configs can still be created when bots are disabled."""
- with patch("app.routers.fanout.server_settings", MagicMock(disable_bots=True)):
+ with patch(
+ "app.routers.fanout.fanout_manager.get_bots_disabled_source", return_value="env"
+ ):
# Create as disabled so fanout_manager.reload_config is not called
result = await create_fanout_config(
FanoutConfigCreate(
@@ -62,13 +67,68 @@ class TestDisableBotsFanoutEndpoint:
)
assert result["type"] == "mqtt_private"
+ @pytest.mark.asyncio
+ async def test_bot_create_returns_403_when_disabled_until_restart(self, test_db):
+ with patch(
+ "app.routers.fanout.fanout_manager.get_bots_disabled_source",
+ return_value="until_restart",
+ ):
+ with pytest.raises(HTTPException) as exc_info:
+ await create_fanout_config(
+ FanoutConfigCreate(
+ type="bot",
+ name="Test Bot",
+ config={"code": "def bot(**k): pass"},
+ enabled=False,
+ )
+ )
+
+ assert exc_info.value.status_code == 403
+ assert "until the server restarts" in exc_info.value.detail
+
+ @pytest.mark.asyncio
+ async def test_disable_bots_until_restart_endpoint(self, test_db):
+ from app.routers.fanout import disable_bots_until_restart
+
+ await FanoutConfigRepository.create(
+ config_type="bot",
+ name="Test Bot",
+ config={"code": "def bot(**k): pass"},
+ scope={"messages": "all", "raw_packets": "none"},
+ enabled=True,
+ )
+
+ with (
+ patch(
+ "app.routers.fanout.fanout_manager.disable_bots_until_restart",
+ new=AsyncMock(return_value="until_restart"),
+ ) as mock_disable,
+ patch("app.websocket.broadcast_health") as mock_broadcast_health,
+ patch("app.services.radio_runtime.radio_runtime") as mock_radio_runtime,
+ ):
+ mock_radio_runtime.is_connected = True
+ mock_radio_runtime.connection_info = "TCP: 1.2.3.4:4000"
+
+ result = await disable_bots_until_restart()
+
+ mock_disable.assert_awaited_once()
+ mock_broadcast_health.assert_called_once_with(True, "TCP: 1.2.3.4:4000")
+ assert result == {
+ "status": "ok",
+ "bots_disabled": True,
+ "bots_disabled_source": "until_restart",
+ }
+
class TestDisableBotsHealthEndpoint:
"""Test that bots_disabled is exposed in health data."""
@pytest.mark.asyncio
async def test_health_includes_bots_disabled_true(self, test_db):
- with patch("app.routers.health.settings", MagicMock(disable_bots=True, database_path="x")):
+ with patch(
+ "app.routers.health.settings",
+ MagicMock(disable_bots=True, basic_auth_enabled=False, database_path="x"),
+ ):
with patch("app.routers.health.os.path.getsize", return_value=0):
data = await build_health_data(True, "TCP: 1.2.3.4:4000")
@@ -76,8 +136,39 @@ class TestDisableBotsHealthEndpoint:
@pytest.mark.asyncio
async def test_health_includes_bots_disabled_false(self, test_db):
- with patch("app.routers.health.settings", MagicMock(disable_bots=False, database_path="x")):
+ with patch(
+ "app.routers.health.settings",
+ MagicMock(disable_bots=False, basic_auth_enabled=False, database_path="x"),
+ ):
with patch("app.routers.health.os.path.getsize", return_value=0):
data = await build_health_data(True, "TCP: 1.2.3.4:4000")
assert data["bots_disabled"] is False
+
+ @pytest.mark.asyncio
+ async def test_health_includes_basic_auth_enabled(self, test_db):
+ with patch(
+ "app.routers.health.settings",
+ MagicMock(disable_bots=False, basic_auth_enabled=True, database_path="x"),
+ ):
+ with patch("app.routers.health.os.path.getsize", return_value=0):
+ data = await build_health_data(True, "TCP: 1.2.3.4:4000")
+
+ assert data["basic_auth_enabled"] is True
+
+ @pytest.mark.asyncio
+ async def test_health_includes_runtime_bot_disable_source(self, test_db):
+ with (
+ patch(
+ "app.routers.health.settings",
+ MagicMock(disable_bots=False, basic_auth_enabled=False, database_path="x"),
+ ),
+ patch("app.routers.health.os.path.getsize", return_value=0),
+ patch("app.fanout.manager.fanout_manager") as mock_fm,
+ ):
+ mock_fm.get_statuses.return_value = {}
+ mock_fm.get_bots_disabled_source.return_value = "until_restart"
+ data = await build_health_data(True, "TCP: 1.2.3.4:4000")
+
+ assert data["bots_disabled"] is True
+ assert data["bots_disabled_source"] == "until_restart"
diff --git a/tests/test_fanout_hitlist.py b/tests/test_fanout_hitlist.py
index e7eec4d..51023b4 100644
--- a/tests/test_fanout_hitlist.py
+++ b/tests/test_fanout_hitlist.py
@@ -593,7 +593,9 @@ class TestDisableBotsPatchGuard:
)
# Now try to update with bots disabled
- with patch("app.routers.fanout.server_settings", MagicMock(disable_bots=True)):
+ with patch(
+ "app.routers.fanout.fanout_manager.get_bots_disabled_source", return_value="env"
+ ):
with pytest.raises(HTTPException) as exc_info:
await update_fanout_config(
cfg["id"],
@@ -617,7 +619,9 @@ class TestDisableBotsPatchGuard:
enabled=False,
)
- with patch("app.routers.fanout.server_settings", MagicMock(disable_bots=True)):
+ with patch(
+ "app.routers.fanout.fanout_manager.get_bots_disabled_source", return_value="env"
+ ):
with patch("app.fanout.manager.fanout_manager.reload_config", new_callable=AsyncMock):
result = await update_fanout_config(
cfg["id"],
From 36eeeae64d5111759aa734b16a5474340809b0de Mon Sep 17 00:00:00 2001
From: Jack Kingsman
Date: Mon, 23 Mar 2026 18:27:42 -0700
Subject: [PATCH 2/5] Protect against uncheck race condition
---
.../src/components/SecurityWarningModal.tsx | 6 +++++
.../src/test/securityWarningModal.test.tsx | 23 ++++++++++++++++++-
2 files changed, 28 insertions(+), 1 deletion(-)
diff --git a/frontend/src/components/SecurityWarningModal.tsx b/frontend/src/components/SecurityWarningModal.tsx
index 9a3e9e1..ae93147 100644
--- a/frontend/src/components/SecurityWarningModal.tsx
+++ b/frontend/src/components/SecurityWarningModal.tsx
@@ -55,6 +55,12 @@ export function SecurityWarningModal({ health }: SecurityWarningModalProps) {
}
}, [shouldWarn]);
+ useEffect(() => {
+ if (health?.bots_disabled !== true) {
+ setBotsDisabledLocally(false);
+ }
+ }, [health?.bots_disabled, health?.bots_disabled_source]);
+
if (!shouldWarn) {
return null;
}
diff --git a/frontend/src/test/securityWarningModal.test.tsx b/frontend/src/test/securityWarningModal.test.tsx
index 7bf26e8..287555a 100644
--- a/frontend/src/test/securityWarningModal.test.tsx
+++ b/frontend/src/test/securityWarningModal.test.tsx
@@ -1,4 +1,4 @@
-import { render, screen } from '@testing-library/react';
+import { render, screen, waitFor } from '@testing-library/react';
import userEvent from '@testing-library/user-event';
import { beforeEach, describe, expect, it, vi } from 'vitest';
@@ -95,4 +95,25 @@ describe('SecurityWarningModal', () => {
expect(mocks.toast.success).toHaveBeenCalledWith('Bots disabled until restart');
expect(screen.queryByText('Unprotected bot execution is enabled')).not.toBeInTheDocument();
});
+
+ it('shows the warning again after temporary bot disable disappears on a later health update', async () => {
+ const user = userEvent.setup();
+ const { rerender } = render();
+
+ await user.click(screen.getByRole('button', { name: 'Disable Bots Until Server Restart' }));
+ expect(screen.queryByText('Unprotected bot execution is enabled')).not.toBeInTheDocument();
+
+ rerender(
+
+ );
+ expect(screen.queryByText('Unprotected bot execution is enabled')).not.toBeInTheDocument();
+
+ rerender();
+
+ await waitFor(() => {
+ expect(screen.getByText('Unprotected bot execution is enabled')).toBeInTheDocument();
+ });
+ });
});
From e6e7267eb1de75a0795aab82f8ae1b00fbaccacc Mon Sep 17 00:00:00 2001
From: Jack Kingsman
Date: Mon, 23 Mar 2026 18:33:37 -0700
Subject: [PATCH 3/5] Fix mobile modal
---
.../src/components/SecurityWarningModal.tsx | 37 ++++++++++---------
1 file changed, 20 insertions(+), 17 deletions(-)
diff --git a/frontend/src/components/SecurityWarningModal.tsx b/frontend/src/components/SecurityWarningModal.tsx
index ae93147..0d6f43e 100644
--- a/frontend/src/components/SecurityWarningModal.tsx
+++ b/frontend/src/components/SecurityWarningModal.tsx
@@ -69,7 +69,7 @@ export function SecurityWarningModal({ health }: SecurityWarningModalProps) {
-
+
This is only safe on protected or isolated networks with appropriate access control. If
your network is untrusted or later compromised, this setup may expose the host system to
arbitrary code execution.
@@ -117,23 +117,10 @@ export function SecurityWarningModal({ health }: SecurityWarningModalProps) {