feat(tenancy): establish cloud isolation foundations

This commit is contained in:
Junyan Qin
2026-07-19 22:39:58 +08:00
parent 59b1570ead
commit 41772920ef
41 changed files with 1984 additions and 43 deletions
@@ -56,6 +56,7 @@ import {
import { CustomApiError } from '@/app/infra/entities/common';
import { BoxUnavailableNotice } from '@/app/home/components/BoxUnavailableNotice';
import { useBoxStatus } from '@/app/infra/hooks/useBoxStatus';
import { useMCPStdioPolicy } from '@/app/infra/hooks/useMCPStdioPolicy';
function StatusDisplay({
testing,
@@ -560,11 +561,15 @@ const MCPForm = forwardRef<MCPFormHandle, MCPFormProps>(function MCPForm(
hint: boxHint,
reason: boxReason,
} = useBoxStatus();
const { enabled: mcpStdioEnabled } = useMCPStdioPolicy();
// stdio mode requires the Box sandbox at runtime. If the user picks
// stdio while Box is disabled / unreachable, the server would refuse
// to start anyway — block creation upfront so they aren't surprised
// by an immediate "Connection failed" on the detail page.
const stdioBlockedByBox = watchMode === 'stdio' && !boxAvailable;
const stdioBlockedByPolicy = watchMode === 'stdio' && !mcpStdioEnabled;
const stdioBlockedByBox =
watchMode === 'stdio' && mcpStdioEnabled && !boxAvailable;
const stdioBlocked = stdioBlockedByPolicy || stdioBlockedByBox;
const { isDirty } = form.formState;
useEffect(() => {
@@ -572,8 +577,8 @@ const MCPForm = forwardRef<MCPFormHandle, MCPFormProps>(function MCPForm(
}, [isDirty, onDirtyChange]);
useEffect(() => {
onSaveBlockedChange?.(stdioBlockedByBox);
}, [stdioBlockedByBox, onSaveBlockedChange]);
onSaveBlockedChange?.(stdioBlocked);
}, [stdioBlocked, onSaveBlockedChange]);
useEffect(() => {
onTestingChange?.(mcpTesting);
@@ -589,10 +594,9 @@ const MCPForm = forwardRef<MCPFormHandle, MCPFormProps>(function MCPForm(
testMcp: () => testMcp(),
isTesting: mcpTesting,
}),
// testMcp now reads everything via form.getValues(), so it does not need
// the latest stdioArgs/extraArgs closure — but keep mcpTesting so the
// exposed isTesting flag stays accurate.
[mcpTesting],
// Form values are read through form.getValues(); policy and runtime health
// remain closure values and must refresh the imperative handler.
[mcpTesting, mcpStdioEnabled, boxAvailable],
);
useEffect(() => {
@@ -747,6 +751,10 @@ const MCPForm = forwardRef<MCPFormHandle, MCPFormProps>(function MCPForm(
async function handleFormSubmit(value: z.infer<typeof formSchema>) {
// Belt-and-suspenders: even though the Save button is disabled when
// stdio is unselectable, intercept programmatic submits too.
if (value.mode === 'stdio' && !mcpStdioEnabled) {
toast.error(t('mcp.stdioDisabledByPolicy'));
return;
}
if (value.mode === 'stdio' && !boxAvailable) {
toast.error(t('mcp.stdioBlockedByBoxToast'));
return;
@@ -814,6 +822,16 @@ const MCPForm = forwardRef<MCPFormHandle, MCPFormProps>(function MCPForm(
try {
const mode = form.getValues('mode');
if (mode === 'stdio' && !mcpStdioEnabled) {
toast.error(t('mcp.stdioDisabledByPolicy'));
setMcpTesting(false);
return;
}
if (mode === 'stdio' && !boxAvailable) {
toast.error(t('mcp.stdioBlockedByBoxToast'));
setMcpTesting(false);
return;
}
// Read every field via form.getValues() rather than the captured
// `stdioArgs` / `extraArgs` state. testMcp() is invoked through an
// imperative handle (formRef.current.testMcp()) whose closure is only
@@ -1020,13 +1038,20 @@ const MCPForm = forwardRef<MCPFormHandle, MCPFormProps>(function MCPForm(
</FormControl>
<SelectContent>
<SelectItem value="remote">{t('mcp.remote')}</SelectItem>
<SelectItem value="stdio" disabled={!boxAvailable}>
<SelectItem
value="stdio"
disabled={!mcpStdioEnabled || !boxAvailable}
>
{t('mcp.local')}
{!boxAvailable && (
{!mcpStdioEnabled ? (
<span className="ml-2 text-xs text-muted-foreground">
({t('mcp.disabledByPolicy')})
</span>
) : !boxAvailable ? (
<span className="ml-2 text-xs text-muted-foreground">
({t('mcp.boxRequired')})
</span>
)}
) : null}
</SelectItem>
</SelectContent>
</Select>
@@ -1035,6 +1060,14 @@ const MCPForm = forwardRef<MCPFormHandle, MCPFormProps>(function MCPForm(
? t('mcp.localModeDescription')
: t('mcp.remoteModeDescription')}
</FormDescription>
{stdioBlockedByPolicy && (
<div
role="alert"
className="mt-2 rounded-md border border-amber-500/40 bg-amber-500/10 p-3 text-sm text-amber-800 dark:text-amber-200"
>
{t('mcp.stdioDisabledByPolicy')}
</div>
)}
{stdioBlockedByBox && (
<BoxUnavailableNotice
hint={boxHint}
+2
View File
@@ -346,6 +346,8 @@ export interface ApiRespSystemInfo {
debug: boolean;
version: string;
edition: string;
/** Independent instance-level gate for local stdio MCP transports. */
mcp_stdio_enabled: boolean;
cloud_service_url: string;
enable_marketplace: boolean;
allow_modify_login_info: boolean;
@@ -0,0 +1,32 @@
import { useCallback, useEffect, useState } from 'react';
import { httpClient } from '@/app/infra/http/HttpClient';
/**
* Load the instance-level stdio MCP gate independently of Box health.
*
* The hook fails closed while loading or when System Info is unavailable.
* This is only a WebUI guard; the backend loader enforces the same gate at
* the final transport boundary.
*/
export function useMCPStdioPolicy() {
const [enabled, setEnabled] = useState(false);
const [loading, setLoading] = useState(true);
const refresh = useCallback(async () => {
try {
const info = await httpClient.getSystemInfo();
setEnabled(info.mcp_stdio_enabled === true);
} catch {
setEnabled(false);
} finally {
setLoading(false);
}
}, []);
useEffect(() => {
void refresh();
}, [refresh]);
return { enabled, loading, refresh };
}
+1
View File
@@ -25,6 +25,7 @@ export const systemInfo: ApiRespSystemInfo = {
debug: false,
version: '',
edition: 'community',
mcp_stdio_enabled: false,
enable_marketplace: true,
cloud_service_url: '',
allow_modify_login_info: true,
+3
View File
@@ -820,6 +820,9 @@ const enUS = {
boxStdioRefusedSuggestion:
'Enable Box (box.enabled = true) and ensure the runtime is healthy, or switch this server to http/sse mode.',
boxRequired: 'requires Box',
disabledByPolicy: 'disabled by policy',
stdioDisabledByPolicy:
'Stdio MCP is disabled for this deployment. Use a remote MCP server instead.',
stdioBlockedByBoxToast:
'Stdio MCP cannot be saved while the Box sandbox is disabled or unreachable. Enable Box or pick http/sse.',
toolsFound: 'tools',
+3
View File
@@ -833,6 +833,9 @@ const esES = {
boxStdioRefusedSuggestion:
'Active Box (box.enabled = true) y asegúrese de que el runtime está conectado, o cambie este servidor a modo http/sse.',
boxRequired: 'requiere Box',
disabledByPolicy: 'desactivado por la política',
stdioDisabledByPolicy:
'Stdio MCP está deshabilitado en este despliegue. Use un servidor MCP remoto.',
stdioBlockedByBoxToast:
'No se puede guardar el MCP en modo stdio mientras el sandbox de Box está desactivado o no disponible. Active Box o seleccione modo http/sse.',
toolsFound: 'herramientas',
+3
View File
@@ -826,6 +826,9 @@ const jaJP = {
boxStdioRefusedSuggestion:
'Box を有効化(box.enabled = true)してランタイムの接続を確認するか、このサーバーを http/sse モードに切り替えてください。',
boxRequired: 'Box が必要',
disabledByPolicy: 'ポリシーにより無効',
stdioDisabledByPolicy:
'このデプロイでは Stdio MCP が無効です。リモート MCP サーバーを使用してください。',
stdioBlockedByBoxToast:
'Box サンドボックスが無効または利用できないため、stdio モードの MCP は保存できません。Box を有効化するか、http/sse モードに切り替えてください。',
toolsFound: '個のツール',
+3
View File
@@ -830,6 +830,9 @@ const ruRU = {
boxStdioRefusedSuggestion:
'Включите Box (box.enabled = true) и убедитесь, что среда работает, либо переключите этот сервер в режим http/sse.',
boxRequired: 'требуется Box',
disabledByPolicy: 'отключено политикой',
stdioDisabledByPolicy:
'Stdio MCP отключён в этом развёртывании. Используйте удалённый MCP-сервер.',
stdioBlockedByBoxToast:
'Сохранить MCP в режиме stdio нельзя: песочница Box отключена или недоступна. Включите Box либо выберите режим http/sse.',
toolsFound: 'инструментов',
+3
View File
@@ -808,6 +808,9 @@ const thTH = {
boxStdioRefusedSuggestion:
'กรุณาเปิดใช้งาน Box (box.enabled = true) และตรวจสอบว่ารันไทม์ทำงานปกติ หรือเปลี่ยน MCP server เป็นโหมด http/sse',
boxRequired: 'ต้องใช้ Box',
disabledByPolicy: 'ถูกปิดใช้งานโดยนโยบาย',
stdioDisabledByPolicy:
'การติดตั้งใช้งานนี้ปิด Stdio MCP อยู่ โปรดใช้เซิร์ฟเวอร์ MCP แบบระยะไกล',
stdioBlockedByBoxToast:
'ไม่สามารถบันทึก MCP โหมด stdio เนื่องจาก Sandbox Box ถูกปิดใช้งานหรือไม่พร้อมใช้งาน กรุณาเปิดใช้งาน Box หรือเลือกโหมด http/sse',
toolsFound: 'เครื่องมือ',
+3
View File
@@ -823,6 +823,9 @@ const viVN = {
boxStdioRefusedSuggestion:
'Hãy bật Box (box.enabled = true) và đảm bảo runtime hoạt động, hoặc chuyển server này sang chế độ http/sse.',
boxRequired: 'cần Box',
disabledByPolicy: 'bị tắt theo chính sách',
stdioDisabledByPolicy:
'Stdio MCP đã bị tắt trong bản triển khai này. Hãy dùng máy chủ MCP từ xa.',
stdioBlockedByBoxToast:
'Không thể lưu MCP ở chế độ stdio khi Sandbox Box bị tắt hoặc không khả dụng. Hãy bật Box hoặc chọn chế độ http/sse.',
toolsFound: 'công cụ',
+2
View File
@@ -786,6 +786,8 @@ const zhHans = {
boxStdioRefusedSuggestion:
'请启用 Boxbox.enabled = true)并确认运行时连接正常,或将此服务器切换到 http/sse 模式。',
boxRequired: '需要 Box',
disabledByPolicy: '已被策略禁用',
stdioDisabledByPolicy: '此部署已禁用 Stdio MCP,请改用远程 MCP 服务器。',
stdioBlockedByBoxToast:
'Box 沙箱已禁用或不可用,无法保存 stdio 模式的 MCP。请启用 Box 或改为 http/sse 模式。',
toolsFound: '个工具',
+2
View File
@@ -784,6 +784,8 @@ const zhHant = {
boxStdioRefusedSuggestion:
'請啟用 Boxbox.enabled = true)並確認執行時連線正常,或將此伺服器切換到 http/sse 模式。',
boxRequired: '需要 Box',
disabledByPolicy: '已被策略停用',
stdioDisabledByPolicy: '此部署已停用 Stdio MCP,請改用遠端 MCP 伺服器。',
stdioBlockedByBoxToast:
'Box 沙箱已停用或無法使用,無法儲存 stdio 模式的 MCP。請啟用 Box 或改為 http/sse 模式。',
toolsFound: '個工具',