refactor(settings): simplify AI tool access configuration (#3957)
Move infrequent Tool Access / MCP / privacy / HTTP setup into focused dialogs; keep status and clear actions on the main page. Rebased onto current main after locale/settings conflicts. Codex CLEAN (gpt-6.1-terra). CI lint-and-test green. Focused privacy/MCP/guidance tests passed on rebase tip. No release.
This commit is contained in:
21 files changed
+1218
-414
No files matched your search
@@ -819,4 +819,27 @@ export const enAiMessages: Messages = {
|
||||
'zmodem.overwrite.skip': 'Skip',
|
||||
'zmodem.overwrite.cancel': 'Cancel',
|
||||
'settings.shortcuts.resetToDefault': 'Reset to default',
|
||||
"ai.toolSettings.immediate": "Changes take effect immediately. Closing this window does not undo them.",
|
||||
"ai.toolSettings.privacyOn": "Text redaction enabled",
|
||||
"ai.toolSettings.privacyOff": "Text redaction disabled",
|
||||
"ai.toolSettings.ruleSummary": "{cidrs} additional IP ranges · {patterns} custom patterns",
|
||||
"ai.toolSettings.configurePrivacy": "Configure privacy",
|
||||
"ai.toolSettings.unsaved": "Unsaved changes",
|
||||
"ai.toolSettings.viewSkills": "View CLI guidance",
|
||||
"ai.toolSettings.viewPrompt": "View setup prompt",
|
||||
"ai.toolSettings.behavior": "MCP behavior",
|
||||
"ai.toolSettings.configureBehavior": "Configure behavior",
|
||||
"ai.toolSettings.configureClients": "Configure clients",
|
||||
"ai.toolSettings.httpOn": "HTTP enabled",
|
||||
"ai.toolSettings.httpOff": "HTTP disabled",
|
||||
"ai.toolSettings.loopback": "Local connections only (127.0.0.1)",
|
||||
"ai.toolSettings.configureHttp": "Configure HTTP",
|
||||
"ai.toolSettings.hideSecret": "Hide secret",
|
||||
"ai.toolSettings.showSecret": "Show secret",
|
||||
"ai.toolSettings.manualSetup": "Manual setup",
|
||||
"ai.toolSettings.format": "Format",
|
||||
"ai.toolSettings.connectionDetails": "Connection details",
|
||||
"ai.toolSettings.conflict": "Settings changed in another window. Your draft is preserved. Cancel and reopen to load the saved settings.",
|
||||
"ai.toolSettings.retry": "Retry",
|
||||
"ai.toolSettings.applyingHttp": "Applying HTTP configuration...",
|
||||
};
|
||||
@@ -819,4 +819,27 @@ export const esAiMessages: Messages = {
|
||||
'zmodem.overwrite.skip': 'Omitir',
|
||||
'zmodem.overwrite.cancel': 'Cancelar',
|
||||
'settings.shortcuts.resetToDefault': 'Restablecer al predeterminado',
|
||||
"ai.toolSettings.immediate": "Los cambios se aplican inmediatamente. Cerrar esta ventana no los deshace.",
|
||||
"ai.toolSettings.privacyOn": "Ocultación de texto activada",
|
||||
"ai.toolSettings.privacyOff": "Ocultación de texto desactivada",
|
||||
"ai.toolSettings.ruleSummary": "{cidrs} rangos IP adicionales · {patterns} patrones personalizados",
|
||||
"ai.toolSettings.configurePrivacy": "Configurar privacidad",
|
||||
"ai.toolSettings.unsaved": "Cambios sin guardar",
|
||||
"ai.toolSettings.viewSkills": "Ver instrucciones de CLI",
|
||||
"ai.toolSettings.viewPrompt": "Ver instrucciones de conexión",
|
||||
"ai.toolSettings.behavior": "Comportamiento de MCP",
|
||||
"ai.toolSettings.configureBehavior": "Configurar comportamiento",
|
||||
"ai.toolSettings.configureClients": "Configurar clientes",
|
||||
"ai.toolSettings.httpOn": "HTTP activado",
|
||||
"ai.toolSettings.httpOff": "HTTP desactivado",
|
||||
"ai.toolSettings.loopback": "Solo conexiones locales (127.0.0.1)",
|
||||
"ai.toolSettings.configureHttp": "Configurar HTTP",
|
||||
"ai.toolSettings.hideSecret": "Ocultar secreto",
|
||||
"ai.toolSettings.showSecret": "Mostrar secreto",
|
||||
"ai.toolSettings.manualSetup": "Configuración manual",
|
||||
"ai.toolSettings.format": "Formato",
|
||||
"ai.toolSettings.connectionDetails": "Detalles de conexión",
|
||||
"ai.toolSettings.conflict": "La configuración cambió en otra ventana. Se conserva el borrador. Cancela y vuelve a abrir para cargar la configuración guardada.",
|
||||
"ai.toolSettings.retry": "Reintentar",
|
||||
"ai.toolSettings.applyingHttp": "Aplicando la configuración HTTP...",
|
||||
};
|
||||
@@ -748,4 +748,27 @@ export const ruAiMessages: Messages = {
|
||||
'zmodem.overwrite.skip': 'Пропустить',
|
||||
'zmodem.overwrite.cancel': 'Отмена',
|
||||
'settings.shortcuts.resetToDefault': 'Сбросить по умолчанию',
|
||||
"ai.toolSettings.immediate": "Изменения применяются сразу. Закрытие окна не отменяет их.",
|
||||
"ai.toolSettings.privacyOn": "Скрытие текста включено",
|
||||
"ai.toolSettings.privacyOff": "Скрытие текста выключено",
|
||||
"ai.toolSettings.ruleSummary": "Дополнительные диапазоны IP: {cidrs} · Пользовательские шаблоны: {patterns}",
|
||||
"ai.toolSettings.configurePrivacy": "Настроить приватность",
|
||||
"ai.toolSettings.unsaved": "Есть несохранённые изменения",
|
||||
"ai.toolSettings.viewSkills": "Инструкции CLI",
|
||||
"ai.toolSettings.viewPrompt": "Инструкции подключения",
|
||||
"ai.toolSettings.behavior": "Поведение MCP",
|
||||
"ai.toolSettings.configureBehavior": "Настроить поведение",
|
||||
"ai.toolSettings.configureClients": "Настроить клиенты",
|
||||
"ai.toolSettings.httpOn": "HTTP включён",
|
||||
"ai.toolSettings.httpOff": "HTTP выключен",
|
||||
"ai.toolSettings.loopback": "Только локальные подключения (127.0.0.1)",
|
||||
"ai.toolSettings.configureHttp": "Настроить HTTP",
|
||||
"ai.toolSettings.hideSecret": "Скрыть секрет",
|
||||
"ai.toolSettings.showSecret": "Показать секрет",
|
||||
"ai.toolSettings.manualSetup": "Ручная настройка",
|
||||
"ai.toolSettings.format": "Формат",
|
||||
"ai.toolSettings.connectionDetails": "Параметры подключения",
|
||||
"ai.toolSettings.conflict": "Настройки изменены в другом окне. Черновик сохранён. Отмените и откройте окно снова, чтобы загрузить сохранённые настройки.",
|
||||
"ai.toolSettings.retry": "Повторить",
|
||||
"ai.toolSettings.applyingHttp": "Применение настроек HTTP...",
|
||||
};
|
||||
@@ -819,4 +819,27 @@ export const zhCNAiMessages: Messages = {
|
||||
'zmodem.overwrite.skip': '跳过',
|
||||
'zmodem.overwrite.cancel': '取消',
|
||||
'settings.shortcuts.resetToDefault': '重置为默认',
|
||||
"ai.toolSettings.immediate": "更改立即生效,关闭此窗口不会撤销。",
|
||||
"ai.toolSettings.privacyOn": "已启用文本脱敏",
|
||||
"ai.toolSettings.privacyOff": "未启用文本脱敏",
|
||||
"ai.toolSettings.ruleSummary": "{cidrs} 个附加 IP 范围 · {patterns} 条自定义规则",
|
||||
"ai.toolSettings.configurePrivacy": "配置隐私",
|
||||
"ai.toolSettings.unsaved": "有未保存的更改",
|
||||
"ai.toolSettings.viewSkills": "查看 CLI 指引",
|
||||
"ai.toolSettings.viewPrompt": "查看接入提示词",
|
||||
"ai.toolSettings.behavior": "MCP 行为",
|
||||
"ai.toolSettings.configureBehavior": "配置行为",
|
||||
"ai.toolSettings.configureClients": "配置客户端",
|
||||
"ai.toolSettings.httpOn": "HTTP 已启用",
|
||||
"ai.toolSettings.httpOff": "HTTP 未启用",
|
||||
"ai.toolSettings.loopback": "仅本地连接(127.0.0.1)",
|
||||
"ai.toolSettings.configureHttp": "配置 HTTP",
|
||||
"ai.toolSettings.hideSecret": "隐藏密钥",
|
||||
"ai.toolSettings.showSecret": "显示密钥",
|
||||
"ai.toolSettings.manualSetup": "手动配置",
|
||||
"ai.toolSettings.format": "格式",
|
||||
"ai.toolSettings.connectionDetails": "连接详情",
|
||||
"ai.toolSettings.conflict": "设置已在另一窗口更改,当前草稿已保留。请取消并重新打开以加载已保存的设置。",
|
||||
"ai.toolSettings.retry": "重试",
|
||||
"ai.toolSettings.applyingHttp": "正在应用 HTTP 配置…",
|
||||
};
|
||||
@@ -809,4 +809,27 @@ export const zhTWAiMessages: Messages = {
|
||||
'zmodem.overwrite.skip': '跳過',
|
||||
'zmodem.overwrite.cancel': '取消',
|
||||
'settings.shortcuts.resetToDefault': '重設為預設值',
|
||||
"ai.toolSettings.immediate": "變更立即生效,關閉此視窗不會復原。",
|
||||
"ai.toolSettings.privacyOn": "已啟用文字遮蔽",
|
||||
"ai.toolSettings.privacyOff": "未啟用文字遮蔽",
|
||||
"ai.toolSettings.ruleSummary": "{cidrs} 個額外 IP 範圍 · {patterns} 條自訂規則",
|
||||
"ai.toolSettings.configurePrivacy": "設定隱私",
|
||||
"ai.toolSettings.unsaved": "有未儲存的變更",
|
||||
"ai.toolSettings.viewSkills": "查看 CLI 指引",
|
||||
"ai.toolSettings.viewPrompt": "查看接入提示詞",
|
||||
"ai.toolSettings.behavior": "MCP 行為",
|
||||
"ai.toolSettings.configureBehavior": "設定行為",
|
||||
"ai.toolSettings.configureClients": "設定用戶端",
|
||||
"ai.toolSettings.httpOn": "HTTP 已啟用",
|
||||
"ai.toolSettings.httpOff": "HTTP 未啟用",
|
||||
"ai.toolSettings.loopback": "僅本機連線(127.0.0.1)",
|
||||
"ai.toolSettings.configureHttp": "設定 HTTP",
|
||||
"ai.toolSettings.hideSecret": "隱藏金鑰",
|
||||
"ai.toolSettings.showSecret": "顯示金鑰",
|
||||
"ai.toolSettings.manualSetup": "手動設定",
|
||||
"ai.toolSettings.format": "格式",
|
||||
"ai.toolSettings.connectionDetails": "連線詳情",
|
||||
"ai.toolSettings.conflict": "設定已在其他視窗變更,目前草稿已保留。請取消並重新開啟以載入已儲存的設定。",
|
||||
"ai.toolSettings.retry": "重試",
|
||||
"ai.toolSettings.applyingHttp": "正在套用 HTTP 設定…",
|
||||
};
|
||||
@@ -0,0 +1,81 @@
|
||||
import assert from 'node:assert/strict';
|
||||
import test from 'node:test';
|
||||
import React from 'react';
|
||||
import { act, create, type ReactTestRenderer } from 'react-test-renderer';
|
||||
import { useToolAccessGuidanceState, type ToolAccessGuidanceState } from './useToolAccessGuidanceState';
|
||||
import { AI_STATE_CHANGED_EVENT } from './aiStateEvents';
|
||||
import { STORAGE_KEY_AI_EXTERNAL_MCP_ENABLED } from '../../infrastructure/config/storageKeys';
|
||||
import type { AIToolIntegrationMode } from '../../infrastructure/ai/types';
|
||||
|
||||
function deferred<T>() {
|
||||
let resolve!: (value: T) => void;
|
||||
const promise = new Promise<T>((done) => { resolve = done; });
|
||||
return { promise, resolve };
|
||||
}
|
||||
async function harness() {
|
||||
const requests: ReturnType<typeof deferred<unknown>>[] = [];
|
||||
const events = new EventTarget();
|
||||
const values = new Map([[STORAGE_KEY_AI_EXTERNAL_MCP_ENABLED, 'true']]);
|
||||
const previous = new Map<string, PropertyDescriptor | undefined>();
|
||||
const globals = {
|
||||
IS_REACT_ACT_ENVIRONMENT: true,
|
||||
localStorage: { getItem: (key: string) => values.get(key) ?? null, setItem: (key: string, value: string) => values.set(key, value) },
|
||||
window: {
|
||||
netcatty: {
|
||||
externalMcpGetStatus: () => { const request = deferred<unknown>(); requests.push(request); return request.promise; },
|
||||
aiSkillsCliGetInvocation: async () => ({ ok: true, skillPath: '/fixture/skills/SKILL.md', commandPrefix: 'fixture-cli' }),
|
||||
},
|
||||
addEventListener: events.addEventListener.bind(events), removeEventListener: events.removeEventListener.bind(events),
|
||||
dispatchEvent: events.dispatchEvent.bind(events), setTimeout, clearTimeout,
|
||||
},
|
||||
};
|
||||
for (const [key, value] of Object.entries(globals)) {
|
||||
previous.set(key, Object.getOwnPropertyDescriptor(globalThis, key));
|
||||
Object.defineProperty(globalThis, key, { value, configurable: true, writable: true });
|
||||
}
|
||||
let result!: ToolAccessGuidanceState;
|
||||
function Harness({ mode }: { mode: AIToolIntegrationMode }) { result = useToolAccessGuidanceState(mode); return null; }
|
||||
let renderer!: ReactTestRenderer;
|
||||
await act(async () => { renderer = create(<Harness mode="mcp" />); });
|
||||
return {
|
||||
requests, events, values, result: () => result,
|
||||
skills: async () => { await act(async () => renderer.update(<Harness mode="skills" />)); },
|
||||
close: async () => {
|
||||
await act(async () => renderer.unmount());
|
||||
for (const [key, value] of previous) { if (value) Object.defineProperty(globalThis, key, value); else Reflect.deleteProperty(globalThis, key); }
|
||||
},
|
||||
};
|
||||
}
|
||||
const status = (path: string) => ({ ok: true, enabled: true, state: 'running', launcherPath: path, discoveryPath: `${path}.json` });
|
||||
|
||||
test('older MCP guidance response cannot overwrite a newer enable-state refresh', async () => {
|
||||
const h = await harness();
|
||||
try {
|
||||
assert.equal(h.requests.length, 1);
|
||||
await act(async () => { h.events.dispatchEvent(new CustomEvent(AI_STATE_CHANGED_EVENT, { detail: { key: STORAGE_KEY_AI_EXTERNAL_MCP_ENABLED } })); });
|
||||
assert.equal(h.requests.length, 2);
|
||||
await act(async () => h.requests[1].resolve(status('/fixture/new')));
|
||||
assert.equal(h.result().mcpLauncherPath, '/fixture/new');
|
||||
await act(async () => h.requests[0].resolve(status('/fixture/old')));
|
||||
assert.equal(h.result().mcpLauncherPath, '/fixture/new');
|
||||
} finally { await h.close(); }
|
||||
});
|
||||
|
||||
test('switching to Skills ignores pending MCP results and preserves resolved Skills guidance', async () => {
|
||||
const h = await harness();
|
||||
try {
|
||||
await h.skills();
|
||||
await act(async () => h.requests[0].resolve(status('/fixture/stale')));
|
||||
assert.equal(h.result().mcpLauncherPath, null);
|
||||
assert.equal(h.result().skillPath, '/fixture/skills/SKILL.md');
|
||||
assert.equal(h.result().commandPrefix, 'fixture-cli');
|
||||
} finally { await h.close(); }
|
||||
});
|
||||
|
||||
test('MCP guidance completion after unmount has no visible state or retry work', async () => {
|
||||
const h = await harness();
|
||||
const initial = h.result();
|
||||
await h.close();
|
||||
await act(async () => h.requests[0].resolve(status('/fixture/stale')));
|
||||
assert.equal(h.result(), initial);
|
||||
});
|
||||
@@ -1,4 +1,4 @@
|
||||
import { useCallback, useEffect, useState } from 'react';
|
||||
import { useCallback, useEffect, useRef, useState } from 'react';
|
||||
import type { AIToolIntegrationMode } from '../../infrastructure/ai/types';
|
||||
import { STORAGE_KEY_AI_EXTERNAL_MCP_ENABLED } from '../../infrastructure/config/storageKeys';
|
||||
import { netcattyBridge } from '../../infrastructure/services/netcattyBridge';
|
||||
@@ -38,14 +38,15 @@ export type ToolAccessGuidanceState = {
|
||||
* runtime is still starting or after an idle shutdown).
|
||||
*/
|
||||
export function useToolAccessGuidanceState(mode: AIToolIntegrationMode): ToolAccessGuidanceState {
|
||||
const requestGeneration = useRef(0);
|
||||
const [skillPath, setSkillPath] = useState<string | null>(null);
|
||||
const [commandPrefix, setCommandPrefix] = useState('');
|
||||
const [mcpLauncherPath, setMcpLauncherPath] = useState<string | null>(null);
|
||||
const [mcpDiscoveryPath, setMcpDiscoveryPath] = useState<string | null>(null);
|
||||
|
||||
const refreshMcpStatus = useCallback(async (): Promise<string | null | undefined> => {
|
||||
const refreshMcpStatus = useCallback(async (isCurrent: () => boolean): Promise<string | null | undefined> => {
|
||||
const status = await netcattyBridge.get()?.externalMcpGetStatus?.();
|
||||
if (!status?.ok) return undefined;
|
||||
if (!isCurrent() || !status?.ok) return undefined;
|
||||
// Do not trust `enabled` alone: the main process flips `enabled` before
|
||||
// host credentials are resolved and the discovery file is written, and it
|
||||
// keeps reporting the configured discovery/launcher paths while starting.
|
||||
@@ -93,9 +94,11 @@ export function useToolAccessGuidanceState(mode: AIToolIntegrationMode): ToolAcc
|
||||
|
||||
const refetch = async () => {
|
||||
if (cancelled) return;
|
||||
const generation = ++requestGeneration.current;
|
||||
const isCurrent = () => !cancelled && generation === requestGeneration.current;
|
||||
const storedEnabled = readExternalMcpStoredEnabled();
|
||||
const readyLauncherPath = await refreshMcpStatus().catch(() => undefined);
|
||||
if (cancelled) return;
|
||||
const readyLauncherPath = await refreshMcpStatus(isCurrent).catch(() => undefined);
|
||||
if (!isCurrent()) return;
|
||||
// The persisted switch is off but the status check failed: drop any
|
||||
// cached launcher/discovery paths. The stored credentials were revoked
|
||||
// by the disable, and falling through to the comparison below would see
|
||||
|
||||
@@ -203,6 +203,7 @@ export const SettingCard: React.FC<SettingCardProps> = ({
|
||||
interface SettingRowProps {
|
||||
leading?: React.ReactNode;
|
||||
label?: React.ReactNode;
|
||||
className?: string;
|
||||
description?: React.ReactNode;
|
||||
children: React.ReactNode;
|
||||
align?: "center" | "start";
|
||||
@@ -213,6 +214,7 @@ interface SettingRowProps {
|
||||
export const SettingRow: React.FC<SettingRowProps> = ({
|
||||
leading,
|
||||
label,
|
||||
className,
|
||||
description,
|
||||
children,
|
||||
align = "center",
|
||||
@@ -225,6 +227,7 @@ export const SettingRow: React.FC<SettingRowProps> = ({
|
||||
// Keep square corners: rounded rows bend divide-y separators at both ends.
|
||||
"flex justify-between py-3 gap-4",
|
||||
align === "start" ? "items-start" : "items-center",
|
||||
className,
|
||||
)}
|
||||
>
|
||||
{leading && <div className="shrink-0">{leading}</div>}
|
||||
|
||||
@@ -1260,6 +1260,34 @@ const SettingsAITab: React.FC<SettingsAITabProps> = ({
|
||||
</TabsContent>
|
||||
|
||||
<TabsContent value="tools" className="m-0 space-y-6">
|
||||
|
||||
|
||||
<SettingsSection
|
||||
anchorId="ai-tool-access-mode"
|
||||
title={t('ai.toolAccess.title')}
|
||||
>
|
||||
<SettingCard>
|
||||
<SettingRow className="flex-col items-start md:flex-row md:items-center" description={t('ai.toolAccess.description')}>
|
||||
<div className="flex flex-wrap justify-end gap-2">
|
||||
<Select
|
||||
value={toolIntegrationMode}
|
||||
options={[
|
||||
{ value: 'mcp', label: t('ai.toolAccess.mode.mcp') },
|
||||
{ value: 'skills', label: t('ai.toolAccess.mode.skills') },
|
||||
]}
|
||||
onChange={(value) => setToolIntegrationMode(value as AIToolIntegrationMode)}
|
||||
className="w-48"
|
||||
/>
|
||||
<ToolAccessGuidance mode={toolIntegrationMode} />
|
||||
</div>
|
||||
</SettingRow>
|
||||
</SettingCard>
|
||||
</SettingsSection>
|
||||
|
||||
<ExternalMcpCard />
|
||||
|
||||
<AIPrivacySection />
|
||||
|
||||
<SettingsSection title={t('ai.chatShortcuts.title')}>
|
||||
<SettingCard divided>
|
||||
<SettingRow
|
||||
@@ -1276,34 +1304,6 @@ const SettingsAITab: React.FC<SettingsAITabProps> = ({
|
||||
</SettingCard>
|
||||
</SettingsSection>
|
||||
|
||||
<SettingsSection
|
||||
anchorId="ai-tool-access-mode"
|
||||
title={t('ai.toolAccess.title')}
|
||||
>
|
||||
<SettingCard>
|
||||
<SettingRow description={t('ai.toolAccess.description')}>
|
||||
<Select
|
||||
value={toolIntegrationMode}
|
||||
options={[
|
||||
{ value: 'mcp', label: t('ai.toolAccess.mode.mcp') },
|
||||
{ value: 'skills', label: t('ai.toolAccess.mode.skills') },
|
||||
]}
|
||||
onChange={(value) => setToolIntegrationMode(value as AIToolIntegrationMode)}
|
||||
className="w-48"
|
||||
/>
|
||||
</SettingRow>
|
||||
<div className="pb-4">
|
||||
<ToolAccessGuidance mode={toolIntegrationMode} />
|
||||
</div>
|
||||
</SettingCard>
|
||||
</SettingsSection>
|
||||
|
||||
<AIPrivacySection />
|
||||
|
||||
<SettingsSection anchorId="ai-external-mcp" title={t('ai.externalMcp.title')}>
|
||||
<ExternalMcpCard />
|
||||
</SettingsSection>
|
||||
|
||||
<SettingsSection
|
||||
anchorId="ai-user-skills"
|
||||
title={t('ai.userSkills.title')}
|
||||
|
||||
@@ -1,183 +1,154 @@
|
||||
import assert from 'node:assert/strict';
|
||||
import { readFile } from 'node:fs/promises';
|
||||
import test from 'node:test';
|
||||
import React from 'react';
|
||||
import { act, create, type ReactTestRenderer } from 'react-test-renderer';
|
||||
import { I18nProvider } from '../../../../application/i18n/I18nProvider';
|
||||
import React, { act } from 'react';
|
||||
import { JSDOM } from 'jsdom';
|
||||
import { STORAGE_KEY_AI_PRIVACY } from '../../../../infrastructure/config/storageKeys';
|
||||
import { AIPrivacySection } from './AIPrivacySection';
|
||||
|
||||
function installEnvironment(setConfig: (config: unknown) => Promise<unknown>) {
|
||||
const values = new Map<string, string>();
|
||||
const events = new EventTarget();
|
||||
const globals = {
|
||||
IS_REACT_ACT_ENVIRONMENT: true,
|
||||
localStorage: {
|
||||
getItem: (key: string) => values.get(key) ?? null,
|
||||
setItem: (key: string, value: string) => values.set(key, value),
|
||||
},
|
||||
window: {
|
||||
netcatty: { externalMcpSetConfig: setConfig },
|
||||
addEventListener: events.addEventListener.bind(events),
|
||||
removeEventListener: events.removeEventListener.bind(events),
|
||||
dispatchEvent: events.dispatchEvent.bind(events),
|
||||
},
|
||||
};
|
||||
const previous = new Map<string, PropertyDescriptor | undefined>();
|
||||
for (const [name, value] of Object.entries(globals)) {
|
||||
previous.set(name, Object.getOwnPropertyDescriptor(globalThis, name));
|
||||
Object.defineProperty(globalThis, name, { value, configurable: true, writable: true });
|
||||
}
|
||||
return {
|
||||
values,
|
||||
restore: () => {
|
||||
for (const [name, descriptor] of previous) {
|
||||
if (descriptor) Object.defineProperty(globalThis, name, descriptor);
|
||||
else Reflect.deleteProperty(globalThis, name);
|
||||
}
|
||||
},
|
||||
};
|
||||
const dom = new JSDOM('<div id="root"></div>', { url: 'http://localhost/#/settings' });
|
||||
for (const key of ['window', 'document', 'navigator', 'HTMLElement', 'HTMLInputElement', 'HTMLTextAreaElement', 'Element', 'Node', 'NodeFilter', 'MutationObserver', 'CustomEvent', 'Event', 'KeyboardEvent', 'MouseEvent', 'FocusEvent', 'getComputedStyle', 'localStorage'] as const) {
|
||||
Object.defineProperty(globalThis, key, { value: dom.window[key], configurable: true });
|
||||
}
|
||||
Object.assign(globalThis, { IS_REACT_ACT_ENVIRONMENT: true, requestAnimationFrame: (fn: () => void) => setTimeout(fn, 0), cancelAnimationFrame: clearTimeout });
|
||||
const { createRoot } = await import('react-dom/client');
|
||||
const { I18nProvider } = await import('../../../../application/i18n/I18nProvider');
|
||||
const { AIPrivacySection } = await import('./AIPrivacySection');
|
||||
|
||||
async function mount(setConfig: (config: unknown) => Promise<unknown> = async () => ({ ok: true }), saved?: string) {
|
||||
dom.window.localStorage.clear();
|
||||
if (saved) dom.window.localStorage.setItem(STORAGE_KEY_AI_PRIVACY, saved);
|
||||
Object.assign(window, { netcatty: { externalMcpSetConfig: setConfig } });
|
||||
const root = createRoot(document.getElementById('root')!);
|
||||
await act(async () => root.render(<I18nProvider locale="en"><AIPrivacySection /></I18nProvider>));
|
||||
return async () => { await act(async () => root.unmount()); };
|
||||
}
|
||||
const button = (text: string) => [...document.querySelectorAll<HTMLButtonElement>('button')].find((el) => el.textContent === text)!;
|
||||
const click = async (text: string) => { await act(async () => button(text).click()); };
|
||||
async function input(index: number, value: string) {
|
||||
const el = document.querySelectorAll('textarea')[index];
|
||||
await act(async () => {
|
||||
Object.getOwnPropertyDescriptor(dom.window.HTMLTextAreaElement.prototype, 'value')!.set!.call(el, value);
|
||||
el.dispatchEvent(new dom.window.Event('input', { bubbles: true }));
|
||||
});
|
||||
}
|
||||
|
||||
function view() {
|
||||
return <I18nProvider locale="en"><AIPrivacySection /></I18nProvider>;
|
||||
}
|
||||
|
||||
test('privacy settings default off and explain their limits and connection scope', async () => {
|
||||
const env = installEnvironment(async () => ({ ok: true }));
|
||||
let renderer: ReactTestRenderer | undefined;
|
||||
test('privacy overview is persisted state; configuration preserves the full scope and atomic draft', async () => {
|
||||
const unmount = await mount();
|
||||
try {
|
||||
await act(async () => { renderer = create(view()); });
|
||||
const markup = JSON.stringify(renderer!.toJSON());
|
||||
assert.equal(renderer!.root.findAllByType('button').filter((node) => node.props['aria-checked'] === false).length, 2);
|
||||
assert.match(markup, /cannot detect every secret/);
|
||||
assert.match(markup, /AI privacy/);
|
||||
assert.match(markup, /Catty, integrated agents and external MCP clients/);
|
||||
assert.match(markup, /Only Netcatty-managed data and tools are covered/);
|
||||
assert.match(markup, /native tools, direct file access and other MCP servers/);
|
||||
assert.match(markup, /Binary tool responses are blocked/);
|
||||
assert.match(markup, /one managed conversation or external MCP connection/);
|
||||
assert.match(markup, /reconnect external MCP clients/);
|
||||
assert.match(markup, /start a new managed conversation/);
|
||||
assert.match(markup, /Additional IP ranges/);
|
||||
assert.match(markup, /Custom regular expressions/);
|
||||
} finally {
|
||||
if (renderer) await act(async () => { renderer!.unmount(); });
|
||||
env.restore();
|
||||
}
|
||||
assert.equal(document.querySelector('textarea'), null);
|
||||
assert.match(document.body.textContent!, /Text redaction disabled/);
|
||||
await click('Configure privacy');
|
||||
const dialog = document.querySelector('[role="dialog"]')!;
|
||||
assert.match(dialog.textContent!, /cannot detect every secret/);
|
||||
assert.match(dialog.textContent!, /Catty, integrated agents and external MCP clients/);
|
||||
assert.match(dialog.textContent!, /Only Netcatty-managed data and tools are covered/);
|
||||
assert.match(dialog.textContent!, /native tools, direct file access and other MCP servers/);
|
||||
assert.match(dialog.textContent!, /Binary tool responses are blocked/);
|
||||
assert.match(dialog.textContent!, /one managed conversation or external MCP connection/);
|
||||
assert.match(dialog.textContent!, /reconnect external MCP clients/);
|
||||
assert.equal(dialog.querySelectorAll('[role="switch"][aria-checked="false"]').length, 2);
|
||||
assert.equal(button('Apply').disabled, true);
|
||||
} finally { await unmount(); }
|
||||
});
|
||||
|
||||
test('AI privacy is shared Tool Access UI, not nested under External MCP', async () => {
|
||||
const settingsTab = await readFile(new URL('../SettingsAITab.tsx', import.meta.url), 'utf8');
|
||||
const externalCard = await readFile(new URL('./ExternalMcpCard.tsx', import.meta.url), 'utf8');
|
||||
const toolsTab = settingsTab.split('<TabsContent value="tools"')[1].split('</TabsContent>')[0];
|
||||
assert.match(toolsTab, /<\/SettingsSection>\s*<AIPrivacySection \/>\s*<SettingsSection anchorId="ai-external-mcp"/);
|
||||
assert.doesNotMatch(externalCard, /AIPrivacySection|ExternalMcpRedactionSection/);
|
||||
});
|
||||
|
||||
test('privacy drafts apply together, retain rejected input and persist only validated settings', async () => {
|
||||
test('rejected privacy draft stays open and accepted draft commits once and returns focus', async () => {
|
||||
const calls: unknown[] = [];
|
||||
let accepted = false;
|
||||
const env = installEnvironment(async (config) => {
|
||||
calls.push(config);
|
||||
return accepted ? { ok: true } : { ok: false, error: 'Invalid privacy rules.' };
|
||||
});
|
||||
let renderer: ReactTestRenderer | undefined;
|
||||
const unmount = await mount(async (config) => { calls.push(config); return accepted ? { ok: true } : { ok: false, error: 'Invalid privacy rules.' }; });
|
||||
try {
|
||||
await act(async () => { renderer = create(view()); });
|
||||
const root = renderer!.root;
|
||||
const privacySwitch = () => root.findAllByType('button').find((node) => (
|
||||
node.props['aria-label'] === 'Enable text redaction'
|
||||
))!;
|
||||
const apply = () => root.findAllByType('button').find((node) => node.props.children === 'Apply')!;
|
||||
await act(async () => { privacySwitch().props.onClick(); });
|
||||
await act(async () => {
|
||||
root.findAllByType('textarea')[0].props.onChange({ currentTarget: { value: '192.0.2.0/24\n\n2001:db8::/32' } });
|
||||
root.findAllByType('textarea')[1].props.onChange({ currentTarget: { value: '[' } });
|
||||
});
|
||||
assert.equal(calls.length, 0, 'draft edits must not change runtime or storage');
|
||||
assert.equal(env.values.get(STORAGE_KEY_AI_PRIVACY), undefined);
|
||||
await act(async () => { apply().props.onClick(); });
|
||||
assert.equal(root.findAllByType('textarea')[1].props.value, '[');
|
||||
assert.match(root.findByProps({ role: 'alert' }).children.join(''), /Invalid privacy rules/);
|
||||
assert.equal(env.values.get(STORAGE_KEY_AI_PRIVACY), undefined);
|
||||
|
||||
const trigger = button('Configure privacy');
|
||||
trigger.focus();
|
||||
await click('Configure privacy');
|
||||
await act(async () => (document.querySelector('[aria-label="Enable text redaction"]') as HTMLButtonElement).click());
|
||||
await input(0, '192.0.2.0/24\n\n2001:db8::/32');
|
||||
await input(1, '[');
|
||||
assert.equal(calls.length, 0);
|
||||
assert.equal(dom.window.localStorage.getItem(STORAGE_KEY_AI_PRIVACY), null);
|
||||
await click('Apply');
|
||||
assert.match(document.querySelector('[role="alert"]')!.textContent!, /Invalid privacy rules/);
|
||||
assert.equal(document.querySelectorAll('textarea')[1].value, '[');
|
||||
assert.equal(dom.window.localStorage.getItem(STORAGE_KEY_AI_PRIVACY), null);
|
||||
accepted = true;
|
||||
await act(async () => {
|
||||
root.findAllByType('textarea')[1].props.onChange({ currentTarget: { value: 'INTERNAL_[A-Z0-9]+' } });
|
||||
});
|
||||
await act(async () => { apply().props.onClick(); });
|
||||
const saved = JSON.parse(env.values.get(STORAGE_KEY_AI_PRIVACY)!);
|
||||
assert.deepEqual(saved, {
|
||||
enabled: true,
|
||||
redactPrivateIps: false,
|
||||
cidrs: ['192.0.2.0/24', '2001:db8::/32'],
|
||||
patterns: ['INTERNAL_[A-Z0-9]+'],
|
||||
});
|
||||
await input(1, 'INTERNAL_[A-Z0-9]+');
|
||||
await click('Apply');
|
||||
const saved = JSON.parse(dom.window.localStorage.getItem(STORAGE_KEY_AI_PRIVACY)!);
|
||||
assert.deepEqual(saved, { enabled: true, redactPrivateIps: false, cidrs: ['192.0.2.0/24', '2001:db8::/32'], patterns: ['INTERNAL_[A-Z0-9]+'] });
|
||||
assert.deepEqual(calls[1], { redaction: saved });
|
||||
assert.equal(root.findAllByProps({ role: 'alert' }).length, 0);
|
||||
assert.match(root.findByProps({ role: 'status' }).children.join(''), /new managed conversation/);
|
||||
} finally {
|
||||
if (renderer) await act(async () => { renderer!.unmount(); });
|
||||
env.restore();
|
||||
}
|
||||
assert.equal(document.querySelector('[role="dialog"]'), null);
|
||||
assert.equal(document.activeElement, trigger);
|
||||
assert.match(document.querySelector('[role="status"]')!.textContent!, /new managed conversation/);
|
||||
await click('Configure privacy');
|
||||
assert.equal(document.querySelectorAll('textarea')[1].value, 'INTERNAL_[A-Z0-9]+');
|
||||
} finally { await unmount(); }
|
||||
});
|
||||
|
||||
test('malformed saved privacy settings remain visible as a recoverable error', async () => {
|
||||
const env = installEnvironment(async () => ({ ok: true }));
|
||||
let renderer: ReactTestRenderer | undefined;
|
||||
test('Cancel, X and Escape discard edits and reopening reads saved values', async () => {
|
||||
let calls = 0;
|
||||
const unmount = await mount(async () => { calls += 1; return { ok: true }; });
|
||||
try {
|
||||
env.values.set(STORAGE_KEY_AI_PRIVACY, '{');
|
||||
await act(async () => { renderer = create(view()); });
|
||||
assert.match(renderer!.root.findByProps({ role: 'alert' }).children.join(''), /Invalid saved AI privacy settings/);
|
||||
assert.ok(renderer!.root.findAllByType('button').some((node) => node.props.children === 'Apply'));
|
||||
assert.equal(env.values.get(STORAGE_KEY_AI_PRIVACY), '{');
|
||||
} finally {
|
||||
if (renderer) await act(async () => { renderer!.unmount(); });
|
||||
env.restore();
|
||||
}
|
||||
for (const close of ['Cancel', 'X', 'Escape']) {
|
||||
await click('Configure privacy');
|
||||
await input(0, '192.0.2.0/24');
|
||||
assert.match(document.querySelector('[role="status"]')!.textContent!, /Unsaved changes/);
|
||||
if (close === 'Cancel') await click('Cancel');
|
||||
else if (close === 'X') await act(async () => (document.querySelector('[data-dialog-close]:not([aria-hidden])') as HTMLButtonElement).click());
|
||||
else await act(async () => { document.dispatchEvent(new dom.window.KeyboardEvent('keydown', { key: 'Escape', bubbles: true })); });
|
||||
assert.equal(document.querySelector('[role="dialog"]'), null);
|
||||
await click('Configure privacy');
|
||||
assert.equal(document.querySelector('textarea')!.value, '');
|
||||
await click('Cancel');
|
||||
}
|
||||
assert.equal(calls, 0);
|
||||
assert.equal(dom.window.localStorage.getItem(STORAGE_KEY_AI_PRIVACY), null);
|
||||
} finally { await unmount(); }
|
||||
});
|
||||
|
||||
test('privacy apply disables draft controls until shared validation settles', async () => {
|
||||
let resolveConfig!: (result: unknown) => void;
|
||||
const env = installEnvironment(() => new Promise((resolve) => { resolveConfig = resolve; }));
|
||||
let renderer: ReactTestRenderer | undefined;
|
||||
test('busy Apply blocks duplicate submits, edits and Escape', async () => {
|
||||
let resolve!: (value: unknown) => void;
|
||||
let calls = 0;
|
||||
const unmount = await mount(() => { calls += 1; return new Promise((done) => { resolve = done; }); });
|
||||
try {
|
||||
await act(async () => { renderer = create(view()); });
|
||||
const root = renderer!.root;
|
||||
await act(async () => {
|
||||
root.findAllByType('button').find((node) => node.props['aria-label'] === 'Enable text redaction')!.props.onClick();
|
||||
});
|
||||
await act(async () => {
|
||||
root.findAllByType('button').find((node) => node.props.children === 'Apply')!.props.onClick();
|
||||
});
|
||||
assert.ok(root.findAllByType('button').every((node) => node.props.disabled));
|
||||
assert.ok(root.findAllByType('textarea').every((node) => node.props.disabled));
|
||||
assert.equal(env.values.get(STORAGE_KEY_AI_PRIVACY), undefined);
|
||||
await act(async () => { resolveConfig({ ok: true }); });
|
||||
assert.ok(root.findAllByType('button').every((node) => !node.props.disabled));
|
||||
assert.equal(JSON.parse(env.values.get(STORAGE_KEY_AI_PRIVACY)!).enabled, true);
|
||||
} finally {
|
||||
if (renderer) await act(async () => { renderer!.unmount(); });
|
||||
env.restore();
|
||||
}
|
||||
await click('Configure privacy');
|
||||
await input(0, '192.0.2.0/24');
|
||||
await click('Apply');
|
||||
assert.ok([...document.querySelectorAll('[role="dialog"] textarea, [role="dialog"] [role="switch"]')].every((el) => (el as HTMLInputElement).disabled));
|
||||
assert.equal(button('Cancel').disabled, true);
|
||||
await act(async () => { button('Applying...').click(); document.dispatchEvent(new dom.window.KeyboardEvent('keydown', { key: 'Escape', bubbles: true })); });
|
||||
assert.ok(document.querySelector('[role="dialog"]'));
|
||||
assert.equal(calls, 1);
|
||||
await act(async () => { resolve({ ok: true }); });
|
||||
assert.equal(document.querySelector('[role="dialog"]'), null);
|
||||
} finally { await unmount(); }
|
||||
});
|
||||
|
||||
test('shared privacy controls refresh when another window changes the shared preference', async () => {
|
||||
const env = installEnvironment(async () => ({ ok: true }));
|
||||
let renderer: ReactTestRenderer | undefined;
|
||||
test('malformed saved settings remain visible and repairable without being rewritten on open', async () => {
|
||||
const unmount = await mount(undefined, '{');
|
||||
try {
|
||||
await act(async () => { renderer = create(view()); });
|
||||
const settings = { enabled: true, redactPrivateIps: true, cidrs: ['192.0.2.0/24'], patterns: ['INTERNAL_.*'] };
|
||||
env.values.set(STORAGE_KEY_AI_PRIVACY, JSON.stringify(settings));
|
||||
const event = new Event('storage');
|
||||
Object.defineProperty(event, 'key', { value: STORAGE_KEY_AI_PRIVACY });
|
||||
await act(async () => { window.dispatchEvent(event); });
|
||||
assert.equal(renderer!.root.findAllByType('button').filter((node) => node.props['aria-checked'] === true).length, 2);
|
||||
assert.equal(renderer!.root.findAllByType('textarea')[0].props.value, '192.0.2.0/24');
|
||||
assert.equal(renderer!.root.findAllByType('textarea')[1].props.value, 'INTERNAL_.*');
|
||||
} finally {
|
||||
if (renderer) await act(async () => { renderer!.unmount(); });
|
||||
env.restore();
|
||||
}
|
||||
assert.match(document.querySelector('[role="alert"]')!.textContent!, /Invalid saved AI privacy settings/);
|
||||
await click('Configure privacy');
|
||||
assert.equal(button('Apply').disabled, false);
|
||||
assert.equal(dom.window.localStorage.getItem(STORAGE_KEY_AI_PRIVACY), '{');
|
||||
} finally { await unmount(); }
|
||||
});
|
||||
|
||||
test('cross-window changes refresh clean drafts but preserve dirty drafts with a conflict notice', async () => {
|
||||
const unmount = await mount();
|
||||
const settings = { enabled: true, redactPrivateIps: true, cidrs: ['192.0.2.0/24'], patterns: ['INTERNAL_.*'] };
|
||||
const update = async () => {
|
||||
dom.window.localStorage.setItem(STORAGE_KEY_AI_PRIVACY, JSON.stringify(settings));
|
||||
await act(async () => { window.dispatchEvent(new dom.window.StorageEvent('storage', { key: STORAGE_KEY_AI_PRIVACY })); });
|
||||
};
|
||||
try {
|
||||
await click('Configure privacy');
|
||||
await update();
|
||||
assert.equal(document.querySelector('textarea')!.value, '192.0.2.0/24');
|
||||
await input(0, '198.51.100.0/24');
|
||||
settings.cidrs = ['203.0.113.0/24'];
|
||||
await update();
|
||||
assert.equal(document.querySelector('textarea')!.value, '198.51.100.0/24');
|
||||
assert.match(document.querySelector('[role="alert"]')!.textContent!, /another window/);
|
||||
assert.equal(button('Apply').disabled, true);
|
||||
await click('Cancel');
|
||||
await click('Configure privacy');
|
||||
assert.equal(document.querySelector('textarea')!.value, '203.0.113.0/24');
|
||||
} finally { await unmount(); }
|
||||
});
|
||||
@@ -1,7 +1,8 @@
|
||||
import React, { useEffect, useState } from "react";
|
||||
import React, { useEffect, useRef, useState } from "react";
|
||||
import { useI18n } from "../../../../application/i18n/I18nProvider";
|
||||
import { useAIPrivacyState } from "../../../../application/state/useExternalMcpToggleState";
|
||||
import { Button } from "../../../ui/button";
|
||||
import { ToolSettingsDialog } from "./ToolSettingsDialog";
|
||||
import { SettingCard, SettingRow, SettingsSection, Toggle } from "../../settings-ui";
|
||||
|
||||
export const AIPrivacySection: React.FC = () => {
|
||||
@@ -12,8 +13,32 @@ export const AIPrivacySection: React.FC = () => {
|
||||
const [cidrs, setCidrs] = useState(settings.cidrs.join("\n"));
|
||||
const [patterns, setPatterns] = useState(settings.patterns.join("\n"));
|
||||
const [applied, setApplied] = useState(false);
|
||||
const [open, setOpen] = useState(false);
|
||||
const errorRef = useRef<HTMLParagraphElement>(null);
|
||||
useEffect(() => { if (open && error) errorRef.current?.focus(); }, [open, error]);
|
||||
const [draftBase, setDraftBase] = useState(settings);
|
||||
const [conflict, setConflict] = useState(false);
|
||||
const dirty = enabled !== draftBase.enabled || privateIps !== draftBase.redactPrivateIps
|
||||
|| cidrs !== draftBase.cidrs.join("\n") || patterns !== draftBase.patterns.join("\n");
|
||||
const changeOpen = (next: boolean) => {
|
||||
if (applying) return;
|
||||
setDraftBase(settings);
|
||||
setEnabled(settings.enabled);
|
||||
setPrivateIps(settings.redactPrivateIps);
|
||||
setCidrs(settings.cidrs.join("\n"));
|
||||
setPatterns(settings.patterns.join("\n"));
|
||||
setConflict(false);
|
||||
setOpen(next);
|
||||
};
|
||||
|
||||
const draftState = useRef({ open, dirty, applying });
|
||||
draftState.current = { open, dirty, applying };
|
||||
useEffect(() => {
|
||||
if (draftState.current.open && draftState.current.dirty && !draftState.current.applying) {
|
||||
setConflict(true);
|
||||
return;
|
||||
}
|
||||
setDraftBase(settings);
|
||||
setEnabled(settings.enabled);
|
||||
setPrivateIps(settings.redactPrivateIps);
|
||||
setCidrs(settings.cidrs.join("\n"));
|
||||
@@ -21,6 +46,7 @@ export const AIPrivacySection: React.FC = () => {
|
||||
}, [settings]);
|
||||
|
||||
const onApply = async () => {
|
||||
if (applying || conflict) return;
|
||||
setApplied(false);
|
||||
const saved = await apply({
|
||||
enabled,
|
||||
@@ -29,13 +55,36 @@ export const AIPrivacySection: React.FC = () => {
|
||||
patterns: patterns.split(/\r?\n/).filter((line) => line.trim().length > 0),
|
||||
});
|
||||
setApplied(saved);
|
||||
if (saved) setOpen(false);
|
||||
};
|
||||
|
||||
return (
|
||||
<SettingsSection anchorId="ai-privacy" title={t("ai.privacy.title")}>
|
||||
<p className="text-xs text-muted-foreground leading-5">{t("ai.privacy.description")}</p>
|
||||
<p className="text-xs text-muted-foreground leading-5">{t("ai.privacy.boundary")}</p>
|
||||
<SettingCard divided className="rounded-md border-border/60 bg-background/70">
|
||||
<SettingCard>
|
||||
<SettingRow
|
||||
className="flex-col items-start md:flex-row md:items-center"
|
||||
label={t(settings.enabled ? "ai.toolSettings.privacyOn" : "ai.toolSettings.privacyOff")}
|
||||
description={`${t("ai.privacy.privateIps")}: ${t(settings.redactPrivateIps ? "common.enabled" : "common.disabled")} · ${t("ai.toolSettings.ruleSummary", { cidrs: String(settings.cidrs.length), patterns: String(settings.patterns.length) })}`}
|
||||
>
|
||||
<ToolSettingsDialog
|
||||
title={t("ai.privacy.title")}
|
||||
triggerLabel={t("ai.toolSettings.configurePrivacy")}
|
||||
open={open}
|
||||
onOpenChange={changeOpen}
|
||||
busy={applying}
|
||||
footer={<>
|
||||
{dirty ? <span role="status" className="mr-auto self-center text-xs text-muted-foreground">{t("ai.toolSettings.unsaved")}</span> : null}
|
||||
<Button type="button" variant="outline" onClick={() => changeOpen(false)} disabled={applying}>{t("common.cancel")}</Button>
|
||||
<Button type="button" onClick={() => { void onApply(); }} disabled={applying || conflict || (!dirty && !error)}>
|
||||
{t(applying ? "ai.privacy.applying" : "ai.privacy.apply")}
|
||||
</Button>
|
||||
</>}
|
||||
>
|
||||
<p className="text-xs text-muted-foreground leading-5">{t("ai.privacy.description")}</p>
|
||||
<p className="text-xs text-muted-foreground leading-5">{t("ai.privacy.boundary")}</p>
|
||||
<SettingCard divided className="border-0 rounded-none bg-transparent p-0">
|
||||
<SettingRow label={t("ai.privacy.enable")}>
|
||||
<Toggle
|
||||
checked={enabled}
|
||||
@@ -56,9 +105,9 @@ export const AIPrivacySection: React.FC = () => {
|
||||
/>
|
||||
</SettingRow>
|
||||
</SettingCard>
|
||||
<div className="space-y-3 rounded-md border border-border/60 bg-background/50 p-3">
|
||||
<div className="space-y-4">
|
||||
<label className="block space-y-1.5">
|
||||
<span className="text-xs font-medium">{t("ai.privacy.cidrs")}</span>
|
||||
<span className="text-sm font-medium">{t("ai.privacy.cidrs")}</span>
|
||||
<span className="block text-xs text-muted-foreground">{t("ai.privacy.cidrs.description")}</span>
|
||||
<textarea
|
||||
value={cidrs}
|
||||
@@ -72,7 +121,7 @@ export const AIPrivacySection: React.FC = () => {
|
||||
/>
|
||||
</label>
|
||||
<label className="block space-y-1.5">
|
||||
<span className="text-xs font-medium">{t("ai.privacy.patterns")}</span>
|
||||
<span className="text-sm font-medium">{t("ai.privacy.patterns")}</span>
|
||||
<span className="block text-xs text-muted-foreground">{t("ai.privacy.patterns.description")}</span>
|
||||
<textarea
|
||||
value={patterns}
|
||||
@@ -86,20 +135,15 @@ export const AIPrivacySection: React.FC = () => {
|
||||
/>
|
||||
</label>
|
||||
<p className="text-xs text-muted-foreground leading-5">{t("ai.privacy.connectionScope")}</p>
|
||||
<div className="flex items-center justify-between gap-3">
|
||||
<p className="text-xs text-amber-500">{t("ai.privacy.reconnect")}</p>
|
||||
<Button size="sm" variant="outline" onClick={() => { void onApply(); }} disabled={applying}>
|
||||
{t(applying ? "ai.privacy.applying" : "ai.privacy.apply")}
|
||||
</Button>
|
||||
</div>
|
||||
{error ? (
|
||||
<p role="alert" className="text-xs text-destructive">
|
||||
{t("ai.privacy.failed")} {error}
|
||||
</p>
|
||||
) : applied ? (
|
||||
<p role="status" className="text-xs text-emerald-500">{t("ai.privacy.applied")}</p>
|
||||
) : null}
|
||||
<p className="text-xs text-amber-500">{t("ai.privacy.reconnect")}</p>
|
||||
</div>
|
||||
{conflict ? <p role="alert" className="text-xs text-destructive">{t("ai.toolSettings.conflict")}</p> : null}
|
||||
{error ? <p ref={errorRef} tabIndex={-1} role="alert" className="text-xs text-destructive">{t("ai.privacy.failed")} {error}</p> : null}
|
||||
</ToolSettingsDialog>
|
||||
</SettingRow>
|
||||
</SettingCard>
|
||||
{error && !open ? <p role="alert" className="text-xs text-destructive">{t("ai.privacy.failed")} {error}</p>
|
||||
: applied ? <p role="status" className="text-xs text-emerald-500">{t("ai.privacy.applied")}</p> : null}
|
||||
</SettingsSection>
|
||||
);
|
||||
};
|
||||
@@ -1,10 +1,12 @@
|
||||
import React from "react";
|
||||
import { Check, Copy } from "lucide-react";
|
||||
import { Check, Copy, Eye, EyeOff } from "lucide-react";
|
||||
import { cn } from "../../../../lib/utils";
|
||||
import { useI18n } from "../../../../application/i18n/I18nProvider";
|
||||
import { Button } from "../../../ui/button";
|
||||
|
||||
type CopyableCodeBlockProps = {
|
||||
label?: string;
|
||||
sensitive?: boolean;
|
||||
value: string;
|
||||
copyKey: string;
|
||||
copied: string | null;
|
||||
@@ -17,6 +19,7 @@ type CopyableCodeBlockProps = {
|
||||
|
||||
export const CopyableCodeBlock: React.FC<CopyableCodeBlockProps> = ({
|
||||
label,
|
||||
sensitive = false,
|
||||
value,
|
||||
copyKey,
|
||||
copied,
|
||||
@@ -26,7 +29,9 @@ export const CopyableCodeBlock: React.FC<CopyableCodeBlockProps> = ({
|
||||
emptyLabel,
|
||||
className,
|
||||
}) => {
|
||||
const display = value || emptyLabel || "";
|
||||
const { t } = useI18n();
|
||||
const [revealed, setRevealed] = React.useState(false);
|
||||
const display = sensitive && value && !revealed ? "••••••••" : value || emptyLabel || "";
|
||||
const canCopy = Boolean(value);
|
||||
const isCopied = copied === copyKey;
|
||||
|
||||
@@ -38,12 +43,15 @@ export const CopyableCodeBlock: React.FC<CopyableCodeBlockProps> = ({
|
||||
<div className="group relative rounded-md border border-border/60 bg-muted/20">
|
||||
<pre
|
||||
className={cn(
|
||||
"max-h-40 overflow-auto whitespace-pre-wrap break-all px-3 py-2.5 pr-11 font-mono text-xs leading-5",
|
||||
"max-h-40 overflow-auto whitespace-pre-wrap break-all px-3 py-2.5 pr-20 font-mono text-xs leading-5",
|
||||
!value && "text-muted-foreground",
|
||||
)}
|
||||
>
|
||||
{display}
|
||||
</pre>
|
||||
{sensitive && value ? <Button type="button" variant="ghost" size="sm" className="absolute right-10 top-1.5 h-7 w-7 p-0" onClick={() => setRevealed(!revealed)} aria-label={t(revealed ? "ai.toolSettings.hideSecret" : "ai.toolSettings.showSecret")}>
|
||||
{revealed ? <EyeOff size={14} /> : <Eye size={14} />}
|
||||
</Button> : null}
|
||||
<Button
|
||||
type="button"
|
||||
variant="ghost"
|
||||
|
||||
@@ -0,0 +1,133 @@
|
||||
import assert from 'node:assert/strict';
|
||||
import test from 'node:test';
|
||||
import React, { act } from 'react';
|
||||
import { JSDOM } from 'jsdom';
|
||||
import { installAiToolAccessFixture } from '../../../../scripts/fixtures/ai-tool-access/bridge.cjs';
|
||||
|
||||
const dom = new JSDOM('<div id="root"></div>', { url: 'http://localhost/#/settings' });
|
||||
for (const key of ['window', 'document', 'location', 'navigator', 'HTMLElement', 'HTMLInputElement', 'Element', 'Node', 'NodeFilter', 'MutationObserver', 'CustomEvent', 'Event', 'KeyboardEvent', 'MouseEvent', 'FocusEvent', 'getComputedStyle', 'localStorage'] as const) {
|
||||
Object.defineProperty(globalThis, key, { value: dom.window[key], configurable: true });
|
||||
}
|
||||
Object.assign(globalThis, { IS_REACT_ACT_ENVIRONMENT: true, requestAnimationFrame: (fn: () => void) => setTimeout(fn, 0), cancelAnimationFrame: clearTimeout });
|
||||
const { createRoot } = await import('react-dom/client');
|
||||
const { I18nProvider } = await import('../../../../application/i18n/I18nProvider');
|
||||
const { TooltipProvider } = await import('../../../ui/tooltip');
|
||||
const { ExternalMcpCard } = await import('./ExternalMcpCard');
|
||||
|
||||
type FixtureControl = { rejectHttp: boolean; throwHttp: boolean; holdHttp: boolean; pendingHttp: (() => void)[]; pendingClients: (() => void)[]; rejectRotate: boolean; throwRotate: boolean; calls: { name: string; args: unknown[] }[] };
|
||||
async function mount(options: { enabled?: boolean; holdClients?: boolean } = {}, openHttp = true) {
|
||||
installAiToolAccessFixture({ locale: 'en', enabled: true, ...options });
|
||||
Object.defineProperty(navigator, 'clipboard', { configurable: true, value: { writeText: async () => {} } });
|
||||
const qa = (window as unknown as { __aiToolQa: FixtureControl }).__aiToolQa;
|
||||
const root = createRoot(document.getElementById('root')!);
|
||||
await act(async () => root.render(<I18nProvider locale="en"><TooltipProvider><ExternalMcpCard /></TooltipProvider></I18nProvider>));
|
||||
if (openHttp) await click('Configure HTTP');
|
||||
return { qa, close: async () => { await act(async () => root.unmount()); } };
|
||||
}
|
||||
const dialog = () => document.querySelector<HTMLElement>('[role="dialog"]')!;
|
||||
const button = (text: string, scope: ParentNode = document) => [...scope.querySelectorAll<HTMLButtonElement>('button')].find((el) => el.textContent === text)!;
|
||||
const click = async (text: string) => { await act(async () => button(text).click()); };
|
||||
async function changePort(value: string) {
|
||||
const port = dialog().querySelector<HTMLInputElement>('[aria-label="Port"]')!;
|
||||
await act(async () => {
|
||||
port.focus();
|
||||
Object.getOwnPropertyDescriptor(dom.window.HTMLInputElement.prototype, 'value')!.set!.call(port, value);
|
||||
port.dispatchEvent(new dom.window.Event('input', { bubbles: true }));
|
||||
});
|
||||
await act(async () => port.blur());
|
||||
return port;
|
||||
}
|
||||
|
||||
for (const failure of ['returned', 'thrown'] as const) {
|
||||
test(`HTTP ${failure} configuration failure is visible inside the dialog, preserves input and can retry`, async () => {
|
||||
const h = await mount();
|
||||
try {
|
||||
h.qa.rejectHttp = failure === 'returned';
|
||||
h.qa.throwHttp = failure === 'thrown';
|
||||
const port = await changePort('47401');
|
||||
const message = failure === 'returned' ? 'Fixture: port unavailable.' : 'Fixture: HTTP configuration threw.';
|
||||
const alert = dialog().querySelector('[role="alert"]')!;
|
||||
assert.match(alert.textContent!, new RegExp(message.replaceAll('.', '\\.')));
|
||||
assert.equal(document.activeElement, alert, 'the error must be reachable without scrolling back manually');
|
||||
assert.equal(port.value, '47401');
|
||||
assert.ok(document.querySelector('[role="dialog"]'));
|
||||
assert.match(document.querySelector('[data-settings-anchor="ai-external-mcp-http"]')!.textContent!, /Fixture:/);
|
||||
const beforeRetry = h.qa.calls.filter((call) => call.name === 'config').length;
|
||||
h.qa.rejectHttp = false;
|
||||
h.qa.throwHttp = false;
|
||||
await click('Retry');
|
||||
assert.equal(h.qa.calls.filter((call) => call.name === 'config').length, beforeRetry + 1);
|
||||
assert.equal(dialog().querySelector('[role="alert"]'), null);
|
||||
assert.equal(port.value, '47401');
|
||||
} finally { await h.close(); }
|
||||
});
|
||||
}
|
||||
|
||||
for (const failure of ['returned', 'thrown'] as const) {
|
||||
test(`HTTP ${failure} token rotation failure stays in the active dialog and retries explicitly`, async () => {
|
||||
const h = await mount();
|
||||
try {
|
||||
await act(async () => (dialog().querySelector('[aria-label="Enable HTTP"]') as HTMLButtonElement).click());
|
||||
await act(async () => dialog().querySelector('summary')!.click());
|
||||
h.qa.rejectRotate = failure === 'returned';
|
||||
h.qa.throwRotate = failure === 'thrown';
|
||||
await click('Regenerate');
|
||||
assert.match(dialog().querySelector('[role="alert"]')!.textContent!, /Fixture: token rotation/);
|
||||
assert.equal(dialog().textContent!.includes('SYNTHETIC_FIXTURE_TOKEN_NEVER_REAL'), false);
|
||||
h.qa.rejectRotate = false;
|
||||
h.qa.throwRotate = false;
|
||||
await click('Retry');
|
||||
assert.equal(dialog().querySelector('[role="alert"]'), null);
|
||||
assert.equal(dialog().textContent!.includes('SYNTHETIC_ROTATED_FIXTURE_TOKEN'), false);
|
||||
} finally { await h.close(); }
|
||||
});
|
||||
}
|
||||
|
||||
test('HTTP clipboard failure is visible in the dialog and clears only after another copy attempt', async () => {
|
||||
const h = await mount();
|
||||
try {
|
||||
await act(async () => dialog().querySelector('summary')!.click());
|
||||
Object.defineProperty(navigator, 'clipboard', { configurable: true, value: { writeText: async () => { throw new Error('Fixture clipboard failure'); } } });
|
||||
const copy = () => dialog().querySelector<HTMLButtonElement>('[aria-label="Copy"]:not([disabled])')!;
|
||||
await act(async () => copy().click());
|
||||
assert.match(dialog().querySelector('[role="alert"]')!.textContent!, /copy/i);
|
||||
Object.defineProperty(navigator, 'clipboard', { configurable: true, value: { writeText: async () => {} } });
|
||||
await act(async () => copy().click());
|
||||
assert.equal(dialog().querySelector('[role="alert"]'), null);
|
||||
} finally { await h.close(); }
|
||||
});
|
||||
|
||||
for (const failure of ['returned', 'thrown'] as const) {
|
||||
test(`a completed runtime poll cannot suppress a pending HTTP ${failure} failure`, async () => {
|
||||
const h = await mount();
|
||||
try {
|
||||
h.qa.holdHttp = true;
|
||||
h.qa.rejectHttp = failure === 'returned';
|
||||
h.qa.throwHttp = failure === 'thrown';
|
||||
const beforePoll = h.qa.calls.filter((call) => call.name === 'status').length;
|
||||
const port = await changePort('47402');
|
||||
assert.match(dialog().querySelector('[role="status"]')!.textContent!, /Applying HTTP/);
|
||||
await act(async () => { await new Promise((resolve) => setTimeout(resolve, 3200)); });
|
||||
assert.ok(h.qa.calls.filter((call) => call.name === 'status').length > beforePoll);
|
||||
await act(async () => h.qa.pendingHttp.splice(0).forEach((resolve) => resolve()));
|
||||
assert.match(dialog().querySelector('[role="alert"]')!.textContent!, /Fixture:/);
|
||||
assert.equal(port.value, '47402');
|
||||
assert.equal(dialog().querySelector('[role="status"]'), null);
|
||||
} finally { await h.close(); }
|
||||
});
|
||||
}
|
||||
|
||||
test('completed MCP enable can be turned off while unrelated client discovery is still pending', async () => {
|
||||
const h = await mount({ enabled: false, holdClients: true }, false);
|
||||
try {
|
||||
const toggle = document.querySelector<HTMLButtonElement>('[role="switch"][aria-label="External MCP"]')!;
|
||||
await act(async () => toggle.click());
|
||||
assert.equal(toggle.getAttribute('aria-checked'), 'true');
|
||||
assert.ok(h.qa.pendingClients.length > 0, 'client discovery remains intentionally unresolved');
|
||||
assert.equal(toggle.disabled, false, 'successful enable must release the switch before client discovery finishes');
|
||||
await act(async () => toggle.click());
|
||||
assert.equal(toggle.getAttribute('aria-checked'), 'false');
|
||||
assert.equal(toggle.disabled, false);
|
||||
assert.deepEqual(h.qa.calls.filter((call) => call.name === 'enable').map((call) => call.args[0]), [true, false]);
|
||||
} finally { await h.close(); }
|
||||
});
|
||||
@@ -1,4 +1,4 @@
|
||||
import React, { useCallback, useEffect, useMemo, useState } from "react";
|
||||
import React, { useCallback, useEffect, useMemo, useRef, useState } from "react";
|
||||
import { HelpCircle, RefreshCw } from "lucide-react";
|
||||
import { useI18n } from "../../../../application/i18n/I18nProvider";
|
||||
import {
|
||||
@@ -31,8 +31,13 @@ import {
|
||||
} from "../../../../application/state/useExternalMcpToggleState";
|
||||
import { cn } from "../../../../lib/utils";
|
||||
import { Button } from "../../../ui/button";
|
||||
import { Tabs, TabsContent, TabsList, TabsTrigger } from "../../../ui/tabs";
|
||||
import { Tooltip, TooltipContent, TooltipTrigger } from "../../../ui/tooltip";
|
||||
import { Select, SettingCard, SettingRow, Toggle } from "../../../settings/settings-ui";
|
||||
import { Select, SettingCard, SettingRow, SettingsSection, Toggle } from "../../../settings/settings-ui";
|
||||
import { ToolStatusPill, type ToolStatusTone } from "./ToolStatusPill";
|
||||
import { ToolSettingsDialog } from "./ToolSettingsDialog";
|
||||
import { useOptionalSettingsFocus } from "../../SettingsFocusContext";
|
||||
import { SettingsAnchor } from "../../settings-ui";
|
||||
import { CopyableCodeBlock } from "./CopyableCodeBlock";
|
||||
import { ExternalMcpHttpSection, type ExternalMcpHttpViewStatus } from "./ExternalMcpHttpSection";
|
||||
import { getBridge } from "./types";
|
||||
@@ -72,23 +77,13 @@ type StatusView = {
|
||||
className: string;
|
||||
};
|
||||
|
||||
function getBridgeStatusView(status: ExternalMcpStatus | null, enabled: boolean): StatusView {
|
||||
if (!enabled) {
|
||||
return { labelKey: "ai.externalMcp.status.disabled", className: "text-muted-foreground" };
|
||||
}
|
||||
if (!status || !status.ok) {
|
||||
return { labelKey: "ai.externalMcp.status.unavailable", className: "text-amber-500" };
|
||||
}
|
||||
if (status.state === "running") {
|
||||
return { labelKey: "ai.externalMcp.status.running", className: "text-emerald-500" };
|
||||
}
|
||||
if (status.state === "starting") {
|
||||
return { labelKey: "ai.externalMcp.status.starting", className: "text-amber-500" };
|
||||
}
|
||||
if (status.state === "error") {
|
||||
return { labelKey: "ai.externalMcp.status.error", className: "text-destructive" };
|
||||
}
|
||||
return { labelKey: "ai.externalMcp.status.disabled", className: "text-muted-foreground" };
|
||||
function getBridgeStatusView(status: ExternalMcpStatus | null, enabled: boolean): { labelKey: string; tone: ToolStatusTone } {
|
||||
if (!enabled) return { labelKey: "ai.externalMcp.status.disabled", tone: "inactive" };
|
||||
if (!status || !status.ok) return { labelKey: "ai.externalMcp.status.unavailable", tone: status?.error ? "error" : "inactive" };
|
||||
if (status.state === "running") return { labelKey: "ai.externalMcp.status.running", tone: "active" };
|
||||
if (status.state === "starting") return { labelKey: "ai.externalMcp.status.starting", tone: "inactive" };
|
||||
if (status.state === "error") return { labelKey: "ai.externalMcp.status.error", tone: "error" };
|
||||
return { labelKey: "ai.externalMcp.status.disabled", tone: "inactive" };
|
||||
}
|
||||
|
||||
/** Map bridge permissionMode to a Safety i18n key for display. */
|
||||
@@ -252,6 +247,15 @@ export function buildCursorSnippet(launcherPath: string, discoveryPath?: string
|
||||
|
||||
export const ExternalMcpCard: React.FC = () => {
|
||||
const { t } = useI18n();
|
||||
const [behaviorOpen, setBehaviorOpen] = useState(false);
|
||||
const focus = useOptionalSettingsFocus();
|
||||
useEffect(() => {
|
||||
if (focus?.request?.anchorId === "ai-external-mcp-approval-alert") setBehaviorOpen(true);
|
||||
}, [focus?.request]);
|
||||
const statusGeneration = useRef(0);
|
||||
const clientStatusGeneration = useRef(0);
|
||||
const httpOperationGeneration = useRef(0);
|
||||
useEffect(() => () => { statusGeneration.current += 1; clientStatusGeneration.current += 1; httpOperationGeneration.current += 1; }, []);
|
||||
const { enabled, setEnabled, error: configError } = useExternalMcpToggleState();
|
||||
const [mode, setModeRaw] = useState<ExternalMcpMode>(() => readExternalMcpMode());
|
||||
const [idleTimeoutMinutes, setIdleTimeoutRaw] = useState<number>(() => readExternalMcpIdleTimeoutMinutes());
|
||||
@@ -268,8 +272,11 @@ export const ExternalMcpCard: React.FC = () => {
|
||||
listenAddress: readExternalMcpHttpListenAddress(),
|
||||
clientHost: readExternalMcpHttpClientHost(),
|
||||
}));
|
||||
const [isApplyingHttp, setIsApplyingHttp] = useState(false);
|
||||
const [isToggling, setIsToggling] = useState(false);
|
||||
const [isRotatingHttpToken, setIsRotatingHttpToken] = useState(false);
|
||||
const [status, setStatus] = useState<ExternalMcpStatus | null>(null);
|
||||
const [manualFormat, setManualFormat] = useState("command");
|
||||
const [selectedClient, setSelectedClient] = useState<ExternalMcpClient>("codex");
|
||||
const [codexStatus, setCodexStatus] = useState<ClientSetupStatus | null>(null);
|
||||
const [claudeStatus, setClaudeStatus] = useState<ClientSetupStatus | null>(null);
|
||||
@@ -280,6 +287,8 @@ export const ExternalMcpCard: React.FC = () => {
|
||||
const [isAddingClaude, setIsAddingClaude] = useState(false);
|
||||
const [isAddingGrok, setIsAddingGrok] = useState(false);
|
||||
const [copied, setCopied] = useState<string | null>(null);
|
||||
const [httpCopyError, setHttpCopyError] = useState<string | null>(null);
|
||||
const [httpActionError, setHttpActionError] = useState<{ kind: "config" | "rotate"; message: string } | null>(null);
|
||||
const [actionMessage, setActionMessage] = useState<{ tone: "error" | "warning" | "success"; text: string } | null>(null);
|
||||
const bridgeUnavailableMessage = t("ai.externalMcp.bridgeUnavailable");
|
||||
|
||||
@@ -324,6 +333,10 @@ export const ExternalMcpCard: React.FC = () => {
|
||||
}, []);
|
||||
|
||||
const applyHttpSettings = useCallback(async (next: ExternalMcpHttpSettings) => {
|
||||
const operation = ++httpOperationGeneration.current;
|
||||
const generation = ++statusGeneration.current;
|
||||
setHttpActionError(null);
|
||||
setIsApplyingHttp(true);
|
||||
const normalized: ExternalMcpHttpSettings = {
|
||||
enabled: writeExternalMcpHttpEnabled(next.enabled),
|
||||
port: writeExternalMcpHttpPort(next.port),
|
||||
@@ -332,36 +345,56 @@ export const ExternalMcpCard: React.FC = () => {
|
||||
clientHost: writeExternalMcpHttpClientHost(next.clientHost),
|
||||
};
|
||||
setHttpSettings(normalized);
|
||||
const nextStatus = await getBridge()?.externalMcpSetConfig?.({
|
||||
httpEnabled: normalized.enabled,
|
||||
httpPort: normalized.port,
|
||||
httpAllowCustomBind: normalized.allowCustomBind,
|
||||
httpListenAddress: normalized.listenAddress,
|
||||
httpClientHost: normalized.clientHost,
|
||||
});
|
||||
if (nextStatus && typeof nextStatus === "object") {
|
||||
setStatus(nextStatus as ExternalMcpStatus);
|
||||
try {
|
||||
const nextStatus = await getBridge()?.externalMcpSetConfig?.({
|
||||
httpEnabled: normalized.enabled,
|
||||
httpPort: normalized.port,
|
||||
httpAllowCustomBind: normalized.allowCustomBind,
|
||||
httpListenAddress: normalized.listenAddress,
|
||||
httpClientHost: normalized.clientHost,
|
||||
}) as ExternalMcpStatus | undefined;
|
||||
if (operation !== httpOperationGeneration.current) return;
|
||||
if (!nextStatus?.ok || nextStatus.error || nextStatus.http?.error) {
|
||||
setHttpActionError({ kind: "config", message: nextStatus?.error || nextStatus?.http?.error || bridgeUnavailableMessage });
|
||||
return;
|
||||
}
|
||||
if (generation === statusGeneration.current) setStatus(nextStatus);
|
||||
} catch (error) {
|
||||
if (operation === httpOperationGeneration.current) {
|
||||
setHttpActionError({ kind: "config", message: error instanceof Error ? error.message : String(error) });
|
||||
}
|
||||
} finally {
|
||||
if (operation === httpOperationGeneration.current) setIsApplyingHttp(false);
|
||||
}
|
||||
}, []);
|
||||
}, [bridgeUnavailableMessage]);
|
||||
|
||||
const rotateHttpToken = useCallback(async () => {
|
||||
const bridge = getBridge();
|
||||
if (!bridge?.externalMcpSetConfig) return;
|
||||
const operation = ++httpOperationGeneration.current;
|
||||
const generation = ++statusGeneration.current;
|
||||
setIsRotatingHttpToken(true);
|
||||
setActionMessage(null);
|
||||
setHttpActionError(null);
|
||||
try {
|
||||
const nextStatus = await bridge.externalMcpSetConfig({ rotateHttpToken: true });
|
||||
if (nextStatus && typeof nextStatus === "object") {
|
||||
setStatus(nextStatus as ExternalMcpStatus);
|
||||
const nextStatus = await getBridge()?.externalMcpSetConfig?.({ rotateHttpToken: true }) as ExternalMcpStatus | undefined;
|
||||
if (operation !== httpOperationGeneration.current) return;
|
||||
if (!nextStatus?.ok || nextStatus.error || nextStatus.http?.error) {
|
||||
setHttpActionError({ kind: "rotate", message: nextStatus?.error || nextStatus?.http?.error || bridgeUnavailableMessage });
|
||||
return;
|
||||
}
|
||||
if (generation === statusGeneration.current) setStatus(nextStatus);
|
||||
} catch (error) {
|
||||
if (operation === httpOperationGeneration.current) {
|
||||
setHttpActionError({ kind: "rotate", message: error instanceof Error ? error.message : String(error) });
|
||||
}
|
||||
} finally {
|
||||
setIsRotatingHttpToken(false);
|
||||
}
|
||||
}, []);
|
||||
}, [bridgeUnavailableMessage]);
|
||||
|
||||
const refreshStatus = useCallback(async (options?: { quiet?: boolean; clients?: boolean }) => {
|
||||
const generation = ++statusGeneration.current;
|
||||
const bridge = getBridge();
|
||||
const includeClients = options?.clients !== false;
|
||||
const clientGeneration = includeClients ? ++clientStatusGeneration.current : clientStatusGeneration.current;
|
||||
if (
|
||||
!bridge?.externalMcpGetStatus
|
||||
|| !bridge?.externalMcpCodexGetStatus
|
||||
@@ -411,10 +444,15 @@ export const ExternalMcpCard: React.FC = () => {
|
||||
bridge.externalMcpGetUniversalSetupPrompt?.()
|
||||
?? Promise.resolve({ ok: false, prompt: "" }),
|
||||
]);
|
||||
setStatus(nextStatus as ExternalMcpStatus);
|
||||
if (enabled && nextStatus?.ok && !nextStatus.enabled) {
|
||||
setEnabled(false);
|
||||
// Runtime polling and CLI/client discovery have separate lifetimes.
|
||||
// A fast quiet poll must not discard a slow, still-current client batch.
|
||||
if (generation === statusGeneration.current) {
|
||||
setStatus(nextStatus as ExternalMcpStatus);
|
||||
if (enabled && nextStatus?.ok && !nextStatus.enabled) {
|
||||
setEnabled(false);
|
||||
}
|
||||
}
|
||||
if (clientGeneration !== clientStatusGeneration.current) return;
|
||||
setCodexStatus(nextCodexStatus as ClientSetupStatus);
|
||||
setClaudeStatus(nextClaudeStatus as ClientSetupStatus);
|
||||
setGrokStatus(nextGrokStatus as ClientSetupStatus);
|
||||
@@ -425,13 +463,14 @@ export const ExternalMcpCard: React.FC = () => {
|
||||
);
|
||||
} else {
|
||||
const nextStatus = await bridge.externalMcpGetStatus();
|
||||
if (generation !== statusGeneration.current) return;
|
||||
setStatus(nextStatus as ExternalMcpStatus);
|
||||
if (enabled && nextStatus?.ok && !nextStatus.enabled) {
|
||||
setEnabled(false);
|
||||
}
|
||||
}
|
||||
} finally {
|
||||
if (!options?.quiet) setIsRefreshing(false);
|
||||
if (!options?.quiet && clientGeneration === clientStatusGeneration.current) setIsRefreshing(false);
|
||||
}
|
||||
}, [bridgeUnavailableMessage, enabled, setEnabled]);
|
||||
|
||||
@@ -481,8 +520,10 @@ export const ExternalMcpCard: React.FC = () => {
|
||||
const canAddToClaude = claudeStatus?.state === "not_configured";
|
||||
const canAddToGrok = grokStatus?.state === "not_configured";
|
||||
|
||||
const copyText = useCallback(async (key: string, text: string) => {
|
||||
const copyText = useCallback(async (key: string, text: string, scope: "client" | "http" = "client") => {
|
||||
if (!text) return;
|
||||
setCopied((current) => current === key ? null : current);
|
||||
if (scope === "http") setHttpCopyError(null);
|
||||
try {
|
||||
await navigator.clipboard.writeText(text);
|
||||
setCopied(key);
|
||||
@@ -490,7 +531,8 @@ export const ExternalMcpCard: React.FC = () => {
|
||||
setCopied((current) => (current === key ? null : current));
|
||||
}, 1200);
|
||||
} catch {
|
||||
setActionMessage({ tone: "error", text: t("ai.externalMcp.copyFailed") });
|
||||
if (scope === "http") setHttpCopyError(t("ai.externalMcp.copyFailed"));
|
||||
else setActionMessage({ tone: "error", text: t("ai.externalMcp.copyFailed") });
|
||||
}
|
||||
}, [t]);
|
||||
|
||||
@@ -634,8 +676,13 @@ export const ExternalMcpCard: React.FC = () => {
|
||||
]);
|
||||
|
||||
return (
|
||||
<div className="rounded-lg border bg-card p-4 space-y-3">
|
||||
<div className="flex items-start justify-between gap-4">
|
||||
<SettingsSection
|
||||
anchorId="ai-external-mcp"
|
||||
title={t("ai.externalMcp.title")}
|
||||
actions={<ToolStatusPill tone={bridgeStatusView.tone}>{t(bridgeStatusView.labelKey)}</ToolStatusPill>}
|
||||
>
|
||||
<div className="space-y-4">
|
||||
<div className="flex items-start gap-4">
|
||||
<div className="min-w-0 flex items-start gap-1.5">
|
||||
<p className="min-w-0 text-xs text-muted-foreground leading-5">
|
||||
{t("ai.externalMcp.description")}
|
||||
@@ -665,12 +712,10 @@ export const ExternalMcpCard: React.FC = () => {
|
||||
</TooltipContent>
|
||||
</Tooltip>
|
||||
</div>
|
||||
<div className={cn("text-xs font-medium shrink-0", bridgeStatusView.className)}>
|
||||
{t(bridgeStatusView.labelKey)}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="flex items-center justify-between gap-4 rounded-md border border-border/60 bg-background/70 px-3 py-2">
|
||||
<SettingCard divided>
|
||||
<div className="flex items-center justify-between gap-4 py-3">
|
||||
<div className="min-w-0">
|
||||
<div className="text-sm font-medium">{t("ai.externalMcp.title")}</div>
|
||||
<div className="text-xs text-muted-foreground">
|
||||
@@ -679,17 +724,21 @@ export const ExternalMcpCard: React.FC = () => {
|
||||
</div>
|
||||
<Toggle
|
||||
checked={enabled}
|
||||
disabled={isToggling}
|
||||
ariaLabel={t("ai.externalMcp.title")}
|
||||
onChange={(nextEnabled) => {
|
||||
statusGeneration.current += 1;
|
||||
setActionMessage(null);
|
||||
void setEnabled(nextEnabled).then(() => refreshStatus());
|
||||
setIsToggling(true);
|
||||
void setEnabled(nextEnabled).then(() => { void refreshStatus(); }).finally(() => setIsToggling(false));
|
||||
}}
|
||||
/>
|
||||
</div>
|
||||
|
||||
{/* Permission mode is controlled in Safety settings; surface it here so External MCP
|
||||
users see why write tools may still prompt (confirm) or run freely (auto). */}
|
||||
<div className="flex items-start justify-between gap-4 rounded-md border border-border/60 bg-background/70 px-3 py-2">
|
||||
<div className="min-w-0 space-y-1">
|
||||
<div className="flex items-start justify-between gap-4 py-3">
|
||||
<div className="min-w-0 flex-1 space-y-1">
|
||||
<div className="text-sm font-medium">{t("ai.externalMcp.permissionMode.label")}</div>
|
||||
<div className="text-xs text-muted-foreground leading-5">
|
||||
{t("ai.externalMcp.permissionMode.hint")}
|
||||
@@ -706,9 +755,11 @@ export const ExternalMcpCard: React.FC = () => {
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<SettingCard divided className="rounded-md border-border/60 bg-background/70">
|
||||
<SettingsAnchor anchorId="ai-external-mcp-approval-alert">
|
||||
<SettingRow className="flex-col items-start md:flex-row md:items-center" label={t("ai.toolSettings.behavior")} description={t(mode === "persistent" ? "ai.externalMcp.mode.persistent" : "ai.externalMcp.mode.temporary")}>
|
||||
<ToolSettingsDialog title={t("ai.toolSettings.behavior")} triggerLabel={t("ai.toolSettings.configureBehavior")} immediate open={behaviorOpen} onOpenChange={setBehaviorOpen}>
|
||||
<SettingCard divided className="border-0 bg-transparent p-0">
|
||||
<SettingRow
|
||||
anchorId="ai-external-mcp-approval-alert"
|
||||
label={t("ai.externalMcp.approvalAlert")}
|
||||
description={t("ai.externalMcp.approvalAlert.description")}
|
||||
>
|
||||
@@ -803,6 +854,10 @@ export const ExternalMcpCard: React.FC = () => {
|
||||
<span className="text-xs text-muted-foreground">{t("ai.externalMcp.idleTimeout.minutes")}</span>
|
||||
</div>
|
||||
</SettingRow>
|
||||
</SettingCard>
|
||||
</ToolSettingsDialog>
|
||||
</SettingRow>
|
||||
</SettingsAnchor>
|
||||
</SettingCard>
|
||||
|
||||
<ExternalMcpHttpSection
|
||||
@@ -811,93 +866,36 @@ export const ExternalMcpCard: React.FC = () => {
|
||||
status={status?.http || null}
|
||||
copied={copied}
|
||||
rotating={isRotatingHttpToken}
|
||||
onCopy={copyText}
|
||||
onCopy={(key, text) => { void copyText(key, text, "http"); }}
|
||||
applying={isApplyingHttp}
|
||||
operationError={httpActionError?.message ?? null}
|
||||
copyError={httpCopyError}
|
||||
onRetry={httpActionError ? () => {
|
||||
if (httpActionError.kind === "rotate") void rotateHttpToken();
|
||||
else void applyHttpSettings(httpSettings);
|
||||
} : undefined}
|
||||
onChange={(next) => { void applyHttpSettings(next); }}
|
||||
onRotateToken={() => { void rotateHttpToken(); }}
|
||||
/>
|
||||
|
||||
<div className="space-y-2">
|
||||
<div className="flex min-h-8 items-center justify-between gap-2">
|
||||
<div className="text-sm font-semibold text-foreground">{t("ai.externalMcp.discovery")}</div>
|
||||
<Button
|
||||
variant="outline"
|
||||
size="sm"
|
||||
onClick={() => void refreshStatus()}
|
||||
disabled={isRefreshing}
|
||||
>
|
||||
<RefreshCw size={14} className={cn("mr-1.5", isRefreshing && "animate-spin")} />
|
||||
{t("ai.externalMcp.refresh")}
|
||||
</Button>
|
||||
</div>
|
||||
<div className="space-y-2.5 rounded-md border border-border/60 bg-background/50 p-3">
|
||||
<CopyableCodeBlock
|
||||
label={t("ai.externalMcp.launcher")}
|
||||
value={launcherPath || ""}
|
||||
copyKey="launcher"
|
||||
copied={copied}
|
||||
onCopy={copyText}
|
||||
copyLabel={t("ai.externalMcp.copy")}
|
||||
copiedLabel={t("ai.externalMcp.copied")}
|
||||
emptyLabel={t("ai.externalMcp.unavailable")}
|
||||
/>
|
||||
<CopyableCodeBlock
|
||||
label={t("ai.externalMcp.discovery")}
|
||||
value={status?.discoveryPath || ""}
|
||||
copyKey="discovery"
|
||||
copied={copied}
|
||||
onCopy={copyText}
|
||||
copyLabel={t("ai.externalMcp.copy")}
|
||||
copiedLabel={t("ai.externalMcp.copied")}
|
||||
emptyLabel={t("ai.externalMcp.unavailable")}
|
||||
/>
|
||||
{!enabled ? (
|
||||
<p className="text-xs text-amber-500">{t("ai.externalMcp.enableForLauncher")}</p>
|
||||
) : null}
|
||||
</div>
|
||||
</div>
|
||||
<SettingCard>
|
||||
<SettingRow className="flex-col items-start md:flex-row md:items-center" label={t("ai.externalMcp.clientConfiguration")} description={t("ai.externalMcp.clientConfiguration.description")}>
|
||||
<ToolSettingsDialog title={t("ai.externalMcp.clientConfiguration")} triggerLabel={t("ai.toolSettings.configureClients")}>
|
||||
|
||||
<div className="space-y-2">
|
||||
<div className="space-y-1">
|
||||
<div className="text-sm font-semibold text-foreground">
|
||||
{t("ai.externalMcp.clientConfiguration")}
|
||||
</div>
|
||||
<p className="text-xs text-muted-foreground leading-5">
|
||||
{t("ai.externalMcp.clientConfiguration.description")}
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<div
|
||||
role="tablist"
|
||||
aria-label={t("ai.externalMcp.clientConfiguration")}
|
||||
className="grid grid-cols-5 gap-1 rounded-md bg-muted p-1"
|
||||
>
|
||||
{CLIENT_TABS.map((client) => {
|
||||
const active = selectedClient === client;
|
||||
return (
|
||||
<button
|
||||
key={client}
|
||||
type="button"
|
||||
role="tab"
|
||||
aria-selected={active}
|
||||
onClick={() => {
|
||||
setSelectedClient(client);
|
||||
setActionMessage(null);
|
||||
}}
|
||||
className={cn(
|
||||
"inline-flex h-8 items-center justify-center rounded-sm px-2 text-xs font-medium transition-colors",
|
||||
"focus-visible:outline-none focus-visible:ring-1 focus-visible:ring-ring",
|
||||
active
|
||||
? "bg-background text-foreground shadow-sm"
|
||||
: "text-muted-foreground hover:text-foreground",
|
||||
)}
|
||||
>
|
||||
{t(`ai.externalMcp.client.${client}`)}
|
||||
</button>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
<Tabs value={selectedClient} onValueChange={(value) => { setSelectedClient(value as ExternalMcpClient); setManualFormat(value === "other" || value === "cursor" ? "snippet" : "command"); setActionMessage(null); }}>
|
||||
<TabsList aria-label={t("ai.externalMcp.clientConfiguration")} className="flex h-auto flex-wrap justify-start">
|
||||
{CLIENT_TABS.map((client) => <TabsTrigger key={client} value={client}>{t(`ai.externalMcp.client.${client}`)}</TabsTrigger>)}
|
||||
</TabsList>
|
||||
<TabsContent value={selectedClient}>
|
||||
|
||||
<div className="space-y-3 rounded-md border border-border/60 bg-background/50 p-3">
|
||||
<div className="space-y-4">
|
||||
{selectedClientMeta.kind === "installable" && selectedClientMeta.statusView ? (
|
||||
<div className="flex items-center justify-between gap-3">
|
||||
<div className={cn("text-xs font-medium", selectedClientMeta.statusView.className)}>
|
||||
@@ -924,57 +922,76 @@ export const ExternalMcpCard: React.FC = () => {
|
||||
</p>
|
||||
)}
|
||||
|
||||
{selectedClientMeta.kind === "installable" ? (
|
||||
<>
|
||||
<CopyableCodeBlock
|
||||
label={t("ai.externalMcp.cliCommand")}
|
||||
value={selectedClientMeta.command}
|
||||
copyKey="command"
|
||||
copied={copied}
|
||||
onCopy={copyText}
|
||||
copyLabel={t("ai.externalMcp.copy")}
|
||||
copiedLabel={t("ai.externalMcp.copied")}
|
||||
emptyLabel={t("ai.externalMcp.unavailable")}
|
||||
/>
|
||||
<CopyableCodeBlock
|
||||
label={t("ai.externalMcp.configSnippet")}
|
||||
value={selectedClientMeta.snippet}
|
||||
copyKey="snippet"
|
||||
copied={copied}
|
||||
onCopy={copyText}
|
||||
copyLabel={t("ai.externalMcp.copy")}
|
||||
copiedLabel={t("ai.externalMcp.copied")}
|
||||
emptyLabel={t("ai.externalMcp.unavailable")}
|
||||
/>
|
||||
</>
|
||||
) : (
|
||||
<>
|
||||
<CopyableCodeBlock
|
||||
label={t("ai.externalMcp.configSnippet")}
|
||||
value={selectedClientMeta.snippet}
|
||||
copyKey="cursor"
|
||||
copied={copied}
|
||||
onCopy={copyText}
|
||||
copyLabel={t("ai.externalMcp.copy")}
|
||||
copiedLabel={t("ai.externalMcp.copied")}
|
||||
emptyLabel={t("ai.externalMcp.unavailable")}
|
||||
/>
|
||||
{selectedClient === "other" ? (
|
||||
<CopyableCodeBlock
|
||||
label={t("ai.externalMcp.universalSetupPrompt")}
|
||||
value={universalSetupPrompt}
|
||||
copyKey="universal-setup-prompt"
|
||||
copied={copied}
|
||||
onCopy={copyText}
|
||||
copyLabel={t("ai.externalMcp.copy")}
|
||||
copiedLabel={t("ai.externalMcp.copied")}
|
||||
emptyLabel={t("ai.externalMcp.unavailable")}
|
||||
/>
|
||||
) : null}
|
||||
</>
|
||||
)}
|
||||
<details className="space-y-3">
|
||||
<summary className="cursor-pointer text-sm font-medium">{t("ai.toolSettings.manualSetup")}</summary>
|
||||
<label className="flex flex-wrap items-center justify-between gap-2 text-sm">
|
||||
{t("ai.toolSettings.format")}
|
||||
<select aria-label={t("ai.toolSettings.format")} className="h-9 max-w-full rounded-md border border-input bg-background px-3 text-sm" value={manualFormat} onChange={(event) => setManualFormat(event.currentTarget.value)}>
|
||||
{selectedClientMeta.kind === "installable" ? <option value="command">{t("ai.externalMcp.cliCommand")}</option> : null}
|
||||
<option value="snippet">{t("ai.externalMcp.configSnippet")}</option>
|
||||
{selectedClient === "other" ? <option value="prompt">{t("ai.externalMcp.universalSetupPrompt")}</option> : null}
|
||||
</select>
|
||||
</label>
|
||||
<CopyableCodeBlock
|
||||
label={t(manualFormat === "command" ? "ai.externalMcp.cliCommand" : manualFormat === "prompt" ? "ai.externalMcp.universalSetupPrompt" : "ai.externalMcp.configSnippet")}
|
||||
value={manualFormat === "command" ? selectedClientMeta.command : manualFormat === "prompt" ? universalSetupPrompt : selectedClientMeta.snippet}
|
||||
copyKey={manualFormat}
|
||||
copied={copied}
|
||||
onCopy={copyText}
|
||||
copyLabel={t("ai.externalMcp.copy")}
|
||||
copiedLabel={t("ai.externalMcp.copied")}
|
||||
emptyLabel={t("ai.externalMcp.unavailable")}
|
||||
/>
|
||||
</details>
|
||||
</div>
|
||||
</TabsContent>
|
||||
</Tabs>
|
||||
</div>
|
||||
<details className="space-y-3">
|
||||
<summary className="cursor-pointer text-sm font-medium">{t("ai.toolSettings.connectionDetails")}</summary>
|
||||
<div className="flex min-h-8 items-center justify-between gap-2">
|
||||
<div className="text-sm font-semibold text-foreground">{t("ai.externalMcp.discovery")}</div>
|
||||
<Button
|
||||
variant="outline"
|
||||
size="sm"
|
||||
onClick={() => void refreshStatus()}
|
||||
disabled={isRefreshing}
|
||||
>
|
||||
<RefreshCw size={14} className={cn("mr-1.5", isRefreshing && "animate-spin")} />
|
||||
{t("ai.externalMcp.refresh")}
|
||||
</Button>
|
||||
</div>
|
||||
<div className="space-y-3">
|
||||
<CopyableCodeBlock
|
||||
label={t("ai.externalMcp.launcher")}
|
||||
value={launcherPath || ""}
|
||||
copyKey="launcher"
|
||||
copied={copied}
|
||||
onCopy={copyText}
|
||||
copyLabel={t("ai.externalMcp.copy")}
|
||||
copiedLabel={t("ai.externalMcp.copied")}
|
||||
emptyLabel={t("ai.externalMcp.unavailable")}
|
||||
/>
|
||||
<CopyableCodeBlock
|
||||
label={t("ai.externalMcp.discovery")}
|
||||
value={status?.discoveryPath || ""}
|
||||
copyKey="discovery"
|
||||
copied={copied}
|
||||
onCopy={copyText}
|
||||
copyLabel={t("ai.externalMcp.copy")}
|
||||
copiedLabel={t("ai.externalMcp.copied")}
|
||||
emptyLabel={t("ai.externalMcp.unavailable")}
|
||||
/>
|
||||
{!enabled ? (
|
||||
<p className="text-xs text-amber-500">{t("ai.externalMcp.enableForLauncher")}</p>
|
||||
) : null}
|
||||
</div>
|
||||
</details>
|
||||
|
||||
{actionMessage ? <p role={actionMessage.tone === "error" ? "alert" : "status"} className={cn("text-xs", actionMessage.tone === "error" ? "text-destructive" : "text-muted-foreground")}>{actionMessage.text}</p> : null}
|
||||
</ToolSettingsDialog>
|
||||
</SettingRow>
|
||||
</SettingCard>
|
||||
|
||||
{actionMessage ? (
|
||||
<div
|
||||
@@ -991,6 +1008,7 @@ export const ExternalMcpCard: React.FC = () => {
|
||||
{configError || status?.error ? (
|
||||
<div role="alert" className="text-xs text-destructive">{configError || status?.error}</div>
|
||||
) : null}
|
||||
</div>
|
||||
</div>
|
||||
</SettingsSection>
|
||||
);
|
||||
};
|
||||
@@ -1,4 +1,4 @@
|
||||
import React, { useState } from "react";
|
||||
import React, { useEffect, useId, useRef, useState } from "react";
|
||||
import {
|
||||
buildClaudeHttpAddCommand,
|
||||
buildCodexHttpEnvLine,
|
||||
@@ -8,9 +8,12 @@ import {
|
||||
DEFAULT_EXTERNAL_MCP_HTTP_PORT,
|
||||
} from "../../../../domain/externalMcpHttp";
|
||||
import { useI18n } from "../../../../application/i18n/I18nProvider";
|
||||
import { cn } from "../../../../lib/utils";
|
||||
import { Button } from "../../../ui/button";
|
||||
import { SettingCard, SettingRow, Toggle } from "../../settings-ui";
|
||||
import { ToolStatusPill } from "./ToolStatusPill";
|
||||
import { ToolSettingsDialog } from "./ToolSettingsDialog";
|
||||
import { useOptionalSettingsFocus } from "../../SettingsFocusContext";
|
||||
import { SettingsAnchor } from "../../settings-ui";
|
||||
import { CopyableCodeBlock } from "./CopyableCodeBlock";
|
||||
|
||||
export type ExternalMcpHttpViewStatus = {
|
||||
@@ -41,6 +44,10 @@ type ExternalMcpHttpSectionProps = {
|
||||
status: ExternalMcpHttpViewStatus | null;
|
||||
copied: string | null;
|
||||
rotating: boolean;
|
||||
applying?: boolean;
|
||||
operationError?: string | null;
|
||||
copyError?: string | null;
|
||||
onRetry?: () => void;
|
||||
onCopy: (key: string, text: string) => void;
|
||||
onChange: (next: ExternalMcpHttpViewSettings) => void;
|
||||
onRotateToken: () => void;
|
||||
@@ -73,11 +80,25 @@ export const ExternalMcpHttpSection: React.FC<ExternalMcpHttpSectionProps> = ({
|
||||
status,
|
||||
copied,
|
||||
rotating,
|
||||
applying,
|
||||
operationError,
|
||||
copyError,
|
||||
onRetry,
|
||||
onCopy,
|
||||
onChange,
|
||||
onRotateToken,
|
||||
}) => {
|
||||
const { t } = useI18n();
|
||||
const operationErrorRef = useRef<HTMLDivElement>(null);
|
||||
const errorId = useId();
|
||||
const [open, setOpen] = useState(false);
|
||||
useEffect(() => {
|
||||
if (open && (operationError || copyError)) operationErrorRef.current?.focus();
|
||||
}, [open, operationError, copyError]);
|
||||
const focus = useOptionalSettingsFocus();
|
||||
useEffect(() => {
|
||||
if (focus?.request?.anchorId === "ai-external-mcp-http") setOpen(true);
|
||||
}, [focus?.request]);
|
||||
const [portDraft, setPortDraft] = useState(String(settings.port || DEFAULT_EXTERNAL_MCP_HTTP_PORT));
|
||||
const [addressDraft, setAddressDraft] = useState(settings.listenAddress || "0.0.0.0");
|
||||
const [clientHostDraft, setClientHostDraft] = useState(settings.clientHost || "");
|
||||
@@ -85,6 +106,16 @@ export const ExternalMcpHttpSection: React.FC<ExternalMcpHttpSectionProps> = ({
|
||||
const [addressError, setAddressError] = useState(false);
|
||||
const [clientHostError, setClientHostError] = useState(false);
|
||||
|
||||
const changeOpen = (next: boolean) => {
|
||||
setPortDraft(String(settings.port || DEFAULT_EXTERNAL_MCP_HTTP_PORT));
|
||||
setAddressDraft(settings.listenAddress || "0.0.0.0");
|
||||
setClientHostDraft(settings.clientHost || "");
|
||||
setPortError(false);
|
||||
setAddressError(false);
|
||||
setClientHostError(false);
|
||||
setOpen(next);
|
||||
};
|
||||
|
||||
const listenAddress = settings.allowCustomBind
|
||||
? (status?.listenAddress || settings.listenAddress)
|
||||
: "127.0.0.1";
|
||||
@@ -97,14 +128,9 @@ export const ExternalMcpHttpSection: React.FC<ExternalMcpHttpSectionProps> = ({
|
||||
);
|
||||
const token = status?.bearerToken || "";
|
||||
const listening = Boolean(status?.listening);
|
||||
const statusLabel = listening
|
||||
? t("ai.externalMcp.http.listening")
|
||||
: t("ai.externalMcp.http.notListening");
|
||||
const statusClass = status?.errorCode
|
||||
? "text-destructive"
|
||||
: listening
|
||||
? "text-emerald-500"
|
||||
: "text-muted-foreground";
|
||||
const hasRuntimeError = Boolean(operationError || status?.errorCode || status?.error);
|
||||
const statusLabel = hasRuntimeError ? t("ai.externalMcp.status.error")
|
||||
: t(listening ? "ai.externalMcp.http.listening" : "ai.externalMcp.http.notListening");
|
||||
|
||||
let errorText = "";
|
||||
if (portError) errorText = t("ai.externalMcp.http.invalidPort");
|
||||
@@ -116,6 +142,7 @@ export const ExternalMcpHttpSection: React.FC<ExternalMcpHttpSectionProps> = ({
|
||||
} else if (status?.errorCode === "invalid_address") errorText = t("ai.externalMcp.http.invalidAddress");
|
||||
else if (status?.errorCode === "invalid_port") errorText = t("ai.externalMcp.http.invalidPort");
|
||||
else if (status?.errorCode === "listen_failed") errorText = status.error || t("ai.externalMcp.http.listenFailed");
|
||||
else if (status?.error) errorText = status.error;
|
||||
|
||||
const commitPort = () => {
|
||||
const port = parsePort(portDraft);
|
||||
@@ -151,16 +178,25 @@ export const ExternalMcpHttpSection: React.FC<ExternalMcpHttpSectionProps> = ({
|
||||
};
|
||||
|
||||
return (
|
||||
<div className="space-y-2">
|
||||
<SettingsAnchor anchorId="ai-external-mcp-http" className="space-y-2">
|
||||
<div className="flex items-center justify-between gap-3">
|
||||
<div className="text-sm font-semibold text-foreground">{t("ai.externalMcp.http.title")}</div>
|
||||
<div className={cn("text-xs font-medium", statusClass)}>{statusLabel}</div>
|
||||
<ToolStatusPill tone={hasRuntimeError ? "error" : listening ? "active" : "inactive"}>{statusLabel}</ToolStatusPill>
|
||||
</div>
|
||||
<p className="text-xs text-muted-foreground leading-5">{t("ai.externalMcp.http.description")}</p>
|
||||
<p className="text-xs text-muted-foreground leading-5">{t("ai.externalMcp.http.legacySseNote")}</p>
|
||||
<SettingCard divided className="rounded-md border-border/60 bg-background/70">
|
||||
<SettingCard>
|
||||
<SettingRow className="flex-col items-start md:flex-row md:items-center" label={t(settings.enabled ? "ai.toolSettings.httpOn" : "ai.toolSettings.httpOff")} description={settings.allowCustomBind ? t("ai.externalMcp.http.warning") : t("ai.toolSettings.loopback")}>
|
||||
<ToolSettingsDialog title={t("ai.externalMcp.http.title")} triggerLabel={t("ai.toolSettings.configureHttp")} immediate open={open} onOpenChange={changeOpen}>
|
||||
{applying ? <p role="status" className="text-xs text-muted-foreground">{t("ai.toolSettings.applyingHttp")}</p> : null}
|
||||
{operationError || copyError ? <div ref={operationErrorRef} role="alert" tabIndex={-1} className="space-y-2 text-xs text-destructive">
|
||||
{operationError ? <p>{operationError}</p> : null}
|
||||
{copyError ? <p>{copyError}</p> : null}
|
||||
{operationError && onRetry ? <Button type="button" variant="outline" size="sm" disabled={rotating || applying} onClick={onRetry}>{t("ai.toolSettings.retry")}</Button> : null}
|
||||
</div> : null}
|
||||
<p className="text-xs text-muted-foreground leading-5">{t("ai.externalMcp.http.description")}</p>
|
||||
<SettingCard divided className="border-0 bg-transparent p-0">
|
||||
<SettingRow
|
||||
anchorId="ai-external-mcp-http"
|
||||
label={t("ai.externalMcp.http.enable")}
|
||||
>
|
||||
<Toggle
|
||||
@@ -176,6 +212,8 @@ export const ExternalMcpHttpSection: React.FC<ExternalMcpHttpSectionProps> = ({
|
||||
<input
|
||||
type="number"
|
||||
aria-label={t("ai.externalMcp.http.port")}
|
||||
aria-invalid={portError}
|
||||
aria-describedby={portError ? errorId : undefined}
|
||||
min={1024}
|
||||
max={65535}
|
||||
value={portDraft}
|
||||
@@ -207,6 +245,8 @@ export const ExternalMcpHttpSection: React.FC<ExternalMcpHttpSectionProps> = ({
|
||||
<input
|
||||
type="text"
|
||||
aria-label={t("ai.externalMcp.http.listenAddress")}
|
||||
aria-invalid={addressError}
|
||||
aria-describedby={addressError ? errorId : undefined}
|
||||
placeholder={t("ai.externalMcp.http.listenAddress.placeholder")}
|
||||
value={addressDraft}
|
||||
onChange={(event) => {
|
||||
@@ -226,6 +266,8 @@ export const ExternalMcpHttpSection: React.FC<ExternalMcpHttpSectionProps> = ({
|
||||
<input
|
||||
type="text"
|
||||
aria-label={t("ai.externalMcp.http.clientHost")}
|
||||
aria-invalid={clientHostError}
|
||||
aria-describedby={clientHostError ? errorId : undefined}
|
||||
placeholder={t("ai.externalMcp.http.clientHost.placeholder")}
|
||||
value={clientHostDraft}
|
||||
onChange={(event) => {
|
||||
@@ -245,8 +287,9 @@ export const ExternalMcpHttpSection: React.FC<ExternalMcpHttpSectionProps> = ({
|
||||
{settings.enabled && !externalMcpEnabled ? (
|
||||
<p className="text-xs text-amber-500">{t("ai.externalMcp.http.requiresExternalMcp")}</p>
|
||||
) : null}
|
||||
{errorText ? <p className="text-xs text-destructive">{errorText}</p> : null}
|
||||
<div className="space-y-2.5 rounded-md border border-border/60 bg-background/50 p-3">
|
||||
{errorText ? <p id={errorId} role="alert" className="text-xs text-destructive">{errorText}</p> : null}
|
||||
<details className="space-y-4">
|
||||
<summary className="cursor-pointer text-sm font-medium">{t("ai.toolSettings.connectionDetails")}</summary>
|
||||
<CopyableCodeBlock
|
||||
label={t("ai.externalMcp.http.url")}
|
||||
value={url}
|
||||
@@ -259,6 +302,7 @@ export const ExternalMcpHttpSection: React.FC<ExternalMcpHttpSectionProps> = ({
|
||||
<CopyableCodeBlock
|
||||
label={t("ai.externalMcp.http.token")}
|
||||
value={token}
|
||||
sensitive
|
||||
copyKey="http-token"
|
||||
copied={copied}
|
||||
onCopy={onCopy}
|
||||
@@ -272,7 +316,7 @@ export const ExternalMcpHttpSection: React.FC<ExternalMcpHttpSectionProps> = ({
|
||||
type="button"
|
||||
variant="outline"
|
||||
size="sm"
|
||||
disabled={!externalMcpEnabled || !settings.enabled || rotating}
|
||||
disabled={!externalMcpEnabled || !settings.enabled || rotating || applying}
|
||||
onClick={onRotateToken}
|
||||
>
|
||||
{t("ai.externalMcp.http.regenerate")}
|
||||
@@ -290,6 +334,7 @@ export const ExternalMcpHttpSection: React.FC<ExternalMcpHttpSectionProps> = ({
|
||||
<CopyableCodeBlock
|
||||
label={t("ai.externalMcp.http.envLine")}
|
||||
value={token ? buildCodexHttpEnvLine(token) : ""}
|
||||
sensitive
|
||||
copyKey="http-env"
|
||||
copied={copied}
|
||||
onCopy={onCopy}
|
||||
@@ -300,6 +345,7 @@ export const ExternalMcpHttpSection: React.FC<ExternalMcpHttpSectionProps> = ({
|
||||
<CopyableCodeBlock
|
||||
label={t("ai.externalMcp.http.claudeCommand")}
|
||||
value={token ? buildClaudeHttpAddCommand(url, token) : ""}
|
||||
sensitive
|
||||
copyKey="http-claude"
|
||||
copied={copied}
|
||||
onCopy={onCopy}
|
||||
@@ -310,6 +356,7 @@ export const ExternalMcpHttpSection: React.FC<ExternalMcpHttpSectionProps> = ({
|
||||
<CopyableCodeBlock
|
||||
label={t("ai.externalMcp.http.genericSnippet")}
|
||||
value={token ? buildGenericHttpMcpJson(url, token) : ""}
|
||||
sensitive
|
||||
copyKey="http-json"
|
||||
copied={copied}
|
||||
onCopy={onCopy}
|
||||
@@ -317,7 +364,14 @@ export const ExternalMcpHttpSection: React.FC<ExternalMcpHttpSectionProps> = ({
|
||||
copiedLabel={t("ai.externalMcp.copied")}
|
||||
emptyLabel={t("ai.externalMcp.unavailable")}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
</details>
|
||||
</ToolSettingsDialog>
|
||||
</SettingRow>
|
||||
</SettingCard>
|
||||
{settings.enabled && !externalMcpEnabled ? <p className="text-xs text-amber-500">{t("ai.externalMcp.http.requiresExternalMcp")}</p> : null}
|
||||
{applying ? <p role="status" className="text-xs text-muted-foreground">{t("ai.toolSettings.applyingHttp")}</p> : null}
|
||||
{copyError ? <p role="alert" className="text-xs text-destructive">{copyError}</p> : null}
|
||||
{operationError || status?.error ? <p role="alert" className="text-xs text-destructive">{operationError || status?.error}</p> : null}
|
||||
</SettingsAnchor>
|
||||
);
|
||||
};
|
||||
@@ -4,6 +4,7 @@ import { useI18n } from "../../../../application/i18n/I18nProvider";
|
||||
import type { AIToolIntegrationMode } from "../../../../infrastructure/ai/types";
|
||||
import { cn } from "../../../../lib/utils";
|
||||
import { useToolAccessGuidanceState } from "../../../../application/state/useToolAccessGuidanceState";
|
||||
import { ToolSettingsDialog } from "./ToolSettingsDialog";
|
||||
import { EXTERNAL_MCP_DISCOVERY_ENV_VAR } from "./ExternalMcpCard";
|
||||
|
||||
/** Build a ready-to-paste prompt so an external AI client can register Netcatty MCP itself. */
|
||||
@@ -45,16 +46,20 @@ type CopyRowProps = {
|
||||
|
||||
const CopyRow: React.FC<CopyRowProps> = ({ value, label, copyLabel, copiedLabel, testId }) => {
|
||||
const [copied, setCopied] = useState(false);
|
||||
const [copyError, setCopyError] = useState(false);
|
||||
const { t } = useI18n();
|
||||
const canCopy = Boolean(value);
|
||||
|
||||
const handleCopy = async () => {
|
||||
if (!value) return;
|
||||
setCopyError(false);
|
||||
setCopied(false);
|
||||
try {
|
||||
await navigator.clipboard.writeText(value);
|
||||
setCopied(true);
|
||||
window.setTimeout(() => setCopied(false), 1200);
|
||||
} catch {
|
||||
// Clipboard may be unavailable; the text stays selectable in the block.
|
||||
setCopyError(true);
|
||||
}
|
||||
};
|
||||
|
||||
@@ -82,6 +87,7 @@ const CopyRow: React.FC<CopyRowProps> = ({ value, label, copyLabel, copiedLabel,
|
||||
{copied ? <Check size={14} className="text-emerald-500" /> : <Copy size={14} />}
|
||||
</button>
|
||||
</div>
|
||||
{copyError ? <p role="alert" className="text-xs text-destructive">{t("ai.externalMcp.copyFailed")}</p> : null}
|
||||
</div>
|
||||
);
|
||||
};
|
||||
@@ -93,7 +99,7 @@ export const ToolAccessGuidance: React.FC<{ mode: AIToolIntegrationMode }> = ({
|
||||
|
||||
if (mode === "skills") {
|
||||
return (
|
||||
<div className="rounded-md border border-border/60 bg-background/50 p-3 space-y-2">
|
||||
<ToolSettingsDialog title={t("ai.toolAccess.skills.file")} triggerLabel={t("ai.toolSettings.viewSkills")}>
|
||||
<p className="text-xs text-muted-foreground leading-5">
|
||||
{t("ai.toolAccess.skills.description")}
|
||||
</p>
|
||||
@@ -110,12 +116,12 @@ export const ToolAccessGuidance: React.FC<{ mode: AIToolIntegrationMode }> = ({
|
||||
{commandPrefix ? (
|
||||
<p className="text-xs text-muted-foreground/80 font-mono break-all">{commandPrefix}</p>
|
||||
) : null}
|
||||
</div>
|
||||
</ToolSettingsDialog>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="rounded-md border border-border/60 bg-background/50 p-3 space-y-2">
|
||||
<ToolSettingsDialog title={t("ai.toolAccess.mcpPrompt.title")} triggerLabel={t("ai.toolSettings.viewPrompt")}>
|
||||
<p className="text-xs text-muted-foreground leading-5">
|
||||
{t("ai.toolAccess.mcpPrompt.description")}
|
||||
</p>
|
||||
@@ -129,6 +135,6 @@ export const ToolAccessGuidance: React.FC<{ mode: AIToolIntegrationMode }> = ({
|
||||
{!mcpLauncherPath ? (
|
||||
<p className="text-xs text-amber-500">{t("ai.toolAccess.mcpPrompt.enableHint")}</p>
|
||||
) : null}
|
||||
</div>
|
||||
</ToolSettingsDialog>
|
||||
);
|
||||
};
|
||||
@@ -0,0 +1,47 @@
|
||||
import React from "react";
|
||||
import { useI18n } from "../../../../application/i18n/I18nProvider";
|
||||
import { Button } from "../../../ui/button";
|
||||
import { Dialog, DialogContent, DialogFooter, DialogHeader, DialogTitle, DialogTrigger } from "../../../ui/dialog";
|
||||
|
||||
/** Shared, bounded settings dialog. Callers retain their original commit semantics. */
|
||||
export const ToolSettingsDialog: React.FC<{
|
||||
title: string;
|
||||
triggerLabel: string;
|
||||
children: React.ReactNode;
|
||||
immediate?: boolean;
|
||||
open?: boolean;
|
||||
onOpenChange?: (open: boolean) => void;
|
||||
footer?: React.ReactNode;
|
||||
busy?: boolean;
|
||||
}> = ({ title, triggerLabel, children, immediate, open, onOpenChange, footer, busy }) => {
|
||||
const { t } = useI18n();
|
||||
const [localOpen, setLocalOpen] = React.useState(false);
|
||||
const changeOpen = (next: boolean) => {
|
||||
if (busy) return;
|
||||
setLocalOpen(next);
|
||||
onOpenChange?.(next);
|
||||
};
|
||||
return (
|
||||
<Dialog open={open ?? localOpen} onOpenChange={changeOpen}>
|
||||
<DialogTrigger asChild>
|
||||
<Button type="button" variant="outline" size="sm">{triggerLabel}</Button>
|
||||
</DialogTrigger>
|
||||
<DialogContent
|
||||
className="flex max-h-[calc(100dvh-2rem)] w-[calc(100vw-2rem)] max-w-2xl flex-col gap-0 overflow-hidden p-0"
|
||||
aria-busy={busy || undefined}
|
||||
hideCloseButton={busy}
|
||||
onEscapeKeyDown={(event) => { if (busy) event.preventDefault(); }}
|
||||
onInteractOutside={(event) => { if (busy) event.preventDefault(); }}
|
||||
>
|
||||
<DialogHeader className="shrink-0 px-6 pb-4 pt-6 pr-12 text-left">
|
||||
<DialogTitle>{title}</DialogTitle>
|
||||
{immediate ? <p className="text-xs text-muted-foreground">{t("ai.toolSettings.immediate")}</p> : null}
|
||||
</DialogHeader>
|
||||
<div className="min-h-0 overflow-y-auto px-6 pb-6 space-y-4">{children}</div>
|
||||
<DialogFooter className="shrink-0 gap-2 border-t px-6 py-4">
|
||||
{footer ?? <Button type="button" variant="outline" onClick={() => changeOpen(false)}>{t("common.close")}</Button>}
|
||||
</DialogFooter>
|
||||
</DialogContent>
|
||||
</Dialog>
|
||||
);
|
||||
};
|
||||
@@ -0,0 +1,14 @@
|
||||
import React from "react";
|
||||
import { cn } from "../../../../lib/utils";
|
||||
|
||||
export type ToolStatusTone = "active" | "inactive" | "error";
|
||||
|
||||
/** Non-interactive runtime status, shared by the MCP and HTTP section headers. */
|
||||
export const ToolStatusPill: React.FC<{ tone: ToolStatusTone; children: React.ReactNode }> = ({ tone, children }) => (
|
||||
<span role="status" className={cn(
|
||||
"inline-flex shrink-0 items-center rounded-full px-2.5 py-0.5 text-xs font-medium leading-5",
|
||||
tone === "active" ? "bg-primary/10 text-primary"
|
||||
: tone === "error" ? "bg-destructive/10 text-destructive"
|
||||
: "bg-muted text-muted-foreground",
|
||||
)}>{children}</span>
|
||||
);
|
||||
@@ -0,0 +1,72 @@
|
||||
# Verify AI Tool Access
|
||||
|
||||
The fixture replaces IPC in the real settings renderer. It records mutations in
|
||||
`window.__aiToolQa.calls`. It never starts MCP or HTTP, writes client configuration,
|
||||
or creates a real token. Paths and tokens are synthetic.
|
||||
|
||||
Use a fresh browser context or a disposable Electron profile. The initializer
|
||||
clears that context's local storage. Never inject it into a personal profile.
|
||||
|
||||
## Browser interaction checks
|
||||
|
||||
Start Vite with `npx vite --host 127.0.0.1`. In a runner with Playwright and Chromium,
|
||||
run `node scripts/fixtures/ai-tool-access/browser-qa.cjs`. Set `CHROMIUM_PATH` if the
|
||||
browser binary is elsewhere. `NETCATTY_QA_URL` defaults to `http://127.0.0.1:5173`.
|
||||
`NETCATTY_QA_OUTPUT` selects the directory for browser screenshots.
|
||||
|
||||
These checks use the production settings components and theme stylesheet.
|
||||
Browser screenshots are not native Electron acceptance.
|
||||
|
||||
## Native screenshot fixture
|
||||
|
||||
Inject `installAiToolAccessFixture` from `bridge.cjs` into the main world before
|
||||
loading `/#/settings` in a disposable Electron window. Do not launch Netcatty's
|
||||
real main-process bridges. Options are `locale`, `theme`, and `enabled`.
|
||||
The supported test locales include `zh-CN` and `en`. Themes are `dark` and `light`.
|
||||
|
||||
Open **AI**, then **Tool Access**. Capture the full settings chrome and modal edges.
|
||||
Name each original screenshot with its state, locale, theme, and commit SHA.
|
||||
|
||||
Capture these states:
|
||||
|
||||
1. Main page, top and lower sections, in Chinese and dark theme.
|
||||
2. Main page with Skills + CLI selected.
|
||||
3. MCP connection guide, including the disabled prerequisite state.
|
||||
4. Skills + CLI connection guide.
|
||||
5. Client configuration for Codex, Cursor, and Other. Include manual format
|
||||
selection and the Other setup prompt.
|
||||
6. MCP behavior with persistent and temporary availability modes.
|
||||
7. HTTP disabled, then custom wildcard binding with a client address and masked
|
||||
synthetic token. Expand connection details for the latter.
|
||||
8. Privacy configuration, then rejected input. Set
|
||||
`window.__aiToolQa.rejectPrivacy = true` to reject Apply.
|
||||
9. A narrow or short dialog with its footer visible.
|
||||
10. Main page and the densest dialog in English and light theme.
|
||||
|
||||
All created dialog types need native evidence. Preserve the original images even
|
||||
if a contact sheet is also produced. Do not reveal synthetic tokens in delivered
|
||||
screenshots; the masking behavior has separate interaction assertions.
|
||||
|
||||
## Commit semantics
|
||||
|
||||
Privacy is one draft, including both switches. Apply validates before persistence.
|
||||
Cancel, Escape, and X discard the draft. Rejected values stay in the dialog.
|
||||
Dirty drafts survive cross-window changes and show a conflict notice.
|
||||
|
||||
MCP behavior keeps immediate persistence. HTTP switches commit immediately, and
|
||||
HTTP inputs commit on blur or Enter. These dialogs explicitly say that changes
|
||||
apply immediately and use Close. Closing them does not undo saved changes.
|
||||
Client registration and token regeneration require their own explicit actions.
|
||||
|
||||
## HTTP failure states
|
||||
|
||||
Set `window.__aiToolQa.rejectHttp = true` to return a failed HTTP configuration
|
||||
result, or `throwHttp = true` to throw instead. For a failure after runtime
|
||||
polling, also set `holdHttp = true`, edit a port, and wait for a poll. Release the
|
||||
pending write with `window.__aiToolQa.pendingHttp.splice(0).forEach(resolve => resolve())`.
|
||||
Set `holdHttp = false` before retrying. These controls affect the in-memory stub.
|
||||
|
||||
`rejectRotate` and `throwRotate` cover explicit token regeneration failures.
|
||||
Configuration and rotation failures appear in the HTTP dialog and overview.
|
||||
Retry resends the saved configuration or repeats the explicit rotation action.
|
||||
Clipboard errors appear in their active dialog; the selectable preview remains.
|
||||
@@ -0,0 +1,62 @@
|
||||
// Test-only IPC replacement. Inject BEFORE loading the real settings renderer.
|
||||
// This function is self-contained so Playwright and an isolated Electron preload
|
||||
// can both serialize it. Every service mutation is synthetic and recorded.
|
||||
function installAiToolAccessFixture(options = {}) {
|
||||
if (location.hostname !== '127.0.0.1' && location.hostname !== 'localhost') throw new Error('QA fixture requires a loopback origin');
|
||||
const calls = [];
|
||||
const qa = { calls, rejectPrivacy: false, rejectHttp: false, throwHttp: false, holdHttp: false, pendingHttp: [], rejectRotate: false, throwRotate: false, holdStatus: false, pending: [], holdClients: options.holdClients || false, pendingClients: [] };
|
||||
window.__aiToolQa = qa;
|
||||
localStorage.clear();
|
||||
localStorage.setItem('netcatty_ui_language_v1', options.locale || 'en');
|
||||
localStorage.setItem('netcatty_theme_v1', options.theme || 'dark');
|
||||
localStorage.setItem('netcatty_ai_external_mcp_enabled_v1', String(options.enabled ?? true));
|
||||
localStorage.setItem('netcatty_ai_external_mcp_mode_v1', 'persistent');
|
||||
let status = {
|
||||
ok: true, enabled: options.enabled ?? true, state: options.enabled === false ? 'disabled' : 'running',
|
||||
exposedSessionCount: 2, permissionMode: 'confirm',
|
||||
launcherPath: '/fixture/netcatty/launcher', discoveryPath: '/fixture/netcatty/discovery.json',
|
||||
http: { enabled: false, listening: false, port: 47391, listenAddress: '127.0.0.1', clientHost: '', url: 'http://127.0.0.1:47391/mcp', bearerToken: null },
|
||||
};
|
||||
const clients = Object.fromEntries(['Codex', 'Claude', 'Grok'].map((name) => [name, { ok: true, state: 'not_configured', launcherPath: status.launcherPath }]));
|
||||
const record = (name, fn) => async (...args) => { calls.push({ name, args }); return fn(...args); };
|
||||
const applyConfig = (config) => {
|
||||
if (config.redaction && qa.rejectPrivacy) return { ok: false, error: 'Fixture: invalid CIDR or RE2 pattern.' };
|
||||
if ('httpEnabled' in config && qa.throwHttp) throw new Error('Fixture: HTTP configuration threw.');
|
||||
if (config.rotateHttpToken && qa.throwRotate) throw new Error('Fixture: token rotation threw.');
|
||||
if (config.rotateHttpToken && qa.rejectRotate) return { ok: false, error: 'Fixture: token rotation unavailable.' };
|
||||
if ('httpEnabled' in config && qa.rejectHttp) return { ...structuredClone(status), ok: false, error: 'Fixture: port unavailable.' };
|
||||
if ('httpEnabled' in config) {
|
||||
status.http = { ...status.http, enabled: config.httpEnabled, listening: config.httpEnabled && status.enabled,
|
||||
port: config.httpPort, listenAddress: config.httpAllowCustomBind ? config.httpListenAddress : '127.0.0.1',
|
||||
clientHost: config.httpClientHost, bearerToken: config.httpEnabled ? 'SYNTHETIC_FIXTURE_TOKEN_NEVER_REAL' : null };
|
||||
status.http.url = `http://${config.httpClientHost || '127.0.0.1'}:${config.httpPort}/mcp`;
|
||||
}
|
||||
if (config.rotateHttpToken) status.http.bearerToken = 'SYNTHETIC_ROTATED_FIXTURE_TOKEN';
|
||||
return structuredClone(status);
|
||||
};
|
||||
const bridge = {
|
||||
getAppLockRuntimeState: async () => ({ initialized: true, locked: false, reason: null, version: 1 }),
|
||||
externalMcpGetStatus: record('status', () => qa.holdStatus ? new Promise((resolve) => qa.pending.push(() => resolve(structuredClone(status)))) : structuredClone(status)),
|
||||
externalMcpSetEnabled: record('enable', (enabled) => { status.enabled = enabled; status.state = enabled ? 'running' : 'disabled'; return structuredClone(status); }),
|
||||
externalMcpSetConfig: record('config', (config) => {
|
||||
if ('httpEnabled' in config && qa.holdHttp) {
|
||||
return new Promise((resolve, reject) => qa.pendingHttp.push(() => {
|
||||
try { resolve(applyConfig(config)); } catch (error) { reject(error); }
|
||||
}));
|
||||
}
|
||||
return applyConfig(config);
|
||||
}),
|
||||
externalMcpGetUniversalSetupPrompt: async () => ({ ok: true, prompt: 'Synthetic setup prompt: register the fixture launcher as netcatty-external.' }),
|
||||
aiSkillsCliGetInvocation: async () => ({ ok: true, skillPath: '/fixture/netcatty/skills/SKILL.md', commandPrefix: 'netcatty-tool-cli' }),
|
||||
aiUserSkillsGetStatus: async () => ({ ok: true, directoryPath: '/fixture/netcatty/skills', readyCount: 0, warningCount: 0, skills: [] }),
|
||||
aiResolveCli: async () => ({ installed: false, available: false }),
|
||||
aiCodexGetIntegrationStatus: async () => ({ ok: true, available: false }),
|
||||
aiCodexAppServerGetStatus: async () => ({ ok: true, available: false }),
|
||||
};
|
||||
for (const name of ['Codex', 'Claude', 'Grok']) {
|
||||
bridge[`externalMcp${name}GetStatus`] = record(`${name}Status`, () => qa.holdClients && name === 'Codex' ? new Promise((resolve) => qa.pendingClients.push(() => resolve(structuredClone(clients[name])))) : structuredClone(clients[name]));
|
||||
bridge[`externalMcp${name}Add`] = record(`${name}Add`, () => { clients[name].state = 'configured'; return structuredClone(clients[name]); });
|
||||
}
|
||||
Object.defineProperty(window, 'netcatty', { value: bridge, configurable: true });
|
||||
}
|
||||
module.exports = { installAiToolAccessFixture };
|
||||
@@ -0,0 +1,173 @@
|
||||
// Run against Vite in a fresh browser context. Requires Playwright in the runner.
|
||||
// Screenshots from this script are browser evidence, not native Electron acceptance.
|
||||
const assert = require('node:assert/strict');
|
||||
const { chromium } = require('playwright');
|
||||
const { installAiToolAccessFixture } = require('./bridge.cjs');
|
||||
const base = process.env.NETCATTY_QA_URL || 'http://127.0.0.1:5173';
|
||||
const output = process.env.NETCATTY_QA_OUTPUT || '/tmp/netcatty-ai-tool-qa';
|
||||
const { mkdirSync } = require('node:fs');
|
||||
mkdirSync(output, { recursive: true });
|
||||
|
||||
(async () => {
|
||||
const browser = await chromium.launch({ executablePath: process.env.CHROMIUM_PATH || '/usr/bin/chromium', headless: true, args: ['--no-sandbox'] });
|
||||
try {
|
||||
const context = await browser.newContext({ viewport: { width: 1200, height: 900 }, permissions: ['clipboard-read', 'clipboard-write'] });
|
||||
await context.addInitScript(installAiToolAccessFixture, { locale: 'en', theme: 'dark', holdClients: true });
|
||||
await context.route('**/*', (route) => new URL(route.request().url()).origin === new URL(base).origin ? route.continue() : route.abort());
|
||||
const page = await context.newPage();
|
||||
const errors = [];
|
||||
page.on('pageerror', (error) => errors.push(error.message));
|
||||
await page.goto(`${base}/#/settings`);
|
||||
await page.getByRole('tab', { name: 'AI', exact: true }).click();
|
||||
await page.getByRole('tab', { name: 'Tool Access', exact: true }).click();
|
||||
await page.getByRole('button', { name: 'Configure privacy', exact: true }).waitFor();
|
||||
const mcpHeader = page.locator('[data-settings-anchor="ai-external-mcp"]').locator(':scope > div').first();
|
||||
const mcpTitleBox = await mcpHeader.getByRole('heading', { name: 'External MCP', exact: true }).boundingBox();
|
||||
const mcpPill = mcpHeader.getByRole('status');
|
||||
const mcpPillBox = await mcpPill.boundingBox();
|
||||
assert.ok(Math.abs(mcpTitleBox.y + mcpTitleBox.height / 2 - mcpPillBox.y - mcpPillBox.height / 2) <= 1, 'MCP title and status pill share a vertical center');
|
||||
assert.equal(await mcpPill.getAttribute('tabindex'), null);
|
||||
assert.equal(await mcpPill.evaluate((el) => getComputedStyle(el).borderTopWidth), '0px');
|
||||
const httpHeader = page.locator('[data-settings-anchor="ai-external-mcp-http"]').locator(':scope > div').first();
|
||||
assert.match(await httpHeader.getByRole('status').innerText(), /Not listening/);
|
||||
|
||||
const dialog = () => page.getByRole('dialog');
|
||||
const close = async () => { await dialog().getByRole('button', { name: 'Close', exact: true }).last().click(); await dialog().waitFor({ state: 'hidden' }); };
|
||||
const mutations = () => page.evaluate(() => window.__aiToolQa.calls.filter((call) => ['config', 'enable', 'CodexAdd', 'ClaudeAdd', 'GrokAdd'].includes(call.name)));
|
||||
const initial = (await mutations()).length;
|
||||
await page.screenshot({ path: `${output}/main-en-dark.png` });
|
||||
await page.getByRole('button', { name: 'View setup prompt' }).click();
|
||||
assert.match(await dialog().innerText(), /fixture\/netcatty\/launcher/);
|
||||
await dialog().getByRole('button', { name: 'Copy', exact: true }).click();
|
||||
assert.match(await page.evaluate(() => navigator.clipboard.readText()), /get_environment/);
|
||||
await page.keyboard.press('Escape');
|
||||
assert.equal(await page.getByRole('button', { name: 'View setup prompt' }).evaluate((el) => el === document.activeElement), true);
|
||||
await page.getByRole('button', { name: 'Configure behavior' }).click();
|
||||
assert.match(await dialog().innerText(), /immediately/);
|
||||
assert.equal(await dialog().getByRole('spinbutton', { name: 'Idle timeout', exact: true }).count(), 0);
|
||||
await dialog().getByRole('combobox').nth(1).click();
|
||||
await page.getByRole('option', { name: 'Temporary', exact: true }).click();
|
||||
await dialog().getByRole('spinbutton', { name: 'Idle timeout', exact: true }).waitFor();
|
||||
await close();
|
||||
await page.getByRole('button', { name: 'Configure clients' }).click();
|
||||
assert.equal(await dialog().getByRole('button', { name: 'Add to Codex', exact: true }).isEnabled(), false);
|
||||
await page.waitForTimeout(3200); // At least one quiet runtime poll must overlap client discovery.
|
||||
await page.evaluate(() => { window.__aiToolQa.holdClients = false; window.__aiToolQa.pendingClients.splice(0).forEach((resolve) => resolve()); });
|
||||
await dialog().getByRole('button', { name: 'Add to Codex', exact: true }).and(page.locator(':enabled')).waitFor();
|
||||
const beforeAdd = (await mutations()).length;
|
||||
await dialog().getByRole('tab', { name: 'Cursor', exact: true }).click();
|
||||
await dialog().getByRole('tab', { name: 'Other', exact: true }).click();
|
||||
assert.equal((await mutations()).length, beforeAdd, 'opening and switching clients must not install anything');
|
||||
await dialog().getByText('Manual setup', { exact: true }).click();
|
||||
await dialog().getByLabel('Format', { exact: true }).selectOption('prompt');
|
||||
assert.match(await dialog().innerText(), /Synthetic setup prompt/);
|
||||
await dialog().getByRole('tab', { name: 'Codex', exact: true }).click();
|
||||
await dialog().getByRole('button', { name: 'Add to Codex', exact: true }).click();
|
||||
await dialog().getByText('Configured', { exact: true }).waitFor();
|
||||
assert.equal((await page.evaluate(() => window.__aiToolQa.calls.filter((call) => call.name === 'CodexAdd'))).length, 1);
|
||||
await close();
|
||||
await page.getByRole('button', { name: 'Configure HTTP' }).click();
|
||||
assert.match(await dialog().innerText(), /immediately/);
|
||||
assert.equal(await dialog().getByRole('button', { name: 'Regenerate token', exact: true }).isVisible(), false);
|
||||
const port = dialog().getByRole('spinbutton', { name: 'Port', exact: true });
|
||||
const beforeInvalid = (await mutations()).length;
|
||||
await port.fill('99');
|
||||
await port.blur();
|
||||
await dialog().getByRole('alert').waitFor();
|
||||
assert.equal(await port.inputValue(), '99');
|
||||
assert.equal((await mutations()).length, beforeInvalid);
|
||||
await port.fill('47400');
|
||||
await port.blur();
|
||||
assert.equal((await mutations()).length, beforeInvalid + 1);
|
||||
await page.evaluate(() => { window.__aiToolQa.rejectHttp = true; window.__aiToolQa.holdHttp = true; });
|
||||
await port.fill('47401');
|
||||
await port.blur();
|
||||
await dialog().getByText('Applying HTTP configuration...', { exact: true }).waitFor();
|
||||
await page.waitForTimeout(3200); // A quiet poll completes while the write is still pending.
|
||||
await page.evaluate(() => { window.__aiToolQa.holdHttp = false; window.__aiToolQa.pendingHttp.splice(0).forEach((resolve) => resolve()); });
|
||||
await dialog().getByRole('alert').filter({ hasText: 'Fixture: port unavailable.' }).waitFor();
|
||||
assert.equal(await port.inputValue(), '47401', 'rejected HTTP settings retain input');
|
||||
await page.screenshot({ path: `${output}/http-error-returned-en-dark.png` });
|
||||
await page.waitForTimeout(3200); // Runtime polling must not erase operation failures.
|
||||
assert.match(await dialog().innerText(), /Fixture: port unavailable/);
|
||||
await page.evaluate(() => { window.__aiToolQa.rejectHttp = false; window.__aiToolQa.throwHttp = true; });
|
||||
await dialog().getByRole('button', { name: 'Retry', exact: true }).click();
|
||||
await dialog().getByRole('alert').filter({ hasText: 'Fixture: HTTP configuration threw.' }).waitFor();
|
||||
assert.equal(await port.inputValue(), '47401', 'thrown HTTP configuration retains input');
|
||||
await page.screenshot({ path: `${output}/http-error-thrown-en-dark.png` });
|
||||
await page.evaluate(() => { window.__aiToolQa.throwHttp = false; });
|
||||
await dialog().getByRole('button', { name: 'Retry', exact: true }).click();
|
||||
await dialog().getByRole('alert').waitFor({ state: 'hidden' });
|
||||
await dialog().getByRole('switch', { name: 'Enable HTTP', exact: true }).click();
|
||||
await dialog().getByRole('switch', { name: 'Listen on another address', exact: true }).last().click();
|
||||
await dialog().getByRole('textbox', { name: 'Listen address', exact: true }).waitFor();
|
||||
await dialog().getByRole('textbox', { name: 'Client address', exact: true }).fill('192.0.2.10');
|
||||
await dialog().getByRole('textbox', { name: 'Client address', exact: true }).blur();
|
||||
await dialog().getByText('Connection details', { exact: true }).click();
|
||||
assert.equal(await dialog().locator('pre').filter({ hasText: 'SYNTHETIC_FIXTURE_TOKEN_NEVER_REAL' }).count(), 0, 'all token-bearing previews start masked');
|
||||
await page.screenshot({ path: `${output}/http-masked-en-dark.png` });
|
||||
await dialog().getByRole('button', { name: 'Show secret', exact: true }).first().click();
|
||||
assert.equal(await dialog().locator('pre').filter({ hasText: 'SYNTHETIC_FIXTURE_TOKEN_NEVER_REAL' }).count(), 1);
|
||||
await close();
|
||||
await page.getByRole('button', { name: 'Configure HTTP' }).click();
|
||||
await dialog().getByText('Connection details', { exact: true }).click();
|
||||
assert.equal(await dialog().locator('pre').filter({ hasText: 'SYNTHETIC_FIXTURE_TOKEN_NEVER_REAL' }).count(), 0);
|
||||
assert.equal(await port.inputValue(), '47401');
|
||||
await close();
|
||||
await page.getByRole('button', { name: 'Configure HTTP' }).click();
|
||||
await dialog().getByText('Connection details', { exact: true }).click();
|
||||
await page.evaluate(() => { window.__aiToolQa.rejectRotate = true; });
|
||||
await dialog().getByRole('button', { name: 'Regenerate', exact: true }).click();
|
||||
await dialog().getByRole('alert').filter({ hasText: 'Fixture: token rotation unavailable.' }).waitFor();
|
||||
await page.evaluate(() => { window.__aiToolQa.rejectRotate = false; window.__aiToolQa.throwRotate = true; });
|
||||
await dialog().getByRole('button', { name: 'Retry', exact: true }).click();
|
||||
await dialog().getByRole('alert').filter({ hasText: 'Fixture: token rotation threw.' }).waitFor();
|
||||
await page.evaluate(() => { window.__aiToolQa.throwRotate = false; });
|
||||
await dialog().getByRole('button', { name: 'Retry', exact: true }).click();
|
||||
await dialog().getByRole('alert').waitFor({ state: 'hidden' });
|
||||
await page.evaluate(() => {
|
||||
window.__qaWriteText = navigator.clipboard.writeText.bind(navigator.clipboard);
|
||||
navigator.clipboard.writeText = async () => { throw new Error('Fixture: clipboard unavailable'); };
|
||||
});
|
||||
await dialog().getByRole('button', { name: 'Copy', exact: true }).first().click();
|
||||
await dialog().getByRole('alert').waitFor();
|
||||
assert.match(await dialog().innerText(), /copy/i);
|
||||
await page.evaluate(() => { navigator.clipboard.writeText = window.__qaWriteText; });
|
||||
await dialog().getByRole('button', { name: 'Copy', exact: true }).first().click();
|
||||
await dialog().getByRole('alert').waitFor({ state: 'hidden' });
|
||||
await close();
|
||||
await page.getByRole('button', { name: 'Configure privacy', exact: true }).click();
|
||||
const beforeDraft = (await mutations()).length;
|
||||
await dialog().getByRole('textbox', { name: 'Additional IP ranges (CIDR)', exact: true }).fill('192.0.2.0/24');
|
||||
await dialog().getByRole('button', { name: 'Cancel', exact: true }).click();
|
||||
assert.equal((await mutations()).length, beforeDraft);
|
||||
await page.getByRole('button', { name: 'Configure privacy', exact: true }).click();
|
||||
assert.equal(await dialog().getByRole('textbox', { name: 'Additional IP ranges (CIDR)', exact: true }).inputValue(), '');
|
||||
await dialog().getByRole('textbox', { name: 'Custom regular expressions', exact: true }).fill('[');
|
||||
await page.evaluate(() => { window.__aiToolQa.rejectPrivacy = true; });
|
||||
await dialog().getByRole('button', { name: 'Apply', exact: true }).click();
|
||||
await dialog().getByRole('alert').waitFor();
|
||||
assert.equal(await dialog().getByRole('textbox', { name: 'Custom regular expressions', exact: true }).inputValue(), '[');
|
||||
await page.screenshot({ path: `${output}/privacy-error-en-dark.png` });
|
||||
await page.keyboard.press('Escape');
|
||||
await page.getByRole('button', { name: 'Search settings', exact: true }).click();
|
||||
await page.getByRole('combobox', { name: 'Search settings…', exact: true }).fill('Streamable HTTP');
|
||||
await page.getByRole('option', { name: /Streamable HTTP/ }).click();
|
||||
await dialog().getByRole('heading', { name: 'Streamable HTTP', exact: true }).waitFor();
|
||||
await close();
|
||||
await page.getByRole('button', { name: 'Search settings', exact: true }).click();
|
||||
await page.getByRole('combobox', { name: 'Search settings…', exact: true }).fill('Approval alerts');
|
||||
await page.getByRole('option', { name: /Approval alerts/ }).click();
|
||||
await dialog().getByRole('heading', { name: 'MCP behavior', exact: true }).waitFor();
|
||||
await close();
|
||||
await page.setViewportSize({ width: 640, height: 580 });
|
||||
await page.getByRole('button', { name: 'Configure behavior' }).click();
|
||||
const bounds = await dialog().boundingBox();
|
||||
assert.ok(bounds.x >= 0 && bounds.y >= 0 && bounds.width <= 640 && bounds.height <= 580);
|
||||
await close();
|
||||
assert.deepEqual(errors, []);
|
||||
assert.ok((await mutations()).length > initial);
|
||||
console.log('PASS: actual settings renderer; guidance/copy/focus, behavior conditionals, explicit client install, HTTP validation/immediate commit/masking/returned-and-thrown-config-and-rotation-errors/retry/copy-failure, privacy failure/cancel/reopen, narrow viewport.');
|
||||
await context.close();
|
||||
} finally { await browser.close(); }
|
||||
})().catch((error) => { console.error(error); process.exitCode = 1; });
|
||||
Reference in new issue
Block a user