Compare commits

..
Author SHA1 Message Date
Cursor Agentand陈大猫 62eb8cbf42 fix(ai): restore the OpenCode 2 server username
The transport-sanitizer edit replaced the opencode username constant, so a real 2.x spawn threw ReferenceError before Basic auth. Put the constant back and lock the default client username.

Co-authored-by: 陈大猫 <binaricat@users.noreply.github.com>
2026-10-20 13:31:57 +00:00
Cursor Agentand陈大猫 f56cc29afe fix(ai): keep OpenCode transport mapping to mid-stream failures
Server-authored tool and session messages are no longer rewritten just because they match a transport string. A dead reply stream still reports the localized session-failed key.

Co-authored-by: 陈大猫 <binaricat@users.noreply.github.com>
2026-10-20 13:26:02 +00:00
Cursor Agentand陈大猫 0a50705bb5 fix(ai): drop undici transport text from OpenCode 2 details
HTTP error bodies and tool failures share the same sanitizer as session errors, so fetch failed, terminated, and other side closed never reach the chat.

Co-authored-by: 陈大猫 <binaricat@users.noreply.github.com>
2026-10-20 13:22:49 +00:00
Cursor Agentand陈大猫 24253d6406 fix(ai): keep caller OpenCode config and hide transport session errors
A profile's OPENCODE_CONFIG_CONTENT is merged under Netcatty's generated settings, and a server-reported fetch failure no longer reaches the chat as raw transport text.

Co-authored-by: 陈大猫 <binaricat@users.noreply.github.com>
2026-10-20 13:17:46 +00:00
Cursor Agentand陈大猫 17b113091c fix(ai): keep the OpenCode 2 config model as a qualified string
The selected provider/model stays the original string in OPENCODE_CONFIG_CONTENT. Session create still sends Model.Ref with id and providerID.

Co-authored-by: 陈大猫 <binaricat@users.noreply.github.com>
2026-10-20 13:10:28 +00:00
Cursor Agentand陈大猫 e47378614a fix(ai): ignore Bun version banners and hide undici transport errors
A crashed Bun binary was cached as OpenCode 1.x, and fetch or JSON-body failures still showed raw undici text. Skip Bun and Node.js runtime versions, and map those transport errors to the existing unavailable message.

Co-authored-by: 陈大猫 <binaricat@users.noreply.github.com>
2026-10-20 13:02:03 +00:00
Cursor Agentand陈大猫 3a190e6a9e fix(ai): localize OpenCode 2 failures when the server dies mid-turn
A killed event stream was surfacing undici's raw "terminated" text. Map that disconnect, a closed socket, and an SSE stream that ends before the turn finishes to the existing session-failed message.

Co-authored-by: 陈大猫 <binaricat@users.noreply.github.com>
2026-10-20 12:54:18 +00:00
Cursor Agentand陈大猫 c9e22779a1 fix(ai): refetch the OpenCode catalog when the settlement budget elapses during a read
A catalog read that outlasts the quiet-window budget still has to fetch the snapshot announced by an in-flight model or provider update.

Co-authored-by: 陈大猫 <binaricat@users.noreply.github.com>
2026-10-20 12:28:43 +00:00
Cursor Agentand陈大猫 7c278f1c55 fix(ai): refetch the OpenCode catalog when it changes during the first read
An update that arrives while the initial model list is in flight belongs to a newer snapshot. Record the event count before that request and fetch again when it moves.

Co-authored-by: 陈大猫 <binaricat@users.noreply.github.com>
2026-10-20 12:20:55 +00:00
Cursor Agentand陈大猫 0ce1eb88be fix(ai): refresh OpenCode version and catalog after startup races
Key the version cache by the resolved PATH binary and its mtime, so an in-place upgrade is not stuck on the previous major. While listing 2.x models, refetch after model.updated or provider.updated until the snapshot stays quiet.

Co-authored-by: 陈大猫 <binaricat@users.noreply.github.com>
2026-10-20 12:14:52 +00:00
Cursor Agentand陈大猫 ceea6f2758 fix(ai): post OpenCode 2 prompts after the event stream is open
The live /api/event stream does not replay. Wait until the server accepts the subscriber and start reading it before sending the user prompt.

Co-authored-by: 陈大猫 <binaricat@users.noreply.github.com>
2026-10-20 12:06:15 +00:00
Cursor Agentand陈大猫 6d911712a6 fix(ai): select OpenCode 2 catalog ids and keep context optional
Aliases expose provider/catalog id rather than the provider API model id. A rejected instruction entry warns and still sends the user prompt. The entry value stays the system-prompt string, which is valid JSON for that endpoint.

Co-authored-by: 陈大猫 <binaricat@users.noreply.github.com>
2026-10-20 11:59:28 +00:00
Cursor Agentand陈大猫 69a8b1a0a2 fix(ai): stop the OpenCode 2 event stream when a prompt is rejected
A rejected prompt does not emit a terminal SSE event. Abort that stream and close the server before waiting, and keep HTTP "not found" responses from being reported as a missing CLI.

Co-authored-by: 陈大猫 <binaricat@users.noreply.github.com>
2026-10-20 11:52:13 +00:00
Cursor Agentand陈大猫 24f390c332 feat(ai): support OpenCode 2.x while keeping the 1.x driver
Detect the installed opencode major version. 1.x stays on the bundled SDK and the old startup banner. 2.x uses the new serve banner, HTTP Basic auth, and the session API so model lists and chat turns work.

Fixes #3799

Co-authored-by: 陈大猫 <binaricat@users.noreply.github.com>
2026-10-20 11:46:15 +00:00
189 changed files with 525 additions and 13698 deletions

No files matched your search

-6
View File
@@ -9,12 +9,6 @@
<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&amp;utm_medium=badge&amp;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.
-16
View File
@@ -384,22 +384,6 @@ 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',
+2 -3
View File
@@ -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': 'Container list refresh',
'settings.terminal.systemManager.dockerListRefreshInterval.desc': 'How often to refresh container and pod lists.',
'settings.terminal.systemManager.dockerListRefreshInterval': 'Docker container list refresh',
'settings.terminal.systemManager.dockerListRefreshInterval.desc': 'How often to refresh the Docker container list.',
'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,7 +1180,6 @@ 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',
+1 -36
View File
@@ -26,11 +26,6 @@ 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',
@@ -137,7 +132,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': 'No container runtime (Docker, Podman, nerdctl, or kubectl) was detected on this host',
'systemManager.docker.unavailable': 'Docker is not available 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',
@@ -199,36 +194,6 @@ 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…',
-16
View File
@@ -367,22 +367,6 @@ 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',
+2 -3
View File
@@ -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',
'settings.terminal.systemManager.dockerListRefreshInterval.desc': 'Con qué frecuencia actualizar las listas de contenedores y pods.',
'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.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,7 +1139,6 @@ 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',
+1 -19
View File
@@ -26,8 +26,6 @@ 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',
@@ -134,7 +132,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': 'No se detectó ningún runtime de contenedores (Docker, Podman, nerdctl o kubectl) en este host',
'systemManager.docker.unavailable': 'Docker no está disponible 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',
@@ -196,22 +194,6 @@ 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…',
@@ -1,23 +0,0 @@
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`,
);
}
});
-16
View File
@@ -323,22 +323,6 @@ 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',
+2 -2
View File
@@ -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': 'Обновление списка контейнеров',
'settings.terminal.systemManager.dockerListRefreshInterval.desc': 'Как часто обновлять списки контейнеров и подов.',
'settings.terminal.systemManager.dockerListRefreshInterval': 'Обновление списка контейнеров Docker',
'settings.terminal.systemManager.dockerListRefreshInterval.desc': 'Как часто обновлять список контейнеров Docker.',
'settings.terminal.systemManager.dockerStatsRefreshInterval': 'Обновление статистики Docker',
'settings.terminal.systemManager.dockerStatsRefreshInterval.desc': 'Как часто обновлять CPU/память/сеть контейнеров Docker.',
'settings.terminal.serverStats.show': 'Показывать статистику сервера',
+1 -19
View File
@@ -26,8 +26,6 @@ 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': 'Не удалось загрузить слушающие порты',
@@ -134,7 +132,7 @@ export const ruSystemManagerMessages: Messages = {
'systemManager.tmux.newWindowPlaceholder': 'Имя окна (необязательно)',
'systemManager.tmux.noWindows': 'Нет окон',
'systemManager.tmux.unavailable': 'tmux недоступен на этом хосте',
'systemManager.docker.unavailable': 'На этом хосте не обнаружена среда контейнеров (Docker, Podman, nerdctl или kubectl)',
'systemManager.docker.unavailable': 'Docker недоступен на этом хосте',
'systemManager.tmux.windowsMismatch': 'В сессии указано {{count}} окон, но list-windows ничего не вернул',
'systemManager.tmux.lastCommand': 'последняя команда: {{command}}',
'systemManager.tmux.noPanes': 'Нет панелей',
@@ -196,22 +194,6 @@ 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': 'Загрузка слушающих портов…',
-1
View File
@@ -131,7 +131,6 @@ 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': 'Сохраняйте хосты, чтобы быстро подключаться к серверам, виртуальным машинам и контейнерам.',
-16
View File
@@ -384,22 +384,6 @@ 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',
-1
View File
@@ -785,7 +785,6 @@ 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,11 +26,6 @@ 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': '加载监听端口失败',
@@ -137,7 +132,7 @@ export const zhCnSystemManagerMessages: Messages = {
'systemManager.tmux.newWindowPlaceholder': '窗口名称(可选)',
'systemManager.tmux.noWindows': '没有窗口',
'systemManager.tmux.unavailable': '此主机未检测到 tmux',
'systemManager.docker.unavailable': '此主机未检测到容器运行时(Docker、Podman、nerdctl 或 kubectl)',
'systemManager.docker.unavailable': '此主机未检测到 Docker',
'systemManager.tmux.windowsMismatch': '会话显示有 {{count}} 个窗口,但 list-windows 未返回任何窗口',
'systemManager.tmux.lastCommand': '最后执行的命令:{{command}}',
'systemManager.tmux.noPanes': '没有面板',
@@ -199,36 +194,6 @@ 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': '正在加载监听端口…',
+2 -2
View File
@@ -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': '容器列表刷新间隔',
'settings.terminal.systemManager.dockerListRefreshInterval.desc': '容器与 Pod 列表的刷新频率。',
'settings.terminal.systemManager.dockerListRefreshInterval': 'Docker 容器列表刷新间隔',
'settings.terminal.systemManager.dockerListRefreshInterval.desc': 'Docker 容器列表的刷新频率。',
'settings.terminal.systemManager.dockerStatsRefreshInterval': 'Docker 性能数据刷新间隔',
'settings.terminal.systemManager.dockerStatsRefreshInterval.desc': 'Docker 容器 CPU/内存/网络指标的刷新频率。',
-16
View File
@@ -374,22 +374,6 @@ 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',
-1
View File
@@ -787,7 +787,6 @@ 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,11 +26,6 @@ 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': '載入監聽連接埠失敗',
@@ -137,7 +132,7 @@ export const zhTwSystemManagerMessages: Messages = {
'systemManager.tmux.newWindowPlaceholder': '視窗名稱(可選)',
'systemManager.tmux.noWindows': '沒有視窗',
'systemManager.tmux.unavailable': '此主機未偵測到 tmux',
'systemManager.docker.unavailable': '此主機未偵測到容器執行環境(Docker、Podman、nerdctl 或 kubectl)',
'systemManager.docker.unavailable': '此主機未偵測到 Docker',
'systemManager.tmux.windowsMismatch': '工作階段顯示有 {{count}} 個視窗,但 list-windows 未傳回任何視窗',
'systemManager.tmux.lastCommand': '最後執行的指令:{{command}}',
'systemManager.tmux.noPanes': '沒有窗格',
@@ -199,36 +194,6 @@ 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': '正在載入監聽連接埠…',
+2 -2
View File
@@ -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': '容器列表重新整理間隔',
'settings.terminal.systemManager.dockerListRefreshInterval.desc': '容器與 Pod 列表的重新整理頻率。',
'settings.terminal.systemManager.dockerListRefreshInterval': 'Docker 容器列表重新整理間隔',
'settings.terminal.systemManager.dockerListRefreshInterval.desc': 'Docker 容器列表的重新整理頻率。',
'settings.terminal.systemManager.dockerStatsRefreshInterval': 'Docker 效能資料重新整理間隔',
'settings.terminal.systemManager.dockerStatsRefreshInterval.desc': 'Docker 容器 CPU/記憶體/網路指標的重新整理頻率。',
@@ -1,21 +1,10 @@
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 () => {
@@ -72,92 +61,6 @@ 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,7 +30,6 @@ import {
isMissingDirectoryReplacePathError,
promoteDirectoryReplaceStage as promoteDirectoryReplacePaths,
} from "./directoryReplacePromotion";
import { unwrapSftpIpcError } from "./errors";
import { sftpTransferCenterStore } from "../sftpTransferCenterStore";
export interface DedicatedResumeDeps {
@@ -180,11 +179,9 @@ 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, hostKeyUnattended: true as const }
: { ...credentials, hostKeyUnattended: true as const };
? { ...credentials, reuseTransport: false as const }
: credentials;
// The main-process auth driver owns method ordering, fallback, MFA and
// retry. A renderer-side key-then-password retry performs a second full SSH
@@ -743,7 +740,7 @@ async function resumeSingleFileWithDedicatedSession(
const err = error as Error & { dedicatedAttention?: boolean; resetCheckpoint?: boolean };
return {
success: false,
error: unwrapSftpIpcError(error),
error: err instanceof Error ? err.message : String(error),
needsAttention: !!err.dedicatedAttention,
resetCheckpoint: !!err.resetCheckpoint,
};
@@ -38,20 +38,6 @@ 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(
-3
View File
@@ -6,9 +6,6 @@ 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();
+3 -4
View File
@@ -4,7 +4,6 @@ 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";
@@ -958,7 +957,7 @@ export const useSftpConnections = ({
if (!isTargetConnectionAtPath(startPath)) return;
updateTargetTab((prev) => ({
...prev,
error: unwrapSftpIpcError(err) || "Failed to list directory",
error: err instanceof Error ? err.message : "Failed to list directory",
loading: false,
reconnecting: false,
}));
@@ -1264,14 +1263,14 @@ export const useSftpConnections = ({
? {
...prev.connection,
status: "error",
error: unwrapSftpIpcError(err) || "Connection failed",
error: err instanceof Error ? err.message : "Connection failed",
}
: null,
files: isReconnectAttempt ? [] : prev.files,
selectedFiles: isReconnectAttempt ? new Set<string>() : prev.selectedFiles,
error: isReconnectAttempt
? "sftp.error.reconnectFailed"
: (unwrapSftpIpcError(err) || "Connection failed"),
: (err instanceof Error ? err.message : "Connection failed"),
loading: false,
reconnecting: false,
}));
@@ -56,166 +56,6 @@ 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;
+12 -30
View File
@@ -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, unwrapSftpIpcError } from "./errors";
import { isMissingStatError } from "./errors";
import { useSftpDirectoryTransferOps } from "./transferDirectoryOps";
import { useSftpTransferConflictOps } from "./transferConflictOps";
import { useSftpTransferTaskOps } from "./transferTaskOps";
@@ -191,21 +191,6 @@ 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;
@@ -483,7 +468,7 @@ export const useSftpTransfers = ({
setTransfers,
listLocalFiles,
listRemoteFiles,
acquireTransferSession: acquireTransferSession ? acquireTransferSessionLive : undefined,
acquireTransferSession,
});
const processTransfer = async (
@@ -617,9 +602,8 @@ 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;
const acquireSourceSession = acquireTransferSessionRef.current;
if (acquireSourceSession && sourceHostId) {
sourceWorkLease = await acquireSourceSession(sourceHostId, `${task.id}:work-source`);
if (acquireTransferSession && sourceHostId) {
sourceWorkLease = await acquireTransferSession(sourceHostId, `${task.id}:work-source`);
sourceSftpId = sourceWorkLease.sftpId;
}
if (!sourceSftpId) {
@@ -631,9 +615,8 @@ export const useSftpTransfers = ({
if (!targetPane.connection?.isLocal) {
const targetHostId = task.targetHostId || targetPane.connection?.hostId;
const acquireTargetSession = acquireTransferSessionRef.current;
if (acquireTargetSession && targetHostId) {
targetWorkLease = await acquireTargetSession(targetHostId, `${task.id}:work-target`);
if (acquireTransferSession && targetHostId) {
targetWorkLease = await acquireTransferSession(targetHostId, `${task.id}:work-target`);
targetSftpId = targetWorkLease.sftpId;
}
if (!targetSftpId) {
@@ -1139,7 +1122,7 @@ export const useSftpTransfers = ({
updateTask({
status: "failed",
error: unwrapSftpIpcError(err) || "Transfer failed",
error: err instanceof Error ? err.message : "Transfer failed",
endTime: Date.now(),
speed: 0,
});
@@ -1524,8 +1507,8 @@ export const useSftpTransfers = ({
try {
const result = await retryExternalDragDropFileUpload(task, {
getBrowseSftpId: (connectionId) => sftpSessionsRef.current.get(connectionId),
acquireTransferSession: acquireTransferSessionRef.current
? (hostId, id) => acquireTransferSessionLive(hostId, id)
acquireTransferSession: acquireTransferSession
? (hostId, id) => acquireTransferSession(hostId, id)
: undefined,
startStreamTransfer: async (options) => {
const bridge = netcattyBridge.get();
@@ -2017,9 +2000,8 @@ export const useSftpTransfers = ({
let sourceWorkLease: TransferConnectionLease | null = null;
let workingSourceSftpId = params.sftpId;
try {
const acquireDownloadSession = acquireTransferSessionRef.current;
if (acquireDownloadSession && params.sourceHostId) {
sourceWorkLease = await acquireDownloadSession(
if (acquireTransferSession && params.sourceHostId) {
sourceWorkLease = await acquireTransferSession(
params.sourceHostId,
`${task.id}:work-source`,
);
@@ -2085,7 +2067,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 = unwrapSftpIpcError(err);
const errMsg = err instanceof Error ? err.message : String(err);
patchDownload({
status: isCancelled ? "cancelled" : "failed",
error: isCancelled ? undefined : errMsg,
@@ -1,39 +0,0 @@
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);
}
@@ -1,225 +0,0 @@
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, 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 { STORAGE_KEY_AI_SESSION_IDLE_TIMEOUT_MINUTES } from '../../infrastructure/config/storageKeys';
import { localStorageAdapter } from '../../infrastructure/persistence/localStorageAdapter';
import {
readExternalMcpHttpAllowCustomBind,
@@ -16,10 +16,6 @@ import {
} from "./useExternalMcpToggleState.ts";
import {
EXTERNAL_MCP_RUNTIME_STATUS_POLL_MS,
applyAIPrivacySettings,
readAIPrivacySettings,
applyExternalMcpRedactionSettings,
readExternalMcpRedactionSettings,
bumpExternalMcpEnableGenerationForTests,
createExternalMcpStartupSyncPlan,
getExternalMcpEnableGenerationForTests,
@@ -120,12 +116,11 @@ 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');
await syncExternalMcpConfig(bridge);
syncExternalMcpConfig(bridge);
assert.deepEqual(received, [expected, expected]);
assert.equal(manager.getTimeoutMinutes(), expected, 'config sync keeps the intended timeout');
assert.equal(
@@ -457,334 +452,3 @@ 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();
}
});
});
+28 -234
View File
@@ -10,10 +10,8 @@ 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';
@@ -41,7 +39,6 @@ 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;
@@ -79,7 +76,6 @@ export function resetExternalMcpStartupReadyForTests(): void {
externalMcpStartupReadyWaitPromise = null;
externalMcpStartupReadyWaitResolve = null;
externalMcpEnableGeneration = 0;
externalMcpConfigError = null;
externalMcpStartupSyncPromise = null;
}
@@ -114,138 +110,6 @@ 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;
@@ -255,11 +119,9 @@ export type ExternalMcpHttpSettings = {
};
type ExternalMcpConfig = {
mode?: ExternalMcpMode;
idleTimeoutMinutes?: number;
sessionIdleTimeoutMinutes?: number;
/** null invalidates shared privacy when persisted settings cannot be read safely. */
redaction?: AIPrivacySettings | null;
mode: ExternalMcpMode;
idleTimeoutMinutes: number;
sessionIdleTimeoutMinutes: number;
httpEnabled?: boolean;
httpPort?: number;
httpAllowCustomBind?: boolean;
@@ -284,7 +146,6 @@ export type ExternalMcpStartupSyncPlan = {
runtimeEnabled: boolean;
storedEnabled: boolean;
shouldPersistStoredEnabled: boolean;
configError?: string;
};
export function normalizeExternalMcpMode(value: string | null): ExternalMcpMode {
@@ -524,7 +385,6 @@ export function createExternalMcpStartupSyncPlan({
mode,
idleTimeoutMinutes,
sessionIdleTimeoutMinutes,
redaction = defaultAIPrivacySettings(),
http = {
enabled: false,
port: DEFAULT_EXTERNAL_MCP_HTTP_PORT,
@@ -538,7 +398,6 @@ export function createExternalMcpStartupSyncPlan({
idleTimeoutMinutes: number;
sessionIdleTimeoutMinutes: number;
http?: ExternalMcpHttpSettings;
redaction?: AIPrivacySettings;
}): ExternalMcpStartupSyncPlan {
const runtimeEnabled = shouldStartExternalMcpOnStartup({ enabled, mode });
const storedEnabled = runtimeEnabled;
@@ -547,7 +406,6 @@ export function createExternalMcpStartupSyncPlan({
mode,
idleTimeoutMinutes,
sessionIdleTimeoutMinutes,
redaction,
...httpConfigFromSettings(http),
},
runtimeEnabled,
@@ -557,44 +415,23 @@ export function createExternalMcpStartupSyncPlan({
}
export function readExternalMcpStartupSyncPlan(): ExternalMcpStartupSyncPlan {
let redaction = defaultAIPrivacySettings();
let configError: string | undefined;
try {
redaction = readAIPrivacySettings();
} catch (error) {
configError = error instanceof Error ? error.message : String(error);
}
const plan = createExternalMcpStartupSyncPlan({
return createExternalMcpStartupSyncPlan({
enabled: readExternalMcpStoredEnabled(),
mode: readExternalMcpMode(),
idleTimeoutMinutes: readExternalMcpIdleTimeoutMinutes(),
sessionIdleTimeoutMinutes: readSessionIdleTimeoutMinutes(),
http: readExternalMcpHttpSettings(),
redaction,
});
return configError ? { ...plan, runtimeEnabled: false, configError } : plan;
}
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.');
}
export function syncExternalMcpConfig(bridge: ExternalMcpBridge | undefined = netcattyBridge.get()): void {
const http = readExternalMcpHttpSettings();
void bridge?.externalMcpSetConfig?.({
mode: readExternalMcpMode(),
idleTimeoutMinutes: readExternalMcpIdleTimeoutMinutes(),
sessionIdleTimeoutMinutes: readSessionIdleTimeoutMinutes(),
...httpConfigFromSettings(http),
});
}
/**
@@ -616,18 +453,10 @@ export async function syncExternalMcpStartupState(
} catch {
// Permission sync is best-effort; the main process keeps its safe default.
}
let configError = initialPlan.configError;
try {
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);
await Promise.resolve(bridge?.externalMcpSetConfig?.(initialPlan.config));
} catch {
// Config sync is best-effort; continue with enable/disable reconcile.
}
// A user toggle during config await invalidates this startup apply.
@@ -636,18 +465,6 @@ 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);
@@ -699,47 +516,24 @@ export function useExternalMcpToggleState() {
emitAIStateChanged(STORAGE_KEY_AI_EXTERNAL_MCP_ENABLED);
}, []);
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 {
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(() => {
emitAIStateChanged(STORAGE_KEY_AI_EXTERNAL_MCP_ENABLED);
}
});
}, [isPeerSessionWindow, persistEnabled]);
useEffect(() => {
const syncFromStorage = () => {
const nextEnabled = readExternalMcpStoredEnabled();
setEnabledRaw(nextEnabled);
setError(externalMcpConfigError);
};
const handleStorage = (event: StorageEvent) => {
@@ -748,7 +542,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 && key !== STORAGE_KEY_AI_EXTERNAL_MCP_REDACTION) return;
if (key !== STORAGE_KEY_AI_EXTERNAL_MCP_ENABLED) return;
syncFromStorage();
};
@@ -793,5 +587,5 @@ export function useExternalMcpToggleState() {
};
}, [enabled, isPeerSessionWindow, persistEnabled]);
return { enabled, setEnabled, error };
return { enabled, setEnabled };
}
+8 -42
View File
@@ -1,5 +1,4 @@
import { useCallback, useMemo } from 'react';
import type { ContainerRuntimeId } from '../../domain/systemManager/containerRuntime';
import type {
DockerContainerAction,
DockerImageManageAction,
@@ -95,27 +94,23 @@ export function useSystemManagerBackend() {
return bridge.tmuxAction(options);
}, []);
const listDockerContainers = useCallback(async (sessionId: string, runtime?: ContainerRuntimeId) => {
const listDockerContainers = useCallback(async (sessionId: string) => {
const bridge = netcattyBridge.get();
if (!bridge?.listDockerContainers) {
return { success: false as const, error: 'listDockerContainers unavailable' };
}
return bridge.listDockerContainers(sessionId, runtime);
return bridge.listDockerContainers(sessionId);
}, []);
const listDockerImages = useCallback(async (sessionId: string, runtime?: ContainerRuntimeId) => {
const listDockerImages = useCallback(async (sessionId: string) => {
const bridge = netcattyBridge.get();
if (!bridge?.listDockerImages) {
return { success: false as const, error: 'listDockerImages unavailable' };
}
return bridge.listDockerImages(sessionId, runtime);
return bridge.listDockerImages(sessionId);
}, []);
const getDockerStats = useCallback(async (options: {
sessionId: string;
ids?: string[];
runtime?: ContainerRuntimeId;
}) => {
const getDockerStats = useCallback(async (options: { sessionId: string; ids?: string[] }) => {
const bridge = netcattyBridge.get();
if (!bridge?.getDockerStats) {
return { success: false as const, error: 'getDockerStats unavailable' };
@@ -160,11 +155,7 @@ export function useSystemManagerBackend() {
return bridge.systemServiceAction(options);
}, []);
const dockerInspect = useCallback(async (options: {
sessionId: string;
containerId: string;
runtime?: ContainerRuntimeId;
}) => {
const dockerInspect = useCallback(async (options: { sessionId: string; containerId: string }) => {
const bridge = netcattyBridge.get();
if (!bridge?.dockerInspect) {
return { success: false as const, error: 'dockerInspect unavailable' };
@@ -172,11 +163,7 @@ export function useSystemManagerBackend() {
return bridge.dockerInspect(options);
}, []);
const dockerImageInspect = useCallback(async (options: {
sessionId: string;
imageId: string;
runtime?: ContainerRuntimeId;
}) => {
const dockerImageInspect = useCallback(async (options: { sessionId: string; imageId: string }) => {
const bridge = netcattyBridge.get();
if (!bridge?.dockerImageInspect) {
return { success: false as const, error: 'dockerImageInspect unavailable' };
@@ -189,7 +176,6 @@ export function useSystemManagerBackend() {
containerId: string;
action: DockerContainerAction;
newName?: string;
runtime?: ContainerRuntimeId;
}) => {
const bridge = netcattyBridge.get();
if (!bridge?.dockerAction) {
@@ -198,10 +184,7 @@ export function useSystemManagerBackend() {
return bridge.dockerAction(options);
}, []);
const dockerImageAction = useCallback(async (options: {
sessionId: string;
runtime?: ContainerRuntimeId;
} & DockerImageManageAction) => {
const dockerImageAction = useCallback(async (options: { sessionId: string } & DockerImageManageAction) => {
const bridge = netcattyBridge.get();
if (!bridge?.dockerImageAction) {
return { success: false as const, error: 'dockerImageAction unavailable' };
@@ -209,21 +192,6 @@ 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],
) => {
@@ -255,7 +223,6 @@ export function useSystemManagerBackend() {
dockerImageInspect,
dockerAction,
dockerImageAction,
kubectl,
openTerminalPopup,
}), [
probeSystemCapabilities,
@@ -278,7 +245,6 @@ export function useSystemManagerBackend() {
dockerImageInspect,
dockerAction,
dockerImageAction,
kubectl,
openTerminalPopup,
]);
}
-1
View File
@@ -235,7 +235,6 @@ 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", () => {
+6 -26
View File
@@ -17,7 +17,6 @@ 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';
@@ -26,8 +25,6 @@ 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 = (
@@ -296,7 +293,6 @@ 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;
@@ -338,7 +334,6 @@ 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;
@@ -386,7 +381,6 @@ const TreeNode: React.FC<TreeNodeProps> = ({
onEditGroup,
onDeleteGroup,
moveHostToGroup,
moveHostsToGroup,
moveGroup,
managedGroupPaths,
onUnmanageGroup,
@@ -532,14 +526,9 @@ const TreeNode: React.FC<TreeNodeProps> = ({
e.preventDefault();
e.stopPropagation();
setDragOverDropTarget?.(null);
if (handleVaultHostDropToGroup({
dataTransfer: e.dataTransfer,
groupPath: node.path,
moveHostToGroup,
moveHostsToGroup,
resetHostDragState: () => {},
})) return;
const hostId = e.dataTransfer.getData("host-id");
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);
}
@@ -609,7 +598,6 @@ const TreeNode: React.FC<TreeNodeProps> = ({
onEditGroup={onEditGroup}
onDeleteGroup={onDeleteGroup}
moveHostToGroup={moveHostToGroup}
moveHostsToGroup={moveHostsToGroup}
moveGroup={moveGroup}
managedGroupPaths={managedGroupPaths}
onUnmanageGroup={onUnmanageGroup}
@@ -786,16 +774,11 @@ const HostTreeItem: React.FC<HostTreeItemProps> = ({
? 0
: -1}
onFocus={() => onActiveTreeItemChange(`host:${host.id}`)}
draggable={isVaultHostDraggable(Boolean(isMultiSelectMode), selectedHostIds, host.id)}
draggable={!isMultiSelectMode}
onDragStart={(e) => {
const ids = beginVaultHostCardDrag({
dataTransfer: e.dataTransfer,
hostId: host.id,
isMultiSelectMode: Boolean(isMultiSelectMode),
selectedHostIds,
countLabel: (count) => t("vault.hosts.dragSelectionCount", { count }),
});
if (!ids) e.preventDefault();
// copyMove: vault reorder uses move; focus-sidebar append uses copy.
e.dataTransfer.effectAllowed = "copyMove";
e.dataTransfer.setData("host-id", host.id);
}}
onClick={() => {
const action = resolveHostActivateAction({
@@ -926,7 +909,6 @@ const HostTreeViewInner: React.FC<HostTreeViewProps> = ({
onEditGroup,
onDeleteGroup,
moveHostToGroup,
moveHostsToGroup,
moveGroup,
managedGroupPaths,
onUnmanageGroup,
@@ -1237,7 +1219,6 @@ const HostTreeViewInner: React.FC<HostTreeViewProps> = ({
onEditGroup={onEditGroup}
onDeleteGroup={onDeleteGroup}
moveHostToGroup={moveHostToGroup}
moveHostsToGroup={moveHostsToGroup}
moveGroup={moveGroup}
managedGroupPaths={managedGroupPaths}
onUnmanageGroup={onUnmanageGroup}
@@ -1330,7 +1311,6 @@ 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
+2 -3
View File
@@ -8,7 +8,6 @@ import {
Search,
Terminal,
TerminalSquare,
Zap,
} from "lucide-react";
import React, { memo, useCallback, useEffect, useMemo, useRef, useState } from "react";
import { useI18n } from "../application/i18n/I18nProvider";
@@ -610,8 +609,8 @@ const QuickSwitcherInner: React.FC<QuickSwitcherProps> = ({
className={rowClass}
onClick={() => handleItemSelect(item)}
>
<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 className="flex h-6 w-6 shrink-0 items-center justify-center rounded text-muted-foreground">
<Terminal size={16} />
</div>
<span className="min-w-0 truncate text-sm font-medium">{item.title}</span>
</div>
-9
View File
@@ -47,15 +47,6 @@ 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,7 +51,6 @@ 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";
@@ -1298,8 +1297,6 @@ const SettingsAITab: React.FC<SettingsAITabProps> = ({
</SettingCard>
</SettingsSection>
<AIPrivacySection />
<SettingsSection anchorId="ai-external-mcp" title={t('ai.externalMcp.title')}>
<ExternalMcpCard />
</SettingsSection>
@@ -1,183 +0,0 @@
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();
}
});
@@ -1,105 +0,0 @@
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, error: configError } = useExternalMcpToggleState();
const { enabled, setEnabled } = useExternalMcpToggleState();
const [mode, setModeRaw] = useState<ExternalMcpMode>(() => readExternalMcpMode());
const [idleTimeoutMinutes, setIdleTimeoutRaw] = useState<number>(() => readExternalMcpIdleTimeoutMinutes());
const [focusOnHostOpen, setFocusOnHostOpenRaw] = useState<boolean>(() => readExternalMcpFocusOnHostOpen());
@@ -681,7 +681,8 @@ export const ExternalMcpCard: React.FC = () => {
checked={enabled}
onChange={(nextEnabled) => {
setActionMessage(null);
void setEnabled(nextEnabled).then(() => refreshStatus());
setEnabled(nextEnabled);
window.setTimeout(() => { void refreshStatus(); }, 0);
}}
/>
</div>
@@ -988,8 +989,8 @@ export const ExternalMcpCard: React.FC = () => {
{actionMessage.text}
</div>
) : null}
{configError || status?.error ? (
<div role="alert" className="text-xs text-destructive">{configError || status?.error}</div>
{status?.error ? (
<div className="text-xs text-destructive">{status.error}</div>
) : null}
</div>
);
-1
View File
@@ -177,7 +177,6 @@ export interface NetcattyAiBridge {
mode?: 'temporary' | 'persistent';
idleTimeoutMinutes?: number;
sessionIdleTimeoutMinutes?: number;
redaction?: import("../../../../application/state/useExternalMcpToggleState").ExternalMcpRedactionSettings;
httpEnabled?: boolean;
httpPort?: number;
httpAllowCustomBind?: boolean;
@@ -7,8 +7,7 @@ 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 type { ContainerRuntimeId } from '../../domain/systemManager/containerRuntime';
import { buildContainerExecShellCommand, buildContainerLogsCommand, describeRuntimeFailure } from '../../domain/systemManager/containerRuntime';
import { buildDockerExecShellCommand, buildDockerLogsCommand } from '../../domain/systemManager/dockerShell';
import { DockerContainerDetail } from './DockerContainerDetail';
import { DockerImageIcon } from './DockerImageIcon';
import { useStableListOrder, mergePollListByKey } from './listStable';
@@ -60,7 +59,6 @@ async function buildContainerPopupIcon(image: string): Promise<TerminalPopupIcon
interface DockerContainersPanelProps {
sessionId: string;
parentSession: TerminalSession;
runtime?: ContainerRuntimeId;
isVisible: boolean;
warmupEnabled?: boolean;
backend: Backend;
@@ -159,7 +157,6 @@ const DockerContainerRow = memo(function DockerContainerRow({
export const DockerContainersPanel = memo(function DockerContainersPanel({
sessionId,
parentSession,
runtime = 'docker',
isVisible,
warmupEnabled = false,
backend,
@@ -182,15 +179,15 @@ export const DockerContainersPanel = memo(function DockerContainersPanel({
setConfirmAction(null);
setPendingAction(null);
setSelectedId(null);
}, [runtime, sessionId]);
}, [sessionId]);
const containersFetcher = useCallback(async () => {
const result = await backend.listDockerContainers(sessionId, runtime);
const result = await backend.listDockerContainers(sessionId);
if (!result.success || !result.containers) {
throw new Error(describeRuntimeFailure(result, stableT, stableT('systemManager.errors.loadDocker')));
throw new Error(result.error || stableT('systemManager.errors.loadDocker'));
}
return result.containers;
}, [backend, runtime, sessionId, stableT]);
}, [backend, sessionId, stableT]);
const listIntervalMs = Math.max(3, listRefreshIntervalSec) * 1000;
const { data: containers, error, loading, refresh } = usePolling<DockerContainerInfo[]>(
@@ -198,7 +195,7 @@ export const DockerContainersPanel = memo(function DockerContainersPanel({
listIntervalMs,
isVisible || warmupEnabled,
(prev, next) => mergePollListByKey(prev, next, (c) => c.id, dockerContainerInfoEqual),
{ poll: isVisible, resetKey: `${sessionId}:${runtime}` },
{ poll: isVisible, resetKey: sessionId },
);
const matched = useMemo<DockerContainerInfo[]>(() => {
@@ -243,12 +240,12 @@ export const DockerContainersPanel = memo(function DockerContainersPanel({
);
const statsFetcher = useCallback(async () => {
if (statContainerIds.length === 0) return [];
const result = await backend.getDockerStats({ sessionId, ids: statContainerIds, runtime });
const result = await backend.getDockerStats({ sessionId, ids: statContainerIds });
if (!result.success || !result.stats) {
throw new Error(describeRuntimeFailure(result, stableT, stableT('systemManager.errors.loadDockerStats')));
throw new Error(result.error || stableT('systemManager.errors.loadDockerStats'));
}
return result.stats;
}, [backend, runtime, sessionId, stableT, statContainerIds]);
}, [backend, sessionId, stableT, statContainerIds]);
const statsIntervalMs = Math.max(2, statsRefreshIntervalSec) * 1000;
const { data: stats, loading: statsLoading } = usePolling<DockerStatInfo[]>(
@@ -269,19 +266,18 @@ export const DockerContainersPanel = memo(function DockerContainersPanel({
}, [stats]);
const getContainerInspectKey = useCallback((container: DockerContainerInfo) => (
`${sessionId}:${runtime}:${container.id}`
), [runtime, sessionId]);
`${sessionId}:${container.id}`
), [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(describeRuntimeFailure(result, stableT, stableT('systemManager.errors.actionFailed')));
throw new Error(result.error || stableT('systemManager.errors.actionFailed'));
}
return result.inspect ?? null;
}, [backend, runtime, sessionId, stableT]);
}, [backend, sessionId, stableT]);
const {
records: inspectByContainerId,
loadRecord: loadContainerInspect,
@@ -309,12 +305,9 @@ export const DockerContainersPanel = memo(function DockerContainersPanel({
}
setPendingAction({ id: containerId, action });
try {
const result = await backend.dockerAction({ sessionId, containerId, action, newName, runtime });
const result = await backend.dockerAction({ sessionId, containerId, action, newName });
if (!result.success) {
showSystemManagerError(
describeRuntimeFailure(result, t, t('systemManager.errors.actionFailed')),
t('common.error'),
);
showSystemManagerError(result.error || t('systemManager.errors.actionFailed'), t('common.error'));
return;
}
const affectedContainer = (containers ?? []).find((container) => (
@@ -339,7 +332,6 @@ export const DockerContainersPanel = memo(function DockerContainersPanel({
invalidateContainerInspectMatching,
refresh,
refreshContainerInspect,
runtime,
sessionId,
t,
]);
@@ -368,7 +360,7 @@ export const DockerContainersPanel = memo(function DockerContainersPanel({
backend,
parentSession,
`docker: ${container.name || id}`,
buildContainerExecShellCommand(runtime, id, parentSession.shellType),
buildDockerExecShellCommand(id),
{ icon: await buildContainerPopupIcon(container.image) },
);
if (!result.success) {
@@ -380,7 +372,7 @@ export const DockerContainersPanel = memo(function DockerContainersPanel({
});
showSystemManagerError(result.error || t('systemManager.errors.actionFailed'), t('common.error'));
}
}, [backend, parentSession, runtime, sessionId, t]);
}, [backend, parentSession, sessionId, t]);
const openLogs = useCallback(async (container: DockerContainerInfo) => {
const id = container.id.slice(0, 12);
@@ -395,7 +387,7 @@ export const DockerContainersPanel = memo(function DockerContainersPanel({
backend,
parentSession,
`logs: ${container.name || id}`,
buildContainerLogsCommand(runtime, id, parentSession.shellType),
buildDockerLogsCommand(id),
{ icon: await buildContainerPopupIcon(container.image) },
);
if (!result.success) {
@@ -407,7 +399,7 @@ export const DockerContainersPanel = memo(function DockerContainersPanel({
});
showSystemManagerError(result.error || t('systemManager.errors.actionFailed'), t('common.error'));
}
}, [backend, parentSession, runtime, sessionId, t]);
}, [backend, parentSession, sessionId, t]);
return (
<div className="flex flex-col flex-1 min-h-0 overflow-hidden" data-section="docker-containers">
+16 -38
View File
@@ -2,7 +2,6 @@ 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';
@@ -36,7 +35,6 @@ type PendingImageConfirm =
interface DockerImagesPanelProps {
sessionId: string;
runtime?: ContainerRuntimeId;
isVisible: boolean;
warmupEnabled?: boolean;
backend: Backend;
@@ -91,7 +89,6 @@ const DockerImageRow = memo(function DockerImageRow({
export const DockerImagesPanel = memo(function DockerImagesPanel({
sessionId,
runtime = 'docker',
isVisible,
warmupEnabled = false,
backend,
@@ -114,15 +111,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);
}, [runtime, sessionId]);
}, [sessionId]);
const imagesFetcher = useCallback(async () => {
const result = await backend.listDockerImages(sessionId, runtime);
const result = await backend.listDockerImages(sessionId);
if (!result.success || !result.images) {
throw new Error(describeRuntimeFailure(result, stableT, stableT('systemManager.errors.loadDockerImages')));
throw new Error(result.error || stableT('systemManager.errors.loadDockerImages'));
}
return result.images;
}, [backend, runtime, sessionId, stableT]);
}, [backend, sessionId, stableT]);
const listIntervalMs = Math.max(3, listRefreshIntervalSec) * 1000;
const { data: images, error, loading, refresh } = usePolling<DockerImageInfo[]>(
@@ -130,7 +127,7 @@ export const DockerImagesPanel = memo(function DockerImagesPanel({
listIntervalMs,
isVisible || warmupEnabled,
(prev, next) => mergePollListByKey(prev, next, dockerImageRowKey, dockerImageInfoEqual),
{ poll: isVisible, resetKey: `${sessionId}:${runtime}` },
{ poll: isVisible, resetKey: sessionId },
);
const filtered = useMemo(() => {
@@ -157,19 +154,18 @@ export const DockerImagesPanel = memo(function DockerImagesPanel({
const displayList = useStableListOrder(filtered, dockerImageRowKey, query, compareImages);
const getImageInspectKey = useCallback((image: DockerImageInfo) => (
`${sessionId}:${runtime}:${dockerImageRowKey(image)}`
), [runtime, sessionId]);
`${sessionId}:${dockerImageRowKey(image)}`
), [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(describeRuntimeFailure(result, stableT, stableT('systemManager.errors.actionFailed')));
throw new Error(result.error || stableT('systemManager.errors.actionFailed'));
}
return result.inspect ?? null;
}, [backend, runtime, sessionId, stableT]);
}, [backend, sessionId, stableT]);
const {
records: inspectByImageKey,
loadRecord: loadImageInspect,
@@ -188,26 +184,15 @@ 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: !tagged,
...(tagged ? { repository: image.repository, tag: image.tag } : {}),
force: image.tag === '<none>',
});
if (actionGenerationRef.current !== actionGeneration) return;
if (!result.success) {
showSystemManagerError(
describeRuntimeFailure(result, t, t('systemManager.errors.actionFailed')),
t('common.error'),
);
showSystemManagerError(result.error || t('systemManager.errors.actionFailed'), t('common.error'));
return;
}
if (selectedId === dockerImageRowKey(image)) {
@@ -220,7 +205,7 @@ export const DockerImagesPanel = memo(function DockerImagesPanel({
setActionBusy(false);
}
}
}, [backend, getImageInspectKey, invalidateImageInspect, refresh, runtime, selectedId, sessionId, t]);
}, [backend, getImageInspectKey, invalidateImageInspect, refresh, selectedId, sessionId, t]);
const handleRemove = useCallback((image: DockerImageInfo) => {
const label = image.name || image.id.slice(0, 12);
@@ -231,13 +216,10 @@ export const DockerImagesPanel = memo(function DockerImagesPanel({
const actionGeneration = actionGenerationRef.current;
setActionBusy(true);
try {
const result = await backend.dockerImageAction({ sessionId, runtime, action: 'prune', all });
const result = await backend.dockerImageAction({ sessionId, action: 'prune', all });
if (actionGenerationRef.current !== actionGeneration) return;
if (!result.success) {
showSystemManagerError(
describeRuntimeFailure(result, t, t('systemManager.errors.actionFailed')),
t('common.error'),
);
showSystemManagerError(result.error || t('systemManager.errors.actionFailed'), t('common.error'));
return;
}
await refresh();
@@ -246,7 +228,7 @@ export const DockerImagesPanel = memo(function DockerImagesPanel({
setActionBusy(false);
}
}
}, [backend, refresh, runtime, sessionId, t]);
}, [backend, refresh, sessionId, t]);
const handlePrune = useCallback((all: boolean) => {
setConfirmTarget({ kind: 'prune', all });
@@ -255,17 +237,13 @@ 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(
describeRuntimeFailure(result, t, t('systemManager.errors.actionFailed')),
t('common.error'),
);
showSystemManagerError(result.error || t('systemManager.errors.actionFailed'), t('common.error'));
return;
}
await refresh();
+17 -139
View File
@@ -1,32 +1,19 @@
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;
@@ -34,48 +21,9 @@ 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,
@@ -84,26 +32,6 @@ 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') },
@@ -112,8 +40,8 @@ export const DockerManagerTab = memo(function DockerManagerTab({
return (
<SystemPanelShell section="system-manager-docker">
<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 }) => (
<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 }) => (
<button
key={id}
type="button"
@@ -129,79 +57,29 @@ 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">
{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
<div className={cn('flex-1 min-h-0 flex flex-col', subTab !== 'containers' && 'hidden')}>
<DockerContainersPanel
sessionId={sessionId}
parentSession={parentSession}
contextName={contextName}
namespaceName={namespaceName}
scopeError={scopeError}
isVisible={isVisible}
warmupEnabled={warmupEnabled}
isVisible={isVisible && subTab === 'containers'}
warmupEnabled={warmupEnabled || (isVisible && subTab !== 'containers')}
backend={backend}
listRefreshIntervalSec={listRefreshIntervalSec}
onReloadScopes={reloadScopes}
statsRefreshIntervalSec={statsRefreshIntervalSec}
/>
)}
</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>
);
@@ -1,361 +0,0 @@
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,7 +14,6 @@ import { resolveCapabilityPanelState } from '../../domain/systemManagerPanelStat
import {
allowSystemManagerMutations,
buildSystemManagerTabs,
hasContainerSource,
shouldCollectServerStats,
} from '../../domain/systemManager/systemTarget';
@@ -285,14 +284,13 @@ 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' && !containerSourceReady) {
if (resolvedTab === 'docker' && capabilities?.hasDocker !== true) {
if (!probingRef.current) {
probingRef.current = true;
refreshCapabilities().finally(() => { probingRef.current = false; });
@@ -300,7 +298,7 @@ export const SystemManagerSidePanel = memo(function SystemManagerSidePanel({
} else if (resolvedTab === 'tmux' && capabilities?.hasTmux !== true) {
void refreshCapabilities();
}
}, [resolvedTab, capabilities, containerSourceReady, refreshCapabilities]);
}, [resolvedTab, capabilities, 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,
@@ -317,7 +315,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' || containerSourceReady) return;
if (!isVisible || resolvedTab !== 'docker' || capabilities?.hasDocker === true) return;
let cancelled = false;
let timerId: ReturnType<typeof setTimeout>;
@@ -346,7 +344,7 @@ export const SystemManagerSidePanel = memo(function SystemManagerSidePanel({
cancelled = true;
if (timerId) clearTimeout(timerId);
};
}, [isVisible, resolvedTab, containerSourceReady, capabilitiesTtlMs]);
}, [isVisible, resolvedTab, capabilities?.hasDocker, capabilitiesTtlMs]);
const selectTab = useCallback((id: SystemManagerSubTab) => {
setActiveTab(id);
@@ -400,7 +398,7 @@ export const SystemManagerSidePanel = memo(function SystemManagerSidePanel({
}
const tmuxReady = capabilities?.hasTmux === true;
const dockerReady = containerSourceReady;
const dockerReady = capabilities?.hasDocker === true;
const gpuReady = capabilities?.hasNvidiaSmi === true || capabilities?.hasNpuSmi === true;
const portsReady = (
capabilities?.hasSs === true
@@ -623,10 +621,8 @@ 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}
@@ -1,25 +0,0 @@
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,71 +93,3 @@ 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,18 +6,6 @@ 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();
@@ -74,13 +62,11 @@ 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, /}, \[runtime, sessionId\]\);/);
assert.match(containersSource, /}, \[sessionId\]\);/);
assert.match(imagesSource, /setConfirmTarget\(null\)/);
assert.match(imagesSource, /setActionBusy\(false\)/);
assert.match(imagesSource, /}, \[runtime, sessionId\]\);/);
assert.match(imagesSource, /}, \[sessionId\]\);/);
});
test("ports and services confirm dialogs reset when sessionId changes", () => {
@@ -360,36 +360,6 @@ 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",
+18 -78
View File
@@ -27,10 +27,7 @@ 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>;
@@ -57,15 +54,12 @@ 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, 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 { 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 hostClickBehavior: HostClickBehavior = hostClickBehaviorProp === 'select' ? 'select' : 'connect';
const multiSelectedGroupPaths: Set<string> = selectedGroupPaths ?? EMPTY_GROUP_PATH_SET;
const [draggingHostIds, setDraggingHostIds] = React.useState<ReadonlySet<string> | null>(null);
const [draggingHostId, setDraggingHostId] = React.useState<string | null>(null);
const draggingHostIdRef = React.useRef<string | null>(null);
const lastPreviewReorderRef = React.useRef<string | null>(null);
const prepareGridLayoutAnimation = useVaultGridLayoutAnimation(hostListScrollRef);
@@ -126,19 +120,6 @@ 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,
@@ -303,25 +284,17 @@ export function VaultHostListSection({ ctx }: { ctx: VaultHostListSectionContext
const resetHostDragState = React.useCallback(() => {
draggingHostIdRef.current = null;
setDraggingHostIds(null);
setDraggingHostId(null);
lastPreviewReorderRef.current = null;
setDragOverDropTarget(null);
}, [setDragOverDropTarget]);
const handleHostDragStart = React.useCallback((e: React.DragEvent, hostId: string) => {
const ids = beginVaultHostCardDrag({
dataTransfer: e.dataTransfer,
hostId,
isMultiSelectMode: !!isMultiSelectMode,
selectedHostIds,
countLabel: (count) => t("vault.hosts.dragSelectionCount", { count }),
});
if (!ids) {
e.preventDefault();
return;
}
// copyMove: vault reorder uses move; focus-sidebar append uses copy.
e.dataTransfer.effectAllowed = "copyMove";
e.dataTransfer.setData("host-id", hostId);
draggingHostIdRef.current = hostId;
setDraggingHostIds(new Set(ids));
setDraggingHostId(hostId);
lastPreviewReorderRef.current = null;
// Grid preview reorder can move the card into another virtual row, and rows
@@ -335,7 +308,7 @@ export function VaultHostListSection({ ctx }: { ctx: VaultHostListSectionContext
resetHostDragState();
};
sourceNode.addEventListener("dragend", handleNativeDragEnd);
}, [isMultiSelectMode, resetHostDragState, selectedHostIds, t]);
}, [resetHostDragState]);
const renderHostEditButton = (host: any, compact = false) => (
<Button
@@ -378,21 +351,14 @@ export function VaultHostListSection({ ctx }: { ctx: VaultHostListSectionContext
return <div
ref={hostListScrollRef}
className={cn(
"relative flex-1 overflow-auto px-4 pb-4 [&>:not([data-vault-marquee])~:not([data-vault-marquee])]:mt-3",
"flex-1 overflow-auto px-4 pb-4 space-y-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");
@@ -426,10 +392,6 @@ 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");
@@ -457,12 +419,7 @@ 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
&& !hasVaultDragType(e.dataTransfer, VAULT_HOST_DRAG_IDS)
) {
if (draggedHostId && targetHostId && draggedHostId !== targetHostId) {
e.preventDefault();
e.stopPropagation();
const position = getVaultDropPosition(target, e.clientX, e.clientY, viewMode === "grid");
@@ -490,19 +447,6 @@ 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">
@@ -532,7 +476,6 @@ export function VaultHostListSection({ ctx }: { ctx: VaultHostListSectionContext
setDragOverDropTarget,
moveGroup,
moveHostToGroup,
moveHostsToGroup,
resetHostDragState,
});
}}
@@ -603,7 +546,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",
draggingHostIds?.has(host.id) && "opacity-45",
draggingHostId === host.id && "opacity-45",
hostCardFocusClassName(viewMode, isHostFocusSelected(host.id)),
)
: cn(
@@ -622,7 +565,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={isVaultHostDraggable(Boolean(isMultiSelectMode), selectedHostIds, host.id)}
draggable={!isMultiSelectMode}
onDragStart={(e) => handleHostDragStart(e, host.id)}
onClick={() => {
activateHost(safeHost);
@@ -729,7 +672,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",
draggingHostIds?.has(host.id) && "opacity-45",
draggingHostId === host.id && "opacity-45",
hostCardFocusClassName(viewMode, isHostFocusSelected(host.id)),
)
: cn(
@@ -746,7 +689,7 @@ export function VaultHostListSection({ ctx }: { ctx: VaultHostListSectionContext
? selectedHostIds.has(host.id)
: undefined}
tabIndex={getHostTabIndex(host.id)}
draggable={isVaultHostDraggable(Boolean(isMultiSelectMode), selectedHostIds, host.id)}
draggable={!isMultiSelectMode}
onDragStart={(e) => handleHostDragStart(e, host.id)}
onClick={() => {
activateHost(safeHost);
@@ -851,7 +794,6 @@ export function VaultHostListSection({ ctx }: { ctx: VaultHostListSectionContext
dataTransfer: e.dataTransfer,
groupPath: selectedGroupPath,
moveHostToGroup,
moveHostsToGroup,
resetHostDragState,
})) return;
const groupPath = e.dataTransfer.getData("group-path");
@@ -932,7 +874,6 @@ export function VaultHostListSection({ ctx }: { ctx: VaultHostListSectionContext
dataTransfer: e.dataTransfer,
groupPath: node.path,
moveHostToGroup,
moveHostsToGroup,
resetHostDragState,
})) return;
const groupPath =
@@ -1042,7 +983,6 @@ export function VaultHostListSection({ ctx }: { ctx: VaultHostListSectionContext
cancelInlineGroupEdit={cancelInlineGroupEdit}
onDeleteGroup={startInlineDeleteGroup}
moveHostToGroup={moveHostToGroup}
moveHostsToGroup={moveHostsToGroup}
moveGroup={moveGroup}
managedGroupPaths={managedGroupPaths}
onUnmanageGroup={handleUnmanageGroup}
@@ -1101,7 +1041,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",
draggingHostIds?.has(host.id) && "opacity-45",
draggingHostId === host.id && "opacity-45",
hostCardFocusClassName(viewMode, isHostFocusSelected(host.id)),
)
: cn(
@@ -1118,7 +1058,7 @@ export function VaultHostListSection({ ctx }: { ctx: VaultHostListSectionContext
? selectedHostIds.has(host.id)
: undefined}
tabIndex={getHostTabIndex(host.id)}
draggable={isVaultHostDraggable(Boolean(isMultiSelectMode), selectedHostIds, host.id)}
draggable={!isMultiSelectMode}
onDragStart={(e) => handleHostDragStart(e, host.id)}
onClick={() => {
activateHost(safeHost);
@@ -1252,7 +1192,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",
draggingHostIds?.has(host.id) && "opacity-45",
draggingHostId === host.id && "opacity-45",
hostCardFocusClassName(viewMode, isHostFocusSelected(host.id)),
)
: cn(
@@ -1269,7 +1209,7 @@ export function VaultHostListSection({ ctx }: { ctx: VaultHostListSectionContext
? selectedHostIds.has(host.id)
: undefined}
tabIndex={getHostTabIndex(host.id)}
draggable={isVaultHostDraggable(Boolean(isMultiSelectMode), selectedHostIds, host.id)}
draggable={!isMultiSelectMode}
onDragStart={(e) => handleHostDragStart(e, host.id)}
onClick={() => {
activateHost(safeHost);
-3
View File
@@ -1237,7 +1237,6 @@ export function VaultViewLayout({ ctx }: { ctx: VaultViewLayoutContext }) {
managedGroupPaths,
moveGroup,
moveHostToGroup,
moveHostsToGroup,
onDeleteHost,
Pin,
pinnedHosts,
@@ -1257,12 +1256,10 @@ export function VaultViewLayout({ ctx }: { ctx: VaultViewLayoutContext }) {
setDragOverDropTarget,
setGroupDragOverDropTarget,
setIsDeleteGroupOpen,
setIsMultiSelectMode,
setIsNewFolderOpen,
setLastPinnedId,
setNewFolderName,
setSelectedGroupPath,
setSelectedHostIds,
setTargetParentPath,
shouldHideEmptyRootHostsSection,
showRecentHosts,
@@ -3,7 +3,6 @@ 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 => ({
@@ -45,37 +44,6 @@ 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 => {
-277
View File
@@ -1,277 +0,0 @@
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 };
}
-42
View File
@@ -1,42 +0,0 @@
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;
}
-52
View File
@@ -115,58 +115,6 @@ 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[] = [];
+7 -24
View File
@@ -1,10 +1,5 @@
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";
@@ -52,7 +47,6 @@ export const handleVaultRootDrop = ({
setDragOverDropTarget,
moveGroup,
moveHostToGroup,
moveHostsToGroup,
resetHostDragState,
}: {
dataTransfer: Pick<DataTransfer, "getData">;
@@ -60,20 +54,15 @@ 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 hostIds = readVaultDraggedHostIds((type) => dataTransfer.getData(type));
const hostId = dataTransfer.getData("host-id");
if (groupPath) moveGroup(groupPath, null);
if (dispatchVaultHostGroupMove({
hostIds,
groupPath: null,
moveHostToGroup,
moveHostsToGroup,
})) {
if (hostId) {
moveHostToGroup(hostId, null);
resetHostDragState();
}
};
@@ -82,23 +71,17 @@ export const handleVaultHostDropToGroup = ({
dataTransfer,
groupPath,
moveHostToGroup,
moveHostsToGroup,
resetHostDragState,
}: {
dataTransfer: Pick<DataTransfer, "getData">;
groupPath: string | null;
moveHostToGroup?: (hostId: string, targetPath: string | null) => void;
moveHostsToGroup?: (hostIds: ReadonlySet<string>, targetPath: string | null) => void;
moveHostToGroup: (hostId: string, targetPath: string | null) => void;
resetHostDragState: () => void;
}) => {
const moved = dispatchVaultHostGroupMove({
hostIds: readVaultDraggedHostIds((type) => dataTransfer.getData(type)),
groupPath,
moveHostToGroup,
moveHostsToGroup,
});
if (!moved) return false;
const hostId = dataTransfer.getData("host-id");
if (!hostId) return false;
moveHostToGroup(hostId, groupPath);
resetHostDragState();
return true;
};
-66
View File
@@ -1,66 +0,0 @@
# 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.
-43
View File
@@ -11,13 +11,6 @@ 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', () => {
@@ -206,44 +199,8 @@ 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);
+2 -9
View File
@@ -1,5 +1,4 @@
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_';
@@ -10,17 +9,11 @@ export function isNetcattyAiHistoryCommand(command: string): boolean {
}
const NETCATTY_MANAGED_STARTUP_COMMAND =
/^(?: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 && )/;
/^(?: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)))/;
/** True when a shell history line came from a Netcatty-managed terminal launch. */
export function isNetcattyManagedStartupHistoryCommand(command: string): boolean {
const text = command.trim();
return text.includes(NETCATTY_POPUP_HISTORY_MARKER)
|| NETCATTY_MANAGED_STARTUP_COMMAND.test(text)
|| NETCATTY_WINDOWS_CONTAINER_POPUP_COMMAND.test(text);
return NETCATTY_MANAGED_STARTUP_COMMAND.test(command.trim());
}
const ZSH_EXTENDED_RECORD = /^: (\d+):\d+;([\s\S]*)$/;
@@ -1,550 +0,0 @@
"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
View File
@@ -1,109 +0,0 @@
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;
@@ -1,486 +0,0 @@
"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);
});
-38
View File
@@ -1,38 +0,0 @@
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;
}
+42 -8
View File
@@ -1,18 +1,52 @@
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);
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)}`;
}
/** Interactive shell into a container — prefer bash, fall back to sh. */
export function buildDockerExecShellCommand(containerId: string): string {
return buildContainerExecShellCommand("docker", containerId);
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'`,
);
}
export function buildDockerLogsCommand(containerId: string): string {
return buildContainerLogsCommand("docker", containerId);
const safeId = sanitizeDockerContainerId(containerId);
if (!safeId) return 'echo "Invalid container id"';
return buildDockerCommandWithSudoFallback(safeId, `logs -f --tail 200 ${safeId}`);
}
-18
View File
@@ -4,7 +4,6 @@ import test from "node:test";
import {
allowSystemManagerMutations,
buildSystemManagerTabs,
hasContainerSource,
shouldCollectServerStats,
shouldShowGpuTab,
shouldShowPortsTab,
@@ -58,23 +57,6 @@ 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);
+1 -8
View File
@@ -44,19 +44,12 @@ 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 (hasContainerSource(capabilities)) return true;
if (capabilities?.hasDocker === true) return true;
// Network appliances: never show Docker from an OS guess alone.
if (isNetworkDeviceTarget(host)) return false;
return isDefiniteLinuxTarget(host, capabilities, session);
+1 -12
View File
@@ -4,12 +4,6 @@ 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). */
@@ -200,7 +194,7 @@ export type DockerContainerAction =
export type DockerImageManageAction =
| { action: 'pull'; imageRef: string }
| { action: 'rm'; imageId: string; force?: boolean; repository?: string; tag?: string }
| { action: 'rm'; imageId: string; force?: boolean }
| { action: 'prune'; all?: boolean }
| { action: 'tag'; imageId: string; repository: string; tag?: string };
@@ -227,11 +221,6 @@ 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
-111
View File
@@ -1,111 +0,0 @@
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);
});
-83
View File
@@ -1,83 +0,0 @@
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;
}
-175
View File
@@ -1,175 +0,0 @@
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']) },
);
});
-182
View File
@@ -1,182 +0,0 @@
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),
};
}
-1
View File
@@ -70,7 +70,6 @@ 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',
-2
View File
@@ -17,7 +17,6 @@ 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");
@@ -998,7 +997,6 @@ function registerHandlers(ipcMain) {
registerAgentDiscoveryHandlers(context);
registerAgentProcessHandlers(context);
registerSdkStreamHandlers(context);
registerPrivacyHandlers(context);
if (externalMcpController) {
externalMcpController.registerHandlers(ipcMain, validateSenderOrSettings);
@@ -1,28 +0,0 @@
"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,16 +6,6 @@ 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) || {};
@@ -34,7 +24,6 @@ async function proxyCattyExecToWorker({
sessionId,
command,
chatSessionId,
beforeExecute,
}) {
if (!terminalWorkerManager?.request) {
return { ok: false, error: "Session not found" };
@@ -65,7 +54,6 @@ async function proxyCattyExecToWorker({
};
try {
beforeExecute();
return await terminalWorkerManager.request("netcatty:ai:exec", {
sessionId,
command,
@@ -75,7 +63,6 @@ async function proxyCattyExecToWorker({
commandBlocklist: mcpServerBridge.getCommandBlocklist?.(),
}, {
webContentsId: event?.sender?.id,
beforeExecute,
});
} catch (err) {
return { ok: false, error: err?.message || String(err) };
@@ -85,7 +72,6 @@ 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)
@@ -96,12 +82,6 @@ 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({
@@ -111,7 +91,6 @@ function registerCattyExecHandlers(ctx) {
sessionId,
command,
chatSessionId,
beforeExecute,
});
}
@@ -141,7 +120,6 @@ 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) {
@@ -187,7 +165,6 @@ function registerCattyExecHandlers(ctx) {
chatSessionId,
});
if (!probed.ok) return probed;
beforeExecute();
const safety = mcpServerBridge.checkCommandSafetyForShell(
command,
mcpServerBridge.resolveSessionBlocklistShellKind(session),
@@ -249,7 +226,6 @@ function registerCattyExecHandlers(ctx) {
chatSessionId,
});
if (!probed.ok) return probed;
beforeExecute();
const safety = mcpServerBridge.checkCommandSafetyForShell(
command,
mcpServerBridge.resolveSessionBlocklistShellKind(session),
@@ -331,16 +307,10 @@ function registerCattyExecHandlers(ctx) {
if (!rpcMethod || typeof rpcMethod !== "string") {
return { ok: false, error: "rpcMethod is required" };
}
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." };
}
return mcpServerBridge.dispatchBuiltinRpc(rpcMethod, {
...(params || {}),
chatSessionId,
});
});
}
}
@@ -1,154 +0,0 @@
"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,7 +2,6 @@ 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 {
@@ -15,8 +14,6 @@ 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) {
@@ -58,7 +55,6 @@ test("catty AI exec proxies to the terminal worker when the real session lives i
registerCattyExecHandlers({
ipcMain,
aiPrivacyService,
validateSender: () => true,
sessions: new Map(),
terminalWorkerManager,
@@ -90,10 +86,9 @@ 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, beforeExecute: requests[0].options.beforeExecute },
options: { webContentsId: 7 },
},
]);
assert.equal(typeof requests[0].options.beforeExecute, "function");
assert.deepEqual(locks, [
["reserve", "ssh-1", "exec"],
["release", "ssh-1", "token-1"],
@@ -206,9 +206,6 @@ 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,26 +198,3 @@ 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,7 +180,6 @@ class CodexAppServerRuntime {
connectionFactory,
sendInteractionRequest,
sendInteractionCleared,
transformUserInputAnswers,
interruptRequestTimeoutMs = INTERRUPT_REQUEST_TIMEOUT_MS,
interruptGraceMs = INTERRUPT_GRACE_MS,
} = {}) {
@@ -188,7 +187,6 @@ class CodexAppServerRuntime {
this.connectionFactory = connectionFactory;
this.sendInteractionRequest = sendInteractionRequest;
this.sendInteractionCleared = sendInteractionCleared;
this.transformUserInputAnswers = transformUserInputAnswers;
this.interruptRequestTimeoutMs = interruptRequestTimeoutMs;
this.interruptGraceMs = interruptGraceMs;
this.connections = new Map();
@@ -259,7 +257,6 @@ class CodexAppServerRuntime {
emitter,
signal,
sender,
assertSessionValid,
}) {
const throwIfAborted = () => {
if (!signal?.aborted) return;
@@ -282,11 +279,9 @@ class CodexAppServerRuntime {
config: buildThreadConfig(injectedMcpServers),
};
assertSessionValid?.();
const requestOptions = { beforeSend: assertSessionValid };
const threadResult = resumeThreadId
? await connection.request("thread/resume", { threadId: resumeThreadId, ...threadParams }, undefined, requestOptions)
: await connection.request("thread/start", threadParams, undefined, requestOptions);
? await connection.request("thread/resume", { threadId: resumeThreadId, ...threadParams })
: await connection.request("thread/start", threadParams);
throwIfAborted();
const threadId = threadResult?.thread?.id || resumeThreadId;
if (!threadId) throw new Error("Codex App Server did not return a thread id");
@@ -302,7 +297,6 @@ class CodexAppServerRuntime {
emitter,
signal,
sender,
assertSessionValid,
lastError: null,
settled: false,
cancelRequested: false,
@@ -331,7 +325,6 @@ class CodexAppServerRuntime {
}
try {
assertSessionValid?.();
const turnResult = await connection.request("turn/start", {
threadId,
input: buildTurnInput(prompt, attachments),
@@ -342,7 +335,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;
@@ -395,13 +388,12 @@ 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" };
}
@@ -869,13 +861,7 @@ class CodexAppServerRuntime {
respondInteraction(interactionId, response, sender) {
const pending = this.pendingInteractions.get(interactionId);
if (sender && pending?.context?.sender && pending.context.sender !== sender) return false;
const safeResponse = pending?.kind === "user-input" && this.transformUserInputAnswers
? {
...response,
answers: this.transformUserInputAnswers(response?.answers || {}, pending.context),
}
: response;
return this.#resolveInteraction(interactionId, safeResponse);
return this.#resolveInteraction(interactionId, response);
}
/**
@@ -311,56 +311,6 @@ 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;
@@ -898,45 +848,3 @@ 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");
});
}
@@ -1,35 +0,0 @@
"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, assertSessionValid }) {
async function runClaudeTurn({ prompt, attachments, options, emitter, queryFn }) {
ensureClaudeConfig();
let query = queryFn;
if (!query) {
@@ -246,7 +246,6 @@ async function runClaudeTurn({ prompt, attachments, options, emitter, queryFn, a
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, assertSessionValid }) {
async function runCodebuddyTurn({ prompt, attachments, options, emitter, queryFn }) {
if (options.abortController?.signal?.aborted) {
emitter.emitDone();
return { sessionId: null };
@@ -585,7 +585,6 @@ 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, assertSessionValid }) {
async getOrCreateSession({ sessionKey, sessionOptions, resumeSessionId }) {
const fingerprint = computeOptionsFingerprint(sessionOptions);
const existing = this.sessions.get(sessionKey);
if (existing) {
@@ -150,8 +150,6 @@ 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);
@@ -182,7 +180,7 @@ class CodebuddySessionManager {
*/
async runTurn({
sessionKey, prompt, attachments, options, emitter,
sessionOptions, resumeSessionId, assertSessionValid,
sessionOptions, resumeSessionId,
}) {
const signal = options.abortController?.signal;
if (signal?.aborted) {
@@ -191,7 +189,7 @@ class CodebuddySessionManager {
}
const session = await this.getOrCreateSession({
sessionKey, sessionOptions, resumeSessionId, assertSessionValid,
sessionKey, sessionOptions, resumeSessionId,
});
if (!session) {
if (signal?.aborted) {
@@ -232,7 +230,6 @@ class CodebuddySessionManager {
}
}
assertSessionValid?.();
try {
// Send before the initial connection so resumed sessions suppress
// historical replay and stream only the response to this prompt.
@@ -241,7 +238,6 @@ class CodebuddySessionManager {
} else {
// Async iterable of UserMessage — send first message.
for await (const msg of promptInput) {
assertSessionValid?.();
await session.send(msg);
}
}
@@ -249,8 +245,6 @@ 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, assertSessionValid,
prompt, attachments, constructorOptions, threadOptions, resumeThreadId, emitter, signal, CodexCtor,
}) {
const Codex = CodexCtor || (await import("@openai/codex-sdk")).Codex;
const promptInput = buildCodexPromptInput(prompt, attachments);
@@ -357,12 +357,9 @@ 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,7 +477,6 @@ async function runCopilotTurn({
emitter,
signal,
sdkModule,
assertSessionValid,
}) {
let resolvedModule = sdkModule;
if (!resolvedModule) {
@@ -565,7 +564,6 @@ async function runCopilotTurn({
}
let final;
try {
assertSessionValid?.();
final = await session.sendAndWait(buildCopilotMessageOptions({ prompt, attachments }));
} finally {
try { unsubscribe(); } catch { /* best effort */ }
@@ -477,7 +477,6 @@ async function runCursorCliTurn({
workspaceCwd,
abortGraceMs = CURSOR_CLI_ABORT_GRACE_MS,
forceKillImpl,
assertSessionValid,
}) {
const cliPath = String(binPath || "").trim();
if (!cliPath) {
@@ -540,7 +539,6 @@ 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, assertSessionValid, getPrompt,
prompt, attachments, agentOptions, runtimeEnv, resumeSessionId, emitter, signal, sdkModule,
}) {
let resolvedModule = sdkModule;
if (!resolvedModule) {
@@ -370,13 +370,10 @@ async function runCursorTurnInProcess({
if (sessionId) emitter.sessionId(sessionId);
if (signal?.aborted) return { sessionId };
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 };
const sendMessage = buildCursorSendMessage(prompt, attachments);
run = await abortable(withExclusiveProcessEnv(runtimeEnv, () => {
if (signal?.aborted) throw new CursorTurnAbortError();
assertSessionValid?.();
return agent.send(buildCursorSendMessage(input.prompt, input.attachments));
return agent.send(sendMessage);
}), signal, (lateRun) => {
if (lateRun && typeof lateRun.cancel === "function") {
void lateRun.cancel().catch(() => {});
@@ -12,33 +12,11 @@ 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();
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 === "abort") controller.abort();
if (data?.type === "start" && !started) {
started = true;
void runCursorTurnInProcess({
...data.params, emitter, signal: controller.signal,
getPrompt: data.params.deferPrompt ? getPrompt : undefined,
}).then(
void runCursorTurnInProcess({ ...data.params, emitter, signal: controller.signal }).then(
(result) => process.parentPort.postMessage({ type: "result", result }),
(error) => process.parentPort.postMessage({ type: "error", message: error?.message || String(error) }),
);
@@ -12,11 +12,7 @@ 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, 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";
function runCursorWorkerTurn({ emitter, signal, ...params }, createWorker = createCursorWorker) {
let sessionId = params.resumeSessionId || null;
if (signal?.aborted) return Promise.resolve({ sessionId });
return new Promise((resolve) => {
@@ -34,11 +30,6 @@ function runCursorWorkerTurn({ emitter, signal, assertSessionValid, ...params },
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.
@@ -59,25 +50,10 @@ function runCursorWorkerTurn({ emitter, signal, assertSessionValid, ...params },
worker.once("spawn", () => {
spawned = true;
if (settled) terminate();
else {
try {
assertSessionValid?.();
worker.postMessage({ type: "start", params: deferPrompt ? { ...startupParams, deferPrompt } : params });
} catch (error) {
privacyFailure(error);
}
}
else worker.postMessage({ type: "start", params });
});
worker.on("message", (message) => {
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 (message?.type === "event") {
if (settled) return;
if (message.method === "sessionId") sessionId = message.args[0];
const handler = emitter[message.method];
@@ -111,52 +111,3 @@ 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;
});
+3 -85
View File
@@ -19,99 +19,17 @@
* { type: 'session-id', sessionId }
* { type: 'error', error }
*/
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 });
};
function createStreamEmitter({ safeSend, sender, requestId }) {
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);
safeSend(sender, "netcatty:ai:sdk-agent:event", { requestId, event });
};
return {
emitEvent,
emitDone() {
if (restoreValue) {
flush("text-delta");
flush("reasoning-delta");
}
safeSend(sender, "netcatty:ai:sdk-agent:done", { requestId });
},
emitError(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 });
safeSend(sender, "netcatty:ai:sdk-agent:error", { requestId, error });
},
text(textDelta) {
if (textDelta) emitEvent({ type: "text-delta", textDelta });
@@ -58,92 +58,3 @@ 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,13 +548,10 @@ 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(
@@ -932,7 +929,6 @@ async function runGrokAcpTurn({
forceKillImpl,
// inject for tests: skip real spawn
rpcClientFactory,
assertSessionValid,
}) {
const cliPath = String(binPath || "").trim();
if (!cliPath) {
@@ -979,7 +975,6 @@ async function runGrokAcpTurn({
permissionMode,
toolIntegrationMode,
systemContext: systemPrompt,
assertSessionValid,
agentCapabilities: parseGrokAcpAgentCapabilities(initResult),
});
state.sessionId = established.sessionId;
@@ -992,7 +987,6 @@ 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),
@@ -1213,7 +1207,6 @@ async function runGrokAcpTurn({
permissionMode,
toolIntegrationMode,
systemContext: systemPrompt,
assertSessionValid,
agentCapabilities: parseGrokAcpAgentCapabilities(initResult),
});
state.sessionId = established.sessionId;
@@ -1231,7 +1224,6 @@ 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,7 +736,6 @@ async function runGrokTurn({
mergeMcp,
abortGraceMs = GROK_ABORT_GRACE_MS,
forceKillImpl,
assertSessionValid,
}) {
const cliPath = String(binPath || "").trim();
if (!cliPath) {
@@ -790,7 +789,6 @@ async function runGrokTurn({
};
try {
assertSessionValid?.();
child = spawnGrokProcess(spawnImpl, cliPath, args, {
cwd: effectiveCwd,
env: childEnv,
+1 -12
View File
@@ -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, assertSessionValid: ctx.assertSessionValid });
return claude.runClaudeTurn({ prompt: ctx.prompt, attachments: ctx.attachments, options, emitter: ctx.emitter });
},
async listModels(ctx) {
return claude.listClaudeModels({
@@ -68,7 +68,6 @@ 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,
@@ -92,7 +91,6 @@ const DRIVER_REGISTRY = {
});
return copilot.runCopilotTurn({
prompt: ctx.prompt,
assertSessionValid: ctx.assertSessionValid,
attachments: ctx.attachments,
clientOptions,
sessionOptions,
@@ -118,7 +116,6 @@ 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,
@@ -141,7 +138,6 @@ const DRIVER_REGISTRY = {
});
return cursor.runCursorTurn({
prompt: ctx.prompt,
assertSessionValid: ctx.assertSessionValid,
attachments: ctx.attachments,
agentOptions,
runtimeEnv: ctx.env,
@@ -255,7 +251,6 @@ const DRIVER_REGISTRY = {
const v2Result = await codebuddySessionManager.runTurn({
sessionKey,
prompt: ctx.prompt,
assertSessionValid: ctx.assertSessionValid,
attachments: ctx.attachments,
options,
emitter: ctx.emitter,
@@ -272,7 +267,6 @@ 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,
@@ -288,7 +282,6 @@ const DRIVER_REGISTRY = {
return codebuddySessionManager.steer({
sessionKey,
prompt: ctx.prompt,
assertSessionValid: ctx.assertSessionValid,
attachments: ctx.attachments,
});
},
@@ -305,7 +298,6 @@ const DRIVER_REGISTRY = {
async runTurn(ctx) {
return opencode.runOpenCodeTurn({
prompt: ctx.prompt,
assertSessionValid: ctx.assertSessionValid,
systemPrompt: ctx.systemPrompt,
attachments: ctx.attachments,
cwd: ctx.cwd,
@@ -345,7 +337,6 @@ 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,
@@ -360,7 +351,6 @@ const DRIVER_REGISTRY = {
}
return grokAcp.runGrokAcpTurn({
prompt: ctx.prompt,
assertSessionValid: ctx.assertSessionValid,
systemPrompt: ctx.systemPrompt,
binPath: ctx.binPath,
cwd: ctx.cwd,
@@ -441,7 +431,6 @@ const DRIVER_REGISTRY = {
async runTurn(ctx) {
return mimo.runMimoTurn({
prompt: ctx.prompt,
assertSessionValid: ctx.assertSessionValid,
systemPrompt: ctx.systemPrompt,
attachments: ctx.attachments,
cwd: ctx.cwd,
@@ -113,32 +113,3 @@ test("Copilot registry catalog uses the configured profile environment", async (
assert.deepEqual(models, [{ id: "profile-model", name: "profile-model" }]);
assert.deepEqual(receivedEnv, env);
});
test("registry forwards the same live session guard to every prompt driver", async (t) => {
const assertSessionValid = () => {};
const cases = [
["claude", "./claudeDriver.cjs", "runClaudeTurn", {}],
["codex", "./codexDriver.cjs", "runCodexTurn", {}],
["copilot", "./copilotDriver.cjs", "runCopilotTurn", {}],
["cursor", "./cursorDriver.cjs", "runCursorTurn", {}],
["cursor", "./cursorCliDriver.cjs", "runCursorCliTurn", { cursorAuthMode: "cli-login" }],
["codebuddy", "./codebuddyDriver.cjs", "runCodebuddyTurn", { maxBudgetUsd: 1 }],
["opencode", "./opencodeDriver.cjs", "runOpenCodeTurn", {}],
["grok", "./grokAcpDriver.cjs", "runGrokAcpTurn", {}],
["grok", "./grokDriver.cjs", "runGrokTurn", { grokRuntime: "streaming-json" }],
["mimo", "./mimoDriver.cjs", "runMimoTurn", {}],
];
for (const [backend, modulePath, method, extra] of cases) {
const driver = require(modulePath);
let received;
t.mock.method(driver, method, async (context) => { received = context; return {}; });
await getDriver(backend).runTurn({
prompt: "hello", emitter: {}, env: {}, assertSessionValid, ...extra,
});
assert.equal(received.assertSessionValid, assertSessionValid, method);
}
let received;
t.mock.method(codebuddySessionManager, "runTurn", async (context) => { received = context; return {}; });
await getDriver("codebuddy").runTurn({ prompt: "hello", emitter: {}, env: {}, assertSessionValid });
assert.equal(received.assertSessionValid, assertSessionValid, "CodeBuddy V2");
});
Loaded 100 of 189 files, more files were not shown because too many files have changed in this diff. Show more