mirror of
https://github.com/binaricat/Netcatty.git
synced 2026-10-31 19:02:31 +00:00
Compare commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
b3bf7c25fc | ||
|
|
d350b02f78 | ||
|
|
d8f43a7138 | ||
|
|
34c3431483 | ||
|
|
faeaa238e9 | ||
|
|
5be4731c4d | ||
|
|
ebc1ac7598 | ||
|
|
570b211e71 | ||
|
|
e61992a911 | ||
|
|
0e79f2193d | ||
|
|
c27d904f23 |
No files matched your search
@@ -9,6 +9,12 @@
|
||||
<a href="https://netcatty.app"><strong>netcatty.app</strong></a>
|
||||
</p>
|
||||
|
||||
<p align="center">
|
||||
<a href="https://trendshift.io/repositories/18391?utm_source=trendshift-badge&utm_medium=badge&utm_campaign=badge-trendshift-18391" target="_blank" rel="noopener noreferrer">
|
||||
<img src="https://trendshift.io/api/badge/trendshift/repositories/18391/daily?language=TypeScript" alt="binaricat/Netcatty | Trendshift" width="250" height="55"/>
|
||||
</a>
|
||||
</p>
|
||||
|
||||
<p align="center">
|
||||
A beautiful, feature-rich SSH workspace built with Electron, React, and xterm.js.<br/>
|
||||
🔥 Built-in AI Agent · Split terminals · Vault views · SFTP workflows · Custom themes — all in one.
|
||||
|
||||
@@ -289,6 +289,14 @@ export const enAiMessages: Messages = {
|
||||
'ai.opencode.customPathPlaceholder': 'e.g. /usr/local/bin/opencode',
|
||||
'ai.opencode.check': 'Check',
|
||||
'ai.opencode.resetPath': 'Reset',
|
||||
'ai.opencode.v2.startupTimeout': 'OpenCode 2 did not become ready in time. Confirm `opencode serve` works in a terminal, then retry.',
|
||||
'ai.opencode.v2.exited': 'OpenCode 2 stopped before the chat could start.',
|
||||
'ai.opencode.v2.unauthorized': 'OpenCode 2 refused the connection. Retry the chat.',
|
||||
'ai.opencode.v2.unavailable': 'OpenCode 2 could not be reached. Check that it is installed and try again.',
|
||||
'ai.opencode.v2.sessionFailed': 'OpenCode 2 could not finish this turn.',
|
||||
'ai.opencode.v2.empty': 'OpenCode 2 returned an empty response. Run `opencode` in a terminal to configure authentication and models.',
|
||||
'ai.opencode.v2.contextSkipped': 'OpenCode 2 could not apply the Netcatty conversation context.',
|
||||
'ai.opencode.v2.mcpSkipped': 'OpenCode 2 could not attach an MCP tool. The chat will continue without it.',
|
||||
|
||||
// AI MiMo Code (Xiaomi MiMo Code CLI — OpenCode fork)
|
||||
'ai.mimo.title': 'MiMo Code',
|
||||
@@ -376,6 +384,22 @@ export const enAiMessages: Messages = {
|
||||
'ai.externalMcp.help.ariaLabel': 'External MCP help',
|
||||
'ai.externalMcp.security': 'Security',
|
||||
'ai.externalMcp.security.description': 'Listens on 127.0.0.1 with a rotating token, reuses AI Permission Mode for writes, and removes discovery when disabled. No OAuth — this is a local desktop bridge.',
|
||||
"ai.privacy.title": "AI privacy",
|
||||
"ai.privacy.description": "Filters known sensitive text patterns in Netcatty-managed context and tool results for Catty, integrated agents and external MCP clients. Off by default. It cannot detect every secret. Binary tool responses are blocked while enabled.",
|
||||
"ai.privacy.boundary": "Only Netcatty-managed data and tools are covered. Third-party agents' native tools, direct file access and other MCP servers remain outside this boundary.",
|
||||
"ai.privacy.enable": "Enable text redaction",
|
||||
"ai.privacy.privateIps": "Redact private IP addresses",
|
||||
"ai.privacy.privateIps.description": "Also hide private and local IP addresses in returned text.",
|
||||
"ai.privacy.cidrs": "Additional IP ranges (CIDR)",
|
||||
"ai.privacy.cidrs.description": "One IPv4 or IPv6 CIDR per line. Leave empty to use only built-in rules.",
|
||||
"ai.privacy.patterns": "Custom regular expressions",
|
||||
"ai.privacy.patterns.description": "One RE2-compatible pattern per line, without / delimiters or flags. Unsupported or unsafe patterns are rejected.",
|
||||
"ai.privacy.connectionScope": "Placeholders stay stable within one managed conversation or external MCP connection. Original values are restored only through Netcatty-managed tool calls in that same scope.",
|
||||
"ai.privacy.reconnect": "Apply changes, then start a new managed conversation and reconnect external MCP clients. Existing conversations and connections must not reuse placeholders.",
|
||||
"ai.privacy.apply": "Apply",
|
||||
"ai.privacy.applying": "Applying...",
|
||||
"ai.privacy.applied": "AI privacy settings applied. Start a new managed conversation and reconnect external MCP clients.",
|
||||
"ai.privacy.failed": "AI privacy settings were not applied.",
|
||||
'ai.externalMcp.http.title': 'Streamable HTTP',
|
||||
'ai.externalMcp.http.description': 'Serve the same External MCP tools over Streamable HTTP for clients that cannot launch the stdio CLI, such as Codex inside WSL. Off by default.',
|
||||
'ai.externalMcp.http.enable': 'Enable HTTP',
|
||||
|
||||
@@ -864,8 +864,8 @@ Highlight the focused split pane:
|
||||
'settings.terminal.systemManager.processRefreshInterval.desc': 'How often to refresh the process list in the system manager side panel.',
|
||||
'settings.terminal.systemManager.tmuxRefreshInterval': 'tmux session refresh',
|
||||
'settings.terminal.systemManager.tmuxRefreshInterval.desc': 'How often to refresh the tmux session list.',
|
||||
'settings.terminal.systemManager.dockerListRefreshInterval': 'Docker container list refresh',
|
||||
'settings.terminal.systemManager.dockerListRefreshInterval.desc': 'How often to refresh the Docker container list.',
|
||||
'settings.terminal.systemManager.dockerListRefreshInterval': 'Container list refresh',
|
||||
'settings.terminal.systemManager.dockerListRefreshInterval.desc': 'How often to refresh container and pod lists.',
|
||||
'settings.terminal.systemManager.dockerStatsRefreshInterval': 'Docker stats refresh',
|
||||
'settings.terminal.systemManager.dockerStatsRefreshInterval.desc': 'How often to refresh Docker container CPU/memory/network stats.',
|
||||
'settings.terminal.serverStats.show': 'Show Server Stats',
|
||||
@@ -1180,6 +1180,7 @@ Highlight the focused split pane:
|
||||
'vault.hosts.moveSelected': 'Move to group ({count})',
|
||||
'vault.hosts.moveSelected.description': 'Move the selected hosts to an existing group, or leave them ungrouped. Selected groups are not moved.',
|
||||
'vault.hosts.moveMultiple.success': 'Moved {count} hosts to {group}',
|
||||
'vault.hosts.dragSelectionCount': '{count} hosts',
|
||||
'vault.hosts.moveToGroup.success': 'Moved {host} to {group}',
|
||||
'vault.hosts.errors.nameRequired': 'Host name is required.',
|
||||
'vault.hosts.empty.title': 'Set up your hosts',
|
||||
|
||||
@@ -26,6 +26,11 @@ export const enSystemManagerMessages: Messages = {
|
||||
'systemManager.errors.loadDocker': 'Failed to load containers',
|
||||
'systemManager.errors.loadDockerStats': 'Failed to load container stats',
|
||||
'systemManager.errors.loadDockerImages': 'Failed to load images',
|
||||
'systemManager.errors.loadKubernetes': 'Failed to load Kubernetes resources',
|
||||
'systemManager.errors.loadPods': 'Failed to load pods',
|
||||
'systemManager.errors.nerdctlUnreachable': "nerdctl can't reach containerd",
|
||||
'systemManager.errors.clusterUnreachable': "Can't reach the cluster for context {{context}}",
|
||||
'systemManager.errors.clusterUnreachableGeneric': "Can't reach the cluster",
|
||||
'systemManager.errors.loadOverview': 'Failed to load system overview',
|
||||
'systemManager.errors.loadGpu': 'Failed to load GPU / NPU stats',
|
||||
'systemManager.errors.loadPorts': 'Failed to load listening ports',
|
||||
@@ -132,7 +137,7 @@ export const enSystemManagerMessages: Messages = {
|
||||
'systemManager.tmux.newWindowPlaceholder': 'Window name (optional)',
|
||||
'systemManager.tmux.noWindows': 'No windows',
|
||||
'systemManager.tmux.unavailable': 'tmux is not available on this host',
|
||||
'systemManager.docker.unavailable': 'Docker is not available on this host',
|
||||
'systemManager.docker.unavailable': 'No container runtime (Docker, Podman, nerdctl, or kubectl) was detected on this host',
|
||||
'systemManager.tmux.windowsMismatch': 'Session reports {{count}} window(s) but list-windows returned none',
|
||||
'systemManager.tmux.lastCommand': 'last command: {{command}}',
|
||||
'systemManager.tmux.noPanes': 'No panes',
|
||||
@@ -194,6 +199,36 @@ export const enSystemManagerMessages: Messages = {
|
||||
'systemManager.docker.imagesMeta': '{{count}} image(s)',
|
||||
'systemManager.docker.start': 'Start',
|
||||
'systemManager.docker.stop': 'Stop',
|
||||
'systemManager.runtime.switch': 'Container runtime',
|
||||
'systemManager.runtime.docker': 'Docker',
|
||||
'systemManager.runtime.podman': 'Podman',
|
||||
'systemManager.runtime.nerdctl': 'nerdctl',
|
||||
'systemManager.runtime.kubernetes': 'Kubernetes',
|
||||
'systemManager.k8s.context': 'Context',
|
||||
'systemManager.k8s.namespace': 'Namespace',
|
||||
'systemManager.k8s.empty': 'No pods found',
|
||||
'systemManager.k8s.search': 'Search pods…',
|
||||
'systemManager.k8s.meta': '{{count}} pod(s)',
|
||||
'systemManager.k8s.describe': 'Describe',
|
||||
'systemManager.k8s.delete': 'Delete pod',
|
||||
'systemManager.k8s.confirmDelete': 'Delete pod "{{name}}"?',
|
||||
'systemManager.k8s.restarts': '{{count}} restarts',
|
||||
'systemManager.k8s.chooseContainer': 'Choose a container',
|
||||
'systemManager.k8s.noContext': 'No Kubernetes context on this host',
|
||||
'systemManager.k8s.status.Running': 'Running',
|
||||
'systemManager.k8s.status.Pending': 'Pending',
|
||||
'systemManager.k8s.status.Completed': 'Completed',
|
||||
'systemManager.k8s.status.Succeeded': 'Succeeded',
|
||||
'systemManager.k8s.status.Failed': 'Failed',
|
||||
'systemManager.k8s.status.Unknown': 'Unknown',
|
||||
'systemManager.k8s.status.CrashLoopBackOff': 'CrashLoopBackOff',
|
||||
'systemManager.k8s.status.ContainerCreating': 'ContainerCreating',
|
||||
'systemManager.k8s.status.Terminating': 'Terminating',
|
||||
'systemManager.k8s.status.ImagePullBackOff': 'ImagePullBackOff',
|
||||
'systemManager.k8s.status.ErrImagePull': 'ErrImagePull',
|
||||
'systemManager.k8s.status.Error': 'Error',
|
||||
'systemManager.k8s.status.Evicted': 'Evicted',
|
||||
'systemManager.k8s.status.OOMKilled': 'OOMKilled',
|
||||
|
||||
'systemManager.ports.unavailable': 'No listening-port tools (ss / netstat) detected on this host.',
|
||||
'systemManager.ports.loading': 'Loading listening ports…',
|
||||
|
||||
@@ -367,6 +367,22 @@ export const esAiMessages: Messages = {
|
||||
'ai.externalMcp.help.ariaLabel': 'Ayuda del MCP externo',
|
||||
'ai.externalMcp.security': 'Seguridad',
|
||||
'ai.externalMcp.security.description': 'Escucha en 127.0.0.1 con un token rotativo, reutiliza el modo de permisos de IA para las escrituras y elimina el descubrimiento al desactivarse. Sin OAuth: este es un puente local de escritorio.',
|
||||
"ai.privacy.title": "Privacidad de IA",
|
||||
"ai.privacy.description": "Filtra patrones de texto sensibles conocidos en el contexto y los resultados de herramientas gestionados por Netcatty para Catty, agentes integrados y clientes MCP externos. Desactivado de forma predeterminada. No detecta todos los secretos. Las respuestas binarias de herramientas se bloquean mientras está activo.",
|
||||
"ai.privacy.boundary": "Solo cubre los datos y las herramientas gestionados por Netcatty. Las herramientas nativas de agentes de terceros, su acceso directo a archivos y otros servidores MCP quedan fuera de esta protección.",
|
||||
"ai.privacy.enable": "Activar ocultación de texto",
|
||||
"ai.privacy.privateIps": "Ocultar direcciones IP privadas",
|
||||
"ai.privacy.privateIps.description": "Oculta también las direcciones IP privadas y locales en el texto devuelto.",
|
||||
"ai.privacy.cidrs": "Rangos IP adicionales (CIDR)",
|
||||
"ai.privacy.cidrs.description": "Un CIDR IPv4 o IPv6 por línea. Déjalo vacío para usar solo las reglas integradas.",
|
||||
"ai.privacy.patterns": "Expresiones regulares personalizadas",
|
||||
"ai.privacy.patterns.description": "Un patrón compatible con RE2 por línea, sin delimitadores / ni indicadores. Los patrones incompatibles o inseguros se rechazan.",
|
||||
"ai.privacy.connectionScope": "Los marcadores son estables dentro de una conversación gestionada o una conexión MCP externa. Los valores originales solo se restauran mediante llamadas a herramientas gestionadas por Netcatty dentro del mismo ámbito.",
|
||||
"ai.privacy.reconnect": "Aplica los cambios, inicia una nueva conversación gestionada y vuelve a conectar los clientes MCP externos. Las conversaciones y conexiones existentes no deben reutilizar marcadores.",
|
||||
"ai.privacy.apply": "Aplicar",
|
||||
"ai.privacy.applying": "Aplicando...",
|
||||
"ai.privacy.applied": "Configuración de privacidad de IA aplicada. Inicia una nueva conversación gestionada y vuelve a conectar los clientes MCP externos.",
|
||||
"ai.privacy.failed": "No se aplicó la configuración de privacidad de IA.",
|
||||
'ai.externalMcp.http.title': 'Streamable HTTP',
|
||||
'ai.externalMcp.http.description': 'Expone las mismas herramientas de MCP externo por Streamable HTTP para clientes que no pueden iniciar el CLI stdio, como Codex dentro de WSL. Desactivado de forma predeterminada.',
|
||||
'ai.externalMcp.http.enable': 'Activar HTTP',
|
||||
|
||||
@@ -779,8 +779,8 @@ Resalta el panel dividido enfocado:
|
||||
'settings.terminal.systemManager.processRefreshInterval.desc': 'Con qué frecuencia actualizar la lista de procesos en el panel lateral del administrador del sistema.',
|
||||
'settings.terminal.systemManager.tmuxRefreshInterval': 'Actualización de sesiones tmux',
|
||||
'settings.terminal.systemManager.tmuxRefreshInterval.desc': 'Con qué frecuencia actualizar la lista de sesiones tmux.',
|
||||
'settings.terminal.systemManager.dockerListRefreshInterval': 'Actualización de la lista de contenedores Docker',
|
||||
'settings.terminal.systemManager.dockerListRefreshInterval.desc': 'Con qué frecuencia actualizar la lista de contenedores Docker.',
|
||||
'settings.terminal.systemManager.dockerListRefreshInterval': 'Actualización de la lista de contenedores',
|
||||
'settings.terminal.systemManager.dockerListRefreshInterval.desc': 'Con qué frecuencia actualizar las listas de contenedores y pods.',
|
||||
'settings.terminal.systemManager.dockerStatsRefreshInterval': 'Actualización de estadísticas Docker',
|
||||
'settings.terminal.systemManager.dockerStatsRefreshInterval.desc': 'Con qué frecuencia actualizar las estadísticas de CPU/memoria/red de los contenedores Docker.',
|
||||
'settings.terminal.serverStats.show': 'Mostrar estadísticas del servidor',
|
||||
@@ -1139,6 +1139,7 @@ Resalta el panel dividido enfocado:
|
||||
'vault.hosts.moveSelected': 'Mover al grupo ({count})',
|
||||
'vault.hosts.moveSelected.description': 'Mueve los hosts seleccionados a un grupo existente o los deja sin grupo. Los grupos seleccionados no se mueven.',
|
||||
'vault.hosts.moveMultiple.success': 'Se movieron {count} hosts a {group}',
|
||||
'vault.hosts.dragSelectionCount': '{count} hosts',
|
||||
'vault.hosts.moveToGroup.success': 'Se movió {host} a {group}',
|
||||
'vault.hosts.errors.nameRequired': 'Se requiere el nombre del host.',
|
||||
'vault.hosts.empty.title': 'Configura tus hosts',
|
||||
|
||||
@@ -26,6 +26,8 @@ export const esSystemManagerMessages: Messages = {
|
||||
'systemManager.errors.loadDocker': 'No se pudieron cargar los contenedores',
|
||||
'systemManager.errors.loadDockerStats': 'No se pudieron cargar las estadísticas de los contenedores',
|
||||
'systemManager.errors.loadDockerImages': 'No se pudieron cargar las imágenes',
|
||||
'systemManager.errors.loadKubernetes': 'No se pudieron cargar los recursos de Kubernetes',
|
||||
'systemManager.errors.loadPods': 'No se pudieron cargar los pods',
|
||||
'systemManager.errors.loadOverview': 'No se pudo cargar el resumen del sistema',
|
||||
'systemManager.errors.loadGpu': 'No se pudieron cargar las estadísticas de GPU / NPU',
|
||||
'systemManager.errors.loadPorts': 'No se pudieron cargar los puertos en escucha',
|
||||
@@ -132,7 +134,7 @@ export const esSystemManagerMessages: Messages = {
|
||||
'systemManager.tmux.newWindowPlaceholder': 'Nombre de la ventana (opcional)',
|
||||
'systemManager.tmux.noWindows': 'No hay ventanas',
|
||||
'systemManager.tmux.unavailable': 'tmux no está disponible en este host',
|
||||
'systemManager.docker.unavailable': 'Docker no está disponible en este host',
|
||||
'systemManager.docker.unavailable': 'No se detectó ningún runtime de contenedores (Docker, Podman, nerdctl o kubectl) en este host',
|
||||
'systemManager.tmux.windowsMismatch': 'La sesión reporta {{count}} ventana(s), pero list-windows no devolvió ninguna',
|
||||
'systemManager.tmux.lastCommand': 'último comando: {{command}}',
|
||||
'systemManager.tmux.noPanes': 'No hay paneles',
|
||||
@@ -194,6 +196,22 @@ export const esSystemManagerMessages: Messages = {
|
||||
'systemManager.docker.imagesMeta': '{{count}} imagen(es)',
|
||||
'systemManager.docker.start': 'Iniciar',
|
||||
'systemManager.docker.stop': 'Detener',
|
||||
'systemManager.runtime.switch': 'Runtime de contenedores',
|
||||
'systemManager.runtime.docker': 'Docker',
|
||||
'systemManager.runtime.podman': 'Podman',
|
||||
'systemManager.runtime.nerdctl': 'nerdctl',
|
||||
'systemManager.runtime.kubernetes': 'Kubernetes',
|
||||
'systemManager.k8s.context': 'Contexto',
|
||||
'systemManager.k8s.namespace': 'Espacio de nombres',
|
||||
'systemManager.k8s.empty': 'No se encontraron pods',
|
||||
'systemManager.k8s.search': 'Buscar pods…',
|
||||
'systemManager.k8s.meta': '{{count}} pod(s)',
|
||||
'systemManager.k8s.describe': 'Describir',
|
||||
'systemManager.k8s.delete': 'Eliminar pod',
|
||||
'systemManager.k8s.confirmDelete': '¿Eliminar el pod "{{name}}"?',
|
||||
'systemManager.k8s.restarts': '{{count}} reinicios',
|
||||
'systemManager.k8s.chooseContainer': 'Elegir un contenedor',
|
||||
'systemManager.k8s.noContext': 'No hay un contexto de Kubernetes en este host',
|
||||
|
||||
'systemManager.ports.unavailable': 'No se detectaron herramientas de puertos en escucha (ss / netstat) en este host.',
|
||||
'systemManager.ports.loading': 'Cargando puertos en escucha…',
|
||||
|
||||
@@ -0,0 +1,23 @@
|
||||
import assert from 'node:assert/strict';
|
||||
import test from 'node:test';
|
||||
import en from './en.ts';
|
||||
import es from './es.ts';
|
||||
import ru from './ru.ts';
|
||||
import zhCN from './zh-CN.ts';
|
||||
import zhTW from './zh-TW.ts';
|
||||
|
||||
const KEYS = [
|
||||
'title', 'description', 'boundary', 'enable', 'privateIps', 'privateIps.description',
|
||||
'cidrs', 'cidrs.description', 'patterns', 'patterns.description',
|
||||
'connectionScope', 'reconnect', 'apply', 'applying', 'applied', 'failed',
|
||||
];
|
||||
|
||||
test('Shared AI privacy settings are localized in every supported locale', () => {
|
||||
for (const [name, messages] of Object.entries({ en, es, ru, 'zh-CN': zhCN, 'zh-TW': zhTW })) {
|
||||
assert.deepEqual(
|
||||
KEYS.filter((key) => !messages[`ai.privacy.${key}`]),
|
||||
[],
|
||||
`${name} is missing privacy settings labels`,
|
||||
);
|
||||
}
|
||||
});
|
||||
@@ -0,0 +1,39 @@
|
||||
import test from 'node:test';
|
||||
import assert from 'node:assert/strict';
|
||||
import en from './en';
|
||||
import zhCN from './zh-CN';
|
||||
import zhTW from './zh-TW';
|
||||
|
||||
const KEYS = [
|
||||
'ai.opencode.v2.startupTimeout',
|
||||
'ai.opencode.v2.exited',
|
||||
'ai.opencode.v2.unauthorized',
|
||||
'ai.opencode.v2.unavailable',
|
||||
'ai.opencode.v2.sessionFailed',
|
||||
'ai.opencode.v2.empty',
|
||||
'ai.opencode.v2.contextSkipped',
|
||||
'ai.opencode.v2.mcpSkipped',
|
||||
];
|
||||
|
||||
test('OpenCode 2 strings exist in English, Simplified Chinese, and Taiwan Chinese', () => {
|
||||
for (const key of KEYS) {
|
||||
assert.equal(typeof en[key], 'string', `en missing ${key}`);
|
||||
assert.equal(typeof zhCN[key], 'string', `zh-CN missing ${key}`);
|
||||
assert.equal(typeof zhTW[key], 'string', `zh-TW missing ${key}`);
|
||||
assert.notEqual(en[key], zhCN[key]);
|
||||
assert.notEqual(en[key], zhTW[key]);
|
||||
assert.notEqual(zhCN[key], zhTW[key]);
|
||||
}
|
||||
});
|
||||
|
||||
test('Taiwan OpenCode 2 wording uses Taiwan terms', () => {
|
||||
const text = KEYS.map((key) => zhTW[key]).join('\n');
|
||||
assert.match(text, /終端機/);
|
||||
assert.match(text, /連線/);
|
||||
assert.match(text, /設定/);
|
||||
assert.match(text, /傳回/);
|
||||
assert.equal(text.includes('终端'), false);
|
||||
assert.equal(text.includes('连接'), false);
|
||||
assert.equal(text.includes('设置'), false);
|
||||
assert.equal(text.includes('返回'), false);
|
||||
});
|
||||
@@ -323,6 +323,22 @@ export const ruAiMessages: Messages = {
|
||||
'ai.externalMcp.help.ariaLabel': 'Справка External MCP',
|
||||
'ai.externalMcp.security': 'Безопасность',
|
||||
'ai.externalMcp.security.description': 'Слушает только 127.0.0.1 с ротацией token, использует AI Permission Mode для записи и удаляет discovery при отключении.',
|
||||
"ai.privacy.title": "Конфиденциальность ИИ",
|
||||
"ai.privacy.description": "Фильтрует известные шаблоны конфиденциального текста в контексте и результатах инструментов, управляемых Netcatty, для Catty, встроенных агентов и внешних MCP-клиентов. По умолчанию отключено. Не обнаруживает все секреты. При включении двоичные ответы инструментов блокируются.",
|
||||
"ai.privacy.boundary": "Защищаются только данные и инструменты, управляемые Netcatty. Собственные инструменты сторонних агентов, их прямой доступ к файлам и другие MCP-серверы не входят в эту область.",
|
||||
"ai.privacy.enable": "Включить скрытие текста",
|
||||
"ai.privacy.privateIps": "Скрывать частные IP-адреса",
|
||||
"ai.privacy.privateIps.description": "Также скрывает частные и локальные IP-адреса в возвращаемом тексте.",
|
||||
"ai.privacy.cidrs": "Дополнительные диапазоны IP (CIDR)",
|
||||
"ai.privacy.cidrs.description": "Один CIDR IPv4 или IPv6 на строку. Оставьте пустым для использования только встроенных правил.",
|
||||
"ai.privacy.patterns": "Пользовательские регулярные выражения",
|
||||
"ai.privacy.patterns.description": "Один RE2-совместимый шаблон на строку, без разделителей / и флагов. Неподдерживаемые или небезопасные шаблоны отклоняются.",
|
||||
"ai.privacy.connectionScope": "Маркеры постоянны в пределах одного управляемого диалога или внешнего MCP-подключения. Исходные значения восстанавливаются только через вызовы инструментов, управляемых Netcatty, в той же области.",
|
||||
"ai.privacy.reconnect": "Примените изменения, начните новый управляемый диалог и переподключите внешние MCP-клиенты. Существующие диалоги и подключения не должны повторно использовать маркеры.",
|
||||
"ai.privacy.apply": "Применить",
|
||||
"ai.privacy.applying": "Применение...",
|
||||
"ai.privacy.applied": "Настройки конфиденциальности ИИ применены. Начните новый управляемый диалог и переподключите внешние MCP-клиенты.",
|
||||
"ai.privacy.failed": "Настройки конфиденциальности ИИ не применены.",
|
||||
'ai.externalMcp.http.title': 'Streamable HTTP',
|
||||
'ai.externalMcp.http.description': 'Отдает те же инструменты External MCP по Streamable HTTP клиентам, которые не могут запустить stdio CLI, например Codex в WSL. По умолчанию выключено.',
|
||||
'ai.externalMcp.http.enable': 'Включить HTTP',
|
||||
|
||||
@@ -817,8 +817,8 @@ export const ruCoreMessages: Messages = {
|
||||
'settings.terminal.systemManager.processRefreshInterval.desc': 'Как часто обновлять список процессов в боковой панели системного менеджера.',
|
||||
'settings.terminal.systemManager.tmuxRefreshInterval': 'Обновление сессий tmux',
|
||||
'settings.terminal.systemManager.tmuxRefreshInterval.desc': 'Как часто обновлять список сессий tmux.',
|
||||
'settings.terminal.systemManager.dockerListRefreshInterval': 'Обновление списка контейнеров Docker',
|
||||
'settings.terminal.systemManager.dockerListRefreshInterval.desc': 'Как часто обновлять список контейнеров Docker.',
|
||||
'settings.terminal.systemManager.dockerListRefreshInterval': 'Обновление списка контейнеров',
|
||||
'settings.terminal.systemManager.dockerListRefreshInterval.desc': 'Как часто обновлять списки контейнеров и подов.',
|
||||
'settings.terminal.systemManager.dockerStatsRefreshInterval': 'Обновление статистики Docker',
|
||||
'settings.terminal.systemManager.dockerStatsRefreshInterval.desc': 'Как часто обновлять CPU/память/сеть контейнеров Docker.',
|
||||
'settings.terminal.serverStats.show': 'Показывать статистику сервера',
|
||||
|
||||
@@ -26,6 +26,8 @@ export const ruSystemManagerMessages: Messages = {
|
||||
'systemManager.errors.loadDocker': 'Не удалось загрузить контейнеры',
|
||||
'systemManager.errors.loadDockerStats': 'Не удалось загрузить статистику контейнеров',
|
||||
'systemManager.errors.loadDockerImages': 'Не удалось загрузить образы',
|
||||
'systemManager.errors.loadKubernetes': 'Не удалось загрузить ресурсы Kubernetes',
|
||||
'systemManager.errors.loadPods': 'Не удалось загрузить поды',
|
||||
'systemManager.errors.loadOverview': 'Не удалось загрузить обзор системы',
|
||||
'systemManager.errors.loadGpu': 'Не удалось загрузить статистику GPU / NPU',
|
||||
'systemManager.errors.loadPorts': 'Не удалось загрузить слушающие порты',
|
||||
@@ -132,7 +134,7 @@ export const ruSystemManagerMessages: Messages = {
|
||||
'systemManager.tmux.newWindowPlaceholder': 'Имя окна (необязательно)',
|
||||
'systemManager.tmux.noWindows': 'Нет окон',
|
||||
'systemManager.tmux.unavailable': 'tmux недоступен на этом хосте',
|
||||
'systemManager.docker.unavailable': 'Docker недоступен на этом хосте',
|
||||
'systemManager.docker.unavailable': 'На этом хосте не обнаружена среда контейнеров (Docker, Podman, nerdctl или kubectl)',
|
||||
'systemManager.tmux.windowsMismatch': 'В сессии указано {{count}} окон, но list-windows ничего не вернул',
|
||||
'systemManager.tmux.lastCommand': 'последняя команда: {{command}}',
|
||||
'systemManager.tmux.noPanes': 'Нет панелей',
|
||||
@@ -194,6 +196,22 @@ export const ruSystemManagerMessages: Messages = {
|
||||
'systemManager.docker.imagesMeta': '{{count}} образов',
|
||||
'systemManager.docker.start': 'Запустить',
|
||||
'systemManager.docker.stop': 'Остановить',
|
||||
'systemManager.runtime.switch': 'Среда выполнения контейнеров',
|
||||
'systemManager.runtime.docker': 'Docker',
|
||||
'systemManager.runtime.podman': 'Podman',
|
||||
'systemManager.runtime.nerdctl': 'nerdctl',
|
||||
'systemManager.runtime.kubernetes': 'Kubernetes',
|
||||
'systemManager.k8s.context': 'Контекст',
|
||||
'systemManager.k8s.namespace': 'Пространство имён',
|
||||
'systemManager.k8s.empty': 'Поды не найдены',
|
||||
'systemManager.k8s.search': 'Поиск подов…',
|
||||
'systemManager.k8s.meta': '{{count}} под(ов)',
|
||||
'systemManager.k8s.describe': 'Описание',
|
||||
'systemManager.k8s.delete': 'Удалить под',
|
||||
'systemManager.k8s.confirmDelete': 'Удалить под «{{name}}»?',
|
||||
'systemManager.k8s.restarts': '{{count}} перезапусков',
|
||||
'systemManager.k8s.chooseContainer': 'Выберите контейнер',
|
||||
'systemManager.k8s.noContext': 'На этом хосте нет контекста Kubernetes',
|
||||
|
||||
'systemManager.ports.unavailable': 'На этом хосте не обнаружены инструменты портов (ss / netstat).',
|
||||
'systemManager.ports.loading': 'Загрузка слушающих портов…',
|
||||
|
||||
@@ -131,6 +131,7 @@ export const ruVaultMessages: Messages = {
|
||||
'vault.hosts.moveSelected': 'Переместить в группу ({count})',
|
||||
'vault.hosts.moveSelected.description': 'Переместить выбранные хосты в существующую группу или оставить без группы. Выбранные группы не перемещаются.',
|
||||
'vault.hosts.moveMultiple.success': 'Перемещено {count} хостов в {group}',
|
||||
'vault.hosts.dragSelectionCount': '{count} хостов',
|
||||
'vault.hosts.moveToGroup.success': 'Хост {host} перемещён в {group}',
|
||||
'vault.hosts.empty.title': 'Настройте свои хосты',
|
||||
'vault.hosts.empty.desc': 'Сохраняйте хосты, чтобы быстро подключаться к серверам, виртуальным машинам и контейнерам.',
|
||||
|
||||
@@ -289,6 +289,14 @@ export const zhCNAiMessages: Messages = {
|
||||
'ai.opencode.customPathPlaceholder': '例如 /usr/local/bin/opencode',
|
||||
'ai.opencode.check': '检查',
|
||||
'ai.opencode.resetPath': '重置',
|
||||
'ai.opencode.v2.startupTimeout': 'OpenCode 2 未能及时就绪。请先在终端确认 `opencode serve` 可以启动,然后重试。',
|
||||
'ai.opencode.v2.exited': 'OpenCode 2 在对话开始前已退出。',
|
||||
'ai.opencode.v2.unauthorized': 'OpenCode 2 拒绝了连接。请重试这次对话。',
|
||||
'ai.opencode.v2.unavailable': '无法连接到 OpenCode 2。请确认已安装后再试。',
|
||||
'ai.opencode.v2.sessionFailed': 'OpenCode 2 未能完成这一轮对话。',
|
||||
'ai.opencode.v2.empty': 'OpenCode 2 没有返回内容。请在终端运行 `opencode` 完成登录和模型配置。',
|
||||
'ai.opencode.v2.contextSkipped': 'OpenCode 2 无法应用 Netcatty 的对话上下文。',
|
||||
'ai.opencode.v2.mcpSkipped': 'OpenCode 2 无法接入 MCP 工具,对话会继续,但没有该工具。',
|
||||
|
||||
// AI MiMo Code(小米 MiMo Code CLI,OpenCode 分支)
|
||||
'ai.mimo.title': 'MiMo Code',
|
||||
@@ -376,6 +384,22 @@ export const zhCNAiMessages: Messages = {
|
||||
'ai.externalMcp.help.ariaLabel': '对外 MCP 使用说明',
|
||||
'ai.externalMcp.security': '安全',
|
||||
'ai.externalMcp.security.description': '仅监听 127.0.0.1,使用轮换 token,写操作复用 AI 权限模式,关闭时删除 discovery。不做 OAuth——这是本机桌面桥接。',
|
||||
"ai.privacy.title": "AI 隐私",
|
||||
"ai.privacy.description": "为 Catty、集成 Agent 和外部 MCP 客户端过滤 Netcatty 管理的上下文及工具结果中已知的敏感文本模式。默认关闭,无法识别所有机密。启用时会阻止二进制工具响应。",
|
||||
"ai.privacy.boundary": "仅覆盖 Netcatty 管理的数据和工具。第三方 Agent 的原生工具、直接读取的文件及其他 MCP 服务器不在保护范围内。",
|
||||
"ai.privacy.enable": "启用文本脱敏",
|
||||
"ai.privacy.privateIps": "隐藏私有 IP 地址",
|
||||
"ai.privacy.privateIps.description": "同时隐藏返回文本中的私有和本地 IP 地址。",
|
||||
"ai.privacy.cidrs": "附加 IP 网段(CIDR)",
|
||||
"ai.privacy.cidrs.description": "每行一个 IPv4 或 IPv6 CIDR。留空则仅使用内置规则。",
|
||||
"ai.privacy.patterns": "自定义正则表达式",
|
||||
"ai.privacy.patterns.description": "每行一个兼容 RE2 的模式,不含 / 分隔符或标志。不支持或不安全的模式会被拒绝。",
|
||||
"ai.privacy.connectionScope": "占位符在同一个受管对话或外部 MCP 连接内保持稳定。只有同一范围内的 Netcatty 受管工具调用才能还原原始值。",
|
||||
"ai.privacy.reconnect": "应用更改后,请新建受管对话并重新连接外部 MCP 客户端。已有对话和连接不得复用占位符。",
|
||||
"ai.privacy.apply": "应用",
|
||||
"ai.privacy.applying": "正在应用...",
|
||||
"ai.privacy.applied": "AI 隐私设置已应用。请新建受管对话并重新连接外部 MCP 客户端。",
|
||||
"ai.privacy.failed": "AI 隐私设置未应用。",
|
||||
'ai.externalMcp.http.title': 'Streamable HTTP',
|
||||
'ai.externalMcp.http.description': '用 Streamable HTTP 提供同一套对外 MCP 工具,方便无法启动 stdio CLI 的客户端,例如 WSL 里的 Codex。默认关闭。',
|
||||
'ai.externalMcp.http.enable': '启用 HTTP',
|
||||
|
||||
@@ -785,6 +785,7 @@ Vaults 标签保持扁平,不使用这块背景。
|
||||
'vault.hosts.moveSelected': '移动到分组 ({count})',
|
||||
'vault.hosts.moveSelected.description': '将选中的主机移动到现有分组,也可移出分组。选中的分组本身不会移动。',
|
||||
'vault.hosts.moveMultiple.success': '已将 {count} 个主机移动到 {group}',
|
||||
'vault.hosts.dragSelectionCount': '{count} 个主机',
|
||||
'vault.hosts.moveToGroup.success': '已将 {host} 移动到 {group}',
|
||||
'vault.hosts.errors.nameRequired': '主机名称不能为空。',
|
||||
'vault.hosts.empty.title': '设置你的主机',
|
||||
|
||||
@@ -26,6 +26,11 @@ export const zhCnSystemManagerMessages: Messages = {
|
||||
'systemManager.errors.loadDocker': '加载容器列表失败',
|
||||
'systemManager.errors.loadDockerStats': '加载容器性能数据失败',
|
||||
'systemManager.errors.loadDockerImages': '加载镜像列表失败',
|
||||
'systemManager.errors.loadKubernetes': '无法加载 Kubernetes 资源',
|
||||
'systemManager.errors.loadPods': '无法加载 Pod',
|
||||
'systemManager.errors.nerdctlUnreachable': 'nerdctl 无法连接 containerd',
|
||||
'systemManager.errors.clusterUnreachable': '无法连接到上下文 {{context}} 的集群',
|
||||
'systemManager.errors.clusterUnreachableGeneric': '无法连接到集群',
|
||||
'systemManager.errors.loadOverview': '加载系统概览失败',
|
||||
'systemManager.errors.loadGpu': '加载 GPU / NPU 状态失败',
|
||||
'systemManager.errors.loadPorts': '加载监听端口失败',
|
||||
@@ -132,7 +137,7 @@ export const zhCnSystemManagerMessages: Messages = {
|
||||
'systemManager.tmux.newWindowPlaceholder': '窗口名称(可选)',
|
||||
'systemManager.tmux.noWindows': '没有窗口',
|
||||
'systemManager.tmux.unavailable': '此主机未检测到 tmux',
|
||||
'systemManager.docker.unavailable': '此主机未检测到 Docker',
|
||||
'systemManager.docker.unavailable': '此主机未检测到容器运行时(Docker、Podman、nerdctl 或 kubectl)',
|
||||
'systemManager.tmux.windowsMismatch': '会话显示有 {{count}} 个窗口,但 list-windows 未返回任何窗口',
|
||||
'systemManager.tmux.lastCommand': '最后执行的命令:{{command}}',
|
||||
'systemManager.tmux.noPanes': '没有面板',
|
||||
@@ -194,6 +199,36 @@ export const zhCnSystemManagerMessages: Messages = {
|
||||
'systemManager.docker.imagesMeta': '{{count}} 个镜像',
|
||||
'systemManager.docker.start': '启动',
|
||||
'systemManager.docker.stop': '停止',
|
||||
'systemManager.runtime.switch': '容器运行时',
|
||||
'systemManager.runtime.docker': 'Docker',
|
||||
'systemManager.runtime.podman': 'Podman',
|
||||
'systemManager.runtime.nerdctl': 'nerdctl',
|
||||
'systemManager.runtime.kubernetes': 'Kubernetes',
|
||||
'systemManager.k8s.context': '上下文',
|
||||
'systemManager.k8s.namespace': '命名空间',
|
||||
'systemManager.k8s.empty': '未找到 Pod',
|
||||
'systemManager.k8s.search': '搜索 Pod…',
|
||||
'systemManager.k8s.meta': '{{count}} 个 Pod',
|
||||
'systemManager.k8s.describe': '描述',
|
||||
'systemManager.k8s.delete': '删除 Pod',
|
||||
'systemManager.k8s.confirmDelete': '确定删除 Pod「{{name}}」?',
|
||||
'systemManager.k8s.restarts': '重启 {{count}} 次',
|
||||
'systemManager.k8s.chooseContainer': '选择容器',
|
||||
'systemManager.k8s.noContext': '此主机没有 Kubernetes 上下文',
|
||||
'systemManager.k8s.status.Running': '运行中',
|
||||
'systemManager.k8s.status.Pending': '等待中',
|
||||
'systemManager.k8s.status.Completed': '已完成',
|
||||
'systemManager.k8s.status.Succeeded': '已成功',
|
||||
'systemManager.k8s.status.Failed': '失败',
|
||||
'systemManager.k8s.status.Unknown': '未知',
|
||||
'systemManager.k8s.status.CrashLoopBackOff': '崩溃循环',
|
||||
'systemManager.k8s.status.ContainerCreating': '创建中',
|
||||
'systemManager.k8s.status.Terminating': '终止中',
|
||||
'systemManager.k8s.status.ImagePullBackOff': '镜像拉取退避',
|
||||
'systemManager.k8s.status.ErrImagePull': '拉取镜像失败',
|
||||
'systemManager.k8s.status.Error': '错误',
|
||||
'systemManager.k8s.status.Evicted': '已驱逐',
|
||||
'systemManager.k8s.status.OOMKilled': '内存不足',
|
||||
|
||||
'systemManager.ports.unavailable': '此主机未检测到端口工具(ss / netstat)。',
|
||||
'systemManager.ports.loading': '正在加载监听端口…',
|
||||
|
||||
@@ -487,8 +487,8 @@ export const zhCNTerminalMessages: Messages = {
|
||||
'settings.terminal.systemManager.processRefreshInterval.desc': '系统管理侧栏中进程列表的刷新频率。',
|
||||
'settings.terminal.systemManager.tmuxRefreshInterval': 'tmux 会话刷新间隔',
|
||||
'settings.terminal.systemManager.tmuxRefreshInterval.desc': 'tmux 会话列表的刷新频率。',
|
||||
'settings.terminal.systemManager.dockerListRefreshInterval': 'Docker 容器列表刷新间隔',
|
||||
'settings.terminal.systemManager.dockerListRefreshInterval.desc': 'Docker 容器列表的刷新频率。',
|
||||
'settings.terminal.systemManager.dockerListRefreshInterval': '容器列表刷新间隔',
|
||||
'settings.terminal.systemManager.dockerListRefreshInterval.desc': '容器与 Pod 列表的刷新频率。',
|
||||
'settings.terminal.systemManager.dockerStatsRefreshInterval': 'Docker 性能数据刷新间隔',
|
||||
'settings.terminal.systemManager.dockerStatsRefreshInterval.desc': 'Docker 容器 CPU/内存/网络指标的刷新频率。',
|
||||
|
||||
|
||||
@@ -280,6 +280,14 @@ export const zhTWAiMessages: Messages = {
|
||||
'ai.opencode.customPathPlaceholder': '例如 /usr/local/bin/opencode',
|
||||
'ai.opencode.check': '檢查',
|
||||
'ai.opencode.resetPath': '重設',
|
||||
'ai.opencode.v2.startupTimeout': 'OpenCode 2 未能及時就緒。請先在終端機確認 `opencode serve` 可以啟動,然後重試。',
|
||||
'ai.opencode.v2.exited': 'OpenCode 2 在對話開始前已結束。',
|
||||
'ai.opencode.v2.unauthorized': 'OpenCode 2 拒絕了連線。請重試這次對話。',
|
||||
'ai.opencode.v2.unavailable': '無法連線到 OpenCode 2。請確認已安裝後再試。',
|
||||
'ai.opencode.v2.sessionFailed': 'OpenCode 2 未能完成這一輪對話。',
|
||||
'ai.opencode.v2.empty': 'OpenCode 2 沒有傳回內容。請在終端機執行 `opencode` 完成登入和模型設定。',
|
||||
'ai.opencode.v2.contextSkipped': 'OpenCode 2 無法套用 Netcatty 的對話內容。',
|
||||
'ai.opencode.v2.mcpSkipped': 'OpenCode 2 無法掛接 MCP 工具,對話會繼續,但沒有該工具。',
|
||||
|
||||
// AI MiMo Code(小米 MiMo Code CLI,OpenCode 分支)
|
||||
'ai.mimo.title': 'MiMo Code',
|
||||
@@ -366,6 +374,22 @@ export const zhTWAiMessages: Messages = {
|
||||
'ai.externalMcp.help.ariaLabel': '對外 MCP 使用說明',
|
||||
'ai.externalMcp.security': '安全',
|
||||
'ai.externalMcp.security.description': '僅監聽 127.0.0.1,使用輪替 token,寫入操作重用 AI 權限模式,關閉時刪除 discovery。',
|
||||
"ai.privacy.title": "AI 隱私",
|
||||
"ai.privacy.description": "為 Catty、整合 Agent 和外部 MCP 用戶端過濾 Netcatty 管理的上下文及工具結果中已知的敏感文字模式。預設關閉,無法識別所有機密。啟用時會封鎖二進位工具回應。",
|
||||
"ai.privacy.boundary": "僅涵蓋 Netcatty 管理的資料和工具。第三方 Agent 的原生工具、直接讀取的檔案及其他 MCP 伺服器不在保護範圍內。",
|
||||
"ai.privacy.enable": "啟用文字去識別化",
|
||||
"ai.privacy.privateIps": "隱藏私有 IP 位址",
|
||||
"ai.privacy.privateIps.description": "同時隱藏回傳文字中的私有和本機 IP 位址。",
|
||||
"ai.privacy.cidrs": "額外 IP 網段(CIDR)",
|
||||
"ai.privacy.cidrs.description": "每行一個 IPv4 或 IPv6 CIDR。留空則僅使用內建規則。",
|
||||
"ai.privacy.patterns": "自訂正規表示式",
|
||||
"ai.privacy.patterns.description": "每行一個相容 RE2 的模式,不含 / 分隔符號或旗標。不支援或不安全的模式會遭拒絕。",
|
||||
"ai.privacy.connectionScope": "預留位置在同一個受管對話或外部 MCP 連線內保持穩定。只有同一範圍內的 Netcatty 受管工具呼叫才能還原原始值。",
|
||||
"ai.privacy.reconnect": "套用變更後,請建立新的受管對話並重新連接外部 MCP 用戶端。現有對話和連線不得重複使用預留位置。",
|
||||
"ai.privacy.apply": "套用",
|
||||
"ai.privacy.applying": "正在套用...",
|
||||
"ai.privacy.applied": "AI 隱私設定已套用。請建立新的受管對話並重新連接外部 MCP 用戶端。",
|
||||
"ai.privacy.failed": "AI 隱私設定未套用。",
|
||||
'ai.externalMcp.http.title': 'Streamable HTTP',
|
||||
'ai.externalMcp.http.description': '以 Streamable HTTP 提供同一套對外 MCP 工具,給無法啟動 stdio CLI 的用戶端使用,例如 WSL 裡的 Codex。預設關閉。',
|
||||
'ai.externalMcp.http.enable': '啟用 HTTP',
|
||||
|
||||
@@ -787,6 +787,7 @@ Vaults 分頁維持扁平,不使用這塊背景。
|
||||
'vault.hosts.moveSelected': '移動到群組 ({count})',
|
||||
'vault.hosts.moveSelected.description': '將選取的主機移動到現有群組,也可移出群組。選取的群組本身不會移動。',
|
||||
'vault.hosts.moveMultiple.success': '已將 {count} 個主機移動到 {group}',
|
||||
'vault.hosts.dragSelectionCount': '{count} 個主機',
|
||||
'vault.hosts.moveToGroup.success': '已將 {host} 移動到 {group}',
|
||||
'vault.hosts.errors.nameRequired': '主機名稱不能為空。',
|
||||
'vault.hosts.empty.title': '設定你的主機',
|
||||
|
||||
@@ -26,6 +26,11 @@ export const zhTwSystemManagerMessages: Messages = {
|
||||
'systemManager.errors.loadDocker': '載入容器列表失敗',
|
||||
'systemManager.errors.loadDockerStats': '載入容器效能資料失敗',
|
||||
'systemManager.errors.loadDockerImages': '載入映像列表失敗',
|
||||
'systemManager.errors.loadKubernetes': '無法載入 Kubernetes 資源',
|
||||
'systemManager.errors.loadPods': '無法載入 Pod',
|
||||
'systemManager.errors.nerdctlUnreachable': 'nerdctl 無法連線到 containerd',
|
||||
'systemManager.errors.clusterUnreachable': '無法連到內容 {{context}} 的叢集',
|
||||
'systemManager.errors.clusterUnreachableGeneric': '無法連到叢集',
|
||||
'systemManager.errors.loadOverview': '載入系統概覽失敗',
|
||||
'systemManager.errors.loadGpu': '載入 GPU / NPU 狀態失敗',
|
||||
'systemManager.errors.loadPorts': '載入監聽連接埠失敗',
|
||||
@@ -132,7 +137,7 @@ export const zhTwSystemManagerMessages: Messages = {
|
||||
'systemManager.tmux.newWindowPlaceholder': '視窗名稱(可選)',
|
||||
'systemManager.tmux.noWindows': '沒有視窗',
|
||||
'systemManager.tmux.unavailable': '此主機未偵測到 tmux',
|
||||
'systemManager.docker.unavailable': '此主機未偵測到 Docker',
|
||||
'systemManager.docker.unavailable': '此主機未偵測到容器執行環境(Docker、Podman、nerdctl 或 kubectl)',
|
||||
'systemManager.tmux.windowsMismatch': '工作階段顯示有 {{count}} 個視窗,但 list-windows 未傳回任何視窗',
|
||||
'systemManager.tmux.lastCommand': '最後執行的指令:{{command}}',
|
||||
'systemManager.tmux.noPanes': '沒有窗格',
|
||||
@@ -194,6 +199,36 @@ export const zhTwSystemManagerMessages: Messages = {
|
||||
'systemManager.docker.imagesMeta': '{{count}} 個映像',
|
||||
'systemManager.docker.start': '啟動',
|
||||
'systemManager.docker.stop': '停止',
|
||||
'systemManager.runtime.switch': '容器執行環境',
|
||||
'systemManager.runtime.docker': 'Docker',
|
||||
'systemManager.runtime.podman': 'Podman',
|
||||
'systemManager.runtime.nerdctl': 'nerdctl',
|
||||
'systemManager.runtime.kubernetes': 'Kubernetes',
|
||||
'systemManager.k8s.context': '內容',
|
||||
'systemManager.k8s.namespace': '命名空間',
|
||||
'systemManager.k8s.empty': '沒有 Pod',
|
||||
'systemManager.k8s.search': '搜尋 Pod…',
|
||||
'systemManager.k8s.meta': '{{count}} 個 Pod',
|
||||
'systemManager.k8s.describe': '描述',
|
||||
'systemManager.k8s.delete': '刪除 Pod',
|
||||
'systemManager.k8s.confirmDelete': '確定刪除 Pod「{{name}}」?',
|
||||
'systemManager.k8s.restarts': '重新啟動 {{count}} 次',
|
||||
'systemManager.k8s.chooseContainer': '選擇容器',
|
||||
'systemManager.k8s.noContext': '此主機沒有 Kubernetes 內容',
|
||||
'systemManager.k8s.status.Running': '執行中',
|
||||
'systemManager.k8s.status.Pending': '等待中',
|
||||
'systemManager.k8s.status.Completed': '已完成',
|
||||
'systemManager.k8s.status.Succeeded': '已成功',
|
||||
'systemManager.k8s.status.Failed': '失敗',
|
||||
'systemManager.k8s.status.Unknown': '未知',
|
||||
'systemManager.k8s.status.CrashLoopBackOff': '當機重試',
|
||||
'systemManager.k8s.status.ContainerCreating': '建立中',
|
||||
'systemManager.k8s.status.Terminating': '終止中',
|
||||
'systemManager.k8s.status.ImagePullBackOff': '映像拉取退避',
|
||||
'systemManager.k8s.status.ErrImagePull': '拉取映像失敗',
|
||||
'systemManager.k8s.status.Error': '錯誤',
|
||||
'systemManager.k8s.status.Evicted': '已驅逐',
|
||||
'systemManager.k8s.status.OOMKilled': '記憶體不足',
|
||||
|
||||
'systemManager.ports.unavailable': '此主機未偵測到連接埠工具(ss / netstat)。',
|
||||
'systemManager.ports.loading': '正在載入監聽連接埠…',
|
||||
|
||||
@@ -487,8 +487,8 @@ export const zhTWTerminalMessages: Messages = {
|
||||
'settings.terminal.systemManager.processRefreshInterval.desc': '系統管理側欄中程序列表的重新整理頻率。',
|
||||
'settings.terminal.systemManager.tmuxRefreshInterval': 'tmux 工作階段重新整理間隔',
|
||||
'settings.terminal.systemManager.tmuxRefreshInterval.desc': 'tmux 工作階段列表的重新整理頻率。',
|
||||
'settings.terminal.systemManager.dockerListRefreshInterval': 'Docker 容器列表重新整理間隔',
|
||||
'settings.terminal.systemManager.dockerListRefreshInterval.desc': 'Docker 容器列表的重新整理頻率。',
|
||||
'settings.terminal.systemManager.dockerListRefreshInterval': '容器列表重新整理間隔',
|
||||
'settings.terminal.systemManager.dockerListRefreshInterval.desc': '容器與 Pod 列表的重新整理頻率。',
|
||||
'settings.terminal.systemManager.dockerStatsRefreshInterval': 'Docker 效能資料重新整理間隔',
|
||||
'settings.terminal.systemManager.dockerStatsRefreshInterval.desc': 'Docker 容器 CPU/記憶體/網路指標的重新整理頻率。',
|
||||
|
||||
|
||||
@@ -1,10 +1,21 @@
|
||||
import assert from 'node:assert/strict';
|
||||
import test from 'node:test';
|
||||
import { createAiPrivacyService } from '../../electron/shared/aiPrivacyService.cjs';
|
||||
import {
|
||||
hasReversibleModelPrivacy,
|
||||
setReversibleModelPrivacy,
|
||||
} from '../../infrastructure/ai/harness/modelSecretRedaction';
|
||||
import type { AISession } from '../../infrastructure/ai/types';
|
||||
import { localStorageAdapter } from '../../infrastructure/persistence/localStorageAdapter';
|
||||
import {
|
||||
cleanupClosedTerminalSessions,
|
||||
cleanupDeletedAIChatSessions,
|
||||
cleanupOrphanedAISessions,
|
||||
cleanupSdkAgentSessions,
|
||||
latestAIActiveSessionMapSnapshot,
|
||||
latestAISessionsSnapshot,
|
||||
setLatestAIActiveSessionMapSnapshot,
|
||||
setLatestAISessionsSnapshot,
|
||||
} from './aiStateSnapshots';
|
||||
|
||||
test('orphan cleanup keeps durable Catty output while explicit deletion removes it', async () => {
|
||||
@@ -61,6 +72,92 @@ function makeSession(id: string, updatedAt: number, messages: unknown[]): AISess
|
||||
} as never;
|
||||
}
|
||||
|
||||
test('orphan cleanup preserves resumable privacy maps until explicit chat deletion', (t) => {
|
||||
const service = createAiPrivacyService({ maxSessions: 3 });
|
||||
service.configure({ enabled: true });
|
||||
const sessions = [
|
||||
{ ...makeSession('privacy-orphan', 1, []), scope: { type: 'terminal' as const, targetId: 'closed' } },
|
||||
{ ...makeSession('privacy-live', 2, []), scope: { type: 'terminal' as const, targetId: 'open' } },
|
||||
{ ...makeSession('privacy-resumed', 3, []), scope: { type: 'terminal' as const, targetId: 'closed' } },
|
||||
];
|
||||
const aliases = sessions.map(session => {
|
||||
setReversibleModelPrivacy(session.id, true);
|
||||
return service.redact(session.id, '8.8.8.8');
|
||||
});
|
||||
const sdkCleanups: string[] = [];
|
||||
const privacyCleanups: string[] = [];
|
||||
const outputCleanups: string[] = [];
|
||||
const previousWindow = globalThis.window;
|
||||
const previousSessions = latestAISessionsSnapshot;
|
||||
const previousActiveSessionMap = latestAIActiveSessionMapSnapshot;
|
||||
t.mock.method(localStorageAdapter, 'write', () => true);
|
||||
Object.defineProperty(globalThis, 'window', {
|
||||
configurable: true,
|
||||
value: {
|
||||
dispatchEvent: () => true,
|
||||
netcatty: {
|
||||
aiSdkAgentCleanup: async (id: string) => {
|
||||
sdkCleanups.push(id);
|
||||
return { ok: true };
|
||||
},
|
||||
aiPrivacyClearSession: async (id: string) => {
|
||||
privacyCleanups.push(id);
|
||||
service.clearSession(id);
|
||||
return { ok: true };
|
||||
},
|
||||
deleteChatToolOutputsTemp: async (id: string) => {
|
||||
outputCleanups.push(id);
|
||||
return { deletedCount: 1 };
|
||||
},
|
||||
},
|
||||
},
|
||||
});
|
||||
setLatestAISessionsSnapshot(sessions);
|
||||
setLatestAIActiveSessionMapSnapshot({
|
||||
'terminal:closed': 'privacy-orphan',
|
||||
'terminal:open': 'privacy-live',
|
||||
'terminal:resumed-into': 'privacy-resumed',
|
||||
});
|
||||
|
||||
try {
|
||||
cleanupOrphanedAISessions(new Set(['open', 'resumed-into']));
|
||||
|
||||
assert.equal(latestAISessionsSnapshot, sessions);
|
||||
assert.deepEqual(latestAIActiveSessionMapSnapshot, {
|
||||
'terminal:open': 'privacy-live',
|
||||
'terminal:resumed-into': 'privacy-resumed',
|
||||
});
|
||||
assert.deepEqual(sdkCleanups, ['privacy-orphan']);
|
||||
assert.deepEqual(privacyCleanups, []);
|
||||
assert.deepEqual(outputCleanups, []);
|
||||
for (const [index, session] of sessions.entries()) {
|
||||
assert.equal(hasReversibleModelPrivacy(session.id), true);
|
||||
assert.equal(service.redact(session.id, '8.8.8.8'), aliases[index]);
|
||||
assert.equal(service.restore(session.id, aliases[index]), '8.8.8.8');
|
||||
}
|
||||
assert.throws(() => service.checkSession('privacy-next'), /session limit reached/);
|
||||
|
||||
cleanupDeletedAIChatSessions(['privacy-orphan']);
|
||||
|
||||
assert.deepEqual(privacyCleanups, ['privacy-orphan']);
|
||||
assert.deepEqual(outputCleanups, ['privacy-orphan']);
|
||||
assert.equal(hasReversibleModelPrivacy('privacy-orphan'), false);
|
||||
assert.equal(service.hasSession('privacy-orphan'), false);
|
||||
assert.throws(() => service.checkSession('privacy-orphan'), /start a new chat/);
|
||||
assert.equal(service.checkSession('privacy-next'), true);
|
||||
for (const [index, session] of sessions.entries()) {
|
||||
if (session.id === 'privacy-orphan') continue;
|
||||
assert.equal(hasReversibleModelPrivacy(session.id), true);
|
||||
assert.equal(service.restore(session.id, aliases[index]), '8.8.8.8');
|
||||
}
|
||||
} finally {
|
||||
for (const session of sessions) setReversibleModelPrivacy(session.id, false);
|
||||
setLatestAISessionsSnapshot(previousSessions ?? []);
|
||||
setLatestAIActiveSessionMapSnapshot(previousActiveSessionMap ?? {});
|
||||
Object.defineProperty(globalThis, 'window', { configurable: true, value: previousWindow });
|
||||
}
|
||||
});
|
||||
|
||||
test('serializeSessionsForStorage strips oldest ciphertext before dropping visible sessions', async () => {
|
||||
const { serializeSessionsForStorage } = await import('./aiStateSnapshots');
|
||||
const ciphertext = 'gAAAA'.repeat(50000); // ~250 KB of ciphertext per message
|
||||
|
||||
@@ -30,6 +30,7 @@ import {
|
||||
isMissingDirectoryReplacePathError,
|
||||
promoteDirectoryReplaceStage as promoteDirectoryReplacePaths,
|
||||
} from "./directoryReplacePromotion";
|
||||
import { unwrapSftpIpcError } from "./errors";
|
||||
import { sftpTransferCenterStore } from "../sftpTransferCenterStore";
|
||||
|
||||
export interface DedicatedResumeDeps {
|
||||
@@ -179,9 +180,11 @@ export async function openTransferSftpSession(
|
||||
// Restart/resume owns an independent SSH dial. Normal pooled transfer opens
|
||||
// leave reuseTransport enabled so the main-process registry can reuse an
|
||||
// authenticated endpoint even when no terminal tab exists.
|
||||
// No SFTP-page host-key dialog owns this session id. An untrusted key must
|
||||
// fail the transfer immediately instead of sitting on the verifier TTL.
|
||||
const openOpts = wantDedicated
|
||||
? { ...credentials, reuseTransport: false as const }
|
||||
: credentials;
|
||||
? { ...credentials, reuseTransport: false as const, hostKeyUnattended: true as const }
|
||||
: { ...credentials, hostKeyUnattended: true as const };
|
||||
|
||||
// The main-process auth driver owns method ordering, fallback, MFA and
|
||||
// retry. A renderer-side key-then-password retry performs a second full SSH
|
||||
@@ -740,7 +743,7 @@ async function resumeSingleFileWithDedicatedSession(
|
||||
const err = error as Error & { dedicatedAttention?: boolean; resetCheckpoint?: boolean };
|
||||
return {
|
||||
success: false,
|
||||
error: err instanceof Error ? err.message : String(error),
|
||||
error: unwrapSftpIpcError(error),
|
||||
needsAttention: !!err.dedicatedAttention,
|
||||
resetCheckpoint: !!err.resetCheckpoint,
|
||||
};
|
||||
|
||||
@@ -38,6 +38,20 @@ test("runWithTransferRetry does not retry permanent errors", async () => {
|
||||
assert.equal(attempts, 1);
|
||||
});
|
||||
|
||||
test("a dead SFTP channel is not retried into another dial (#3310)", async () => {
|
||||
assert.equal(isTransientTransferError(new Error("SFTP channel closed")), false);
|
||||
assert.equal(isTransientTransferError(new Error("SFTP session not found")), true);
|
||||
let attempts = 0;
|
||||
await assert.rejects(
|
||||
() => runWithTransferRetry(async () => {
|
||||
attempts += 1;
|
||||
throw new Error("SFTP channel closed");
|
||||
}, { retries: 1, delayMs: 0 }),
|
||||
/SFTP channel closed/,
|
||||
);
|
||||
assert.equal(attempts, 1, "channel death must not schedule a reconnect");
|
||||
});
|
||||
|
||||
test("runWithTransferRetry does not retry cancel", async () => {
|
||||
let attempts = 0;
|
||||
await assert.rejects(
|
||||
|
||||
@@ -6,6 +6,9 @@ import { isSessionError } from "./errors";
|
||||
* path) should not be retried.
|
||||
*/
|
||||
export function isTransientTransferError(err: unknown): boolean {
|
||||
// The channel is gone. Retrying opens a new dial, and an OTP host then
|
||||
// sits on keyboard-interactive while the transfer stays active.
|
||||
if (err instanceof Error && /sftp channel closed/i.test(err.message)) return false;
|
||||
if (isSessionError(err)) return true;
|
||||
if (!(err instanceof Error)) return false;
|
||||
const msg = err.message.toLowerCase();
|
||||
|
||||
@@ -4,6 +4,7 @@ import { netcattyBridge } from "../../../infrastructure/services/netcattyBridge"
|
||||
import type { Host, Identity, KnownHost, SftpConnection, SftpFileEntry, SftpFilenameEncoding, SSHKey } from "../../../domain/models";
|
||||
import { createKnownHostFromHostKeyInfo } from "../../../domain/knownHosts";
|
||||
import type { SftpHostKeyInfo, SftpHostKeyVerificationState, SftpPane } from "./types";
|
||||
import { unwrapSftpIpcError } from "./errors";
|
||||
import { useSftpDirectoryListing } from "./useSftpDirectoryListing";
|
||||
import { useSftpHostCredentials } from "./useSftpHostCredentials";
|
||||
import { buildCacheKey, getSharedRemoteHostCache, setSharedRemoteHostCache } from "./sharedRemoteHostCache";
|
||||
@@ -957,7 +958,7 @@ export const useSftpConnections = ({
|
||||
if (!isTargetConnectionAtPath(startPath)) return;
|
||||
updateTargetTab((prev) => ({
|
||||
...prev,
|
||||
error: err instanceof Error ? err.message : "Failed to list directory",
|
||||
error: unwrapSftpIpcError(err) || "Failed to list directory",
|
||||
loading: false,
|
||||
reconnecting: false,
|
||||
}));
|
||||
@@ -1263,14 +1264,14 @@ export const useSftpConnections = ({
|
||||
? {
|
||||
...prev.connection,
|
||||
status: "error",
|
||||
error: err instanceof Error ? err.message : "Connection failed",
|
||||
error: unwrapSftpIpcError(err) || "Connection failed",
|
||||
}
|
||||
: null,
|
||||
files: isReconnectAttempt ? [] : prev.files,
|
||||
selectedFiles: isReconnectAttempt ? new Set<string>() : prev.selectedFiles,
|
||||
error: isReconnectAttempt
|
||||
? "sftp.error.reconnectFailed"
|
||||
: (err instanceof Error ? err.message : "Connection failed"),
|
||||
: (unwrapSftpIpcError(err) || "Connection failed"),
|
||||
loading: false,
|
||||
reconnecting: false,
|
||||
}));
|
||||
|
||||
@@ -56,6 +56,166 @@ test(`failed ${direction} connection replaces Waiting with the actual error (#36
|
||||
});
|
||||
}
|
||||
|
||||
test("upload open uses known hosts trusted after the transfer callback was created (#3310)", async () => {
|
||||
const seen: string[] = [];
|
||||
let fingerprint = "";
|
||||
let ops: ReturnType<typeof useSftpTransfers> | undefined;
|
||||
let renderer: ReactTestRenderer | undefined;
|
||||
let firstStart: ReturnType<typeof useSftpTransfers>["startTransfer"] | undefined;
|
||||
const restore = installGlobals({});
|
||||
function Probe() {
|
||||
const captured = fingerprint;
|
||||
const acquireTransferSession = React.useCallback(async () => {
|
||||
seen.push(captured);
|
||||
throw new Error(`known-hosts:${captured || "empty"}`);
|
||||
}, [captured]);
|
||||
ops = useSftpTransfers({
|
||||
ownerId: "known-hosts-after-trust-3310",
|
||||
getActivePane: (side) => makePane(side === "left" ? "right" : "left"),
|
||||
getPaneByConnectionId: () => null,
|
||||
getTabByConnectionId: () => null,
|
||||
updateTab: () => undefined,
|
||||
refresh: async () => undefined,
|
||||
clearCacheForConnection: () => undefined,
|
||||
handleSessionError: () => undefined,
|
||||
sftpSessionsRef: { current: new Map() },
|
||||
connectionCacheKeyMapRef: { current: new Map() },
|
||||
listLocalFiles: async () => [],
|
||||
listRemoteFiles: async () => [],
|
||||
acquireTransferSession,
|
||||
});
|
||||
if (!firstStart) firstStart = ops.startTransfer;
|
||||
return null;
|
||||
}
|
||||
try {
|
||||
await act(async () => { renderer = create(React.createElement(Probe)); });
|
||||
fingerprint = "trusted-this-session";
|
||||
await act(async () => { renderer?.update(React.createElement(Probe)); });
|
||||
await act(async () => {
|
||||
await firstStart!(
|
||||
[{ name: "upload.txt", isDirectory: false }],
|
||||
"left",
|
||||
"right",
|
||||
);
|
||||
});
|
||||
assert.deepEqual(seen, ["trusted-this-session"]);
|
||||
const task = sftpTransferCenterStore.getOwnerTasks("known-hosts-after-trust-3310")[0];
|
||||
assert.equal(task?.error, "known-hosts:trusted-this-session");
|
||||
} finally {
|
||||
await act(async () => { renderer?.unmount(); });
|
||||
restore();
|
||||
}
|
||||
});
|
||||
|
||||
test("channel death fails the upload without a second session open (#3310)", async () => {
|
||||
let acquires = 0;
|
||||
let streams = 0;
|
||||
const restore = installGlobals({
|
||||
statLocal: async () => ({ size: 48, lastModified: 1 }),
|
||||
statSftp: async () => null,
|
||||
startStreamTransfer: async () => {
|
||||
streams += 1;
|
||||
return { error: "SFTP channel closed" };
|
||||
},
|
||||
pauseTransfer: async () => ({ success: false, reason: "Transfer is no longer active" }),
|
||||
resumeTransfer: async () => ({ success: false, reason: "Transfer is no longer active" }),
|
||||
});
|
||||
let ops: ReturnType<typeof useSftpTransfers> | undefined;
|
||||
let renderer: ReactTestRenderer | undefined;
|
||||
const ownerId = "channel-death-no-reconnect-3310";
|
||||
function Probe() {
|
||||
ops = useSftpTransfers({
|
||||
ownerId,
|
||||
getActivePane: (side) => makePane(side === "left" ? "right" : "left"),
|
||||
getPaneByConnectionId: () => null,
|
||||
getTabByConnectionId: () => null,
|
||||
updateTab: () => undefined,
|
||||
refresh: async () => undefined,
|
||||
clearCacheForConnection: () => undefined,
|
||||
handleSessionError: () => undefined,
|
||||
sftpSessionsRef: { current: new Map() },
|
||||
connectionCacheKeyMapRef: { current: new Map() },
|
||||
listLocalFiles: async () => [],
|
||||
listRemoteFiles: async () => [],
|
||||
acquireTransferSession: async () => {
|
||||
acquires += 1;
|
||||
return {
|
||||
sftpId: "sftp-borrowed",
|
||||
release: () => undefined,
|
||||
discard: () => undefined,
|
||||
};
|
||||
},
|
||||
});
|
||||
return null;
|
||||
}
|
||||
try {
|
||||
await act(async () => { renderer = create(React.createElement(Probe)); });
|
||||
await act(async () => {
|
||||
await ops!.startTransfer(
|
||||
[{ name: "upload.txt", isDirectory: false }],
|
||||
"left",
|
||||
"right",
|
||||
);
|
||||
});
|
||||
assert.equal(streams, 1, "channel death must not start another transfer dial");
|
||||
assert.equal(acquires, 2, "work lease plus the file lease, and no retry open");
|
||||
const task = sftpTransferCenterStore.getOwnerTasks(ownerId)[0];
|
||||
assert.equal(task?.status, "failed");
|
||||
assert.equal(task?.error, "SFTP channel closed");
|
||||
assert.equal(
|
||||
sftpTransferCenterStore.getOwnerTasks(ownerId)
|
||||
.filter((row) => ["pending", "queued", "transferring", "pausing"].includes(row.status))
|
||||
.length,
|
||||
0,
|
||||
);
|
||||
} finally {
|
||||
await act(async () => { renderer?.unmount(); });
|
||||
restore();
|
||||
}
|
||||
});
|
||||
|
||||
test("transfer failure strips the Electron IPC wrapper (#3310)", async () => {
|
||||
const restore = installGlobals({});
|
||||
let ops: ReturnType<typeof useSftpTransfers> | undefined;
|
||||
let renderer: ReactTestRenderer | undefined;
|
||||
const ownerId = "ipc-prefix-3310";
|
||||
function Probe() {
|
||||
ops = useSftpTransfers({
|
||||
ownerId,
|
||||
getActivePane: (side) => makePane(side === "left" ? "right" : "left"),
|
||||
getPaneByConnectionId: () => null,
|
||||
getTabByConnectionId: () => null,
|
||||
updateTab: () => undefined,
|
||||
refresh: async () => undefined,
|
||||
clearCacheForConnection: () => undefined,
|
||||
handleSessionError: () => undefined,
|
||||
sftpSessionsRef: { current: new Map() },
|
||||
connectionCacheKeyMapRef: { current: new Map() },
|
||||
listLocalFiles: async () => [],
|
||||
listRemoteFiles: async () => [],
|
||||
acquireTransferSession: async () => {
|
||||
throw new Error("Error invoking remote method 'netcatty:sftp:open': Error: Host key verification required for 127.0.0.1");
|
||||
},
|
||||
});
|
||||
return null;
|
||||
}
|
||||
try {
|
||||
await act(async () => { renderer = create(React.createElement(Probe)); });
|
||||
await act(async () => {
|
||||
await ops!.startTransfer(
|
||||
[{ name: "upload.txt", isDirectory: false }],
|
||||
"left",
|
||||
"right",
|
||||
);
|
||||
});
|
||||
const task = sftpTransferCenterStore.getOwnerTasks(ownerId)[0];
|
||||
assert.equal(task?.error, "Host key verification required for 127.0.0.1");
|
||||
} finally {
|
||||
await act(async () => { renderer?.unmount(); });
|
||||
restore();
|
||||
}
|
||||
});
|
||||
|
||||
type StartOptions = {
|
||||
transferId: string;
|
||||
sourcePath: string;
|
||||
|
||||
@@ -24,7 +24,7 @@ import { netcattyBridge } from "../../../infrastructure/services/netcattyBridge"
|
||||
import { logger } from "../../../lib/logger";
|
||||
import { sftpTransferCenterStore } from "../sftpTransferCenterStore";
|
||||
import { SftpPane } from "./types";
|
||||
import { isMissingStatError } from "./errors";
|
||||
import { isMissingStatError, unwrapSftpIpcError } from "./errors";
|
||||
import { useSftpDirectoryTransferOps } from "./transferDirectoryOps";
|
||||
import { useSftpTransferConflictOps } from "./transferConflictOps";
|
||||
import { useSftpTransferTaskOps } from "./transferTaskOps";
|
||||
@@ -191,6 +191,21 @@ export const useSftpTransfers = ({
|
||||
const { t } = useI18n();
|
||||
const tRef = useRef(t);
|
||||
tRef.current = t;
|
||||
// startTransfer keeps the processTransfer closure from its first render.
|
||||
// Read the latest acquire fn so a host key trusted after that render is
|
||||
// included in the transfer open, instead of the empty list from mount.
|
||||
const acquireTransferSessionRef = useRef(acquireTransferSession);
|
||||
acquireTransferSessionRef.current = acquireTransferSession;
|
||||
const acquireTransferSessionLive = useCallback<NonNullable<UseSftpTransfersParams["acquireTransferSession"]>>(
|
||||
(hostId, transferId, connectHost) => {
|
||||
const acquire = acquireTransferSessionRef.current;
|
||||
if (!acquire) {
|
||||
return Promise.reject(new Error("SFTP transfer session acquisition is unavailable"));
|
||||
}
|
||||
return acquire(hostId, transferId, connectHost);
|
||||
},
|
||||
[],
|
||||
);
|
||||
// Ref so progress flushes always see the latest visibility without rebinding.
|
||||
const surfaceVisibleRef = useRef(surfaceVisible);
|
||||
surfaceVisibleRef.current = surfaceVisible;
|
||||
@@ -468,7 +483,7 @@ export const useSftpTransfers = ({
|
||||
setTransfers,
|
||||
listLocalFiles,
|
||||
listRemoteFiles,
|
||||
acquireTransferSession,
|
||||
acquireTransferSession: acquireTransferSession ? acquireTransferSessionLive : undefined,
|
||||
});
|
||||
|
||||
const processTransfer = async (
|
||||
@@ -602,8 +617,9 @@ export const useSftpTransfers = ({
|
||||
// survive browse park and tab hide (pool holders, not panel map ids).
|
||||
if (!sourcePane.connection?.isLocal) {
|
||||
const sourceHostId = task.sourceHostId || sourcePane.connection?.hostId;
|
||||
if (acquireTransferSession && sourceHostId) {
|
||||
sourceWorkLease = await acquireTransferSession(sourceHostId, `${task.id}:work-source`);
|
||||
const acquireSourceSession = acquireTransferSessionRef.current;
|
||||
if (acquireSourceSession && sourceHostId) {
|
||||
sourceWorkLease = await acquireSourceSession(sourceHostId, `${task.id}:work-source`);
|
||||
sourceSftpId = sourceWorkLease.sftpId;
|
||||
}
|
||||
if (!sourceSftpId) {
|
||||
@@ -615,8 +631,9 @@ export const useSftpTransfers = ({
|
||||
|
||||
if (!targetPane.connection?.isLocal) {
|
||||
const targetHostId = task.targetHostId || targetPane.connection?.hostId;
|
||||
if (acquireTransferSession && targetHostId) {
|
||||
targetWorkLease = await acquireTransferSession(targetHostId, `${task.id}:work-target`);
|
||||
const acquireTargetSession = acquireTransferSessionRef.current;
|
||||
if (acquireTargetSession && targetHostId) {
|
||||
targetWorkLease = await acquireTargetSession(targetHostId, `${task.id}:work-target`);
|
||||
targetSftpId = targetWorkLease.sftpId;
|
||||
}
|
||||
if (!targetSftpId) {
|
||||
@@ -1122,7 +1139,7 @@ export const useSftpTransfers = ({
|
||||
|
||||
updateTask({
|
||||
status: "failed",
|
||||
error: err instanceof Error ? err.message : "Transfer failed",
|
||||
error: unwrapSftpIpcError(err) || "Transfer failed",
|
||||
endTime: Date.now(),
|
||||
speed: 0,
|
||||
});
|
||||
@@ -1507,8 +1524,8 @@ export const useSftpTransfers = ({
|
||||
try {
|
||||
const result = await retryExternalDragDropFileUpload(task, {
|
||||
getBrowseSftpId: (connectionId) => sftpSessionsRef.current.get(connectionId),
|
||||
acquireTransferSession: acquireTransferSession
|
||||
? (hostId, id) => acquireTransferSession(hostId, id)
|
||||
acquireTransferSession: acquireTransferSessionRef.current
|
||||
? (hostId, id) => acquireTransferSessionLive(hostId, id)
|
||||
: undefined,
|
||||
startStreamTransfer: async (options) => {
|
||||
const bridge = netcattyBridge.get();
|
||||
@@ -2000,8 +2017,9 @@ export const useSftpTransfers = ({
|
||||
let sourceWorkLease: TransferConnectionLease | null = null;
|
||||
let workingSourceSftpId = params.sftpId;
|
||||
try {
|
||||
if (acquireTransferSession && params.sourceHostId) {
|
||||
sourceWorkLease = await acquireTransferSession(
|
||||
const acquireDownloadSession = acquireTransferSessionRef.current;
|
||||
if (acquireDownloadSession && params.sourceHostId) {
|
||||
sourceWorkLease = await acquireDownloadSession(
|
||||
params.sourceHostId,
|
||||
`${task.id}:work-source`,
|
||||
);
|
||||
@@ -2067,7 +2085,7 @@ export const useSftpTransfers = ({
|
||||
const isCancelled = cancelledTasksRef.current.has(task.id);
|
||||
// Clean up cancelled task tracking to prevent memory leak
|
||||
if (isCancelled) cancelledTasksRef.current.delete(task.id);
|
||||
const errMsg = err instanceof Error ? err.message : String(err);
|
||||
const errMsg = unwrapSftpIpcError(err);
|
||||
patchDownload({
|
||||
status: isCancelled ? "cancelled" : "failed",
|
||||
error: isCancelled ? undefined : errMsg,
|
||||
|
||||
@@ -0,0 +1,39 @@
|
||||
import type { ContainerRuntimeId, ContainerSourceId } from '../../../domain/systemManager/containerRuntime';
|
||||
import { isContainerSource } from '../../../domain/systemManager/containerRuntime';
|
||||
import { STORAGE_KEY_CONTAINER_RUNTIME } from '../../../infrastructure/config/storageKeys';
|
||||
import { localStorageAdapter } from '../../../infrastructure/persistence/localStorageAdapter';
|
||||
|
||||
export interface ContainerRuntimePreference {
|
||||
source: ContainerSourceId;
|
||||
engine?: ContainerRuntimeId;
|
||||
context?: string;
|
||||
namespace?: string;
|
||||
}
|
||||
|
||||
type PreferenceStore = Record<string, ContainerRuntimePreference>;
|
||||
|
||||
const CONTAINER_ENGINES = new Set<ContainerRuntimeId>(['docker', 'podman', 'nerdctl']);
|
||||
|
||||
function readStore(): PreferenceStore {
|
||||
const value = localStorageAdapter.read<PreferenceStore>(STORAGE_KEY_CONTAINER_RUNTIME);
|
||||
return value && typeof value === 'object' ? value : {};
|
||||
}
|
||||
|
||||
export function readContainerRuntimePreference(hostId: string): ContainerRuntimePreference | null {
|
||||
if (!hostId) return null;
|
||||
const entry = readStore()[hostId];
|
||||
if (!entry || !isContainerSource(entry.source)) return null;
|
||||
return {
|
||||
source: entry.source,
|
||||
engine: entry.engine && CONTAINER_ENGINES.has(entry.engine) ? entry.engine : undefined,
|
||||
context: typeof entry.context === 'string' ? entry.context : undefined,
|
||||
namespace: typeof entry.namespace === 'string' ? entry.namespace : undefined,
|
||||
};
|
||||
}
|
||||
|
||||
export function writeContainerRuntimePreference(hostId: string, entry: ContainerRuntimePreference): void {
|
||||
if (!hostId || !isContainerSource(entry.source)) return;
|
||||
const store = readStore();
|
||||
store[hostId] = entry;
|
||||
localStorageAdapter.write(STORAGE_KEY_CONTAINER_RUNTIME, store);
|
||||
}
|
||||
@@ -0,0 +1,225 @@
|
||||
import { useCallback, useEffect, useRef, useState } from 'react';
|
||||
import type { useSystemManagerBackend } from '../useSystemManagerBackend';
|
||||
import { useStableTranslate } from '../useSystemManager';
|
||||
import type {
|
||||
ContainerRuntimeId,
|
||||
ContainerSourceId,
|
||||
KubeContextInfo,
|
||||
} from '../../../domain/systemManager/containerRuntime';
|
||||
import {
|
||||
describeRuntimeFailure,
|
||||
firstContainerRuntime,
|
||||
isAutomaticRuntimeFallback,
|
||||
isKubectlForbiddenError,
|
||||
reconcileContainerSource,
|
||||
sourcesFromCapabilities,
|
||||
} from '../../../domain/systemManager/containerRuntime';
|
||||
import type { SessionCapabilities } from '../../../domain/systemManager/types';
|
||||
import {
|
||||
readContainerRuntimePreference,
|
||||
writeContainerRuntimePreference,
|
||||
} from './containerRuntimePreference';
|
||||
|
||||
type Backend = ReturnType<typeof useSystemManagerBackend>;
|
||||
|
||||
interface UseContainerRuntimeSelectionOptions {
|
||||
sessionId: string;
|
||||
hostKey: string;
|
||||
capabilities?: SessionCapabilities;
|
||||
backend: Backend;
|
||||
}
|
||||
|
||||
export function useContainerRuntimeSelection({
|
||||
sessionId,
|
||||
hostKey,
|
||||
capabilities,
|
||||
backend,
|
||||
}: UseContainerRuntimeSelectionOptions) {
|
||||
const stableT = useStableTranslate();
|
||||
const detected = sourcesFromCapabilities(capabilities);
|
||||
const detectedKey = detected.join('|');
|
||||
const [source, setSource] = useState<ContainerSourceId | null>(() => {
|
||||
const remembered = readContainerRuntimePreference(hostKey);
|
||||
return reconcileContainerSource(detected, null, remembered);
|
||||
});
|
||||
const [contextName, setContextName] = useState<string | null>(() => (
|
||||
readContainerRuntimePreference(hostKey)?.context ?? null
|
||||
));
|
||||
const [namespaceName, setNamespaceName] = useState<string | null>(() => (
|
||||
readContainerRuntimePreference(hostKey)?.namespace ?? null
|
||||
));
|
||||
const [contexts, setContexts] = useState<KubeContextInfo[]>([]);
|
||||
const [namespaces, setNamespaces] = useState<string[]>([]);
|
||||
const [scopeError, setScopeError] = useState<string | null>(null);
|
||||
const [scopeNonce, setScopeNonce] = useState(0);
|
||||
const skipPersist = useRef(true);
|
||||
const namespaceNameRef = useRef(namespaceName);
|
||||
namespaceNameRef.current = namespaceName;
|
||||
|
||||
useEffect(() => {
|
||||
skipPersist.current = true;
|
||||
const remembered = readContainerRuntimePreference(hostKey);
|
||||
setSource(reconcileContainerSource(detected, null, remembered));
|
||||
setContextName(remembered?.context ?? null);
|
||||
setNamespaceName(remembered?.namespace ?? null);
|
||||
setContexts([]);
|
||||
setNamespaces([]);
|
||||
setScopeError(null);
|
||||
// Detection is reconciled by the next effect so a host switch does not
|
||||
// keep the previous host's in-memory selection.
|
||||
// eslint-disable-next-line react-hooks/exhaustive-deps
|
||||
}, [hostKey]);
|
||||
|
||||
useEffect(() => {
|
||||
const available = sourcesFromCapabilities(capabilities);
|
||||
setSource((current) => reconcileContainerSource(
|
||||
available,
|
||||
current,
|
||||
readContainerRuntimePreference(hostKey),
|
||||
));
|
||||
}, [capabilities, detectedKey, hostKey]);
|
||||
|
||||
useEffect(() => {
|
||||
if (skipPersist.current) {
|
||||
skipPersist.current = false;
|
||||
return;
|
||||
}
|
||||
if (!source) return;
|
||||
const remembered = readContainerRuntimePreference(hostKey);
|
||||
const available = sourcesFromCapabilities(capabilities);
|
||||
if (isAutomaticRuntimeFallback(remembered, source, available)) return;
|
||||
const engine = source === 'kubernetes'
|
||||
? (remembered?.engine ?? firstContainerRuntime(available) ?? undefined)
|
||||
: source;
|
||||
writeContainerRuntimePreference(hostKey, {
|
||||
source,
|
||||
engine,
|
||||
context: contextName || undefined,
|
||||
namespace: namespaceName || undefined,
|
||||
});
|
||||
}, [capabilities, contextName, hostKey, namespaceName, source]);
|
||||
|
||||
useEffect(() => {
|
||||
if (source !== 'kubernetes') return undefined;
|
||||
let cancelled = false;
|
||||
void backend.kubectl({ sessionId, op: 'contexts' }).then((result) => {
|
||||
if (cancelled) return;
|
||||
if (!result.success || !result.contexts) {
|
||||
setContexts([]);
|
||||
setScopeError(describeRuntimeFailure(
|
||||
result,
|
||||
stableT,
|
||||
stableT('systemManager.errors.loadKubernetes'),
|
||||
contextName,
|
||||
));
|
||||
return;
|
||||
}
|
||||
setScopeError(null);
|
||||
const available = result.contexts;
|
||||
const kept = contextName && available.some((item) => item.name === contextName)
|
||||
? contextName
|
||||
: null;
|
||||
const next = kept
|
||||
?? available.find((item) => item.current)?.name
|
||||
?? available[0]?.name
|
||||
?? null;
|
||||
setContexts(available);
|
||||
setContextName(next);
|
||||
if (!kept) {
|
||||
const configured = available.find((item) => item.name === next)?.namespace || 'default';
|
||||
setNamespaceName(next ? configured : null);
|
||||
setNamespaces([]);
|
||||
}
|
||||
});
|
||||
return () => {
|
||||
cancelled = true;
|
||||
};
|
||||
}, [backend, contextName, sessionId, source, scopeNonce, stableT]);
|
||||
|
||||
useEffect(() => {
|
||||
if (source !== 'kubernetes' || !contextName) return undefined;
|
||||
let cancelled = false;
|
||||
void backend.kubectl({
|
||||
sessionId,
|
||||
op: 'namespaces',
|
||||
context: contextName,
|
||||
}).then((result) => {
|
||||
if (cancelled) return;
|
||||
if (!result.success || !result.namespaces) {
|
||||
const message = result.error || '';
|
||||
if (isKubectlForbiddenError(message)) {
|
||||
const configured = contexts.find((item) => item.name === contextName)?.namespace || 'default';
|
||||
const fallback = namespaceNameRef.current || configured;
|
||||
setScopeError(null);
|
||||
setNamespaceName((current) => current || fallback);
|
||||
setNamespaces([fallback]);
|
||||
return;
|
||||
}
|
||||
setScopeError(describeRuntimeFailure(
|
||||
result,
|
||||
stableT,
|
||||
stableT('systemManager.errors.loadKubernetes'),
|
||||
contextName,
|
||||
));
|
||||
return;
|
||||
}
|
||||
setScopeError(null);
|
||||
setNamespaces(result.namespaces);
|
||||
setNamespaceName((current) => {
|
||||
if (current && result.namespaces?.includes(current)) return current;
|
||||
const configured = contexts.find((item) => item.name === contextName)?.namespace;
|
||||
if (configured && result.namespaces?.includes(configured)) return configured;
|
||||
if (result.namespaces?.includes('default')) return 'default';
|
||||
return result.namespaces?.[0] ?? null;
|
||||
});
|
||||
});
|
||||
return () => {
|
||||
cancelled = true;
|
||||
};
|
||||
}, [backend, contextName, contexts, sessionId, source, scopeNonce, stableT]);
|
||||
|
||||
const selectSource = useCallback((value: string) => {
|
||||
if (value !== 'docker' && value !== 'podman' && value !== 'nerdctl' && value !== 'kubernetes') return;
|
||||
setSource(value);
|
||||
const remembered = readContainerRuntimePreference(hostKey);
|
||||
const available = sourcesFromCapabilities(capabilities);
|
||||
const engine = value === 'kubernetes'
|
||||
? (remembered?.engine ?? firstContainerRuntime(available) ?? undefined)
|
||||
: value;
|
||||
writeContainerRuntimePreference(hostKey, {
|
||||
source: value,
|
||||
engine,
|
||||
context: contextName || undefined,
|
||||
namespace: namespaceName || undefined,
|
||||
});
|
||||
}, [capabilities, contextName, hostKey, namespaceName]);
|
||||
|
||||
const selectContext = useCallback((value: string) => {
|
||||
setContextName(value);
|
||||
const configured = contexts.find((item) => item.name === value)?.namespace ?? null;
|
||||
setNamespaceName(configured);
|
||||
}, [contexts]);
|
||||
|
||||
const reloadScopes = useCallback(() => {
|
||||
setScopeNonce((value) => value + 1);
|
||||
}, []);
|
||||
|
||||
const runtime: ContainerRuntimeId = source && source !== 'kubernetes' ? source : 'docker';
|
||||
const showRuntimeSwitcher = detected.length > 1;
|
||||
|
||||
return {
|
||||
source,
|
||||
selectSource,
|
||||
contextName,
|
||||
selectContext,
|
||||
namespaceName,
|
||||
setNamespaceName,
|
||||
contexts,
|
||||
namespaces,
|
||||
scopeError,
|
||||
reloadScopes,
|
||||
runtime,
|
||||
detected,
|
||||
showRuntimeSwitcher,
|
||||
};
|
||||
}
|
||||
@@ -1,7 +1,7 @@
|
||||
import assert from 'node:assert/strict';
|
||||
import { describe, it } from 'node:test';
|
||||
import { createSessionIdleManager } from '../../electron/bridges/mcpServerBridge/sessionIdleManager.cjs';
|
||||
import { STORAGE_KEY_AI_SESSION_IDLE_TIMEOUT_MINUTES } from '../../infrastructure/config/storageKeys';
|
||||
import { STORAGE_KEY_AI_SESSION_IDLE_TIMEOUT_MINUTES, STORAGE_KEY_AI_PRIVACY, STORAGE_KEY_AI_EXTERNAL_MCP_REDACTION, STORAGE_KEY_AI_EXTERNAL_MCP_ENABLED, STORAGE_KEY_AI_EXTERNAL_MCP_MODE } from '../../infrastructure/config/storageKeys';
|
||||
import { localStorageAdapter } from '../../infrastructure/persistence/localStorageAdapter';
|
||||
import {
|
||||
readExternalMcpHttpAllowCustomBind,
|
||||
@@ -16,6 +16,10 @@ import {
|
||||
} from "./useExternalMcpToggleState.ts";
|
||||
import {
|
||||
EXTERNAL_MCP_RUNTIME_STATUS_POLL_MS,
|
||||
applyAIPrivacySettings,
|
||||
readAIPrivacySettings,
|
||||
applyExternalMcpRedactionSettings,
|
||||
readExternalMcpRedactionSettings,
|
||||
bumpExternalMcpEnableGenerationForTests,
|
||||
createExternalMcpStartupSyncPlan,
|
||||
getExternalMcpEnableGenerationForTests,
|
||||
@@ -116,11 +120,12 @@ describe('useExternalMcpToggleState helpers', () => {
|
||||
externalMcpSetConfig: (config: { sessionIdleTimeoutMinutes: number }) => {
|
||||
received.push(config.sessionIdleTimeoutMinutes);
|
||||
manager.setTimeoutMinutes(config.sessionIdleTimeoutMinutes);
|
||||
return { ok: true };
|
||||
},
|
||||
};
|
||||
await syncExternalMcpStartupState(bridge);
|
||||
assert.equal(manager.getTimeoutMinutes(), expected, 'startup keeps the intended timeout');
|
||||
syncExternalMcpConfig(bridge);
|
||||
await syncExternalMcpConfig(bridge);
|
||||
assert.deepEqual(received, [expected, expected]);
|
||||
assert.equal(manager.getTimeoutMinutes(), expected, 'config sync keeps the intended timeout');
|
||||
assert.equal(
|
||||
@@ -452,3 +457,334 @@ describe("external MCP HTTP settings", () => {
|
||||
}
|
||||
});
|
||||
});
|
||||
|
||||
|
||||
describe('Shared AI privacy settings', () => {
|
||||
const settings = {
|
||||
enabled: true,
|
||||
redactPrivateIps: true,
|
||||
cidrs: ['192.0.2.0/24'],
|
||||
patterns: ['INTERNAL_[A-Z0-9]+'],
|
||||
};
|
||||
|
||||
it('defaults off and persists only after successful bridge validation', async () => {
|
||||
const restore = installMemoryLocalStorage();
|
||||
try {
|
||||
assert.deepEqual(readExternalMcpRedactionSettings(), {
|
||||
enabled: false, redactPrivateIps: false, cidrs: [], patterns: [],
|
||||
});
|
||||
let resolveConfig: (value: unknown) => void;
|
||||
const apply = applyExternalMcpRedactionSettings(settings, {
|
||||
externalMcpSetConfig: async (config) => {
|
||||
assert.deepEqual(config, { redaction: settings });
|
||||
return new Promise((resolve) => { resolveConfig = resolve; });
|
||||
},
|
||||
});
|
||||
assert.equal(localStorageAdapter.readString(STORAGE_KEY_AI_PRIVACY), null);
|
||||
resolveConfig!({ ok: true });
|
||||
await apply;
|
||||
assert.deepEqual(readExternalMcpRedactionSettings(), settings);
|
||||
assert.deepEqual(localStorageAdapter.read(STORAGE_KEY_AI_PRIVACY), settings);
|
||||
assert.equal(localStorageAdapter.readString(STORAGE_KEY_AI_EXTERNAL_MCP_REDACTION), null);
|
||||
} finally {
|
||||
restore();
|
||||
}
|
||||
});
|
||||
|
||||
it('reads legacy privacy preferences until a validated shared preference is applied', async () => {
|
||||
const restore = installMemoryLocalStorage();
|
||||
try {
|
||||
localStorageAdapter.write(STORAGE_KEY_AI_EXTERNAL_MCP_REDACTION, settings);
|
||||
assert.deepEqual(readAIPrivacySettings(), settings);
|
||||
assert.equal(localStorageAdapter.readString(STORAGE_KEY_AI_PRIVACY), null, 'reads must not migrate or discard data');
|
||||
const changed = { ...settings, redactPrivateIps: false };
|
||||
await applyAIPrivacySettings(changed, { externalMcpSetConfig: async () => ({ ok: true }) });
|
||||
assert.deepEqual(readAIPrivacySettings(), changed);
|
||||
assert.deepEqual(readExternalMcpRedactionSettings(), changed, 'legacy API must read the same shared policy');
|
||||
assert.deepEqual(localStorageAdapter.read(STORAGE_KEY_AI_PRIVACY), changed);
|
||||
assert.deepEqual(localStorageAdapter.read(STORAGE_KEY_AI_EXTERNAL_MCP_REDACTION), settings, 'keep legacy storage intact');
|
||||
} finally {
|
||||
restore();
|
||||
}
|
||||
});
|
||||
|
||||
it('never falls back to legacy settings when a shared preference exists, even if corrupt', () => {
|
||||
const restore = installMemoryLocalStorage();
|
||||
try {
|
||||
localStorageAdapter.write(STORAGE_KEY_AI_EXTERNAL_MCP_REDACTION, settings);
|
||||
const disabled = { enabled: false, redactPrivateIps: false, cidrs: [], patterns: [] };
|
||||
localStorageAdapter.write(STORAGE_KEY_AI_PRIVACY, disabled);
|
||||
assert.deepEqual(readAIPrivacySettings(), disabled);
|
||||
localStorageAdapter.writeString(STORAGE_KEY_AI_PRIVACY, '{');
|
||||
assert.throws(readAIPrivacySettings, /Invalid saved AI privacy settings/);
|
||||
localStorageAdapter.remove(STORAGE_KEY_AI_PRIVACY);
|
||||
localStorageAdapter.writeString(STORAGE_KEY_AI_EXTERNAL_MCP_REDACTION, '{');
|
||||
assert.throws(readAIPrivacySettings, /Invalid saved AI privacy settings/);
|
||||
} finally {
|
||||
restore();
|
||||
}
|
||||
});
|
||||
|
||||
it('restores the shared policy even when External MCP is disabled', async () => {
|
||||
const restore = installMemoryLocalStorage();
|
||||
try {
|
||||
resetExternalMcpStartupReadyForTests();
|
||||
localStorageAdapter.writeBoolean(STORAGE_KEY_AI_EXTERNAL_MCP_ENABLED, false);
|
||||
localStorageAdapter.write(STORAGE_KEY_AI_PRIVACY, settings);
|
||||
const calls: string[] = [];
|
||||
const plan = await syncExternalMcpStartupState({
|
||||
externalMcpSetConfig: async (config) => {
|
||||
assert.deepEqual(config.redaction, settings);
|
||||
calls.push('shared-policy');
|
||||
return { ok: true };
|
||||
},
|
||||
externalMcpSetEnabled: async (enabled) => { calls.push(`external:${enabled}`); },
|
||||
});
|
||||
assert.deepEqual(calls, ['shared-policy', 'external:false']);
|
||||
assert.equal(plan.runtimeEnabled, false);
|
||||
assert.deepEqual(readAIPrivacySettings(), settings);
|
||||
} finally {
|
||||
restore();
|
||||
}
|
||||
});
|
||||
|
||||
it('does not persist rejected, unverified or failed privacy changes', async () => {
|
||||
const restore = installMemoryLocalStorage();
|
||||
try {
|
||||
localStorageAdapter.write(STORAGE_KEY_AI_PRIVACY, settings);
|
||||
const changed = { ...settings, patterns: ['['] };
|
||||
for (const result of [{ ok: false, error: 'Invalid regular expression' }, { ok: false, error: '' }, undefined, {}]) {
|
||||
await assert.rejects(applyExternalMcpRedactionSettings(changed, {
|
||||
externalMcpSetConfig: async () => result,
|
||||
}));
|
||||
assert.deepEqual(readExternalMcpRedactionSettings(), settings);
|
||||
}
|
||||
await assert.rejects(applyExternalMcpRedactionSettings(changed, {
|
||||
externalMcpSetConfig: async () => { throw new Error('Disconnected'); },
|
||||
}), /Disconnected/);
|
||||
assert.deepEqual(readExternalMcpRedactionSettings(), settings);
|
||||
} finally {
|
||||
restore();
|
||||
}
|
||||
});
|
||||
|
||||
it('syncs saved privacy settings before persistent startup enable', async () => {
|
||||
const restore = installMemoryLocalStorage();
|
||||
try {
|
||||
resetExternalMcpStartupReadyForTests();
|
||||
localStorageAdapter.writeBoolean(STORAGE_KEY_AI_EXTERNAL_MCP_ENABLED, true);
|
||||
localStorageAdapter.writeString(STORAGE_KEY_AI_EXTERNAL_MCP_MODE, 'persistent');
|
||||
localStorageAdapter.write(STORAGE_KEY_AI_PRIVACY, settings);
|
||||
const calls: string[] = [];
|
||||
await syncExternalMcpStartupState({
|
||||
externalMcpSetConfig: async (config) => {
|
||||
assert.deepEqual(config.redaction, settings);
|
||||
calls.push('config');
|
||||
return { ok: true };
|
||||
},
|
||||
externalMcpSetEnabled: async (enabled) => { calls.push(`enabled:${enabled}`); },
|
||||
});
|
||||
assert.deepEqual(calls, ['config', 'enabled:true']);
|
||||
} finally {
|
||||
restore();
|
||||
}
|
||||
});
|
||||
|
||||
it('fails closed at startup when saved privacy settings are malformed', async () => {
|
||||
const restore = installMemoryLocalStorage();
|
||||
try {
|
||||
for (const saved of ['{', 'null', '{}', '{"enabled":"true"}', '{"enabled":false,"redactPrivateIps":false,"cidrs":[],"patterns":[2]}', '{"enabled":false,"redactPrivateIps":false,"cidrs":[],"patterns":[],"unexpected":true}']) {
|
||||
resetExternalMcpStartupReadyForTests();
|
||||
localStorageAdapter.writeBoolean(STORAGE_KEY_AI_EXTERNAL_MCP_ENABLED, true);
|
||||
localStorageAdapter.writeString(STORAGE_KEY_AI_EXTERNAL_MCP_MODE, 'persistent');
|
||||
localStorageAdapter.writeString(STORAGE_KEY_AI_PRIVACY, saved);
|
||||
const enabledCalls: boolean[] = [];
|
||||
let configCalls = 0;
|
||||
const bridge = {
|
||||
externalMcpSetConfig: async (config: unknown) => {
|
||||
assert.deepEqual(config, { redaction: null });
|
||||
configCalls += 1;
|
||||
return { ok: false, error: 'Shared AI privacy configuration is unavailable.' };
|
||||
},
|
||||
externalMcpSetEnabled: async (enabled: boolean) => { enabledCalls.push(enabled); },
|
||||
};
|
||||
const plan = await syncExternalMcpStartupState(bridge);
|
||||
assert.equal(plan.runtimeEnabled, false);
|
||||
assert.match(plan.configError!, /Invalid saved/);
|
||||
assert.equal(configCalls, 1, 'invalidate shared privacy instead of applying unprotected defaults');
|
||||
assert.deepEqual(enabledCalls, [false]);
|
||||
assert.equal(readExternalMcpStoredEnabled(), false);
|
||||
await assert.rejects(syncExternalMcpConfig(bridge), /Invalid saved/);
|
||||
assert.equal(configCalls, 2);
|
||||
}
|
||||
} finally {
|
||||
restore();
|
||||
}
|
||||
});
|
||||
|
||||
it('fails closed when the backend rejects or cannot verify startup config', async () => {
|
||||
const restore = installMemoryLocalStorage();
|
||||
try {
|
||||
for (const result of [{ ok: false, error: 'Invalid CIDR' }, { ok: false, error: '' }, undefined, {}]) {
|
||||
resetExternalMcpStartupReadyForTests();
|
||||
localStorageAdapter.writeBoolean(STORAGE_KEY_AI_EXTERNAL_MCP_ENABLED, true);
|
||||
localStorageAdapter.writeString(STORAGE_KEY_AI_EXTERNAL_MCP_MODE, 'persistent');
|
||||
localStorageAdapter.write(STORAGE_KEY_AI_PRIVACY, settings);
|
||||
const enabledCalls: boolean[] = [];
|
||||
const plan = await syncExternalMcpStartupState({
|
||||
externalMcpSetConfig: async () => result,
|
||||
externalMcpSetEnabled: async (enabled) => { enabledCalls.push(enabled); },
|
||||
});
|
||||
assert.equal(plan.runtimeEnabled, false);
|
||||
assert.ok(plan.configError);
|
||||
assert.deepEqual(enabledCalls, [false]);
|
||||
assert.deepEqual(readExternalMcpRedactionSettings(), settings);
|
||||
}
|
||||
} finally {
|
||||
restore();
|
||||
}
|
||||
});
|
||||
|
||||
it('preserves the corrupt-settings error if shared policy invalidation cannot reach the bridge', async () => {
|
||||
const restore = installMemoryLocalStorage();
|
||||
try {
|
||||
resetExternalMcpStartupReadyForTests();
|
||||
localStorageAdapter.writeString(STORAGE_KEY_AI_PRIVACY, '{');
|
||||
const calls: boolean[] = [];
|
||||
const bridge = {
|
||||
externalMcpSetConfig: async (config: unknown) => {
|
||||
assert.deepEqual(config, { redaction: null });
|
||||
throw new Error('Disconnected');
|
||||
},
|
||||
externalMcpSetEnabled: async (enabled: boolean) => { calls.push(enabled); },
|
||||
};
|
||||
const plan = await syncExternalMcpStartupState(bridge);
|
||||
assert.match(plan.configError!, /Invalid saved AI privacy settings/);
|
||||
assert.deepEqual(calls, [false]);
|
||||
await assert.rejects(syncExternalMcpConfig(bridge), /Invalid saved AI privacy settings/);
|
||||
assert.equal(localStorageAdapter.readString(STORAGE_KEY_AI_PRIVACY), '{');
|
||||
} finally {
|
||||
restore();
|
||||
}
|
||||
});
|
||||
|
||||
it('does not enable from a stale privacy snapshot changed during config sync', async () => {
|
||||
const restore = installMemoryLocalStorage();
|
||||
try {
|
||||
resetExternalMcpStartupReadyForTests();
|
||||
localStorageAdapter.writeBoolean(STORAGE_KEY_AI_EXTERNAL_MCP_ENABLED, true);
|
||||
localStorageAdapter.writeString(STORAGE_KEY_AI_EXTERNAL_MCP_MODE, 'persistent');
|
||||
const calls: boolean[] = [];
|
||||
const plan = await syncExternalMcpStartupState({
|
||||
externalMcpSetConfig: async () => {
|
||||
localStorageAdapter.write(STORAGE_KEY_AI_PRIVACY, settings);
|
||||
return { ok: true };
|
||||
},
|
||||
externalMcpSetEnabled: async (enabled) => { calls.push(enabled); },
|
||||
});
|
||||
assert.deepEqual(calls, [false]);
|
||||
assert.match(plan.configError!, /changed during startup/);
|
||||
await assert.rejects(syncExternalMcpConfig({
|
||||
externalMcpSetConfig: async () => {
|
||||
localStorageAdapter.write(STORAGE_KEY_AI_PRIVACY, { ...settings, cidrs: [] });
|
||||
return { ok: true };
|
||||
},
|
||||
}), /settings changed/);
|
||||
} finally {
|
||||
restore();
|
||||
}
|
||||
});
|
||||
|
||||
it('fails closed on a startup config transport failure', async () => {
|
||||
const restore = installMemoryLocalStorage();
|
||||
try {
|
||||
resetExternalMcpStartupReadyForTests();
|
||||
localStorageAdapter.writeBoolean(STORAGE_KEY_AI_EXTERNAL_MCP_ENABLED, true);
|
||||
localStorageAdapter.writeString(STORAGE_KEY_AI_EXTERNAL_MCP_MODE, 'persistent');
|
||||
const calls: boolean[] = [];
|
||||
const plan = await syncExternalMcpStartupState({
|
||||
externalMcpSetConfig: async () => { throw new Error('Disconnected'); },
|
||||
externalMcpSetEnabled: async (enabled) => { calls.push(enabled); },
|
||||
});
|
||||
assert.deepEqual(calls, [false]);
|
||||
assert.equal(plan.configError, 'Disconnected');
|
||||
} finally {
|
||||
restore();
|
||||
}
|
||||
});
|
||||
});
|
||||
|
||||
describe('External MCP manual enable privacy gate', () => {
|
||||
it('waits for validated config and keeps rejected or superseded enables off', async () => {
|
||||
const React = await import('react');
|
||||
const { act, create } = await import('react-test-renderer');
|
||||
const { useExternalMcpToggleState } = await import('./useExternalMcpToggleState.ts');
|
||||
const restoreStorage = installMemoryLocalStorage();
|
||||
const previousWindow = Object.getOwnPropertyDescriptor(globalThis, 'window');
|
||||
const previousAct = Object.getOwnPropertyDescriptor(globalThis, 'IS_REACT_ACT_ENVIRONMENT');
|
||||
const events = new EventTarget();
|
||||
let resolveConfig!: (value: unknown) => void;
|
||||
const calls: string[] = [];
|
||||
const bridge = {
|
||||
externalMcpSetConfig: async () => {
|
||||
calls.push('config');
|
||||
return new Promise((resolve) => { resolveConfig = resolve; });
|
||||
},
|
||||
externalMcpSetEnabled: async (enabled: boolean) => {
|
||||
calls.push(`enabled:${enabled}`);
|
||||
return { ok: true, enabled };
|
||||
},
|
||||
};
|
||||
Object.defineProperty(globalThis, 'IS_REACT_ACT_ENVIRONMENT', { value: true, configurable: true });
|
||||
Object.defineProperty(globalThis, 'window', {
|
||||
value: {
|
||||
netcatty: bridge,
|
||||
location: { hash: '#/settings' },
|
||||
addEventListener: events.addEventListener.bind(events),
|
||||
removeEventListener: events.removeEventListener.bind(events),
|
||||
dispatchEvent: events.dispatchEvent.bind(events),
|
||||
setInterval,
|
||||
clearInterval,
|
||||
},
|
||||
configurable: true,
|
||||
});
|
||||
let hook!: ReturnType<typeof useExternalMcpToggleState>;
|
||||
const Capture = () => { hook = useExternalMcpToggleState(); return null; };
|
||||
let renderer: ReturnType<typeof create> | undefined;
|
||||
try {
|
||||
resetExternalMcpStartupReadyForTests();
|
||||
await act(async () => { renderer = create(React.createElement(Capture)); });
|
||||
let pending!: Promise<void>;
|
||||
await act(async () => { pending = hook.setEnabled(true); });
|
||||
assert.deepEqual(calls, ['config']);
|
||||
assert.equal(hook.enabled, false);
|
||||
assert.equal(readExternalMcpStoredEnabled(), false);
|
||||
await act(async () => { resolveConfig({ ok: false, error: 'Invalid privacy rules' }); await pending; });
|
||||
assert.deepEqual(calls, ['config', 'enabled:false']);
|
||||
assert.equal(hook.enabled, false);
|
||||
assert.match(hook.error!, /Invalid privacy rules/);
|
||||
|
||||
calls.length = 0;
|
||||
await act(async () => { pending = hook.setEnabled(true); });
|
||||
await act(async () => { await hook.setEnabled(false); });
|
||||
await act(async () => { resolveConfig({ ok: true }); await pending; });
|
||||
assert.deepEqual(calls, ['config', 'enabled:false']);
|
||||
assert.equal(hook.enabled, false, 'a newer disable wins over a pending enable');
|
||||
|
||||
calls.length = 0;
|
||||
await act(async () => { pending = hook.setEnabled(true); });
|
||||
await act(async () => { resolveConfig({ ok: true }); await pending; });
|
||||
assert.deepEqual(calls, ['config', 'enabled:true']);
|
||||
assert.equal(hook.enabled, true);
|
||||
assert.equal(readExternalMcpStoredEnabled(), true);
|
||||
assert.equal(hook.error, null);
|
||||
} finally {
|
||||
if (renderer) await act(async () => renderer!.unmount());
|
||||
if (previousWindow) Object.defineProperty(globalThis, 'window', previousWindow);
|
||||
else Reflect.deleteProperty(globalThis, 'window');
|
||||
if (previousAct) Object.defineProperty(globalThis, 'IS_REACT_ACT_ENVIRONMENT', previousAct);
|
||||
else Reflect.deleteProperty(globalThis, 'IS_REACT_ACT_ENVIRONMENT');
|
||||
restoreStorage();
|
||||
}
|
||||
});
|
||||
});
|
||||
@@ -10,8 +10,10 @@ import {
|
||||
STORAGE_KEY_AI_EXTERNAL_MCP_HTTP_PORT,
|
||||
STORAGE_KEY_AI_EXTERNAL_MCP_IDLE_TIMEOUT_MINUTES,
|
||||
STORAGE_KEY_AI_EXTERNAL_MCP_MODE,
|
||||
STORAGE_KEY_AI_EXTERNAL_MCP_REDACTION,
|
||||
STORAGE_KEY_AI_EXTERNAL_MCP_SILENT_SESSIONS,
|
||||
STORAGE_KEY_AI_PERMISSION_MODE,
|
||||
STORAGE_KEY_AI_PRIVACY,
|
||||
STORAGE_KEY_AI_SESSION_IDLE_TIMEOUT_MINUTES,
|
||||
} from '../../infrastructure/config/storageKeys';
|
||||
import type { AIPermissionMode } from '../../infrastructure/ai/types';
|
||||
@@ -39,6 +41,7 @@ let externalMcpStartupReadyWaitPromise: Promise<void> | null = null;
|
||||
let externalMcpStartupReadyWaitResolve: (() => void) | null = null;
|
||||
/** Bumps on every intentional enable/disable so stale startup applies lose. */
|
||||
let externalMcpEnableGeneration = 0;
|
||||
let externalMcpConfigError: string | null = null;
|
||||
|
||||
function getWindowHash(hash?: string): string {
|
||||
if (typeof hash === 'string') return hash;
|
||||
@@ -76,6 +79,7 @@ export function resetExternalMcpStartupReadyForTests(): void {
|
||||
externalMcpStartupReadyWaitPromise = null;
|
||||
externalMcpStartupReadyWaitResolve = null;
|
||||
externalMcpEnableGeneration = 0;
|
||||
externalMcpConfigError = null;
|
||||
externalMcpStartupSyncPromise = null;
|
||||
}
|
||||
|
||||
@@ -110,6 +114,138 @@ export function waitForExternalMcpStartupReady(hash?: string): Promise<void> {
|
||||
return externalMcpStartupReadyWaitPromise;
|
||||
}
|
||||
|
||||
export type AIPrivacySettings = {
|
||||
enabled: boolean;
|
||||
redactPrivateIps: boolean;
|
||||
cidrs: string[];
|
||||
patterns: string[];
|
||||
};
|
||||
|
||||
export function defaultAIPrivacySettings(): AIPrivacySettings {
|
||||
return { enabled: false, redactPrivateIps: false, cidrs: [], patterns: [] };
|
||||
}
|
||||
|
||||
/** Missing settings are opt-out; corrupt settings must never silently opt out. */
|
||||
export function readAIPrivacySettings(): AIPrivacySettings {
|
||||
// The shared key wins, including invalid data: never fall back to a weaker legacy policy.
|
||||
// Leave the legacy value intact for older installs; Apply writes only the validated shared key.
|
||||
const stored = localStorageAdapter.readString(STORAGE_KEY_AI_PRIVACY)
|
||||
?? localStorageAdapter.readString(STORAGE_KEY_AI_EXTERNAL_MCP_REDACTION);
|
||||
if (stored === null) return defaultAIPrivacySettings();
|
||||
let value: unknown;
|
||||
try {
|
||||
value = JSON.parse(stored);
|
||||
} catch {
|
||||
throw new Error('Invalid saved AI privacy settings. Review and apply the settings again.');
|
||||
}
|
||||
if (!value || typeof value !== 'object' || Array.isArray(value)) {
|
||||
throw new Error('Invalid saved AI privacy settings. Review and apply the settings again.');
|
||||
}
|
||||
const settings = value as Record<string, unknown>;
|
||||
if (
|
||||
Object.keys(settings).some((key) => !['enabled', 'redactPrivateIps', 'cidrs', 'patterns'].includes(key))
|
||||
|| typeof settings.enabled !== 'boolean'
|
||||
|| typeof settings.redactPrivateIps !== 'boolean'
|
||||
|| !Array.isArray(settings.cidrs) || !settings.cidrs.every((item) => typeof item === 'string')
|
||||
|| !Array.isArray(settings.patterns) || !settings.patterns.every((item) => typeof item === 'string')
|
||||
) {
|
||||
throw new Error('Invalid saved AI privacy settings. Review and apply the settings again.');
|
||||
}
|
||||
return {
|
||||
enabled: settings.enabled,
|
||||
redactPrivateIps: settings.redactPrivateIps,
|
||||
cidrs: [...settings.cidrs],
|
||||
patterns: [...settings.patterns],
|
||||
};
|
||||
}
|
||||
|
||||
function configResponseError(
|
||||
result: unknown,
|
||||
unverifiedMessage = 'External MCP settings could not be verified. The bridge has not been enabled.',
|
||||
): string | null {
|
||||
if (result && typeof result === 'object' && 'ok' in result && result.ok === true) return null;
|
||||
if (result && typeof result === 'object' && 'error' in result && typeof result.error === 'string' && result.error) {
|
||||
return result.error;
|
||||
}
|
||||
return unverifiedMessage;
|
||||
}
|
||||
|
||||
/** Validate in the main process before committing any privacy preference. */
|
||||
export async function applyAIPrivacySettings(
|
||||
settings: AIPrivacySettings,
|
||||
bridge: ExternalMcpBridge | undefined = netcattyBridge.get(),
|
||||
): Promise<void> {
|
||||
const result = await bridge?.externalMcpSetConfig?.({ redaction: settings });
|
||||
const error = configResponseError(result, 'AI privacy settings could not be verified. Try applying them again.');
|
||||
if (error) throw new Error(error);
|
||||
if (!localStorageAdapter.write(STORAGE_KEY_AI_PRIVACY, settings)) {
|
||||
throw new Error('AI privacy settings could not be saved. Apply them again before restarting.');
|
||||
}
|
||||
externalMcpConfigError = null;
|
||||
emitAIStateChanged(STORAGE_KEY_AI_PRIVACY);
|
||||
}
|
||||
|
||||
function readAIPrivacyState() {
|
||||
try {
|
||||
return { settings: readAIPrivacySettings(), error: null as string | null };
|
||||
} catch (error) {
|
||||
return {
|
||||
settings: defaultAIPrivacySettings(),
|
||||
error: error instanceof Error ? error.message : String(error),
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
export function useAIPrivacyState() {
|
||||
const [state, setState] = useState(readAIPrivacyState);
|
||||
const [applying, setApplying] = useState(false);
|
||||
const apply = useCallback(async (settings: AIPrivacySettings): Promise<boolean> => {
|
||||
setApplying(true);
|
||||
try {
|
||||
await applyAIPrivacySettings(settings);
|
||||
setState({ settings, error: null });
|
||||
return true;
|
||||
} catch (error) {
|
||||
setState((current) => ({
|
||||
...current,
|
||||
error: error instanceof Error ? error.message : String(error),
|
||||
}));
|
||||
return false;
|
||||
} finally {
|
||||
setApplying(false);
|
||||
}
|
||||
}, []);
|
||||
|
||||
useEffect(() => {
|
||||
const onStorage = (event: StorageEvent) => {
|
||||
if (event.key === STORAGE_KEY_AI_PRIVACY || event.key === STORAGE_KEY_AI_EXTERNAL_MCP_REDACTION) {
|
||||
setState(readAIPrivacyState());
|
||||
}
|
||||
};
|
||||
const onLocalChange = (event: Event) => {
|
||||
const key = (event as CustomEvent<{ key?: string }>).detail?.key;
|
||||
if (key === STORAGE_KEY_AI_PRIVACY || key === STORAGE_KEY_AI_EXTERNAL_MCP_REDACTION) {
|
||||
setState(readAIPrivacyState());
|
||||
}
|
||||
};
|
||||
window.addEventListener('storage', onStorage);
|
||||
window.addEventListener(AI_STATE_CHANGED_EVENT, onLocalChange);
|
||||
return () => {
|
||||
window.removeEventListener('storage', onStorage);
|
||||
window.removeEventListener(AI_STATE_CHANGED_EVENT, onLocalChange);
|
||||
};
|
||||
}, []);
|
||||
|
||||
return { ...state, applying, apply };
|
||||
}
|
||||
|
||||
/** Compatibility exports for existing External MCP configuration callers. One shared policy only. */
|
||||
export type ExternalMcpRedactionSettings = AIPrivacySettings;
|
||||
export const defaultExternalMcpRedactionSettings = defaultAIPrivacySettings;
|
||||
export const readExternalMcpRedactionSettings = readAIPrivacySettings;
|
||||
export const applyExternalMcpRedactionSettings = applyAIPrivacySettings;
|
||||
export const useExternalMcpRedactionState = useAIPrivacyState;
|
||||
|
||||
export type ExternalMcpHttpSettings = {
|
||||
enabled: boolean;
|
||||
port: number;
|
||||
@@ -119,9 +255,11 @@ export type ExternalMcpHttpSettings = {
|
||||
};
|
||||
|
||||
type ExternalMcpConfig = {
|
||||
mode: ExternalMcpMode;
|
||||
idleTimeoutMinutes: number;
|
||||
sessionIdleTimeoutMinutes: number;
|
||||
mode?: ExternalMcpMode;
|
||||
idleTimeoutMinutes?: number;
|
||||
sessionIdleTimeoutMinutes?: number;
|
||||
/** null invalidates shared privacy when persisted settings cannot be read safely. */
|
||||
redaction?: AIPrivacySettings | null;
|
||||
httpEnabled?: boolean;
|
||||
httpPort?: number;
|
||||
httpAllowCustomBind?: boolean;
|
||||
@@ -146,6 +284,7 @@ export type ExternalMcpStartupSyncPlan = {
|
||||
runtimeEnabled: boolean;
|
||||
storedEnabled: boolean;
|
||||
shouldPersistStoredEnabled: boolean;
|
||||
configError?: string;
|
||||
};
|
||||
|
||||
export function normalizeExternalMcpMode(value: string | null): ExternalMcpMode {
|
||||
@@ -385,6 +524,7 @@ export function createExternalMcpStartupSyncPlan({
|
||||
mode,
|
||||
idleTimeoutMinutes,
|
||||
sessionIdleTimeoutMinutes,
|
||||
redaction = defaultAIPrivacySettings(),
|
||||
http = {
|
||||
enabled: false,
|
||||
port: DEFAULT_EXTERNAL_MCP_HTTP_PORT,
|
||||
@@ -398,6 +538,7 @@ export function createExternalMcpStartupSyncPlan({
|
||||
idleTimeoutMinutes: number;
|
||||
sessionIdleTimeoutMinutes: number;
|
||||
http?: ExternalMcpHttpSettings;
|
||||
redaction?: AIPrivacySettings;
|
||||
}): ExternalMcpStartupSyncPlan {
|
||||
const runtimeEnabled = shouldStartExternalMcpOnStartup({ enabled, mode });
|
||||
const storedEnabled = runtimeEnabled;
|
||||
@@ -406,6 +547,7 @@ export function createExternalMcpStartupSyncPlan({
|
||||
mode,
|
||||
idleTimeoutMinutes,
|
||||
sessionIdleTimeoutMinutes,
|
||||
redaction,
|
||||
...httpConfigFromSettings(http),
|
||||
},
|
||||
runtimeEnabled,
|
||||
@@ -415,23 +557,44 @@ export function createExternalMcpStartupSyncPlan({
|
||||
}
|
||||
|
||||
export function readExternalMcpStartupSyncPlan(): ExternalMcpStartupSyncPlan {
|
||||
return createExternalMcpStartupSyncPlan({
|
||||
let redaction = defaultAIPrivacySettings();
|
||||
let configError: string | undefined;
|
||||
try {
|
||||
redaction = readAIPrivacySettings();
|
||||
} catch (error) {
|
||||
configError = error instanceof Error ? error.message : String(error);
|
||||
}
|
||||
const plan = createExternalMcpStartupSyncPlan({
|
||||
enabled: readExternalMcpStoredEnabled(),
|
||||
mode: readExternalMcpMode(),
|
||||
idleTimeoutMinutes: readExternalMcpIdleTimeoutMinutes(),
|
||||
sessionIdleTimeoutMinutes: readSessionIdleTimeoutMinutes(),
|
||||
http: readExternalMcpHttpSettings(),
|
||||
redaction,
|
||||
});
|
||||
return configError ? { ...plan, runtimeEnabled: false, configError } : plan;
|
||||
}
|
||||
|
||||
export function syncExternalMcpConfig(bridge: ExternalMcpBridge | undefined = netcattyBridge.get()): void {
|
||||
const http = readExternalMcpHttpSettings();
|
||||
void bridge?.externalMcpSetConfig?.({
|
||||
mode: readExternalMcpMode(),
|
||||
idleTimeoutMinutes: readExternalMcpIdleTimeoutMinutes(),
|
||||
sessionIdleTimeoutMinutes: readSessionIdleTimeoutMinutes(),
|
||||
...httpConfigFromSettings(http),
|
||||
});
|
||||
export async function syncExternalMcpConfig(
|
||||
bridge: ExternalMcpBridge | undefined = netcattyBridge.get(),
|
||||
): Promise<void> {
|
||||
const plan = readExternalMcpStartupSyncPlan();
|
||||
if (plan.configError) {
|
||||
// A corrupt saved policy must block all managed agents, not only External MCP.
|
||||
// Keep the original recovery error even if notifying the main process fails.
|
||||
try {
|
||||
await bridge?.externalMcpSetConfig?.({ redaction: null });
|
||||
} catch {
|
||||
// The saved policy still needs user repair regardless of bridge availability.
|
||||
}
|
||||
throw new Error(plan.configError);
|
||||
}
|
||||
const result = await bridge?.externalMcpSetConfig?.(plan.config);
|
||||
const error = configResponseError(result);
|
||||
if (error) throw new Error(error);
|
||||
if (JSON.stringify(readAIPrivacySettings()) !== JSON.stringify(plan.config.redaction)) {
|
||||
throw new Error('AI privacy settings changed. Try enabling the bridge again.');
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -453,10 +616,18 @@ export async function syncExternalMcpStartupState(
|
||||
} catch {
|
||||
// Permission sync is best-effort; the main process keeps its safe default.
|
||||
}
|
||||
let configError = initialPlan.configError;
|
||||
try {
|
||||
await Promise.resolve(bridge?.externalMcpSetConfig?.(initialPlan.config));
|
||||
} catch {
|
||||
// Config sync is best-effort; continue with enable/disable reconcile.
|
||||
if (configError) {
|
||||
// Explicit invalidation is distinct from a rejected draft Apply, which keeps
|
||||
// the previous working policy. Never replace corrupt saved data with defaults.
|
||||
await Promise.resolve(bridge?.externalMcpSetConfig?.({ redaction: null }));
|
||||
} else {
|
||||
const result = await Promise.resolve(bridge?.externalMcpSetConfig?.(initialPlan.config));
|
||||
configError = configResponseError(result) ?? undefined;
|
||||
}
|
||||
} catch (error) {
|
||||
configError ??= error instanceof Error ? error.message : String(error);
|
||||
}
|
||||
|
||||
// A user toggle during config await invalidates this startup apply.
|
||||
@@ -465,6 +636,18 @@ export async function syncExternalMcpStartupState(
|
||||
}
|
||||
|
||||
const plan = readExternalMcpStartupSyncPlan();
|
||||
if (JSON.stringify(plan.config.redaction) !== JSON.stringify(initialPlan.config.redaction)) {
|
||||
configError = 'AI privacy settings changed during startup. Enable the bridge again.';
|
||||
}
|
||||
if (configError || plan.configError) {
|
||||
// A rejected or unverified config must never expose an unprotected runtime.
|
||||
plan.configError = configError || plan.configError;
|
||||
plan.runtimeEnabled = false;
|
||||
plan.storedEnabled = false;
|
||||
plan.shouldPersistStoredEnabled = readExternalMcpStoredEnabled();
|
||||
}
|
||||
externalMcpConfigError = plan.configError ?? null;
|
||||
emitAIStateChanged(STORAGE_KEY_AI_PRIVACY);
|
||||
if (plan.shouldPersistStoredEnabled) {
|
||||
localStorageAdapter.writeBoolean(STORAGE_KEY_AI_EXTERNAL_MCP_ENABLED, plan.storedEnabled);
|
||||
emitAIStateChanged(STORAGE_KEY_AI_EXTERNAL_MCP_ENABLED);
|
||||
@@ -516,24 +699,47 @@ export function useExternalMcpToggleState() {
|
||||
emitAIStateChanged(STORAGE_KEY_AI_EXTERNAL_MCP_ENABLED);
|
||||
}, []);
|
||||
|
||||
const setEnabled = useCallback((nextEnabled: boolean) => {
|
||||
persistEnabled(nextEnabled);
|
||||
// Peer session windows can mirror the stored switch, but they must not own
|
||||
// the main-process External MCP lifecycle.
|
||||
if (isPeerSessionWindow) return;
|
||||
externalMcpEnableGeneration += 1;
|
||||
// Re-emit after the lifecycle IPC settles so consumers that refetch runtime
|
||||
// status (e.g. Tool Access guidance) do not act on the pre-IPC storage
|
||||
// event, which can still observe the previous enabled state.
|
||||
void Promise.resolve(netcattyBridge.get()?.externalMcpSetEnabled?.(nextEnabled)).finally(() => {
|
||||
const [error, setError] = useState<string | null>(() => externalMcpConfigError);
|
||||
|
||||
const setEnabled = useCallback(async (nextEnabled: boolean) => {
|
||||
// Peer session windows mirror storage, but never own the main lifecycle.
|
||||
if (isPeerSessionWindow) {
|
||||
persistEnabled(nextEnabled);
|
||||
return;
|
||||
}
|
||||
if (!nextEnabled) persistEnabled(false);
|
||||
const generation = ++externalMcpEnableGeneration;
|
||||
setError(null);
|
||||
externalMcpConfigError = null;
|
||||
const bridge = netcattyBridge.get();
|
||||
try {
|
||||
// Even a manual enable must apply and verify saved privacy settings first.
|
||||
if (nextEnabled) await syncExternalMcpConfig(bridge);
|
||||
if (generation !== externalMcpEnableGeneration) return;
|
||||
const result = await bridge?.externalMcpSetEnabled?.(nextEnabled);
|
||||
if (nextEnabled && result && typeof result === 'object' && 'ok' in result && result.ok === false) {
|
||||
throw new Error('error' in result && typeof result.error === 'string' && result.error
|
||||
? result.error : 'External MCP could not be enabled.');
|
||||
}
|
||||
if (generation === externalMcpEnableGeneration) persistEnabled(nextEnabled);
|
||||
} catch (cause) {
|
||||
if (generation !== externalMcpEnableGeneration) return;
|
||||
externalMcpConfigError = cause instanceof Error ? cause.message : String(cause);
|
||||
setError(externalMcpConfigError);
|
||||
if (nextEnabled) {
|
||||
persistEnabled(false);
|
||||
await Promise.resolve(bridge?.externalMcpSetEnabled?.(false)).catch(() => undefined);
|
||||
}
|
||||
} finally {
|
||||
emitAIStateChanged(STORAGE_KEY_AI_EXTERNAL_MCP_ENABLED);
|
||||
});
|
||||
}
|
||||
}, [isPeerSessionWindow, persistEnabled]);
|
||||
|
||||
useEffect(() => {
|
||||
const syncFromStorage = () => {
|
||||
const nextEnabled = readExternalMcpStoredEnabled();
|
||||
setEnabledRaw(nextEnabled);
|
||||
setError(externalMcpConfigError);
|
||||
};
|
||||
|
||||
const handleStorage = (event: StorageEvent) => {
|
||||
@@ -542,7 +748,7 @@ export function useExternalMcpToggleState() {
|
||||
};
|
||||
const handleLocalStateChanged = (event: Event) => {
|
||||
const key = (event as CustomEvent<{ key?: string }>).detail?.key;
|
||||
if (key !== STORAGE_KEY_AI_EXTERNAL_MCP_ENABLED) return;
|
||||
if (key !== STORAGE_KEY_AI_EXTERNAL_MCP_ENABLED && key !== STORAGE_KEY_AI_EXTERNAL_MCP_REDACTION) return;
|
||||
syncFromStorage();
|
||||
};
|
||||
|
||||
@@ -587,5 +793,5 @@ export function useExternalMcpToggleState() {
|
||||
};
|
||||
}, [enabled, isPeerSessionWindow, persistEnabled]);
|
||||
|
||||
return { enabled, setEnabled };
|
||||
return { enabled, setEnabled, error };
|
||||
}
|
||||
@@ -1,4 +1,5 @@
|
||||
import { useCallback, useMemo } from 'react';
|
||||
import type { ContainerRuntimeId } from '../../domain/systemManager/containerRuntime';
|
||||
import type {
|
||||
DockerContainerAction,
|
||||
DockerImageManageAction,
|
||||
@@ -94,23 +95,27 @@ export function useSystemManagerBackend() {
|
||||
return bridge.tmuxAction(options);
|
||||
}, []);
|
||||
|
||||
const listDockerContainers = useCallback(async (sessionId: string) => {
|
||||
const listDockerContainers = useCallback(async (sessionId: string, runtime?: ContainerRuntimeId) => {
|
||||
const bridge = netcattyBridge.get();
|
||||
if (!bridge?.listDockerContainers) {
|
||||
return { success: false as const, error: 'listDockerContainers unavailable' };
|
||||
}
|
||||
return bridge.listDockerContainers(sessionId);
|
||||
return bridge.listDockerContainers(sessionId, runtime);
|
||||
}, []);
|
||||
|
||||
const listDockerImages = useCallback(async (sessionId: string) => {
|
||||
const listDockerImages = useCallback(async (sessionId: string, runtime?: ContainerRuntimeId) => {
|
||||
const bridge = netcattyBridge.get();
|
||||
if (!bridge?.listDockerImages) {
|
||||
return { success: false as const, error: 'listDockerImages unavailable' };
|
||||
}
|
||||
return bridge.listDockerImages(sessionId);
|
||||
return bridge.listDockerImages(sessionId, runtime);
|
||||
}, []);
|
||||
|
||||
const getDockerStats = useCallback(async (options: { sessionId: string; ids?: string[] }) => {
|
||||
const getDockerStats = useCallback(async (options: {
|
||||
sessionId: string;
|
||||
ids?: string[];
|
||||
runtime?: ContainerRuntimeId;
|
||||
}) => {
|
||||
const bridge = netcattyBridge.get();
|
||||
if (!bridge?.getDockerStats) {
|
||||
return { success: false as const, error: 'getDockerStats unavailable' };
|
||||
@@ -155,7 +160,11 @@ export function useSystemManagerBackend() {
|
||||
return bridge.systemServiceAction(options);
|
||||
}, []);
|
||||
|
||||
const dockerInspect = useCallback(async (options: { sessionId: string; containerId: string }) => {
|
||||
const dockerInspect = useCallback(async (options: {
|
||||
sessionId: string;
|
||||
containerId: string;
|
||||
runtime?: ContainerRuntimeId;
|
||||
}) => {
|
||||
const bridge = netcattyBridge.get();
|
||||
if (!bridge?.dockerInspect) {
|
||||
return { success: false as const, error: 'dockerInspect unavailable' };
|
||||
@@ -163,7 +172,11 @@ export function useSystemManagerBackend() {
|
||||
return bridge.dockerInspect(options);
|
||||
}, []);
|
||||
|
||||
const dockerImageInspect = useCallback(async (options: { sessionId: string; imageId: string }) => {
|
||||
const dockerImageInspect = useCallback(async (options: {
|
||||
sessionId: string;
|
||||
imageId: string;
|
||||
runtime?: ContainerRuntimeId;
|
||||
}) => {
|
||||
const bridge = netcattyBridge.get();
|
||||
if (!bridge?.dockerImageInspect) {
|
||||
return { success: false as const, error: 'dockerImageInspect unavailable' };
|
||||
@@ -176,6 +189,7 @@ export function useSystemManagerBackend() {
|
||||
containerId: string;
|
||||
action: DockerContainerAction;
|
||||
newName?: string;
|
||||
runtime?: ContainerRuntimeId;
|
||||
}) => {
|
||||
const bridge = netcattyBridge.get();
|
||||
if (!bridge?.dockerAction) {
|
||||
@@ -184,7 +198,10 @@ export function useSystemManagerBackend() {
|
||||
return bridge.dockerAction(options);
|
||||
}, []);
|
||||
|
||||
const dockerImageAction = useCallback(async (options: { sessionId: string } & DockerImageManageAction) => {
|
||||
const dockerImageAction = useCallback(async (options: {
|
||||
sessionId: string;
|
||||
runtime?: ContainerRuntimeId;
|
||||
} & DockerImageManageAction) => {
|
||||
const bridge = netcattyBridge.get();
|
||||
if (!bridge?.dockerImageAction) {
|
||||
return { success: false as const, error: 'dockerImageAction unavailable' };
|
||||
@@ -192,6 +209,21 @@ export function useSystemManagerBackend() {
|
||||
return bridge.dockerImageAction(options);
|
||||
}, []);
|
||||
|
||||
const kubectl = useCallback(async (options: {
|
||||
sessionId: string;
|
||||
op: 'contexts' | 'namespaces' | 'pods' | 'delete';
|
||||
context?: string;
|
||||
namespace?: string;
|
||||
pod?: string;
|
||||
shell?: string;
|
||||
}) => {
|
||||
const bridge = netcattyBridge.get();
|
||||
if (!bridge?.kubectl) {
|
||||
return { success: false as const, error: 'kubectl unavailable' };
|
||||
}
|
||||
return bridge.kubectl(options);
|
||||
}, []);
|
||||
|
||||
const openTerminalPopup = useCallback(async (
|
||||
payload: Parameters<NonNullable<NetcattyBridge['openTerminalPopup']>>[0],
|
||||
) => {
|
||||
@@ -223,6 +255,7 @@ export function useSystemManagerBackend() {
|
||||
dockerImageInspect,
|
||||
dockerAction,
|
||||
dockerImageAction,
|
||||
kubectl,
|
||||
openTerminalPopup,
|
||||
}), [
|
||||
probeSystemCapabilities,
|
||||
@@ -245,6 +278,7 @@ export function useSystemManagerBackend() {
|
||||
dockerImageInspect,
|
||||
dockerAction,
|
||||
dockerImageAction,
|
||||
kubectl,
|
||||
openTerminalPopup,
|
||||
]);
|
||||
}
|
||||
@@ -235,6 +235,7 @@ test("HostTreeView exposes selected hosts to keyboard and assistive technology",
|
||||
assert.match(markup, /data-host-id="host-1"[^>]*data-tree-depth="0"/);
|
||||
assert.match(markup, /data-host-id="host-1"[^>]*aria-level="1"/);
|
||||
assert.match(markup, /data-host-id="host-1"[^>]*tabindex="0"/);
|
||||
assert.match(markup, /data-host-id="host-1"[^>]*draggable="true"/);
|
||||
});
|
||||
|
||||
test("HostTreeView exposes only one tree item in the tab order", () => {
|
||||
|
||||
@@ -17,6 +17,7 @@ import {
|
||||
} from '../domain/hostClickBehavior';
|
||||
import { sortByVaultOrder } from '../domain/vaultOrder';
|
||||
import { getVaultHostRowSubtitle } from '../domain/localShellHost';
|
||||
import { isVaultHostDraggable } from '../domain/vaultHostDrag';
|
||||
import { STORAGE_KEY_VAULT_HOSTS_TREE_EXPANDED } from '../infrastructure/config/storageKeys';
|
||||
import { GroupConfig, GroupNode, Host } from '../types';
|
||||
import { Collapsible, CollapsibleContent, CollapsibleTrigger } from './ui/collapsible';
|
||||
@@ -25,6 +26,8 @@ import { ContextMenu, ContextMenuTrigger } from './ui/context-menu';
|
||||
import { DistroAvatar } from './DistroAvatar';
|
||||
import { HostNotesIndicator } from './host/HostNotesIndicator';
|
||||
import { Button } from './ui/button';
|
||||
import { handleVaultHostDropToGroup } from './vault/vaultReorderDrag';
|
||||
import { beginVaultHostCardDrag } from './vault/vaultHostDragImage';
|
||||
import { VaultTreeGroupRow, VaultTreeItemRow } from './vault/VaultTreeRow';
|
||||
|
||||
const getTreeGroupDropIntent = (
|
||||
@@ -293,6 +296,7 @@ interface HostTreeViewProps {
|
||||
onEditGroup: (groupPath: string) => void;
|
||||
onDeleteGroup: (groupPath: string) => void;
|
||||
moveHostToGroup: (hostId: string, groupPath: string | null) => void;
|
||||
moveHostsToGroup?: (hostIds: ReadonlySet<string>, groupPath: string | null) => void;
|
||||
moveGroup: (sourcePath: string, targetParent: string | null) => void;
|
||||
commitInlineGroupRename?: (name: string) => boolean | void | Promise<boolean | void>;
|
||||
cancelInlineGroupEdit?: () => void;
|
||||
@@ -334,6 +338,7 @@ interface TreeNodeProps {
|
||||
onEditGroup: (groupPath: string) => void;
|
||||
onDeleteGroup: (groupPath: string) => void;
|
||||
moveHostToGroup: (hostId: string, groupPath: string | null) => void;
|
||||
moveHostsToGroup?: (hostIds: ReadonlySet<string>, groupPath: string | null) => void;
|
||||
moveGroup: (sourcePath: string, targetParent: string | null) => void;
|
||||
commitInlineGroupRename?: (name: string) => boolean | void | Promise<boolean | void>;
|
||||
cancelInlineGroupEdit?: () => void;
|
||||
@@ -381,6 +386,7 @@ const TreeNode: React.FC<TreeNodeProps> = ({
|
||||
onEditGroup,
|
||||
onDeleteGroup,
|
||||
moveHostToGroup,
|
||||
moveHostsToGroup,
|
||||
moveGroup,
|
||||
managedGroupPaths,
|
||||
onUnmanageGroup,
|
||||
@@ -526,9 +532,14 @@ const TreeNode: React.FC<TreeNodeProps> = ({
|
||||
e.preventDefault();
|
||||
e.stopPropagation();
|
||||
setDragOverDropTarget?.(null);
|
||||
const hostId = e.dataTransfer.getData("host-id");
|
||||
if (handleVaultHostDropToGroup({
|
||||
dataTransfer: e.dataTransfer,
|
||||
groupPath: node.path,
|
||||
moveHostToGroup,
|
||||
moveHostsToGroup,
|
||||
resetHostDragState: () => {},
|
||||
})) return;
|
||||
const groupPath = e.dataTransfer.getData("group-path");
|
||||
if (hostId) moveHostToGroup(hostId, node.path);
|
||||
if (groupPath && getTreeGroupDropIntent(e.currentTarget, e.clientY) === "inside") {
|
||||
moveGroup(groupPath, node.path);
|
||||
}
|
||||
@@ -598,6 +609,7 @@ const TreeNode: React.FC<TreeNodeProps> = ({
|
||||
onEditGroup={onEditGroup}
|
||||
onDeleteGroup={onDeleteGroup}
|
||||
moveHostToGroup={moveHostToGroup}
|
||||
moveHostsToGroup={moveHostsToGroup}
|
||||
moveGroup={moveGroup}
|
||||
managedGroupPaths={managedGroupPaths}
|
||||
onUnmanageGroup={onUnmanageGroup}
|
||||
@@ -774,11 +786,16 @@ const HostTreeItem: React.FC<HostTreeItemProps> = ({
|
||||
? 0
|
||||
: -1}
|
||||
onFocus={() => onActiveTreeItemChange(`host:${host.id}`)}
|
||||
draggable={!isMultiSelectMode}
|
||||
draggable={isVaultHostDraggable(Boolean(isMultiSelectMode), selectedHostIds, host.id)}
|
||||
onDragStart={(e) => {
|
||||
// copyMove: vault reorder uses move; focus-sidebar append uses copy.
|
||||
e.dataTransfer.effectAllowed = "copyMove";
|
||||
e.dataTransfer.setData("host-id", host.id);
|
||||
const ids = beginVaultHostCardDrag({
|
||||
dataTransfer: e.dataTransfer,
|
||||
hostId: host.id,
|
||||
isMultiSelectMode: Boolean(isMultiSelectMode),
|
||||
selectedHostIds,
|
||||
countLabel: (count) => t("vault.hosts.dragSelectionCount", { count }),
|
||||
});
|
||||
if (!ids) e.preventDefault();
|
||||
}}
|
||||
onClick={() => {
|
||||
const action = resolveHostActivateAction({
|
||||
@@ -909,6 +926,7 @@ const HostTreeViewInner: React.FC<HostTreeViewProps> = ({
|
||||
onEditGroup,
|
||||
onDeleteGroup,
|
||||
moveHostToGroup,
|
||||
moveHostsToGroup,
|
||||
moveGroup,
|
||||
managedGroupPaths,
|
||||
onUnmanageGroup,
|
||||
@@ -1219,6 +1237,7 @@ const HostTreeViewInner: React.FC<HostTreeViewProps> = ({
|
||||
onEditGroup={onEditGroup}
|
||||
onDeleteGroup={onDeleteGroup}
|
||||
moveHostToGroup={moveHostToGroup}
|
||||
moveHostsToGroup={moveHostsToGroup}
|
||||
moveGroup={moveGroup}
|
||||
managedGroupPaths={managedGroupPaths}
|
||||
onUnmanageGroup={onUnmanageGroup}
|
||||
@@ -1311,6 +1330,7 @@ function hostTreeViewAreEqual(prev: HostTreeViewProps, next: HostTreeViewProps):
|
||||
&& prev.onEditGroup === next.onEditGroup
|
||||
&& prev.onDeleteGroup === next.onDeleteGroup
|
||||
&& prev.moveHostToGroup === next.moveHostToGroup
|
||||
&& prev.moveHostsToGroup === next.moveHostsToGroup
|
||||
&& prev.moveGroup === next.moveGroup
|
||||
&& prev.commitInlineGroupRename === next.commitInlineGroupRename
|
||||
&& prev.cancelInlineGroupEdit === next.cancelInlineGroupEdit
|
||||
|
||||
@@ -8,6 +8,7 @@ import {
|
||||
Search,
|
||||
Terminal,
|
||||
TerminalSquare,
|
||||
Zap,
|
||||
} from "lucide-react";
|
||||
import React, { memo, useCallback, useEffect, useMemo, useRef, useState } from "react";
|
||||
import { useI18n } from "../application/i18n/I18nProvider";
|
||||
@@ -609,8 +610,8 @@ const QuickSwitcherInner: React.FC<QuickSwitcherProps> = ({
|
||||
className={rowClass}
|
||||
onClick={() => handleItemSelect(item)}
|
||||
>
|
||||
<div className="flex h-6 w-6 shrink-0 items-center justify-center rounded text-muted-foreground">
|
||||
<Terminal size={16} />
|
||||
<div className="shrink-0 flex items-center justify-center bg-primary text-primary-foreground h-5 w-5 rounded-[5px]">
|
||||
<Zap className="h-3 w-3" fill="currentColor" />
|
||||
</div>
|
||||
<span className="min-w-0 truncate text-sm font-medium">{item.title}</span>
|
||||
</div>
|
||||
|
||||
@@ -47,6 +47,15 @@ test("plugin importer merge and commit workflow is owned by application state",
|
||||
assert.match(pluginImporterCommitHookSource, /buildPluginImporterSafePreview/);
|
||||
});
|
||||
|
||||
test("host list section receives marquee selection and batched group move", () => {
|
||||
const sectionStart = vaultViewLayoutSource.indexOf("<VaultHostListSection");
|
||||
const sectionEnd = vaultViewLayoutSource.indexOf("/>", sectionStart);
|
||||
const section = vaultViewLayoutSource.slice(sectionStart, sectionEnd);
|
||||
assert.match(section, /\bmoveHostsToGroup,/);
|
||||
assert.match(section, /\bsetIsMultiSelectMode,/);
|
||||
assert.match(section, /\bsetSelectedHostIds,/);
|
||||
});
|
||||
|
||||
test("Vault import entry is disabled while an import is running", () => {
|
||||
assert.match(vaultViewLayoutSource, /disabled=\{importProgress\?\.status === "running"\}/);
|
||||
assert.match(vaultViewLayoutSource, /if \(importProgress\?\.status === "running"\) return;/);
|
||||
|
||||
@@ -51,6 +51,7 @@ import { CopilotCliCard } from "./ai/CopilotCliCard";
|
||||
import { CodebuddyCard } from "./ai/CodebuddyCard";
|
||||
import { SafetySettings } from "./ai/SafetySettings";
|
||||
import { ExternalMcpCard } from "./ai/ExternalMcpCard";
|
||||
import { AIPrivacySection } from "./ai/AIPrivacySection";
|
||||
import { ToolAccessGuidance } from "./ai/ToolAccessGuidance";
|
||||
import { PermissionGrantsSettings } from "./ai/PermissionGrantsSettings";
|
||||
import { useAIPermissionGrantsState } from "../../../application/state/useAIPermissionGrantsState";
|
||||
@@ -1297,6 +1298,8 @@ const SettingsAITab: React.FC<SettingsAITabProps> = ({
|
||||
</SettingCard>
|
||||
</SettingsSection>
|
||||
|
||||
<AIPrivacySection />
|
||||
|
||||
<SettingsSection anchorId="ai-external-mcp" title={t('ai.externalMcp.title')}>
|
||||
<ExternalMcpCard />
|
||||
</SettingsSection>
|
||||
|
||||
@@ -0,0 +1,183 @@
|
||||
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 { 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);
|
||||
}
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
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;
|
||||
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();
|
||||
}
|
||||
});
|
||||
|
||||
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 () => {
|
||||
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;
|
||||
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);
|
||||
|
||||
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]+'],
|
||||
});
|
||||
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();
|
||||
}
|
||||
});
|
||||
|
||||
test('malformed saved privacy settings remain visible as a recoverable error', async () => {
|
||||
const env = installEnvironment(async () => ({ ok: true }));
|
||||
let renderer: ReactTestRenderer | undefined;
|
||||
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();
|
||||
}
|
||||
});
|
||||
|
||||
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;
|
||||
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();
|
||||
}
|
||||
});
|
||||
|
||||
test('shared privacy controls refresh when another window changes the shared preference', async () => {
|
||||
const env = installEnvironment(async () => ({ ok: true }));
|
||||
let renderer: ReactTestRenderer | 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();
|
||||
}
|
||||
});
|
||||
@@ -0,0 +1,105 @@
|
||||
import React, { useEffect, useState } from "react";
|
||||
import { useI18n } from "../../../../application/i18n/I18nProvider";
|
||||
import { useAIPrivacyState } from "../../../../application/state/useExternalMcpToggleState";
|
||||
import { Button } from "../../../ui/button";
|
||||
import { SettingCard, SettingRow, SettingsSection, Toggle } from "../../settings-ui";
|
||||
|
||||
export const AIPrivacySection: React.FC = () => {
|
||||
const { t } = useI18n();
|
||||
const { settings, error, applying, apply } = useAIPrivacyState();
|
||||
const [enabled, setEnabled] = useState(settings.enabled);
|
||||
const [privateIps, setPrivateIps] = useState(settings.redactPrivateIps);
|
||||
const [cidrs, setCidrs] = useState(settings.cidrs.join("\n"));
|
||||
const [patterns, setPatterns] = useState(settings.patterns.join("\n"));
|
||||
const [applied, setApplied] = useState(false);
|
||||
|
||||
useEffect(() => {
|
||||
setEnabled(settings.enabled);
|
||||
setPrivateIps(settings.redactPrivateIps);
|
||||
setCidrs(settings.cidrs.join("\n"));
|
||||
setPatterns(settings.patterns.join("\n"));
|
||||
}, [settings]);
|
||||
|
||||
const onApply = async () => {
|
||||
setApplied(false);
|
||||
const saved = await apply({
|
||||
enabled,
|
||||
redactPrivateIps: privateIps,
|
||||
cidrs: cidrs.split(/\r?\n/).map((line) => line.trim()).filter(Boolean),
|
||||
patterns: patterns.split(/\r?\n/).filter((line) => line.trim().length > 0),
|
||||
});
|
||||
setApplied(saved);
|
||||
};
|
||||
|
||||
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">
|
||||
<SettingRow label={t("ai.privacy.enable")}>
|
||||
<Toggle
|
||||
checked={enabled}
|
||||
onChange={(next) => { setEnabled(next); setApplied(false); }}
|
||||
ariaLabel={t("ai.privacy.enable")}
|
||||
disabled={applying}
|
||||
/>
|
||||
</SettingRow>
|
||||
<SettingRow
|
||||
label={t("ai.privacy.privateIps")}
|
||||
description={t("ai.privacy.privateIps.description")}
|
||||
>
|
||||
<Toggle
|
||||
checked={privateIps}
|
||||
onChange={(next) => { setPrivateIps(next); setApplied(false); }}
|
||||
ariaLabel={t("ai.privacy.privateIps")}
|
||||
disabled={applying}
|
||||
/>
|
||||
</SettingRow>
|
||||
</SettingCard>
|
||||
<div className="space-y-3 rounded-md border border-border/60 bg-background/50 p-3">
|
||||
<label className="block space-y-1.5">
|
||||
<span className="text-xs font-medium">{t("ai.privacy.cidrs")}</span>
|
||||
<span className="block text-xs text-muted-foreground">{t("ai.privacy.cidrs.description")}</span>
|
||||
<textarea
|
||||
value={cidrs}
|
||||
onChange={(event) => { setCidrs(event.currentTarget.value); setApplied(false); }}
|
||||
aria-label={t("ai.privacy.cidrs")}
|
||||
placeholder={"192.0.2.0/24\n2001:db8::/32"}
|
||||
rows={3}
|
||||
spellCheck={false}
|
||||
disabled={applying}
|
||||
className="w-full resize-y rounded-md border border-border/60 bg-background px-2 py-1.5 font-mono text-xs focus-visible:outline-none focus-visible:ring-1 focus-visible:ring-ring"
|
||||
/>
|
||||
</label>
|
||||
<label className="block space-y-1.5">
|
||||
<span className="text-xs font-medium">{t("ai.privacy.patterns")}</span>
|
||||
<span className="block text-xs text-muted-foreground">{t("ai.privacy.patterns.description")}</span>
|
||||
<textarea
|
||||
value={patterns}
|
||||
onChange={(event) => { setPatterns(event.currentTarget.value); setApplied(false); }}
|
||||
aria-label={t("ai.privacy.patterns")}
|
||||
placeholder="INTERNAL_[A-Z0-9]+"
|
||||
rows={3}
|
||||
spellCheck={false}
|
||||
disabled={applying}
|
||||
className="w-full resize-y rounded-md border border-border/60 bg-background px-2 py-1.5 font-mono text-xs focus-visible:outline-none focus-visible:ring-1 focus-visible:ring-ring"
|
||||
/>
|
||||
</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}
|
||||
</div>
|
||||
</SettingsSection>
|
||||
);
|
||||
};
|
||||
@@ -252,7 +252,7 @@ export function buildCursorSnippet(launcherPath: string, discoveryPath?: string
|
||||
|
||||
export const ExternalMcpCard: React.FC = () => {
|
||||
const { t } = useI18n();
|
||||
const { enabled, setEnabled } = useExternalMcpToggleState();
|
||||
const { enabled, setEnabled, error: configError } = useExternalMcpToggleState();
|
||||
const [mode, setModeRaw] = useState<ExternalMcpMode>(() => readExternalMcpMode());
|
||||
const [idleTimeoutMinutes, setIdleTimeoutRaw] = useState<number>(() => readExternalMcpIdleTimeoutMinutes());
|
||||
const [focusOnHostOpen, setFocusOnHostOpenRaw] = useState<boolean>(() => readExternalMcpFocusOnHostOpen());
|
||||
@@ -681,8 +681,7 @@ export const ExternalMcpCard: React.FC = () => {
|
||||
checked={enabled}
|
||||
onChange={(nextEnabled) => {
|
||||
setActionMessage(null);
|
||||
setEnabled(nextEnabled);
|
||||
window.setTimeout(() => { void refreshStatus(); }, 0);
|
||||
void setEnabled(nextEnabled).then(() => refreshStatus());
|
||||
}}
|
||||
/>
|
||||
</div>
|
||||
@@ -989,8 +988,8 @@ export const ExternalMcpCard: React.FC = () => {
|
||||
{actionMessage.text}
|
||||
</div>
|
||||
) : null}
|
||||
{status?.error ? (
|
||||
<div className="text-xs text-destructive">{status.error}</div>
|
||||
{configError || status?.error ? (
|
||||
<div role="alert" className="text-xs text-destructive">{configError || status?.error}</div>
|
||||
) : null}
|
||||
</div>
|
||||
);
|
||||
|
||||
@@ -177,6 +177,7 @@ export interface NetcattyAiBridge {
|
||||
mode?: 'temporary' | 'persistent';
|
||||
idleTimeoutMinutes?: number;
|
||||
sessionIdleTimeoutMinutes?: number;
|
||||
redaction?: import("../../../../application/state/useExternalMcpToggleState").ExternalMcpRedactionSettings;
|
||||
httpEnabled?: boolean;
|
||||
httpPort?: number;
|
||||
httpAllowCustomBind?: boolean;
|
||||
|
||||
@@ -7,7 +7,8 @@ import type { TerminalSession } from '../../types';
|
||||
import type { DockerContainerAction, DockerContainerInfo, DockerStatInfo, TerminalPopupIcon } from '../../domain/systemManager/types';
|
||||
import { dockerContainerInfoEqual } from '../../domain/systemManager/pollEquals';
|
||||
import { getContainerFlags, getContainerTone } from '../../domain/systemManager/containerState';
|
||||
import { buildDockerExecShellCommand, buildDockerLogsCommand } from '../../domain/systemManager/dockerShell';
|
||||
import type { ContainerRuntimeId } from '../../domain/systemManager/containerRuntime';
|
||||
import { buildContainerExecShellCommand, buildContainerLogsCommand, describeRuntimeFailure } from '../../domain/systemManager/containerRuntime';
|
||||
import { DockerContainerDetail } from './DockerContainerDetail';
|
||||
import { DockerImageIcon } from './DockerImageIcon';
|
||||
import { useStableListOrder, mergePollListByKey } from './listStable';
|
||||
@@ -59,6 +60,7 @@ async function buildContainerPopupIcon(image: string): Promise<TerminalPopupIcon
|
||||
interface DockerContainersPanelProps {
|
||||
sessionId: string;
|
||||
parentSession: TerminalSession;
|
||||
runtime?: ContainerRuntimeId;
|
||||
isVisible: boolean;
|
||||
warmupEnabled?: boolean;
|
||||
backend: Backend;
|
||||
@@ -157,6 +159,7 @@ const DockerContainerRow = memo(function DockerContainerRow({
|
||||
export const DockerContainersPanel = memo(function DockerContainersPanel({
|
||||
sessionId,
|
||||
parentSession,
|
||||
runtime = 'docker',
|
||||
isVisible,
|
||||
warmupEnabled = false,
|
||||
backend,
|
||||
@@ -179,15 +182,15 @@ export const DockerContainersPanel = memo(function DockerContainersPanel({
|
||||
setConfirmAction(null);
|
||||
setPendingAction(null);
|
||||
setSelectedId(null);
|
||||
}, [sessionId]);
|
||||
}, [runtime, sessionId]);
|
||||
|
||||
const containersFetcher = useCallback(async () => {
|
||||
const result = await backend.listDockerContainers(sessionId);
|
||||
const result = await backend.listDockerContainers(sessionId, runtime);
|
||||
if (!result.success || !result.containers) {
|
||||
throw new Error(result.error || stableT('systemManager.errors.loadDocker'));
|
||||
throw new Error(describeRuntimeFailure(result, stableT, stableT('systemManager.errors.loadDocker')));
|
||||
}
|
||||
return result.containers;
|
||||
}, [backend, sessionId, stableT]);
|
||||
}, [backend, runtime, sessionId, stableT]);
|
||||
|
||||
const listIntervalMs = Math.max(3, listRefreshIntervalSec) * 1000;
|
||||
const { data: containers, error, loading, refresh } = usePolling<DockerContainerInfo[]>(
|
||||
@@ -195,7 +198,7 @@ export const DockerContainersPanel = memo(function DockerContainersPanel({
|
||||
listIntervalMs,
|
||||
isVisible || warmupEnabled,
|
||||
(prev, next) => mergePollListByKey(prev, next, (c) => c.id, dockerContainerInfoEqual),
|
||||
{ poll: isVisible, resetKey: sessionId },
|
||||
{ poll: isVisible, resetKey: `${sessionId}:${runtime}` },
|
||||
);
|
||||
|
||||
const matched = useMemo<DockerContainerInfo[]>(() => {
|
||||
@@ -240,12 +243,12 @@ export const DockerContainersPanel = memo(function DockerContainersPanel({
|
||||
);
|
||||
const statsFetcher = useCallback(async () => {
|
||||
if (statContainerIds.length === 0) return [];
|
||||
const result = await backend.getDockerStats({ sessionId, ids: statContainerIds });
|
||||
const result = await backend.getDockerStats({ sessionId, ids: statContainerIds, runtime });
|
||||
if (!result.success || !result.stats) {
|
||||
throw new Error(result.error || stableT('systemManager.errors.loadDockerStats'));
|
||||
throw new Error(describeRuntimeFailure(result, stableT, stableT('systemManager.errors.loadDockerStats')));
|
||||
}
|
||||
return result.stats;
|
||||
}, [backend, sessionId, stableT, statContainerIds]);
|
||||
}, [backend, runtime, sessionId, stableT, statContainerIds]);
|
||||
|
||||
const statsIntervalMs = Math.max(2, statsRefreshIntervalSec) * 1000;
|
||||
const { data: stats, loading: statsLoading } = usePolling<DockerStatInfo[]>(
|
||||
@@ -266,18 +269,19 @@ export const DockerContainersPanel = memo(function DockerContainersPanel({
|
||||
}, [stats]);
|
||||
|
||||
const getContainerInspectKey = useCallback((container: DockerContainerInfo) => (
|
||||
`${sessionId}:${container.id}`
|
||||
), [sessionId]);
|
||||
`${sessionId}:${runtime}:${container.id}`
|
||||
), [runtime, sessionId]);
|
||||
const fetchContainerInspect = useCallback(async (container: DockerContainerInfo) => {
|
||||
const result = await backend.dockerInspect({
|
||||
sessionId,
|
||||
containerId: container.id.slice(0, 12),
|
||||
runtime,
|
||||
});
|
||||
if (!result.success) {
|
||||
throw new Error(result.error || stableT('systemManager.errors.actionFailed'));
|
||||
throw new Error(describeRuntimeFailure(result, stableT, stableT('systemManager.errors.actionFailed')));
|
||||
}
|
||||
return result.inspect ?? null;
|
||||
}, [backend, sessionId, stableT]);
|
||||
}, [backend, runtime, sessionId, stableT]);
|
||||
const {
|
||||
records: inspectByContainerId,
|
||||
loadRecord: loadContainerInspect,
|
||||
@@ -305,9 +309,12 @@ export const DockerContainersPanel = memo(function DockerContainersPanel({
|
||||
}
|
||||
setPendingAction({ id: containerId, action });
|
||||
try {
|
||||
const result = await backend.dockerAction({ sessionId, containerId, action, newName });
|
||||
const result = await backend.dockerAction({ sessionId, containerId, action, newName, runtime });
|
||||
if (!result.success) {
|
||||
showSystemManagerError(result.error || t('systemManager.errors.actionFailed'), t('common.error'));
|
||||
showSystemManagerError(
|
||||
describeRuntimeFailure(result, t, t('systemManager.errors.actionFailed')),
|
||||
t('common.error'),
|
||||
);
|
||||
return;
|
||||
}
|
||||
const affectedContainer = (containers ?? []).find((container) => (
|
||||
@@ -332,6 +339,7 @@ export const DockerContainersPanel = memo(function DockerContainersPanel({
|
||||
invalidateContainerInspectMatching,
|
||||
refresh,
|
||||
refreshContainerInspect,
|
||||
runtime,
|
||||
sessionId,
|
||||
t,
|
||||
]);
|
||||
@@ -360,7 +368,7 @@ export const DockerContainersPanel = memo(function DockerContainersPanel({
|
||||
backend,
|
||||
parentSession,
|
||||
`docker: ${container.name || id}`,
|
||||
buildDockerExecShellCommand(id),
|
||||
buildContainerExecShellCommand(runtime, id, parentSession.shellType),
|
||||
{ icon: await buildContainerPopupIcon(container.image) },
|
||||
);
|
||||
if (!result.success) {
|
||||
@@ -372,7 +380,7 @@ export const DockerContainersPanel = memo(function DockerContainersPanel({
|
||||
});
|
||||
showSystemManagerError(result.error || t('systemManager.errors.actionFailed'), t('common.error'));
|
||||
}
|
||||
}, [backend, parentSession, sessionId, t]);
|
||||
}, [backend, parentSession, runtime, sessionId, t]);
|
||||
|
||||
const openLogs = useCallback(async (container: DockerContainerInfo) => {
|
||||
const id = container.id.slice(0, 12);
|
||||
@@ -387,7 +395,7 @@ export const DockerContainersPanel = memo(function DockerContainersPanel({
|
||||
backend,
|
||||
parentSession,
|
||||
`logs: ${container.name || id}`,
|
||||
buildDockerLogsCommand(id),
|
||||
buildContainerLogsCommand(runtime, id, parentSession.shellType),
|
||||
{ icon: await buildContainerPopupIcon(container.image) },
|
||||
);
|
||||
if (!result.success) {
|
||||
@@ -399,7 +407,7 @@ export const DockerContainersPanel = memo(function DockerContainersPanel({
|
||||
});
|
||||
showSystemManagerError(result.error || t('systemManager.errors.actionFailed'), t('common.error'));
|
||||
}
|
||||
}, [backend, parentSession, sessionId, t]);
|
||||
}, [backend, parentSession, runtime, sessionId, t]);
|
||||
|
||||
return (
|
||||
<div className="flex flex-col flex-1 min-h-0 overflow-hidden" data-section="docker-containers">
|
||||
|
||||
@@ -2,6 +2,7 @@ import { Layers, Loader2, Tag, Trash2 } from 'lucide-react';
|
||||
import React, { memo, useCallback, useEffect, useMemo, useRef, useState } from 'react';
|
||||
import { useI18n } from '../../application/i18n/I18nProvider';
|
||||
import type { useSystemManagerBackend } from '../../application/state/useSystemManagerBackend';
|
||||
import { describeRuntimeFailure, type ContainerRuntimeId } from '../../domain/systemManager/containerRuntime';
|
||||
import { dockerImageRowKey, type DockerImageInfo } from '../../domain/systemManager/types';
|
||||
import { dockerImageInfoEqual } from '../../domain/systemManager/pollEquals';
|
||||
import { DockerImageIcon } from './DockerImageIcon';
|
||||
@@ -35,6 +36,7 @@ type PendingImageConfirm =
|
||||
|
||||
interface DockerImagesPanelProps {
|
||||
sessionId: string;
|
||||
runtime?: ContainerRuntimeId;
|
||||
isVisible: boolean;
|
||||
warmupEnabled?: boolean;
|
||||
backend: Backend;
|
||||
@@ -89,6 +91,7 @@ const DockerImageRow = memo(function DockerImageRow({
|
||||
|
||||
export const DockerImagesPanel = memo(function DockerImagesPanel({
|
||||
sessionId,
|
||||
runtime = 'docker',
|
||||
isVisible,
|
||||
warmupEnabled = false,
|
||||
backend,
|
||||
@@ -111,15 +114,15 @@ export const DockerImagesPanel = memo(function DockerImagesPanel({
|
||||
// Clear busy so a hung/in-flight action from the previous session cannot
|
||||
// leave the new session's confirm dialog permanently disabled.
|
||||
setActionBusy(false);
|
||||
}, [sessionId]);
|
||||
}, [runtime, sessionId]);
|
||||
|
||||
const imagesFetcher = useCallback(async () => {
|
||||
const result = await backend.listDockerImages(sessionId);
|
||||
const result = await backend.listDockerImages(sessionId, runtime);
|
||||
if (!result.success || !result.images) {
|
||||
throw new Error(result.error || stableT('systemManager.errors.loadDockerImages'));
|
||||
throw new Error(describeRuntimeFailure(result, stableT, stableT('systemManager.errors.loadDockerImages')));
|
||||
}
|
||||
return result.images;
|
||||
}, [backend, sessionId, stableT]);
|
||||
}, [backend, runtime, sessionId, stableT]);
|
||||
|
||||
const listIntervalMs = Math.max(3, listRefreshIntervalSec) * 1000;
|
||||
const { data: images, error, loading, refresh } = usePolling<DockerImageInfo[]>(
|
||||
@@ -127,7 +130,7 @@ export const DockerImagesPanel = memo(function DockerImagesPanel({
|
||||
listIntervalMs,
|
||||
isVisible || warmupEnabled,
|
||||
(prev, next) => mergePollListByKey(prev, next, dockerImageRowKey, dockerImageInfoEqual),
|
||||
{ poll: isVisible, resetKey: sessionId },
|
||||
{ poll: isVisible, resetKey: `${sessionId}:${runtime}` },
|
||||
);
|
||||
|
||||
const filtered = useMemo(() => {
|
||||
@@ -154,18 +157,19 @@ export const DockerImagesPanel = memo(function DockerImagesPanel({
|
||||
const displayList = useStableListOrder(filtered, dockerImageRowKey, query, compareImages);
|
||||
|
||||
const getImageInspectKey = useCallback((image: DockerImageInfo) => (
|
||||
`${sessionId}:${dockerImageRowKey(image)}`
|
||||
), [sessionId]);
|
||||
`${sessionId}:${runtime}:${dockerImageRowKey(image)}`
|
||||
), [runtime, sessionId]);
|
||||
const fetchImageInspect = useCallback(async (image: DockerImageInfo) => {
|
||||
const result = await backend.dockerImageInspect({
|
||||
sessionId,
|
||||
imageId: image.id.slice(0, 12),
|
||||
runtime,
|
||||
});
|
||||
if (!result.success) {
|
||||
throw new Error(result.error || stableT('systemManager.errors.actionFailed'));
|
||||
throw new Error(describeRuntimeFailure(result, stableT, stableT('systemManager.errors.actionFailed')));
|
||||
}
|
||||
return result.inspect ?? null;
|
||||
}, [backend, sessionId, stableT]);
|
||||
}, [backend, runtime, sessionId, stableT]);
|
||||
const {
|
||||
records: inspectByImageKey,
|
||||
loadRecord: loadImageInspect,
|
||||
@@ -184,15 +188,26 @@ export const DockerImagesPanel = memo(function DockerImagesPanel({
|
||||
const actionGeneration = actionGenerationRef.current;
|
||||
setActionBusy(true);
|
||||
try {
|
||||
const tagged = Boolean(
|
||||
image.repository
|
||||
&& image.tag
|
||||
&& image.repository !== '<none>'
|
||||
&& image.tag !== '<none>',
|
||||
);
|
||||
const result = await backend.dockerImageAction({
|
||||
sessionId,
|
||||
runtime,
|
||||
action: 'rm',
|
||||
imageId: image.id.slice(0, 12),
|
||||
force: image.tag === '<none>',
|
||||
force: !tagged,
|
||||
...(tagged ? { repository: image.repository, tag: image.tag } : {}),
|
||||
});
|
||||
if (actionGenerationRef.current !== actionGeneration) return;
|
||||
if (!result.success) {
|
||||
showSystemManagerError(result.error || t('systemManager.errors.actionFailed'), t('common.error'));
|
||||
showSystemManagerError(
|
||||
describeRuntimeFailure(result, t, t('systemManager.errors.actionFailed')),
|
||||
t('common.error'),
|
||||
);
|
||||
return;
|
||||
}
|
||||
if (selectedId === dockerImageRowKey(image)) {
|
||||
@@ -205,7 +220,7 @@ export const DockerImagesPanel = memo(function DockerImagesPanel({
|
||||
setActionBusy(false);
|
||||
}
|
||||
}
|
||||
}, [backend, getImageInspectKey, invalidateImageInspect, refresh, selectedId, sessionId, t]);
|
||||
}, [backend, getImageInspectKey, invalidateImageInspect, refresh, runtime, selectedId, sessionId, t]);
|
||||
|
||||
const handleRemove = useCallback((image: DockerImageInfo) => {
|
||||
const label = image.name || image.id.slice(0, 12);
|
||||
@@ -216,10 +231,13 @@ export const DockerImagesPanel = memo(function DockerImagesPanel({
|
||||
const actionGeneration = actionGenerationRef.current;
|
||||
setActionBusy(true);
|
||||
try {
|
||||
const result = await backend.dockerImageAction({ sessionId, action: 'prune', all });
|
||||
const result = await backend.dockerImageAction({ sessionId, runtime, action: 'prune', all });
|
||||
if (actionGenerationRef.current !== actionGeneration) return;
|
||||
if (!result.success) {
|
||||
showSystemManagerError(result.error || t('systemManager.errors.actionFailed'), t('common.error'));
|
||||
showSystemManagerError(
|
||||
describeRuntimeFailure(result, t, t('systemManager.errors.actionFailed')),
|
||||
t('common.error'),
|
||||
);
|
||||
return;
|
||||
}
|
||||
await refresh();
|
||||
@@ -228,7 +246,7 @@ export const DockerImagesPanel = memo(function DockerImagesPanel({
|
||||
setActionBusy(false);
|
||||
}
|
||||
}
|
||||
}, [backend, refresh, sessionId, t]);
|
||||
}, [backend, refresh, runtime, sessionId, t]);
|
||||
|
||||
const handlePrune = useCallback((all: boolean) => {
|
||||
setConfirmTarget({ kind: 'prune', all });
|
||||
@@ -237,13 +255,17 @@ export const DockerImagesPanel = memo(function DockerImagesPanel({
|
||||
const handleTagSubmit = async (image: DockerImageInfo, repository: string, tag: string) => {
|
||||
const result = await backend.dockerImageAction({
|
||||
sessionId,
|
||||
runtime,
|
||||
action: 'tag',
|
||||
imageId: image.id.slice(0, 12),
|
||||
repository,
|
||||
tag: tag || 'latest',
|
||||
});
|
||||
if (!result.success) {
|
||||
showSystemManagerError(result.error || t('systemManager.errors.actionFailed'), t('common.error'));
|
||||
showSystemManagerError(
|
||||
describeRuntimeFailure(result, t, t('systemManager.errors.actionFailed')),
|
||||
t('common.error'),
|
||||
);
|
||||
return;
|
||||
}
|
||||
await refresh();
|
||||
|
||||
@@ -1,19 +1,32 @@
|
||||
import { Box, Layers } from 'lucide-react';
|
||||
import React, { memo, useState } from 'react';
|
||||
import { useI18n } from '../../application/i18n/I18nProvider';
|
||||
import { useContainerRuntimeSelection } from '../../application/state/systemManager/useContainerRuntimeSelection';
|
||||
import type { useSystemManagerBackend } from '../../application/state/useSystemManagerBackend';
|
||||
import type { ContainerSourceId } from '../../domain/systemManager/containerRuntime';
|
||||
import type { SessionCapabilities } from '../../domain/systemManager/types';
|
||||
import type { TerminalSession } from '../../types';
|
||||
import { cn } from '../../lib/utils';
|
||||
import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from '../ui/select';
|
||||
import { DockerContainersPanel } from './DockerContainersPanel';
|
||||
import { DockerImagesPanel } from './DockerImagesPanel';
|
||||
import { KubernetesPodsPanel } from './KubernetesPodsPanel';
|
||||
import { SystemPanelShell } from './SystemPanelUi';
|
||||
|
||||
type Backend = ReturnType<typeof useSystemManagerBackend>;
|
||||
type DockerSubTab = 'containers' | 'images';
|
||||
|
||||
const RUNTIME_LABEL: Record<ContainerSourceId, string> = {
|
||||
docker: 'systemManager.runtime.docker',
|
||||
podman: 'systemManager.runtime.podman',
|
||||
nerdctl: 'systemManager.runtime.nerdctl',
|
||||
kubernetes: 'systemManager.runtime.kubernetes',
|
||||
};
|
||||
|
||||
interface DockerManagerTabProps {
|
||||
sessionId: string;
|
||||
parentSession: TerminalSession;
|
||||
capabilities?: SessionCapabilities;
|
||||
isVisible: boolean;
|
||||
warmupEnabled?: boolean;
|
||||
backend: Backend;
|
||||
@@ -21,9 +34,48 @@ interface DockerManagerTabProps {
|
||||
statsRefreshIntervalSec: number;
|
||||
}
|
||||
|
||||
function CompactSelect({
|
||||
value,
|
||||
options,
|
||||
ariaLabel,
|
||||
section,
|
||||
onChange,
|
||||
className,
|
||||
}: {
|
||||
value: string;
|
||||
options: { id: string; label: string }[];
|
||||
ariaLabel: string;
|
||||
section: string;
|
||||
onChange: (value: string) => void;
|
||||
className?: string;
|
||||
}) {
|
||||
return (
|
||||
<Select value={value} onValueChange={onChange}>
|
||||
<SelectTrigger
|
||||
aria-label={ariaLabel}
|
||||
data-section={section}
|
||||
className={cn(
|
||||
"h-6 w-auto max-w-[9.5rem] gap-1 rounded border-border/40 bg-transparent px-2 py-0 text-[11px] shadow-none",
|
||||
className,
|
||||
)}
|
||||
>
|
||||
<SelectValue />
|
||||
</SelectTrigger>
|
||||
<SelectContent>
|
||||
{options.map((option) => (
|
||||
<SelectItem key={option.id} value={option.id} className="text-xs">
|
||||
{option.label}
|
||||
</SelectItem>
|
||||
))}
|
||||
</SelectContent>
|
||||
</Select>
|
||||
);
|
||||
}
|
||||
|
||||
export const DockerManagerTab = memo(function DockerManagerTab({
|
||||
sessionId,
|
||||
parentSession,
|
||||
capabilities,
|
||||
isVisible,
|
||||
warmupEnabled = false,
|
||||
backend,
|
||||
@@ -32,6 +84,26 @@ export const DockerManagerTab = memo(function DockerManagerTab({
|
||||
}: DockerManagerTabProps) {
|
||||
const { t } = useI18n();
|
||||
const [subTab, setSubTab] = useState<DockerSubTab>('containers');
|
||||
const {
|
||||
source,
|
||||
selectSource,
|
||||
contextName,
|
||||
selectContext,
|
||||
namespaceName,
|
||||
setNamespaceName,
|
||||
contexts,
|
||||
namespaces,
|
||||
scopeError,
|
||||
reloadScopes,
|
||||
runtime,
|
||||
detected,
|
||||
showRuntimeSwitcher,
|
||||
} = useContainerRuntimeSelection({
|
||||
sessionId,
|
||||
hostKey: parentSession.hostId || sessionId,
|
||||
capabilities,
|
||||
backend,
|
||||
});
|
||||
|
||||
const tabs: { id: DockerSubTab; icon: typeof Box; label: string }[] = [
|
||||
{ id: 'containers', icon: Box, label: t('systemManager.docker.subTabs.containers') },
|
||||
@@ -40,8 +112,8 @@ export const DockerManagerTab = memo(function DockerManagerTab({
|
||||
|
||||
return (
|
||||
<SystemPanelShell section="system-manager-docker">
|
||||
<div className="shrink-0 flex items-center gap-0.5 px-2 py-1 border-b border-border/30">
|
||||
{tabs.map(({ id, icon: Icon, label }) => (
|
||||
<div className="shrink-0 flex items-center gap-1 px-2 py-1 border-b border-border/30">
|
||||
{source !== 'kubernetes' && tabs.map(({ id, icon: Icon, label }) => (
|
||||
<button
|
||||
key={id}
|
||||
type="button"
|
||||
@@ -57,29 +129,79 @@ export const DockerManagerTab = memo(function DockerManagerTab({
|
||||
{label}
|
||||
</button>
|
||||
))}
|
||||
{showRuntimeSwitcher && source && detected.includes(source) && (
|
||||
<CompactSelect
|
||||
value={source}
|
||||
ariaLabel={t('systemManager.runtime.switch')}
|
||||
section="container-runtime-switcher"
|
||||
className="shrink-0"
|
||||
options={detected.map((id) => ({ id, label: t(RUNTIME_LABEL[id]) }))}
|
||||
onChange={selectSource}
|
||||
/>
|
||||
)}
|
||||
{source === 'kubernetes' && contextName && contexts.some((item) => item.name === contextName) && (
|
||||
<CompactSelect
|
||||
value={contextName}
|
||||
ariaLabel={t('systemManager.k8s.context')}
|
||||
section="k8s-context-select"
|
||||
className="w-full min-w-0 max-w-none flex-1"
|
||||
options={contexts.map((item) => ({ id: item.name, label: item.name }))}
|
||||
onChange={selectContext}
|
||||
/>
|
||||
)}
|
||||
{source === 'kubernetes' && namespaceName && namespaces.includes(namespaceName) && (
|
||||
<CompactSelect
|
||||
value={namespaceName}
|
||||
ariaLabel={t('systemManager.k8s.namespace')}
|
||||
section="k8s-namespace-select"
|
||||
className="shrink-0"
|
||||
options={namespaces.map((name) => ({ id: name, label: name }))}
|
||||
onChange={setNamespaceName}
|
||||
/>
|
||||
)}
|
||||
</div>
|
||||
|
||||
<div className="flex-1 min-h-0 flex flex-col">
|
||||
<div className={cn('flex-1 min-h-0 flex flex-col', subTab !== 'containers' && 'hidden')}>
|
||||
<DockerContainersPanel
|
||||
{source && source !== 'kubernetes' && (
|
||||
<>
|
||||
<div className={cn('flex-1 min-h-0 flex flex-col', subTab !== 'containers' && 'hidden')}>
|
||||
<DockerContainersPanel
|
||||
sessionId={sessionId}
|
||||
parentSession={parentSession}
|
||||
runtime={runtime}
|
||||
isVisible={isVisible && subTab === 'containers'}
|
||||
warmupEnabled={warmupEnabled || (isVisible && subTab !== 'containers')}
|
||||
backend={backend}
|
||||
listRefreshIntervalSec={listRefreshIntervalSec}
|
||||
statsRefreshIntervalSec={statsRefreshIntervalSec}
|
||||
/>
|
||||
</div>
|
||||
<div className={cn('flex-1 min-h-0 flex flex-col', subTab !== 'images' && 'hidden')}>
|
||||
<DockerImagesPanel
|
||||
sessionId={sessionId}
|
||||
runtime={runtime}
|
||||
isVisible={isVisible && subTab === 'images'}
|
||||
warmupEnabled={warmupEnabled || (isVisible && subTab !== 'images')}
|
||||
backend={backend}
|
||||
listRefreshIntervalSec={listRefreshIntervalSec}
|
||||
/>
|
||||
</div>
|
||||
</>
|
||||
)}
|
||||
{source === 'kubernetes' && (
|
||||
<KubernetesPodsPanel
|
||||
sessionId={sessionId}
|
||||
parentSession={parentSession}
|
||||
isVisible={isVisible && subTab === 'containers'}
|
||||
warmupEnabled={warmupEnabled || (isVisible && subTab !== 'containers')}
|
||||
contextName={contextName}
|
||||
namespaceName={namespaceName}
|
||||
scopeError={scopeError}
|
||||
isVisible={isVisible}
|
||||
warmupEnabled={warmupEnabled}
|
||||
backend={backend}
|
||||
listRefreshIntervalSec={listRefreshIntervalSec}
|
||||
statsRefreshIntervalSec={statsRefreshIntervalSec}
|
||||
onReloadScopes={reloadScopes}
|
||||
/>
|
||||
</div>
|
||||
<div className={cn('flex-1 min-h-0 flex flex-col', subTab !== 'images' && 'hidden')}>
|
||||
<DockerImagesPanel
|
||||
sessionId={sessionId}
|
||||
isVisible={isVisible && subTab === 'images'}
|
||||
warmupEnabled={warmupEnabled || (isVisible && subTab !== 'images')}
|
||||
backend={backend}
|
||||
listRefreshIntervalSec={listRefreshIntervalSec}
|
||||
/>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</SystemPanelShell>
|
||||
);
|
||||
|
||||
@@ -0,0 +1,361 @@
|
||||
import { Box, FileText, Info, Terminal, Trash2 } from 'lucide-react';
|
||||
import React, { memo, useCallback, useEffect, useMemo, useState } from 'react';
|
||||
import { useI18n } from '../../application/i18n/I18nProvider';
|
||||
import type { useSystemManagerBackend } from '../../application/state/useSystemManagerBackend';
|
||||
import { usePolling, useStableTranslate } from '../../application/state/useSystemManager';
|
||||
import type { KubectlPodInfo } from '../../domain/systemManager/containerRuntime';
|
||||
import {
|
||||
buildKubectlDescribeCommand,
|
||||
buildKubectlExecShellCommand,
|
||||
buildKubectlLogsCommand,
|
||||
describeRuntimeFailure,
|
||||
podStatusMessageKey,
|
||||
} from '../../domain/systemManager/containerRuntime';
|
||||
import type { TerminalSession } from '../../types';
|
||||
import { Dialog, DialogContent, DialogHeader, DialogTitle } from '../ui/dialog';
|
||||
import { useStableListOrder } from './listStable';
|
||||
import { openInteractiveTerminal } from './openInteractiveTerminal';
|
||||
import { SystemPanelConfirmDialog } from './SystemPanelConfirmDialog';
|
||||
import {
|
||||
SystemPanelEmpty,
|
||||
SystemPanelError,
|
||||
SystemPanelList,
|
||||
SystemPanelLoading,
|
||||
SystemPanelMetaBar,
|
||||
SystemPanelRefreshButton,
|
||||
SystemPanelRoundButton,
|
||||
SystemPanelRow,
|
||||
SystemPanelSearch,
|
||||
SystemPanelStatusBadge,
|
||||
SystemPanelToolbar,
|
||||
} from './SystemPanelUi';
|
||||
import { showSystemManagerError } from './systemManagerToast';
|
||||
|
||||
type Backend = ReturnType<typeof useSystemManagerBackend>;
|
||||
|
||||
interface KubernetesPodsPanelProps {
|
||||
sessionId: string;
|
||||
parentSession: TerminalSession;
|
||||
contextName: string | null;
|
||||
namespaceName: string | null;
|
||||
scopeError?: string | null;
|
||||
isVisible: boolean;
|
||||
warmupEnabled?: boolean;
|
||||
backend: Backend;
|
||||
listRefreshIntervalSec: number;
|
||||
onReloadScopes: () => void;
|
||||
}
|
||||
|
||||
function podTone(status: string): 'success' | 'warning' | 'muted' {
|
||||
if (status === 'Running') return 'success';
|
||||
if (status === 'Pending' || status === 'Completed' || status === 'Succeeded') return 'muted';
|
||||
return 'warning';
|
||||
}
|
||||
|
||||
const KubernetesPodRow = memo(function KubernetesPodRow({
|
||||
pod,
|
||||
selected,
|
||||
pendingDelete,
|
||||
onSelect,
|
||||
onShell,
|
||||
onLogs,
|
||||
onDescribe,
|
||||
onDelete,
|
||||
}: {
|
||||
pod: KubectlPodInfo;
|
||||
selected: boolean;
|
||||
pendingDelete: boolean;
|
||||
onSelect: (pod: KubectlPodInfo) => void;
|
||||
onShell: (pod: KubectlPodInfo) => void;
|
||||
onLogs: (pod: KubectlPodInfo) => void;
|
||||
onDescribe: (pod: KubectlPodInfo) => void;
|
||||
onDelete: (pod: KubectlPodInfo) => void;
|
||||
}) {
|
||||
const { t } = useI18n();
|
||||
const statusKey = podStatusMessageKey(pod.status);
|
||||
const statusLabel = statusKey ? t(statusKey) : pod.status;
|
||||
return (
|
||||
<SystemPanelRow
|
||||
selected={selected}
|
||||
onClick={() => onSelect(pod)}
|
||||
leading={<Box size={14} className="text-muted-foreground" />}
|
||||
title={pod.name}
|
||||
subtitle={`${pod.containers.join(', ') || '—'} · ${t('systemManager.k8s.restarts', { count: String(pod.restarts) })}${pod.age ? ` · ${pod.age}` : ''}`}
|
||||
trailing={(
|
||||
<div className="flex shrink-0 items-center gap-1">
|
||||
<SystemPanelStatusBadge tone={podTone(pod.status)}>{statusLabel}</SystemPanelStatusBadge>
|
||||
{pod.shellContainers.length > 0 && (
|
||||
<SystemPanelRoundButton title={t('systemManager.docker.shell')} onClick={() => onShell(pod)}>
|
||||
<Terminal size={12} />
|
||||
</SystemPanelRoundButton>
|
||||
)}
|
||||
<SystemPanelRoundButton title={t('systemManager.docker.logs')} onClick={() => onLogs(pod)}>
|
||||
<FileText size={12} />
|
||||
</SystemPanelRoundButton>
|
||||
<SystemPanelRoundButton title={t('systemManager.k8s.describe')} onClick={() => onDescribe(pod)}>
|
||||
<Info size={12} />
|
||||
</SystemPanelRoundButton>
|
||||
<SystemPanelRoundButton
|
||||
title={t('systemManager.k8s.delete')}
|
||||
destructive
|
||||
loading={pendingDelete}
|
||||
onClick={() => onDelete(pod)}
|
||||
>
|
||||
<Trash2 size={12} />
|
||||
</SystemPanelRoundButton>
|
||||
</div>
|
||||
)}
|
||||
/>
|
||||
);
|
||||
});
|
||||
|
||||
export const KubernetesPodsPanel = memo(function KubernetesPodsPanel({
|
||||
sessionId,
|
||||
parentSession,
|
||||
contextName,
|
||||
namespaceName,
|
||||
scopeError = null,
|
||||
isVisible,
|
||||
warmupEnabled = false,
|
||||
backend,
|
||||
listRefreshIntervalSec,
|
||||
onReloadScopes,
|
||||
}: KubernetesPodsPanelProps) {
|
||||
const { t } = useI18n();
|
||||
const stableT = useStableTranslate();
|
||||
const [query, setQuery] = useState('');
|
||||
const [selectedName, setSelectedName] = useState<string | null>(null);
|
||||
const [pendingDelete, setPendingDelete] = useState<string | null>(null);
|
||||
const [confirmPod, setConfirmPod] = useState<string | null>(null);
|
||||
const [containerPrompt, setContainerPrompt] = useState<{
|
||||
pod: KubectlPodInfo;
|
||||
action: 'shell' | 'logs';
|
||||
} | null>(null);
|
||||
|
||||
const scopeKey = `${sessionId}:${contextName ?? ''}:${namespaceName ?? ''}`;
|
||||
|
||||
useEffect(() => {
|
||||
setSelectedName(null);
|
||||
setPendingDelete(null);
|
||||
setConfirmPod(null);
|
||||
setContainerPrompt(null);
|
||||
}, [scopeKey]);
|
||||
|
||||
const podsFetcher = useCallback(async () => {
|
||||
if (!contextName || !namespaceName) return [];
|
||||
const result = await backend.kubectl({
|
||||
sessionId,
|
||||
op: 'pods',
|
||||
context: contextName,
|
||||
namespace: namespaceName,
|
||||
});
|
||||
if (!result.success || !result.pods) {
|
||||
throw new Error(describeRuntimeFailure(
|
||||
result,
|
||||
stableT,
|
||||
stableT('systemManager.errors.loadPods'),
|
||||
contextName,
|
||||
));
|
||||
}
|
||||
return result.pods;
|
||||
}, [backend, contextName, namespaceName, sessionId, stableT]);
|
||||
|
||||
const listIntervalMs = Math.max(3, listRefreshIntervalSec) * 1000;
|
||||
const { data: pods, error, loading, refresh } = usePolling<KubectlPodInfo[]>(
|
||||
podsFetcher,
|
||||
listIntervalMs,
|
||||
(isVisible || warmupEnabled) && !!contextName && !!namespaceName,
|
||||
undefined,
|
||||
{ poll: isVisible, resetKey: scopeKey },
|
||||
);
|
||||
|
||||
const matched = useMemo(() => {
|
||||
const q = query.trim().toLowerCase();
|
||||
return (pods ?? []).filter((pod) => {
|
||||
if (!q) return true;
|
||||
const statusKey = podStatusMessageKey(pod.status);
|
||||
const statusLabel = (statusKey ? t(statusKey) : pod.status).toLowerCase();
|
||||
return pod.name.toLowerCase().includes(q)
|
||||
|| pod.status.toLowerCase().includes(q)
|
||||
|| statusLabel.includes(q)
|
||||
|| pod.containers.some((name) => name.toLowerCase().includes(q));
|
||||
});
|
||||
}, [pods, query, t]);
|
||||
|
||||
const displayList = useStableListOrder<KubectlPodInfo, string>(
|
||||
matched,
|
||||
(pod) => pod.name,
|
||||
`${scopeKey}|${query}`,
|
||||
(a, b) => a.name.localeCompare(b.name),
|
||||
);
|
||||
|
||||
const openCommand = useCallback(async (title: string, command: string) => {
|
||||
const result = await openInteractiveTerminal(backend, parentSession, title, command);
|
||||
if (!result.success) {
|
||||
showSystemManagerError(result.error || t('systemManager.errors.actionFailed'), t('common.error'));
|
||||
}
|
||||
}, [backend, parentSession, t]);
|
||||
|
||||
const runPodCommand = useCallback((pod: KubectlPodInfo, action: 'shell' | 'logs' | 'describe', container?: string) => {
|
||||
if (!contextName || !namespaceName) return;
|
||||
if (action === 'describe') {
|
||||
void openCommand(
|
||||
`describe: ${pod.name}`,
|
||||
buildKubectlDescribeCommand(contextName, namespaceName, pod.name, parentSession.shellType),
|
||||
);
|
||||
return;
|
||||
}
|
||||
const containers = action === 'shell' ? pod.shellContainers : pod.containers;
|
||||
if (action === 'shell' && containers.length === 0) return;
|
||||
if (!container && containers.length > 1) {
|
||||
setContainerPrompt({ pod, action });
|
||||
return;
|
||||
}
|
||||
const chosen = container || containers[0];
|
||||
if (action === 'shell') {
|
||||
void openCommand(
|
||||
`pod: ${pod.name}`,
|
||||
buildKubectlExecShellCommand(contextName, namespaceName, pod.name, chosen, parentSession.shellType),
|
||||
);
|
||||
return;
|
||||
}
|
||||
void openCommand(
|
||||
`logs: ${pod.name}`,
|
||||
buildKubectlLogsCommand(contextName, namespaceName, pod.name, chosen, parentSession.shellType),
|
||||
);
|
||||
}, [contextName, namespaceName, openCommand, parentSession.shellType]);
|
||||
|
||||
const deletePod = useCallback(async (name: string) => {
|
||||
if (!contextName || !namespaceName) return;
|
||||
setPendingDelete(name);
|
||||
try {
|
||||
const result = await backend.kubectl({
|
||||
sessionId,
|
||||
op: 'delete',
|
||||
context: contextName,
|
||||
namespace: namespaceName,
|
||||
pod: name,
|
||||
});
|
||||
if (!result.success) {
|
||||
showSystemManagerError(
|
||||
describeRuntimeFailure(result, t, t('systemManager.errors.actionFailed'), contextName),
|
||||
t('common.error'),
|
||||
);
|
||||
return;
|
||||
}
|
||||
if (selectedName === name) setSelectedName(null);
|
||||
await refresh();
|
||||
} finally {
|
||||
setPendingDelete(null);
|
||||
}
|
||||
}, [backend, contextName, namespaceName, refresh, selectedName, sessionId, t]);
|
||||
|
||||
const visibleError = scopeError || error;
|
||||
|
||||
return (
|
||||
<div className="flex flex-col flex-1 min-h-0 overflow-hidden" data-section="kubernetes-pods">
|
||||
<SystemPanelToolbar
|
||||
trailing={(
|
||||
<SystemPanelRefreshButton
|
||||
title={t('history.action.refresh')}
|
||||
loading={loading}
|
||||
onClick={() => {
|
||||
onReloadScopes();
|
||||
void refresh();
|
||||
}}
|
||||
/>
|
||||
)}
|
||||
>
|
||||
<SystemPanelSearch
|
||||
value={query}
|
||||
onChange={setQuery}
|
||||
placeholder={t('systemManager.k8s.search')}
|
||||
/>
|
||||
</SystemPanelToolbar>
|
||||
|
||||
<SystemPanelMetaBar>
|
||||
{t('systemManager.k8s.meta', { count: String(displayList.length) })}
|
||||
</SystemPanelMetaBar>
|
||||
|
||||
<SystemPanelList>
|
||||
{!contextName && !visibleError && (
|
||||
<SystemPanelEmpty icon={Box} message={t('systemManager.k8s.noContext')} />
|
||||
)}
|
||||
{visibleError && (
|
||||
<SystemPanelError
|
||||
message={visibleError}
|
||||
onRetry={() => {
|
||||
onReloadScopes();
|
||||
void refresh();
|
||||
}}
|
||||
retryLabel={t('history.action.retry')}
|
||||
loading={loading}
|
||||
/>
|
||||
)}
|
||||
{!visibleError && contextName && displayList.length === 0 && loading && (
|
||||
<SystemPanelLoading message={t('systemManager.common.loading')} />
|
||||
)}
|
||||
{!visibleError && contextName && namespaceName && displayList.length === 0 && !loading && (
|
||||
<SystemPanelEmpty icon={Box} message={t('systemManager.k8s.empty')} />
|
||||
)}
|
||||
{displayList.map((pod) => (
|
||||
<KubernetesPodRow
|
||||
key={pod.name}
|
||||
pod={pod}
|
||||
selected={selectedName === pod.name}
|
||||
pendingDelete={pendingDelete === pod.name}
|
||||
onSelect={(item) => setSelectedName((current) => current === item.name ? null : item.name)}
|
||||
onShell={(item) => runPodCommand(item, 'shell')}
|
||||
onLogs={(item) => runPodCommand(item, 'logs')}
|
||||
onDescribe={(item) => runPodCommand(item, 'describe')}
|
||||
onDelete={(item) => setConfirmPod(item.name)}
|
||||
/>
|
||||
))}
|
||||
</SystemPanelList>
|
||||
|
||||
<SystemPanelConfirmDialog
|
||||
open={confirmPod !== null}
|
||||
title={t('systemManager.k8s.delete')}
|
||||
message={t('systemManager.k8s.confirmDelete', { name: confirmPod ?? '' })}
|
||||
confirmLabel={t('systemManager.k8s.delete')}
|
||||
destructive
|
||||
busy={pendingDelete !== null}
|
||||
onOpenChange={(open) => { if (!open) setConfirmPod(null); }}
|
||||
onConfirm={() => {
|
||||
const name = confirmPod;
|
||||
setConfirmPod(null);
|
||||
if (!name) return;
|
||||
void deletePod(name);
|
||||
}}
|
||||
/>
|
||||
|
||||
<Dialog open={containerPrompt !== null} onOpenChange={(open) => { if (!open) setContainerPrompt(null); }}>
|
||||
<DialogContent className="sm:max-w-[380px]">
|
||||
<DialogHeader>
|
||||
<DialogTitle>{t('systemManager.k8s.chooseContainer')}</DialogTitle>
|
||||
</DialogHeader>
|
||||
<div className="flex flex-col gap-1">
|
||||
{(containerPrompt?.action === 'shell'
|
||||
? containerPrompt.pod.shellContainers
|
||||
: containerPrompt?.pod.containers
|
||||
)?.map((name) => (
|
||||
<button
|
||||
key={name}
|
||||
type="button"
|
||||
className="rounded-sm px-2 py-1.5 text-left text-sm outline-none transition-colors hover:bg-accent hover:text-accent-foreground focus:bg-accent focus:text-accent-foreground"
|
||||
onClick={() => {
|
||||
const prompt = containerPrompt;
|
||||
setContainerPrompt(null);
|
||||
if (!prompt) return;
|
||||
runPodCommand(prompt.pod, prompt.action, name);
|
||||
}}
|
||||
>
|
||||
{name}
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
</DialogContent>
|
||||
</Dialog>
|
||||
</div>
|
||||
);
|
||||
});
|
||||
@@ -14,6 +14,7 @@ import { resolveCapabilityPanelState } from '../../domain/systemManagerPanelStat
|
||||
import {
|
||||
allowSystemManagerMutations,
|
||||
buildSystemManagerTabs,
|
||||
hasContainerSource,
|
||||
shouldCollectServerStats,
|
||||
} from '../../domain/systemManager/systemTarget';
|
||||
|
||||
@@ -284,13 +285,14 @@ export const SystemManagerSidePanel = memo(function SystemManagerSidePanel({
|
||||
}, [isConnectedSession, isVisible, availableTabsKey, tabBarEl]);
|
||||
|
||||
// Must be defined before early returns to comply with React rules of hooks.
|
||||
const containerSourceReady = hasContainerSource(capabilities);
|
||||
const prevTabRef = React.useRef(resolvedTab);
|
||||
const probingRef = React.useRef(false);
|
||||
React.useEffect(() => {
|
||||
const prev = prevTabRef.current;
|
||||
prevTabRef.current = resolvedTab;
|
||||
if (prev === resolvedTab) return;
|
||||
if (resolvedTab === 'docker' && capabilities?.hasDocker !== true) {
|
||||
if (resolvedTab === 'docker' && !containerSourceReady) {
|
||||
if (!probingRef.current) {
|
||||
probingRef.current = true;
|
||||
refreshCapabilities().finally(() => { probingRef.current = false; });
|
||||
@@ -298,7 +300,7 @@ export const SystemManagerSidePanel = memo(function SystemManagerSidePanel({
|
||||
} else if (resolvedTab === 'tmux' && capabilities?.hasTmux !== true) {
|
||||
void refreshCapabilities();
|
||||
}
|
||||
}, [resolvedTab, capabilities, refreshCapabilities]);
|
||||
}, [resolvedTab, capabilities, containerSourceReady, refreshCapabilities]);
|
||||
|
||||
// Auto-poll for Docker capabilities while Docker tab is active and Docker not yet detected.
|
||||
// Use setTimeout recursion so the next probe only starts after the previous one finishes,
|
||||
@@ -315,7 +317,7 @@ export const SystemManagerSidePanel = memo(function SystemManagerSidePanel({
|
||||
// preventing stale probes from surviving cleanup (unlike cancelledRef which is shared).
|
||||
// First poll is delayed by one interval to avoid overlapping with the tab-switch probe.
|
||||
React.useEffect(() => {
|
||||
if (!isVisible || resolvedTab !== 'docker' || capabilities?.hasDocker === true) return;
|
||||
if (!isVisible || resolvedTab !== 'docker' || containerSourceReady) return;
|
||||
|
||||
let cancelled = false;
|
||||
let timerId: ReturnType<typeof setTimeout>;
|
||||
@@ -344,7 +346,7 @@ export const SystemManagerSidePanel = memo(function SystemManagerSidePanel({
|
||||
cancelled = true;
|
||||
if (timerId) clearTimeout(timerId);
|
||||
};
|
||||
}, [isVisible, resolvedTab, capabilities?.hasDocker, capabilitiesTtlMs]);
|
||||
}, [isVisible, resolvedTab, containerSourceReady, capabilitiesTtlMs]);
|
||||
|
||||
const selectTab = useCallback((id: SystemManagerSubTab) => {
|
||||
setActiveTab(id);
|
||||
@@ -398,7 +400,7 @@ export const SystemManagerSidePanel = memo(function SystemManagerSidePanel({
|
||||
}
|
||||
|
||||
const tmuxReady = capabilities?.hasTmux === true;
|
||||
const dockerReady = capabilities?.hasDocker === true;
|
||||
const dockerReady = containerSourceReady;
|
||||
const gpuReady = capabilities?.hasNvidiaSmi === true || capabilities?.hasNpuSmi === true;
|
||||
const portsReady = (
|
||||
capabilities?.hasSs === true
|
||||
@@ -621,8 +623,10 @@ export const SystemManagerSidePanel = memo(function SystemManagerSidePanel({
|
||||
) : dockerPanelState === 'ready' ? (
|
||||
<div className={cn('flex-1 min-h-0 flex flex-col', resolvedTab !== 'docker' && 'hidden')}>
|
||||
<DockerManagerTab
|
||||
key={session.hostId || sessionId}
|
||||
sessionId={sessionId}
|
||||
parentSession={session}
|
||||
capabilities={capabilities}
|
||||
isVisible={isVisible && resolvedTab === 'docker'}
|
||||
warmupEnabled={isVisible && resolvedTab !== 'docker'}
|
||||
backend={backend}
|
||||
|
||||
@@ -0,0 +1,25 @@
|
||||
import assert from 'node:assert/strict';
|
||||
import test from 'node:test';
|
||||
import { readFileSync } from 'node:fs';
|
||||
|
||||
test('kubernetes keeps the shared runtime select and drops the Containers back button', () => {
|
||||
const tab = readFileSync(new URL('./DockerManagerTab.tsx', import.meta.url), 'utf8');
|
||||
const hook = readFileSync(
|
||||
new URL('../../application/state/systemManager/useContainerRuntimeSelection.ts', import.meta.url),
|
||||
'utf8',
|
||||
);
|
||||
|
||||
assert.equal(tab.includes('container-runtime-back'), false);
|
||||
assert.match(tab, /data-section=\{section\}/);
|
||||
assert.match(tab, /section="container-runtime-switcher"/);
|
||||
assert.match(tab, /section="k8s-context-select"/);
|
||||
assert.match(tab, /section="k8s-namespace-select"/);
|
||||
assert.doesNotMatch(tab, /<div className="flex-1" \/>/);
|
||||
assert.match(tab, /section="k8s-context-select"[\s\S]*className="w-full min-w-0 max-w-none flex-1"/);
|
||||
assert.match(tab, /section="container-runtime-switcher"[\s\S]*className="shrink-0"/);
|
||||
assert.match(tab, /section="k8s-namespace-select"[\s\S]*className="shrink-0"/);
|
||||
assert.match(hook, /const showRuntimeSwitcher = detected\.length > 1;/);
|
||||
assert.doesNotMatch(hook, /source !== 'kubernetes' && detected\.length/);
|
||||
assert.match(hook, /reconcileContainerSource\(/);
|
||||
assert.match(hook, /isAutomaticRuntimeFallback\(remembered, source, available\)/);
|
||||
});
|
||||
@@ -93,3 +93,71 @@ test('openInteractiveTerminal keeps SSH connection reuse for ordinary connected
|
||||
assert.equal(payload.sourceSession.protocol, 'ssh');
|
||||
assert.equal(payload.sourceSession.reuseConnectionFromSessionId, 'session-1');
|
||||
});
|
||||
|
||||
async function withSystemPanels(
|
||||
panels: Array<{ getBoundingClientRect: () => { x: number; y: number; width: number; height: number } }>,
|
||||
run: () => Promise<void>,
|
||||
): Promise<void> {
|
||||
const previous = globalThis.document;
|
||||
globalThis.document = {
|
||||
querySelectorAll(selector: string) {
|
||||
return selector === '[data-section="system-manager-panel"]' ? panels : [];
|
||||
},
|
||||
} as unknown as Document;
|
||||
try {
|
||||
await run();
|
||||
} finally {
|
||||
if (previous === undefined) {
|
||||
delete (globalThis as { document?: Document }).document;
|
||||
} else {
|
||||
globalThis.document = previous;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
test('openInteractiveTerminal passes the system panel rectangle so the shell can avoid it', async () => {
|
||||
const panel = {
|
||||
getBoundingClientRect: () => ({ x: 1000, y: 40, width: 440, height: 860 }),
|
||||
};
|
||||
const payloads: Array<{ avoidClientRect?: { x: number; width: number } }> = [];
|
||||
await withSystemPanels([panel], async () => {
|
||||
await openInteractiveTerminal(
|
||||
{
|
||||
openTerminalPopup: async (payload: { avoidClientRect?: { x: number; width: number } }) => {
|
||||
payloads.push(payload);
|
||||
return { success: true };
|
||||
},
|
||||
} as never,
|
||||
parentSession(),
|
||||
'pod: multi-box',
|
||||
'kubectl exec -it multi-box -- sh',
|
||||
);
|
||||
});
|
||||
|
||||
assert.deepEqual(payloads[0]?.avoidClientRect, { x: 1000, y: 40, width: 440, height: 860 });
|
||||
});
|
||||
|
||||
test('openInteractiveTerminal skips a parked zero-size system panel and avoids the visible one', async () => {
|
||||
const parked = {
|
||||
getBoundingClientRect: () => ({ x: 0, y: 0, width: 0, height: 0 }),
|
||||
};
|
||||
const visible = {
|
||||
getBoundingClientRect: () => ({ x: 1000, y: 40, width: 440, height: 860 }),
|
||||
};
|
||||
const payloads: Array<{ avoidClientRect?: { x: number; y: number; width: number; height: number } }> = [];
|
||||
await withSystemPanels([parked, visible], async () => {
|
||||
await openInteractiveTerminal(
|
||||
{
|
||||
openTerminalPopup: async (payload: { avoidClientRect?: { x: number; y: number; width: number; height: number } }) => {
|
||||
payloads.push(payload);
|
||||
return { success: true };
|
||||
},
|
||||
} as never,
|
||||
parentSession(),
|
||||
'pod: multi-box',
|
||||
'kubectl exec -it multi-box -- sh',
|
||||
);
|
||||
});
|
||||
|
||||
assert.deepEqual(payloads[0]?.avoidClientRect, { x: 1000, y: 40, width: 440, height: 860 });
|
||||
});
|
||||
@@ -6,6 +6,18 @@ import type { useSystemManagerBackend } from '../../application/state/useSystemM
|
||||
|
||||
type Backend = ReturnType<typeof useSystemManagerBackend>;
|
||||
|
||||
function readSystemPanelClientRect(): { x: number; y: number; width: number; height: number } | undefined {
|
||||
if (typeof document === "undefined" || typeof document.querySelectorAll !== "function") return undefined;
|
||||
const panels = document.querySelectorAll('[data-section="system-manager-panel"]');
|
||||
for (const panel of panels) {
|
||||
if (typeof panel.getBoundingClientRect !== "function") continue;
|
||||
const rect = panel.getBoundingClientRect();
|
||||
if (rect.width < 1 || rect.height < 1) continue;
|
||||
return { x: rect.x, y: rect.y, width: rect.width, height: rect.height };
|
||||
}
|
||||
return undefined;
|
||||
}
|
||||
|
||||
function buildPopupTitle(parentSession: TerminalSession, title: string): string {
|
||||
const hostLabel = parentSession.hostLabel.trim();
|
||||
const cleanTitle = title.trim();
|
||||
@@ -62,11 +74,13 @@ export async function openInteractiveTerminal(
|
||||
canReuseConnection,
|
||||
hasIcon: !!options?.icon,
|
||||
});
|
||||
const avoidClientRect = readSystemPanelClientRect();
|
||||
const result = await backend.openTerminalPopup({
|
||||
title: popupTitle,
|
||||
icon: options?.icon,
|
||||
parentSessionId: parentSession.id,
|
||||
startupCommand,
|
||||
...(avoidClientRect ? { avoidClientRect } : {}),
|
||||
sourceSession: buildCommandPopupSourceSession(parentSession, startupCommand, canReuseConnection),
|
||||
});
|
||||
await writeSystemManagerDiagnostic('openInteractiveTerminal result', {
|
||||
|
||||
@@ -49,11 +49,11 @@ test("process and docker confirm dialogs reset when sessionId changes", () => {
|
||||
assert.match(processSource, /}, \[sessionId\]\);/);
|
||||
|
||||
assert.match(containersSource, /setConfirmAction\(null\)/);
|
||||
assert.match(containersSource, /}, \[sessionId\]\);/);
|
||||
assert.match(containersSource, /}, \[runtime, sessionId\]\);/);
|
||||
|
||||
assert.match(imagesSource, /setConfirmTarget\(null\)/);
|
||||
assert.match(imagesSource, /setActionBusy\(false\)/);
|
||||
assert.match(imagesSource, /}, \[sessionId\]\);/);
|
||||
assert.match(imagesSource, /}, \[runtime, sessionId\]\);/);
|
||||
});
|
||||
|
||||
test("ports and services confirm dialogs reset when sessionId changes", () => {
|
||||
|
||||
@@ -360,6 +360,36 @@ test("VaultHostListSection exposes normal group cards to keyboard", () => {
|
||||
assert.match(markup, /data-group-path="production"[^>]*tabindex="0"/);
|
||||
});
|
||||
|
||||
test("VaultHostListSection keeps selected host cards draggable and leaves unselected cards in place", () => {
|
||||
const otherHost = makeHost("other-host", "Other Router");
|
||||
const markup = renderHostList({
|
||||
viewMode: "list",
|
||||
displayedGroups: [],
|
||||
displayedHosts: [mainHost, otherHost],
|
||||
visibleDisplayedHosts: [mainHost, otherHost],
|
||||
isMultiSelectMode: true,
|
||||
selectedHostIds: new Set([mainHost.id]),
|
||||
});
|
||||
|
||||
assert.match(markup, /data-host-id="main-host"[^>]*draggable="true"/);
|
||||
assert.match(markup, /data-host-id="other-host"[^>]*draggable="false"/);
|
||||
const source = readFileSync(new URL("./VaultHostListSection.tsx", import.meta.url), "utf8");
|
||||
assert.match(source, /useVaultHostMarquee/);
|
||||
assert.match(source, /data-vault-marquee/);
|
||||
assert.match(source, /moveHostsToGroup/);
|
||||
});
|
||||
|
||||
test("VaultHostListSection keeps a normal host card draggable outside multi-select", () => {
|
||||
const markup = renderHostList({
|
||||
viewMode: "grid",
|
||||
displayedGroups: [],
|
||||
displayedHosts: [mainHost],
|
||||
visibleDisplayedHosts: [mainHost],
|
||||
});
|
||||
|
||||
assert.match(markup, /data-host-id="main-host"[^>]*draggable="true"/);
|
||||
});
|
||||
|
||||
test("VaultHostListSection exposes ungrouped hosts to keyboard and assistive technology", () => {
|
||||
const markup = renderHostList({
|
||||
viewMode: "list",
|
||||
|
||||
@@ -27,7 +27,10 @@ import {
|
||||
import type { GroupNode, Host } from "../../domain/models";
|
||||
import { isPluginHostProtocol } from "../../domain/pluginConnection";
|
||||
import { OpenDualPaneSftpMenuItem, OpenHostBrowserMenuItem } from "../host/HostTreeContextMenus";
|
||||
import { VAULT_HOST_DRAG_IDS, isVaultHostDraggable } from "../../domain/vaultHostDrag";
|
||||
import { getVaultHostRowSubtitle } from "../../domain/localShellHost";
|
||||
import { beginVaultHostCardDrag } from "./vaultHostDragImage";
|
||||
import { useVaultHostMarquee } from "./useVaultHostMarquee";
|
||||
|
||||
type VaultHostListSectionContext = Record<string, any>;
|
||||
|
||||
@@ -54,12 +57,15 @@ const isRelatedTargetInside = (
|
||||
};
|
||||
|
||||
const EMPTY_GROUP_PATH_SET = new Set<string>();
|
||||
const EMPTY_HOST_ID_SET: ReadonlySet<string> = new Set();
|
||||
const noopSetMultiSelectMode = (_enabled: boolean) => {};
|
||||
const noopSetSelectedHostIds = (_ids: Set<string>) => {};
|
||||
|
||||
export function VaultHostListSection({ ctx }: { ctx: VaultHostListSectionContext }) {
|
||||
const { Badge, Boolean, Button, cancelInlineGroupEdit, CheckSquare, ClipboardCopy, Clock, cn, commitInlineGroupRename, ContextMenu, ContextMenuContent, ContextMenuItem, ContextMenuTrigger, Copy, displayedGroups, displayedHosts, DistroAvatar, Edit2, FileSymlink, FolderPlus, FolderTree, getDropTargetClasses, getEffectiveHostDistro, groupConfigs, groupedDisplayHosts, handleCopyCredentials, handleCopyHostname, handleDuplicateHost, handleEditGroupConfig, handleEditHost, handleHostConnect, handleOpenHostBrowser, hostClickBehavior: hostClickBehaviorProp, handleUnmanageGroup, hasHostsSidePanel, hostListScrollRef, HostTreeView, isHostsSectionActive, isMultiSelectMode, lastPinnedId, LayoutGrid, managedGroupPaths, moveGroup, moveHostToGroup, onDeleteHost, Pin, pinnedHosts, Plug, recentHosts, reorderGroup, reorderHost, sanitizeHost, search, selectedGroupPath, selectedGroupPaths, selectedHostIds, selectedTags, sessionCount, setDeleteTargetPath, setDragOverDropTarget, setGroupDragOverDropTarget, setIsDeleteGroupOpen, setIsNewFolderOpen, setLastPinnedId, setNewFolderName, setSelectedGroupPath, setTargetParentPath, shouldHideEmptyRootHostsSection, showRecentHosts, sortMode, Square, Star, startInlineDeleteGroup, startInlineNewGroup, startInlineRenameGroup, t, toggleGroupSelection, toggleHostPinned, toggleHostSelection, Trash2, treeExpandedState, treeViewGroupTree, treeViewHosts, viewMode, visibleDisplayedHosts } = ctx;
|
||||
const { Badge, Boolean, Button, cancelInlineGroupEdit, CheckSquare, ClipboardCopy, Clock, cn, commitInlineGroupRename, ContextMenu, ContextMenuContent, ContextMenuItem, ContextMenuTrigger, Copy, displayedGroups, displayedHosts, DistroAvatar, Edit2, FileSymlink, FolderPlus, FolderTree, getDropTargetClasses, getEffectiveHostDistro, groupConfigs, groupedDisplayHosts, handleCopyCredentials, handleCopyHostname, handleDuplicateHost, handleEditGroupConfig, handleEditHost, handleHostConnect, handleOpenHostBrowser, hostClickBehavior: hostClickBehaviorProp, handleUnmanageGroup, hasHostsSidePanel, hostListScrollRef, HostTreeView, isHostsSectionActive, isMultiSelectMode, lastPinnedId, LayoutGrid, managedGroupPaths, moveGroup, moveHostToGroup, moveHostsToGroup, onDeleteHost, Pin, pinnedHosts, Plug, recentHosts, reorderGroup, reorderHost, sanitizeHost, search, selectedGroupPath, selectedGroupPaths, selectedHostIds, selectedTags, sessionCount, setDeleteTargetPath, setDragOverDropTarget, setGroupDragOverDropTarget, setIsDeleteGroupOpen, setIsMultiSelectMode = noopSetMultiSelectMode, setIsNewFolderOpen, setLastPinnedId, setNewFolderName, setSelectedGroupPath, setSelectedHostIds = noopSetSelectedHostIds, setTargetParentPath, shouldHideEmptyRootHostsSection, showRecentHosts, sortMode, Square, Star, startInlineDeleteGroup, startInlineNewGroup, startInlineRenameGroup, t, toggleGroupSelection, toggleHostPinned, toggleHostSelection, Trash2, treeExpandedState, treeViewGroupTree, treeViewHosts, viewMode, visibleDisplayedHosts } = ctx;
|
||||
const hostClickBehavior: HostClickBehavior = hostClickBehaviorProp === 'select' ? 'select' : 'connect';
|
||||
const multiSelectedGroupPaths: Set<string> = selectedGroupPaths ?? EMPTY_GROUP_PATH_SET;
|
||||
const [draggingHostId, setDraggingHostId] = React.useState<string | null>(null);
|
||||
const [draggingHostIds, setDraggingHostIds] = React.useState<ReadonlySet<string> | null>(null);
|
||||
const draggingHostIdRef = React.useRef<string | null>(null);
|
||||
const lastPreviewReorderRef = React.useRef<string | null>(null);
|
||||
const prepareGridLayoutAnimation = useVaultGridLayoutAnimation(hostListScrollRef);
|
||||
@@ -120,6 +126,19 @@ export function VaultHostListSection({ ctx }: { ctx: VaultHostListSectionContext
|
||||
setFocusedGroupPath(null);
|
||||
}, [selectedGroupPath, viewMode, hostClickBehavior]);
|
||||
|
||||
const {
|
||||
marqueeRect,
|
||||
onPointerDown: onMarqueePointerDown,
|
||||
onClickCapture: onMarqueeClickCapture,
|
||||
} = useVaultHostMarquee({
|
||||
containerRef: hostListScrollRef,
|
||||
viewMode,
|
||||
isMultiSelectMode: Boolean(isMultiSelectMode),
|
||||
selectedHostIds: selectedHostIds ?? EMPTY_HOST_ID_SET,
|
||||
setIsMultiSelectMode,
|
||||
setSelectedHostIds,
|
||||
});
|
||||
|
||||
const activateHost = React.useCallback((host: Host) => {
|
||||
const action = resolveHostActivateAction({
|
||||
behavior: hostClickBehavior,
|
||||
@@ -284,17 +303,25 @@ export function VaultHostListSection({ ctx }: { ctx: VaultHostListSectionContext
|
||||
|
||||
const resetHostDragState = React.useCallback(() => {
|
||||
draggingHostIdRef.current = null;
|
||||
setDraggingHostId(null);
|
||||
setDraggingHostIds(null);
|
||||
lastPreviewReorderRef.current = null;
|
||||
setDragOverDropTarget(null);
|
||||
}, [setDragOverDropTarget]);
|
||||
|
||||
const handleHostDragStart = React.useCallback((e: React.DragEvent, hostId: string) => {
|
||||
// copyMove: vault reorder uses move; focus-sidebar append uses copy.
|
||||
e.dataTransfer.effectAllowed = "copyMove";
|
||||
e.dataTransfer.setData("host-id", hostId);
|
||||
const ids = beginVaultHostCardDrag({
|
||||
dataTransfer: e.dataTransfer,
|
||||
hostId,
|
||||
isMultiSelectMode: !!isMultiSelectMode,
|
||||
selectedHostIds,
|
||||
countLabel: (count) => t("vault.hosts.dragSelectionCount", { count }),
|
||||
});
|
||||
if (!ids) {
|
||||
e.preventDefault();
|
||||
return;
|
||||
}
|
||||
draggingHostIdRef.current = hostId;
|
||||
setDraggingHostId(hostId);
|
||||
setDraggingHostIds(new Set(ids));
|
||||
lastPreviewReorderRef.current = null;
|
||||
|
||||
// Grid preview reorder can move the card into another virtual row, and rows
|
||||
@@ -308,7 +335,7 @@ export function VaultHostListSection({ ctx }: { ctx: VaultHostListSectionContext
|
||||
resetHostDragState();
|
||||
};
|
||||
sourceNode.addEventListener("dragend", handleNativeDragEnd);
|
||||
}, [resetHostDragState]);
|
||||
}, [isMultiSelectMode, resetHostDragState, selectedHostIds, t]);
|
||||
|
||||
const renderHostEditButton = (host: any, compact = false) => (
|
||||
<Button
|
||||
@@ -351,14 +378,21 @@ export function VaultHostListSection({ ctx }: { ctx: VaultHostListSectionContext
|
||||
return <div
|
||||
ref={hostListScrollRef}
|
||||
className={cn(
|
||||
"flex-1 overflow-auto px-4 pb-4 space-y-3",
|
||||
"relative flex-1 overflow-auto px-4 pb-4 [&>:not([data-vault-marquee])~:not([data-vault-marquee])]:mt-3",
|
||||
viewMode === "tree" ? "pt-1.5" : "pt-0",
|
||||
!isHostsSectionActive && "hidden",
|
||||
marqueeRect && "select-none",
|
||||
)}
|
||||
data-section="vault-host-list"
|
||||
onClick={handleHostListClick}
|
||||
onClickCapture={onMarqueeClickCapture}
|
||||
onPointerDown={onMarqueePointerDown}
|
||||
onDragOverCapture={(e) => {
|
||||
const target = (e.target as Element | null)?.closest("[data-host-id], [data-group-path]");
|
||||
if (hasVaultDragType(e.dataTransfer, VAULT_HOST_DRAG_IDS)) {
|
||||
const groupTarget = target instanceof HTMLElement && target.hasAttribute("data-group-path");
|
||||
if (!groupTarget) return;
|
||||
}
|
||||
if (target) e.preventDefault();
|
||||
if (!(target instanceof HTMLElement)) return;
|
||||
const draggedGroupPath = e.dataTransfer.getData("group-path");
|
||||
@@ -392,6 +426,10 @@ export function VaultHostListSection({ ctx }: { ctx: VaultHostListSectionContext
|
||||
}}
|
||||
onDragOver={(e) => {
|
||||
const target = (e.target as Element | null)?.closest("[data-host-id], [data-group-path]");
|
||||
if (hasVaultDragType(e.dataTransfer, VAULT_HOST_DRAG_IDS)) {
|
||||
const groupTarget = target instanceof HTMLElement && target.hasAttribute("data-group-path");
|
||||
if (!groupTarget) return;
|
||||
}
|
||||
if (!(target instanceof HTMLElement) || viewMode === "grid") return;
|
||||
const draggedGroupPath = e.dataTransfer.getData("group-path");
|
||||
const isDraggingGroup = hasVaultDragType(e.dataTransfer, "group-path");
|
||||
@@ -419,7 +457,12 @@ export function VaultHostListSection({ ctx }: { ctx: VaultHostListSectionContext
|
||||
if (!(target instanceof HTMLElement)) return;
|
||||
const targetHostId = target.getAttribute("data-host-id");
|
||||
const targetGroupPath = target.getAttribute("data-group-path");
|
||||
if (draggedHostId && targetHostId && draggedHostId !== targetHostId) {
|
||||
if (
|
||||
draggedHostId
|
||||
&& targetHostId
|
||||
&& draggedHostId !== targetHostId
|
||||
&& !hasVaultDragType(e.dataTransfer, VAULT_HOST_DRAG_IDS)
|
||||
) {
|
||||
e.preventDefault();
|
||||
e.stopPropagation();
|
||||
const position = getVaultDropPosition(target, e.clientX, e.clientY, viewMode === "grid");
|
||||
@@ -447,6 +490,19 @@ export function VaultHostListSection({ ctx }: { ctx: VaultHostListSectionContext
|
||||
resetHostDragState();
|
||||
}}
|
||||
>
|
||||
{marqueeRect && (
|
||||
<div
|
||||
data-vault-marquee="true"
|
||||
aria-hidden="true"
|
||||
className="pointer-events-none absolute z-20 border border-primary/80 bg-primary/15"
|
||||
style={{
|
||||
left: marqueeRect.left,
|
||||
top: marqueeRect.top,
|
||||
width: marqueeRect.right - marqueeRect.left,
|
||||
height: marqueeRect.bottom - marqueeRect.top,
|
||||
}}
|
||||
/>
|
||||
)}
|
||||
{viewMode !== "tree" && (
|
||||
<section className="space-y-2 pt-2">
|
||||
<div className="flex items-center gap-2 text-sm font-semibold">
|
||||
@@ -476,6 +532,7 @@ export function VaultHostListSection({ ctx }: { ctx: VaultHostListSectionContext
|
||||
setDragOverDropTarget,
|
||||
moveGroup,
|
||||
moveHostToGroup,
|
||||
moveHostsToGroup,
|
||||
resetHostDragState,
|
||||
});
|
||||
}}
|
||||
@@ -546,7 +603,7 @@ export function VaultHostListSection({ ctx }: { ctx: VaultHostListSectionContext
|
||||
viewMode === "grid"
|
||||
? cn(
|
||||
"soft-card elevate rounded-xl h-[68px] px-3 py-2 will-change-transform transition-[opacity,box-shadow,border-color,background-color] duration-150",
|
||||
draggingHostId === host.id && "opacity-45",
|
||||
draggingHostIds?.has(host.id) && "opacity-45",
|
||||
hostCardFocusClassName(viewMode, isHostFocusSelected(host.id)),
|
||||
)
|
||||
: cn(
|
||||
@@ -565,7 +622,7 @@ export function VaultHostListSection({ ctx }: { ctx: VaultHostListSectionContext
|
||||
tabIndex={getHostTabIndex(host.id)}
|
||||
style={lastPinnedId === host.id ? { animation: "pop-in 0.4s cubic-bezier(0.34, 1.56, 0.64, 1) both" } : undefined}
|
||||
onAnimationEnd={() => { if (lastPinnedId === host.id) setLastPinnedId(null); }}
|
||||
draggable={!isMultiSelectMode}
|
||||
draggable={isVaultHostDraggable(Boolean(isMultiSelectMode), selectedHostIds, host.id)}
|
||||
onDragStart={(e) => handleHostDragStart(e, host.id)}
|
||||
onClick={() => {
|
||||
activateHost(safeHost);
|
||||
@@ -672,7 +729,7 @@ export function VaultHostListSection({ ctx }: { ctx: VaultHostListSectionContext
|
||||
viewMode === "grid"
|
||||
? cn(
|
||||
"soft-card elevate rounded-xl h-[68px] px-3 py-2 will-change-transform transition-[opacity,box-shadow,border-color,background-color] duration-150",
|
||||
draggingHostId === host.id && "opacity-45",
|
||||
draggingHostIds?.has(host.id) && "opacity-45",
|
||||
hostCardFocusClassName(viewMode, isHostFocusSelected(host.id)),
|
||||
)
|
||||
: cn(
|
||||
@@ -689,7 +746,7 @@ export function VaultHostListSection({ ctx }: { ctx: VaultHostListSectionContext
|
||||
? selectedHostIds.has(host.id)
|
||||
: undefined}
|
||||
tabIndex={getHostTabIndex(host.id)}
|
||||
draggable={!isMultiSelectMode}
|
||||
draggable={isVaultHostDraggable(Boolean(isMultiSelectMode), selectedHostIds, host.id)}
|
||||
onDragStart={(e) => handleHostDragStart(e, host.id)}
|
||||
onClick={() => {
|
||||
activateHost(safeHost);
|
||||
@@ -794,6 +851,7 @@ export function VaultHostListSection({ ctx }: { ctx: VaultHostListSectionContext
|
||||
dataTransfer: e.dataTransfer,
|
||||
groupPath: selectedGroupPath,
|
||||
moveHostToGroup,
|
||||
moveHostsToGroup,
|
||||
resetHostDragState,
|
||||
})) return;
|
||||
const groupPath = e.dataTransfer.getData("group-path");
|
||||
@@ -874,6 +932,7 @@ export function VaultHostListSection({ ctx }: { ctx: VaultHostListSectionContext
|
||||
dataTransfer: e.dataTransfer,
|
||||
groupPath: node.path,
|
||||
moveHostToGroup,
|
||||
moveHostsToGroup,
|
||||
resetHostDragState,
|
||||
})) return;
|
||||
const groupPath =
|
||||
@@ -983,6 +1042,7 @@ export function VaultHostListSection({ ctx }: { ctx: VaultHostListSectionContext
|
||||
cancelInlineGroupEdit={cancelInlineGroupEdit}
|
||||
onDeleteGroup={startInlineDeleteGroup}
|
||||
moveHostToGroup={moveHostToGroup}
|
||||
moveHostsToGroup={moveHostsToGroup}
|
||||
moveGroup={moveGroup}
|
||||
managedGroupPaths={managedGroupPaths}
|
||||
onUnmanageGroup={handleUnmanageGroup}
|
||||
@@ -1041,7 +1101,7 @@ export function VaultHostListSection({ ctx }: { ctx: VaultHostListSectionContext
|
||||
viewMode === "grid"
|
||||
? cn(
|
||||
"soft-card elevate rounded-xl h-[68px] px-3 py-2 will-change-transform transition-[opacity,box-shadow,border-color,background-color] duration-150",
|
||||
draggingHostId === host.id && "opacity-45",
|
||||
draggingHostIds?.has(host.id) && "opacity-45",
|
||||
hostCardFocusClassName(viewMode, isHostFocusSelected(host.id)),
|
||||
)
|
||||
: cn(
|
||||
@@ -1058,7 +1118,7 @@ export function VaultHostListSection({ ctx }: { ctx: VaultHostListSectionContext
|
||||
? selectedHostIds.has(host.id)
|
||||
: undefined}
|
||||
tabIndex={getHostTabIndex(host.id)}
|
||||
draggable={!isMultiSelectMode}
|
||||
draggable={isVaultHostDraggable(Boolean(isMultiSelectMode), selectedHostIds, host.id)}
|
||||
onDragStart={(e) => handleHostDragStart(e, host.id)}
|
||||
onClick={() => {
|
||||
activateHost(safeHost);
|
||||
@@ -1192,7 +1252,7 @@ export function VaultHostListSection({ ctx }: { ctx: VaultHostListSectionContext
|
||||
viewMode === "grid"
|
||||
? cn(
|
||||
"soft-card elevate rounded-xl h-[68px] px-3 py-2 will-change-transform transition-[opacity,box-shadow,border-color,background-color] duration-150",
|
||||
draggingHostId === host.id && "opacity-45",
|
||||
draggingHostIds?.has(host.id) && "opacity-45",
|
||||
hostCardFocusClassName(viewMode, isHostFocusSelected(host.id)),
|
||||
)
|
||||
: cn(
|
||||
@@ -1209,7 +1269,7 @@ export function VaultHostListSection({ ctx }: { ctx: VaultHostListSectionContext
|
||||
? selectedHostIds.has(host.id)
|
||||
: undefined}
|
||||
tabIndex={getHostTabIndex(host.id)}
|
||||
draggable={!isMultiSelectMode}
|
||||
draggable={isVaultHostDraggable(Boolean(isMultiSelectMode), selectedHostIds, host.id)}
|
||||
onDragStart={(e) => handleHostDragStart(e, host.id)}
|
||||
onClick={() => {
|
||||
activateHost(safeHost);
|
||||
|
||||
@@ -1237,6 +1237,7 @@ export function VaultViewLayout({ ctx }: { ctx: VaultViewLayoutContext }) {
|
||||
managedGroupPaths,
|
||||
moveGroup,
|
||||
moveHostToGroup,
|
||||
moveHostsToGroup,
|
||||
onDeleteHost,
|
||||
Pin,
|
||||
pinnedHosts,
|
||||
@@ -1256,10 +1257,12 @@ export function VaultViewLayout({ ctx }: { ctx: VaultViewLayoutContext }) {
|
||||
setDragOverDropTarget,
|
||||
setGroupDragOverDropTarget,
|
||||
setIsDeleteGroupOpen,
|
||||
setIsMultiSelectMode,
|
||||
setIsNewFolderOpen,
|
||||
setLastPinnedId,
|
||||
setNewFolderName,
|
||||
setSelectedGroupPath,
|
||||
setSelectedHostIds,
|
||||
setTargetParentPath,
|
||||
shouldHideEmptyRootHostsSection,
|
||||
showRecentHosts,
|
||||
|
||||
@@ -3,6 +3,7 @@ import test from 'node:test';
|
||||
import React from 'react';
|
||||
import { act, create, type ReactTestRenderer } from 'react-test-renderer';
|
||||
import type { Host, ManagedSource } from '../../types';
|
||||
import { handleVaultHostDropToGroup } from './vaultReorderDrag';
|
||||
import { useVaultGroupDragHandlers } from './useVaultGroupDragHandlers';
|
||||
|
||||
const host = (id: string, extra: Partial<Host> = {}): Host => ({
|
||||
@@ -44,6 +45,37 @@ test('mixed protocols entering and leaving managed groups retain single-host mov
|
||||
}, writes);
|
||||
});
|
||||
|
||||
test('a multi-id group drop publishes the whole selection through the batched move', async () => {
|
||||
const original = [host('ssh'), host('telnet', { protocol: 'telnet' }), host('local', { protocol: 'local' })];
|
||||
const sources = [{ id: 'parent', groupName: 'Managed' }, { id: 'child', groupName: 'Managed/Nested' }] as ManagedSource[];
|
||||
const writes: Host[][] = [];
|
||||
await withActions(original, sources, actions => {
|
||||
let handled = false;
|
||||
act(() => {
|
||||
handled = handleVaultHostDropToGroup({
|
||||
dataTransfer: {
|
||||
getData: (type: string) => type === 'host-ids' ? JSON.stringify(['ssh', 'telnet', 'local']) : '',
|
||||
},
|
||||
groupPath: 'Managed/Nested',
|
||||
moveHostToGroup: () => {
|
||||
throw new Error('multi-id drop must not move hosts one at a time');
|
||||
},
|
||||
moveHostsToGroup: actions.moveHostsToGroup,
|
||||
resetHostDragState: () => {},
|
||||
});
|
||||
});
|
||||
assert.equal(handled, true);
|
||||
assert.equal(writes.length, 1);
|
||||
assert.deepEqual(writes[0].map(item => item.group), ['Managed/Nested', 'Managed/Nested', 'Managed/Nested']);
|
||||
assert.equal(writes[0][0].managedSourceId, 'child');
|
||||
assert.equal(writes[0][0].label, 'Hostssh');
|
||||
assert.equal(writes[0][1].managedSourceId, undefined);
|
||||
assert.equal(writes[0][1].label, 'Host telnet');
|
||||
assert.equal(writes[0][2].managedSourceId, undefined);
|
||||
assert.equal(writes[0][2].label, 'Host local');
|
||||
}, writes);
|
||||
});
|
||||
|
||||
test('same-group, missing and empty selections do not publish changes', async () => {
|
||||
const writes: Host[][] = [];
|
||||
await withActions([host('a')], [], actions => {
|
||||
|
||||
@@ -0,0 +1,277 @@
|
||||
import React, { useCallback, useEffect, useRef, useState } from "react";
|
||||
|
||||
import {
|
||||
clientPointToContent,
|
||||
contentRectFromClientBox,
|
||||
exceedsMarqueeThreshold,
|
||||
hostIdsIntersectingMarquee,
|
||||
isPointerOnNativeScrollbar,
|
||||
marqueeAutoScrollDelta,
|
||||
marqueeCancelSelection,
|
||||
marqueeCommitSelection,
|
||||
marqueeRectFromPoints,
|
||||
marqueeSelectionMode,
|
||||
mergeMarqueeSelection,
|
||||
rememberHostPlacements,
|
||||
shouldStartVaultMarquee,
|
||||
type HostPlacement,
|
||||
type MarqueeContentFrame,
|
||||
type MarqueeRect,
|
||||
} from "../../domain/vaultMarqueeSelection";
|
||||
|
||||
const MARQUEE_BLOCKED_SELECTOR = [
|
||||
"[data-host-id]",
|
||||
"[data-group-path]",
|
||||
"button",
|
||||
"input",
|
||||
"textarea",
|
||||
"select",
|
||||
"a",
|
||||
"label",
|
||||
"[role='scrollbar']",
|
||||
].join(",");
|
||||
|
||||
const selectionSignature = (ids: ReadonlySet<string>) => [...ids].sort().join("\0");
|
||||
|
||||
const elementFromEventTarget = (target: EventTarget | null): Element | null => {
|
||||
if (target instanceof Element) return target;
|
||||
if (typeof Node !== "undefined" && target instanceof Node) return target.parentElement;
|
||||
return null;
|
||||
};
|
||||
|
||||
const isBlockedMarqueeTarget = (target: EventTarget | null): boolean => {
|
||||
const element = elementFromEventTarget(target);
|
||||
if (!element) return true;
|
||||
return Boolean(element.closest(MARQUEE_BLOCKED_SELECTOR));
|
||||
};
|
||||
|
||||
const readFrame = (container: HTMLElement): MarqueeContentFrame => {
|
||||
const rect = container.getBoundingClientRect();
|
||||
return {
|
||||
left: rect.left,
|
||||
top: rect.top,
|
||||
scrollLeft: container.scrollLeft,
|
||||
scrollTop: container.scrollTop,
|
||||
clientLeft: container.clientLeft,
|
||||
clientTop: container.clientTop,
|
||||
};
|
||||
};
|
||||
|
||||
const readVisibleHostPlacements = (container: HTMLElement): HostPlacement[] => {
|
||||
const frame = readFrame(container);
|
||||
const placements: HostPlacement[] = [];
|
||||
container.querySelectorAll<HTMLElement>("[data-host-id]").forEach((node, index) => {
|
||||
const id = node.dataset.hostId;
|
||||
if (!id) return;
|
||||
const box = node.getBoundingClientRect();
|
||||
placements.push({
|
||||
key: node.dataset.vaultGridItem || `${id}:${index}`,
|
||||
id,
|
||||
rect: contentRectFromClientBox(box, frame),
|
||||
});
|
||||
});
|
||||
return placements;
|
||||
};
|
||||
|
||||
interface UseVaultHostMarqueeOptions {
|
||||
containerRef: React.RefObject<HTMLElement | null>;
|
||||
viewMode: string;
|
||||
isMultiSelectMode: boolean;
|
||||
selectedHostIds: ReadonlySet<string>;
|
||||
setIsMultiSelectMode?: (value: boolean) => void;
|
||||
setSelectedHostIds?: (ids: Set<string>) => void;
|
||||
}
|
||||
|
||||
export function useVaultHostMarquee({
|
||||
containerRef,
|
||||
viewMode,
|
||||
isMultiSelectMode,
|
||||
selectedHostIds,
|
||||
setIsMultiSelectMode,
|
||||
setSelectedHostIds,
|
||||
}: UseVaultHostMarqueeOptions) {
|
||||
const [marqueeRect, setMarqueeRect] = useState<MarqueeRect | null>(null);
|
||||
const suppressClickRef = useRef(false);
|
||||
const sessionRef = useRef(0);
|
||||
const stopGestureRef = useRef<(() => void) | null>(null);
|
||||
|
||||
useEffect(() => () => stopGestureRef.current?.(), []);
|
||||
|
||||
const onPointerDown = useCallback((event: React.PointerEvent<HTMLElement>) => {
|
||||
if (suppressClickRef.current) suppressClickRef.current = false;
|
||||
const container = containerRef.current;
|
||||
if (!container || !setSelectedHostIds || !setIsMultiSelectMode) return;
|
||||
const bounds = container.getBoundingClientRect();
|
||||
if (!shouldStartVaultMarquee({
|
||||
viewMode,
|
||||
button: event.button,
|
||||
pointerType: event.pointerType,
|
||||
onScrollbar: isPointerOnNativeScrollbar({
|
||||
rectLeft: bounds.left,
|
||||
rectTop: bounds.top,
|
||||
clientLeft: container.clientLeft,
|
||||
clientTop: container.clientTop,
|
||||
clientWidth: container.clientWidth,
|
||||
clientHeight: container.clientHeight,
|
||||
}, event.clientX, event.clientY),
|
||||
blockedTarget: isBlockedMarqueeTarget(event.target),
|
||||
})) return;
|
||||
|
||||
stopGestureRef.current?.();
|
||||
const startContent = clientPointToContent(
|
||||
{ x: event.clientX, y: event.clientY },
|
||||
readFrame(container),
|
||||
);
|
||||
const baseline = new Set(selectedHostIds);
|
||||
const wasMultiSelectMode = Boolean(isMultiSelectMode);
|
||||
const mode = marqueeSelectionMode(event);
|
||||
const cache = new Map<string, HostPlacement>();
|
||||
const session = sessionRef.current + 1;
|
||||
sessionRef.current = session;
|
||||
let active = false;
|
||||
let scrollDelta = 0;
|
||||
let frameId = 0;
|
||||
let lastClientX = event.clientX;
|
||||
let lastClientY = event.clientY;
|
||||
let lastSignature = selectionSignature(baseline);
|
||||
let lastMode = wasMultiSelectMode;
|
||||
|
||||
const publish = (nextIds: Set<string>, modeEnabled: boolean) => {
|
||||
const signature = selectionSignature(nextIds);
|
||||
if (signature !== lastSignature) {
|
||||
lastSignature = signature;
|
||||
setSelectedHostIds(nextIds);
|
||||
}
|
||||
if (modeEnabled !== lastMode) {
|
||||
lastMode = modeEnabled;
|
||||
setIsMultiSelectMode(modeEnabled);
|
||||
}
|
||||
};
|
||||
|
||||
const applyAt = (clientX: number, clientY: number) => {
|
||||
if (sessionRef.current !== session) return;
|
||||
const current = clientPointToContent({ x: clientX, y: clientY }, readFrame(container));
|
||||
if (!active && !exceedsMarqueeThreshold(startContent, current)) return;
|
||||
active = true;
|
||||
const rect = marqueeRectFromPoints(startContent, current);
|
||||
rememberHostPlacements(cache, readVisibleHostPlacements(container));
|
||||
const hits = hostIdsIntersectingMarquee(rect, cache.values());
|
||||
const next = marqueeCommitSelection({
|
||||
wasMultiSelectMode,
|
||||
nextIds: mergeMarqueeSelection(baseline, hits, mode),
|
||||
});
|
||||
setMarqueeRect(rect);
|
||||
publish(next.selectedHostIds, next.isMultiSelectMode);
|
||||
};
|
||||
|
||||
const listeners: {
|
||||
move?: (event: PointerEvent) => void;
|
||||
up?: () => void;
|
||||
cancel?: () => void;
|
||||
key?: (event: KeyboardEvent) => void;
|
||||
scroll?: () => void;
|
||||
} = {};
|
||||
let stopGesture = () => {};
|
||||
|
||||
const finishListeners = () => {
|
||||
if (listeners.move) window.removeEventListener("pointermove", listeners.move);
|
||||
if (listeners.up) window.removeEventListener("pointerup", listeners.up);
|
||||
if (listeners.cancel) window.removeEventListener("pointercancel", listeners.cancel);
|
||||
if (listeners.key) window.removeEventListener("keydown", listeners.key);
|
||||
if (listeners.scroll) container.removeEventListener("scroll", listeners.scroll);
|
||||
if (frameId) cancelAnimationFrame(frameId);
|
||||
frameId = 0;
|
||||
scrollDelta = 0;
|
||||
if (stopGestureRef.current === stopGesture) stopGestureRef.current = null;
|
||||
};
|
||||
|
||||
stopGesture = () => {
|
||||
if (sessionRef.current !== session) return;
|
||||
sessionRef.current += 1;
|
||||
finishListeners();
|
||||
setMarqueeRect(null);
|
||||
};
|
||||
|
||||
const end = (cancel: boolean) => {
|
||||
if (sessionRef.current !== session) return;
|
||||
const wasActive = active;
|
||||
stopGesture();
|
||||
if (!wasActive) return;
|
||||
suppressClickRef.current = true;
|
||||
if (!cancel) return;
|
||||
const restored = marqueeCancelSelection({
|
||||
wasMultiSelectMode,
|
||||
baselineIds: baseline,
|
||||
});
|
||||
lastSignature = "";
|
||||
lastMode = !restored.isMultiSelectMode;
|
||||
publish(restored.selectedHostIds, restored.isMultiSelectMode);
|
||||
};
|
||||
|
||||
const tick = () => {
|
||||
frameId = 0;
|
||||
if (!scrollDelta || sessionRef.current !== session) return;
|
||||
container.scrollTop += scrollDelta;
|
||||
frameId = window.requestAnimationFrame(tick);
|
||||
};
|
||||
|
||||
listeners.move = (pointerEvent: PointerEvent) => {
|
||||
lastClientX = pointerEvent.clientX;
|
||||
lastClientY = pointerEvent.clientY;
|
||||
applyAt(pointerEvent.clientX, pointerEvent.clientY);
|
||||
if (active) {
|
||||
pointerEvent.preventDefault();
|
||||
window.getSelection?.()?.removeAllRanges();
|
||||
}
|
||||
const nextBounds = container.getBoundingClientRect();
|
||||
scrollDelta = active
|
||||
? marqueeAutoScrollDelta(pointerEvent.clientY, nextBounds.top, nextBounds.bottom)
|
||||
: 0;
|
||||
if (scrollDelta && !frameId) frameId = window.requestAnimationFrame(tick);
|
||||
if (!scrollDelta && frameId) {
|
||||
cancelAnimationFrame(frameId);
|
||||
frameId = 0;
|
||||
}
|
||||
};
|
||||
|
||||
listeners.scroll = () => {
|
||||
if (!active) return;
|
||||
applyAt(lastClientX, lastClientY);
|
||||
};
|
||||
listeners.up = () => end(false);
|
||||
listeners.cancel = () => end(true);
|
||||
listeners.key = (keyEvent: KeyboardEvent) => {
|
||||
if (keyEvent.key !== "Escape") return;
|
||||
if (!active) {
|
||||
end(false);
|
||||
return;
|
||||
}
|
||||
keyEvent.preventDefault();
|
||||
keyEvent.stopPropagation();
|
||||
end(true);
|
||||
};
|
||||
|
||||
stopGestureRef.current = stopGesture;
|
||||
window.addEventListener("pointermove", listeners.move);
|
||||
window.addEventListener("pointerup", listeners.up);
|
||||
window.addEventListener("pointercancel", listeners.cancel);
|
||||
window.addEventListener("keydown", listeners.key);
|
||||
container.addEventListener("scroll", listeners.scroll, { passive: true });
|
||||
}, [
|
||||
containerRef,
|
||||
isMultiSelectMode,
|
||||
selectedHostIds,
|
||||
setIsMultiSelectMode,
|
||||
setSelectedHostIds,
|
||||
viewMode,
|
||||
]);
|
||||
|
||||
const onClickCapture = useCallback((event: React.MouseEvent<HTMLElement>) => {
|
||||
if (!suppressClickRef.current) return;
|
||||
suppressClickRef.current = false;
|
||||
event.preventDefault();
|
||||
event.stopPropagation();
|
||||
}, []);
|
||||
|
||||
return { marqueeRect, onPointerDown, onClickCapture };
|
||||
}
|
||||
@@ -0,0 +1,42 @@
|
||||
import {
|
||||
applyVaultHostDragData,
|
||||
resolveVaultHostDragIds,
|
||||
} from "../../domain/vaultHostDrag";
|
||||
|
||||
export function attachVaultHostDragBadge(dataTransfer: DataTransfer, label: string): void {
|
||||
if (typeof document === "undefined") return;
|
||||
const badge = document.createElement("div");
|
||||
badge.textContent = label;
|
||||
badge.setAttribute("data-vault-host-drag-badge", "true");
|
||||
badge.style.position = "fixed";
|
||||
badge.style.top = "-1000px";
|
||||
badge.style.left = "0";
|
||||
badge.style.zIndex = "2147483647";
|
||||
badge.style.padding = "2px 8px";
|
||||
badge.style.borderRadius = "999px";
|
||||
badge.style.background = "#111827";
|
||||
badge.style.color = "#ffffff";
|
||||
badge.style.font = "12px/16px sans-serif";
|
||||
badge.style.pointerEvents = "none";
|
||||
document.body.appendChild(badge);
|
||||
dataTransfer.setDragImage(badge, badge.offsetWidth / 2, badge.offsetHeight / 2);
|
||||
window.setTimeout(() => badge.remove(), 0);
|
||||
}
|
||||
|
||||
export function beginVaultHostCardDrag(input: {
|
||||
dataTransfer: DataTransfer;
|
||||
hostId: string;
|
||||
isMultiSelectMode: boolean;
|
||||
selectedHostIds: ReadonlySet<string> | undefined;
|
||||
countLabel: (count: number) => string;
|
||||
}): string[] | null {
|
||||
const ids = resolveVaultHostDragIds({
|
||||
hostId: input.hostId,
|
||||
isMultiSelectMode: input.isMultiSelectMode,
|
||||
selectedHostIds: input.selectedHostIds ?? new Set<string>(),
|
||||
});
|
||||
if (!ids) return null;
|
||||
applyVaultHostDragData(input.dataTransfer, ids);
|
||||
if (ids.length > 1) attachVaultHostDragBadge(input.dataTransfer, input.countLabel(ids.length));
|
||||
return ids;
|
||||
}
|
||||
@@ -115,6 +115,58 @@ test("group host drop resets host drag state after moving the host to the group"
|
||||
]);
|
||||
});
|
||||
|
||||
test("group drop of a multi-id payload batches every selected host once", () => {
|
||||
const singleCalls: string[] = [];
|
||||
const batchCalls: Array<{ ids: string[]; group: string | null }> = [];
|
||||
|
||||
const handled = handleVaultHostDropToGroup({
|
||||
dataTransfer: {
|
||||
getData: (type: string) => (
|
||||
type === "host-ids" ? JSON.stringify(["a", "b", "c"]) : type === "host-id" ? "a" : ""
|
||||
),
|
||||
},
|
||||
groupPath: "team/nested",
|
||||
moveHostToGroup: (hostId, targetPath) => {
|
||||
singleCalls.push(`${hostId}:${String(targetPath)}`);
|
||||
},
|
||||
moveHostsToGroup: (hostIds, targetPath) => {
|
||||
batchCalls.push({ ids: [...hostIds], group: targetPath });
|
||||
},
|
||||
resetHostDragState: () => {
|
||||
singleCalls.push("reset");
|
||||
},
|
||||
});
|
||||
|
||||
assert.equal(handled, true);
|
||||
assert.deepEqual(singleCalls, ["reset"]);
|
||||
assert.deepEqual(batchCalls, [{ ids: ["a", "b", "c"], group: "team/nested" }]);
|
||||
});
|
||||
|
||||
test("root drop of a multi-id payload batches every selected host into all hosts", () => {
|
||||
const calls: string[] = [];
|
||||
|
||||
handleVaultRootDrop({
|
||||
dataTransfer: {
|
||||
getData: (type: string) => (type === "host-ids" ? JSON.stringify(["a", "b"]) : ""),
|
||||
},
|
||||
preventDefault: () => calls.push("preventDefault"),
|
||||
setDragOverDropTarget: () => calls.push("drop:null"),
|
||||
moveGroup: () => calls.push("group"),
|
||||
moveHostToGroup: () => calls.push("single"),
|
||||
moveHostsToGroup: (hostIds, targetPath) => {
|
||||
calls.push(`batch:${[...hostIds].join(",")}:${String(targetPath)}`);
|
||||
},
|
||||
resetHostDragState: () => calls.push("resetHostDragState"),
|
||||
});
|
||||
|
||||
assert.deepEqual(calls, [
|
||||
"preventDefault",
|
||||
"drop:null",
|
||||
"batch:a,b:null",
|
||||
"resetHostDragState",
|
||||
]);
|
||||
});
|
||||
|
||||
test("group drop without a host leaves host drag state alone", () => {
|
||||
const calls: string[] = [];
|
||||
|
||||
|
||||
@@ -1,5 +1,10 @@
|
||||
import React from "react";
|
||||
|
||||
import {
|
||||
dispatchVaultHostGroupMove,
|
||||
readVaultDraggedHostIds,
|
||||
} from "../../domain/vaultHostDrag";
|
||||
|
||||
export type VaultDropPosition = "before" | "after";
|
||||
export type VaultDropIntent = VaultDropPosition | "inside";
|
||||
export type VaultDropAxis = "x" | "y";
|
||||
@@ -47,6 +52,7 @@ export const handleVaultRootDrop = ({
|
||||
setDragOverDropTarget,
|
||||
moveGroup,
|
||||
moveHostToGroup,
|
||||
moveHostsToGroup,
|
||||
resetHostDragState,
|
||||
}: {
|
||||
dataTransfer: Pick<DataTransfer, "getData">;
|
||||
@@ -54,15 +60,20 @@ export const handleVaultRootDrop = ({
|
||||
setDragOverDropTarget: (target: null) => void;
|
||||
moveGroup: (groupPath: string, targetParentPath: string | null) => void;
|
||||
moveHostToGroup: (hostId: string, targetPath: string | null) => void;
|
||||
moveHostsToGroup?: (hostIds: ReadonlySet<string>, targetPath: string | null) => void;
|
||||
resetHostDragState: () => void;
|
||||
}) => {
|
||||
preventDefault();
|
||||
setDragOverDropTarget(null);
|
||||
const groupPath = dataTransfer.getData("group-path");
|
||||
const hostId = dataTransfer.getData("host-id");
|
||||
const hostIds = readVaultDraggedHostIds((type) => dataTransfer.getData(type));
|
||||
if (groupPath) moveGroup(groupPath, null);
|
||||
if (hostId) {
|
||||
moveHostToGroup(hostId, null);
|
||||
if (dispatchVaultHostGroupMove({
|
||||
hostIds,
|
||||
groupPath: null,
|
||||
moveHostToGroup,
|
||||
moveHostsToGroup,
|
||||
})) {
|
||||
resetHostDragState();
|
||||
}
|
||||
};
|
||||
@@ -71,17 +82,23 @@ export const handleVaultHostDropToGroup = ({
|
||||
dataTransfer,
|
||||
groupPath,
|
||||
moveHostToGroup,
|
||||
moveHostsToGroup,
|
||||
resetHostDragState,
|
||||
}: {
|
||||
dataTransfer: Pick<DataTransfer, "getData">;
|
||||
groupPath: string | null;
|
||||
moveHostToGroup: (hostId: string, targetPath: string | null) => void;
|
||||
moveHostToGroup?: (hostId: string, targetPath: string | null) => void;
|
||||
moveHostsToGroup?: (hostIds: ReadonlySet<string>, targetPath: string | null) => void;
|
||||
resetHostDragState: () => void;
|
||||
}) => {
|
||||
const hostId = dataTransfer.getData("host-id");
|
||||
if (!hostId) return false;
|
||||
const moved = dispatchVaultHostGroupMove({
|
||||
hostIds: readVaultDraggedHostIds((type) => dataTransfer.getData(type)),
|
||||
groupPath,
|
||||
moveHostToGroup,
|
||||
moveHostsToGroup,
|
||||
});
|
||||
if (!moved) return false;
|
||||
|
||||
moveHostToGroup(hostId, groupPath);
|
||||
resetHostDragState();
|
||||
return true;
|
||||
};
|
||||
|
||||
@@ -0,0 +1,66 @@
|
||||
# Shared AI privacy and reversible masking
|
||||
|
||||
AI privacy masking is **off by default**. Enable it in Settings > AI > Tool Access >
|
||||
AI privacy. This shared policy applies to Netcatty-managed context and tools used by
|
||||
Catty, integrated agents and external MCP clients. It is independent of the External
|
||||
MCP switch. External stdio and Streamable HTTP clients use the same policy, including
|
||||
for the environment resource.
|
||||
|
||||
This is not a system-wide AI traffic filter. Third-party agents' native tools,
|
||||
direct file access and other MCP servers are outside the Netcatty-managed boundary.
|
||||
Local terminal display, saved terminal output and actual host data are not changed.
|
||||
|
||||
## Working loop
|
||||
|
||||
A recognized address or credential in managed context or a tool response becomes an
|
||||
opaque token, for example `[NC_<session>_IP_1]`. The same exact value has the same token
|
||||
within its managed conversation or external MCP connection. A later managed command
|
||||
or file-write argument containing that token is restored locally before permission
|
||||
checks and the approval dialog. The host receives the original value; the agent
|
||||
receives masked managed results.
|
||||
|
||||
Mappings stay in memory and are isolated by managed conversation or external MCP
|
||||
connection. No mapping is saved to disk or shared through the app-wide external chat
|
||||
scope. New conversations and reconnects start a new mapping. Unknown, stale, or
|
||||
other-session tokens are rejected rather than sent to the host.
|
||||
|
||||
After changing privacy settings, start a new managed conversation and reconnect
|
||||
external MCP clients. Existing scopes fail closed instead of silently changing
|
||||
their protection. Settings apply only after main-process validation succeeds. A
|
||||
saved legacy External MCP privacy preference is used only if no shared AI privacy
|
||||
preference exists; applying valid settings saves the shared preference. Corrupt
|
||||
saved settings must be repaired rather than silently reverting to unprotected
|
||||
operation.
|
||||
|
||||
## What is covered
|
||||
|
||||
- Recognized credential assignments, authorization bearer values, URL passwords,
|
||||
familiar API token forms, structured secret fields and complete private-key blocks.
|
||||
- IPv4 and IPv6 addresses, with private/local ranges retained by default. Enable
|
||||
**Redact private IP addresses** to hide them too, or select additional CIDRs.
|
||||
Documentation/test addresses are also masked.
|
||||
- Structured host names and user-supplied regular-expression matches.
|
||||
- Netcatty-managed text context, tool results, nested structured results and errors.
|
||||
|
||||
Custom expressions match whole values and use the existing RE2 engine. Unsupported
|
||||
expressions, malformed CIDRs, or empty matches are rejected. Rules and mapping
|
||||
sizes are bounded; exhaustion blocks the response instead of evicting active tokens.
|
||||
|
||||
Binary/base64 tool attachments are blocked while masking is enabled. This feature
|
||||
does not scan images, archives, encoded/encrypted content, files accessed directly
|
||||
by third-party agents, or other MCP servers' results. It cannot guarantee detection
|
||||
of arbitrary credentials, all hostnames, or intentionally obfuscated data. It is an
|
||||
accidental-disclosure safeguard, not a DLP system or a substitute for the normal
|
||||
tool permission policy.
|
||||
|
||||
## Verification
|
||||
|
||||
Keep External MCP disabled and enable AI privacy. Use synthetic values in a new
|
||||
Catty conversation and a new integrated-agent conversation, then repeat through
|
||||
external stdio and HTTP clients after enabling External MCP: read `192.0.2.10`, read
|
||||
`198.51.100.7`, then read the first again. Tokens should be different for different
|
||||
values and stable for repeated reads within the same scope. Write a returned token
|
||||
back through a managed tool or use it in a managed terminal command; the actual
|
||||
file/terminal should contain the original value, never the token. A token from
|
||||
another conversation/client or an earlier scope must fail without dispatching a
|
||||
command. No protection should be implied for third-party native tool/file access.
|
||||
@@ -11,6 +11,13 @@ import {
|
||||
isNetcattyManagedStartupHistoryCommand,
|
||||
} from './remoteHistory.ts';
|
||||
import { buildDockerExecShellCommand, buildDockerLogsCommand } from './systemManager/dockerShell.ts';
|
||||
import {
|
||||
buildContainerExecShellCommand,
|
||||
buildContainerLogsCommand,
|
||||
buildKubectlDescribeCommand,
|
||||
buildKubectlExecShellCommand,
|
||||
buildKubectlLogsCommand,
|
||||
} from './systemManager/containerRuntime.ts';
|
||||
import { buildTmuxAttachCommand } from './systemManager/tmuxShell.ts';
|
||||
|
||||
test('parseBashHistory: plain lines', () => {
|
||||
@@ -199,8 +206,44 @@ test('isNetcattyAiHistoryCommand: detects AI PTY marker lines', () => {
|
||||
test('isNetcattyManagedStartupHistoryCommand: detects Docker and tmux terminal launch commands', () => {
|
||||
assert.equal(isNetcattyManagedStartupHistoryCommand(buildDockerExecShellCommand('587abcdef123')), true);
|
||||
assert.equal(isNetcattyManagedStartupHistoryCommand(buildDockerLogsCommand('587abcdef123')), true);
|
||||
assert.equal(isNetcattyManagedStartupHistoryCommand(buildContainerLogsCommand('podman', '587abcdef123')), true);
|
||||
assert.equal(
|
||||
isNetcattyManagedStartupHistoryCommand(buildKubectlLogsCommand('kind-dev', 'default', 'api-0')),
|
||||
true,
|
||||
);
|
||||
assert.equal(
|
||||
isNetcattyManagedStartupHistoryCommand(
|
||||
"printf '\\033[H\\033[2J\\033[3J'; _nc_docker_err=$(docker inspect 587abcdef123 2>&1 >/dev/null);",
|
||||
),
|
||||
true,
|
||||
);
|
||||
assert.equal(isNetcattyManagedStartupHistoryCommand(buildTmuxAttachCommand('my-session')), true);
|
||||
assert.equal(isNetcattyManagedStartupHistoryCommand(buildTmuxAttachCommand('my-session', 2)), true);
|
||||
assert.equal(isNetcattyManagedStartupHistoryCommand(buildContainerLogsCommand('podman', 'abc123', 'powershell')), true);
|
||||
assert.equal(isNetcattyManagedStartupHistoryCommand(buildContainerExecShellCommand('nerdctl', 'abc123', 'cmd')), true);
|
||||
assert.equal(
|
||||
isNetcattyManagedStartupHistoryCommand(buildKubectlLogsCommand('kind-kind', 'default', 'api', undefined, 'powershell')),
|
||||
true,
|
||||
);
|
||||
assert.equal(
|
||||
isNetcattyManagedStartupHistoryCommand(buildKubectlDescribeCommand('kind-kind', 'default', 'api', 'cmd')),
|
||||
true,
|
||||
);
|
||||
assert.equal(
|
||||
isNetcattyManagedStartupHistoryCommand(buildKubectlExecShellCommand('kind-kind', 'default', 'api', 'app', 'powershell')),
|
||||
true,
|
||||
);
|
||||
assert.equal(isNetcattyManagedStartupHistoryCommand('podman logs -f abc123'), false);
|
||||
assert.equal(isNetcattyManagedStartupHistoryCommand('kubectl logs api'), false);
|
||||
assert.equal(isNetcattyManagedStartupHistoryCommand('kubectl describe pod api'), false);
|
||||
assert.equal(
|
||||
isNetcattyManagedStartupHistoryCommand('kubectl --context prod -n default logs -f --tail 200 api'),
|
||||
false,
|
||||
);
|
||||
assert.equal(
|
||||
isNetcattyManagedStartupHistoryCommand('kubectl --context prod describe pod api'),
|
||||
false,
|
||||
);
|
||||
assert.equal(isNetcattyManagedStartupHistoryCommand('docker ps -a'), false);
|
||||
assert.equal(isNetcattyManagedStartupHistoryCommand('docker logs -f 587abcdef123'), false);
|
||||
assert.equal(isNetcattyManagedStartupHistoryCommand('docker exec -it 587abcdef123 bash'), false);
|
||||
|
||||
@@ -1,4 +1,5 @@
|
||||
import { RemoteHistoryEntry, RemoteHistorySource } from './models';
|
||||
import { NETCATTY_POPUP_HISTORY_MARKER } from './systemManager/containerRuntime.ts';
|
||||
|
||||
/** Marker prefix Netcatty AI uses when executing commands via the PTY bridge. */
|
||||
export const NETCATTY_AI_HISTORY_MARKER = '__NCMCP_';
|
||||
@@ -9,11 +10,17 @@ export function isNetcattyAiHistoryCommand(command: string): boolean {
|
||||
}
|
||||
|
||||
const NETCATTY_MANAGED_STARTUP_COMMAND =
|
||||
/^(?:sh\s+-c\s+.*printf .*\\033\[H\\033\[2J\\033\[3J.*_nc_docker_err=.*\bdocker\s+inspect\b|printf '\\033\[H\\033\[2J\\033\[3J';\s*(?:_nc_docker_err=.*\bdocker\s+inspect\b|exec\s+(?:docker\s+(?:exec|logs)\b|tmux\s+attach\b)))/;
|
||||
/^(?:sh\s+-c\s+.*printf .*\\033\[H\\033\[2J\\033\[3J.*(?:_nc_(?:docker|rt)_err=.*\b(?:docker|podman|nerdctl)\s+inspect\b|exec\s+kubectl\b)|printf '\\033\[H\\033\[2J\\033\[3J';\s*(?:_nc_(?:docker|rt)_err=.*\b(?:docker|podman|nerdctl)\s+inspect\b|exec\s+(?:(?:docker|podman|nerdctl)\s+(?:exec|logs)\b|tmux\s+attach\b)))/;
|
||||
|
||||
const NETCATTY_WINDOWS_CONTAINER_POPUP_COMMAND =
|
||||
/^(?:\$__nc_popup='__NC_POPUP__'; |set "__NC_POPUP__=1"& )?(?:docker|podman|nerdctl) inspect [a-zA-Z0-9]+ (?:>\$null; if \(\$LASTEXITCODE -eq 0\) \{|>nul && )/;
|
||||
|
||||
/** True when a shell history line came from a Netcatty-managed terminal launch. */
|
||||
export function isNetcattyManagedStartupHistoryCommand(command: string): boolean {
|
||||
return NETCATTY_MANAGED_STARTUP_COMMAND.test(command.trim());
|
||||
const text = command.trim();
|
||||
return text.includes(NETCATTY_POPUP_HISTORY_MARKER)
|
||||
|| NETCATTY_MANAGED_STARTUP_COMMAND.test(text)
|
||||
|| NETCATTY_WINDOWS_CONTAINER_POPUP_COMMAND.test(text);
|
||||
}
|
||||
|
||||
const ZSH_EXTENDED_RECORD = /^: (\d+):\d+;([\s\S]*)$/;
|
||||
|
||||
@@ -0,0 +1,550 @@
|
||||
"use strict";
|
||||
|
||||
const CONTAINER_RUNTIMES = ["docker", "podman", "nerdctl"];
|
||||
const SOURCE_ORDER = ["docker", "podman", "nerdctl", "kubernetes"];
|
||||
|
||||
const CLEAR_STARTUP_OUTPUT = "printf '\\033[H\\033[2J\\033[3J';";
|
||||
const NETCATTY_POPUP_HISTORY_MARKER = "__NC_POPUP__";
|
||||
|
||||
function popupHistoryPrefix(shell) {
|
||||
if (shell === "powershell") return `$__nc_popup='${NETCATTY_POPUP_HISTORY_MARKER}'; `;
|
||||
if (shell === "cmd") return `set "${NETCATTY_POPUP_HISTORY_MARKER}=1"& `;
|
||||
return "";
|
||||
}
|
||||
|
||||
function shQuote(str) {
|
||||
return `'${String(str).replace(/'/g, "'\"'\"'")}'`;
|
||||
}
|
||||
|
||||
function normalizeContainerRuntime(runtime) {
|
||||
return CONTAINER_RUNTIMES.includes(runtime) ? runtime : "docker";
|
||||
}
|
||||
|
||||
function isContainerSource(value) {
|
||||
return SOURCE_ORDER.includes(value);
|
||||
}
|
||||
|
||||
function runtimeBinary(runtime) {
|
||||
return normalizeContainerRuntime(runtime);
|
||||
}
|
||||
|
||||
function buildRuntimeCommand(runtime, args) {
|
||||
return `${runtimeBinary(runtime)} ${String(args || "")}`.trim();
|
||||
}
|
||||
|
||||
function parseRuntimeProbe(stdout) {
|
||||
const text = stdout || "";
|
||||
const hasFlag = (name) => new RegExp(`(?:^|\\r?\\n)${name}=1(?:\\r?\\n|$)`).test(text);
|
||||
return {
|
||||
docker: hasFlag("__NC_DOCKER__"),
|
||||
podman: hasFlag("__NC_PODMAN__"),
|
||||
nerdctl: hasFlag("__NC_NERDCTL__"),
|
||||
kubernetes: hasFlag("__NC_KUBECTL__"),
|
||||
};
|
||||
}
|
||||
|
||||
function sourcesFromCapabilities(capabilities) {
|
||||
if (!capabilities) return [];
|
||||
const present = {
|
||||
docker: capabilities.hasDocker === true || capabilities.docker === true,
|
||||
podman: capabilities.hasPodman === true || capabilities.podman === true,
|
||||
nerdctl: capabilities.hasNerdctl === true || capabilities.nerdctl === true,
|
||||
kubernetes: capabilities.hasKubectl === true || capabilities.kubernetes === true,
|
||||
};
|
||||
return SOURCE_ORDER.filter((id) => present[id]);
|
||||
}
|
||||
|
||||
function pickContainerSource(detected, current, remembered) {
|
||||
const available = SOURCE_ORDER.filter((id) => Array.isArray(detected) && detected.includes(id));
|
||||
if (current && available.includes(current)) return current;
|
||||
if (remembered && available.includes(remembered)) return remembered;
|
||||
return available[0] || null;
|
||||
}
|
||||
|
||||
function rememberedContainerSource(preference, detected) {
|
||||
const source = preference?.source;
|
||||
if (!isContainerSource(source)) return null;
|
||||
const available = Array.isArray(detected) ? detected : [];
|
||||
if (source === "kubernetes" && !available.includes("kubernetes") && CONTAINER_RUNTIMES.includes(preference?.engine)) {
|
||||
return preference.engine;
|
||||
}
|
||||
return source;
|
||||
}
|
||||
|
||||
function reconcileContainerSource(detected, current, preference) {
|
||||
const available = SOURCE_ORDER.filter((id) => Array.isArray(detected) && detected.includes(id));
|
||||
const preferred = rememberedContainerSource(preference, available);
|
||||
if (preferred && available.includes(preferred)) return preferred;
|
||||
return pickContainerSource(available, current, preferred);
|
||||
}
|
||||
|
||||
function isAutomaticRuntimeFallback(preference, source, detected) {
|
||||
if (!preference?.source || !isContainerSource(source) || preference.source === source) return false;
|
||||
const available = Array.isArray(detected) ? detected : [];
|
||||
if (available.includes(preference.source)) return false;
|
||||
const automatic = pickContainerSource(
|
||||
available,
|
||||
null,
|
||||
rememberedContainerSource(preference, available),
|
||||
);
|
||||
return source === automatic;
|
||||
}
|
||||
|
||||
function firstContainerRuntime(detected) {
|
||||
return CONTAINER_RUNTIMES.find((id) => Array.isArray(detected) && detected.includes(id)) || null;
|
||||
}
|
||||
|
||||
function socketNeedles(runtime) {
|
||||
if (runtime === "podman") {
|
||||
return ["podman.sock", "/run/podman", "podman daemon"];
|
||||
}
|
||||
if (runtime === "nerdctl") {
|
||||
return ["containerd.sock", "/run/containerd"];
|
||||
}
|
||||
return ["docker daemon", "docker.sock", "/var/run/docker.sock", "connect to the docker daemon"];
|
||||
}
|
||||
|
||||
function isRuntimeSocketPermissionError(runtime, result) {
|
||||
const text = `${result?.stderr || ""}\n${result?.stdout || ""}\n${result?.error || ""}`.toLowerCase();
|
||||
if (!text.includes("permission denied")) return false;
|
||||
return socketNeedles(normalizeContainerRuntime(runtime)).some((needle) => text.includes(needle));
|
||||
}
|
||||
|
||||
function socketCasePatterns(runtime) {
|
||||
if (runtime === "podman") {
|
||||
return [
|
||||
"*permission\\ denied*podman.sock*",
|
||||
"*podman.sock*permission\\ denied*",
|
||||
"*permission\\ denied*/run/podman*",
|
||||
"*/run/podman*permission\\ denied*",
|
||||
"*permission\\ denied*podman\\ daemon*",
|
||||
"*podman\\ daemon*permission\\ denied*",
|
||||
];
|
||||
}
|
||||
if (runtime === "nerdctl") {
|
||||
return [
|
||||
"*permission\\ denied*containerd.sock*",
|
||||
"*containerd.sock*permission\\ denied*",
|
||||
"*permission\\ denied*/run/containerd*",
|
||||
"*/run/containerd*permission\\ denied*",
|
||||
];
|
||||
}
|
||||
return [
|
||||
"*permission\\ denied*docker\\ daemon*",
|
||||
"*docker\\ daemon*permission\\ denied*",
|
||||
"*permission\\ denied*docker.sock*",
|
||||
"*docker.sock*permission\\ denied*",
|
||||
"*permission\\ denied*/var/run/docker.sock*",
|
||||
"*/var/run/docker.sock*permission\\ denied*",
|
||||
"*permission\\ denied*connect\\ to\\ the\\ docker\\ daemon*",
|
||||
"*connect\\ to\\ the\\ docker\\ daemon*permission\\ denied*",
|
||||
];
|
||||
}
|
||||
|
||||
function sanitizeContainerId(id) {
|
||||
return String(id || "").replace(/[^a-zA-Z0-9]/g, "").slice(0, 64);
|
||||
}
|
||||
|
||||
function containerInteractiveArgs(containerId, shell) {
|
||||
if (shell === "cmd") {
|
||||
return `exec -it ${containerId} sh -c "command -v bash >/dev/null 2>&1 && exec bash || exec sh"`;
|
||||
}
|
||||
return `exec -it ${containerId} sh -c 'command -v bash >/dev/null 2>&1 && exec bash || exec sh'`;
|
||||
}
|
||||
|
||||
function buildContainerCommandWithSudoFallback(runtime, containerId, cliArgs, shell) {
|
||||
const popupShell = normalizePopupShell(shell);
|
||||
const bin = runtimeBinary(runtime);
|
||||
const plainCommand = `${bin} ${cliArgs}`;
|
||||
if (popupShell === "powershell") {
|
||||
return `${popupHistoryPrefix(popupShell)}${bin} inspect ${containerId} >$null; if ($LASTEXITCODE -eq 0) { ${plainCommand} } else { exit $LASTEXITCODE }`;
|
||||
}
|
||||
if (popupShell === "cmd") {
|
||||
return `${popupHistoryPrefix(popupShell)}${bin} inspect ${containerId} >nul && ${plainCommand}`;
|
||||
}
|
||||
const sudoCommand = `sudo ${plainCommand}`;
|
||||
const script = [
|
||||
CLEAR_STARTUP_OUTPUT,
|
||||
`_nc_rt_err=$(${bin} inspect ${containerId} 2>&1 >/dev/null);`,
|
||||
"_nc_rt_status=$?;",
|
||||
`if [ "$_nc_rt_status" -eq 0 ]; then exec ${plainCommand}; fi;`,
|
||||
"_nc_rt_lc=$(printf '%s' \"$_nc_rt_err\" | tr '[:upper:]' '[:lower:]');",
|
||||
"case \"$_nc_rt_lc\" in",
|
||||
`${socketCasePatterns(normalizeContainerRuntime(runtime)).join("|")}) exec ${sudoCommand} ;;`,
|
||||
"*) printf '%s\\n' \"$_nc_rt_err\" >&2; exit \"$_nc_rt_status\" ;;",
|
||||
"esac",
|
||||
].join(" ");
|
||||
return `sh -c ${shQuote(script)}`;
|
||||
}
|
||||
|
||||
function buildContainerExecShellCommand(runtime, containerId, shell) {
|
||||
const safeId = sanitizeContainerId(containerId);
|
||||
if (!safeId) return 'echo "Invalid container id"';
|
||||
return buildContainerCommandWithSudoFallback(
|
||||
runtime,
|
||||
safeId,
|
||||
containerInteractiveArgs(safeId, normalizePopupShell(shell)),
|
||||
shell,
|
||||
);
|
||||
}
|
||||
|
||||
function buildContainerLogsCommand(runtime, containerId, shell) {
|
||||
const safeId = sanitizeContainerId(containerId);
|
||||
if (!safeId) return 'echo "Invalid container id"';
|
||||
return buildContainerCommandWithSudoFallback(runtime, safeId, `logs -f --tail 200 ${safeId}`, shell);
|
||||
}
|
||||
|
||||
function hasControlChar(text) {
|
||||
for (let i = 0; i < text.length; i += 1) {
|
||||
const code = text.charCodeAt(i);
|
||||
if (code <= 31 || code === 127) return true;
|
||||
}
|
||||
return false;
|
||||
}
|
||||
|
||||
function isSafeShellArg(value, shell) {
|
||||
const text = String(value ?? "");
|
||||
if (!text || text.length > 512) return false;
|
||||
if (hasControlChar(text)) return false;
|
||||
// Interactive cmd.exe expands %VAR% inside double quotes, and %% is not a
|
||||
// reliable escape there. With /V:ON it also expands !VAR! inside quotes.
|
||||
// Reject those arguments instead of targeting a substituted context.
|
||||
if (shell === "cmd" && (text.includes("%") || text.includes("!"))) return false;
|
||||
return true;
|
||||
}
|
||||
|
||||
function normalizePopupShell(shell) {
|
||||
return shell === "powershell" || shell === "cmd" ? shell : "posix";
|
||||
}
|
||||
|
||||
function quoteShellArg(shell, value) {
|
||||
const text = String(value);
|
||||
if (shell === "cmd") return `"${text.replace(/"/g, '""')}"`;
|
||||
if (shell === "powershell") return `'${text.replace(/'/g, "''")}'`;
|
||||
return shQuote(text);
|
||||
}
|
||||
|
||||
function kubectlPrefix(context, namespace, shell = "posix") {
|
||||
const popupShell = normalizePopupShell(shell);
|
||||
if (!isSafeShellArg(context, popupShell)) return null;
|
||||
const parts = ["kubectl", "--context", quoteShellArg(popupShell, context)];
|
||||
if (namespace != null && namespace !== "") {
|
||||
if (!isSafeShellArg(namespace, popupShell)) return null;
|
||||
parts.push("-n", quoteShellArg(popupShell, namespace));
|
||||
}
|
||||
return parts.join(" ");
|
||||
}
|
||||
|
||||
function optionalContainerArg(container, shell = "posix") {
|
||||
if (container == null || container === "") return "";
|
||||
if (!isSafeShellArg(container, normalizePopupShell(shell))) return null;
|
||||
return ` -c ${quoteShellArg(normalizePopupShell(shell), container)}`;
|
||||
}
|
||||
|
||||
function isKubectlForbiddenError(message) {
|
||||
return /\bforbidden\b/i.test(String(message || ""));
|
||||
}
|
||||
|
||||
function isKubectlUnreachableError(message) {
|
||||
const text = String(message || "");
|
||||
if (!text.trim() || isKubectlForbiddenError(text)) return false;
|
||||
return /couldn'?t get current server api group list|could not get current server api group list|unable to connect to the server|connection refused|dial tcp|i\/o timeout|no such host|network is unreachable|context deadline exceeded|tls handshake timeout/i.test(text);
|
||||
}
|
||||
|
||||
function isNerdctlUnreachableError(message) {
|
||||
const text = String(message || "").toLowerCase();
|
||||
if (!text.trim()) return false;
|
||||
if (text.includes("rootless containerd") || text.includes("xdg_runtime_dir")) return true;
|
||||
if (text.includes("cannot connect to the docker daemon")) return true;
|
||||
if (!text.includes("containerd")) return false;
|
||||
return /not running|connection refused|no such file|cannot connect|failed to connect|unavailable/.test(text);
|
||||
}
|
||||
|
||||
const POD_STATUS_MESSAGE_KEYS = {
|
||||
Running: "systemManager.k8s.status.Running",
|
||||
Pending: "systemManager.k8s.status.Pending",
|
||||
Completed: "systemManager.k8s.status.Completed",
|
||||
Succeeded: "systemManager.k8s.status.Succeeded",
|
||||
Failed: "systemManager.k8s.status.Failed",
|
||||
Unknown: "systemManager.k8s.status.Unknown",
|
||||
CrashLoopBackOff: "systemManager.k8s.status.CrashLoopBackOff",
|
||||
ContainerCreating: "systemManager.k8s.status.ContainerCreating",
|
||||
Evicted: "systemManager.k8s.status.Evicted",
|
||||
Terminating: "systemManager.k8s.status.Terminating",
|
||||
ImagePullBackOff: "systemManager.k8s.status.ImagePullBackOff",
|
||||
ErrImagePull: "systemManager.k8s.status.ErrImagePull",
|
||||
Error: "systemManager.k8s.status.Error",
|
||||
OOMKilled: "systemManager.k8s.status.OOMKilled",
|
||||
};
|
||||
|
||||
function podStatusMessageKey(status) {
|
||||
return POD_STATUS_MESSAGE_KEYS[String(status || "")] || null;
|
||||
}
|
||||
|
||||
function runtimeFailureMessage(errorCode, context) {
|
||||
if (errorCode === "nerdctlUnreachable") {
|
||||
return { key: "systemManager.errors.nerdctlUnreachable" };
|
||||
}
|
||||
if (errorCode === "clusterUnreachable") {
|
||||
const name = typeof context === "string" ? context.trim() : "";
|
||||
if (name) {
|
||||
return {
|
||||
key: "systemManager.errors.clusterUnreachable",
|
||||
values: { context: name },
|
||||
};
|
||||
}
|
||||
return { key: "systemManager.errors.clusterUnreachableGeneric" };
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
||||
function wrapClearAndExec(command) {
|
||||
return `sh -c ${shQuote(`${CLEAR_STARTUP_OUTPUT} exec ${command}`)}`;
|
||||
}
|
||||
|
||||
function buildKubectlContextsCommand() {
|
||||
return "kubectl config view -o json";
|
||||
}
|
||||
|
||||
function buildKubectlNamespacesCommand(context, shell) {
|
||||
const popupShell = normalizePopupShell(shell);
|
||||
if (!isSafeShellArg(context, popupShell)) return null;
|
||||
return `kubectl --context ${quoteShellArg(popupShell, context)} get namespaces -o json`;
|
||||
}
|
||||
|
||||
function buildKubectlPodsCommand(context, namespace, shell) {
|
||||
const prefix = kubectlPrefix(context, namespace, shell);
|
||||
if (!prefix) return null;
|
||||
return `${prefix} get pods -o json`;
|
||||
}
|
||||
|
||||
function buildKubectlDeletePodCommand(context, namespace, pod, shell) {
|
||||
const popupShell = normalizePopupShell(shell);
|
||||
const prefix = kubectlPrefix(context, namespace, popupShell);
|
||||
if (!prefix || !isSafeShellArg(pod, popupShell)) return null;
|
||||
return `${prefix} delete pod ${quoteShellArg(popupShell, pod)} --wait=false`;
|
||||
}
|
||||
|
||||
function containerShellArg(shell) {
|
||||
if (shell === "cmd") {
|
||||
return 'sh -c "command -v bash >/dev/null 2>&1 && exec bash || exec sh"';
|
||||
}
|
||||
return "sh -c 'command -v bash >/dev/null 2>&1 && exec bash || exec sh'";
|
||||
}
|
||||
|
||||
function finishKubectlPopup(shell, command) {
|
||||
return shell === "posix" ? wrapClearAndExec(command) : `${popupHistoryPrefix(shell)}${command}`;
|
||||
}
|
||||
|
||||
function buildKubectlExecShellCommand(context, namespace, pod, container, shell) {
|
||||
const popupShell = normalizePopupShell(shell);
|
||||
const prefix = kubectlPrefix(context, namespace, popupShell);
|
||||
if (!prefix) return 'echo "Invalid context"';
|
||||
if (!isSafeShellArg(pod, popupShell)) return 'echo "Invalid pod name"';
|
||||
const containerArg = optionalContainerArg(container, popupShell);
|
||||
if (containerArg == null) return 'echo "Invalid container name"';
|
||||
const command = `${prefix} exec -it ${quoteShellArg(popupShell, pod)}${containerArg} -- ${containerShellArg(popupShell)}`;
|
||||
return finishKubectlPopup(popupShell, command);
|
||||
}
|
||||
|
||||
function buildKubectlLogsCommand(context, namespace, pod, container, shell) {
|
||||
const popupShell = normalizePopupShell(shell);
|
||||
const prefix = kubectlPrefix(context, namespace, popupShell);
|
||||
if (!prefix) return 'echo "Invalid context"';
|
||||
if (!isSafeShellArg(pod, popupShell)) return 'echo "Invalid pod name"';
|
||||
const containerArg = optionalContainerArg(container, popupShell);
|
||||
if (containerArg == null) return 'echo "Invalid container name"';
|
||||
const command = `${prefix} logs -f --tail 200 ${quoteShellArg(popupShell, pod)}${containerArg}`;
|
||||
return finishKubectlPopup(popupShell, command);
|
||||
}
|
||||
|
||||
function buildKubectlDescribeCommand(context, namespace, pod, shell) {
|
||||
const popupShell = normalizePopupShell(shell);
|
||||
const prefix = kubectlPrefix(context, namespace, popupShell);
|
||||
if (!prefix) return 'echo "Invalid context"';
|
||||
if (!isSafeShellArg(pod, popupShell)) return 'echo "Invalid pod name"';
|
||||
const command = `${prefix} describe pod ${quoteShellArg(popupShell, pod)}`;
|
||||
return finishKubectlPopup(popupShell, command);
|
||||
}
|
||||
|
||||
function parseJsonValue(text) {
|
||||
const raw = String(text || "");
|
||||
for (let i = 0; i < raw.length; i += 1) {
|
||||
const ch = raw[i];
|
||||
if (ch !== "{" && ch !== "[") continue;
|
||||
try {
|
||||
return JSON.parse(raw.slice(i));
|
||||
} catch {
|
||||
// A bracketed banner such as [WARN] is not the JSON document.
|
||||
}
|
||||
}
|
||||
throw new Error("Invalid JSON");
|
||||
}
|
||||
|
||||
function parseKubeconfigContexts(jsonText) {
|
||||
const parsed = parseJsonValue(jsonText);
|
||||
const current = typeof parsed["current-context"] === "string" ? parsed["current-context"] : "";
|
||||
const contexts = [];
|
||||
const list = Array.isArray(parsed.contexts) ? parsed.contexts : [];
|
||||
for (const entry of list) {
|
||||
const name = typeof entry?.name === "string" ? entry.name : "";
|
||||
if (!name) continue;
|
||||
const rawNamespace = entry?.context?.namespace;
|
||||
const namespace = typeof rawNamespace === "string" && rawNamespace ? rawNamespace : "default";
|
||||
contexts.push({
|
||||
name,
|
||||
namespace,
|
||||
current: name === current,
|
||||
});
|
||||
}
|
||||
return { contexts, current };
|
||||
}
|
||||
|
||||
function parseNamespaceList(jsonText) {
|
||||
const parsed = parseJsonValue(jsonText);
|
||||
const items = Array.isArray(parsed.items) ? parsed.items : [];
|
||||
const names = [];
|
||||
for (const item of items) {
|
||||
const name = item?.metadata?.name;
|
||||
if (typeof name === "string" && name) names.push(name);
|
||||
}
|
||||
return names;
|
||||
}
|
||||
|
||||
function formatPodAge(iso, now = Date.now()) {
|
||||
const timestamp = Date.parse(String(iso || ""));
|
||||
if (!Number.isFinite(timestamp)) return "";
|
||||
const seconds = Math.max(0, Math.floor((now - timestamp) / 1000));
|
||||
if (seconds < 60) return `${seconds}s`;
|
||||
const minutes = Math.floor(seconds / 60);
|
||||
if (minutes < 60) return `${minutes}m`;
|
||||
const hours = Math.floor(minutes / 60);
|
||||
if (hours < 24) return `${hours}h`;
|
||||
return `${Math.floor(hours / 24)}d`;
|
||||
}
|
||||
|
||||
function podDisplayStatus(pod) {
|
||||
if (typeof pod?.metadata?.deletionTimestamp === "string" && pod.metadata.deletionTimestamp) {
|
||||
return "Terminating";
|
||||
}
|
||||
const statuses = [
|
||||
...(Array.isArray(pod?.status?.initContainerStatuses) ? pod.status.initContainerStatuses : []),
|
||||
...(Array.isArray(pod?.status?.containerStatuses) ? pod.status.containerStatuses : []),
|
||||
];
|
||||
for (const status of statuses) {
|
||||
const reason = status?.state?.waiting?.reason;
|
||||
if (reason) return String(reason);
|
||||
}
|
||||
const podReason = typeof pod?.status?.reason === "string" ? pod.status.reason.trim() : "";
|
||||
if (podReason) return podReason;
|
||||
for (const status of statuses) {
|
||||
const reason = status?.state?.terminated?.reason;
|
||||
if (reason && reason !== "Completed") return String(reason);
|
||||
}
|
||||
const phase = String(pod?.status?.phase || "Unknown");
|
||||
if (phase === "Succeeded") return "Completed";
|
||||
return phase;
|
||||
}
|
||||
|
||||
function podContainerNames(pod) {
|
||||
const names = [];
|
||||
const specs = [
|
||||
...(Array.isArray(pod?.spec?.containers) ? pod.spec.containers : []),
|
||||
...(Array.isArray(pod?.spec?.initContainers) ? pod.spec.initContainers : []),
|
||||
...(Array.isArray(pod?.spec?.ephemeralContainers) ? pod.spec.ephemeralContainers : []),
|
||||
];
|
||||
for (const container of specs) {
|
||||
const name = container?.name;
|
||||
if (typeof name === "string" && name && !names.includes(name)) names.push(name);
|
||||
}
|
||||
return names;
|
||||
}
|
||||
|
||||
function podShellContainerNames(pod) {
|
||||
const running = new Set();
|
||||
const statuses = [
|
||||
...(Array.isArray(pod?.status?.containerStatuses) ? pod.status.containerStatuses : []),
|
||||
...(Array.isArray(pod?.status?.initContainerStatuses) ? pod.status.initContainerStatuses : []),
|
||||
...(Array.isArray(pod?.status?.ephemeralContainerStatuses) ? pod.status.ephemeralContainerStatuses : []),
|
||||
];
|
||||
for (const status of statuses) {
|
||||
if (status?.state?.running && typeof status.name === "string" && status.name) {
|
||||
running.add(status.name);
|
||||
}
|
||||
}
|
||||
const ordered = podContainerNames(pod).filter((name) => running.has(name));
|
||||
for (const name of running) {
|
||||
if (!ordered.includes(name)) ordered.push(name);
|
||||
}
|
||||
return ordered;
|
||||
}
|
||||
|
||||
function podRestartCount(pod) {
|
||||
const statuses = [
|
||||
...(Array.isArray(pod?.status?.containerStatuses) ? pod.status.containerStatuses : []),
|
||||
...(Array.isArray(pod?.status?.initContainerStatuses) ? pod.status.initContainerStatuses : []),
|
||||
];
|
||||
let restarts = 0;
|
||||
for (const status of statuses) {
|
||||
const count = Number(status?.restartCount || 0);
|
||||
if (Number.isFinite(count)) restarts += count;
|
||||
}
|
||||
return restarts;
|
||||
}
|
||||
|
||||
function parseKubectlPodList(jsonText, now = Date.now()) {
|
||||
const parsed = parseJsonValue(jsonText);
|
||||
const items = Array.isArray(parsed.items) ? parsed.items : [];
|
||||
const pods = [];
|
||||
for (const pod of items) {
|
||||
const name = typeof pod?.metadata?.name === "string" ? pod.metadata.name : "";
|
||||
if (!name) continue;
|
||||
pods.push({
|
||||
name,
|
||||
namespace: typeof pod?.metadata?.namespace === "string" ? pod.metadata.namespace : "",
|
||||
status: podDisplayStatus(pod),
|
||||
restarts: podRestartCount(pod),
|
||||
age: formatPodAge(pod?.metadata?.creationTimestamp, now),
|
||||
containers: podContainerNames(pod),
|
||||
shellContainers: podShellContainerNames(pod),
|
||||
});
|
||||
}
|
||||
return pods;
|
||||
}
|
||||
|
||||
module.exports = {
|
||||
NETCATTY_POPUP_HISTORY_MARKER,
|
||||
CONTAINER_RUNTIMES,
|
||||
SOURCE_ORDER,
|
||||
normalizeContainerRuntime,
|
||||
isContainerSource,
|
||||
runtimeBinary,
|
||||
buildRuntimeCommand,
|
||||
parseRuntimeProbe,
|
||||
sourcesFromCapabilities,
|
||||
pickContainerSource,
|
||||
rememberedContainerSource,
|
||||
reconcileContainerSource,
|
||||
isAutomaticRuntimeFallback,
|
||||
firstContainerRuntime,
|
||||
isRuntimeSocketPermissionError,
|
||||
sanitizeContainerId,
|
||||
buildContainerExecShellCommand,
|
||||
buildContainerLogsCommand,
|
||||
buildKubectlContextsCommand,
|
||||
buildKubectlNamespacesCommand,
|
||||
buildKubectlPodsCommand,
|
||||
buildKubectlDeletePodCommand,
|
||||
buildKubectlExecShellCommand,
|
||||
buildKubectlLogsCommand,
|
||||
buildKubectlDescribeCommand,
|
||||
isKubectlForbiddenError,
|
||||
isKubectlUnreachableError,
|
||||
isNerdctlUnreachableError,
|
||||
podStatusMessageKey,
|
||||
runtimeFailureMessage,
|
||||
parseKubeconfigContexts,
|
||||
parseNamespaceList,
|
||||
parseKubectlPodList,
|
||||
formatPodAge,
|
||||
};
|
||||
+109
@@ -0,0 +1,109 @@
|
||||
export type ContainerRuntimeId = "docker" | "podman" | "nerdctl";
|
||||
export type ContainerSourceId = ContainerRuntimeId | "kubernetes";
|
||||
|
||||
export interface RuntimeProbeFlags {
|
||||
docker: boolean;
|
||||
podman: boolean;
|
||||
nerdctl: boolean;
|
||||
kubernetes: boolean;
|
||||
}
|
||||
|
||||
export interface KubeContextInfo {
|
||||
name: string;
|
||||
namespace: string;
|
||||
current: boolean;
|
||||
}
|
||||
|
||||
export interface KubectlPodInfo {
|
||||
name: string;
|
||||
namespace: string;
|
||||
status: string;
|
||||
restarts: number;
|
||||
age: string;
|
||||
containers: string[];
|
||||
shellContainers: string[];
|
||||
}
|
||||
|
||||
export const NETCATTY_POPUP_HISTORY_MARKER: string;
|
||||
|
||||
export function normalizeContainerRuntime(runtime: unknown): ContainerRuntimeId;
|
||||
export function isContainerSource(value: unknown): value is ContainerSourceId;
|
||||
export function runtimeBinary(runtime: unknown): string;
|
||||
export function buildRuntimeCommand(runtime: unknown, args: string): string;
|
||||
export function parseRuntimeProbe(stdout: string): RuntimeProbeFlags;
|
||||
export function sourcesFromCapabilities(capabilities: {
|
||||
hasDocker?: boolean;
|
||||
hasPodman?: boolean;
|
||||
hasNerdctl?: boolean;
|
||||
hasKubectl?: boolean;
|
||||
docker?: boolean;
|
||||
podman?: boolean;
|
||||
nerdctl?: boolean;
|
||||
kubernetes?: boolean;
|
||||
} | null | undefined): ContainerSourceId[];
|
||||
export function pickContainerSource(
|
||||
detected: readonly ContainerSourceId[] | null | undefined,
|
||||
current: ContainerSourceId | null | undefined,
|
||||
remembered: ContainerSourceId | null | undefined,
|
||||
): ContainerSourceId | null;
|
||||
export function rememberedContainerSource(
|
||||
preference: { source?: string; engine?: string } | null | undefined,
|
||||
detected: readonly ContainerSourceId[] | null | undefined,
|
||||
): ContainerSourceId | null;
|
||||
export function reconcileContainerSource(
|
||||
detected: readonly ContainerSourceId[] | null | undefined,
|
||||
current: ContainerSourceId | null | undefined,
|
||||
preference: { source?: string; engine?: string } | null | undefined,
|
||||
): ContainerSourceId | null;
|
||||
export function isAutomaticRuntimeFallback(
|
||||
preference: { source?: string; engine?: string } | null | undefined,
|
||||
source: ContainerSourceId | null | undefined,
|
||||
detected: readonly ContainerSourceId[] | null | undefined,
|
||||
): boolean;
|
||||
export function firstContainerRuntime(
|
||||
detected: readonly ContainerSourceId[] | null | undefined,
|
||||
): ContainerRuntimeId | null;
|
||||
export function isRuntimeSocketPermissionError(
|
||||
runtime: unknown,
|
||||
result: { stderr?: string; stdout?: string; error?: string } | null | undefined,
|
||||
): boolean;
|
||||
export function sanitizeContainerId(id: string): string;
|
||||
export function buildContainerExecShellCommand(runtime: unknown, containerId: string, shell?: string): string;
|
||||
export function buildContainerLogsCommand(runtime: unknown, containerId: string, shell?: string): string;
|
||||
export function buildKubectlContextsCommand(): string;
|
||||
export function buildKubectlNamespacesCommand(context: string, shell?: string): string | null;
|
||||
export function buildKubectlPodsCommand(context: string, namespace: string, shell?: string): string | null;
|
||||
export function buildKubectlDeletePodCommand(context: string, namespace: string, pod: string, shell?: string): string | null;
|
||||
export type PopupShell = "posix" | "powershell" | "cmd";
|
||||
export function isKubectlForbiddenError(message: string | null | undefined): boolean;
|
||||
export function isKubectlUnreachableError(message: string | null | undefined): boolean;
|
||||
export function isNerdctlUnreachableError(message: string | null | undefined): boolean;
|
||||
export function podStatusMessageKey(status: string | null | undefined): string | null;
|
||||
export function runtimeFailureMessage(
|
||||
errorCode: string | null | undefined,
|
||||
context?: string | null,
|
||||
): { key: string; values?: Record<string, string> } | null;
|
||||
export function buildKubectlExecShellCommand(
|
||||
context: string,
|
||||
namespace: string,
|
||||
pod: string,
|
||||
container?: string,
|
||||
shell?: string,
|
||||
): string;
|
||||
export function buildKubectlLogsCommand(
|
||||
context: string,
|
||||
namespace: string,
|
||||
pod: string,
|
||||
container?: string,
|
||||
shell?: string,
|
||||
): string;
|
||||
export function buildKubectlDescribeCommand(
|
||||
context: string,
|
||||
namespace: string,
|
||||
pod: string,
|
||||
shell?: string,
|
||||
): string;
|
||||
export function parseKubeconfigContexts(jsonText: string): { contexts: KubeContextInfo[]; current: string };
|
||||
export function parseNamespaceList(jsonText: string): string[];
|
||||
export function parseKubectlPodList(jsonText: string, now?: number): KubectlPodInfo[];
|
||||
export function formatPodAge(iso: string, now?: number): string;
|
||||
@@ -0,0 +1,486 @@
|
||||
"use strict";
|
||||
|
||||
const test = require("node:test");
|
||||
const assert = require("node:assert/strict");
|
||||
const {
|
||||
buildContainerExecShellCommand,
|
||||
buildContainerLogsCommand,
|
||||
buildKubectlDeletePodCommand,
|
||||
buildKubectlDescribeCommand,
|
||||
buildKubectlExecShellCommand,
|
||||
buildKubectlLogsCommand,
|
||||
buildKubectlNamespacesCommand,
|
||||
buildKubectlPodsCommand,
|
||||
buildRuntimeCommand,
|
||||
firstContainerRuntime,
|
||||
formatPodAge,
|
||||
isRuntimeSocketPermissionError,
|
||||
parseKubeconfigContexts,
|
||||
parseKubectlPodList,
|
||||
parseNamespaceList,
|
||||
parseRuntimeProbe,
|
||||
isAutomaticRuntimeFallback,
|
||||
pickContainerSource,
|
||||
reconcileContainerSource,
|
||||
rememberedContainerSource,
|
||||
sourcesFromCapabilities,
|
||||
isKubectlForbiddenError,
|
||||
isKubectlUnreachableError,
|
||||
isNerdctlUnreachableError,
|
||||
podStatusMessageKey,
|
||||
runtimeFailureMessage,
|
||||
} = require("./containerRuntime.shared.cjs");
|
||||
|
||||
test("parseRuntimeProbe reads line-anchored command -v markers", () => {
|
||||
const probe = parseRuntimeProbe([
|
||||
"command -v docker >/dev/null && printf __NC_DOCKER__=1",
|
||||
"__NC_OS__=Linux",
|
||||
"__NC_DOCKER__=1",
|
||||
"__NC_PODMAN__=1",
|
||||
"echo __NC_NERDCTL__=1 trailing",
|
||||
"__NC_KUBECTL__=1",
|
||||
].join("\n"));
|
||||
|
||||
assert.deepEqual(probe, {
|
||||
docker: true,
|
||||
podman: true,
|
||||
nerdctl: false,
|
||||
kubernetes: true,
|
||||
});
|
||||
assert.deepEqual(sourcesFromCapabilities(probe), ["docker", "podman", "kubernetes"]);
|
||||
});
|
||||
|
||||
test("sourcesFromCapabilities keeps a stable runtime order and ignores a docker-only host's missing peers", () => {
|
||||
assert.deepEqual(sourcesFromCapabilities({ hasDocker: true }), ["docker"]);
|
||||
assert.deepEqual(
|
||||
sourcesFromCapabilities({ hasNerdctl: true, hasKubectl: true, hasPodman: true }),
|
||||
["podman", "nerdctl", "kubernetes"],
|
||||
);
|
||||
assert.deepEqual(sourcesFromCapabilities(undefined), []);
|
||||
});
|
||||
|
||||
test("pickContainerSource remembers the last choice per host and falls back when it disappears", () => {
|
||||
assert.equal(
|
||||
pickContainerSource(["docker", "podman", "kubernetes"], null, "podman"),
|
||||
"podman",
|
||||
);
|
||||
assert.equal(
|
||||
pickContainerSource(["docker", "podman"], "kubernetes", "podman"),
|
||||
"podman",
|
||||
);
|
||||
assert.equal(
|
||||
pickContainerSource(["docker", "podman", "kubernetes"], "kubernetes", "docker"),
|
||||
"kubernetes",
|
||||
);
|
||||
assert.equal(pickContainerSource(["nerdctl"], null, "docker"), "nerdctl");
|
||||
assert.equal(
|
||||
rememberedContainerSource({ source: "kubernetes", engine: "podman" }, ["docker", "podman"]),
|
||||
"podman",
|
||||
);
|
||||
assert.equal(
|
||||
rememberedContainerSource({ source: "kubernetes", engine: "podman" }, ["docker", "podman", "kubernetes"]),
|
||||
"kubernetes",
|
||||
);
|
||||
assert.equal(
|
||||
pickContainerSource(
|
||||
["docker", "podman"],
|
||||
"kubernetes",
|
||||
rememberedContainerSource({ source: "kubernetes", engine: "podman" }, ["docker", "podman"]),
|
||||
),
|
||||
"podman",
|
||||
);
|
||||
assert.equal(firstContainerRuntime(["kubernetes", "podman"]), "podman");
|
||||
assert.equal(firstContainerRuntime(["kubernetes"]), null);
|
||||
});
|
||||
|
||||
test("reconcileContainerSource restores a runtime after a probe gap and does not treat the fallback as the choice", () => {
|
||||
const nerdctl = { source: "nerdctl", engine: "nerdctl" };
|
||||
assert.equal(reconcileContainerSource(["docker", "podman"], "nerdctl", nerdctl), "docker");
|
||||
assert.equal(isAutomaticRuntimeFallback(nerdctl, "docker", ["docker", "podman"]), true);
|
||||
assert.equal(
|
||||
reconcileContainerSource(["docker", "podman", "nerdctl"], "docker", nerdctl),
|
||||
"nerdctl",
|
||||
);
|
||||
assert.equal(isAutomaticRuntimeFallback(nerdctl, "podman", ["docker", "podman"]), false);
|
||||
|
||||
const kubernetes = { source: "kubernetes", engine: "podman" };
|
||||
assert.equal(reconcileContainerSource(["docker", "podman"], "kubernetes", kubernetes), "podman");
|
||||
assert.equal(isAutomaticRuntimeFallback(kubernetes, "podman", ["docker", "podman"]), true);
|
||||
assert.equal(
|
||||
reconcileContainerSource(["docker", "podman", "kubernetes"], "podman", kubernetes),
|
||||
"kubernetes",
|
||||
);
|
||||
});
|
||||
|
||||
test("buildRuntimeCommand uses the detected CLI and defaults unknown runtimes to docker", () => {
|
||||
assert.equal(buildRuntimeCommand("docker", "ps -a --format '{{json .}}'"), "docker ps -a --format '{{json .}}'");
|
||||
assert.equal(buildRuntimeCommand("podman", "start abc123"), "podman start abc123");
|
||||
assert.equal(buildRuntimeCommand("nerdctl", "logs -f --tail 200 abc123"), "nerdctl logs -f --tail 200 abc123");
|
||||
assert.equal(buildRuntimeCommand("kubernetes", "ps"), "docker ps");
|
||||
assert.equal(buildRuntimeCommand(undefined, "inspect abc"), "docker inspect abc");
|
||||
});
|
||||
|
||||
test("interactive container commands keep the docker sudo probe and swap the binary", () => {
|
||||
const dockerShell = buildContainerExecShellCommand("docker", "587abcdef123");
|
||||
assert.match(dockerShell, /^sh -c /);
|
||||
assert.match(dockerShell, /docker inspect 587abcdef123/);
|
||||
assert.match(dockerShell, /exec docker exec -it 587abcdef123/);
|
||||
assert.match(dockerShell, /exec sudo docker exec -it 587abcdef123/);
|
||||
assert.match(dockerShell, /permission\\ denied.*docker\.sock.*docker\.sock.*permission\\ denied/);
|
||||
assert.equal(dockerShell.includes("\n"), false);
|
||||
assert.doesNotMatch(dockerShell, /sudo -S/);
|
||||
|
||||
const podmanLogs = buildContainerLogsCommand("podman", "587abcdef123");
|
||||
assert.match(podmanLogs, /podman inspect 587abcdef123/);
|
||||
assert.match(podmanLogs, /exec podman logs -f --tail 200 587abcdef123/);
|
||||
assert.match(podmanLogs, /exec sudo podman logs -f --tail 200 587abcdef123/);
|
||||
assert.match(podmanLogs, /podman\.sock/);
|
||||
assert.doesNotMatch(podmanLogs, /docker/);
|
||||
|
||||
const nerdctlShell = buildContainerExecShellCommand("nerdctl", "abc");
|
||||
assert.match(nerdctlShell, /exec nerdctl exec -it abc/);
|
||||
assert.match(nerdctlShell, /exec sudo nerdctl exec -it abc/);
|
||||
assert.match(nerdctlShell, /containerd\.sock/);
|
||||
});
|
||||
|
||||
test("socket permission fallback is specific to each runtime", () => {
|
||||
assert.equal(
|
||||
isRuntimeSocketPermissionError("docker", { stderr: "dial unix /var/run/docker.sock: connect: permission denied" }),
|
||||
true,
|
||||
);
|
||||
assert.equal(
|
||||
isRuntimeSocketPermissionError("docker", { stderr: "permission denied" }),
|
||||
false,
|
||||
);
|
||||
assert.equal(
|
||||
isRuntimeSocketPermissionError("podman", { stderr: "dial unix /run/podman/podman.sock: connect: permission denied" }),
|
||||
true,
|
||||
);
|
||||
assert.equal(
|
||||
isRuntimeSocketPermissionError("nerdctl", { stderr: "dial unix /run/containerd/containerd.sock: connect: permission denied" }),
|
||||
true,
|
||||
);
|
||||
assert.equal(
|
||||
isRuntimeSocketPermissionError("podman", { stderr: "dial unix /var/run/docker.sock: connect: permission denied" }),
|
||||
false,
|
||||
);
|
||||
});
|
||||
|
||||
test("parseKubeconfigContexts returns names and namespaces without user material", () => {
|
||||
const parsed = parseKubeconfigContexts(JSON.stringify({
|
||||
"current-context": "kind-kind",
|
||||
contexts: [
|
||||
{
|
||||
name: "kind-kind",
|
||||
context: { cluster: "kind-kind", user: "kind-kind", namespace: "app" },
|
||||
},
|
||||
{
|
||||
name: "arn:aws:eks:us-west-2:123:cluster/prod",
|
||||
context: { cluster: "prod", user: "admin" },
|
||||
},
|
||||
],
|
||||
users: [{ name: "admin", user: { token: "secret-token" } }],
|
||||
}));
|
||||
|
||||
assert.equal(parsed.current, "kind-kind");
|
||||
assert.deepEqual(parsed.contexts, [
|
||||
{ name: "kind-kind", namespace: "app", current: true },
|
||||
{ name: "arn:aws:eks:us-west-2:123:cluster/prod", namespace: "default", current: false },
|
||||
]);
|
||||
assert.equal(JSON.stringify(parsed).includes("secret-token"), false);
|
||||
});
|
||||
|
||||
test("parseNamespaceList skips a bracketed banner before the JSON object", () => {
|
||||
const names = parseNamespaceList([
|
||||
"[WARN] connection is using an insecure certificate",
|
||||
'{"items":[{"metadata":{"name":"default"}}]}',
|
||||
].join("\n"));
|
||||
assert.deepEqual(names, ["default"]);
|
||||
});
|
||||
|
||||
test("parseNamespaceList reads metadata names and ignores kubectl warnings before the JSON", () => {
|
||||
const stdout = "Warning: old client\n" + JSON.stringify({
|
||||
items: [
|
||||
{ metadata: { name: "default" } },
|
||||
{ metadata: { name: "kube-system" } },
|
||||
{ metadata: {} },
|
||||
],
|
||||
});
|
||||
assert.deepEqual(parseNamespaceList(stdout), ["default", "kube-system"]);
|
||||
});
|
||||
|
||||
test("parseKubectlPodList reports status, restarts, age, and containers", () => {
|
||||
const now = Date.parse("2026-10-20T00:10:00Z");
|
||||
const pods = parseKubectlPodList(JSON.stringify({
|
||||
items: [
|
||||
{
|
||||
metadata: {
|
||||
name: "api",
|
||||
namespace: "default",
|
||||
creationTimestamp: "2026-10-20T00:08:30Z",
|
||||
},
|
||||
spec: {
|
||||
containers: [{ name: "app" }, { name: "sidecar" }],
|
||||
initContainers: [{ name: "init" }],
|
||||
},
|
||||
status: {
|
||||
phase: "Running",
|
||||
initContainerStatuses: [
|
||||
{ name: "init", restartCount: 0, state: { terminated: { reason: "Completed" } } },
|
||||
],
|
||||
containerStatuses: [
|
||||
{ name: "app", restartCount: 4, state: { waiting: { reason: "CrashLoopBackOff" } } },
|
||||
{ name: "sidecar", restartCount: 1, state: { running: {} } },
|
||||
],
|
||||
},
|
||||
},
|
||||
{
|
||||
metadata: {
|
||||
name: "batch",
|
||||
namespace: "jobs",
|
||||
creationTimestamp: "2026-10-18T00:10:00Z",
|
||||
},
|
||||
spec: { containers: [{ name: "worker" }] },
|
||||
status: {
|
||||
phase: "Succeeded",
|
||||
containerStatuses: [
|
||||
{ name: "worker", restartCount: 0, state: { terminated: { reason: "Completed" } } },
|
||||
],
|
||||
},
|
||||
},
|
||||
],
|
||||
}), now);
|
||||
|
||||
assert.deepEqual(pods, [
|
||||
{
|
||||
name: "api",
|
||||
namespace: "default",
|
||||
status: "CrashLoopBackOff",
|
||||
restarts: 5,
|
||||
age: "1m",
|
||||
containers: ["app", "sidecar", "init"],
|
||||
shellContainers: ["sidecar"],
|
||||
},
|
||||
{
|
||||
name: "batch",
|
||||
namespace: "jobs",
|
||||
status: "Completed",
|
||||
restarts: 0,
|
||||
age: "2d",
|
||||
containers: ["worker"],
|
||||
shellContainers: [],
|
||||
},
|
||||
]);
|
||||
const terminating = parseKubectlPodList(JSON.stringify({
|
||||
items: [{
|
||||
metadata: {
|
||||
name: "api",
|
||||
namespace: "default",
|
||||
creationTimestamp: "2026-10-20T00:08:30Z",
|
||||
deletionTimestamp: "2026-10-20T00:10:00Z",
|
||||
},
|
||||
spec: {
|
||||
containers: [{ name: "app" }],
|
||||
ephemeralContainers: [{ name: "debugger" }, { name: "old-debug" }],
|
||||
},
|
||||
status: {
|
||||
phase: "Running",
|
||||
containerStatuses: [
|
||||
{ name: "app", restartCount: 0, state: { running: {} } },
|
||||
],
|
||||
ephemeralContainerStatuses: [
|
||||
{ name: "debugger", restartCount: 0, state: { running: {} } },
|
||||
{ name: "old-debug", restartCount: 0, state: { terminated: { reason: "Completed" } } },
|
||||
],
|
||||
},
|
||||
}],
|
||||
}), now);
|
||||
const evicted = parseKubectlPodList(JSON.stringify({
|
||||
items: [{
|
||||
metadata: { name: "api", namespace: "default", creationTimestamp: "2026-10-20T00:08:30Z" },
|
||||
spec: { containers: [{ name: "app" }] },
|
||||
status: {
|
||||
phase: "Failed",
|
||||
reason: "Evicted",
|
||||
message: "The node was low on resource: memory.",
|
||||
containerStatuses: [
|
||||
{ name: "app", restartCount: 0, state: { terminated: { reason: "Error", exitCode: 137 } } },
|
||||
],
|
||||
},
|
||||
}],
|
||||
}), now);
|
||||
assert.equal(evicted[0].status, "Evicted");
|
||||
|
||||
assert.equal(terminating[0].status, "Terminating");
|
||||
assert.deepEqual(terminating[0].containers, ["app", "debugger", "old-debug"]);
|
||||
assert.deepEqual(terminating[0].shellContainers, ["app", "debugger"]);
|
||||
|
||||
assert.equal(formatPodAge("2026-10-20T00:09:10Z", now), "50s");
|
||||
assert.equal(formatPodAge("2026-10-20T00:07:00Z", now), "3m");
|
||||
assert.equal(formatPodAge("2026-10-19T22:10:00Z", now), "2h");
|
||||
});
|
||||
|
||||
test("kubectl commands quote remote arguments and reject control characters", () => {
|
||||
assert.equal(
|
||||
buildKubectlPodsCommand("kind-kind", "default"),
|
||||
"kubectl --context 'kind-kind' -n 'default' get pods -o json",
|
||||
);
|
||||
assert.equal(
|
||||
buildKubectlNamespacesCommand("arn:aws:eks:us-west-2:123:cluster/prod"),
|
||||
"kubectl --context 'arn:aws:eks:us-west-2:123:cluster/prod' get namespaces -o json",
|
||||
);
|
||||
assert.equal(
|
||||
buildKubectlDeletePodCommand("ctx", "kube-system", "coredns-a"),
|
||||
"kubectl --context 'ctx' -n 'kube-system' delete pod 'coredns-a' --wait=false",
|
||||
);
|
||||
assert.equal(buildKubectlDeletePodCommand("ctx", "default", "bad\nname"), null);
|
||||
assert.equal(buildKubectlPodsCommand("ok", "bad\nns"), null);
|
||||
assert.equal(
|
||||
buildKubectlNamespacesCommand("o'brien", "powershell"),
|
||||
"kubectl --context 'o''brien' get namespaces -o json",
|
||||
);
|
||||
assert.equal(
|
||||
buildKubectlPodsCommand("o'brien", "default", "powershell"),
|
||||
"kubectl --context 'o''brien' -n 'default' get pods -o json",
|
||||
);
|
||||
assert.match(
|
||||
buildKubectlDeletePodCommand("o'brien", "default", "web", "cmd"),
|
||||
/--context "o'brien"/,
|
||||
);
|
||||
|
||||
const quoted = buildKubectlDeletePodCommand("o'brien", "default", "web");
|
||||
assert.match(quoted, /--context 'o'"'"'brien'/);
|
||||
assert.doesNotMatch(quoted, /--context 'o'brien'/);
|
||||
|
||||
const shell = buildKubectlExecShellCommand("kind-kind", "default", "api", "sidecar");
|
||||
assert.match(shell, /^sh -c /);
|
||||
assert.match(shell, /kubectl/);
|
||||
assert.match(shell, /--context/);
|
||||
assert.match(shell, /kind-kind/);
|
||||
assert.match(shell, /-n/);
|
||||
assert.match(shell, /default/);
|
||||
assert.match(shell, /exec -it/);
|
||||
assert.match(shell, /api/);
|
||||
assert.match(shell, /-c/);
|
||||
assert.match(shell, /sidecar/);
|
||||
assert.match(shell, /command -v bash/);
|
||||
assert.equal(shell.includes("\n"), false);
|
||||
|
||||
const logs = buildKubectlLogsCommand("kind-kind", "default", "api", "app");
|
||||
assert.match(logs, /kubectl/);
|
||||
assert.match(logs, /logs -f --tail 200/);
|
||||
assert.match(logs, /kind-kind/);
|
||||
assert.match(logs, /app/);
|
||||
|
||||
const described = buildKubectlDescribeCommand("kind-kind", "default", "api");
|
||||
assert.match(described, /kubectl/);
|
||||
assert.match(described, /describe pod/);
|
||||
assert.match(described, /kind-kind/);
|
||||
assert.match(described, /api/);
|
||||
assert.equal(buildKubectlExecShellCommand("kind-kind", "default", "bad\npod"), 'echo "Invalid pod name"');
|
||||
});
|
||||
|
||||
test("kubectl popup commands skip the POSIX wrapper on Windows shells", () => {
|
||||
for (const shell of ["powershell", "cmd"]) {
|
||||
const exec = buildKubectlExecShellCommand("kind-kind", "default", "api", "sidecar", shell);
|
||||
const logs = buildKubectlLogsCommand("kind-kind", "default", "api", "app", shell);
|
||||
const described = buildKubectlDescribeCommand("kind-kind", "default", "api", shell);
|
||||
assert.match(exec, /kubectl --context/, shell);
|
||||
assert.match(logs, /kubectl --context/, shell);
|
||||
assert.match(described, /kubectl --context/, shell);
|
||||
assert.match(exec, /__NC_POPUP__/, shell);
|
||||
assert.equal(exec.startsWith("sh -c "), false, shell);
|
||||
assert.match(exec, /exec -it/);
|
||||
assert.match(exec, /sidecar/);
|
||||
assert.match(logs, /logs -f --tail 200/);
|
||||
assert.match(described, /describe pod/);
|
||||
}
|
||||
assert.match(
|
||||
buildKubectlExecShellCommand("kind-kind", "default", "api", "sidecar", "powershell"),
|
||||
/--context 'kind-kind'/,
|
||||
);
|
||||
assert.match(
|
||||
buildKubectlDescribeCommand("kind-kind", "default", "api", "cmd"),
|
||||
/--context "kind-kind"/,
|
||||
);
|
||||
assert.equal(
|
||||
buildKubectlDescribeCommand("prod-%USERNAME%", "default", "api", "cmd"),
|
||||
'echo "Invalid context"',
|
||||
);
|
||||
assert.equal(
|
||||
buildKubectlDescribeCommand("prod!USER!", "default", "api", "cmd"),
|
||||
'echo "Invalid context"',
|
||||
);
|
||||
assert.match(
|
||||
buildKubectlDescribeCommand("prod-%USERNAME%", "default", "api", "powershell"),
|
||||
/--context 'prod-%USERNAME%'/,
|
||||
);
|
||||
assert.equal(
|
||||
buildKubectlNamespacesCommand("prod-%USERNAME%", "powershell"),
|
||||
"kubectl --context 'prod-%USERNAME%' get namespaces -o json",
|
||||
);
|
||||
});
|
||||
|
||||
test("container popup commands inspect in the host shell on Windows", () => {
|
||||
const powershellLogs = buildContainerLogsCommand("podman", "abc123", "powershell");
|
||||
assert.equal(powershellLogs.startsWith("sh -c "), false);
|
||||
assert.match(powershellLogs, /\$__nc_popup='__NC_POPUP__'; podman inspect abc123 >\$null;/);
|
||||
assert.match(powershellLogs, /podman logs -f --tail 200 abc123/);
|
||||
|
||||
const cmdShell = buildContainerExecShellCommand("nerdctl", "abc123", "cmd");
|
||||
assert.equal(cmdShell.startsWith("sh -c "), false);
|
||||
assert.match(cmdShell, /^set "__NC_POPUP__=1"& nerdctl inspect abc123 >nul && nerdctl exec -it abc123/);
|
||||
assert.match(cmdShell, /sh -c "command -v bash/);
|
||||
});
|
||||
|
||||
test("isKubectlForbiddenError matches namespace list denial", () => {
|
||||
assert.equal(
|
||||
isKubectlForbiddenError('Error from server (Forbidden): namespaces is forbidden: User "view" cannot list resource "namespaces"'),
|
||||
true,
|
||||
);
|
||||
assert.equal(isKubectlForbiddenError("Unable to connect to the server"), false);
|
||||
});
|
||||
|
||||
test("unreachable kubectl API text is classified without treating Forbidden as unreachable", () => {
|
||||
const spam = [
|
||||
'memcache.go:381] "Couldn\'t get current server API group list"',
|
||||
'dial tcp 127.0.0.1:6443: connect: connection refused',
|
||||
].join("\n");
|
||||
assert.equal(isKubectlUnreachableError(spam), true);
|
||||
assert.equal(isKubectlUnreachableError("Unable to connect to the server: dial tcp 10.0.0.1:6443: i/o timeout"), true);
|
||||
assert.equal(
|
||||
isKubectlUnreachableError('Error from server (Forbidden): pods is forbidden'),
|
||||
false,
|
||||
);
|
||||
assert.equal(isKubectlUnreachableError(""), false);
|
||||
});
|
||||
|
||||
test("nerdctl containerd failures are classified separately from socket permission errors", () => {
|
||||
assert.equal(
|
||||
isNerdctlUnreachableError('time="2026-10-20T18:31:27+08:00" level=fatal msg="rootless containerd not running? (hint: use containerd-rootless-setuptool.sh install to start rootless containerd): environment variable XDG_RUNTIME_DIR is not set"'),
|
||||
true,
|
||||
);
|
||||
assert.equal(
|
||||
isNerdctlUnreachableError("permission denied while trying to connect to the containerd socket"),
|
||||
false,
|
||||
);
|
||||
assert.equal(isNerdctlUnreachableError("Error: no such container: abc"), false);
|
||||
});
|
||||
|
||||
test("runtime failures map to localized message keys and pod badges use known status keys", () => {
|
||||
assert.deepEqual(runtimeFailureMessage("nerdctlUnreachable"), {
|
||||
key: "systemManager.errors.nerdctlUnreachable",
|
||||
});
|
||||
assert.deepEqual(runtimeFailureMessage("clusterUnreachable", "broken-cluster"), {
|
||||
key: "systemManager.errors.clusterUnreachable",
|
||||
values: { context: "broken-cluster" },
|
||||
});
|
||||
assert.deepEqual(runtimeFailureMessage("clusterUnreachable", " "), {
|
||||
key: "systemManager.errors.clusterUnreachableGeneric",
|
||||
});
|
||||
assert.equal(runtimeFailureMessage("other"), null);
|
||||
assert.equal(podStatusMessageKey("Running"), "systemManager.k8s.status.Running");
|
||||
assert.equal(podStatusMessageKey("CrashLoopBackOff"), "systemManager.k8s.status.CrashLoopBackOff");
|
||||
assert.equal(podStatusMessageKey("Evicted"), "systemManager.k8s.status.Evicted");
|
||||
assert.equal(podStatusMessageKey("Init:0/1"), null);
|
||||
});
|
||||
@@ -0,0 +1,38 @@
|
||||
import { runtimeFailureMessage } from "@/domain/systemManager/containerRuntime.shared.cjs";
|
||||
|
||||
export type {
|
||||
ContainerRuntimeId,
|
||||
ContainerSourceId,
|
||||
KubeContextInfo,
|
||||
KubectlPodInfo,
|
||||
RuntimeProbeFlags,
|
||||
} from "@/domain/systemManager/containerRuntime.shared.cjs";
|
||||
|
||||
export {
|
||||
NETCATTY_POPUP_HISTORY_MARKER,
|
||||
buildContainerExecShellCommand,
|
||||
buildContainerLogsCommand,
|
||||
buildKubectlDescribeCommand,
|
||||
buildKubectlExecShellCommand,
|
||||
buildKubectlLogsCommand,
|
||||
firstContainerRuntime,
|
||||
isContainerSource,
|
||||
isKubectlForbiddenError,
|
||||
isAutomaticRuntimeFallback,
|
||||
pickContainerSource,
|
||||
podStatusMessageKey,
|
||||
reconcileContainerSource,
|
||||
rememberedContainerSource,
|
||||
sourcesFromCapabilities,
|
||||
} from "@/domain/systemManager/containerRuntime.shared.cjs";
|
||||
|
||||
export function describeRuntimeFailure(
|
||||
result: { error?: string; errorCode?: string } | null | undefined,
|
||||
translate: (key: string, values?: Record<string, string>) => string,
|
||||
fallback: string,
|
||||
context?: string | null,
|
||||
): string {
|
||||
const mapped = runtimeFailureMessage(result?.errorCode, context);
|
||||
if (mapped) return translate(mapped.key, mapped.values);
|
||||
return result?.error || fallback;
|
||||
}
|
||||
@@ -1,52 +1,18 @@
|
||||
import {
|
||||
buildContainerExecShellCommand,
|
||||
buildContainerLogsCommand,
|
||||
} from "./containerRuntime.ts";
|
||||
|
||||
/** Sanitize Docker container/image IDs — must match electron/bridges/systemManager/dockerOps.cjs */
|
||||
export function sanitizeDockerContainerId(id: string): string {
|
||||
return String(id || '').replace(/[^a-zA-Z0-9]/g, '').slice(0, 64);
|
||||
}
|
||||
|
||||
const CLEAR_STARTUP_OUTPUT = "printf '\\033[H\\033[2J\\033[3J';";
|
||||
|
||||
function shQuote(value: string): string {
|
||||
return `'${String(value).replace(/'/g, `'"'"'`)}'`;
|
||||
}
|
||||
|
||||
function buildDockerCommandWithSudoFallback(containerId: string, dockerArgs: string): string {
|
||||
const plainCommand = `docker ${dockerArgs}`;
|
||||
const sudoCommand = `sudo ${plainCommand}`;
|
||||
const script = [
|
||||
CLEAR_STARTUP_OUTPUT,
|
||||
`_nc_docker_err=$(docker inspect ${containerId} 2>&1 >/dev/null);`,
|
||||
'_nc_docker_status=$?;',
|
||||
`if [ "$_nc_docker_status" -eq 0 ]; then exec ${plainCommand}; fi;`,
|
||||
'_nc_docker_lc=$(printf \'%s\' "$_nc_docker_err" | tr \'[:upper:]\' \'[:lower:]\');',
|
||||
'case "$_nc_docker_lc" in',
|
||||
[
|
||||
'*permission\\ denied*docker\\ daemon*',
|
||||
'*docker\\ daemon*permission\\ denied*',
|
||||
'*permission\\ denied*docker.sock*',
|
||||
'*docker.sock*permission\\ denied*',
|
||||
'*permission\\ denied*/var/run/docker.sock*',
|
||||
'*/var/run/docker.sock*permission\\ denied*',
|
||||
'*permission\\ denied*connect\\ to\\ the\\ docker\\ daemon*',
|
||||
'*connect\\ to\\ the\\ docker\\ daemon*permission\\ denied*',
|
||||
].join('|') + `) exec ${sudoCommand} ;;`,
|
||||
'*) printf \'%s\\n\' "$_nc_docker_err" >&2; exit "$_nc_docker_status" ;;',
|
||||
'esac',
|
||||
].join(' ');
|
||||
return `sh -c ${shQuote(script)}`;
|
||||
return String(id || "").replace(/[^a-zA-Z0-9]/g, "").slice(0, 64);
|
||||
}
|
||||
|
||||
/** Interactive shell into a container — prefer bash, fall back to sh. */
|
||||
export function buildDockerExecShellCommand(containerId: string): string {
|
||||
const safeId = sanitizeDockerContainerId(containerId);
|
||||
if (!safeId) return 'echo "Invalid container id"';
|
||||
return buildDockerCommandWithSudoFallback(
|
||||
safeId,
|
||||
`exec -it ${safeId} sh -c 'command -v bash >/dev/null 2>&1 && exec bash || exec sh'`,
|
||||
);
|
||||
return buildContainerExecShellCommand("docker", containerId);
|
||||
}
|
||||
|
||||
export function buildDockerLogsCommand(containerId: string): string {
|
||||
const safeId = sanitizeDockerContainerId(containerId);
|
||||
if (!safeId) return 'echo "Invalid container id"';
|
||||
return buildDockerCommandWithSudoFallback(safeId, `logs -f --tail 200 ${safeId}`);
|
||||
return buildContainerLogsCommand("docker", containerId);
|
||||
}
|
||||
@@ -4,6 +4,7 @@ import test from "node:test";
|
||||
import {
|
||||
allowSystemManagerMutations,
|
||||
buildSystemManagerTabs,
|
||||
hasContainerSource,
|
||||
shouldCollectServerStats,
|
||||
shouldShowGpuTab,
|
||||
shouldShowPortsTab,
|
||||
@@ -57,6 +58,23 @@ test("gpu tab appears only after nvidia-smi or npu-smi is detected", () => {
|
||||
);
|
||||
});
|
||||
|
||||
test("the single container tab appears for podman, nerdctl, or kubectl without a docker binary", () => {
|
||||
const networkHost = {
|
||||
id: "host-podman",
|
||||
label: "Podman",
|
||||
hostname: "podman.local",
|
||||
username: "root",
|
||||
tags: [],
|
||||
os: "linux" as const,
|
||||
deviceType: "network" as const,
|
||||
};
|
||||
assert.equal(hasContainerSource(caps()), false);
|
||||
assert.equal(hasContainerSource(caps({ hasPodman: true })), true);
|
||||
assert.equal(buildSystemManagerTabs(networkHost, caps({ hasPodman: true }), null).includes("docker"), true);
|
||||
assert.equal(buildSystemManagerTabs(networkHost, caps({ hasKubectl: true }), null).includes("docker"), true);
|
||||
assert.equal(buildSystemManagerTabs(networkHost, caps(), null).includes("docker"), false);
|
||||
});
|
||||
|
||||
test("ports and services tabs are detect-first like GPU", () => {
|
||||
assert.equal(shouldShowPortsTab(undefined), false);
|
||||
assert.equal(shouldShowPortsTab(caps({ hasSs: true })), true);
|
||||
|
||||
@@ -44,12 +44,19 @@ export function shouldShowTmuxTab(
|
||||
return false;
|
||||
}
|
||||
|
||||
export function hasContainerSource(capabilities: SessionCapabilities | undefined): boolean {
|
||||
return capabilities?.hasDocker === true
|
||||
|| capabilities?.hasPodman === true
|
||||
|| capabilities?.hasNerdctl === true
|
||||
|| capabilities?.hasKubectl === true;
|
||||
}
|
||||
|
||||
export function shouldShowDockerTab(
|
||||
host: Host | null | undefined,
|
||||
capabilities: SessionCapabilities | undefined,
|
||||
session: TerminalSession | null | undefined,
|
||||
): boolean {
|
||||
if (capabilities?.hasDocker === true) return true;
|
||||
if (hasContainerSource(capabilities)) return true;
|
||||
// Network appliances: never show Docker from an OS guess alone.
|
||||
if (isNetworkDeviceTarget(host)) return false;
|
||||
return isDefiniteLinuxTarget(host, capabilities, session);
|
||||
|
||||
@@ -4,6 +4,12 @@ export interface SessionCapabilities {
|
||||
targetOs: TargetOs;
|
||||
hasTmux: boolean;
|
||||
hasDocker: boolean;
|
||||
/** `podman` binary present. A podman-docker shim may also set hasDocker. */
|
||||
hasPodman?: boolean;
|
||||
/** `nerdctl` binary present. */
|
||||
hasNerdctl?: boolean;
|
||||
/** `kubectl` binary present on the remote host. */
|
||||
hasKubectl?: boolean;
|
||||
hasNvidiaSmi: boolean;
|
||||
hasNpuSmi: boolean;
|
||||
/** `ss` binary present (preferred listening-port collector). */
|
||||
@@ -194,7 +200,7 @@ export type DockerContainerAction =
|
||||
|
||||
export type DockerImageManageAction =
|
||||
| { action: 'pull'; imageRef: string }
|
||||
| { action: 'rm'; imageId: string; force?: boolean }
|
||||
| { action: 'rm'; imageId: string; force?: boolean; repository?: string; tag?: string }
|
||||
| { action: 'prune'; all?: boolean }
|
||||
| { action: 'tag'; imageId: string; repository: string; tag?: string };
|
||||
|
||||
@@ -221,6 +227,11 @@ export interface TerminalPopupPayload {
|
||||
parentSessionId: string;
|
||||
sourceSession: import('../../types').TerminalSession;
|
||||
startupCommand: string;
|
||||
/**
|
||||
* System-manager panel rectangle in renderer viewport pixels. The popup is
|
||||
* placed in the remaining source-window area so it does not cover this panel.
|
||||
*/
|
||||
avoidClientRect?: { x: number; y: number; width: number; height: number };
|
||||
localShellType?: import('../../types').TerminalSession['shellType'];
|
||||
/**
|
||||
* When set, the popup attaches to this already-running backend session
|
||||
|
||||
@@ -0,0 +1,111 @@
|
||||
import assert from 'node:assert/strict';
|
||||
import test from 'node:test';
|
||||
|
||||
import {
|
||||
VAULT_HOST_DRAG_ID,
|
||||
VAULT_HOST_DRAG_IDS,
|
||||
applyVaultHostDragData,
|
||||
dispatchVaultHostGroupMove,
|
||||
isVaultHostDraggable,
|
||||
readVaultDraggedHostIds,
|
||||
resolveVaultHostDragIds,
|
||||
} from './vaultHostDrag.ts';
|
||||
|
||||
test('outside multi-select a host drag carries only that host', () => {
|
||||
const ids = resolveVaultHostDragIds({
|
||||
hostId: 'a',
|
||||
isMultiSelectMode: false,
|
||||
selectedHostIds: new Set(['a', 'b']),
|
||||
});
|
||||
assert.deepEqual(ids, ['a']);
|
||||
assert.equal(isVaultHostDraggable(false, new Set(), 'a'), true);
|
||||
|
||||
const written: Record<string, string> = {};
|
||||
applyVaultHostDragData({
|
||||
effectAllowed: '',
|
||||
setData: (type, value) => {
|
||||
written[type] = value;
|
||||
},
|
||||
}, ids!);
|
||||
assert.deepEqual(written, { [VAULT_HOST_DRAG_ID]: 'a' });
|
||||
});
|
||||
|
||||
test('a multi-select drag starts only on an already selected host and carries every selected id', () => {
|
||||
assert.equal(resolveVaultHostDragIds({
|
||||
hostId: 'c',
|
||||
isMultiSelectMode: true,
|
||||
selectedHostIds: new Set(['a', 'b']),
|
||||
}), null);
|
||||
assert.equal(isVaultHostDraggable(true, new Set(['a', 'b']), 'c'), false);
|
||||
|
||||
const ids = resolveVaultHostDragIds({
|
||||
hostId: 'b',
|
||||
isMultiSelectMode: true,
|
||||
selectedHostIds: new Set(['a', 'b', 'c']),
|
||||
});
|
||||
assert.deepEqual(ids, ['b', 'a', 'c']);
|
||||
|
||||
const written: Record<string, string> = {};
|
||||
applyVaultHostDragData({
|
||||
effectAllowed: 'none',
|
||||
setData: (type, value) => {
|
||||
written[type] = value;
|
||||
},
|
||||
}, ids!);
|
||||
assert.equal(written[VAULT_HOST_DRAG_ID], 'b');
|
||||
assert.deepEqual(JSON.parse(written[VAULT_HOST_DRAG_IDS]), ['b', 'a', 'c']);
|
||||
});
|
||||
|
||||
test('reading a drag payload prefers the multi-id list and falls back to one host id', () => {
|
||||
assert.deepEqual(readVaultDraggedHostIds((type) => (
|
||||
type === VAULT_HOST_DRAG_IDS ? JSON.stringify(['a', 'b', 'a']) : 'a'
|
||||
)), ['a', 'b', 'a']);
|
||||
assert.deepEqual(readVaultDraggedHostIds((type) => (
|
||||
type === VAULT_HOST_DRAG_ID ? 'solo' : ''
|
||||
)), ['solo']);
|
||||
assert.deepEqual(readVaultDraggedHostIds((type) => (
|
||||
type === VAULT_HOST_DRAG_IDS ? '{nope' : 'solo'
|
||||
)), ['solo']);
|
||||
assert.deepEqual(readVaultDraggedHostIds(() => ''), []);
|
||||
});
|
||||
|
||||
test('multi-id drag payload plans one batched group move', () => {
|
||||
const singleCalls: string[] = [];
|
||||
const batchCalls: Array<{ ids: string[]; group: string | null }> = [];
|
||||
const moved = dispatchVaultHostGroupMove({
|
||||
hostIds: readVaultDraggedHostIds((type) => (
|
||||
type === VAULT_HOST_DRAG_IDS ? JSON.stringify(['a', 'b', 'c']) : 'a'
|
||||
)),
|
||||
groupPath: 'Ops/Nested',
|
||||
moveHostToGroup: (hostId, groupPath) => {
|
||||
singleCalls.push(`${hostId}:${String(groupPath)}`);
|
||||
},
|
||||
moveHostsToGroup: (hostIds, groupPath) => {
|
||||
batchCalls.push({ ids: [...hostIds], group: groupPath });
|
||||
},
|
||||
});
|
||||
|
||||
assert.equal(moved, true);
|
||||
assert.deepEqual(singleCalls, []);
|
||||
assert.deepEqual(batchCalls, [{ ids: ['a', 'b', 'c'], group: 'Ops/Nested' }]);
|
||||
});
|
||||
|
||||
test('a single host id still moves through the single-host path when no batch handler is present', () => {
|
||||
const calls: string[] = [];
|
||||
const moved = dispatchVaultHostGroupMove({
|
||||
hostIds: ['host-1'],
|
||||
groupPath: null,
|
||||
moveHostToGroup: (hostId, groupPath) => {
|
||||
calls.push(`${hostId}:${String(groupPath)}`);
|
||||
},
|
||||
});
|
||||
assert.equal(moved, true);
|
||||
assert.deepEqual(calls, ['host-1:null']);
|
||||
assert.equal(dispatchVaultHostGroupMove({
|
||||
hostIds: [],
|
||||
groupPath: 'Ops',
|
||||
moveHostsToGroup: () => {
|
||||
throw new Error('empty selection must not move');
|
||||
},
|
||||
}), false);
|
||||
});
|
||||
@@ -0,0 +1,83 @@
|
||||
export const VAULT_HOST_DRAG_ID = 'host-id';
|
||||
export const VAULT_HOST_DRAG_IDS = 'host-ids';
|
||||
|
||||
export interface VaultHostDragWriter {
|
||||
effectAllowed: string;
|
||||
setData: (type: string, value: string) => void;
|
||||
}
|
||||
|
||||
export function resolveVaultHostDragIds(input: {
|
||||
hostId: string;
|
||||
isMultiSelectMode: boolean;
|
||||
selectedHostIds: ReadonlySet<string>;
|
||||
}): string[] | null {
|
||||
if (!input.isMultiSelectMode) return [input.hostId];
|
||||
if (!input.selectedHostIds.has(input.hostId)) return null;
|
||||
return [
|
||||
input.hostId,
|
||||
...input.selectedHostIds,
|
||||
].filter((id, index, ids) => id.length > 0 && ids.indexOf(id) === index);
|
||||
}
|
||||
|
||||
export function isVaultHostDraggable(
|
||||
isMultiSelectMode: boolean,
|
||||
selectedHostIds: ReadonlySet<string> | undefined,
|
||||
hostId: string,
|
||||
): boolean {
|
||||
return resolveVaultHostDragIds({
|
||||
hostId,
|
||||
isMultiSelectMode,
|
||||
selectedHostIds: selectedHostIds ?? new Set(),
|
||||
}) !== null;
|
||||
}
|
||||
|
||||
export function encodeVaultHostDragIds(ids: readonly string[]): string {
|
||||
return JSON.stringify(ids);
|
||||
}
|
||||
|
||||
export function decodeVaultHostDragIds(raw: string | null | undefined): string[] {
|
||||
if (!raw) return [];
|
||||
try {
|
||||
const parsed = JSON.parse(raw) as unknown;
|
||||
if (!Array.isArray(parsed)) return [];
|
||||
return parsed.filter((id): id is string => typeof id === 'string' && id.length > 0);
|
||||
} catch {
|
||||
return [];
|
||||
}
|
||||
}
|
||||
|
||||
export function applyVaultHostDragData(dataTransfer: VaultHostDragWriter, ids: readonly string[]): void {
|
||||
if (ids.length === 0) return;
|
||||
dataTransfer.effectAllowed = 'copyMove';
|
||||
dataTransfer.setData(VAULT_HOST_DRAG_ID, ids[0]);
|
||||
if (ids.length > 1) dataTransfer.setData(VAULT_HOST_DRAG_IDS, encodeVaultHostDragIds(ids));
|
||||
}
|
||||
|
||||
export function readVaultDraggedHostIds(getData: (type: string) => string): string[] {
|
||||
const many = decodeVaultHostDragIds(getData(VAULT_HOST_DRAG_IDS));
|
||||
if (many.length > 0) return many;
|
||||
const single = getData(VAULT_HOST_DRAG_ID)?.trim() ?? '';
|
||||
return single ? [single] : [];
|
||||
}
|
||||
|
||||
export function dispatchVaultHostGroupMove(input: {
|
||||
hostIds: readonly string[];
|
||||
groupPath: string | null;
|
||||
moveHostToGroup?: (hostId: string, groupPath: string | null) => void;
|
||||
moveHostsToGroup?: (hostIds: ReadonlySet<string>, groupPath: string | null) => void;
|
||||
}): boolean {
|
||||
const ids: string[] = [];
|
||||
for (const id of input.hostIds) {
|
||||
if (id && !ids.includes(id)) ids.push(id);
|
||||
}
|
||||
if (ids.length === 0) return false;
|
||||
if (input.moveHostsToGroup) {
|
||||
input.moveHostsToGroup(new Set(ids), input.groupPath);
|
||||
return true;
|
||||
}
|
||||
if (ids.length === 1 && input.moveHostToGroup) {
|
||||
input.moveHostToGroup(ids[0], input.groupPath);
|
||||
return true;
|
||||
}
|
||||
return false;
|
||||
}
|
||||
@@ -0,0 +1,175 @@
|
||||
import assert from 'node:assert/strict';
|
||||
import test from 'node:test';
|
||||
|
||||
import {
|
||||
MARQUEE_MOVEMENT_THRESHOLD_PX,
|
||||
clientPointToContent,
|
||||
contentRectFromClientBox,
|
||||
exceedsMarqueeThreshold,
|
||||
hostIdsIntersectingMarquee,
|
||||
isPointerOnNativeScrollbar,
|
||||
marqueeAutoScrollDelta,
|
||||
marqueeCancelSelection,
|
||||
marqueeCommitSelection,
|
||||
marqueeRectFromPoints,
|
||||
marqueeSelectionMode,
|
||||
mergeMarqueeSelection,
|
||||
rememberHostPlacements,
|
||||
shouldStartVaultMarquee,
|
||||
type HostPlacement,
|
||||
} from './vaultMarqueeSelection.ts';
|
||||
|
||||
const box = (left: number, top: number, right: number, bottom: number) => ({
|
||||
left, top, right, bottom,
|
||||
});
|
||||
|
||||
test('marquee rect normalizes a drag that moves up and left', () => {
|
||||
assert.deepEqual(
|
||||
marqueeRectFromPoints({ x: 40, y: 50 }, { x: 10, y: 20 }),
|
||||
box(10, 20, 40, 50),
|
||||
);
|
||||
});
|
||||
|
||||
test('marquee movement threshold keeps a plain click below 4px', () => {
|
||||
const start = { x: 10, y: 10 };
|
||||
assert.equal(exceedsMarqueeThreshold(start, { x: 13, y: 12 }), false);
|
||||
assert.equal(
|
||||
exceedsMarqueeThreshold(start, { x: 10 + MARQUEE_MOVEMENT_THRESHOLD_PX, y: 10 }),
|
||||
true,
|
||||
);
|
||||
assert.equal(exceedsMarqueeThreshold(start, { x: 10, y: 14 }), true);
|
||||
});
|
||||
|
||||
test('marquee modifiers replace, add with shift, and toggle with ctrl or meta', () => {
|
||||
assert.equal(marqueeSelectionMode({ shiftKey: false, ctrlKey: false, metaKey: false }), 'replace');
|
||||
assert.equal(marqueeSelectionMode({ shiftKey: true, ctrlKey: false, metaKey: false }), 'add');
|
||||
assert.equal(marqueeSelectionMode({ shiftKey: false, ctrlKey: true, metaKey: false }), 'toggle');
|
||||
assert.equal(marqueeSelectionMode({ shiftKey: false, ctrlKey: false, metaKey: true }), 'toggle');
|
||||
assert.equal(marqueeSelectionMode({ shiftKey: true, ctrlKey: true, metaKey: false }), 'toggle');
|
||||
});
|
||||
|
||||
test('marquee selection merge uses the selection from drag start', () => {
|
||||
const baseline = new Set(['a', 'b']);
|
||||
const hits = new Set(['b', 'c']);
|
||||
|
||||
assert.deepEqual([...mergeMarqueeSelection(baseline, hits, 'replace')].sort(), ['b', 'c']);
|
||||
assert.deepEqual([...mergeMarqueeSelection(baseline, hits, 'add')].sort(), ['a', 'b', 'c']);
|
||||
assert.deepEqual([...mergeMarqueeSelection(baseline, hits, 'toggle')].sort(), ['a', 'c']);
|
||||
assert.deepEqual([...baseline].sort(), ['a', 'b']);
|
||||
});
|
||||
|
||||
test('host cards that intersect the marquee are selected, including partial overlap', () => {
|
||||
const placements = [
|
||||
{ id: 'inside', rect: box(10, 10, 30, 30) },
|
||||
{ id: 'partial', rect: box(28, 28, 50, 50) },
|
||||
{ id: 'touch', rect: box(40, 10, 55, 20) },
|
||||
{ id: 'miss', rect: box(41, 10, 60, 20) },
|
||||
];
|
||||
|
||||
assert.deepEqual(
|
||||
[...hostIdsIntersectingMarquee(box(0, 0, 40, 40), placements)].sort(),
|
||||
['inside', 'partial'],
|
||||
);
|
||||
});
|
||||
|
||||
test('placement cache keeps hosts that scroll out of the mounted set', () => {
|
||||
const cache = new Map<string, HostPlacement>();
|
||||
rememberHostPlacements(cache, [
|
||||
{ key: 'main:a', id: 'a', rect: box(0, 0, 10, 10) },
|
||||
{ key: 'pinned:a', id: 'a', rect: box(0, 100, 10, 110) },
|
||||
{ key: 'main:b', id: 'b', rect: box(0, 20, 10, 30) },
|
||||
]);
|
||||
rememberHostPlacements(cache, [
|
||||
{ key: 'main:b', id: 'b', rect: box(0, 20, 12, 30) },
|
||||
{ key: 'main:c', id: 'c', rect: box(0, 40, 10, 50) },
|
||||
]);
|
||||
|
||||
const hits = hostIdsIntersectingMarquee(box(0, 0, 10, 50), cache.values());
|
||||
assert.deepEqual([...hits].sort(), ['a', 'b', 'c']);
|
||||
assert.equal(cache.get('main:b')?.rect.right, 12);
|
||||
});
|
||||
|
||||
test('content coordinates stay stable when the scroll container moves', () => {
|
||||
const frame = {
|
||||
left: 100,
|
||||
top: 40,
|
||||
scrollLeft: 0,
|
||||
scrollTop: 25,
|
||||
clientLeft: 0,
|
||||
clientTop: 0,
|
||||
};
|
||||
assert.deepEqual(clientPointToContent({ x: 130, y: 80 }, frame), { x: 30, y: 65 });
|
||||
|
||||
const beforeScroll = contentRectFromClientBox(
|
||||
{ left: 120, top: 70, width: 40, height: 20 },
|
||||
frame,
|
||||
);
|
||||
const afterScroll = contentRectFromClientBox(
|
||||
{ left: 120, top: 50, width: 40, height: 20 },
|
||||
{ ...frame, scrollTop: 45 },
|
||||
);
|
||||
assert.deepEqual(beforeScroll, afterScroll);
|
||||
assert.deepEqual(beforeScroll, box(20, 55, 60, 75));
|
||||
});
|
||||
|
||||
test('auto-scroll only steps when the pointer is near a vertical edge', () => {
|
||||
assert.equal(marqueeAutoScrollDelta(10, 0, 200), -16);
|
||||
assert.equal(marqueeAutoScrollDelta(190, 0, 200), 16);
|
||||
assert.equal(marqueeAutoScrollDelta(100, 0, 200), 0);
|
||||
});
|
||||
|
||||
test('native scrollbar gutter does not start a marquee', () => {
|
||||
const metrics = {
|
||||
rectLeft: 0,
|
||||
rectTop: 0,
|
||||
clientLeft: 0,
|
||||
clientTop: 0,
|
||||
clientWidth: 200,
|
||||
clientHeight: 300,
|
||||
};
|
||||
assert.equal(isPointerOnNativeScrollbar(metrics, 190, 40), false);
|
||||
assert.equal(isPointerOnNativeScrollbar(metrics, 210, 40), true);
|
||||
assert.equal(isPointerOnNativeScrollbar(metrics, 20, 310), true);
|
||||
});
|
||||
|
||||
test('marquee starts on primary grid or list presses in empty space only', () => {
|
||||
const ready = {
|
||||
viewMode: 'grid',
|
||||
button: 0,
|
||||
pointerType: 'mouse',
|
||||
onScrollbar: false,
|
||||
blockedTarget: false,
|
||||
};
|
||||
assert.equal(shouldStartVaultMarquee(ready), true);
|
||||
assert.equal(shouldStartVaultMarquee({ ...ready, viewMode: 'list' }), true);
|
||||
assert.equal(shouldStartVaultMarquee({ ...ready, viewMode: 'tree' }), false);
|
||||
assert.equal(shouldStartVaultMarquee({ ...ready, button: 2 }), false);
|
||||
assert.equal(shouldStartVaultMarquee({ ...ready, pointerType: 'touch' }), false);
|
||||
assert.equal(shouldStartVaultMarquee({ ...ready, onScrollbar: true }), false);
|
||||
assert.equal(shouldStartVaultMarquee({ ...ready, blockedTarget: true }), false);
|
||||
});
|
||||
|
||||
test('committing a marquee enters multi-select only when hosts are selected', () => {
|
||||
assert.deepEqual(
|
||||
marqueeCommitSelection({ wasMultiSelectMode: false, nextIds: new Set(['a']) }),
|
||||
{ isMultiSelectMode: true, selectedHostIds: new Set(['a']) },
|
||||
);
|
||||
assert.deepEqual(
|
||||
marqueeCommitSelection({ wasMultiSelectMode: false, nextIds: new Set() }),
|
||||
{ isMultiSelectMode: false, selectedHostIds: new Set() },
|
||||
);
|
||||
assert.deepEqual(
|
||||
marqueeCommitSelection({ wasMultiSelectMode: true, nextIds: new Set() }),
|
||||
{ isMultiSelectMode: true, selectedHostIds: new Set() },
|
||||
);
|
||||
});
|
||||
|
||||
test('escape restores the selection captured at drag start', () => {
|
||||
assert.deepEqual(
|
||||
marqueeCancelSelection({
|
||||
wasMultiSelectMode: false,
|
||||
baselineIds: new Set(['kept']),
|
||||
}),
|
||||
{ isMultiSelectMode: false, selectedHostIds: new Set(['kept']) },
|
||||
);
|
||||
});
|
||||
@@ -0,0 +1,182 @@
|
||||
export const MARQUEE_MOVEMENT_THRESHOLD_PX = 4;
|
||||
export const MARQUEE_AUTOSCROLL_EDGE_PX = 28;
|
||||
export const MARQUEE_AUTOSCROLL_STEP_PX = 16;
|
||||
|
||||
export interface MarqueePoint {
|
||||
x: number;
|
||||
y: number;
|
||||
}
|
||||
|
||||
export interface MarqueeRect {
|
||||
left: number;
|
||||
top: number;
|
||||
right: number;
|
||||
bottom: number;
|
||||
}
|
||||
|
||||
export interface MarqueeContentFrame {
|
||||
left: number;
|
||||
top: number;
|
||||
scrollLeft: number;
|
||||
scrollTop: number;
|
||||
clientLeft?: number;
|
||||
clientTop?: number;
|
||||
}
|
||||
|
||||
export interface ScrollbarMetrics {
|
||||
rectLeft: number;
|
||||
rectTop: number;
|
||||
clientLeft: number;
|
||||
clientTop: number;
|
||||
clientWidth: number;
|
||||
clientHeight: number;
|
||||
}
|
||||
|
||||
export type MarqueeSelectionMode = 'replace' | 'add' | 'toggle';
|
||||
|
||||
export interface HostPlacement {
|
||||
key: string;
|
||||
id: string;
|
||||
rect: MarqueeRect;
|
||||
}
|
||||
|
||||
export function marqueeRectFromPoints(start: MarqueePoint, current: MarqueePoint): MarqueeRect {
|
||||
return {
|
||||
left: Math.min(start.x, current.x),
|
||||
top: Math.min(start.y, current.y),
|
||||
right: Math.max(start.x, current.x),
|
||||
bottom: Math.max(start.y, current.y),
|
||||
};
|
||||
}
|
||||
|
||||
export function exceedsMarqueeThreshold(
|
||||
start: MarqueePoint,
|
||||
current: MarqueePoint,
|
||||
threshold = MARQUEE_MOVEMENT_THRESHOLD_PX,
|
||||
): boolean {
|
||||
return Math.abs(current.x - start.x) >= threshold || Math.abs(current.y - start.y) >= threshold;
|
||||
}
|
||||
|
||||
export function marqueeSelectionMode(modifiers: {
|
||||
shiftKey: boolean;
|
||||
ctrlKey: boolean;
|
||||
metaKey: boolean;
|
||||
}): MarqueeSelectionMode {
|
||||
if (modifiers.ctrlKey || modifiers.metaKey) return 'toggle';
|
||||
if (modifiers.shiftKey) return 'add';
|
||||
return 'replace';
|
||||
}
|
||||
|
||||
export function rectsIntersect(a: MarqueeRect, b: MarqueeRect): boolean {
|
||||
return a.left < b.right && a.right > b.left && a.top < b.bottom && a.bottom > b.top;
|
||||
}
|
||||
|
||||
export function mergeMarqueeSelection(
|
||||
baseline: ReadonlySet<string>,
|
||||
hitIds: ReadonlySet<string>,
|
||||
mode: MarqueeSelectionMode,
|
||||
): Set<string> {
|
||||
if (mode === 'replace') return new Set(hitIds);
|
||||
const next = new Set(baseline);
|
||||
for (const id of hitIds) {
|
||||
if (mode === 'toggle' && next.has(id)) next.delete(id);
|
||||
else next.add(id);
|
||||
}
|
||||
return next;
|
||||
}
|
||||
|
||||
export function hostIdsIntersectingMarquee(
|
||||
marquee: MarqueeRect,
|
||||
placements: Iterable<{ id: string; rect: MarqueeRect }>,
|
||||
): Set<string> {
|
||||
const hits = new Set<string>();
|
||||
for (const placement of placements) {
|
||||
if (rectsIntersect(marquee, placement.rect)) hits.add(placement.id);
|
||||
}
|
||||
return hits;
|
||||
}
|
||||
|
||||
export function rememberHostPlacements(
|
||||
cache: Map<string, HostPlacement>,
|
||||
visible: readonly HostPlacement[],
|
||||
): Map<string, HostPlacement> {
|
||||
for (const placement of visible) cache.set(placement.key, placement);
|
||||
return cache;
|
||||
}
|
||||
|
||||
export function clientPointToContent(client: MarqueePoint, frame: MarqueeContentFrame): MarqueePoint {
|
||||
return {
|
||||
x: client.x - frame.left - (frame.clientLeft ?? 0) + frame.scrollLeft,
|
||||
y: client.y - frame.top - (frame.clientTop ?? 0) + frame.scrollTop,
|
||||
};
|
||||
}
|
||||
|
||||
export function contentRectFromClientBox(
|
||||
box: { left: number; top: number; width: number; height: number },
|
||||
frame: MarqueeContentFrame,
|
||||
): MarqueeRect {
|
||||
const origin = clientPointToContent({ x: box.left, y: box.top }, frame);
|
||||
return {
|
||||
left: origin.x,
|
||||
top: origin.y,
|
||||
right: origin.x + box.width,
|
||||
bottom: origin.y + box.height,
|
||||
};
|
||||
}
|
||||
|
||||
export function marqueeAutoScrollDelta(
|
||||
client: number,
|
||||
start: number,
|
||||
end: number,
|
||||
edge = MARQUEE_AUTOSCROLL_EDGE_PX,
|
||||
step = MARQUEE_AUTOSCROLL_STEP_PX,
|
||||
): number {
|
||||
if (client < start + edge) return -step;
|
||||
if (client > end - edge) return step;
|
||||
return 0;
|
||||
}
|
||||
|
||||
export function isPointerOnNativeScrollbar(
|
||||
metrics: ScrollbarMetrics,
|
||||
clientX: number,
|
||||
clientY: number,
|
||||
): boolean {
|
||||
const x = clientX - metrics.rectLeft - metrics.clientLeft;
|
||||
const y = clientY - metrics.rectTop - metrics.clientTop;
|
||||
return x > metrics.clientWidth || y > metrics.clientHeight;
|
||||
}
|
||||
|
||||
export function shouldStartVaultMarquee(input: {
|
||||
viewMode: string;
|
||||
button: number;
|
||||
pointerType?: string;
|
||||
onScrollbar: boolean;
|
||||
blockedTarget: boolean;
|
||||
}): boolean {
|
||||
if (input.viewMode !== 'grid' && input.viewMode !== 'list') return false;
|
||||
if (input.button !== 0) return false;
|
||||
if (input.pointerType === 'touch') return false;
|
||||
if (input.onScrollbar || input.blockedTarget) return false;
|
||||
return true;
|
||||
}
|
||||
|
||||
export function marqueeCommitSelection(input: {
|
||||
wasMultiSelectMode: boolean;
|
||||
nextIds: ReadonlySet<string>;
|
||||
}): { isMultiSelectMode: boolean; selectedHostIds: Set<string> } {
|
||||
const selectedHostIds = new Set(input.nextIds);
|
||||
return {
|
||||
isMultiSelectMode: selectedHostIds.size > 0 || input.wasMultiSelectMode,
|
||||
selectedHostIds,
|
||||
};
|
||||
}
|
||||
|
||||
export function marqueeCancelSelection(input: {
|
||||
wasMultiSelectMode: boolean;
|
||||
baselineIds: ReadonlySet<string>;
|
||||
}): { isMultiSelectMode: boolean; selectedHostIds: Set<string> } {
|
||||
return {
|
||||
isMultiSelectMode: input.wasMultiSelectMode,
|
||||
selectedHostIds: new Set(input.baselineIds),
|
||||
};
|
||||
}
|
||||
@@ -70,6 +70,7 @@ module.exports = {
|
||||
'domain/terminalPromptSecurity.shared.cjs',
|
||||
// Main-process SSH, SFTP, and port-forward bridges share this matcher.
|
||||
// TypeScript under domain/ is excluded below, so the .cjs must be listed.
|
||||
'domain/systemManager/containerRuntime.shared.cjs',
|
||||
'domain/singleChannelSshBanner.shared.cjs',
|
||||
'domain/singleChannelSshBanner.shared.mjs',
|
||||
'domain/sshServerVendor.mjs',
|
||||
|
||||
@@ -17,6 +17,7 @@ const { appendVaultAgentGuidance, VAULT_NOTES_CLI_GUIDANCE } = require("../share
|
||||
|
||||
const mcpServerBridge = require("./mcpServerBridge.cjs");
|
||||
const { createExternalMcpController } = require("./externalMcpController.cjs");
|
||||
const { registerPrivacyHandlers } = require("./aiBridge/privacyHandlers.cjs");
|
||||
const { getCliLauncherPath, TOOL_CLI_DISCOVERY_ENV_VAR } = require("../cli/discoveryPath.cjs");
|
||||
const { injectCliTransportEnv } = require("../cli/cliChatSession.cjs");
|
||||
const { getExternalMcpDiscoveryFilePath } = require("../cli/externalMcpDiscoveryPath.cjs");
|
||||
@@ -997,6 +998,7 @@ function registerHandlers(ipcMain) {
|
||||
registerAgentDiscoveryHandlers(context);
|
||||
registerAgentProcessHandlers(context);
|
||||
registerSdkStreamHandlers(context);
|
||||
registerPrivacyHandlers(context);
|
||||
|
||||
if (externalMcpController) {
|
||||
externalMcpController.registerHandlers(ipcMain, validateSenderOrSettings);
|
||||
|
||||
@@ -0,0 +1,28 @@
|
||||
"use strict";
|
||||
|
||||
const { test } = require("node:test");
|
||||
const assert = require("node:assert/strict");
|
||||
const { registerPrivacyHandlers } = require("./aiBridge/privacyHandlers.cjs");
|
||||
const { createAiPrivacyService } = require("../shared/aiPrivacyService.cjs");
|
||||
|
||||
test("AI privacy IPC validates sender and shares semantic transforms", async () => {
|
||||
const handlers = new Map();
|
||||
const service = createAiPrivacyService();
|
||||
service.configure({ enabled: true });
|
||||
registerPrivacyHandlers({
|
||||
ipcMain: { handle: (name, handler) => handlers.set(name, handler) },
|
||||
validateSender: (event) => event.trusted === true,
|
||||
aiPrivacyService: service,
|
||||
});
|
||||
const transform = handlers.get("netcatty:ai:privacy:transform");
|
||||
assert.equal(transform({}, {}).ok, false);
|
||||
const request = { chatSessionId: "catty-chat", operation: "redact", value: ["password=synthetic-secret", "192.0.2.1"] };
|
||||
const masked = transform({ trusted: true }, request);
|
||||
assert.equal(masked.ok, true);
|
||||
assert.doesNotMatch(JSON.stringify(masked.value), /synthetic-secret|192\.0\.2\.1/);
|
||||
assert.deepEqual(transform({ trusted: true }, { ...request, operation: "restore", value: masked.value }).value, request.value);
|
||||
handlers.get("netcatty:ai:privacy:clear")({ trusted: true }, request.chatSessionId);
|
||||
assert.equal(transform({ trusted: true }, { ...request, operation: "restore", value: masked.value }).ok, false);
|
||||
service.invalidateConfiguration();
|
||||
assert.equal(handlers.get("netcatty:ai:privacy:status")({ trusted: true }).ok, false);
|
||||
});
|
||||
@@ -6,6 +6,16 @@ const { emitTerminalSessionData } = require("../emitTerminalSessionData.cjs");
|
||||
const { formatSyntheticEcho } = require("../ai/shellUtils.cjs");
|
||||
const { clearSessionFlowState } = require("../terminalFlowAck.cjs");
|
||||
const { remoteDisallowsExecChannelProbe, sessionDisallowsExtraSshChannel, ensureSessionShellKindForExec } = require("../ai/sessionShellKind.cjs");
|
||||
const defaultAiPrivacyService = require("../../shared/aiPrivacyService.cjs");
|
||||
|
||||
function assertCattyPrivacySession(privacyService, chatSessionId) {
|
||||
if (chatSessionId) {
|
||||
// Unlike checkSession/redact, restore cannot create an unpinned session.
|
||||
privacyService.restore(chatSessionId, []);
|
||||
} else if (privacyService.isEnabled()) {
|
||||
throw new Error("AI privacy requires a managed chat session.");
|
||||
}
|
||||
}
|
||||
|
||||
function getWorkerExecutionMeta(mcpServerBridge, sessionId, chatSessionId) {
|
||||
return mcpServerBridge.getSessionMeta?.(sessionId, chatSessionId) || {};
|
||||
@@ -24,6 +34,7 @@ async function proxyCattyExecToWorker({
|
||||
sessionId,
|
||||
command,
|
||||
chatSessionId,
|
||||
beforeExecute,
|
||||
}) {
|
||||
if (!terminalWorkerManager?.request) {
|
||||
return { ok: false, error: "Session not found" };
|
||||
@@ -54,6 +65,7 @@ async function proxyCattyExecToWorker({
|
||||
};
|
||||
|
||||
try {
|
||||
beforeExecute();
|
||||
return await terminalWorkerManager.request("netcatty:ai:exec", {
|
||||
sessionId,
|
||||
command,
|
||||
@@ -63,6 +75,7 @@ async function proxyCattyExecToWorker({
|
||||
commandBlocklist: mcpServerBridge.getCommandBlocklist?.(),
|
||||
}, {
|
||||
webContentsId: event?.sender?.id,
|
||||
beforeExecute,
|
||||
});
|
||||
} catch (err) {
|
||||
return { ok: false, error: err?.message || String(err) };
|
||||
@@ -72,6 +85,7 @@ async function proxyCattyExecToWorker({
|
||||
}
|
||||
|
||||
function registerCattyExecHandlers(ctx) {
|
||||
const privacyService = ctx.aiPrivacyService || defaultAiPrivacyService;
|
||||
with (ctx) {
|
||||
ipcMain.handle("netcatty:ai:exec", async (event, { sessionId, command, chatSessionId }) => {
|
||||
// Validate IPC sender (Issue #17)
|
||||
@@ -82,6 +96,12 @@ function registerCattyExecHandlers(ctx) {
|
||||
if (mcpServerBridge.getPermissionMode() === "observer") {
|
||||
return { ok: false, error: "Execution blocked: permission mode is 'observer'" };
|
||||
}
|
||||
const beforeExecute = () => assertCattyPrivacySession(privacyService, chatSessionId);
|
||||
try {
|
||||
beforeExecute();
|
||||
} catch (err) {
|
||||
return { ok: false, error: err?.message || "AI privacy protection is unavailable." };
|
||||
}
|
||||
const session = sessions?.get(sessionId);
|
||||
if (!session) {
|
||||
return proxyCattyExecToWorker({
|
||||
@@ -91,6 +111,7 @@ function registerCattyExecHandlers(ctx) {
|
||||
sessionId,
|
||||
command,
|
||||
chatSessionId,
|
||||
beforeExecute,
|
||||
});
|
||||
}
|
||||
|
||||
@@ -120,6 +141,7 @@ function registerCattyExecHandlers(ctx) {
|
||||
// (or immediately on a synchronous error/early return).
|
||||
const withLockRelease = (factory) => {
|
||||
try {
|
||||
beforeExecute();
|
||||
const result = factory();
|
||||
return Promise.resolve(result).finally(releaseLock);
|
||||
} catch (err) {
|
||||
@@ -165,6 +187,7 @@ function registerCattyExecHandlers(ctx) {
|
||||
chatSessionId,
|
||||
});
|
||||
if (!probed.ok) return probed;
|
||||
beforeExecute();
|
||||
const safety = mcpServerBridge.checkCommandSafetyForShell(
|
||||
command,
|
||||
mcpServerBridge.resolveSessionBlocklistShellKind(session),
|
||||
@@ -226,6 +249,7 @@ function registerCattyExecHandlers(ctx) {
|
||||
chatSessionId,
|
||||
});
|
||||
if (!probed.ok) return probed;
|
||||
beforeExecute();
|
||||
const safety = mcpServerBridge.checkCommandSafetyForShell(
|
||||
command,
|
||||
mcpServerBridge.resolveSessionBlocklistShellKind(session),
|
||||
@@ -307,10 +331,16 @@ function registerCattyExecHandlers(ctx) {
|
||||
if (!rpcMethod || typeof rpcMethod !== "string") {
|
||||
return { ok: false, error: "rpcMethod is required" };
|
||||
}
|
||||
return mcpServerBridge.dispatchBuiltinRpc(rpcMethod, {
|
||||
...(params || {}),
|
||||
chatSessionId,
|
||||
});
|
||||
const beforeExecute = () => assertCattyPrivacySession(privacyService, chatSessionId);
|
||||
try {
|
||||
beforeExecute();
|
||||
return await mcpServerBridge.dispatchBuiltinRpc(rpcMethod, {
|
||||
...(params || {}),
|
||||
chatSessionId,
|
||||
}, { beforeExecute });
|
||||
} catch (err) {
|
||||
return { ok: false, error: err?.message || "AI privacy protection is unavailable." };
|
||||
}
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
@@ -0,0 +1,154 @@
|
||||
"use strict";
|
||||
|
||||
const assert = require("node:assert/strict");
|
||||
const test = require("node:test");
|
||||
const { registerCattyExecHandlers } = require("./cattyExecHandlers.cjs");
|
||||
const { createAiPrivacyService } = require("../../shared/aiPrivacyService.cjs");
|
||||
|
||||
function deferred() {
|
||||
let resolve;
|
||||
const promise = new Promise((done) => { resolve = done; });
|
||||
return { promise, resolve };
|
||||
}
|
||||
|
||||
function harness(privacyService, overrides = {}) {
|
||||
const handlers = new Map();
|
||||
const effects = [];
|
||||
const locks = [];
|
||||
const mcpServerBridge = {
|
||||
getPermissionMode: () => "auto",
|
||||
getSessionBusyError: () => null,
|
||||
getSessionMeta: () => ({ protocol: "ssh" }),
|
||||
checkCommandSafetyCommonOnly: () => ({ blocked: false }),
|
||||
checkCommandSafetyForShell: () => ({ blocked: false }),
|
||||
resolveSessionBlocklistShellKind: () => "posix",
|
||||
reserveSessionExecution: () => ({ ok: true, token: "lock" }),
|
||||
releaseSessionExecution: () => { locks.push("released"); },
|
||||
activePtyExecs: new Map(),
|
||||
dispatchBuiltinRpc: async (method, params, context) => {
|
||||
context.beforeExecute();
|
||||
effects.push({ method, params });
|
||||
return { ok: true };
|
||||
},
|
||||
...overrides.mcpServerBridge,
|
||||
};
|
||||
registerCattyExecHandlers({
|
||||
ipcMain: { handle: (name, handler) => handlers.set(name, handler) },
|
||||
validateSender: () => true,
|
||||
aiPrivacyService: privacyService,
|
||||
sessions: overrides.sessions || new Map(),
|
||||
terminalWorkerManager: {
|
||||
request: async (channel, payload) => {
|
||||
effects.push({ channel, payload });
|
||||
return { ok: true };
|
||||
},
|
||||
},
|
||||
electronModule: {},
|
||||
safeSend() {},
|
||||
getFreshIdlePrompt: () => "",
|
||||
execViaPty: async () => {
|
||||
effects.push({ terminal: true });
|
||||
return { ok: true };
|
||||
},
|
||||
...overrides,
|
||||
mcpServerBridge,
|
||||
});
|
||||
const event = { sender: { id: 7 } };
|
||||
return {
|
||||
effects,
|
||||
locks,
|
||||
exec: (chatSessionId) => handlers.get("netcatty:ai:exec")(event, {
|
||||
sessionId: "session", command: "echo synthetic", chatSessionId,
|
||||
}),
|
||||
capability: (chatSessionId) => handlers.get("netcatty:ai:capability")(event, {
|
||||
rpcMethod: "netcatty/sftp/write", params: { sessionId: "session", path: "/synthetic" }, chatSessionId,
|
||||
}),
|
||||
};
|
||||
}
|
||||
|
||||
test("Catty main execution requires a live pinned chat when privacy is enabled", async () => {
|
||||
const service = createAiPrivacyService();
|
||||
service.configure({ enabled: true });
|
||||
const ctx = harness(service);
|
||||
assert.equal((await ctx.exec("unseen")).ok, false);
|
||||
assert.equal((await ctx.capability("unseen")).ok, false);
|
||||
assert.equal(service.hasSession("unseen"), false);
|
||||
assert.equal(ctx.effects.length, 0);
|
||||
service.checkSession("pinned");
|
||||
assert.equal((await ctx.exec("pinned")).ok, true);
|
||||
assert.equal((await ctx.capability("pinned")).ok, true);
|
||||
assert.equal(ctx.effects.length, 2);
|
||||
service.configure({ enabled: false });
|
||||
assert.equal((await ctx.exec("pinned")).ok, false);
|
||||
assert.equal((await ctx.capability("pinned")).ok, false);
|
||||
assert.equal(ctx.effects.length, 2);
|
||||
});
|
||||
|
||||
test("Catty legacy no-chat execution is allowed only for initialized disabled privacy", async () => {
|
||||
const loading = harness(createAiPrivacyService({ initialized: false }));
|
||||
assert.equal((await loading.exec()).ok, false);
|
||||
assert.equal((await loading.capability()).ok, false);
|
||||
assert.equal(loading.effects.length, 0);
|
||||
const service = createAiPrivacyService();
|
||||
const ctx = harness(service);
|
||||
assert.equal((await ctx.exec()).ok, true);
|
||||
assert.equal((await ctx.capability()).ok, true);
|
||||
service.configure({ enabled: true });
|
||||
assert.equal((await ctx.exec()).ok, false);
|
||||
assert.equal((await ctx.capability()).ok, false);
|
||||
assert.equal(ctx.effects.length, 2);
|
||||
});
|
||||
|
||||
for (const chatSessionId of ["pinned", undefined]) {
|
||||
test(`Catty capability rechecks policy after dispatcher approval (${chatSessionId || "legacy"})`, async () => {
|
||||
const service = createAiPrivacyService();
|
||||
if (chatSessionId) {
|
||||
service.configure({ enabled: true });
|
||||
service.checkSession(chatSessionId);
|
||||
}
|
||||
const entered = deferred();
|
||||
const approval = deferred();
|
||||
let calls = 0;
|
||||
const ctx = harness(service, {
|
||||
mcpServerBridge: {
|
||||
dispatchBuiltinRpc: async (_method, _params, context) => {
|
||||
entered.resolve();
|
||||
await approval.promise;
|
||||
context.beforeExecute();
|
||||
calls++;
|
||||
return { ok: true };
|
||||
},
|
||||
},
|
||||
});
|
||||
const pending = ctx.capability(chatSessionId);
|
||||
await entered.promise;
|
||||
service.configure({ enabled: !chatSessionId });
|
||||
approval.resolve();
|
||||
assert.equal((await pending).ok, false);
|
||||
assert.equal(calls, 0);
|
||||
});
|
||||
}
|
||||
|
||||
test("Catty terminal rechecks a deleted chat after shell probing and releases its lock", async () => {
|
||||
const service = createAiPrivacyService();
|
||||
service.configure({ enabled: true });
|
||||
service.checkSession("pinned");
|
||||
const entered = deferred();
|
||||
const probe = deferred();
|
||||
const ctx = harness(service, {
|
||||
sessions: new Map([["session", { protocol: "local", shellKind: "posix", pty: { write() {} } }]]),
|
||||
ensureSessionShellKindForExec: async () => {
|
||||
entered.resolve();
|
||||
await probe.promise;
|
||||
return { ok: true };
|
||||
},
|
||||
});
|
||||
const pending = ctx.exec("pinned");
|
||||
await entered.promise;
|
||||
service.clearSession("pinned");
|
||||
const rejected = assert.rejects(pending, /privacy session is unavailable/);
|
||||
probe.resolve();
|
||||
await rejected;
|
||||
assert.equal(ctx.effects.length, 0);
|
||||
assert.deepEqual(ctx.locks, ["released"]);
|
||||
});
|
||||
@@ -2,6 +2,7 @@ const assert = require("node:assert/strict");
|
||||
const test = require("node:test");
|
||||
|
||||
const { registerCattyExecHandlers } = require("./cattyExecHandlers.cjs");
|
||||
const { createAiPrivacyService } = require("../../shared/aiPrivacyService.cjs");
|
||||
|
||||
function createFakeIpcMain() {
|
||||
return {
|
||||
@@ -14,6 +15,8 @@ function createFakeIpcMain() {
|
||||
|
||||
test("catty AI exec proxies to the terminal worker when the real session lives in the worker", async () => {
|
||||
const ipcMain = createFakeIpcMain();
|
||||
const aiPrivacyService = createAiPrivacyService();
|
||||
aiPrivacyService.checkSession("chat-1");
|
||||
const requests = [];
|
||||
const terminalWorkerManager = {
|
||||
request(channel, payload, options) {
|
||||
@@ -55,6 +58,7 @@ test("catty AI exec proxies to the terminal worker when the real session lives i
|
||||
|
||||
registerCattyExecHandlers({
|
||||
ipcMain,
|
||||
aiPrivacyService,
|
||||
validateSender: () => true,
|
||||
sessions: new Map(),
|
||||
terminalWorkerManager,
|
||||
@@ -86,9 +90,10 @@ test("catty AI exec proxies to the terminal worker when the real session lives i
|
||||
sessionMeta: { protocol: "ssh", deviceType: "", hostname: "host.example" },
|
||||
commandBlocklist: [],
|
||||
},
|
||||
options: { webContentsId: 7 },
|
||||
options: { webContentsId: 7, beforeExecute: requests[0].options.beforeExecute },
|
||||
},
|
||||
]);
|
||||
assert.equal(typeof requests[0].options.beforeExecute, "function");
|
||||
assert.deepEqual(locks, [
|
||||
["reserve", "ssh-1", "exec"],
|
||||
["release", "ssh-1", "token-1"],
|
||||
|
||||
@@ -206,6 +206,9 @@ class CodexAppServerConnection {
|
||||
|
||||
async request(method, params = {}, timeoutMs = DEFAULT_REQUEST_TIMEOUT_MS, options = {}) {
|
||||
if (!options.skipStart) await this.start();
|
||||
// Even an initialized connection yields in start(). Revalidate prompt
|
||||
// handoffs after that await, before any JSONL payload reaches the process.
|
||||
options.beforeSend?.();
|
||||
const id = this.nextRequestId++;
|
||||
return new Promise((resolve, reject) => {
|
||||
const timer = setTimeout(() => {
|
||||
|
||||
@@ -198,3 +198,26 @@ test("App Server process keys include executable and environment identity", () =
|
||||
buildCodexAppServerKey("/a/codex", { HOME: "/b" }),
|
||||
);
|
||||
});
|
||||
|
||||
test("prompt guard runs after the connection's own startup await and prevents the JSONL write", async () => {
|
||||
const { createAiPrivacyService } = require("../../../shared/aiPrivacyService.cjs");
|
||||
const privacy = createAiPrivacyService();
|
||||
privacy.checkSession("chat");
|
||||
const child = createFakeChild();
|
||||
const writes = [];
|
||||
child.stdin.on("data", (chunk) => writes.push(String(chunk)));
|
||||
const connection = new CodexAppServerConnection({ binPath: "/bin/codex", spawnImpl: () => child });
|
||||
let release;
|
||||
connection.process = child;
|
||||
connection.start = () => new Promise((resolve) => { release = resolve; });
|
||||
const run = connection.request("turn/start", { input: [{ type: "text", text: "raw-secret" }] }, undefined, {
|
||||
beforeSend: () => privacy.checkSession("chat"),
|
||||
});
|
||||
const rejected = assert.rejects(run, /start a new chat/);
|
||||
privacy.configure({ enabled: true });
|
||||
release();
|
||||
await rejected;
|
||||
assert.deepEqual(writes, []);
|
||||
assert.equal(connection.pending.size, 0);
|
||||
connection.close();
|
||||
});
|
||||
@@ -180,6 +180,7 @@ class CodexAppServerRuntime {
|
||||
connectionFactory,
|
||||
sendInteractionRequest,
|
||||
sendInteractionCleared,
|
||||
transformUserInputAnswers,
|
||||
interruptRequestTimeoutMs = INTERRUPT_REQUEST_TIMEOUT_MS,
|
||||
interruptGraceMs = INTERRUPT_GRACE_MS,
|
||||
} = {}) {
|
||||
@@ -187,6 +188,7 @@ class CodexAppServerRuntime {
|
||||
this.connectionFactory = connectionFactory;
|
||||
this.sendInteractionRequest = sendInteractionRequest;
|
||||
this.sendInteractionCleared = sendInteractionCleared;
|
||||
this.transformUserInputAnswers = transformUserInputAnswers;
|
||||
this.interruptRequestTimeoutMs = interruptRequestTimeoutMs;
|
||||
this.interruptGraceMs = interruptGraceMs;
|
||||
this.connections = new Map();
|
||||
@@ -257,6 +259,7 @@ class CodexAppServerRuntime {
|
||||
emitter,
|
||||
signal,
|
||||
sender,
|
||||
assertSessionValid,
|
||||
}) {
|
||||
const throwIfAborted = () => {
|
||||
if (!signal?.aborted) return;
|
||||
@@ -279,9 +282,11 @@ class CodexAppServerRuntime {
|
||||
config: buildThreadConfig(injectedMcpServers),
|
||||
};
|
||||
|
||||
assertSessionValid?.();
|
||||
const requestOptions = { beforeSend: assertSessionValid };
|
||||
const threadResult = resumeThreadId
|
||||
? await connection.request("thread/resume", { threadId: resumeThreadId, ...threadParams })
|
||||
: await connection.request("thread/start", threadParams);
|
||||
? await connection.request("thread/resume", { threadId: resumeThreadId, ...threadParams }, undefined, requestOptions)
|
||||
: await connection.request("thread/start", threadParams, undefined, requestOptions);
|
||||
throwIfAborted();
|
||||
const threadId = threadResult?.thread?.id || resumeThreadId;
|
||||
if (!threadId) throw new Error("Codex App Server did not return a thread id");
|
||||
@@ -297,6 +302,7 @@ class CodexAppServerRuntime {
|
||||
emitter,
|
||||
signal,
|
||||
sender,
|
||||
assertSessionValid,
|
||||
lastError: null,
|
||||
settled: false,
|
||||
cancelRequested: false,
|
||||
@@ -325,6 +331,7 @@ class CodexAppServerRuntime {
|
||||
}
|
||||
|
||||
try {
|
||||
assertSessionValid?.();
|
||||
const turnResult = await connection.request("turn/start", {
|
||||
threadId,
|
||||
input: buildTurnInput(prompt, attachments),
|
||||
@@ -335,7 +342,7 @@ class CodexAppServerRuntime {
|
||||
model: selection.model || null,
|
||||
effort: selection.effort || null,
|
||||
summary: "concise",
|
||||
});
|
||||
}, undefined, requestOptions);
|
||||
const turnId = turnResult?.turn?.id;
|
||||
if (turnId) this.#assignTurnId(context, turnId);
|
||||
await completion;
|
||||
@@ -388,12 +395,13 @@ class CodexAppServerRuntime {
|
||||
|
||||
const steerPromise = (async () => {
|
||||
try {
|
||||
context.assertSessionValid?.();
|
||||
const response = await context.connection.request("turn/steer", {
|
||||
threadId: context.threadId,
|
||||
expectedTurnId: context.turnId,
|
||||
input: buildTurnInput(prompt, attachments),
|
||||
clientUserMessageId: clientUserMessageId || null,
|
||||
});
|
||||
}, undefined, { beforeSend: context.assertSessionValid });
|
||||
if (context.cancelRequested || context.signal?.aborted || context.settled) {
|
||||
return { status: "cancelled" };
|
||||
}
|
||||
@@ -861,7 +869,13 @@ class CodexAppServerRuntime {
|
||||
respondInteraction(interactionId, response, sender) {
|
||||
const pending = this.pendingInteractions.get(interactionId);
|
||||
if (sender && pending?.context?.sender && pending.context.sender !== sender) return false;
|
||||
return this.#resolveInteraction(interactionId, response);
|
||||
const safeResponse = pending?.kind === "user-input" && this.transformUserInputAnswers
|
||||
? {
|
||||
...response,
|
||||
answers: this.transformUserInputAnswers(response?.answers || {}, pending.context),
|
||||
}
|
||||
: response;
|
||||
return this.#resolveInteraction(interactionId, safeResponse);
|
||||
}
|
||||
|
||||
/**
|
||||
|
||||
@@ -311,6 +311,56 @@ test("runtime routes native approvals and request_user_input responses", async (
|
||||
await run;
|
||||
});
|
||||
|
||||
test("runtime transforms user answers with authoritative pending chat context after sender validation", () => {
|
||||
const responses = [];
|
||||
const sender = {};
|
||||
const transformedContexts = [];
|
||||
const runtime = new CodexAppServerRuntime({
|
||||
transformUserInputAnswers(answers, context) {
|
||||
transformedContexts.push(context.chatSessionId);
|
||||
return { ...answers, question: { answers: ["protected-answer"] } };
|
||||
},
|
||||
});
|
||||
runtime.pendingInteractions.set("private-answer", {
|
||||
interactionId: "private-answer",
|
||||
kind: "user-input",
|
||||
context: { chatSessionId: "chat-private", sender },
|
||||
connection: { respond: (id, result) => responses.push({ id, result }) },
|
||||
rpcId: 90,
|
||||
params: {},
|
||||
});
|
||||
assert.equal(runtime.respondInteraction("private-answer", {
|
||||
answers: { question: { answers: ["private-input"] } },
|
||||
}, {}), false);
|
||||
assert.equal(transformedContexts.length, 0);
|
||||
assert.equal(runtime.respondInteraction("private-answer", {
|
||||
answers: { question: { answers: ["private-input"] } },
|
||||
}, sender), true);
|
||||
assert.deepEqual(transformedContexts, ["chat-private"]);
|
||||
assert.deepEqual(responses, [{ id: 90, result: { answers: { question: { answers: ["protected-answer"] } } } }]);
|
||||
});
|
||||
|
||||
test("failed user answer transformation sends no raw reply and leaves interaction pending", () => {
|
||||
const responses = [];
|
||||
const runtime = new CodexAppServerRuntime({
|
||||
transformUserInputAnswers() { throw new Error("Privacy settings changed"); },
|
||||
});
|
||||
runtime.pendingInteractions.set("private-answer", {
|
||||
interactionId: "private-answer",
|
||||
kind: "user-input",
|
||||
context: { chatSessionId: "chat-private" },
|
||||
connection: { respond: (id, result) => responses.push({ id, result }) },
|
||||
rpcId: 91,
|
||||
params: {},
|
||||
});
|
||||
assert.throws(() => runtime.respondInteraction("private-answer", {
|
||||
answers: { question: { answers: ["private-input"] } },
|
||||
}), /Privacy settings changed/);
|
||||
assert.deepEqual(responses, []);
|
||||
assert.equal(runtime.pendingInteractions.has("private-answer"), true);
|
||||
runtime.close();
|
||||
});
|
||||
|
||||
test("cancelInteractionTimeout re-arms the absolute approval deadline (Catty/MCP style)", async () => {
|
||||
let connection;
|
||||
let interaction;
|
||||
@@ -848,3 +898,45 @@ test("model and file-change normalization preserve UI contract", async () => {
|
||||
assert.deepEqual(catalog.models[1].thinkingLevels, ["low", "high"]);
|
||||
assert.equal(catalog.models[1].defaultThinkingLevel, "high");
|
||||
});
|
||||
|
||||
for (const startupStage of ["connection", "thread"]) {
|
||||
test(`privacy off-to-on while Codex ${startupStage} startup is paused prevents turn/start`, async () => {
|
||||
const { createAiPrivacyService } = require("../../../shared/aiPrivacyService.cjs");
|
||||
const privacy = createAiPrivacyService();
|
||||
privacy.checkSession("chat-privacy-race");
|
||||
let notifyReady;
|
||||
let release;
|
||||
const ready = new Promise((resolve) => { notifyReady = resolve; });
|
||||
const gate = new Promise((resolve) => { release = resolve; });
|
||||
let connection;
|
||||
const runtime = new CodexAppServerRuntime({
|
||||
connectionFactory: (options) => {
|
||||
connection = new FakeConnection(options);
|
||||
if (startupStage === "connection") {
|
||||
connection.start = async () => { notifyReady(); await gate; };
|
||||
} else {
|
||||
const request = connection.request.bind(connection);
|
||||
connection.request = async (method, params) => {
|
||||
if (method === "thread/start") { notifyReady(); await gate; }
|
||||
return request(method, params);
|
||||
};
|
||||
}
|
||||
return connection;
|
||||
},
|
||||
});
|
||||
const run = runtime.runTurn({
|
||||
requestId: "privacy-race", chatSessionId: "chat-privacy-race",
|
||||
prompt: "raw-secret", binPath: "/bin/codex", emitter: createEmitter(),
|
||||
assertSessionValid: () => privacy.checkSession("chat-privacy-race"),
|
||||
});
|
||||
const rejected = assert.rejects(run, /start a new chat/);
|
||||
await ready;
|
||||
privacy.configure({ enabled: true });
|
||||
release();
|
||||
await rejected;
|
||||
assert.equal(connection.requests.some(({ method }) => method === "turn/start"), false);
|
||||
assert.equal(runtime.activeByRequest.size, 0);
|
||||
assert.equal(runtime.activeByThread.size, 0);
|
||||
await runtime.cleanupChatSession("chat-privacy-race");
|
||||
});
|
||||
}
|
||||
@@ -0,0 +1,35 @@
|
||||
"use strict";
|
||||
|
||||
const privacyService = require("../../shared/aiPrivacyService.cjs");
|
||||
|
||||
function registerPrivacyHandlers({ ipcMain, validateSender, aiPrivacyService = privacyService }) {
|
||||
ipcMain.handle("netcatty:ai:privacy:status", (event) => {
|
||||
if (!validateSender(event)) return { ok: false, error: "Unauthorized" };
|
||||
try {
|
||||
return { ok: true, enabled: aiPrivacyService.isEnabled() };
|
||||
} catch (error) {
|
||||
return { ok: false, error: error.message };
|
||||
}
|
||||
});
|
||||
ipcMain.handle("netcatty:ai:privacy:transform", (event, request = {}) => {
|
||||
if (!validateSender(event)) return { ok: false, error: "Unauthorized" };
|
||||
try {
|
||||
if (request.operation !== "redact" && request.operation !== "restore") {
|
||||
throw new Error("Invalid AI privacy operation.");
|
||||
}
|
||||
return {
|
||||
ok: true,
|
||||
value: aiPrivacyService[request.operation](request.chatSessionId, request.value),
|
||||
};
|
||||
} catch (error) {
|
||||
return { ok: false, error: error.message };
|
||||
}
|
||||
});
|
||||
ipcMain.handle("netcatty:ai:privacy:clear", (event, chatSessionId) => {
|
||||
if (!validateSender(event)) return { ok: false, error: "Unauthorized" };
|
||||
aiPrivacyService.clearSession(chatSessionId);
|
||||
return { ok: true };
|
||||
});
|
||||
}
|
||||
|
||||
module.exports = { registerPrivacyHandlers };
|
||||
@@ -233,7 +233,7 @@ function buildClaudePromptInput(prompt, attachments) {
|
||||
* @param {object} args.emitter createStreamEmitter(...)
|
||||
* @param {Function} [args.queryFn] inject @anthropic-ai/claude-agent-sdk query (for tests)
|
||||
*/
|
||||
async function runClaudeTurn({ prompt, attachments, options, emitter, queryFn }) {
|
||||
async function runClaudeTurn({ prompt, attachments, options, emitter, queryFn, assertSessionValid }) {
|
||||
ensureClaudeConfig();
|
||||
let query = queryFn;
|
||||
if (!query) {
|
||||
@@ -246,6 +246,7 @@ async function runClaudeTurn({ prompt, attachments, options, emitter, queryFn })
|
||||
let sessionId = null;
|
||||
let hasContent = false;
|
||||
try {
|
||||
assertSessionValid?.();
|
||||
const stream = query({ prompt: promptInput, options });
|
||||
for await (const message of stream) {
|
||||
if (options.abortController?.signal?.aborted) break;
|
||||
|
||||
@@ -557,7 +557,7 @@ function buildCodebuddyPromptInput(prompt, attachments) {
|
||||
* @param {object} args.emitter createStreamEmitter(...)
|
||||
* @param {Function} [args.queryFn] inject @tencent-ai/agent-sdk query (for tests)
|
||||
*/
|
||||
async function runCodebuddyTurn({ prompt, attachments, options, emitter, queryFn }) {
|
||||
async function runCodebuddyTurn({ prompt, attachments, options, emitter, queryFn, assertSessionValid }) {
|
||||
if (options.abortController?.signal?.aborted) {
|
||||
emitter.emitDone();
|
||||
return { sessionId: null };
|
||||
@@ -585,6 +585,7 @@ async function runCodebuddyTurn({ prompt, attachments, options, emitter, queryFn
|
||||
let queryRef = null;
|
||||
let removeAbortListener = null;
|
||||
try {
|
||||
assertSessionValid?.();
|
||||
queryRef = query({ prompt: promptInput, options });
|
||||
const signal = options.abortController?.signal;
|
||||
const interruptQuery = () => {
|
||||
|
||||
@@ -119,7 +119,7 @@ class CodebuddySessionManager {
|
||||
* @param {string} [args.resumeSessionId] resume an existing session by ID
|
||||
* @returns {Promise<object|null>} session instance or null if V2 unavailable
|
||||
*/
|
||||
async getOrCreateSession({ sessionKey, sessionOptions, resumeSessionId }) {
|
||||
async getOrCreateSession({ sessionKey, sessionOptions, resumeSessionId, assertSessionValid }) {
|
||||
const fingerprint = computeOptionsFingerprint(sessionOptions);
|
||||
const existing = this.sessions.get(sessionKey);
|
||||
if (existing) {
|
||||
@@ -150,6 +150,8 @@ class CodebuddySessionManager {
|
||||
const resumeSession = sdk.unstable_v2_resumeSession;
|
||||
if (!createSession || !resumeSession) return null;
|
||||
|
||||
// Session options can themselves contain system/custom-agent prompts.
|
||||
assertSessionValid?.();
|
||||
let session;
|
||||
try {
|
||||
const callbackState = createSessionCallbackState(sessionOptions);
|
||||
@@ -180,7 +182,7 @@ class CodebuddySessionManager {
|
||||
*/
|
||||
async runTurn({
|
||||
sessionKey, prompt, attachments, options, emitter,
|
||||
sessionOptions, resumeSessionId,
|
||||
sessionOptions, resumeSessionId, assertSessionValid,
|
||||
}) {
|
||||
const signal = options.abortController?.signal;
|
||||
if (signal?.aborted) {
|
||||
@@ -189,7 +191,7 @@ class CodebuddySessionManager {
|
||||
}
|
||||
|
||||
const session = await this.getOrCreateSession({
|
||||
sessionKey, sessionOptions, resumeSessionId,
|
||||
sessionKey, sessionOptions, resumeSessionId, assertSessionValid,
|
||||
});
|
||||
if (!session) {
|
||||
if (signal?.aborted) {
|
||||
@@ -230,6 +232,7 @@ class CodebuddySessionManager {
|
||||
}
|
||||
}
|
||||
|
||||
assertSessionValid?.();
|
||||
try {
|
||||
// Send before the initial connection so resumed sessions suppress
|
||||
// historical replay and stream only the response to this prompt.
|
||||
@@ -238,6 +241,7 @@ class CodebuddySessionManager {
|
||||
} else {
|
||||
// Async iterable of UserMessage — send first message.
|
||||
for await (const msg of promptInput) {
|
||||
assertSessionValid?.();
|
||||
await session.send(msg);
|
||||
}
|
||||
}
|
||||
@@ -245,6 +249,8 @@ class CodebuddySessionManager {
|
||||
// Initial transport setup happens inside send(). Release any acquired
|
||||
// session lock/process before the caller falls back to legacy query().
|
||||
this.closeSession(sessionKey);
|
||||
// Do not reinterpret invalid privacy state as a transport fallback.
|
||||
assertSessionValid?.();
|
||||
if (signal?.aborted) {
|
||||
emitter.emitDone();
|
||||
return { sessionId, usedV2: true };
|
||||
|
||||
@@ -345,7 +345,7 @@ function translateCodexEvent(event, emitter, state) {
|
||||
* @param {Function} [args.CodexCtor] inject Codex class (for tests)
|
||||
*/
|
||||
async function runCodexTurn({
|
||||
prompt, attachments, constructorOptions, threadOptions, resumeThreadId, emitter, signal, CodexCtor,
|
||||
prompt, attachments, constructorOptions, threadOptions, resumeThreadId, emitter, signal, CodexCtor, assertSessionValid,
|
||||
}) {
|
||||
const Codex = CodexCtor || (await import("@openai/codex-sdk")).Codex;
|
||||
const promptInput = buildCodexPromptInput(prompt, attachments);
|
||||
@@ -357,9 +357,12 @@ async function runCodexTurn({
|
||||
? codex.resumeThread(resumeThreadId, threadOptions)
|
||||
: codex.startThread(threadOptions);
|
||||
|
||||
assertSessionValid?.();
|
||||
const { events } = await thread.runStreamed(promptInput, signal ? { signal } : undefined);
|
||||
let hasContent = false;
|
||||
const state = { reasoningOpen: false };
|
||||
// runStreamed returns a lazy iterator; its first read starts execution.
|
||||
assertSessionValid?.();
|
||||
for await (const event of events) {
|
||||
// Capture + emit the resumable thread id as EARLY as possible — it exists
|
||||
// the moment `thread.started` arrives (the first event). Emitting it only at
|
||||
|
||||
@@ -477,6 +477,7 @@ async function runCopilotTurn({
|
||||
emitter,
|
||||
signal,
|
||||
sdkModule,
|
||||
assertSessionValid,
|
||||
}) {
|
||||
let resolvedModule = sdkModule;
|
||||
if (!resolvedModule) {
|
||||
@@ -564,6 +565,7 @@ async function runCopilotTurn({
|
||||
}
|
||||
let final;
|
||||
try {
|
||||
assertSessionValid?.();
|
||||
final = await session.sendAndWait(buildCopilotMessageOptions({ prompt, attachments }));
|
||||
} finally {
|
||||
try { unsubscribe(); } catch { /* best effort */ }
|
||||
|
||||
@@ -477,6 +477,7 @@ async function runCursorCliTurn({
|
||||
workspaceCwd,
|
||||
abortGraceMs = CURSOR_CLI_ABORT_GRACE_MS,
|
||||
forceKillImpl,
|
||||
assertSessionValid,
|
||||
}) {
|
||||
const cliPath = String(binPath || "").trim();
|
||||
if (!cliPath) {
|
||||
@@ -539,6 +540,7 @@ async function runCursorCliTurn({
|
||||
};
|
||||
|
||||
try {
|
||||
assertSessionValid?.();
|
||||
child = spawnCursorCliProcess(spawnImpl, cliPath, args, {
|
||||
cwd: effectiveCwd,
|
||||
env: childEnv,
|
||||
|
||||
@@ -320,7 +320,7 @@ function runCursorTurn(options) {
|
||||
}
|
||||
|
||||
async function runCursorTurnInProcess({
|
||||
prompt, attachments, agentOptions, runtimeEnv, resumeSessionId, emitter, signal, sdkModule,
|
||||
prompt, attachments, agentOptions, runtimeEnv, resumeSessionId, emitter, signal, sdkModule, assertSessionValid, getPrompt,
|
||||
}) {
|
||||
let resolvedModule = sdkModule;
|
||||
if (!resolvedModule) {
|
||||
@@ -370,10 +370,13 @@ async function runCursorTurnInProcess({
|
||||
if (sessionId) emitter.sessionId(sessionId);
|
||||
if (signal?.aborted) return { sessionId };
|
||||
|
||||
const sendMessage = buildCursorSendMessage(prompt, attachments);
|
||||
run = await abortable(withExclusiveProcessEnv(runtimeEnv, () => {
|
||||
run = await abortable(withExclusiveProcessEnv(runtimeEnv, async () => {
|
||||
// The utility worker receives prompt content only after startup and the
|
||||
// main process has revalidated this chat at the handoff boundary.
|
||||
const input = getPrompt ? await getPrompt() : { prompt, attachments };
|
||||
if (signal?.aborted) throw new CursorTurnAbortError();
|
||||
return agent.send(sendMessage);
|
||||
assertSessionValid?.();
|
||||
return agent.send(buildCursorSendMessage(input.prompt, input.attachments));
|
||||
}), signal, (lateRun) => {
|
||||
if (lateRun && typeof lateRun.cancel === "function") {
|
||||
void lateRun.cancel().catch(() => {});
|
||||
|
||||
@@ -12,11 +12,33 @@ for (const method of [
|
||||
}
|
||||
|
||||
let started = false;
|
||||
let pendingPrompt = null;
|
||||
function getPrompt() {
|
||||
return new Promise((resolve, reject) => {
|
||||
if (controller.signal.aborted) {
|
||||
reject(new Error("Cursor turn was interrupted"));
|
||||
return;
|
||||
}
|
||||
pendingPrompt = { resolve, reject };
|
||||
process.parentPort.postMessage({ type: "prompt-request" });
|
||||
});
|
||||
}
|
||||
process.parentPort.on("message", ({ data }) => {
|
||||
if (data?.type === "abort") controller.abort();
|
||||
if (data?.type === "abort") {
|
||||
controller.abort();
|
||||
pendingPrompt?.reject(new Error("Cursor turn was interrupted"));
|
||||
pendingPrompt = null;
|
||||
}
|
||||
if (data?.type === "prompt") {
|
||||
pendingPrompt?.resolve({ prompt: data.prompt, attachments: data.attachments });
|
||||
pendingPrompt = null;
|
||||
}
|
||||
if (data?.type === "start" && !started) {
|
||||
started = true;
|
||||
void runCursorTurnInProcess({ ...data.params, emitter, signal: controller.signal }).then(
|
||||
void runCursorTurnInProcess({
|
||||
...data.params, emitter, signal: controller.signal,
|
||||
getPrompt: data.params.deferPrompt ? getPrompt : undefined,
|
||||
}).then(
|
||||
(result) => process.parentPort.postMessage({ type: "result", result }),
|
||||
(error) => process.parentPort.postMessage({ type: "error", message: error?.message || String(error) }),
|
||||
);
|
||||
|
||||
@@ -12,7 +12,11 @@ function createCursorWorker(options) {
|
||||
|
||||
// Each turn gets its own environment and SDK instance. A stalled SDK network
|
||||
// request cannot retain a main-process environment lock or block another chat.
|
||||
function runCursorWorkerTurn({ emitter, signal, ...params }, createWorker = createCursorWorker) {
|
||||
function runCursorWorkerTurn({ emitter, signal, assertSessionValid, ...params }, createWorker = createCursorWorker) {
|
||||
// Functions cannot cross the utility-process boundary. Keep semantic content
|
||||
// in main until the worker is ready to send, then validate before releasing it.
|
||||
const { prompt, attachments, ...startupParams } = params;
|
||||
const deferPrompt = typeof assertSessionValid === "function";
|
||||
let sessionId = params.resumeSessionId || null;
|
||||
if (signal?.aborted) return Promise.resolve({ sessionId });
|
||||
return new Promise((resolve) => {
|
||||
@@ -30,6 +34,11 @@ function runCursorWorkerTurn({ emitter, signal, ...params }, createWorker = crea
|
||||
clearTimeout(stopTimer);
|
||||
worker.kill();
|
||||
};
|
||||
const privacyFailure = (error) => {
|
||||
emitter.emitError(error?.message || String(error));
|
||||
finish({ sessionId });
|
||||
terminate();
|
||||
};
|
||||
const onAbort = () => {
|
||||
if (spawned) worker.postMessage({ type: "abort" });
|
||||
// Allow run.cancel()/agent.close() first, then contain a stuck startup.
|
||||
@@ -50,10 +59,25 @@ function runCursorWorkerTurn({ emitter, signal, ...params }, createWorker = crea
|
||||
worker.once("spawn", () => {
|
||||
spawned = true;
|
||||
if (settled) terminate();
|
||||
else worker.postMessage({ type: "start", params });
|
||||
else {
|
||||
try {
|
||||
assertSessionValid?.();
|
||||
worker.postMessage({ type: "start", params: deferPrompt ? { ...startupParams, deferPrompt } : params });
|
||||
} catch (error) {
|
||||
privacyFailure(error);
|
||||
}
|
||||
}
|
||||
});
|
||||
worker.on("message", (message) => {
|
||||
if (message?.type === "event") {
|
||||
if (message?.type === "prompt-request") {
|
||||
if (settled || !deferPrompt) return;
|
||||
try {
|
||||
assertSessionValid();
|
||||
worker.postMessage({ type: "prompt", prompt, attachments });
|
||||
} catch (error) {
|
||||
privacyFailure(error);
|
||||
}
|
||||
} else if (message?.type === "event") {
|
||||
if (settled) return;
|
||||
if (message.method === "sessionId") sessionId = message.args[0];
|
||||
const handler = emitter[message.method];
|
||||
|
||||
@@ -111,3 +111,52 @@ test("utility-process fatal errors retain their reason and settle only once", as
|
||||
assert.deepEqual(errors, ["FatalError: v8::Heap"]);
|
||||
assert.equal(kills, 1);
|
||||
});
|
||||
|
||||
test("Cursor worker startup withholds semantic content until main validates the prompt handoff", async () => {
|
||||
const { EventEmitter } = require("node:events");
|
||||
const { createAiPrivacyService } = require("../../../shared/aiPrivacyService.cjs");
|
||||
const privacy = createAiPrivacyService();
|
||||
privacy.checkSession("chat");
|
||||
const child = new EventEmitter();
|
||||
const sent = [];
|
||||
const errors = [];
|
||||
let killed = false;
|
||||
child.postMessage = (message) => sent.push(structuredClone(message));
|
||||
child.kill = () => { killed = true; child.emit("exit", 0); };
|
||||
const turn = runCursorWorkerTurn({
|
||||
prompt: "raw-secret", attachments: [{ data: "raw-image" }],
|
||||
emitter: { emitError: (error) => errors.push(error) },
|
||||
assertSessionValid: () => privacy.checkSession("chat"),
|
||||
}, () => child);
|
||||
child.emit("spawn");
|
||||
assert.equal(sent[0].type, "start");
|
||||
assert.equal(sent[0].params.deferPrompt, true);
|
||||
assert.equal("prompt" in sent[0].params, false);
|
||||
assert.equal("attachments" in sent[0].params, false);
|
||||
assert.equal("assertSessionValid" in sent[0].params, false);
|
||||
privacy.configure({ enabled: true });
|
||||
child.emit("message", { type: "prompt-request" });
|
||||
await turn;
|
||||
assert.equal(sent.length, 1);
|
||||
assert.equal(killed, true);
|
||||
assert.match(errors.join("\n"), /start a new chat/);
|
||||
});
|
||||
|
||||
test("Cursor releases the original prompt only on a valid post-startup request", async () => {
|
||||
const { EventEmitter } = require("node:events");
|
||||
const child = new EventEmitter();
|
||||
const sent = [];
|
||||
let checks = 0;
|
||||
child.postMessage = (message) => sent.push(structuredClone(message));
|
||||
child.kill = () => child.emit("exit", 0);
|
||||
const turn = runCursorWorkerTurn({
|
||||
prompt: "hello", attachments: [], emitter: {},
|
||||
assertSessionValid: () => { checks++; },
|
||||
}, () => child);
|
||||
child.emit("spawn");
|
||||
child.emit("message", { type: "prompt-request" });
|
||||
assert.equal(checks, 2);
|
||||
assert.deepEqual(sent[1], { type: "prompt", prompt: "hello", attachments: [] });
|
||||
child.emit("message", { type: "result", result: { sessionId: null } });
|
||||
await turn;
|
||||
});
|
||||
@@ -19,17 +19,99 @@
|
||||
* { type: 'session-id', sessionId }
|
||||
* { type: 'error', error }
|
||||
*/
|
||||
function createStreamEmitter({ safeSend, sender, requestId }) {
|
||||
const emitEvent = (event) => {
|
||||
function createStreamEmitter({ safeSend, sender, requestId, restoreValue }) {
|
||||
const pending = { "text-delta": "", "reasoning-delta": "" };
|
||||
const restore = typeof restoreValue === "function" ? restoreValue : (value) => value;
|
||||
const streamFields = { "text-delta": "textDelta", "reasoning-delta": "delta" };
|
||||
const displayFields = {
|
||||
"tool-call": ["args", "input"],
|
||||
"tool-result": ["output", "result"],
|
||||
"file-change": ["changes"],
|
||||
"web-search": ["query"],
|
||||
"plan-update": ["items"],
|
||||
warning: ["message"],
|
||||
status: ["message"],
|
||||
error: ["error"],
|
||||
hook: ["toolInput", "error", "reason", "message", "title", "compactSummary"],
|
||||
};
|
||||
const restoreQuestionSchema = (schema) => {
|
||||
if (Array.isArray(schema)) return schema.map(restoreQuestionSchema);
|
||||
if (!schema || typeof schema !== "object") return schema;
|
||||
return Object.fromEntries(Object.entries(schema).map(([key, value]) => [
|
||||
key,
|
||||
["title", "description", "enum", "enumNames"].includes(key)
|
||||
? restore(value)
|
||||
: restoreQuestionSchema(value),
|
||||
]));
|
||||
};
|
||||
const sendEvent = (event) => {
|
||||
safeSend(sender, "netcatty:ai:sdk-agent:event", { requestId, event });
|
||||
};
|
||||
// Providers may split a pseudonym anywhere, including between "[" and "N".
|
||||
// Only the unfinished suffix is retained; ordinary text streams immediately.
|
||||
const restoreDelta = (type, delta, final = false) => {
|
||||
const text = pending[type] + String(delta || "");
|
||||
let end = text.length;
|
||||
if (!final) {
|
||||
const start = text.lastIndexOf("[");
|
||||
if (start >= 0) {
|
||||
const suffix = text.slice(start);
|
||||
if ("[NC_".startsWith(suffix)
|
||||
|| (suffix.startsWith("[NC_") && !suffix.includes("]"))) {
|
||||
if (suffix.length > 128) throw new Error("AI privacy could not restore an incomplete response token.");
|
||||
end = start;
|
||||
}
|
||||
}
|
||||
}
|
||||
pending[type] = text.slice(end);
|
||||
return end ? String(restore(text.slice(0, end))) : "";
|
||||
};
|
||||
const flush = (type) => {
|
||||
if (!pending[type]) return;
|
||||
const text = restoreDelta(type, "", true);
|
||||
if (text) sendEvent({ type, [streamFields[type]]: text });
|
||||
};
|
||||
const emitEvent = (event) => {
|
||||
if (!restoreValue) return sendEvent(event);
|
||||
const field = streamFields[event.type];
|
||||
if (field) {
|
||||
const text = restoreDelta(event.type, event[field]);
|
||||
if (text) sendEvent({ ...event, [field]: text });
|
||||
return;
|
||||
}
|
||||
if (event.type === "reasoning-end") flush("reasoning-delta");
|
||||
const output = { ...event };
|
||||
for (const key of displayFields[event.type] || []) {
|
||||
if (output[key] !== undefined) output[key] = restore(output[key]);
|
||||
}
|
||||
if (event.type === "elicitation-create" && event.request) {
|
||||
output.request = { ...event.request };
|
||||
if (event.request.message !== undefined) output.request.message = restore(event.request.message);
|
||||
if (event.request.requestedSchema) {
|
||||
output.request.requestedSchema = restoreQuestionSchema(event.request.requestedSchema);
|
||||
}
|
||||
}
|
||||
sendEvent(output);
|
||||
};
|
||||
return {
|
||||
emitEvent,
|
||||
emitDone() {
|
||||
if (restoreValue) {
|
||||
flush("text-delta");
|
||||
flush("reasoning-delta");
|
||||
}
|
||||
safeSend(sender, "netcatty:ai:sdk-agent:done", { requestId });
|
||||
},
|
||||
emitError(error) {
|
||||
safeSend(sender, "netcatty:ai:sdk-agent:error", { requestId, error });
|
||||
// Errors can follow an aborted partial token. Never emit that suffix, and
|
||||
// never let a failed restoration recursively prevent error delivery.
|
||||
pending["text-delta"] = "";
|
||||
pending["reasoning-delta"] = "";
|
||||
let restored;
|
||||
try { restored = restore(error); } catch {
|
||||
restored = "AI privacy could not restore the response. Start a new conversation.";
|
||||
}
|
||||
safeSend(sender, "netcatty:ai:sdk-agent:error", { requestId, error: restored });
|
||||
},
|
||||
text(textDelta) {
|
||||
if (textDelta) emitEvent({ type: "text-delta", textDelta });
|
||||
|
||||
@@ -58,3 +58,92 @@ test("convenience helpers emit the canonical event shapes", () => {
|
||||
assert.equal(calls[7].payload.event.totalTokens, 15);
|
||||
assert.equal(calls[9].payload.event.sessionId, "sess-9");
|
||||
});
|
||||
|
||||
const PRIVATE_TOKEN = "[NC_aabbccddeeff_SECRET_1]";
|
||||
function restoreTestValue(value) {
|
||||
if (typeof value === "string") {
|
||||
return value.replace(/\[NC_[^\]]*(?:\]|$)/g, (token) => {
|
||||
if (token !== PRIVATE_TOKEN) throw new Error("Unknown privacy token");
|
||||
return "private-original";
|
||||
});
|
||||
}
|
||||
if (Array.isArray(value)) return value.map(restoreTestValue);
|
||||
if (value && typeof value === "object") {
|
||||
return Object.fromEntries(Object.entries(value).map(([key, entry]) => [key, restoreTestValue(entry)]));
|
||||
}
|
||||
return value;
|
||||
}
|
||||
|
||||
test("privacy restoration buffers tokens at every text and reasoning split boundary", () => {
|
||||
for (const stream of ["text", "reasoning"]) {
|
||||
for (let split = 1; split < PRIVATE_TOKEN.length; split += 1) {
|
||||
const { calls, safeSend } = recordingSend();
|
||||
const emitter = createStreamEmitter({
|
||||
safeSend, sender: {}, requestId: "private", restoreValue: restoreTestValue,
|
||||
});
|
||||
emitter[stream](`Before ${PRIVATE_TOKEN.slice(0, split)}`);
|
||||
emitter[stream](`${PRIVATE_TOKEN.slice(split)} after`);
|
||||
if (stream === "reasoning") emitter.reasoningEnd();
|
||||
emitter.emitDone();
|
||||
const text = calls.map((entry) => entry.payload.event?.textDelta || entry.payload.event?.delta || "").join("");
|
||||
assert.equal(text, "Before private-original after", `${stream} split ${split}`);
|
||||
assert.doesNotMatch(JSON.stringify(calls), /\[NC_/);
|
||||
}
|
||||
}
|
||||
});
|
||||
|
||||
test("privacy restoration supports character-at-a-time tokens and preserves event routing identifiers", () => {
|
||||
const { calls, safeSend } = recordingSend();
|
||||
const emitter = createStreamEmitter({
|
||||
safeSend, sender: {}, requestId: PRIVATE_TOKEN, restoreValue: restoreTestValue,
|
||||
});
|
||||
for (const character of PRIVATE_TOKEN) emitter.emitEvent({ type: "text-delta", textDelta: character });
|
||||
emitter.toolCall(PRIVATE_TOKEN, { command: `echo ${PRIVATE_TOKEN}` }, PRIVATE_TOKEN);
|
||||
emitter.sessionId(PRIVATE_TOKEN);
|
||||
emitter.emitDone();
|
||||
const events = calls.filter((entry) => entry.payload.event).map((entry) => entry.payload.event);
|
||||
assert.equal(events[0].textDelta, "private-original");
|
||||
assert.equal(events[1].toolName, PRIVATE_TOKEN);
|
||||
assert.equal(events[1].toolCallId, PRIVATE_TOKEN);
|
||||
assert.deepEqual(events[1].args, { command: "echo private-original" });
|
||||
assert.equal(events[2].sessionId, PRIVATE_TOKEN);
|
||||
assert.ok(calls.every((entry) => entry.payload.requestId === PRIVATE_TOKEN));
|
||||
});
|
||||
|
||||
test("privacy restoration restores activity fields and never emits an unfinished token on abort", () => {
|
||||
const { calls, safeSend } = recordingSend();
|
||||
const emitter = createStreamEmitter({
|
||||
safeSend, sender: {}, requestId: "private", restoreValue: restoreTestValue,
|
||||
});
|
||||
emitter.toolResult("tool-1", { output: PRIVATE_TOKEN }, "tool");
|
||||
emitter.fileChange("file-1", [{ path: PRIVATE_TOKEN }], "completed");
|
||||
emitter.planUpdate("plan-1", [{ text: PRIVATE_TOKEN }], "running");
|
||||
emitter.webSearch("search-1", PRIVATE_TOKEN, "completed");
|
||||
emitter.warning("warning-1", PRIVATE_TOKEN);
|
||||
emitter.text("Before [NC_aabb");
|
||||
emitter.emitError("Aborted");
|
||||
assert.doesNotMatch(JSON.stringify(calls), /\[NC_/);
|
||||
assert.equal(calls.at(-1).payload.error, "Aborted");
|
||||
assert.match(JSON.stringify(calls), /private-original/);
|
||||
});
|
||||
|
||||
test("privacy restoration fails closed on unknown complete tokens and incomplete final tokens", () => {
|
||||
const { calls, safeSend } = recordingSend();
|
||||
const emitter = createStreamEmitter({
|
||||
safeSend, sender: {}, requestId: "private", restoreValue: restoreTestValue,
|
||||
});
|
||||
assert.throws(() => emitter.text("[NC_other_SECRET_1]"), /Unknown privacy token/);
|
||||
emitter.text("[NC_incomplete");
|
||||
assert.throws(() => emitter.emitDone(), /Unknown privacy token/);
|
||||
emitter.emitError("[NC_other_SECRET_1]");
|
||||
assert.equal(calls.length, 1);
|
||||
assert.match(calls[0].payload.error, /Start a new conversation/);
|
||||
assert.doesNotMatch(JSON.stringify(calls), /\[NC_/);
|
||||
});
|
||||
|
||||
test("disabled emitter does not buffer token-looking literal text", () => {
|
||||
const { calls, safeSend } = recordingSend();
|
||||
const emitter = createStreamEmitter({ safeSend, sender: {}, requestId: "plain" });
|
||||
emitter.text("[NC_");
|
||||
assert.equal(calls[0].payload.event.textDelta, "[NC_");
|
||||
});
|
||||
@@ -548,10 +548,13 @@ async function establishGrokAcpSession(rpc, {
|
||||
toolIntegrationMode,
|
||||
systemContext,
|
||||
agentCapabilities,
|
||||
assertSessionValid,
|
||||
} = {}) {
|
||||
const methods = planGrokAcpSessionEstablish({ resumeSessionId, agentCapabilities });
|
||||
let lastError = null;
|
||||
for (const method of methods) {
|
||||
// session/new carries system context; retries must recheck as well.
|
||||
assertSessionValid?.();
|
||||
try {
|
||||
if (method === "new") {
|
||||
const created = await rpc.request(
|
||||
@@ -929,6 +932,7 @@ async function runGrokAcpTurn({
|
||||
forceKillImpl,
|
||||
// inject for tests: skip real spawn
|
||||
rpcClientFactory,
|
||||
assertSessionValid,
|
||||
}) {
|
||||
const cliPath = String(binPath || "").trim();
|
||||
if (!cliPath) {
|
||||
@@ -975,6 +979,7 @@ async function runGrokAcpTurn({
|
||||
permissionMode,
|
||||
toolIntegrationMode,
|
||||
systemContext: systemPrompt,
|
||||
assertSessionValid,
|
||||
agentCapabilities: parseGrokAcpAgentCapabilities(initResult),
|
||||
});
|
||||
state.sessionId = established.sessionId;
|
||||
@@ -987,6 +992,7 @@ async function runGrokAcpTurn({
|
||||
});
|
||||
// Only accept updates for this turn's prompt (not session/load replay).
|
||||
state.acceptUpdates = true;
|
||||
assertSessionValid?.();
|
||||
const promptResult = await client.request(
|
||||
"session/prompt",
|
||||
buildGrokAcpPromptParams(state.sessionId, effectivePrompt),
|
||||
@@ -1207,6 +1213,7 @@ async function runGrokAcpTurn({
|
||||
permissionMode,
|
||||
toolIntegrationMode,
|
||||
systemContext: systemPrompt,
|
||||
assertSessionValid,
|
||||
agentCapabilities: parseGrokAcpAgentCapabilities(initResult),
|
||||
});
|
||||
state.sessionId = established.sessionId;
|
||||
@@ -1224,6 +1231,7 @@ async function runGrokAcpTurn({
|
||||
// Await the prompt RPC only (no race with promptDone). Process death
|
||||
// rejects via rejectAll on close; success sets turnCompleted + usage in
|
||||
// handleGrokAcpMessage before resolve.
|
||||
assertSessionValid?.();
|
||||
await rpc.request(
|
||||
"session/prompt",
|
||||
buildGrokAcpPromptParams(state.sessionId, effectivePrompt),
|
||||
|
||||
@@ -736,6 +736,7 @@ async function runGrokTurn({
|
||||
mergeMcp,
|
||||
abortGraceMs = GROK_ABORT_GRACE_MS,
|
||||
forceKillImpl,
|
||||
assertSessionValid,
|
||||
}) {
|
||||
const cliPath = String(binPath || "").trim();
|
||||
if (!cliPath) {
|
||||
@@ -789,6 +790,7 @@ async function runGrokTurn({
|
||||
};
|
||||
|
||||
try {
|
||||
assertSessionValid?.();
|
||||
child = spawnGrokProcess(spawnImpl, cliPath, args, {
|
||||
cwd: effectiveCwd,
|
||||
env: childEnv,
|
||||
|
||||
@@ -45,7 +45,7 @@ const DRIVER_REGISTRY = {
|
||||
resume: ctx.resumeSessionId,
|
||||
toolIntegrationMode: ctx.toolIntegrationMode,
|
||||
});
|
||||
return claude.runClaudeTurn({ prompt: ctx.prompt, attachments: ctx.attachments, options, emitter: ctx.emitter });
|
||||
return claude.runClaudeTurn({ prompt: ctx.prompt, attachments: ctx.attachments, options, emitter: ctx.emitter, assertSessionValid: ctx.assertSessionValid });
|
||||
},
|
||||
async listModels(ctx) {
|
||||
return claude.listClaudeModels({
|
||||
@@ -68,6 +68,7 @@ const DRIVER_REGISTRY = {
|
||||
const threadOptions = codex.buildCodexThreadOptions({ cwd: ctx.cwd, model: ctx.model });
|
||||
return codex.runCodexTurn({
|
||||
prompt: ctx.prompt,
|
||||
assertSessionValid: ctx.assertSessionValid,
|
||||
attachments: ctx.attachments,
|
||||
constructorOptions,
|
||||
threadOptions,
|
||||
@@ -91,6 +92,7 @@ const DRIVER_REGISTRY = {
|
||||
});
|
||||
return copilot.runCopilotTurn({
|
||||
prompt: ctx.prompt,
|
||||
assertSessionValid: ctx.assertSessionValid,
|
||||
attachments: ctx.attachments,
|
||||
clientOptions,
|
||||
sessionOptions,
|
||||
@@ -116,6 +118,7 @@ const DRIVER_REGISTRY = {
|
||||
if (authMode === "cli-login") {
|
||||
return cursorCli.runCursorCliTurn({
|
||||
prompt: ctx.prompt,
|
||||
assertSessionValid: ctx.assertSessionValid,
|
||||
binPath: ctx.cursorCliBinPath || ctx.binPath,
|
||||
cwd: ctx.cwd,
|
||||
chatSessionId: ctx.chatSessionId,
|
||||
@@ -138,6 +141,7 @@ const DRIVER_REGISTRY = {
|
||||
});
|
||||
return cursor.runCursorTurn({
|
||||
prompt: ctx.prompt,
|
||||
assertSessionValid: ctx.assertSessionValid,
|
||||
attachments: ctx.attachments,
|
||||
agentOptions,
|
||||
runtimeEnv: ctx.env,
|
||||
@@ -251,6 +255,7 @@ const DRIVER_REGISTRY = {
|
||||
const v2Result = await codebuddySessionManager.runTurn({
|
||||
sessionKey,
|
||||
prompt: ctx.prompt,
|
||||
assertSessionValid: ctx.assertSessionValid,
|
||||
attachments: ctx.attachments,
|
||||
options,
|
||||
emitter: ctx.emitter,
|
||||
@@ -267,6 +272,7 @@ const DRIVER_REGISTRY = {
|
||||
// Fallback: legacy query() per-turn (supports all Options fields).
|
||||
return codebuddy.runCodebuddyTurn({
|
||||
prompt: ctx.prompt,
|
||||
assertSessionValid: ctx.assertSessionValid,
|
||||
attachments: ctx.attachments,
|
||||
options,
|
||||
emitter: ctx.emitter,
|
||||
@@ -282,6 +288,7 @@ const DRIVER_REGISTRY = {
|
||||
return codebuddySessionManager.steer({
|
||||
sessionKey,
|
||||
prompt: ctx.prompt,
|
||||
assertSessionValid: ctx.assertSessionValid,
|
||||
attachments: ctx.attachments,
|
||||
});
|
||||
},
|
||||
@@ -298,6 +305,7 @@ const DRIVER_REGISTRY = {
|
||||
async runTurn(ctx) {
|
||||
return opencode.runOpenCodeTurn({
|
||||
prompt: ctx.prompt,
|
||||
assertSessionValid: ctx.assertSessionValid,
|
||||
systemPrompt: ctx.systemPrompt,
|
||||
attachments: ctx.attachments,
|
||||
cwd: ctx.cwd,
|
||||
@@ -337,6 +345,7 @@ const DRIVER_REGISTRY = {
|
||||
// is handled on ACP (default runtime) via historySeed + session/new.
|
||||
return grok.runGrokTurn({
|
||||
prompt: ctx.prompt,
|
||||
assertSessionValid: ctx.assertSessionValid,
|
||||
binPath: ctx.binPath,
|
||||
cwd: ctx.cwd,
|
||||
model: ctx.model,
|
||||
@@ -351,6 +360,7 @@ const DRIVER_REGISTRY = {
|
||||
}
|
||||
return grokAcp.runGrokAcpTurn({
|
||||
prompt: ctx.prompt,
|
||||
assertSessionValid: ctx.assertSessionValid,
|
||||
systemPrompt: ctx.systemPrompt,
|
||||
binPath: ctx.binPath,
|
||||
cwd: ctx.cwd,
|
||||
@@ -431,6 +441,7 @@ const DRIVER_REGISTRY = {
|
||||
async runTurn(ctx) {
|
||||
return mimo.runMimoTurn({
|
||||
prompt: ctx.prompt,
|
||||
assertSessionValid: ctx.assertSessionValid,
|
||||
systemPrompt: ctx.systemPrompt,
|
||||
attachments: ctx.attachments,
|
||||
cwd: ctx.cwd,
|
||||
|
||||
Loaded 100 of 193 files, more files were not shown because too many files have changed in this diff.
Show more
Reference in new issue
Block a user