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:
陈大猫 authored and GitHub committed 2026-10-27 20:59:11 -07:00
1 parent 631d04b654
commit 30a74f7c1d
21 files changed
+1218 -414

No files matched your search

+23
View File
@@ -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...",
};
+23
View File
@@ -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...",
};
+23
View File
@@ -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...",
};
+23
View File
@@ -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 配置…",
};
+23
View File
@@ -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
+3
View File
@@ -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>}
+28 -28
View File
@@ -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(); }
});
+195 -177
View File
@@ -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>
);
+72
View File
@@ -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; });