diff --git a/backend/main.py b/backend/main.py
index d350237..8f6c527 100644
--- a/backend/main.py
+++ b/backend/main.py
@@ -292,7 +292,6 @@ class WSConnectionManager:
manager = WSConnectionManager()
-BOT_ACCESS_PASSWORD_HEADER = "x-bot-password"
PANEL_ACCESS_PASSWORD_HEADER = "x-panel-password"
@@ -313,14 +312,6 @@ def _extract_bot_id_from_api_path(path: str) -> Optional[str]:
return str(decoded).strip() or None
-def _get_supplied_bot_password_http(request: Request) -> str:
- header_value = str(request.headers.get(BOT_ACCESS_PASSWORD_HEADER) or "").strip()
- if header_value:
- return header_value
- query_value = str(request.query_params.get("access_password") or "").strip()
- return query_value
-
-
def _get_supplied_panel_password_http(request: Request) -> str:
header_value = str(request.headers.get(PANEL_ACCESS_PASSWORD_HEADER) or "").strip()
if header_value:
@@ -341,8 +332,9 @@ def _validate_panel_access_password(supplied: str) -> Optional[str]:
return None
-def _is_panel_protected_api_path(path: str) -> bool:
+def _is_panel_protected_api_path(path: str, method: str = "GET") -> bool:
raw = str(path or "").strip()
+ verb = str(method or "GET").strip().upper()
if not raw.startswith("/api/"):
return False
if raw in {
@@ -352,21 +344,28 @@ def _is_panel_protected_api_path(path: str) -> bool:
"/api/health/cache",
}:
return False
- if _is_bot_panel_management_api_path(raw):
+ if _is_bot_panel_management_api_path(raw, verb):
return True
- # Bot-scoped content/chat APIs are protected by the bot's own access password only.
+ # Other bot-scoped APIs are not protected by panel password.
if _extract_bot_id_from_api_path(raw):
return False
return True
-def _is_bot_panel_management_api_path(path: str) -> bool:
+def _is_bot_panel_management_api_path(path: str, method: str = "GET") -> bool:
raw = str(path or "").strip()
+ verb = str(method or "GET").strip().upper()
if not raw.startswith("/api/bots/"):
return False
- if not _extract_bot_id_from_api_path(raw):
+ bot_id = _extract_bot_id_from_api_path(raw)
+ if not bot_id:
return False
- return raw.endswith("/start") or raw.endswith("/stop") or raw.endswith("/deactivate") or raw == f"/api/bots/{_extract_bot_id_from_api_path(raw)}"
+ return (
+ raw.endswith("/start")
+ or raw.endswith("/stop")
+ or raw.endswith("/deactivate")
+ or (verb in {"PUT", "DELETE"} and raw == f"/api/bots/{bot_id}")
+ )
@app.middleware("http")
@@ -374,7 +373,7 @@ async def bot_access_password_guard(request: Request, call_next):
if request.method.upper() == "OPTIONS":
return await call_next(request)
- if _is_panel_protected_api_path(request.url.path):
+ if _is_panel_protected_api_path(request.url.path, request.method):
panel_error = _validate_panel_access_password(_get_supplied_panel_password_http(request))
if panel_error:
return JSONResponse(status_code=401, content={"detail": panel_error})
@@ -387,14 +386,6 @@ async def bot_access_password_guard(request: Request, call_next):
bot = session.get(BotInstance, bot_id)
if not bot:
return JSONResponse(status_code=404, content={"detail": "Bot not found"})
- configured_password = str(bot.access_password or "").strip()
- if configured_password:
- supplied = _get_supplied_bot_password_http(request)
- if not supplied:
- return JSONResponse(status_code=401, content={"detail": "Bot access password required"})
- if supplied != configured_password:
- return JSONResponse(status_code=401, content={"detail": "Invalid bot access password"})
-
return await call_next(request)
@@ -2721,17 +2712,6 @@ async def websocket_endpoint(websocket: WebSocket, bot_id: str):
if not bot:
await websocket.close(code=4404, reason="Bot not found")
return
- configured_password = str(bot.access_password or "").strip()
- if configured_password:
- supplied = str(
- websocket.headers.get(BOT_ACCESS_PASSWORD_HEADER) or websocket.query_params.get("access_password") or ""
- ).strip()
- if not supplied:
- await websocket.close(code=4401, reason="Bot access password required")
- return
- if supplied != configured_password:
- await websocket.close(code=4401, reason="Invalid bot access password")
- return
await manager.connect(bot_id, websocket)
docker_manager.ensure_monitor(bot_id, docker_callback)
diff --git a/frontend/src/App.tsx b/frontend/src/App.tsx
index 03003a8..bc228b7 100644
--- a/frontend/src/App.tsx
+++ b/frontend/src/App.tsx
@@ -25,26 +25,45 @@ function AuthenticatedApp({
const { theme, setTheme, locale, setLocale, activeBots } = useAppStore();
const [showImageFactory, setShowImageFactory] = useState(false);
const [showCreateWizard, setShowCreateWizard] = useState(false);
+ const [singleBotPassword, setSingleBotPassword] = useState('');
+ const [singleBotPasswordError, setSingleBotPasswordError] = useState('');
+ const [singleBotUnlocked, setSingleBotUnlocked] = useState(false);
useBotsSync(forcedBotId);
const t = pickLocale(locale, { 'zh-cn': appZhCn, en: appEn });
const isSingleBotCompactView = compactMode && Boolean(String(forcedBotId || '').trim());
const [headerCollapsed, setHeaderCollapsed] = useState(isSingleBotCompactView);
+ const forced = String(forcedBotId || '').trim();
+ const forcedBot = forced ? activeBots[forced] : undefined;
+ const shouldPromptSingleBotPassword = Boolean(forced && forcedBot?.has_access_password && !singleBotUnlocked);
useEffect(() => {
- const forced = String(forcedBotId || '').trim();
if (!forced) {
document.title = t.title;
return;
}
- const bot = activeBots[forced];
- const botName = String(bot?.name || '').trim();
+ const botName = String(forcedBot?.name || '').trim();
document.title = botName ? `${t.title} - ${botName}` : `${t.title} - ${forced}`;
- }, [activeBots, t.title, forcedBotId]);
+ }, [forced, forcedBot?.name, t.title]);
useEffect(() => {
setHeaderCollapsed(isSingleBotCompactView);
}, [isSingleBotCompactView, forcedBotId]);
+ useEffect(() => {
+ setSingleBotUnlocked(false);
+ setSingleBotPassword('');
+ setSingleBotPasswordError('');
+ }, [forced]);
+
+ const unlockSingleBot = () => {
+ if (!String(singleBotPassword || '').trim()) {
+ setSingleBotPasswordError(locale === 'zh' ? '请输入 Bot 密码。' : 'Enter the bot password.');
+ return;
+ }
+ setSingleBotPasswordError('');
+ setSingleBotUnlocked(true);
+ };
+
return (
@@ -180,6 +199,36 @@ function AuthenticatedApp({
)}
+
+ {shouldPromptSingleBotPassword ? (
+
+
event.stopPropagation()}>
+

+
{forcedBot?.name || forced}
+
{locale === 'zh' ? '请输入该 Bot 的访问密码后继续。' : 'Enter the bot password to continue.'}
+
+
{
+ setSingleBotPassword(event.target.value);
+ if (singleBotPasswordError) setSingleBotPasswordError('');
+ }}
+ onKeyDown={(event) => {
+ if (event.key === 'Enter') unlockSingleBot();
+ }}
+ placeholder={locale === 'zh' ? 'Bot 密码' : 'Bot password'}
+ autoFocus
+ />
+ {singleBotPasswordError ?
{singleBotPasswordError}
: null}
+
+
+
+
+ ) : null}
);
}
@@ -217,8 +266,15 @@ function PanelLoginGate({
const [password, setPassword] = useState('');
const [error, setError] = useState('');
const [submitting, setSubmitting] = useState(false);
+ const bypassPanelGate = Boolean(String(urlView.forcedBotId || '').trim());
useEffect(() => {
+ if (bypassPanelGate) {
+ setRequired(false);
+ setAuthenticated(true);
+ setChecking(false);
+ return;
+ }
let alive = true;
const boot = async () => {
try {
@@ -259,7 +315,7 @@ function PanelLoginGate({
return () => {
alive = false;
};
- }, [locale]);
+ }, [bypassPanelGate, locale]);
const onSubmit = async () => {
const next = String(password || '').trim();
diff --git a/frontend/src/hooks/useBotsSync.ts b/frontend/src/hooks/useBotsSync.ts
index 026d1cd..20573f2 100644
--- a/frontend/src/hooks/useBotsSync.ts
+++ b/frontend/src/hooks/useBotsSync.ts
@@ -7,7 +7,7 @@ import { normalizeAssistantMessageText, normalizeUserMessageText, summarizeProgr
import { pickLocale } from '../i18n';
import { botsSyncZhCn } from '../i18n/bots-sync.zh-cn';
import { botsSyncEn } from '../i18n/bots-sync.en';
-import { buildMonitorWsUrl, getBotAccessPassword } from '../utils/botAccess';
+import { buildMonitorWsUrl } from '../utils/botAccess';
function normalizeState(v: string): 'THINKING' | 'TOOL_CALL' | 'SUCCESS' | 'ERROR' | 'INFO' {
const s = (v || '').toUpperCase();
@@ -122,8 +122,6 @@ export function useBotsSync(forcedBotId?: string) {
botIds.forEach((botId) => {
if (hydratedMessagesRef.current[botId]) return;
- const bot = activeBots[botId];
- if (bot?.has_access_password && !getBotAccessPassword(botId)) return;
hydratedMessagesRef.current[botId] = true;
void (async () => {
try {
@@ -177,9 +175,6 @@ export function useBotsSync(forcedBotId?: string) {
if (bot.docker_status !== 'RUNNING') {
return;
}
- if (bot.has_access_password && !getBotAccessPassword(bot.id)) {
- return;
- }
if (socketsRef.current[bot.id]) {
return;
}
diff --git a/frontend/src/modules/dashboard/BotDashboardModule.tsx b/frontend/src/modules/dashboard/BotDashboardModule.tsx
index d4edfae..7adba47 100644
--- a/frontend/src/modules/dashboard/BotDashboardModule.tsx
+++ b/frontend/src/modules/dashboard/BotDashboardModule.tsx
@@ -18,7 +18,6 @@ import { dashboardZhCn } from '../../i18n/dashboard.zh-cn';
import { dashboardEn } from '../../i18n/dashboard.en';
import { useLucentPrompt } from '../../components/lucent/LucentPromptProvider';
import { LucentIconButton } from '../../components/lucent/LucentIconButton';
-import { clearBotAccessPassword, getBotAccessPassword, isBotUnauthorizedError, setBotAccessPassword } from '../../utils/botAccess';
interface BotDashboardModuleProps {
onOpenCreateWizard?: () => void;
@@ -649,6 +648,8 @@ export function BotDashboardModule({
const [showRuntimeActionModal, setShowRuntimeActionModal] = useState(false);
const [workspaceHoverCard, setWorkspaceHoverCard] = useState(null);
const runtimeMenuRef = useRef(null);
+ const botOrderRef = useRef>({});
+ const nextBotOrderRef = useRef(1);
const applyEditFormFromBot = useCallback((bot?: any) => {
if (!bot) return;
setProviderTestResult('');
@@ -914,13 +915,37 @@ export function BotDashboardModule({
storage_gb: '10',
});
+ useEffect(() => {
+ const ordered = Object.values(activeBots).sort((a, b) => {
+ const aCreated = parseBotTimestamp(a.created_at);
+ const bCreated = parseBotTimestamp(b.created_at);
+ if (aCreated !== bCreated) return aCreated - bCreated;
+ return String(a.id || '').localeCompare(String(b.id || ''));
+ });
+
+ ordered.forEach((bot) => {
+ const id = String(bot.id || '').trim();
+ if (!id) return;
+ if (botOrderRef.current[id] !== undefined) return;
+ botOrderRef.current[id] = nextBotOrderRef.current;
+ nextBotOrderRef.current += 1;
+ });
+
+ const alive = new Set(ordered.map((bot) => String(bot.id || '').trim()).filter(Boolean));
+ Object.keys(botOrderRef.current).forEach((id) => {
+ if (!alive.has(id)) delete botOrderRef.current[id];
+ });
+ }, [activeBots]);
+
const bots = useMemo(
() =>
Object.values(activeBots).sort((a, b) => {
- const aCreated = parseBotTimestamp(a.created_at);
- const bCreated = parseBotTimestamp(b.created_at);
- if (aCreated !== bCreated) return aCreated - bCreated;
- return String(a.id || '').localeCompare(String(b.id || ''));
+ const aId = String(a.id || '').trim();
+ const bId = String(b.id || '').trim();
+ const aOrder = botOrderRef.current[aId] ?? Number.MAX_SAFE_INTEGER;
+ const bOrder = botOrderRef.current[bId] ?? Number.MAX_SAFE_INTEGER;
+ if (aOrder !== bOrder) return aOrder - bOrder;
+ return aId.localeCompare(bId);
}),
[activeBots],
);
@@ -947,97 +972,6 @@ export function BotDashboardModule({
const noteLocale = pickLocale(locale, { 'zh-cn': 'zh-cn' as const, en: 'en' as const });
const t = pickLocale(locale, { 'zh-cn': dashboardZhCn, en: dashboardEn });
const lc = isZh ? channelsZhCn : channelsEn;
- const botAccessCheckRef = useRef | undefined>>({});
- const botPasswordResolverRef = useRef<((value: string | null) => void) | null>(null);
- const [botPasswordDialog, setBotPasswordDialog] = useState<{
- open: boolean;
- botName: string;
- invalid: boolean;
- value: string;
- }>({
- open: false,
- botName: '',
- invalid: false,
- value: '',
- });
-
- const promptForBotPassword = (botName: string, invalid: boolean): Promise => {
- setBotPasswordDialog({
- open: true,
- botName,
- invalid,
- value: '',
- });
- return new Promise((resolve) => {
- botPasswordResolverRef.current = resolve;
- });
- };
-
- const closeBotPasswordDialog = (value: string | null) => {
- const resolver = botPasswordResolverRef.current;
- botPasswordResolverRef.current = null;
- setBotPasswordDialog((prev) => ({ ...prev, open: false, value: '' }));
- if (resolver) resolver(value && String(value).trim() ? String(value).trim() : null);
- };
-
- const verifyBotPassword = async (botId: string): Promise => {
- await axios.get(`${APP_ENDPOINTS.apiBase}/bots/${botId}/channels`);
- return true;
- };
-
- const ensureBotAccess = async (botId: string): Promise => {
- const normalizedBotId = String(botId || '').trim();
- if (!normalizedBotId) return false;
- const bot = activeBots[normalizedBotId];
- if (!bot?.has_access_password) return true;
-
- const inFlight = botAccessCheckRef.current[normalizedBotId];
- if (inFlight) return inFlight;
-
- const checkPromise = (async () => {
- const botName = String(bot.name || bot.id || normalizedBotId).trim();
- let askForNewPassword = false;
- for (let attempt = 0; attempt < 3; attempt += 1) {
- let password = getBotAccessPassword(normalizedBotId);
- if (!password || askForNewPassword) {
- const input = await promptForBotPassword(botName, askForNewPassword);
- if (input === null) {
- notify(isZh ? '已取消密码输入,无法访问该机器人。' : 'Password input cancelled. Bot access blocked.', {
- tone: 'warning',
- });
- return false;
- }
- setBotAccessPassword(normalizedBotId, input);
- password = input;
- }
- if (!password) {
- askForNewPassword = true;
- continue;
- }
-
- try {
- await verifyBotPassword(normalizedBotId);
- return true;
- } catch (error: any) {
- if (isBotUnauthorizedError(error, normalizedBotId)) {
- clearBotAccessPassword(normalizedBotId);
- askForNewPassword = true;
- notify(isZh ? '访问密码错误,请重试。' : 'Access password is invalid. Please retry.', { tone: 'warning' });
- continue;
- }
- throw error;
- }
- }
- return false;
- })();
-
- botAccessCheckRef.current[normalizedBotId] = checkPromise;
- try {
- return await checkPromise;
- } finally {
- delete botAccessCheckRef.current[normalizedBotId];
- }
- };
const baseImageOptions = useMemo(() => {
const readyTags = new Set(
availableImages
@@ -2447,8 +2381,7 @@ export function BotDashboardModule({
let cancelled = false;
const loadAll = async () => {
try {
- const granted = await ensureBotAccess(selectedBotId);
- if (!granted || cancelled) return;
+ if (cancelled) return;
await Promise.all([
loadWorkspaceTree(selectedBotId, ''),
loadCronJobs(selectedBotId),
@@ -2457,15 +2390,6 @@ export function BotDashboardModule({
]);
} catch (error: any) {
const detail = String(error?.response?.data?.detail || '').trim();
- if (isBotUnauthorizedError(error, selectedBotId)) {
- clearBotAccessPassword(selectedBotId);
- if (!cancelled) {
- notify(isZh ? '访问密码校验失败,请重新进入该机器人。' : 'Bot password check failed. Reopen the bot and retry.', {
- tone: 'error',
- });
- }
- return;
- }
if (!cancelled && detail) {
notify(detail, { tone: 'error' });
}
@@ -2584,14 +2508,6 @@ export function BotDashboardModule({
}
await axios.put(`${APP_ENDPOINTS.apiBase}/bots/${targetBotId}`, payload);
- if (mode === 'base') {
- const nextPassword = String(editForm.access_password || '').trim();
- if (nextPassword) {
- setBotAccessPassword(targetBotId, nextPassword);
- } else {
- clearBotAccessPassword(targetBotId);
- }
- }
await refresh();
setShowBaseModal(false);
setShowParamModal(false);
@@ -4214,52 +4130,6 @@ export function BotDashboardModule({
) : null}
- {botPasswordDialog.open ? (
- closeBotPasswordDialog(null)}>
-
event.stopPropagation()}>
-
-
-
- {botPasswordDialog.invalid
- ? (isZh ? `访问密码错误:${botPasswordDialog.botName}` : `Invalid access password: ${botPasswordDialog.botName}`)
- : (isZh ? `请输入访问密码:${botPasswordDialog.botName}` : `Enter access password for ${botPasswordDialog.botName}`)}
-
-
-
- closeBotPasswordDialog(null)} tooltip={isZh ? '关闭' : 'Close'} aria-label={isZh ? '关闭' : 'Close'}>
-
-
-
-
-
-
- setBotPasswordDialog((prev) => ({
- ...prev,
- value: event.target.value,
- }))
- }
- onKeyDown={(event) => {
- if (event.key === 'Enter') closeBotPasswordDialog(botPasswordDialog.value);
- }}
- placeholder={isZh ? '输入 Bot 访问密码' : 'Enter bot access password'}
- />
-
-
-
-
-
-
-
- ) : null}
>
);
}
diff --git a/frontend/src/utils/botAccess.ts b/frontend/src/utils/botAccess.ts
index ca2868b..2c06fc9 100644
--- a/frontend/src/utils/botAccess.ts
+++ b/frontend/src/utils/botAccess.ts
@@ -1,8 +1,6 @@
import axios from 'axios';
import { appendPanelAccessPassword } from './panelAccess';
-const BOT_PASSWORD_HEADER = 'X-Bot-Password';
-
let initialized = false;
const memoryMap = new Map();
@@ -63,41 +61,17 @@ export function clearAllBotAccessPasswords(): void {
}
export function isBotUnauthorizedError(error: any, botId?: string): boolean {
- if (!axios.isAxiosError(error)) return false;
- if (Number(error.response?.status) !== 401) return false;
- const detail = String(error.response?.data?.detail || '').trim().toLowerCase();
- if (!detail.includes('bot access password')) return false;
- if (!botId) return true;
-
- const fromConfig = extractBotIdFromApiPath(String(error.config?.url || ''));
- const fromRequest = extractBotIdFromApiPath(String(error.request?.responseURL || ''));
- const expected = normalizeBotId(botId);
- return expected === fromConfig || expected === fromRequest;
+ void error;
+ void botId;
+ return false;
}
export function buildMonitorWsUrl(base: string, botId: string): string {
- const target = appendPanelAccessPassword(`${String(base || '').replace(/\/$/, '')}/${encodeURIComponent(botId)}`);
- const password = getBotAccessPassword(botId);
- if (!password) return target;
- const joiner = target.includes('?') ? '&' : '?';
- return `${target}${joiner}access_password=${encodeURIComponent(password)}`;
+ return appendPanelAccessPassword(`${String(base || '').replace(/\/$/, '')}/${encodeURIComponent(botId)}`);
}
export function setupBotAccessAuth(): void {
if (initialized) return;
initialized = true;
-
- axios.interceptors.request.use((config) => {
- const botId = extractBotIdFromApiPath(String(config.url || ''));
- if (!botId) return config;
- const password = getBotAccessPassword(botId);
- if (!password) return config;
-
- const headers = config.headers || {};
- if (!(BOT_PASSWORD_HEADER in (headers as Record))) {
- (headers as Record)[BOT_PASSWORD_HEADER] = password;
- config.headers = headers;
- }
- return config;
- });
+ void axios;
}