mirror of
https://github.com/binaricat/Netcatty.git
synced 2026-11-05 22:12:31 +00:00
Compare commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
89ed2b37fd | ||
|
|
faeaa238e9 | ||
|
|
5be4731c4d | ||
|
|
ebc1ac7598 | ||
|
|
570b211e71 | ||
|
|
e61992a911 | ||
|
|
0e79f2193d | ||
|
|
c27d904f23 |
No files matched your search
@@ -9,6 +9,12 @@
|
||||
<a href="https://netcatty.app"><strong>netcatty.app</strong></a>
|
||||
</p>
|
||||
|
||||
<p align="center">
|
||||
<a href="https://trendshift.io/repositories/18391?utm_source=trendshift-badge&utm_medium=badge&utm_campaign=badge-trendshift-18391" target="_blank" rel="noopener noreferrer">
|
||||
<img src="https://trendshift.io/api/badge/trendshift/repositories/18391/daily?language=TypeScript" alt="binaricat/Netcatty | Trendshift" width="250" height="55"/>
|
||||
</a>
|
||||
</p>
|
||||
|
||||
<p align="center">
|
||||
A beautiful, feature-rich SSH workspace built with Electron, React, and xterm.js.<br/>
|
||||
🔥 Built-in AI Agent · Split terminals · Vault views · SFTP workflows · Custom themes — all in one.
|
||||
|
||||
@@ -384,6 +384,22 @@ export const enAiMessages: Messages = {
|
||||
'ai.externalMcp.help.ariaLabel': 'External MCP help',
|
||||
'ai.externalMcp.security': 'Security',
|
||||
'ai.externalMcp.security.description': 'Listens on 127.0.0.1 with a rotating token, reuses AI Permission Mode for writes, and removes discovery when disabled. No OAuth — this is a local desktop bridge.',
|
||||
"ai.privacy.title": "AI privacy",
|
||||
"ai.privacy.description": "Filters known sensitive text patterns in Netcatty-managed context and tool results for Catty, integrated agents and external MCP clients. Off by default. It cannot detect every secret. Binary tool responses are blocked while enabled.",
|
||||
"ai.privacy.boundary": "Only Netcatty-managed data and tools are covered. Third-party agents' native tools, direct file access and other MCP servers remain outside this boundary.",
|
||||
"ai.privacy.enable": "Enable text redaction",
|
||||
"ai.privacy.privateIps": "Redact private IP addresses",
|
||||
"ai.privacy.privateIps.description": "Also hide private and local IP addresses in returned text.",
|
||||
"ai.privacy.cidrs": "Additional IP ranges (CIDR)",
|
||||
"ai.privacy.cidrs.description": "One IPv4 or IPv6 CIDR per line. Leave empty to use only built-in rules.",
|
||||
"ai.privacy.patterns": "Custom regular expressions",
|
||||
"ai.privacy.patterns.description": "One RE2-compatible pattern per line, without / delimiters or flags. Unsupported or unsafe patterns are rejected.",
|
||||
"ai.privacy.connectionScope": "Placeholders stay stable within one managed conversation or external MCP connection. Original values are restored only through Netcatty-managed tool calls in that same scope.",
|
||||
"ai.privacy.reconnect": "Apply changes, then start a new managed conversation and reconnect external MCP clients. Existing conversations and connections must not reuse placeholders.",
|
||||
"ai.privacy.apply": "Apply",
|
||||
"ai.privacy.applying": "Applying...",
|
||||
"ai.privacy.applied": "AI privacy settings applied. Start a new managed conversation and reconnect external MCP clients.",
|
||||
"ai.privacy.failed": "AI privacy settings were not applied.",
|
||||
'ai.externalMcp.http.title': 'Streamable HTTP',
|
||||
'ai.externalMcp.http.description': 'Serve the same External MCP tools over Streamable HTTP for clients that cannot launch the stdio CLI, such as Codex inside WSL. Off by default.',
|
||||
'ai.externalMcp.http.enable': 'Enable HTTP',
|
||||
|
||||
@@ -864,8 +864,8 @@ Highlight the focused split pane:
|
||||
'settings.terminal.systemManager.processRefreshInterval.desc': 'How often to refresh the process list in the system manager side panel.',
|
||||
'settings.terminal.systemManager.tmuxRefreshInterval': 'tmux session refresh',
|
||||
'settings.terminal.systemManager.tmuxRefreshInterval.desc': 'How often to refresh the tmux session list.',
|
||||
'settings.terminal.systemManager.dockerListRefreshInterval': 'Docker container list refresh',
|
||||
'settings.terminal.systemManager.dockerListRefreshInterval.desc': 'How often to refresh the Docker container list.',
|
||||
'settings.terminal.systemManager.dockerListRefreshInterval': 'Container list refresh',
|
||||
'settings.terminal.systemManager.dockerListRefreshInterval.desc': 'How often to refresh container and pod lists.',
|
||||
'settings.terminal.systemManager.dockerStatsRefreshInterval': 'Docker stats refresh',
|
||||
'settings.terminal.systemManager.dockerStatsRefreshInterval.desc': 'How often to refresh Docker container CPU/memory/network stats.',
|
||||
'settings.terminal.serverStats.show': 'Show Server Stats',
|
||||
|
||||
@@ -26,6 +26,11 @@ export const enSystemManagerMessages: Messages = {
|
||||
'systemManager.errors.loadDocker': 'Failed to load containers',
|
||||
'systemManager.errors.loadDockerStats': 'Failed to load container stats',
|
||||
'systemManager.errors.loadDockerImages': 'Failed to load images',
|
||||
'systemManager.errors.loadKubernetes': 'Failed to load Kubernetes resources',
|
||||
'systemManager.errors.loadPods': 'Failed to load pods',
|
||||
'systemManager.errors.nerdctlUnreachable': "nerdctl can't reach containerd",
|
||||
'systemManager.errors.clusterUnreachable': "Can't reach the cluster for context {{context}}",
|
||||
'systemManager.errors.clusterUnreachableGeneric': "Can't reach the cluster",
|
||||
'systemManager.errors.loadOverview': 'Failed to load system overview',
|
||||
'systemManager.errors.loadGpu': 'Failed to load GPU / NPU stats',
|
||||
'systemManager.errors.loadPorts': 'Failed to load listening ports',
|
||||
@@ -132,7 +137,7 @@ export const enSystemManagerMessages: Messages = {
|
||||
'systemManager.tmux.newWindowPlaceholder': 'Window name (optional)',
|
||||
'systemManager.tmux.noWindows': 'No windows',
|
||||
'systemManager.tmux.unavailable': 'tmux is not available on this host',
|
||||
'systemManager.docker.unavailable': 'Docker is not available on this host',
|
||||
'systemManager.docker.unavailable': 'No container runtime (Docker, Podman, nerdctl, or kubectl) was detected on this host',
|
||||
'systemManager.tmux.windowsMismatch': 'Session reports {{count}} window(s) but list-windows returned none',
|
||||
'systemManager.tmux.lastCommand': 'last command: {{command}}',
|
||||
'systemManager.tmux.noPanes': 'No panes',
|
||||
@@ -194,6 +199,36 @@ export const enSystemManagerMessages: Messages = {
|
||||
'systemManager.docker.imagesMeta': '{{count}} image(s)',
|
||||
'systemManager.docker.start': 'Start',
|
||||
'systemManager.docker.stop': 'Stop',
|
||||
'systemManager.runtime.switch': 'Container runtime',
|
||||
'systemManager.runtime.docker': 'Docker',
|
||||
'systemManager.runtime.podman': 'Podman',
|
||||
'systemManager.runtime.nerdctl': 'nerdctl',
|
||||
'systemManager.runtime.kubernetes': 'Kubernetes',
|
||||
'systemManager.k8s.context': 'Context',
|
||||
'systemManager.k8s.namespace': 'Namespace',
|
||||
'systemManager.k8s.empty': 'No pods found',
|
||||
'systemManager.k8s.search': 'Search pods…',
|
||||
'systemManager.k8s.meta': '{{count}} pod(s)',
|
||||
'systemManager.k8s.describe': 'Describe',
|
||||
'systemManager.k8s.delete': 'Delete pod',
|
||||
'systemManager.k8s.confirmDelete': 'Delete pod "{{name}}"?',
|
||||
'systemManager.k8s.restarts': '{{count}} restarts',
|
||||
'systemManager.k8s.chooseContainer': 'Choose a container',
|
||||
'systemManager.k8s.noContext': 'No Kubernetes context on this host',
|
||||
'systemManager.k8s.status.Running': 'Running',
|
||||
'systemManager.k8s.status.Pending': 'Pending',
|
||||
'systemManager.k8s.status.Completed': 'Completed',
|
||||
'systemManager.k8s.status.Succeeded': 'Succeeded',
|
||||
'systemManager.k8s.status.Failed': 'Failed',
|
||||
'systemManager.k8s.status.Unknown': 'Unknown',
|
||||
'systemManager.k8s.status.CrashLoopBackOff': 'CrashLoopBackOff',
|
||||
'systemManager.k8s.status.ContainerCreating': 'ContainerCreating',
|
||||
'systemManager.k8s.status.Terminating': 'Terminating',
|
||||
'systemManager.k8s.status.ImagePullBackOff': 'ImagePullBackOff',
|
||||
'systemManager.k8s.status.ErrImagePull': 'ErrImagePull',
|
||||
'systemManager.k8s.status.Error': 'Error',
|
||||
'systemManager.k8s.status.Evicted': 'Evicted',
|
||||
'systemManager.k8s.status.OOMKilled': 'OOMKilled',
|
||||
|
||||
'systemManager.ports.unavailable': 'No listening-port tools (ss / netstat) detected on this host.',
|
||||
'systemManager.ports.loading': 'Loading listening ports…',
|
||||
|
||||
@@ -367,6 +367,22 @@ export const esAiMessages: Messages = {
|
||||
'ai.externalMcp.help.ariaLabel': 'Ayuda del MCP externo',
|
||||
'ai.externalMcp.security': 'Seguridad',
|
||||
'ai.externalMcp.security.description': 'Escucha en 127.0.0.1 con un token rotativo, reutiliza el modo de permisos de IA para las escrituras y elimina el descubrimiento al desactivarse. Sin OAuth: este es un puente local de escritorio.',
|
||||
"ai.privacy.title": "Privacidad de IA",
|
||||
"ai.privacy.description": "Filtra patrones de texto sensibles conocidos en el contexto y los resultados de herramientas gestionados por Netcatty para Catty, agentes integrados y clientes MCP externos. Desactivado de forma predeterminada. No detecta todos los secretos. Las respuestas binarias de herramientas se bloquean mientras está activo.",
|
||||
"ai.privacy.boundary": "Solo cubre los datos y las herramientas gestionados por Netcatty. Las herramientas nativas de agentes de terceros, su acceso directo a archivos y otros servidores MCP quedan fuera de esta protección.",
|
||||
"ai.privacy.enable": "Activar ocultación de texto",
|
||||
"ai.privacy.privateIps": "Ocultar direcciones IP privadas",
|
||||
"ai.privacy.privateIps.description": "Oculta también las direcciones IP privadas y locales en el texto devuelto.",
|
||||
"ai.privacy.cidrs": "Rangos IP adicionales (CIDR)",
|
||||
"ai.privacy.cidrs.description": "Un CIDR IPv4 o IPv6 por línea. Déjalo vacío para usar solo las reglas integradas.",
|
||||
"ai.privacy.patterns": "Expresiones regulares personalizadas",
|
||||
"ai.privacy.patterns.description": "Un patrón compatible con RE2 por línea, sin delimitadores / ni indicadores. Los patrones incompatibles o inseguros se rechazan.",
|
||||
"ai.privacy.connectionScope": "Los marcadores son estables dentro de una conversación gestionada o una conexión MCP externa. Los valores originales solo se restauran mediante llamadas a herramientas gestionadas por Netcatty dentro del mismo ámbito.",
|
||||
"ai.privacy.reconnect": "Aplica los cambios, inicia una nueva conversación gestionada y vuelve a conectar los clientes MCP externos. Las conversaciones y conexiones existentes no deben reutilizar marcadores.",
|
||||
"ai.privacy.apply": "Aplicar",
|
||||
"ai.privacy.applying": "Aplicando...",
|
||||
"ai.privacy.applied": "Configuración de privacidad de IA aplicada. Inicia una nueva conversación gestionada y vuelve a conectar los clientes MCP externos.",
|
||||
"ai.privacy.failed": "No se aplicó la configuración de privacidad de IA.",
|
||||
'ai.externalMcp.http.title': 'Streamable HTTP',
|
||||
'ai.externalMcp.http.description': 'Expone las mismas herramientas de MCP externo por Streamable HTTP para clientes que no pueden iniciar el CLI stdio, como Codex dentro de WSL. Desactivado de forma predeterminada.',
|
||||
'ai.externalMcp.http.enable': 'Activar HTTP',
|
||||
|
||||
@@ -779,8 +779,8 @@ Resalta el panel dividido enfocado:
|
||||
'settings.terminal.systemManager.processRefreshInterval.desc': 'Con qué frecuencia actualizar la lista de procesos en el panel lateral del administrador del sistema.',
|
||||
'settings.terminal.systemManager.tmuxRefreshInterval': 'Actualización de sesiones tmux',
|
||||
'settings.terminal.systemManager.tmuxRefreshInterval.desc': 'Con qué frecuencia actualizar la lista de sesiones tmux.',
|
||||
'settings.terminal.systemManager.dockerListRefreshInterval': 'Actualización de la lista de contenedores Docker',
|
||||
'settings.terminal.systemManager.dockerListRefreshInterval.desc': 'Con qué frecuencia actualizar la lista de contenedores Docker.',
|
||||
'settings.terminal.systemManager.dockerListRefreshInterval': 'Actualización de la lista de contenedores',
|
||||
'settings.terminal.systemManager.dockerListRefreshInterval.desc': 'Con qué frecuencia actualizar las listas de contenedores y pods.',
|
||||
'settings.terminal.systemManager.dockerStatsRefreshInterval': 'Actualización de estadísticas Docker',
|
||||
'settings.terminal.systemManager.dockerStatsRefreshInterval.desc': 'Con qué frecuencia actualizar las estadísticas de CPU/memoria/red de los contenedores Docker.',
|
||||
'settings.terminal.serverStats.show': 'Mostrar estadísticas del servidor',
|
||||
|
||||
@@ -26,6 +26,8 @@ export const esSystemManagerMessages: Messages = {
|
||||
'systemManager.errors.loadDocker': 'No se pudieron cargar los contenedores',
|
||||
'systemManager.errors.loadDockerStats': 'No se pudieron cargar las estadísticas de los contenedores',
|
||||
'systemManager.errors.loadDockerImages': 'No se pudieron cargar las imágenes',
|
||||
'systemManager.errors.loadKubernetes': 'No se pudieron cargar los recursos de Kubernetes',
|
||||
'systemManager.errors.loadPods': 'No se pudieron cargar los pods',
|
||||
'systemManager.errors.loadOverview': 'No se pudo cargar el resumen del sistema',
|
||||
'systemManager.errors.loadGpu': 'No se pudieron cargar las estadísticas de GPU / NPU',
|
||||
'systemManager.errors.loadPorts': 'No se pudieron cargar los puertos en escucha',
|
||||
@@ -132,7 +134,7 @@ export const esSystemManagerMessages: Messages = {
|
||||
'systemManager.tmux.newWindowPlaceholder': 'Nombre de la ventana (opcional)',
|
||||
'systemManager.tmux.noWindows': 'No hay ventanas',
|
||||
'systemManager.tmux.unavailable': 'tmux no está disponible en este host',
|
||||
'systemManager.docker.unavailable': 'Docker no está disponible en este host',
|
||||
'systemManager.docker.unavailable': 'No se detectó ningún runtime de contenedores (Docker, Podman, nerdctl o kubectl) en este host',
|
||||
'systemManager.tmux.windowsMismatch': 'La sesión reporta {{count}} ventana(s), pero list-windows no devolvió ninguna',
|
||||
'systemManager.tmux.lastCommand': 'último comando: {{command}}',
|
||||
'systemManager.tmux.noPanes': 'No hay paneles',
|
||||
@@ -194,6 +196,22 @@ export const esSystemManagerMessages: Messages = {
|
||||
'systemManager.docker.imagesMeta': '{{count}} imagen(es)',
|
||||
'systemManager.docker.start': 'Iniciar',
|
||||
'systemManager.docker.stop': 'Detener',
|
||||
'systemManager.runtime.switch': 'Runtime de contenedores',
|
||||
'systemManager.runtime.docker': 'Docker',
|
||||
'systemManager.runtime.podman': 'Podman',
|
||||
'systemManager.runtime.nerdctl': 'nerdctl',
|
||||
'systemManager.runtime.kubernetes': 'Kubernetes',
|
||||
'systemManager.k8s.context': 'Contexto',
|
||||
'systemManager.k8s.namespace': 'Espacio de nombres',
|
||||
'systemManager.k8s.empty': 'No se encontraron pods',
|
||||
'systemManager.k8s.search': 'Buscar pods…',
|
||||
'systemManager.k8s.meta': '{{count}} pod(s)',
|
||||
'systemManager.k8s.describe': 'Describir',
|
||||
'systemManager.k8s.delete': 'Eliminar pod',
|
||||
'systemManager.k8s.confirmDelete': '¿Eliminar el pod "{{name}}"?',
|
||||
'systemManager.k8s.restarts': '{{count}} reinicios',
|
||||
'systemManager.k8s.chooseContainer': 'Elegir un contenedor',
|
||||
'systemManager.k8s.noContext': 'No hay un contexto de Kubernetes en este host',
|
||||
|
||||
'systemManager.ports.unavailable': 'No se detectaron herramientas de puertos en escucha (ss / netstat) en este host.',
|
||||
'systemManager.ports.loading': 'Cargando puertos en escucha…',
|
||||
|
||||
@@ -0,0 +1,23 @@
|
||||
import assert from 'node:assert/strict';
|
||||
import test from 'node:test';
|
||||
import en from './en.ts';
|
||||
import es from './es.ts';
|
||||
import ru from './ru.ts';
|
||||
import zhCN from './zh-CN.ts';
|
||||
import zhTW from './zh-TW.ts';
|
||||
|
||||
const KEYS = [
|
||||
'title', 'description', 'boundary', 'enable', 'privateIps', 'privateIps.description',
|
||||
'cidrs', 'cidrs.description', 'patterns', 'patterns.description',
|
||||
'connectionScope', 'reconnect', 'apply', 'applying', 'applied', 'failed',
|
||||
];
|
||||
|
||||
test('Shared AI privacy settings are localized in every supported locale', () => {
|
||||
for (const [name, messages] of Object.entries({ en, es, ru, 'zh-CN': zhCN, 'zh-TW': zhTW })) {
|
||||
assert.deepEqual(
|
||||
KEYS.filter((key) => !messages[`ai.privacy.${key}`]),
|
||||
[],
|
||||
`${name} is missing privacy settings labels`,
|
||||
);
|
||||
}
|
||||
});
|
||||
@@ -323,6 +323,22 @@ export const ruAiMessages: Messages = {
|
||||
'ai.externalMcp.help.ariaLabel': 'Справка External MCP',
|
||||
'ai.externalMcp.security': 'Безопасность',
|
||||
'ai.externalMcp.security.description': 'Слушает только 127.0.0.1 с ротацией token, использует AI Permission Mode для записи и удаляет discovery при отключении.',
|
||||
"ai.privacy.title": "Конфиденциальность ИИ",
|
||||
"ai.privacy.description": "Фильтрует известные шаблоны конфиденциального текста в контексте и результатах инструментов, управляемых Netcatty, для Catty, встроенных агентов и внешних MCP-клиентов. По умолчанию отключено. Не обнаруживает все секреты. При включении двоичные ответы инструментов блокируются.",
|
||||
"ai.privacy.boundary": "Защищаются только данные и инструменты, управляемые Netcatty. Собственные инструменты сторонних агентов, их прямой доступ к файлам и другие MCP-серверы не входят в эту область.",
|
||||
"ai.privacy.enable": "Включить скрытие текста",
|
||||
"ai.privacy.privateIps": "Скрывать частные IP-адреса",
|
||||
"ai.privacy.privateIps.description": "Также скрывает частные и локальные IP-адреса в возвращаемом тексте.",
|
||||
"ai.privacy.cidrs": "Дополнительные диапазоны IP (CIDR)",
|
||||
"ai.privacy.cidrs.description": "Один CIDR IPv4 или IPv6 на строку. Оставьте пустым для использования только встроенных правил.",
|
||||
"ai.privacy.patterns": "Пользовательские регулярные выражения",
|
||||
"ai.privacy.patterns.description": "Один RE2-совместимый шаблон на строку, без разделителей / и флагов. Неподдерживаемые или небезопасные шаблоны отклоняются.",
|
||||
"ai.privacy.connectionScope": "Маркеры постоянны в пределах одного управляемого диалога или внешнего MCP-подключения. Исходные значения восстанавливаются только через вызовы инструментов, управляемых Netcatty, в той же области.",
|
||||
"ai.privacy.reconnect": "Примените изменения, начните новый управляемый диалог и переподключите внешние MCP-клиенты. Существующие диалоги и подключения не должны повторно использовать маркеры.",
|
||||
"ai.privacy.apply": "Применить",
|
||||
"ai.privacy.applying": "Применение...",
|
||||
"ai.privacy.applied": "Настройки конфиденциальности ИИ применены. Начните новый управляемый диалог и переподключите внешние MCP-клиенты.",
|
||||
"ai.privacy.failed": "Настройки конфиденциальности ИИ не применены.",
|
||||
'ai.externalMcp.http.title': 'Streamable HTTP',
|
||||
'ai.externalMcp.http.description': 'Отдает те же инструменты External MCP по Streamable HTTP клиентам, которые не могут запустить stdio CLI, например Codex в WSL. По умолчанию выключено.',
|
||||
'ai.externalMcp.http.enable': 'Включить HTTP',
|
||||
|
||||
@@ -817,8 +817,8 @@ export const ruCoreMessages: Messages = {
|
||||
'settings.terminal.systemManager.processRefreshInterval.desc': 'Как часто обновлять список процессов в боковой панели системного менеджера.',
|
||||
'settings.terminal.systemManager.tmuxRefreshInterval': 'Обновление сессий tmux',
|
||||
'settings.terminal.systemManager.tmuxRefreshInterval.desc': 'Как часто обновлять список сессий tmux.',
|
||||
'settings.terminal.systemManager.dockerListRefreshInterval': 'Обновление списка контейнеров Docker',
|
||||
'settings.terminal.systemManager.dockerListRefreshInterval.desc': 'Как часто обновлять список контейнеров Docker.',
|
||||
'settings.terminal.systemManager.dockerListRefreshInterval': 'Обновление списка контейнеров',
|
||||
'settings.terminal.systemManager.dockerListRefreshInterval.desc': 'Как часто обновлять списки контейнеров и подов.',
|
||||
'settings.terminal.systemManager.dockerStatsRefreshInterval': 'Обновление статистики Docker',
|
||||
'settings.terminal.systemManager.dockerStatsRefreshInterval.desc': 'Как часто обновлять CPU/память/сеть контейнеров Docker.',
|
||||
'settings.terminal.serverStats.show': 'Показывать статистику сервера',
|
||||
|
||||
@@ -26,6 +26,8 @@ export const ruSystemManagerMessages: Messages = {
|
||||
'systemManager.errors.loadDocker': 'Не удалось загрузить контейнеры',
|
||||
'systemManager.errors.loadDockerStats': 'Не удалось загрузить статистику контейнеров',
|
||||
'systemManager.errors.loadDockerImages': 'Не удалось загрузить образы',
|
||||
'systemManager.errors.loadKubernetes': 'Не удалось загрузить ресурсы Kubernetes',
|
||||
'systemManager.errors.loadPods': 'Не удалось загрузить поды',
|
||||
'systemManager.errors.loadOverview': 'Не удалось загрузить обзор системы',
|
||||
'systemManager.errors.loadGpu': 'Не удалось загрузить статистику GPU / NPU',
|
||||
'systemManager.errors.loadPorts': 'Не удалось загрузить слушающие порты',
|
||||
@@ -132,7 +134,7 @@ export const ruSystemManagerMessages: Messages = {
|
||||
'systemManager.tmux.newWindowPlaceholder': 'Имя окна (необязательно)',
|
||||
'systemManager.tmux.noWindows': 'Нет окон',
|
||||
'systemManager.tmux.unavailable': 'tmux недоступен на этом хосте',
|
||||
'systemManager.docker.unavailable': 'Docker недоступен на этом хосте',
|
||||
'systemManager.docker.unavailable': 'На этом хосте не обнаружена среда контейнеров (Docker, Podman, nerdctl или kubectl)',
|
||||
'systemManager.tmux.windowsMismatch': 'В сессии указано {{count}} окон, но list-windows ничего не вернул',
|
||||
'systemManager.tmux.lastCommand': 'последняя команда: {{command}}',
|
||||
'systemManager.tmux.noPanes': 'Нет панелей',
|
||||
@@ -194,6 +196,22 @@ export const ruSystemManagerMessages: Messages = {
|
||||
'systemManager.docker.imagesMeta': '{{count}} образов',
|
||||
'systemManager.docker.start': 'Запустить',
|
||||
'systemManager.docker.stop': 'Остановить',
|
||||
'systemManager.runtime.switch': 'Среда выполнения контейнеров',
|
||||
'systemManager.runtime.docker': 'Docker',
|
||||
'systemManager.runtime.podman': 'Podman',
|
||||
'systemManager.runtime.nerdctl': 'nerdctl',
|
||||
'systemManager.runtime.kubernetes': 'Kubernetes',
|
||||
'systemManager.k8s.context': 'Контекст',
|
||||
'systemManager.k8s.namespace': 'Пространство имён',
|
||||
'systemManager.k8s.empty': 'Поды не найдены',
|
||||
'systemManager.k8s.search': 'Поиск подов…',
|
||||
'systemManager.k8s.meta': '{{count}} под(ов)',
|
||||
'systemManager.k8s.describe': 'Описание',
|
||||
'systemManager.k8s.delete': 'Удалить под',
|
||||
'systemManager.k8s.confirmDelete': 'Удалить под «{{name}}»?',
|
||||
'systemManager.k8s.restarts': '{{count}} перезапусков',
|
||||
'systemManager.k8s.chooseContainer': 'Выберите контейнер',
|
||||
'systemManager.k8s.noContext': 'На этом хосте нет контекста Kubernetes',
|
||||
|
||||
'systemManager.ports.unavailable': 'На этом хосте не обнаружены инструменты портов (ss / netstat).',
|
||||
'systemManager.ports.loading': 'Загрузка слушающих портов…',
|
||||
|
||||
@@ -384,6 +384,22 @@ export const zhCNAiMessages: Messages = {
|
||||
'ai.externalMcp.help.ariaLabel': '对外 MCP 使用说明',
|
||||
'ai.externalMcp.security': '安全',
|
||||
'ai.externalMcp.security.description': '仅监听 127.0.0.1,使用轮换 token,写操作复用 AI 权限模式,关闭时删除 discovery。不做 OAuth——这是本机桌面桥接。',
|
||||
"ai.privacy.title": "AI 隐私",
|
||||
"ai.privacy.description": "为 Catty、集成 Agent 和外部 MCP 客户端过滤 Netcatty 管理的上下文及工具结果中已知的敏感文本模式。默认关闭,无法识别所有机密。启用时会阻止二进制工具响应。",
|
||||
"ai.privacy.boundary": "仅覆盖 Netcatty 管理的数据和工具。第三方 Agent 的原生工具、直接读取的文件及其他 MCP 服务器不在保护范围内。",
|
||||
"ai.privacy.enable": "启用文本脱敏",
|
||||
"ai.privacy.privateIps": "隐藏私有 IP 地址",
|
||||
"ai.privacy.privateIps.description": "同时隐藏返回文本中的私有和本地 IP 地址。",
|
||||
"ai.privacy.cidrs": "附加 IP 网段(CIDR)",
|
||||
"ai.privacy.cidrs.description": "每行一个 IPv4 或 IPv6 CIDR。留空则仅使用内置规则。",
|
||||
"ai.privacy.patterns": "自定义正则表达式",
|
||||
"ai.privacy.patterns.description": "每行一个兼容 RE2 的模式,不含 / 分隔符或标志。不支持或不安全的模式会被拒绝。",
|
||||
"ai.privacy.connectionScope": "占位符在同一个受管对话或外部 MCP 连接内保持稳定。只有同一范围内的 Netcatty 受管工具调用才能还原原始值。",
|
||||
"ai.privacy.reconnect": "应用更改后,请新建受管对话并重新连接外部 MCP 客户端。已有对话和连接不得复用占位符。",
|
||||
"ai.privacy.apply": "应用",
|
||||
"ai.privacy.applying": "正在应用...",
|
||||
"ai.privacy.applied": "AI 隐私设置已应用。请新建受管对话并重新连接外部 MCP 客户端。",
|
||||
"ai.privacy.failed": "AI 隐私设置未应用。",
|
||||
'ai.externalMcp.http.title': 'Streamable HTTP',
|
||||
'ai.externalMcp.http.description': '用 Streamable HTTP 提供同一套对外 MCP 工具,方便无法启动 stdio CLI 的客户端,例如 WSL 里的 Codex。默认关闭。',
|
||||
'ai.externalMcp.http.enable': '启用 HTTP',
|
||||
|
||||
@@ -26,6 +26,11 @@ export const zhCnSystemManagerMessages: Messages = {
|
||||
'systemManager.errors.loadDocker': '加载容器列表失败',
|
||||
'systemManager.errors.loadDockerStats': '加载容器性能数据失败',
|
||||
'systemManager.errors.loadDockerImages': '加载镜像列表失败',
|
||||
'systemManager.errors.loadKubernetes': '无法加载 Kubernetes 资源',
|
||||
'systemManager.errors.loadPods': '无法加载 Pod',
|
||||
'systemManager.errors.nerdctlUnreachable': 'nerdctl 无法连接 containerd',
|
||||
'systemManager.errors.clusterUnreachable': '无法连接到上下文 {{context}} 的集群',
|
||||
'systemManager.errors.clusterUnreachableGeneric': '无法连接到集群',
|
||||
'systemManager.errors.loadOverview': '加载系统概览失败',
|
||||
'systemManager.errors.loadGpu': '加载 GPU / NPU 状态失败',
|
||||
'systemManager.errors.loadPorts': '加载监听端口失败',
|
||||
@@ -132,7 +137,7 @@ export const zhCnSystemManagerMessages: Messages = {
|
||||
'systemManager.tmux.newWindowPlaceholder': '窗口名称(可选)',
|
||||
'systemManager.tmux.noWindows': '没有窗口',
|
||||
'systemManager.tmux.unavailable': '此主机未检测到 tmux',
|
||||
'systemManager.docker.unavailable': '此主机未检测到 Docker',
|
||||
'systemManager.docker.unavailable': '此主机未检测到容器运行时(Docker、Podman、nerdctl 或 kubectl)',
|
||||
'systemManager.tmux.windowsMismatch': '会话显示有 {{count}} 个窗口,但 list-windows 未返回任何窗口',
|
||||
'systemManager.tmux.lastCommand': '最后执行的命令:{{command}}',
|
||||
'systemManager.tmux.noPanes': '没有面板',
|
||||
@@ -194,6 +199,36 @@ export const zhCnSystemManagerMessages: Messages = {
|
||||
'systemManager.docker.imagesMeta': '{{count}} 个镜像',
|
||||
'systemManager.docker.start': '启动',
|
||||
'systemManager.docker.stop': '停止',
|
||||
'systemManager.runtime.switch': '容器运行时',
|
||||
'systemManager.runtime.docker': 'Docker',
|
||||
'systemManager.runtime.podman': 'Podman',
|
||||
'systemManager.runtime.nerdctl': 'nerdctl',
|
||||
'systemManager.runtime.kubernetes': 'Kubernetes',
|
||||
'systemManager.k8s.context': '上下文',
|
||||
'systemManager.k8s.namespace': '命名空间',
|
||||
'systemManager.k8s.empty': '未找到 Pod',
|
||||
'systemManager.k8s.search': '搜索 Pod…',
|
||||
'systemManager.k8s.meta': '{{count}} 个 Pod',
|
||||
'systemManager.k8s.describe': '描述',
|
||||
'systemManager.k8s.delete': '删除 Pod',
|
||||
'systemManager.k8s.confirmDelete': '确定删除 Pod「{{name}}」?',
|
||||
'systemManager.k8s.restarts': '重启 {{count}} 次',
|
||||
'systemManager.k8s.chooseContainer': '选择容器',
|
||||
'systemManager.k8s.noContext': '此主机没有 Kubernetes 上下文',
|
||||
'systemManager.k8s.status.Running': '运行中',
|
||||
'systemManager.k8s.status.Pending': '等待中',
|
||||
'systemManager.k8s.status.Completed': '已完成',
|
||||
'systemManager.k8s.status.Succeeded': '已成功',
|
||||
'systemManager.k8s.status.Failed': '失败',
|
||||
'systemManager.k8s.status.Unknown': '未知',
|
||||
'systemManager.k8s.status.CrashLoopBackOff': '崩溃循环',
|
||||
'systemManager.k8s.status.ContainerCreating': '创建中',
|
||||
'systemManager.k8s.status.Terminating': '终止中',
|
||||
'systemManager.k8s.status.ImagePullBackOff': '镜像拉取退避',
|
||||
'systemManager.k8s.status.ErrImagePull': '拉取镜像失败',
|
||||
'systemManager.k8s.status.Error': '错误',
|
||||
'systemManager.k8s.status.Evicted': '已驱逐',
|
||||
'systemManager.k8s.status.OOMKilled': '内存不足',
|
||||
|
||||
'systemManager.ports.unavailable': '此主机未检测到端口工具(ss / netstat)。',
|
||||
'systemManager.ports.loading': '正在加载监听端口…',
|
||||
|
||||
@@ -487,8 +487,8 @@ export const zhCNTerminalMessages: Messages = {
|
||||
'settings.terminal.systemManager.processRefreshInterval.desc': '系统管理侧栏中进程列表的刷新频率。',
|
||||
'settings.terminal.systemManager.tmuxRefreshInterval': 'tmux 会话刷新间隔',
|
||||
'settings.terminal.systemManager.tmuxRefreshInterval.desc': 'tmux 会话列表的刷新频率。',
|
||||
'settings.terminal.systemManager.dockerListRefreshInterval': 'Docker 容器列表刷新间隔',
|
||||
'settings.terminal.systemManager.dockerListRefreshInterval.desc': 'Docker 容器列表的刷新频率。',
|
||||
'settings.terminal.systemManager.dockerListRefreshInterval': '容器列表刷新间隔',
|
||||
'settings.terminal.systemManager.dockerListRefreshInterval.desc': '容器与 Pod 列表的刷新频率。',
|
||||
'settings.terminal.systemManager.dockerStatsRefreshInterval': 'Docker 性能数据刷新间隔',
|
||||
'settings.terminal.systemManager.dockerStatsRefreshInterval.desc': 'Docker 容器 CPU/内存/网络指标的刷新频率。',
|
||||
|
||||
|
||||
@@ -374,6 +374,22 @@ export const zhTWAiMessages: Messages = {
|
||||
'ai.externalMcp.help.ariaLabel': '對外 MCP 使用說明',
|
||||
'ai.externalMcp.security': '安全',
|
||||
'ai.externalMcp.security.description': '僅監聽 127.0.0.1,使用輪替 token,寫入操作重用 AI 權限模式,關閉時刪除 discovery。',
|
||||
"ai.privacy.title": "AI 隱私",
|
||||
"ai.privacy.description": "為 Catty、整合 Agent 和外部 MCP 用戶端過濾 Netcatty 管理的上下文及工具結果中已知的敏感文字模式。預設關閉,無法識別所有機密。啟用時會封鎖二進位工具回應。",
|
||||
"ai.privacy.boundary": "僅涵蓋 Netcatty 管理的資料和工具。第三方 Agent 的原生工具、直接讀取的檔案及其他 MCP 伺服器不在保護範圍內。",
|
||||
"ai.privacy.enable": "啟用文字去識別化",
|
||||
"ai.privacy.privateIps": "隱藏私有 IP 位址",
|
||||
"ai.privacy.privateIps.description": "同時隱藏回傳文字中的私有和本機 IP 位址。",
|
||||
"ai.privacy.cidrs": "額外 IP 網段(CIDR)",
|
||||
"ai.privacy.cidrs.description": "每行一個 IPv4 或 IPv6 CIDR。留空則僅使用內建規則。",
|
||||
"ai.privacy.patterns": "自訂正規表示式",
|
||||
"ai.privacy.patterns.description": "每行一個相容 RE2 的模式,不含 / 分隔符號或旗標。不支援或不安全的模式會遭拒絕。",
|
||||
"ai.privacy.connectionScope": "預留位置在同一個受管對話或外部 MCP 連線內保持穩定。只有同一範圍內的 Netcatty 受管工具呼叫才能還原原始值。",
|
||||
"ai.privacy.reconnect": "套用變更後,請建立新的受管對話並重新連接外部 MCP 用戶端。現有對話和連線不得重複使用預留位置。",
|
||||
"ai.privacy.apply": "套用",
|
||||
"ai.privacy.applying": "正在套用...",
|
||||
"ai.privacy.applied": "AI 隱私設定已套用。請建立新的受管對話並重新連接外部 MCP 用戶端。",
|
||||
"ai.privacy.failed": "AI 隱私設定未套用。",
|
||||
'ai.externalMcp.http.title': 'Streamable HTTP',
|
||||
'ai.externalMcp.http.description': '以 Streamable HTTP 提供同一套對外 MCP 工具,給無法啟動 stdio CLI 的用戶端使用,例如 WSL 裡的 Codex。預設關閉。',
|
||||
'ai.externalMcp.http.enable': '啟用 HTTP',
|
||||
|
||||
@@ -26,6 +26,11 @@ export const zhTwSystemManagerMessages: Messages = {
|
||||
'systemManager.errors.loadDocker': '載入容器列表失敗',
|
||||
'systemManager.errors.loadDockerStats': '載入容器效能資料失敗',
|
||||
'systemManager.errors.loadDockerImages': '載入映像列表失敗',
|
||||
'systemManager.errors.loadKubernetes': '無法載入 Kubernetes 資源',
|
||||
'systemManager.errors.loadPods': '無法載入 Pod',
|
||||
'systemManager.errors.nerdctlUnreachable': 'nerdctl 無法連線到 containerd',
|
||||
'systemManager.errors.clusterUnreachable': '無法連到內容 {{context}} 的叢集',
|
||||
'systemManager.errors.clusterUnreachableGeneric': '無法連到叢集',
|
||||
'systemManager.errors.loadOverview': '載入系統概覽失敗',
|
||||
'systemManager.errors.loadGpu': '載入 GPU / NPU 狀態失敗',
|
||||
'systemManager.errors.loadPorts': '載入監聽連接埠失敗',
|
||||
@@ -132,7 +137,7 @@ export const zhTwSystemManagerMessages: Messages = {
|
||||
'systemManager.tmux.newWindowPlaceholder': '視窗名稱(可選)',
|
||||
'systemManager.tmux.noWindows': '沒有視窗',
|
||||
'systemManager.tmux.unavailable': '此主機未偵測到 tmux',
|
||||
'systemManager.docker.unavailable': '此主機未偵測到 Docker',
|
||||
'systemManager.docker.unavailable': '此主機未偵測到容器執行環境(Docker、Podman、nerdctl 或 kubectl)',
|
||||
'systemManager.tmux.windowsMismatch': '工作階段顯示有 {{count}} 個視窗,但 list-windows 未傳回任何視窗',
|
||||
'systemManager.tmux.lastCommand': '最後執行的指令:{{command}}',
|
||||
'systemManager.tmux.noPanes': '沒有窗格',
|
||||
@@ -194,6 +199,36 @@ export const zhTwSystemManagerMessages: Messages = {
|
||||
'systemManager.docker.imagesMeta': '{{count}} 個映像',
|
||||
'systemManager.docker.start': '啟動',
|
||||
'systemManager.docker.stop': '停止',
|
||||
'systemManager.runtime.switch': '容器執行環境',
|
||||
'systemManager.runtime.docker': 'Docker',
|
||||
'systemManager.runtime.podman': 'Podman',
|
||||
'systemManager.runtime.nerdctl': 'nerdctl',
|
||||
'systemManager.runtime.kubernetes': 'Kubernetes',
|
||||
'systemManager.k8s.context': '內容',
|
||||
'systemManager.k8s.namespace': '命名空間',
|
||||
'systemManager.k8s.empty': '沒有 Pod',
|
||||
'systemManager.k8s.search': '搜尋 Pod…',
|
||||
'systemManager.k8s.meta': '{{count}} 個 Pod',
|
||||
'systemManager.k8s.describe': '描述',
|
||||
'systemManager.k8s.delete': '刪除 Pod',
|
||||
'systemManager.k8s.confirmDelete': '確定刪除 Pod「{{name}}」?',
|
||||
'systemManager.k8s.restarts': '重新啟動 {{count}} 次',
|
||||
'systemManager.k8s.chooseContainer': '選擇容器',
|
||||
'systemManager.k8s.noContext': '此主機沒有 Kubernetes 內容',
|
||||
'systemManager.k8s.status.Running': '執行中',
|
||||
'systemManager.k8s.status.Pending': '等待中',
|
||||
'systemManager.k8s.status.Completed': '已完成',
|
||||
'systemManager.k8s.status.Succeeded': '已成功',
|
||||
'systemManager.k8s.status.Failed': '失敗',
|
||||
'systemManager.k8s.status.Unknown': '未知',
|
||||
'systemManager.k8s.status.CrashLoopBackOff': '當機重試',
|
||||
'systemManager.k8s.status.ContainerCreating': '建立中',
|
||||
'systemManager.k8s.status.Terminating': '終止中',
|
||||
'systemManager.k8s.status.ImagePullBackOff': '映像拉取退避',
|
||||
'systemManager.k8s.status.ErrImagePull': '拉取映像失敗',
|
||||
'systemManager.k8s.status.Error': '錯誤',
|
||||
'systemManager.k8s.status.Evicted': '已驅逐',
|
||||
'systemManager.k8s.status.OOMKilled': '記憶體不足',
|
||||
|
||||
'systemManager.ports.unavailable': '此主機未偵測到連接埠工具(ss / netstat)。',
|
||||
'systemManager.ports.loading': '正在載入監聽連接埠…',
|
||||
|
||||
@@ -487,8 +487,8 @@ export const zhTWTerminalMessages: Messages = {
|
||||
'settings.terminal.systemManager.processRefreshInterval.desc': '系統管理側欄中程序列表的重新整理頻率。',
|
||||
'settings.terminal.systemManager.tmuxRefreshInterval': 'tmux 工作階段重新整理間隔',
|
||||
'settings.terminal.systemManager.tmuxRefreshInterval.desc': 'tmux 工作階段列表的重新整理頻率。',
|
||||
'settings.terminal.systemManager.dockerListRefreshInterval': 'Docker 容器列表重新整理間隔',
|
||||
'settings.terminal.systemManager.dockerListRefreshInterval.desc': 'Docker 容器列表的重新整理頻率。',
|
||||
'settings.terminal.systemManager.dockerListRefreshInterval': '容器列表重新整理間隔',
|
||||
'settings.terminal.systemManager.dockerListRefreshInterval.desc': '容器與 Pod 列表的重新整理頻率。',
|
||||
'settings.terminal.systemManager.dockerStatsRefreshInterval': 'Docker 效能資料重新整理間隔',
|
||||
'settings.terminal.systemManager.dockerStatsRefreshInterval.desc': 'Docker 容器 CPU/記憶體/網路指標的重新整理頻率。',
|
||||
|
||||
|
||||
@@ -1,10 +1,21 @@
|
||||
import assert from 'node:assert/strict';
|
||||
import test from 'node:test';
|
||||
import { createAiPrivacyService } from '../../electron/shared/aiPrivacyService.cjs';
|
||||
import {
|
||||
hasReversibleModelPrivacy,
|
||||
setReversibleModelPrivacy,
|
||||
} from '../../infrastructure/ai/harness/modelSecretRedaction';
|
||||
import type { AISession } from '../../infrastructure/ai/types';
|
||||
import { localStorageAdapter } from '../../infrastructure/persistence/localStorageAdapter';
|
||||
import {
|
||||
cleanupClosedTerminalSessions,
|
||||
cleanupDeletedAIChatSessions,
|
||||
cleanupOrphanedAISessions,
|
||||
cleanupSdkAgentSessions,
|
||||
latestAIActiveSessionMapSnapshot,
|
||||
latestAISessionsSnapshot,
|
||||
setLatestAIActiveSessionMapSnapshot,
|
||||
setLatestAISessionsSnapshot,
|
||||
} from './aiStateSnapshots';
|
||||
|
||||
test('orphan cleanup keeps durable Catty output while explicit deletion removes it', async () => {
|
||||
@@ -61,6 +72,92 @@ function makeSession(id: string, updatedAt: number, messages: unknown[]): AISess
|
||||
} as never;
|
||||
}
|
||||
|
||||
test('orphan cleanup preserves resumable privacy maps until explicit chat deletion', (t) => {
|
||||
const service = createAiPrivacyService({ maxSessions: 3 });
|
||||
service.configure({ enabled: true });
|
||||
const sessions = [
|
||||
{ ...makeSession('privacy-orphan', 1, []), scope: { type: 'terminal' as const, targetId: 'closed' } },
|
||||
{ ...makeSession('privacy-live', 2, []), scope: { type: 'terminal' as const, targetId: 'open' } },
|
||||
{ ...makeSession('privacy-resumed', 3, []), scope: { type: 'terminal' as const, targetId: 'closed' } },
|
||||
];
|
||||
const aliases = sessions.map(session => {
|
||||
setReversibleModelPrivacy(session.id, true);
|
||||
return service.redact(session.id, '8.8.8.8');
|
||||
});
|
||||
const sdkCleanups: string[] = [];
|
||||
const privacyCleanups: string[] = [];
|
||||
const outputCleanups: string[] = [];
|
||||
const previousWindow = globalThis.window;
|
||||
const previousSessions = latestAISessionsSnapshot;
|
||||
const previousActiveSessionMap = latestAIActiveSessionMapSnapshot;
|
||||
t.mock.method(localStorageAdapter, 'write', () => true);
|
||||
Object.defineProperty(globalThis, 'window', {
|
||||
configurable: true,
|
||||
value: {
|
||||
dispatchEvent: () => true,
|
||||
netcatty: {
|
||||
aiSdkAgentCleanup: async (id: string) => {
|
||||
sdkCleanups.push(id);
|
||||
return { ok: true };
|
||||
},
|
||||
aiPrivacyClearSession: async (id: string) => {
|
||||
privacyCleanups.push(id);
|
||||
service.clearSession(id);
|
||||
return { ok: true };
|
||||
},
|
||||
deleteChatToolOutputsTemp: async (id: string) => {
|
||||
outputCleanups.push(id);
|
||||
return { deletedCount: 1 };
|
||||
},
|
||||
},
|
||||
},
|
||||
});
|
||||
setLatestAISessionsSnapshot(sessions);
|
||||
setLatestAIActiveSessionMapSnapshot({
|
||||
'terminal:closed': 'privacy-orphan',
|
||||
'terminal:open': 'privacy-live',
|
||||
'terminal:resumed-into': 'privacy-resumed',
|
||||
});
|
||||
|
||||
try {
|
||||
cleanupOrphanedAISessions(new Set(['open', 'resumed-into']));
|
||||
|
||||
assert.equal(latestAISessionsSnapshot, sessions);
|
||||
assert.deepEqual(latestAIActiveSessionMapSnapshot, {
|
||||
'terminal:open': 'privacy-live',
|
||||
'terminal:resumed-into': 'privacy-resumed',
|
||||
});
|
||||
assert.deepEqual(sdkCleanups, ['privacy-orphan']);
|
||||
assert.deepEqual(privacyCleanups, []);
|
||||
assert.deepEqual(outputCleanups, []);
|
||||
for (const [index, session] of sessions.entries()) {
|
||||
assert.equal(hasReversibleModelPrivacy(session.id), true);
|
||||
assert.equal(service.redact(session.id, '8.8.8.8'), aliases[index]);
|
||||
assert.equal(service.restore(session.id, aliases[index]), '8.8.8.8');
|
||||
}
|
||||
assert.throws(() => service.checkSession('privacy-next'), /session limit reached/);
|
||||
|
||||
cleanupDeletedAIChatSessions(['privacy-orphan']);
|
||||
|
||||
assert.deepEqual(privacyCleanups, ['privacy-orphan']);
|
||||
assert.deepEqual(outputCleanups, ['privacy-orphan']);
|
||||
assert.equal(hasReversibleModelPrivacy('privacy-orphan'), false);
|
||||
assert.equal(service.hasSession('privacy-orphan'), false);
|
||||
assert.throws(() => service.checkSession('privacy-orphan'), /start a new chat/);
|
||||
assert.equal(service.checkSession('privacy-next'), true);
|
||||
for (const [index, session] of sessions.entries()) {
|
||||
if (session.id === 'privacy-orphan') continue;
|
||||
assert.equal(hasReversibleModelPrivacy(session.id), true);
|
||||
assert.equal(service.restore(session.id, aliases[index]), '8.8.8.8');
|
||||
}
|
||||
} finally {
|
||||
for (const session of sessions) setReversibleModelPrivacy(session.id, false);
|
||||
setLatestAISessionsSnapshot(previousSessions ?? []);
|
||||
setLatestAIActiveSessionMapSnapshot(previousActiveSessionMap ?? {});
|
||||
Object.defineProperty(globalThis, 'window', { configurable: true, value: previousWindow });
|
||||
}
|
||||
});
|
||||
|
||||
test('serializeSessionsForStorage strips oldest ciphertext before dropping visible sessions', async () => {
|
||||
const { serializeSessionsForStorage } = await import('./aiStateSnapshots');
|
||||
const ciphertext = 'gAAAA'.repeat(50000); // ~250 KB of ciphertext per message
|
||||
|
||||
@@ -0,0 +1,39 @@
|
||||
import type { ContainerRuntimeId, ContainerSourceId } from '../../../domain/systemManager/containerRuntime';
|
||||
import { isContainerSource } from '../../../domain/systemManager/containerRuntime';
|
||||
import { STORAGE_KEY_CONTAINER_RUNTIME } from '../../../infrastructure/config/storageKeys';
|
||||
import { localStorageAdapter } from '../../../infrastructure/persistence/localStorageAdapter';
|
||||
|
||||
export interface ContainerRuntimePreference {
|
||||
source: ContainerSourceId;
|
||||
engine?: ContainerRuntimeId;
|
||||
context?: string;
|
||||
namespace?: string;
|
||||
}
|
||||
|
||||
type PreferenceStore = Record<string, ContainerRuntimePreference>;
|
||||
|
||||
const CONTAINER_ENGINES = new Set<ContainerRuntimeId>(['docker', 'podman', 'nerdctl']);
|
||||
|
||||
function readStore(): PreferenceStore {
|
||||
const value = localStorageAdapter.read<PreferenceStore>(STORAGE_KEY_CONTAINER_RUNTIME);
|
||||
return value && typeof value === 'object' ? value : {};
|
||||
}
|
||||
|
||||
export function readContainerRuntimePreference(hostId: string): ContainerRuntimePreference | null {
|
||||
if (!hostId) return null;
|
||||
const entry = readStore()[hostId];
|
||||
if (!entry || !isContainerSource(entry.source)) return null;
|
||||
return {
|
||||
source: entry.source,
|
||||
engine: entry.engine && CONTAINER_ENGINES.has(entry.engine) ? entry.engine : undefined,
|
||||
context: typeof entry.context === 'string' ? entry.context : undefined,
|
||||
namespace: typeof entry.namespace === 'string' ? entry.namespace : undefined,
|
||||
};
|
||||
}
|
||||
|
||||
export function writeContainerRuntimePreference(hostId: string, entry: ContainerRuntimePreference): void {
|
||||
if (!hostId || !isContainerSource(entry.source)) return;
|
||||
const store = readStore();
|
||||
store[hostId] = entry;
|
||||
localStorageAdapter.write(STORAGE_KEY_CONTAINER_RUNTIME, store);
|
||||
}
|
||||
@@ -0,0 +1,225 @@
|
||||
import { useCallback, useEffect, useRef, useState } from 'react';
|
||||
import type { useSystemManagerBackend } from '../useSystemManagerBackend';
|
||||
import { useStableTranslate } from '../useSystemManager';
|
||||
import type {
|
||||
ContainerRuntimeId,
|
||||
ContainerSourceId,
|
||||
KubeContextInfo,
|
||||
} from '../../../domain/systemManager/containerRuntime';
|
||||
import {
|
||||
describeRuntimeFailure,
|
||||
firstContainerRuntime,
|
||||
isAutomaticRuntimeFallback,
|
||||
isKubectlForbiddenError,
|
||||
reconcileContainerSource,
|
||||
sourcesFromCapabilities,
|
||||
} from '../../../domain/systemManager/containerRuntime';
|
||||
import type { SessionCapabilities } from '../../../domain/systemManager/types';
|
||||
import {
|
||||
readContainerRuntimePreference,
|
||||
writeContainerRuntimePreference,
|
||||
} from './containerRuntimePreference';
|
||||
|
||||
type Backend = ReturnType<typeof useSystemManagerBackend>;
|
||||
|
||||
interface UseContainerRuntimeSelectionOptions {
|
||||
sessionId: string;
|
||||
hostKey: string;
|
||||
capabilities?: SessionCapabilities;
|
||||
backend: Backend;
|
||||
}
|
||||
|
||||
export function useContainerRuntimeSelection({
|
||||
sessionId,
|
||||
hostKey,
|
||||
capabilities,
|
||||
backend,
|
||||
}: UseContainerRuntimeSelectionOptions) {
|
||||
const stableT = useStableTranslate();
|
||||
const detected = sourcesFromCapabilities(capabilities);
|
||||
const detectedKey = detected.join('|');
|
||||
const [source, setSource] = useState<ContainerSourceId | null>(() => {
|
||||
const remembered = readContainerRuntimePreference(hostKey);
|
||||
return reconcileContainerSource(detected, null, remembered);
|
||||
});
|
||||
const [contextName, setContextName] = useState<string | null>(() => (
|
||||
readContainerRuntimePreference(hostKey)?.context ?? null
|
||||
));
|
||||
const [namespaceName, setNamespaceName] = useState<string | null>(() => (
|
||||
readContainerRuntimePreference(hostKey)?.namespace ?? null
|
||||
));
|
||||
const [contexts, setContexts] = useState<KubeContextInfo[]>([]);
|
||||
const [namespaces, setNamespaces] = useState<string[]>([]);
|
||||
const [scopeError, setScopeError] = useState<string | null>(null);
|
||||
const [scopeNonce, setScopeNonce] = useState(0);
|
||||
const skipPersist = useRef(true);
|
||||
const namespaceNameRef = useRef(namespaceName);
|
||||
namespaceNameRef.current = namespaceName;
|
||||
|
||||
useEffect(() => {
|
||||
skipPersist.current = true;
|
||||
const remembered = readContainerRuntimePreference(hostKey);
|
||||
setSource(reconcileContainerSource(detected, null, remembered));
|
||||
setContextName(remembered?.context ?? null);
|
||||
setNamespaceName(remembered?.namespace ?? null);
|
||||
setContexts([]);
|
||||
setNamespaces([]);
|
||||
setScopeError(null);
|
||||
// Detection is reconciled by the next effect so a host switch does not
|
||||
// keep the previous host's in-memory selection.
|
||||
// eslint-disable-next-line react-hooks/exhaustive-deps
|
||||
}, [hostKey]);
|
||||
|
||||
useEffect(() => {
|
||||
const available = sourcesFromCapabilities(capabilities);
|
||||
setSource((current) => reconcileContainerSource(
|
||||
available,
|
||||
current,
|
||||
readContainerRuntimePreference(hostKey),
|
||||
));
|
||||
}, [capabilities, detectedKey, hostKey]);
|
||||
|
||||
useEffect(() => {
|
||||
if (skipPersist.current) {
|
||||
skipPersist.current = false;
|
||||
return;
|
||||
}
|
||||
if (!source) return;
|
||||
const remembered = readContainerRuntimePreference(hostKey);
|
||||
const available = sourcesFromCapabilities(capabilities);
|
||||
if (isAutomaticRuntimeFallback(remembered, source, available)) return;
|
||||
const engine = source === 'kubernetes'
|
||||
? (remembered?.engine ?? firstContainerRuntime(available) ?? undefined)
|
||||
: source;
|
||||
writeContainerRuntimePreference(hostKey, {
|
||||
source,
|
||||
engine,
|
||||
context: contextName || undefined,
|
||||
namespace: namespaceName || undefined,
|
||||
});
|
||||
}, [capabilities, contextName, hostKey, namespaceName, source]);
|
||||
|
||||
useEffect(() => {
|
||||
if (source !== 'kubernetes') return undefined;
|
||||
let cancelled = false;
|
||||
void backend.kubectl({ sessionId, op: 'contexts' }).then((result) => {
|
||||
if (cancelled) return;
|
||||
if (!result.success || !result.contexts) {
|
||||
setContexts([]);
|
||||
setScopeError(describeRuntimeFailure(
|
||||
result,
|
||||
stableT,
|
||||
stableT('systemManager.errors.loadKubernetes'),
|
||||
contextName,
|
||||
));
|
||||
return;
|
||||
}
|
||||
setScopeError(null);
|
||||
const available = result.contexts;
|
||||
const kept = contextName && available.some((item) => item.name === contextName)
|
||||
? contextName
|
||||
: null;
|
||||
const next = kept
|
||||
?? available.find((item) => item.current)?.name
|
||||
?? available[0]?.name
|
||||
?? null;
|
||||
setContexts(available);
|
||||
setContextName(next);
|
||||
if (!kept) {
|
||||
const configured = available.find((item) => item.name === next)?.namespace || 'default';
|
||||
setNamespaceName(next ? configured : null);
|
||||
setNamespaces([]);
|
||||
}
|
||||
});
|
||||
return () => {
|
||||
cancelled = true;
|
||||
};
|
||||
}, [backend, contextName, sessionId, source, scopeNonce, stableT]);
|
||||
|
||||
useEffect(() => {
|
||||
if (source !== 'kubernetes' || !contextName) return undefined;
|
||||
let cancelled = false;
|
||||
void backend.kubectl({
|
||||
sessionId,
|
||||
op: 'namespaces',
|
||||
context: contextName,
|
||||
}).then((result) => {
|
||||
if (cancelled) return;
|
||||
if (!result.success || !result.namespaces) {
|
||||
const message = result.error || '';
|
||||
if (isKubectlForbiddenError(message)) {
|
||||
const configured = contexts.find((item) => item.name === contextName)?.namespace || 'default';
|
||||
const fallback = namespaceNameRef.current || configured;
|
||||
setScopeError(null);
|
||||
setNamespaceName((current) => current || fallback);
|
||||
setNamespaces([fallback]);
|
||||
return;
|
||||
}
|
||||
setScopeError(describeRuntimeFailure(
|
||||
result,
|
||||
stableT,
|
||||
stableT('systemManager.errors.loadKubernetes'),
|
||||
contextName,
|
||||
));
|
||||
return;
|
||||
}
|
||||
setScopeError(null);
|
||||
setNamespaces(result.namespaces);
|
||||
setNamespaceName((current) => {
|
||||
if (current && result.namespaces?.includes(current)) return current;
|
||||
const configured = contexts.find((item) => item.name === contextName)?.namespace;
|
||||
if (configured && result.namespaces?.includes(configured)) return configured;
|
||||
if (result.namespaces?.includes('default')) return 'default';
|
||||
return result.namespaces?.[0] ?? null;
|
||||
});
|
||||
});
|
||||
return () => {
|
||||
cancelled = true;
|
||||
};
|
||||
}, [backend, contextName, contexts, sessionId, source, scopeNonce, stableT]);
|
||||
|
||||
const selectSource = useCallback((value: string) => {
|
||||
if (value !== 'docker' && value !== 'podman' && value !== 'nerdctl' && value !== 'kubernetes') return;
|
||||
setSource(value);
|
||||
const remembered = readContainerRuntimePreference(hostKey);
|
||||
const available = sourcesFromCapabilities(capabilities);
|
||||
const engine = value === 'kubernetes'
|
||||
? (remembered?.engine ?? firstContainerRuntime(available) ?? undefined)
|
||||
: value;
|
||||
writeContainerRuntimePreference(hostKey, {
|
||||
source: value,
|
||||
engine,
|
||||
context: contextName || undefined,
|
||||
namespace: namespaceName || undefined,
|
||||
});
|
||||
}, [capabilities, contextName, hostKey, namespaceName]);
|
||||
|
||||
const selectContext = useCallback((value: string) => {
|
||||
setContextName(value);
|
||||
const configured = contexts.find((item) => item.name === value)?.namespace ?? null;
|
||||
setNamespaceName(configured);
|
||||
}, [contexts]);
|
||||
|
||||
const reloadScopes = useCallback(() => {
|
||||
setScopeNonce((value) => value + 1);
|
||||
}, []);
|
||||
|
||||
const runtime: ContainerRuntimeId = source && source !== 'kubernetes' ? source : 'docker';
|
||||
const showRuntimeSwitcher = detected.length > 1;
|
||||
|
||||
return {
|
||||
source,
|
||||
selectSource,
|
||||
contextName,
|
||||
selectContext,
|
||||
namespaceName,
|
||||
setNamespaceName,
|
||||
contexts,
|
||||
namespaces,
|
||||
scopeError,
|
||||
reloadScopes,
|
||||
runtime,
|
||||
detected,
|
||||
showRuntimeSwitcher,
|
||||
};
|
||||
}
|
||||
@@ -1,7 +1,7 @@
|
||||
import assert from 'node:assert/strict';
|
||||
import { describe, it } from 'node:test';
|
||||
import { createSessionIdleManager } from '../../electron/bridges/mcpServerBridge/sessionIdleManager.cjs';
|
||||
import { STORAGE_KEY_AI_SESSION_IDLE_TIMEOUT_MINUTES } from '../../infrastructure/config/storageKeys';
|
||||
import { STORAGE_KEY_AI_SESSION_IDLE_TIMEOUT_MINUTES, STORAGE_KEY_AI_PRIVACY, STORAGE_KEY_AI_EXTERNAL_MCP_REDACTION, STORAGE_KEY_AI_EXTERNAL_MCP_ENABLED, STORAGE_KEY_AI_EXTERNAL_MCP_MODE } from '../../infrastructure/config/storageKeys';
|
||||
import { localStorageAdapter } from '../../infrastructure/persistence/localStorageAdapter';
|
||||
import {
|
||||
readExternalMcpHttpAllowCustomBind,
|
||||
@@ -16,6 +16,10 @@ import {
|
||||
} from "./useExternalMcpToggleState.ts";
|
||||
import {
|
||||
EXTERNAL_MCP_RUNTIME_STATUS_POLL_MS,
|
||||
applyAIPrivacySettings,
|
||||
readAIPrivacySettings,
|
||||
applyExternalMcpRedactionSettings,
|
||||
readExternalMcpRedactionSettings,
|
||||
bumpExternalMcpEnableGenerationForTests,
|
||||
createExternalMcpStartupSyncPlan,
|
||||
getExternalMcpEnableGenerationForTests,
|
||||
@@ -116,11 +120,12 @@ describe('useExternalMcpToggleState helpers', () => {
|
||||
externalMcpSetConfig: (config: { sessionIdleTimeoutMinutes: number }) => {
|
||||
received.push(config.sessionIdleTimeoutMinutes);
|
||||
manager.setTimeoutMinutes(config.sessionIdleTimeoutMinutes);
|
||||
return { ok: true };
|
||||
},
|
||||
};
|
||||
await syncExternalMcpStartupState(bridge);
|
||||
assert.equal(manager.getTimeoutMinutes(), expected, 'startup keeps the intended timeout');
|
||||
syncExternalMcpConfig(bridge);
|
||||
await syncExternalMcpConfig(bridge);
|
||||
assert.deepEqual(received, [expected, expected]);
|
||||
assert.equal(manager.getTimeoutMinutes(), expected, 'config sync keeps the intended timeout');
|
||||
assert.equal(
|
||||
@@ -452,3 +457,334 @@ describe("external MCP HTTP settings", () => {
|
||||
}
|
||||
});
|
||||
});
|
||||
|
||||
|
||||
describe('Shared AI privacy settings', () => {
|
||||
const settings = {
|
||||
enabled: true,
|
||||
redactPrivateIps: true,
|
||||
cidrs: ['192.0.2.0/24'],
|
||||
patterns: ['INTERNAL_[A-Z0-9]+'],
|
||||
};
|
||||
|
||||
it('defaults off and persists only after successful bridge validation', async () => {
|
||||
const restore = installMemoryLocalStorage();
|
||||
try {
|
||||
assert.deepEqual(readExternalMcpRedactionSettings(), {
|
||||
enabled: false, redactPrivateIps: false, cidrs: [], patterns: [],
|
||||
});
|
||||
let resolveConfig: (value: unknown) => void;
|
||||
const apply = applyExternalMcpRedactionSettings(settings, {
|
||||
externalMcpSetConfig: async (config) => {
|
||||
assert.deepEqual(config, { redaction: settings });
|
||||
return new Promise((resolve) => { resolveConfig = resolve; });
|
||||
},
|
||||
});
|
||||
assert.equal(localStorageAdapter.readString(STORAGE_KEY_AI_PRIVACY), null);
|
||||
resolveConfig!({ ok: true });
|
||||
await apply;
|
||||
assert.deepEqual(readExternalMcpRedactionSettings(), settings);
|
||||
assert.deepEqual(localStorageAdapter.read(STORAGE_KEY_AI_PRIVACY), settings);
|
||||
assert.equal(localStorageAdapter.readString(STORAGE_KEY_AI_EXTERNAL_MCP_REDACTION), null);
|
||||
} finally {
|
||||
restore();
|
||||
}
|
||||
});
|
||||
|
||||
it('reads legacy privacy preferences until a validated shared preference is applied', async () => {
|
||||
const restore = installMemoryLocalStorage();
|
||||
try {
|
||||
localStorageAdapter.write(STORAGE_KEY_AI_EXTERNAL_MCP_REDACTION, settings);
|
||||
assert.deepEqual(readAIPrivacySettings(), settings);
|
||||
assert.equal(localStorageAdapter.readString(STORAGE_KEY_AI_PRIVACY), null, 'reads must not migrate or discard data');
|
||||
const changed = { ...settings, redactPrivateIps: false };
|
||||
await applyAIPrivacySettings(changed, { externalMcpSetConfig: async () => ({ ok: true }) });
|
||||
assert.deepEqual(readAIPrivacySettings(), changed);
|
||||
assert.deepEqual(readExternalMcpRedactionSettings(), changed, 'legacy API must read the same shared policy');
|
||||
assert.deepEqual(localStorageAdapter.read(STORAGE_KEY_AI_PRIVACY), changed);
|
||||
assert.deepEqual(localStorageAdapter.read(STORAGE_KEY_AI_EXTERNAL_MCP_REDACTION), settings, 'keep legacy storage intact');
|
||||
} finally {
|
||||
restore();
|
||||
}
|
||||
});
|
||||
|
||||
it('never falls back to legacy settings when a shared preference exists, even if corrupt', () => {
|
||||
const restore = installMemoryLocalStorage();
|
||||
try {
|
||||
localStorageAdapter.write(STORAGE_KEY_AI_EXTERNAL_MCP_REDACTION, settings);
|
||||
const disabled = { enabled: false, redactPrivateIps: false, cidrs: [], patterns: [] };
|
||||
localStorageAdapter.write(STORAGE_KEY_AI_PRIVACY, disabled);
|
||||
assert.deepEqual(readAIPrivacySettings(), disabled);
|
||||
localStorageAdapter.writeString(STORAGE_KEY_AI_PRIVACY, '{');
|
||||
assert.throws(readAIPrivacySettings, /Invalid saved AI privacy settings/);
|
||||
localStorageAdapter.remove(STORAGE_KEY_AI_PRIVACY);
|
||||
localStorageAdapter.writeString(STORAGE_KEY_AI_EXTERNAL_MCP_REDACTION, '{');
|
||||
assert.throws(readAIPrivacySettings, /Invalid saved AI privacy settings/);
|
||||
} finally {
|
||||
restore();
|
||||
}
|
||||
});
|
||||
|
||||
it('restores the shared policy even when External MCP is disabled', async () => {
|
||||
const restore = installMemoryLocalStorage();
|
||||
try {
|
||||
resetExternalMcpStartupReadyForTests();
|
||||
localStorageAdapter.writeBoolean(STORAGE_KEY_AI_EXTERNAL_MCP_ENABLED, false);
|
||||
localStorageAdapter.write(STORAGE_KEY_AI_PRIVACY, settings);
|
||||
const calls: string[] = [];
|
||||
const plan = await syncExternalMcpStartupState({
|
||||
externalMcpSetConfig: async (config) => {
|
||||
assert.deepEqual(config.redaction, settings);
|
||||
calls.push('shared-policy');
|
||||
return { ok: true };
|
||||
},
|
||||
externalMcpSetEnabled: async (enabled) => { calls.push(`external:${enabled}`); },
|
||||
});
|
||||
assert.deepEqual(calls, ['shared-policy', 'external:false']);
|
||||
assert.equal(plan.runtimeEnabled, false);
|
||||
assert.deepEqual(readAIPrivacySettings(), settings);
|
||||
} finally {
|
||||
restore();
|
||||
}
|
||||
});
|
||||
|
||||
it('does not persist rejected, unverified or failed privacy changes', async () => {
|
||||
const restore = installMemoryLocalStorage();
|
||||
try {
|
||||
localStorageAdapter.write(STORAGE_KEY_AI_PRIVACY, settings);
|
||||
const changed = { ...settings, patterns: ['['] };
|
||||
for (const result of [{ ok: false, error: 'Invalid regular expression' }, { ok: false, error: '' }, undefined, {}]) {
|
||||
await assert.rejects(applyExternalMcpRedactionSettings(changed, {
|
||||
externalMcpSetConfig: async () => result,
|
||||
}));
|
||||
assert.deepEqual(readExternalMcpRedactionSettings(), settings);
|
||||
}
|
||||
await assert.rejects(applyExternalMcpRedactionSettings(changed, {
|
||||
externalMcpSetConfig: async () => { throw new Error('Disconnected'); },
|
||||
}), /Disconnected/);
|
||||
assert.deepEqual(readExternalMcpRedactionSettings(), settings);
|
||||
} finally {
|
||||
restore();
|
||||
}
|
||||
});
|
||||
|
||||
it('syncs saved privacy settings before persistent startup enable', async () => {
|
||||
const restore = installMemoryLocalStorage();
|
||||
try {
|
||||
resetExternalMcpStartupReadyForTests();
|
||||
localStorageAdapter.writeBoolean(STORAGE_KEY_AI_EXTERNAL_MCP_ENABLED, true);
|
||||
localStorageAdapter.writeString(STORAGE_KEY_AI_EXTERNAL_MCP_MODE, 'persistent');
|
||||
localStorageAdapter.write(STORAGE_KEY_AI_PRIVACY, settings);
|
||||
const calls: string[] = [];
|
||||
await syncExternalMcpStartupState({
|
||||
externalMcpSetConfig: async (config) => {
|
||||
assert.deepEqual(config.redaction, settings);
|
||||
calls.push('config');
|
||||
return { ok: true };
|
||||
},
|
||||
externalMcpSetEnabled: async (enabled) => { calls.push(`enabled:${enabled}`); },
|
||||
});
|
||||
assert.deepEqual(calls, ['config', 'enabled:true']);
|
||||
} finally {
|
||||
restore();
|
||||
}
|
||||
});
|
||||
|
||||
it('fails closed at startup when saved privacy settings are malformed', async () => {
|
||||
const restore = installMemoryLocalStorage();
|
||||
try {
|
||||
for (const saved of ['{', 'null', '{}', '{"enabled":"true"}', '{"enabled":false,"redactPrivateIps":false,"cidrs":[],"patterns":[2]}', '{"enabled":false,"redactPrivateIps":false,"cidrs":[],"patterns":[],"unexpected":true}']) {
|
||||
resetExternalMcpStartupReadyForTests();
|
||||
localStorageAdapter.writeBoolean(STORAGE_KEY_AI_EXTERNAL_MCP_ENABLED, true);
|
||||
localStorageAdapter.writeString(STORAGE_KEY_AI_EXTERNAL_MCP_MODE, 'persistent');
|
||||
localStorageAdapter.writeString(STORAGE_KEY_AI_PRIVACY, saved);
|
||||
const enabledCalls: boolean[] = [];
|
||||
let configCalls = 0;
|
||||
const bridge = {
|
||||
externalMcpSetConfig: async (config: unknown) => {
|
||||
assert.deepEqual(config, { redaction: null });
|
||||
configCalls += 1;
|
||||
return { ok: false, error: 'Shared AI privacy configuration is unavailable.' };
|
||||
},
|
||||
externalMcpSetEnabled: async (enabled: boolean) => { enabledCalls.push(enabled); },
|
||||
};
|
||||
const plan = await syncExternalMcpStartupState(bridge);
|
||||
assert.equal(plan.runtimeEnabled, false);
|
||||
assert.match(plan.configError!, /Invalid saved/);
|
||||
assert.equal(configCalls, 1, 'invalidate shared privacy instead of applying unprotected defaults');
|
||||
assert.deepEqual(enabledCalls, [false]);
|
||||
assert.equal(readExternalMcpStoredEnabled(), false);
|
||||
await assert.rejects(syncExternalMcpConfig(bridge), /Invalid saved/);
|
||||
assert.equal(configCalls, 2);
|
||||
}
|
||||
} finally {
|
||||
restore();
|
||||
}
|
||||
});
|
||||
|
||||
it('fails closed when the backend rejects or cannot verify startup config', async () => {
|
||||
const restore = installMemoryLocalStorage();
|
||||
try {
|
||||
for (const result of [{ ok: false, error: 'Invalid CIDR' }, { ok: false, error: '' }, undefined, {}]) {
|
||||
resetExternalMcpStartupReadyForTests();
|
||||
localStorageAdapter.writeBoolean(STORAGE_KEY_AI_EXTERNAL_MCP_ENABLED, true);
|
||||
localStorageAdapter.writeString(STORAGE_KEY_AI_EXTERNAL_MCP_MODE, 'persistent');
|
||||
localStorageAdapter.write(STORAGE_KEY_AI_PRIVACY, settings);
|
||||
const enabledCalls: boolean[] = [];
|
||||
const plan = await syncExternalMcpStartupState({
|
||||
externalMcpSetConfig: async () => result,
|
||||
externalMcpSetEnabled: async (enabled) => { enabledCalls.push(enabled); },
|
||||
});
|
||||
assert.equal(plan.runtimeEnabled, false);
|
||||
assert.ok(plan.configError);
|
||||
assert.deepEqual(enabledCalls, [false]);
|
||||
assert.deepEqual(readExternalMcpRedactionSettings(), settings);
|
||||
}
|
||||
} finally {
|
||||
restore();
|
||||
}
|
||||
});
|
||||
|
||||
it('preserves the corrupt-settings error if shared policy invalidation cannot reach the bridge', async () => {
|
||||
const restore = installMemoryLocalStorage();
|
||||
try {
|
||||
resetExternalMcpStartupReadyForTests();
|
||||
localStorageAdapter.writeString(STORAGE_KEY_AI_PRIVACY, '{');
|
||||
const calls: boolean[] = [];
|
||||
const bridge = {
|
||||
externalMcpSetConfig: async (config: unknown) => {
|
||||
assert.deepEqual(config, { redaction: null });
|
||||
throw new Error('Disconnected');
|
||||
},
|
||||
externalMcpSetEnabled: async (enabled: boolean) => { calls.push(enabled); },
|
||||
};
|
||||
const plan = await syncExternalMcpStartupState(bridge);
|
||||
assert.match(plan.configError!, /Invalid saved AI privacy settings/);
|
||||
assert.deepEqual(calls, [false]);
|
||||
await assert.rejects(syncExternalMcpConfig(bridge), /Invalid saved AI privacy settings/);
|
||||
assert.equal(localStorageAdapter.readString(STORAGE_KEY_AI_PRIVACY), '{');
|
||||
} finally {
|
||||
restore();
|
||||
}
|
||||
});
|
||||
|
||||
it('does not enable from a stale privacy snapshot changed during config sync', async () => {
|
||||
const restore = installMemoryLocalStorage();
|
||||
try {
|
||||
resetExternalMcpStartupReadyForTests();
|
||||
localStorageAdapter.writeBoolean(STORAGE_KEY_AI_EXTERNAL_MCP_ENABLED, true);
|
||||
localStorageAdapter.writeString(STORAGE_KEY_AI_EXTERNAL_MCP_MODE, 'persistent');
|
||||
const calls: boolean[] = [];
|
||||
const plan = await syncExternalMcpStartupState({
|
||||
externalMcpSetConfig: async () => {
|
||||
localStorageAdapter.write(STORAGE_KEY_AI_PRIVACY, settings);
|
||||
return { ok: true };
|
||||
},
|
||||
externalMcpSetEnabled: async (enabled) => { calls.push(enabled); },
|
||||
});
|
||||
assert.deepEqual(calls, [false]);
|
||||
assert.match(plan.configError!, /changed during startup/);
|
||||
await assert.rejects(syncExternalMcpConfig({
|
||||
externalMcpSetConfig: async () => {
|
||||
localStorageAdapter.write(STORAGE_KEY_AI_PRIVACY, { ...settings, cidrs: [] });
|
||||
return { ok: true };
|
||||
},
|
||||
}), /settings changed/);
|
||||
} finally {
|
||||
restore();
|
||||
}
|
||||
});
|
||||
|
||||
it('fails closed on a startup config transport failure', async () => {
|
||||
const restore = installMemoryLocalStorage();
|
||||
try {
|
||||
resetExternalMcpStartupReadyForTests();
|
||||
localStorageAdapter.writeBoolean(STORAGE_KEY_AI_EXTERNAL_MCP_ENABLED, true);
|
||||
localStorageAdapter.writeString(STORAGE_KEY_AI_EXTERNAL_MCP_MODE, 'persistent');
|
||||
const calls: boolean[] = [];
|
||||
const plan = await syncExternalMcpStartupState({
|
||||
externalMcpSetConfig: async () => { throw new Error('Disconnected'); },
|
||||
externalMcpSetEnabled: async (enabled) => { calls.push(enabled); },
|
||||
});
|
||||
assert.deepEqual(calls, [false]);
|
||||
assert.equal(plan.configError, 'Disconnected');
|
||||
} finally {
|
||||
restore();
|
||||
}
|
||||
});
|
||||
});
|
||||
|
||||
describe('External MCP manual enable privacy gate', () => {
|
||||
it('waits for validated config and keeps rejected or superseded enables off', async () => {
|
||||
const React = await import('react');
|
||||
const { act, create } = await import('react-test-renderer');
|
||||
const { useExternalMcpToggleState } = await import('./useExternalMcpToggleState.ts');
|
||||
const restoreStorage = installMemoryLocalStorage();
|
||||
const previousWindow = Object.getOwnPropertyDescriptor(globalThis, 'window');
|
||||
const previousAct = Object.getOwnPropertyDescriptor(globalThis, 'IS_REACT_ACT_ENVIRONMENT');
|
||||
const events = new EventTarget();
|
||||
let resolveConfig!: (value: unknown) => void;
|
||||
const calls: string[] = [];
|
||||
const bridge = {
|
||||
externalMcpSetConfig: async () => {
|
||||
calls.push('config');
|
||||
return new Promise((resolve) => { resolveConfig = resolve; });
|
||||
},
|
||||
externalMcpSetEnabled: async (enabled: boolean) => {
|
||||
calls.push(`enabled:${enabled}`);
|
||||
return { ok: true, enabled };
|
||||
},
|
||||
};
|
||||
Object.defineProperty(globalThis, 'IS_REACT_ACT_ENVIRONMENT', { value: true, configurable: true });
|
||||
Object.defineProperty(globalThis, 'window', {
|
||||
value: {
|
||||
netcatty: bridge,
|
||||
location: { hash: '#/settings' },
|
||||
addEventListener: events.addEventListener.bind(events),
|
||||
removeEventListener: events.removeEventListener.bind(events),
|
||||
dispatchEvent: events.dispatchEvent.bind(events),
|
||||
setInterval,
|
||||
clearInterval,
|
||||
},
|
||||
configurable: true,
|
||||
});
|
||||
let hook!: ReturnType<typeof useExternalMcpToggleState>;
|
||||
const Capture = () => { hook = useExternalMcpToggleState(); return null; };
|
||||
let renderer: ReturnType<typeof create> | undefined;
|
||||
try {
|
||||
resetExternalMcpStartupReadyForTests();
|
||||
await act(async () => { renderer = create(React.createElement(Capture)); });
|
||||
let pending!: Promise<void>;
|
||||
await act(async () => { pending = hook.setEnabled(true); });
|
||||
assert.deepEqual(calls, ['config']);
|
||||
assert.equal(hook.enabled, false);
|
||||
assert.equal(readExternalMcpStoredEnabled(), false);
|
||||
await act(async () => { resolveConfig({ ok: false, error: 'Invalid privacy rules' }); await pending; });
|
||||
assert.deepEqual(calls, ['config', 'enabled:false']);
|
||||
assert.equal(hook.enabled, false);
|
||||
assert.match(hook.error!, /Invalid privacy rules/);
|
||||
|
||||
calls.length = 0;
|
||||
await act(async () => { pending = hook.setEnabled(true); });
|
||||
await act(async () => { await hook.setEnabled(false); });
|
||||
await act(async () => { resolveConfig({ ok: true }); await pending; });
|
||||
assert.deepEqual(calls, ['config', 'enabled:false']);
|
||||
assert.equal(hook.enabled, false, 'a newer disable wins over a pending enable');
|
||||
|
||||
calls.length = 0;
|
||||
await act(async () => { pending = hook.setEnabled(true); });
|
||||
await act(async () => { resolveConfig({ ok: true }); await pending; });
|
||||
assert.deepEqual(calls, ['config', 'enabled:true']);
|
||||
assert.equal(hook.enabled, true);
|
||||
assert.equal(readExternalMcpStoredEnabled(), true);
|
||||
assert.equal(hook.error, null);
|
||||
} finally {
|
||||
if (renderer) await act(async () => renderer!.unmount());
|
||||
if (previousWindow) Object.defineProperty(globalThis, 'window', previousWindow);
|
||||
else Reflect.deleteProperty(globalThis, 'window');
|
||||
if (previousAct) Object.defineProperty(globalThis, 'IS_REACT_ACT_ENVIRONMENT', previousAct);
|
||||
else Reflect.deleteProperty(globalThis, 'IS_REACT_ACT_ENVIRONMENT');
|
||||
restoreStorage();
|
||||
}
|
||||
});
|
||||
});
|
||||
@@ -10,8 +10,10 @@ import {
|
||||
STORAGE_KEY_AI_EXTERNAL_MCP_HTTP_PORT,
|
||||
STORAGE_KEY_AI_EXTERNAL_MCP_IDLE_TIMEOUT_MINUTES,
|
||||
STORAGE_KEY_AI_EXTERNAL_MCP_MODE,
|
||||
STORAGE_KEY_AI_EXTERNAL_MCP_REDACTION,
|
||||
STORAGE_KEY_AI_EXTERNAL_MCP_SILENT_SESSIONS,
|
||||
STORAGE_KEY_AI_PERMISSION_MODE,
|
||||
STORAGE_KEY_AI_PRIVACY,
|
||||
STORAGE_KEY_AI_SESSION_IDLE_TIMEOUT_MINUTES,
|
||||
} from '../../infrastructure/config/storageKeys';
|
||||
import type { AIPermissionMode } from '../../infrastructure/ai/types';
|
||||
@@ -39,6 +41,7 @@ let externalMcpStartupReadyWaitPromise: Promise<void> | null = null;
|
||||
let externalMcpStartupReadyWaitResolve: (() => void) | null = null;
|
||||
/** Bumps on every intentional enable/disable so stale startup applies lose. */
|
||||
let externalMcpEnableGeneration = 0;
|
||||
let externalMcpConfigError: string | null = null;
|
||||
|
||||
function getWindowHash(hash?: string): string {
|
||||
if (typeof hash === 'string') return hash;
|
||||
@@ -76,6 +79,7 @@ export function resetExternalMcpStartupReadyForTests(): void {
|
||||
externalMcpStartupReadyWaitPromise = null;
|
||||
externalMcpStartupReadyWaitResolve = null;
|
||||
externalMcpEnableGeneration = 0;
|
||||
externalMcpConfigError = null;
|
||||
externalMcpStartupSyncPromise = null;
|
||||
}
|
||||
|
||||
@@ -110,6 +114,138 @@ export function waitForExternalMcpStartupReady(hash?: string): Promise<void> {
|
||||
return externalMcpStartupReadyWaitPromise;
|
||||
}
|
||||
|
||||
export type AIPrivacySettings = {
|
||||
enabled: boolean;
|
||||
redactPrivateIps: boolean;
|
||||
cidrs: string[];
|
||||
patterns: string[];
|
||||
};
|
||||
|
||||
export function defaultAIPrivacySettings(): AIPrivacySettings {
|
||||
return { enabled: false, redactPrivateIps: false, cidrs: [], patterns: [] };
|
||||
}
|
||||
|
||||
/** Missing settings are opt-out; corrupt settings must never silently opt out. */
|
||||
export function readAIPrivacySettings(): AIPrivacySettings {
|
||||
// The shared key wins, including invalid data: never fall back to a weaker legacy policy.
|
||||
// Leave the legacy value intact for older installs; Apply writes only the validated shared key.
|
||||
const stored = localStorageAdapter.readString(STORAGE_KEY_AI_PRIVACY)
|
||||
?? localStorageAdapter.readString(STORAGE_KEY_AI_EXTERNAL_MCP_REDACTION);
|
||||
if (stored === null) return defaultAIPrivacySettings();
|
||||
let value: unknown;
|
||||
try {
|
||||
value = JSON.parse(stored);
|
||||
} catch {
|
||||
throw new Error('Invalid saved AI privacy settings. Review and apply the settings again.');
|
||||
}
|
||||
if (!value || typeof value !== 'object' || Array.isArray(value)) {
|
||||
throw new Error('Invalid saved AI privacy settings. Review and apply the settings again.');
|
||||
}
|
||||
const settings = value as Record<string, unknown>;
|
||||
if (
|
||||
Object.keys(settings).some((key) => !['enabled', 'redactPrivateIps', 'cidrs', 'patterns'].includes(key))
|
||||
|| typeof settings.enabled !== 'boolean'
|
||||
|| typeof settings.redactPrivateIps !== 'boolean'
|
||||
|| !Array.isArray(settings.cidrs) || !settings.cidrs.every((item) => typeof item === 'string')
|
||||
|| !Array.isArray(settings.patterns) || !settings.patterns.every((item) => typeof item === 'string')
|
||||
) {
|
||||
throw new Error('Invalid saved AI privacy settings. Review and apply the settings again.');
|
||||
}
|
||||
return {
|
||||
enabled: settings.enabled,
|
||||
redactPrivateIps: settings.redactPrivateIps,
|
||||
cidrs: [...settings.cidrs],
|
||||
patterns: [...settings.patterns],
|
||||
};
|
||||
}
|
||||
|
||||
function configResponseError(
|
||||
result: unknown,
|
||||
unverifiedMessage = 'External MCP settings could not be verified. The bridge has not been enabled.',
|
||||
): string | null {
|
||||
if (result && typeof result === 'object' && 'ok' in result && result.ok === true) return null;
|
||||
if (result && typeof result === 'object' && 'error' in result && typeof result.error === 'string' && result.error) {
|
||||
return result.error;
|
||||
}
|
||||
return unverifiedMessage;
|
||||
}
|
||||
|
||||
/** Validate in the main process before committing any privacy preference. */
|
||||
export async function applyAIPrivacySettings(
|
||||
settings: AIPrivacySettings,
|
||||
bridge: ExternalMcpBridge | undefined = netcattyBridge.get(),
|
||||
): Promise<void> {
|
||||
const result = await bridge?.externalMcpSetConfig?.({ redaction: settings });
|
||||
const error = configResponseError(result, 'AI privacy settings could not be verified. Try applying them again.');
|
||||
if (error) throw new Error(error);
|
||||
if (!localStorageAdapter.write(STORAGE_KEY_AI_PRIVACY, settings)) {
|
||||
throw new Error('AI privacy settings could not be saved. Apply them again before restarting.');
|
||||
}
|
||||
externalMcpConfigError = null;
|
||||
emitAIStateChanged(STORAGE_KEY_AI_PRIVACY);
|
||||
}
|
||||
|
||||
function readAIPrivacyState() {
|
||||
try {
|
||||
return { settings: readAIPrivacySettings(), error: null as string | null };
|
||||
} catch (error) {
|
||||
return {
|
||||
settings: defaultAIPrivacySettings(),
|
||||
error: error instanceof Error ? error.message : String(error),
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
export function useAIPrivacyState() {
|
||||
const [state, setState] = useState(readAIPrivacyState);
|
||||
const [applying, setApplying] = useState(false);
|
||||
const apply = useCallback(async (settings: AIPrivacySettings): Promise<boolean> => {
|
||||
setApplying(true);
|
||||
try {
|
||||
await applyAIPrivacySettings(settings);
|
||||
setState({ settings, error: null });
|
||||
return true;
|
||||
} catch (error) {
|
||||
setState((current) => ({
|
||||
...current,
|
||||
error: error instanceof Error ? error.message : String(error),
|
||||
}));
|
||||
return false;
|
||||
} finally {
|
||||
setApplying(false);
|
||||
}
|
||||
}, []);
|
||||
|
||||
useEffect(() => {
|
||||
const onStorage = (event: StorageEvent) => {
|
||||
if (event.key === STORAGE_KEY_AI_PRIVACY || event.key === STORAGE_KEY_AI_EXTERNAL_MCP_REDACTION) {
|
||||
setState(readAIPrivacyState());
|
||||
}
|
||||
};
|
||||
const onLocalChange = (event: Event) => {
|
||||
const key = (event as CustomEvent<{ key?: string }>).detail?.key;
|
||||
if (key === STORAGE_KEY_AI_PRIVACY || key === STORAGE_KEY_AI_EXTERNAL_MCP_REDACTION) {
|
||||
setState(readAIPrivacyState());
|
||||
}
|
||||
};
|
||||
window.addEventListener('storage', onStorage);
|
||||
window.addEventListener(AI_STATE_CHANGED_EVENT, onLocalChange);
|
||||
return () => {
|
||||
window.removeEventListener('storage', onStorage);
|
||||
window.removeEventListener(AI_STATE_CHANGED_EVENT, onLocalChange);
|
||||
};
|
||||
}, []);
|
||||
|
||||
return { ...state, applying, apply };
|
||||
}
|
||||
|
||||
/** Compatibility exports for existing External MCP configuration callers. One shared policy only. */
|
||||
export type ExternalMcpRedactionSettings = AIPrivacySettings;
|
||||
export const defaultExternalMcpRedactionSettings = defaultAIPrivacySettings;
|
||||
export const readExternalMcpRedactionSettings = readAIPrivacySettings;
|
||||
export const applyExternalMcpRedactionSettings = applyAIPrivacySettings;
|
||||
export const useExternalMcpRedactionState = useAIPrivacyState;
|
||||
|
||||
export type ExternalMcpHttpSettings = {
|
||||
enabled: boolean;
|
||||
port: number;
|
||||
@@ -119,9 +255,11 @@ export type ExternalMcpHttpSettings = {
|
||||
};
|
||||
|
||||
type ExternalMcpConfig = {
|
||||
mode: ExternalMcpMode;
|
||||
idleTimeoutMinutes: number;
|
||||
sessionIdleTimeoutMinutes: number;
|
||||
mode?: ExternalMcpMode;
|
||||
idleTimeoutMinutes?: number;
|
||||
sessionIdleTimeoutMinutes?: number;
|
||||
/** null invalidates shared privacy when persisted settings cannot be read safely. */
|
||||
redaction?: AIPrivacySettings | null;
|
||||
httpEnabled?: boolean;
|
||||
httpPort?: number;
|
||||
httpAllowCustomBind?: boolean;
|
||||
@@ -146,6 +284,7 @@ export type ExternalMcpStartupSyncPlan = {
|
||||
runtimeEnabled: boolean;
|
||||
storedEnabled: boolean;
|
||||
shouldPersistStoredEnabled: boolean;
|
||||
configError?: string;
|
||||
};
|
||||
|
||||
export function normalizeExternalMcpMode(value: string | null): ExternalMcpMode {
|
||||
@@ -385,6 +524,7 @@ export function createExternalMcpStartupSyncPlan({
|
||||
mode,
|
||||
idleTimeoutMinutes,
|
||||
sessionIdleTimeoutMinutes,
|
||||
redaction = defaultAIPrivacySettings(),
|
||||
http = {
|
||||
enabled: false,
|
||||
port: DEFAULT_EXTERNAL_MCP_HTTP_PORT,
|
||||
@@ -398,6 +538,7 @@ export function createExternalMcpStartupSyncPlan({
|
||||
idleTimeoutMinutes: number;
|
||||
sessionIdleTimeoutMinutes: number;
|
||||
http?: ExternalMcpHttpSettings;
|
||||
redaction?: AIPrivacySettings;
|
||||
}): ExternalMcpStartupSyncPlan {
|
||||
const runtimeEnabled = shouldStartExternalMcpOnStartup({ enabled, mode });
|
||||
const storedEnabled = runtimeEnabled;
|
||||
@@ -406,6 +547,7 @@ export function createExternalMcpStartupSyncPlan({
|
||||
mode,
|
||||
idleTimeoutMinutes,
|
||||
sessionIdleTimeoutMinutes,
|
||||
redaction,
|
||||
...httpConfigFromSettings(http),
|
||||
},
|
||||
runtimeEnabled,
|
||||
@@ -415,23 +557,44 @@ export function createExternalMcpStartupSyncPlan({
|
||||
}
|
||||
|
||||
export function readExternalMcpStartupSyncPlan(): ExternalMcpStartupSyncPlan {
|
||||
return createExternalMcpStartupSyncPlan({
|
||||
let redaction = defaultAIPrivacySettings();
|
||||
let configError: string | undefined;
|
||||
try {
|
||||
redaction = readAIPrivacySettings();
|
||||
} catch (error) {
|
||||
configError = error instanceof Error ? error.message : String(error);
|
||||
}
|
||||
const plan = createExternalMcpStartupSyncPlan({
|
||||
enabled: readExternalMcpStoredEnabled(),
|
||||
mode: readExternalMcpMode(),
|
||||
idleTimeoutMinutes: readExternalMcpIdleTimeoutMinutes(),
|
||||
sessionIdleTimeoutMinutes: readSessionIdleTimeoutMinutes(),
|
||||
http: readExternalMcpHttpSettings(),
|
||||
redaction,
|
||||
});
|
||||
return configError ? { ...plan, runtimeEnabled: false, configError } : plan;
|
||||
}
|
||||
|
||||
export function syncExternalMcpConfig(bridge: ExternalMcpBridge | undefined = netcattyBridge.get()): void {
|
||||
const http = readExternalMcpHttpSettings();
|
||||
void bridge?.externalMcpSetConfig?.({
|
||||
mode: readExternalMcpMode(),
|
||||
idleTimeoutMinutes: readExternalMcpIdleTimeoutMinutes(),
|
||||
sessionIdleTimeoutMinutes: readSessionIdleTimeoutMinutes(),
|
||||
...httpConfigFromSettings(http),
|
||||
});
|
||||
export async function syncExternalMcpConfig(
|
||||
bridge: ExternalMcpBridge | undefined = netcattyBridge.get(),
|
||||
): Promise<void> {
|
||||
const plan = readExternalMcpStartupSyncPlan();
|
||||
if (plan.configError) {
|
||||
// A corrupt saved policy must block all managed agents, not only External MCP.
|
||||
// Keep the original recovery error even if notifying the main process fails.
|
||||
try {
|
||||
await bridge?.externalMcpSetConfig?.({ redaction: null });
|
||||
} catch {
|
||||
// The saved policy still needs user repair regardless of bridge availability.
|
||||
}
|
||||
throw new Error(plan.configError);
|
||||
}
|
||||
const result = await bridge?.externalMcpSetConfig?.(plan.config);
|
||||
const error = configResponseError(result);
|
||||
if (error) throw new Error(error);
|
||||
if (JSON.stringify(readAIPrivacySettings()) !== JSON.stringify(plan.config.redaction)) {
|
||||
throw new Error('AI privacy settings changed. Try enabling the bridge again.');
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -453,10 +616,18 @@ export async function syncExternalMcpStartupState(
|
||||
} catch {
|
||||
// Permission sync is best-effort; the main process keeps its safe default.
|
||||
}
|
||||
let configError = initialPlan.configError;
|
||||
try {
|
||||
await Promise.resolve(bridge?.externalMcpSetConfig?.(initialPlan.config));
|
||||
} catch {
|
||||
// Config sync is best-effort; continue with enable/disable reconcile.
|
||||
if (configError) {
|
||||
// Explicit invalidation is distinct from a rejected draft Apply, which keeps
|
||||
// the previous working policy. Never replace corrupt saved data with defaults.
|
||||
await Promise.resolve(bridge?.externalMcpSetConfig?.({ redaction: null }));
|
||||
} else {
|
||||
const result = await Promise.resolve(bridge?.externalMcpSetConfig?.(initialPlan.config));
|
||||
configError = configResponseError(result) ?? undefined;
|
||||
}
|
||||
} catch (error) {
|
||||
configError ??= error instanceof Error ? error.message : String(error);
|
||||
}
|
||||
|
||||
// A user toggle during config await invalidates this startup apply.
|
||||
@@ -465,6 +636,18 @@ export async function syncExternalMcpStartupState(
|
||||
}
|
||||
|
||||
const plan = readExternalMcpStartupSyncPlan();
|
||||
if (JSON.stringify(plan.config.redaction) !== JSON.stringify(initialPlan.config.redaction)) {
|
||||
configError = 'AI privacy settings changed during startup. Enable the bridge again.';
|
||||
}
|
||||
if (configError || plan.configError) {
|
||||
// A rejected or unverified config must never expose an unprotected runtime.
|
||||
plan.configError = configError || plan.configError;
|
||||
plan.runtimeEnabled = false;
|
||||
plan.storedEnabled = false;
|
||||
plan.shouldPersistStoredEnabled = readExternalMcpStoredEnabled();
|
||||
}
|
||||
externalMcpConfigError = plan.configError ?? null;
|
||||
emitAIStateChanged(STORAGE_KEY_AI_PRIVACY);
|
||||
if (plan.shouldPersistStoredEnabled) {
|
||||
localStorageAdapter.writeBoolean(STORAGE_KEY_AI_EXTERNAL_MCP_ENABLED, plan.storedEnabled);
|
||||
emitAIStateChanged(STORAGE_KEY_AI_EXTERNAL_MCP_ENABLED);
|
||||
@@ -516,24 +699,47 @@ export function useExternalMcpToggleState() {
|
||||
emitAIStateChanged(STORAGE_KEY_AI_EXTERNAL_MCP_ENABLED);
|
||||
}, []);
|
||||
|
||||
const setEnabled = useCallback((nextEnabled: boolean) => {
|
||||
persistEnabled(nextEnabled);
|
||||
// Peer session windows can mirror the stored switch, but they must not own
|
||||
// the main-process External MCP lifecycle.
|
||||
if (isPeerSessionWindow) return;
|
||||
externalMcpEnableGeneration += 1;
|
||||
// Re-emit after the lifecycle IPC settles so consumers that refetch runtime
|
||||
// status (e.g. Tool Access guidance) do not act on the pre-IPC storage
|
||||
// event, which can still observe the previous enabled state.
|
||||
void Promise.resolve(netcattyBridge.get()?.externalMcpSetEnabled?.(nextEnabled)).finally(() => {
|
||||
const [error, setError] = useState<string | null>(() => externalMcpConfigError);
|
||||
|
||||
const setEnabled = useCallback(async (nextEnabled: boolean) => {
|
||||
// Peer session windows mirror storage, but never own the main lifecycle.
|
||||
if (isPeerSessionWindow) {
|
||||
persistEnabled(nextEnabled);
|
||||
return;
|
||||
}
|
||||
if (!nextEnabled) persistEnabled(false);
|
||||
const generation = ++externalMcpEnableGeneration;
|
||||
setError(null);
|
||||
externalMcpConfigError = null;
|
||||
const bridge = netcattyBridge.get();
|
||||
try {
|
||||
// Even a manual enable must apply and verify saved privacy settings first.
|
||||
if (nextEnabled) await syncExternalMcpConfig(bridge);
|
||||
if (generation !== externalMcpEnableGeneration) return;
|
||||
const result = await bridge?.externalMcpSetEnabled?.(nextEnabled);
|
||||
if (nextEnabled && result && typeof result === 'object' && 'ok' in result && result.ok === false) {
|
||||
throw new Error('error' in result && typeof result.error === 'string' && result.error
|
||||
? result.error : 'External MCP could not be enabled.');
|
||||
}
|
||||
if (generation === externalMcpEnableGeneration) persistEnabled(nextEnabled);
|
||||
} catch (cause) {
|
||||
if (generation !== externalMcpEnableGeneration) return;
|
||||
externalMcpConfigError = cause instanceof Error ? cause.message : String(cause);
|
||||
setError(externalMcpConfigError);
|
||||
if (nextEnabled) {
|
||||
persistEnabled(false);
|
||||
await Promise.resolve(bridge?.externalMcpSetEnabled?.(false)).catch(() => undefined);
|
||||
}
|
||||
} finally {
|
||||
emitAIStateChanged(STORAGE_KEY_AI_EXTERNAL_MCP_ENABLED);
|
||||
});
|
||||
}
|
||||
}, [isPeerSessionWindow, persistEnabled]);
|
||||
|
||||
useEffect(() => {
|
||||
const syncFromStorage = () => {
|
||||
const nextEnabled = readExternalMcpStoredEnabled();
|
||||
setEnabledRaw(nextEnabled);
|
||||
setError(externalMcpConfigError);
|
||||
};
|
||||
|
||||
const handleStorage = (event: StorageEvent) => {
|
||||
@@ -542,7 +748,7 @@ export function useExternalMcpToggleState() {
|
||||
};
|
||||
const handleLocalStateChanged = (event: Event) => {
|
||||
const key = (event as CustomEvent<{ key?: string }>).detail?.key;
|
||||
if (key !== STORAGE_KEY_AI_EXTERNAL_MCP_ENABLED) return;
|
||||
if (key !== STORAGE_KEY_AI_EXTERNAL_MCP_ENABLED && key !== STORAGE_KEY_AI_EXTERNAL_MCP_REDACTION) return;
|
||||
syncFromStorage();
|
||||
};
|
||||
|
||||
@@ -587,5 +793,5 @@ export function useExternalMcpToggleState() {
|
||||
};
|
||||
}, [enabled, isPeerSessionWindow, persistEnabled]);
|
||||
|
||||
return { enabled, setEnabled };
|
||||
return { enabled, setEnabled, error };
|
||||
}
|
||||
@@ -1,4 +1,5 @@
|
||||
import { useCallback, useMemo } from 'react';
|
||||
import type { ContainerRuntimeId } from '../../domain/systemManager/containerRuntime';
|
||||
import type {
|
||||
DockerContainerAction,
|
||||
DockerImageManageAction,
|
||||
@@ -94,23 +95,27 @@ export function useSystemManagerBackend() {
|
||||
return bridge.tmuxAction(options);
|
||||
}, []);
|
||||
|
||||
const listDockerContainers = useCallback(async (sessionId: string) => {
|
||||
const listDockerContainers = useCallback(async (sessionId: string, runtime?: ContainerRuntimeId) => {
|
||||
const bridge = netcattyBridge.get();
|
||||
if (!bridge?.listDockerContainers) {
|
||||
return { success: false as const, error: 'listDockerContainers unavailable' };
|
||||
}
|
||||
return bridge.listDockerContainers(sessionId);
|
||||
return bridge.listDockerContainers(sessionId, runtime);
|
||||
}, []);
|
||||
|
||||
const listDockerImages = useCallback(async (sessionId: string) => {
|
||||
const listDockerImages = useCallback(async (sessionId: string, runtime?: ContainerRuntimeId) => {
|
||||
const bridge = netcattyBridge.get();
|
||||
if (!bridge?.listDockerImages) {
|
||||
return { success: false as const, error: 'listDockerImages unavailable' };
|
||||
}
|
||||
return bridge.listDockerImages(sessionId);
|
||||
return bridge.listDockerImages(sessionId, runtime);
|
||||
}, []);
|
||||
|
||||
const getDockerStats = useCallback(async (options: { sessionId: string; ids?: string[] }) => {
|
||||
const getDockerStats = useCallback(async (options: {
|
||||
sessionId: string;
|
||||
ids?: string[];
|
||||
runtime?: ContainerRuntimeId;
|
||||
}) => {
|
||||
const bridge = netcattyBridge.get();
|
||||
if (!bridge?.getDockerStats) {
|
||||
return { success: false as const, error: 'getDockerStats unavailable' };
|
||||
@@ -155,7 +160,11 @@ export function useSystemManagerBackend() {
|
||||
return bridge.systemServiceAction(options);
|
||||
}, []);
|
||||
|
||||
const dockerInspect = useCallback(async (options: { sessionId: string; containerId: string }) => {
|
||||
const dockerInspect = useCallback(async (options: {
|
||||
sessionId: string;
|
||||
containerId: string;
|
||||
runtime?: ContainerRuntimeId;
|
||||
}) => {
|
||||
const bridge = netcattyBridge.get();
|
||||
if (!bridge?.dockerInspect) {
|
||||
return { success: false as const, error: 'dockerInspect unavailable' };
|
||||
@@ -163,7 +172,11 @@ export function useSystemManagerBackend() {
|
||||
return bridge.dockerInspect(options);
|
||||
}, []);
|
||||
|
||||
const dockerImageInspect = useCallback(async (options: { sessionId: string; imageId: string }) => {
|
||||
const dockerImageInspect = useCallback(async (options: {
|
||||
sessionId: string;
|
||||
imageId: string;
|
||||
runtime?: ContainerRuntimeId;
|
||||
}) => {
|
||||
const bridge = netcattyBridge.get();
|
||||
if (!bridge?.dockerImageInspect) {
|
||||
return { success: false as const, error: 'dockerImageInspect unavailable' };
|
||||
@@ -176,6 +189,7 @@ export function useSystemManagerBackend() {
|
||||
containerId: string;
|
||||
action: DockerContainerAction;
|
||||
newName?: string;
|
||||
runtime?: ContainerRuntimeId;
|
||||
}) => {
|
||||
const bridge = netcattyBridge.get();
|
||||
if (!bridge?.dockerAction) {
|
||||
@@ -184,7 +198,10 @@ export function useSystemManagerBackend() {
|
||||
return bridge.dockerAction(options);
|
||||
}, []);
|
||||
|
||||
const dockerImageAction = useCallback(async (options: { sessionId: string } & DockerImageManageAction) => {
|
||||
const dockerImageAction = useCallback(async (options: {
|
||||
sessionId: string;
|
||||
runtime?: ContainerRuntimeId;
|
||||
} & DockerImageManageAction) => {
|
||||
const bridge = netcattyBridge.get();
|
||||
if (!bridge?.dockerImageAction) {
|
||||
return { success: false as const, error: 'dockerImageAction unavailable' };
|
||||
@@ -192,6 +209,21 @@ export function useSystemManagerBackend() {
|
||||
return bridge.dockerImageAction(options);
|
||||
}, []);
|
||||
|
||||
const kubectl = useCallback(async (options: {
|
||||
sessionId: string;
|
||||
op: 'contexts' | 'namespaces' | 'pods' | 'delete';
|
||||
context?: string;
|
||||
namespace?: string;
|
||||
pod?: string;
|
||||
shell?: string;
|
||||
}) => {
|
||||
const bridge = netcattyBridge.get();
|
||||
if (!bridge?.kubectl) {
|
||||
return { success: false as const, error: 'kubectl unavailable' };
|
||||
}
|
||||
return bridge.kubectl(options);
|
||||
}, []);
|
||||
|
||||
const openTerminalPopup = useCallback(async (
|
||||
payload: Parameters<NonNullable<NetcattyBridge['openTerminalPopup']>>[0],
|
||||
) => {
|
||||
@@ -223,6 +255,7 @@ export function useSystemManagerBackend() {
|
||||
dockerImageInspect,
|
||||
dockerAction,
|
||||
dockerImageAction,
|
||||
kubectl,
|
||||
openTerminalPopup,
|
||||
}), [
|
||||
probeSystemCapabilities,
|
||||
@@ -245,6 +278,7 @@ export function useSystemManagerBackend() {
|
||||
dockerImageInspect,
|
||||
dockerAction,
|
||||
dockerImageAction,
|
||||
kubectl,
|
||||
openTerminalPopup,
|
||||
]);
|
||||
}
|
||||
@@ -8,6 +8,7 @@ import {
|
||||
Search,
|
||||
Terminal,
|
||||
TerminalSquare,
|
||||
Zap,
|
||||
} from "lucide-react";
|
||||
import React, { memo, useCallback, useEffect, useMemo, useRef, useState } from "react";
|
||||
import { useI18n } from "../application/i18n/I18nProvider";
|
||||
@@ -609,8 +610,8 @@ const QuickSwitcherInner: React.FC<QuickSwitcherProps> = ({
|
||||
className={rowClass}
|
||||
onClick={() => handleItemSelect(item)}
|
||||
>
|
||||
<div className="flex h-6 w-6 shrink-0 items-center justify-center rounded text-muted-foreground">
|
||||
<Terminal size={16} />
|
||||
<div className="shrink-0 flex items-center justify-center bg-primary text-primary-foreground h-5 w-5 rounded-[5px]">
|
||||
<Zap className="h-3 w-3" fill="currentColor" />
|
||||
</div>
|
||||
<span className="min-w-0 truncate text-sm font-medium">{item.title}</span>
|
||||
</div>
|
||||
|
||||
@@ -51,6 +51,7 @@ import { CopilotCliCard } from "./ai/CopilotCliCard";
|
||||
import { CodebuddyCard } from "./ai/CodebuddyCard";
|
||||
import { SafetySettings } from "./ai/SafetySettings";
|
||||
import { ExternalMcpCard } from "./ai/ExternalMcpCard";
|
||||
import { AIPrivacySection } from "./ai/AIPrivacySection";
|
||||
import { ToolAccessGuidance } from "./ai/ToolAccessGuidance";
|
||||
import { PermissionGrantsSettings } from "./ai/PermissionGrantsSettings";
|
||||
import { useAIPermissionGrantsState } from "../../../application/state/useAIPermissionGrantsState";
|
||||
@@ -1297,6 +1298,8 @@ const SettingsAITab: React.FC<SettingsAITabProps> = ({
|
||||
</SettingCard>
|
||||
</SettingsSection>
|
||||
|
||||
<AIPrivacySection />
|
||||
|
||||
<SettingsSection anchorId="ai-external-mcp" title={t('ai.externalMcp.title')}>
|
||||
<ExternalMcpCard />
|
||||
</SettingsSection>
|
||||
|
||||
@@ -0,0 +1,183 @@
|
||||
import assert from 'node:assert/strict';
|
||||
import { readFile } from 'node:fs/promises';
|
||||
import test from 'node:test';
|
||||
import React from 'react';
|
||||
import { act, create, type ReactTestRenderer } from 'react-test-renderer';
|
||||
import { I18nProvider } from '../../../../application/i18n/I18nProvider';
|
||||
import { STORAGE_KEY_AI_PRIVACY } from '../../../../infrastructure/config/storageKeys';
|
||||
import { AIPrivacySection } from './AIPrivacySection';
|
||||
|
||||
function installEnvironment(setConfig: (config: unknown) => Promise<unknown>) {
|
||||
const values = new Map<string, string>();
|
||||
const events = new EventTarget();
|
||||
const globals = {
|
||||
IS_REACT_ACT_ENVIRONMENT: true,
|
||||
localStorage: {
|
||||
getItem: (key: string) => values.get(key) ?? null,
|
||||
setItem: (key: string, value: string) => values.set(key, value),
|
||||
},
|
||||
window: {
|
||||
netcatty: { externalMcpSetConfig: setConfig },
|
||||
addEventListener: events.addEventListener.bind(events),
|
||||
removeEventListener: events.removeEventListener.bind(events),
|
||||
dispatchEvent: events.dispatchEvent.bind(events),
|
||||
},
|
||||
};
|
||||
const previous = new Map<string, PropertyDescriptor | undefined>();
|
||||
for (const [name, value] of Object.entries(globals)) {
|
||||
previous.set(name, Object.getOwnPropertyDescriptor(globalThis, name));
|
||||
Object.defineProperty(globalThis, name, { value, configurable: true, writable: true });
|
||||
}
|
||||
return {
|
||||
values,
|
||||
restore: () => {
|
||||
for (const [name, descriptor] of previous) {
|
||||
if (descriptor) Object.defineProperty(globalThis, name, descriptor);
|
||||
else Reflect.deleteProperty(globalThis, name);
|
||||
}
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
function view() {
|
||||
return <I18nProvider locale="en"><AIPrivacySection /></I18nProvider>;
|
||||
}
|
||||
|
||||
test('privacy settings default off and explain their limits and connection scope', async () => {
|
||||
const env = installEnvironment(async () => ({ ok: true }));
|
||||
let renderer: ReactTestRenderer | undefined;
|
||||
try {
|
||||
await act(async () => { renderer = create(view()); });
|
||||
const markup = JSON.stringify(renderer!.toJSON());
|
||||
assert.equal(renderer!.root.findAllByType('button').filter((node) => node.props['aria-checked'] === false).length, 2);
|
||||
assert.match(markup, /cannot detect every secret/);
|
||||
assert.match(markup, /AI privacy/);
|
||||
assert.match(markup, /Catty, integrated agents and external MCP clients/);
|
||||
assert.match(markup, /Only Netcatty-managed data and tools are covered/);
|
||||
assert.match(markup, /native tools, direct file access and other MCP servers/);
|
||||
assert.match(markup, /Binary tool responses are blocked/);
|
||||
assert.match(markup, /one managed conversation or external MCP connection/);
|
||||
assert.match(markup, /reconnect external MCP clients/);
|
||||
assert.match(markup, /start a new managed conversation/);
|
||||
assert.match(markup, /Additional IP ranges/);
|
||||
assert.match(markup, /Custom regular expressions/);
|
||||
} finally {
|
||||
if (renderer) await act(async () => { renderer!.unmount(); });
|
||||
env.restore();
|
||||
}
|
||||
});
|
||||
|
||||
test('AI privacy is shared Tool Access UI, not nested under External MCP', async () => {
|
||||
const settingsTab = await readFile(new URL('../SettingsAITab.tsx', import.meta.url), 'utf8');
|
||||
const externalCard = await readFile(new URL('./ExternalMcpCard.tsx', import.meta.url), 'utf8');
|
||||
const toolsTab = settingsTab.split('<TabsContent value="tools"')[1].split('</TabsContent>')[0];
|
||||
assert.match(toolsTab, /<\/SettingsSection>\s*<AIPrivacySection \/>\s*<SettingsSection anchorId="ai-external-mcp"/);
|
||||
assert.doesNotMatch(externalCard, /AIPrivacySection|ExternalMcpRedactionSection/);
|
||||
});
|
||||
|
||||
test('privacy drafts apply together, retain rejected input and persist only validated settings', async () => {
|
||||
const calls: unknown[] = [];
|
||||
let accepted = false;
|
||||
const env = installEnvironment(async (config) => {
|
||||
calls.push(config);
|
||||
return accepted ? { ok: true } : { ok: false, error: 'Invalid privacy rules.' };
|
||||
});
|
||||
let renderer: ReactTestRenderer | undefined;
|
||||
try {
|
||||
await act(async () => { renderer = create(view()); });
|
||||
const root = renderer!.root;
|
||||
const privacySwitch = () => root.findAllByType('button').find((node) => (
|
||||
node.props['aria-label'] === 'Enable text redaction'
|
||||
))!;
|
||||
const apply = () => root.findAllByType('button').find((node) => node.props.children === 'Apply')!;
|
||||
await act(async () => { privacySwitch().props.onClick(); });
|
||||
await act(async () => {
|
||||
root.findAllByType('textarea')[0].props.onChange({ currentTarget: { value: '192.0.2.0/24\n\n2001:db8::/32' } });
|
||||
root.findAllByType('textarea')[1].props.onChange({ currentTarget: { value: '[' } });
|
||||
});
|
||||
assert.equal(calls.length, 0, 'draft edits must not change runtime or storage');
|
||||
assert.equal(env.values.get(STORAGE_KEY_AI_PRIVACY), undefined);
|
||||
await act(async () => { apply().props.onClick(); });
|
||||
assert.equal(root.findAllByType('textarea')[1].props.value, '[');
|
||||
assert.match(root.findByProps({ role: 'alert' }).children.join(''), /Invalid privacy rules/);
|
||||
assert.equal(env.values.get(STORAGE_KEY_AI_PRIVACY), undefined);
|
||||
|
||||
accepted = true;
|
||||
await act(async () => {
|
||||
root.findAllByType('textarea')[1].props.onChange({ currentTarget: { value: 'INTERNAL_[A-Z0-9]+' } });
|
||||
});
|
||||
await act(async () => { apply().props.onClick(); });
|
||||
const saved = JSON.parse(env.values.get(STORAGE_KEY_AI_PRIVACY)!);
|
||||
assert.deepEqual(saved, {
|
||||
enabled: true,
|
||||
redactPrivateIps: false,
|
||||
cidrs: ['192.0.2.0/24', '2001:db8::/32'],
|
||||
patterns: ['INTERNAL_[A-Z0-9]+'],
|
||||
});
|
||||
assert.deepEqual(calls[1], { redaction: saved });
|
||||
assert.equal(root.findAllByProps({ role: 'alert' }).length, 0);
|
||||
assert.match(root.findByProps({ role: 'status' }).children.join(''), /new managed conversation/);
|
||||
} finally {
|
||||
if (renderer) await act(async () => { renderer!.unmount(); });
|
||||
env.restore();
|
||||
}
|
||||
});
|
||||
|
||||
test('malformed saved privacy settings remain visible as a recoverable error', async () => {
|
||||
const env = installEnvironment(async () => ({ ok: true }));
|
||||
let renderer: ReactTestRenderer | undefined;
|
||||
try {
|
||||
env.values.set(STORAGE_KEY_AI_PRIVACY, '{');
|
||||
await act(async () => { renderer = create(view()); });
|
||||
assert.match(renderer!.root.findByProps({ role: 'alert' }).children.join(''), /Invalid saved AI privacy settings/);
|
||||
assert.ok(renderer!.root.findAllByType('button').some((node) => node.props.children === 'Apply'));
|
||||
assert.equal(env.values.get(STORAGE_KEY_AI_PRIVACY), '{');
|
||||
} finally {
|
||||
if (renderer) await act(async () => { renderer!.unmount(); });
|
||||
env.restore();
|
||||
}
|
||||
});
|
||||
|
||||
test('privacy apply disables draft controls until shared validation settles', async () => {
|
||||
let resolveConfig!: (result: unknown) => void;
|
||||
const env = installEnvironment(() => new Promise((resolve) => { resolveConfig = resolve; }));
|
||||
let renderer: ReactTestRenderer | undefined;
|
||||
try {
|
||||
await act(async () => { renderer = create(view()); });
|
||||
const root = renderer!.root;
|
||||
await act(async () => {
|
||||
root.findAllByType('button').find((node) => node.props['aria-label'] === 'Enable text redaction')!.props.onClick();
|
||||
});
|
||||
await act(async () => {
|
||||
root.findAllByType('button').find((node) => node.props.children === 'Apply')!.props.onClick();
|
||||
});
|
||||
assert.ok(root.findAllByType('button').every((node) => node.props.disabled));
|
||||
assert.ok(root.findAllByType('textarea').every((node) => node.props.disabled));
|
||||
assert.equal(env.values.get(STORAGE_KEY_AI_PRIVACY), undefined);
|
||||
await act(async () => { resolveConfig({ ok: true }); });
|
||||
assert.ok(root.findAllByType('button').every((node) => !node.props.disabled));
|
||||
assert.equal(JSON.parse(env.values.get(STORAGE_KEY_AI_PRIVACY)!).enabled, true);
|
||||
} finally {
|
||||
if (renderer) await act(async () => { renderer!.unmount(); });
|
||||
env.restore();
|
||||
}
|
||||
});
|
||||
|
||||
test('shared privacy controls refresh when another window changes the shared preference', async () => {
|
||||
const env = installEnvironment(async () => ({ ok: true }));
|
||||
let renderer: ReactTestRenderer | undefined;
|
||||
try {
|
||||
await act(async () => { renderer = create(view()); });
|
||||
const settings = { enabled: true, redactPrivateIps: true, cidrs: ['192.0.2.0/24'], patterns: ['INTERNAL_.*'] };
|
||||
env.values.set(STORAGE_KEY_AI_PRIVACY, JSON.stringify(settings));
|
||||
const event = new Event('storage');
|
||||
Object.defineProperty(event, 'key', { value: STORAGE_KEY_AI_PRIVACY });
|
||||
await act(async () => { window.dispatchEvent(event); });
|
||||
assert.equal(renderer!.root.findAllByType('button').filter((node) => node.props['aria-checked'] === true).length, 2);
|
||||
assert.equal(renderer!.root.findAllByType('textarea')[0].props.value, '192.0.2.0/24');
|
||||
assert.equal(renderer!.root.findAllByType('textarea')[1].props.value, 'INTERNAL_.*');
|
||||
} finally {
|
||||
if (renderer) await act(async () => { renderer!.unmount(); });
|
||||
env.restore();
|
||||
}
|
||||
});
|
||||
@@ -0,0 +1,105 @@
|
||||
import React, { useEffect, useState } from "react";
|
||||
import { useI18n } from "../../../../application/i18n/I18nProvider";
|
||||
import { useAIPrivacyState } from "../../../../application/state/useExternalMcpToggleState";
|
||||
import { Button } from "../../../ui/button";
|
||||
import { SettingCard, SettingRow, SettingsSection, Toggle } from "../../settings-ui";
|
||||
|
||||
export const AIPrivacySection: React.FC = () => {
|
||||
const { t } = useI18n();
|
||||
const { settings, error, applying, apply } = useAIPrivacyState();
|
||||
const [enabled, setEnabled] = useState(settings.enabled);
|
||||
const [privateIps, setPrivateIps] = useState(settings.redactPrivateIps);
|
||||
const [cidrs, setCidrs] = useState(settings.cidrs.join("\n"));
|
||||
const [patterns, setPatterns] = useState(settings.patterns.join("\n"));
|
||||
const [applied, setApplied] = useState(false);
|
||||
|
||||
useEffect(() => {
|
||||
setEnabled(settings.enabled);
|
||||
setPrivateIps(settings.redactPrivateIps);
|
||||
setCidrs(settings.cidrs.join("\n"));
|
||||
setPatterns(settings.patterns.join("\n"));
|
||||
}, [settings]);
|
||||
|
||||
const onApply = async () => {
|
||||
setApplied(false);
|
||||
const saved = await apply({
|
||||
enabled,
|
||||
redactPrivateIps: privateIps,
|
||||
cidrs: cidrs.split(/\r?\n/).map((line) => line.trim()).filter(Boolean),
|
||||
patterns: patterns.split(/\r?\n/).filter((line) => line.trim().length > 0),
|
||||
});
|
||||
setApplied(saved);
|
||||
};
|
||||
|
||||
return (
|
||||
<SettingsSection anchorId="ai-privacy" title={t("ai.privacy.title")}>
|
||||
<p className="text-xs text-muted-foreground leading-5">{t("ai.privacy.description")}</p>
|
||||
<p className="text-xs text-muted-foreground leading-5">{t("ai.privacy.boundary")}</p>
|
||||
<SettingCard divided className="rounded-md border-border/60 bg-background/70">
|
||||
<SettingRow label={t("ai.privacy.enable")}>
|
||||
<Toggle
|
||||
checked={enabled}
|
||||
onChange={(next) => { setEnabled(next); setApplied(false); }}
|
||||
ariaLabel={t("ai.privacy.enable")}
|
||||
disabled={applying}
|
||||
/>
|
||||
</SettingRow>
|
||||
<SettingRow
|
||||
label={t("ai.privacy.privateIps")}
|
||||
description={t("ai.privacy.privateIps.description")}
|
||||
>
|
||||
<Toggle
|
||||
checked={privateIps}
|
||||
onChange={(next) => { setPrivateIps(next); setApplied(false); }}
|
||||
ariaLabel={t("ai.privacy.privateIps")}
|
||||
disabled={applying}
|
||||
/>
|
||||
</SettingRow>
|
||||
</SettingCard>
|
||||
<div className="space-y-3 rounded-md border border-border/60 bg-background/50 p-3">
|
||||
<label className="block space-y-1.5">
|
||||
<span className="text-xs font-medium">{t("ai.privacy.cidrs")}</span>
|
||||
<span className="block text-xs text-muted-foreground">{t("ai.privacy.cidrs.description")}</span>
|
||||
<textarea
|
||||
value={cidrs}
|
||||
onChange={(event) => { setCidrs(event.currentTarget.value); setApplied(false); }}
|
||||
aria-label={t("ai.privacy.cidrs")}
|
||||
placeholder={"192.0.2.0/24\n2001:db8::/32"}
|
||||
rows={3}
|
||||
spellCheck={false}
|
||||
disabled={applying}
|
||||
className="w-full resize-y rounded-md border border-border/60 bg-background px-2 py-1.5 font-mono text-xs focus-visible:outline-none focus-visible:ring-1 focus-visible:ring-ring"
|
||||
/>
|
||||
</label>
|
||||
<label className="block space-y-1.5">
|
||||
<span className="text-xs font-medium">{t("ai.privacy.patterns")}</span>
|
||||
<span className="block text-xs text-muted-foreground">{t("ai.privacy.patterns.description")}</span>
|
||||
<textarea
|
||||
value={patterns}
|
||||
onChange={(event) => { setPatterns(event.currentTarget.value); setApplied(false); }}
|
||||
aria-label={t("ai.privacy.patterns")}
|
||||
placeholder="INTERNAL_[A-Z0-9]+"
|
||||
rows={3}
|
||||
spellCheck={false}
|
||||
disabled={applying}
|
||||
className="w-full resize-y rounded-md border border-border/60 bg-background px-2 py-1.5 font-mono text-xs focus-visible:outline-none focus-visible:ring-1 focus-visible:ring-ring"
|
||||
/>
|
||||
</label>
|
||||
<p className="text-xs text-muted-foreground leading-5">{t("ai.privacy.connectionScope")}</p>
|
||||
<div className="flex items-center justify-between gap-3">
|
||||
<p className="text-xs text-amber-500">{t("ai.privacy.reconnect")}</p>
|
||||
<Button size="sm" variant="outline" onClick={() => { void onApply(); }} disabled={applying}>
|
||||
{t(applying ? "ai.privacy.applying" : "ai.privacy.apply")}
|
||||
</Button>
|
||||
</div>
|
||||
{error ? (
|
||||
<p role="alert" className="text-xs text-destructive">
|
||||
{t("ai.privacy.failed")} {error}
|
||||
</p>
|
||||
) : applied ? (
|
||||
<p role="status" className="text-xs text-emerald-500">{t("ai.privacy.applied")}</p>
|
||||
) : null}
|
||||
</div>
|
||||
</SettingsSection>
|
||||
);
|
||||
};
|
||||
@@ -252,7 +252,7 @@ export function buildCursorSnippet(launcherPath: string, discoveryPath?: string
|
||||
|
||||
export const ExternalMcpCard: React.FC = () => {
|
||||
const { t } = useI18n();
|
||||
const { enabled, setEnabled } = useExternalMcpToggleState();
|
||||
const { enabled, setEnabled, error: configError } = useExternalMcpToggleState();
|
||||
const [mode, setModeRaw] = useState<ExternalMcpMode>(() => readExternalMcpMode());
|
||||
const [idleTimeoutMinutes, setIdleTimeoutRaw] = useState<number>(() => readExternalMcpIdleTimeoutMinutes());
|
||||
const [focusOnHostOpen, setFocusOnHostOpenRaw] = useState<boolean>(() => readExternalMcpFocusOnHostOpen());
|
||||
@@ -681,8 +681,7 @@ export const ExternalMcpCard: React.FC = () => {
|
||||
checked={enabled}
|
||||
onChange={(nextEnabled) => {
|
||||
setActionMessage(null);
|
||||
setEnabled(nextEnabled);
|
||||
window.setTimeout(() => { void refreshStatus(); }, 0);
|
||||
void setEnabled(nextEnabled).then(() => refreshStatus());
|
||||
}}
|
||||
/>
|
||||
</div>
|
||||
@@ -989,8 +988,8 @@ export const ExternalMcpCard: React.FC = () => {
|
||||
{actionMessage.text}
|
||||
</div>
|
||||
) : null}
|
||||
{status?.error ? (
|
||||
<div className="text-xs text-destructive">{status.error}</div>
|
||||
{configError || status?.error ? (
|
||||
<div role="alert" className="text-xs text-destructive">{configError || status?.error}</div>
|
||||
) : null}
|
||||
</div>
|
||||
);
|
||||
|
||||
@@ -177,6 +177,7 @@ export interface NetcattyAiBridge {
|
||||
mode?: 'temporary' | 'persistent';
|
||||
idleTimeoutMinutes?: number;
|
||||
sessionIdleTimeoutMinutes?: number;
|
||||
redaction?: import("../../../../application/state/useExternalMcpToggleState").ExternalMcpRedactionSettings;
|
||||
httpEnabled?: boolean;
|
||||
httpPort?: number;
|
||||
httpAllowCustomBind?: boolean;
|
||||
|
||||
@@ -7,7 +7,8 @@ import type { TerminalSession } from '../../types';
|
||||
import type { DockerContainerAction, DockerContainerInfo, DockerStatInfo, TerminalPopupIcon } from '../../domain/systemManager/types';
|
||||
import { dockerContainerInfoEqual } from '../../domain/systemManager/pollEquals';
|
||||
import { getContainerFlags, getContainerTone } from '../../domain/systemManager/containerState';
|
||||
import { buildDockerExecShellCommand, buildDockerLogsCommand } from '../../domain/systemManager/dockerShell';
|
||||
import type { ContainerRuntimeId } from '../../domain/systemManager/containerRuntime';
|
||||
import { buildContainerExecShellCommand, buildContainerLogsCommand, describeRuntimeFailure } from '../../domain/systemManager/containerRuntime';
|
||||
import { DockerContainerDetail } from './DockerContainerDetail';
|
||||
import { DockerImageIcon } from './DockerImageIcon';
|
||||
import { useStableListOrder, mergePollListByKey } from './listStable';
|
||||
@@ -59,6 +60,7 @@ async function buildContainerPopupIcon(image: string): Promise<TerminalPopupIcon
|
||||
interface DockerContainersPanelProps {
|
||||
sessionId: string;
|
||||
parentSession: TerminalSession;
|
||||
runtime?: ContainerRuntimeId;
|
||||
isVisible: boolean;
|
||||
warmupEnabled?: boolean;
|
||||
backend: Backend;
|
||||
@@ -157,6 +159,7 @@ const DockerContainerRow = memo(function DockerContainerRow({
|
||||
export const DockerContainersPanel = memo(function DockerContainersPanel({
|
||||
sessionId,
|
||||
parentSession,
|
||||
runtime = 'docker',
|
||||
isVisible,
|
||||
warmupEnabled = false,
|
||||
backend,
|
||||
@@ -179,15 +182,15 @@ export const DockerContainersPanel = memo(function DockerContainersPanel({
|
||||
setConfirmAction(null);
|
||||
setPendingAction(null);
|
||||
setSelectedId(null);
|
||||
}, [sessionId]);
|
||||
}, [runtime, sessionId]);
|
||||
|
||||
const containersFetcher = useCallback(async () => {
|
||||
const result = await backend.listDockerContainers(sessionId);
|
||||
const result = await backend.listDockerContainers(sessionId, runtime);
|
||||
if (!result.success || !result.containers) {
|
||||
throw new Error(result.error || stableT('systemManager.errors.loadDocker'));
|
||||
throw new Error(describeRuntimeFailure(result, stableT, stableT('systemManager.errors.loadDocker')));
|
||||
}
|
||||
return result.containers;
|
||||
}, [backend, sessionId, stableT]);
|
||||
}, [backend, runtime, sessionId, stableT]);
|
||||
|
||||
const listIntervalMs = Math.max(3, listRefreshIntervalSec) * 1000;
|
||||
const { data: containers, error, loading, refresh } = usePolling<DockerContainerInfo[]>(
|
||||
@@ -195,7 +198,7 @@ export const DockerContainersPanel = memo(function DockerContainersPanel({
|
||||
listIntervalMs,
|
||||
isVisible || warmupEnabled,
|
||||
(prev, next) => mergePollListByKey(prev, next, (c) => c.id, dockerContainerInfoEqual),
|
||||
{ poll: isVisible, resetKey: sessionId },
|
||||
{ poll: isVisible, resetKey: `${sessionId}:${runtime}` },
|
||||
);
|
||||
|
||||
const matched = useMemo<DockerContainerInfo[]>(() => {
|
||||
@@ -240,12 +243,12 @@ export const DockerContainersPanel = memo(function DockerContainersPanel({
|
||||
);
|
||||
const statsFetcher = useCallback(async () => {
|
||||
if (statContainerIds.length === 0) return [];
|
||||
const result = await backend.getDockerStats({ sessionId, ids: statContainerIds });
|
||||
const result = await backend.getDockerStats({ sessionId, ids: statContainerIds, runtime });
|
||||
if (!result.success || !result.stats) {
|
||||
throw new Error(result.error || stableT('systemManager.errors.loadDockerStats'));
|
||||
throw new Error(describeRuntimeFailure(result, stableT, stableT('systemManager.errors.loadDockerStats')));
|
||||
}
|
||||
return result.stats;
|
||||
}, [backend, sessionId, stableT, statContainerIds]);
|
||||
}, [backend, runtime, sessionId, stableT, statContainerIds]);
|
||||
|
||||
const statsIntervalMs = Math.max(2, statsRefreshIntervalSec) * 1000;
|
||||
const { data: stats, loading: statsLoading } = usePolling<DockerStatInfo[]>(
|
||||
@@ -266,18 +269,19 @@ export const DockerContainersPanel = memo(function DockerContainersPanel({
|
||||
}, [stats]);
|
||||
|
||||
const getContainerInspectKey = useCallback((container: DockerContainerInfo) => (
|
||||
`${sessionId}:${container.id}`
|
||||
), [sessionId]);
|
||||
`${sessionId}:${runtime}:${container.id}`
|
||||
), [runtime, sessionId]);
|
||||
const fetchContainerInspect = useCallback(async (container: DockerContainerInfo) => {
|
||||
const result = await backend.dockerInspect({
|
||||
sessionId,
|
||||
containerId: container.id.slice(0, 12),
|
||||
runtime,
|
||||
});
|
||||
if (!result.success) {
|
||||
throw new Error(result.error || stableT('systemManager.errors.actionFailed'));
|
||||
throw new Error(describeRuntimeFailure(result, stableT, stableT('systemManager.errors.actionFailed')));
|
||||
}
|
||||
return result.inspect ?? null;
|
||||
}, [backend, sessionId, stableT]);
|
||||
}, [backend, runtime, sessionId, stableT]);
|
||||
const {
|
||||
records: inspectByContainerId,
|
||||
loadRecord: loadContainerInspect,
|
||||
@@ -305,9 +309,12 @@ export const DockerContainersPanel = memo(function DockerContainersPanel({
|
||||
}
|
||||
setPendingAction({ id: containerId, action });
|
||||
try {
|
||||
const result = await backend.dockerAction({ sessionId, containerId, action, newName });
|
||||
const result = await backend.dockerAction({ sessionId, containerId, action, newName, runtime });
|
||||
if (!result.success) {
|
||||
showSystemManagerError(result.error || t('systemManager.errors.actionFailed'), t('common.error'));
|
||||
showSystemManagerError(
|
||||
describeRuntimeFailure(result, t, t('systemManager.errors.actionFailed')),
|
||||
t('common.error'),
|
||||
);
|
||||
return;
|
||||
}
|
||||
const affectedContainer = (containers ?? []).find((container) => (
|
||||
@@ -332,6 +339,7 @@ export const DockerContainersPanel = memo(function DockerContainersPanel({
|
||||
invalidateContainerInspectMatching,
|
||||
refresh,
|
||||
refreshContainerInspect,
|
||||
runtime,
|
||||
sessionId,
|
||||
t,
|
||||
]);
|
||||
@@ -360,7 +368,7 @@ export const DockerContainersPanel = memo(function DockerContainersPanel({
|
||||
backend,
|
||||
parentSession,
|
||||
`docker: ${container.name || id}`,
|
||||
buildDockerExecShellCommand(id),
|
||||
buildContainerExecShellCommand(runtime, id, parentSession.shellType),
|
||||
{ icon: await buildContainerPopupIcon(container.image) },
|
||||
);
|
||||
if (!result.success) {
|
||||
@@ -372,7 +380,7 @@ export const DockerContainersPanel = memo(function DockerContainersPanel({
|
||||
});
|
||||
showSystemManagerError(result.error || t('systemManager.errors.actionFailed'), t('common.error'));
|
||||
}
|
||||
}, [backend, parentSession, sessionId, t]);
|
||||
}, [backend, parentSession, runtime, sessionId, t]);
|
||||
|
||||
const openLogs = useCallback(async (container: DockerContainerInfo) => {
|
||||
const id = container.id.slice(0, 12);
|
||||
@@ -387,7 +395,7 @@ export const DockerContainersPanel = memo(function DockerContainersPanel({
|
||||
backend,
|
||||
parentSession,
|
||||
`logs: ${container.name || id}`,
|
||||
buildDockerLogsCommand(id),
|
||||
buildContainerLogsCommand(runtime, id, parentSession.shellType),
|
||||
{ icon: await buildContainerPopupIcon(container.image) },
|
||||
);
|
||||
if (!result.success) {
|
||||
@@ -399,7 +407,7 @@ export const DockerContainersPanel = memo(function DockerContainersPanel({
|
||||
});
|
||||
showSystemManagerError(result.error || t('systemManager.errors.actionFailed'), t('common.error'));
|
||||
}
|
||||
}, [backend, parentSession, sessionId, t]);
|
||||
}, [backend, parentSession, runtime, sessionId, t]);
|
||||
|
||||
return (
|
||||
<div className="flex flex-col flex-1 min-h-0 overflow-hidden" data-section="docker-containers">
|
||||
|
||||
@@ -2,6 +2,7 @@ import { Layers, Loader2, Tag, Trash2 } from 'lucide-react';
|
||||
import React, { memo, useCallback, useEffect, useMemo, useRef, useState } from 'react';
|
||||
import { useI18n } from '../../application/i18n/I18nProvider';
|
||||
import type { useSystemManagerBackend } from '../../application/state/useSystemManagerBackend';
|
||||
import { describeRuntimeFailure, type ContainerRuntimeId } from '../../domain/systemManager/containerRuntime';
|
||||
import { dockerImageRowKey, type DockerImageInfo } from '../../domain/systemManager/types';
|
||||
import { dockerImageInfoEqual } from '../../domain/systemManager/pollEquals';
|
||||
import { DockerImageIcon } from './DockerImageIcon';
|
||||
@@ -35,6 +36,7 @@ type PendingImageConfirm =
|
||||
|
||||
interface DockerImagesPanelProps {
|
||||
sessionId: string;
|
||||
runtime?: ContainerRuntimeId;
|
||||
isVisible: boolean;
|
||||
warmupEnabled?: boolean;
|
||||
backend: Backend;
|
||||
@@ -89,6 +91,7 @@ const DockerImageRow = memo(function DockerImageRow({
|
||||
|
||||
export const DockerImagesPanel = memo(function DockerImagesPanel({
|
||||
sessionId,
|
||||
runtime = 'docker',
|
||||
isVisible,
|
||||
warmupEnabled = false,
|
||||
backend,
|
||||
@@ -111,15 +114,15 @@ export const DockerImagesPanel = memo(function DockerImagesPanel({
|
||||
// Clear busy so a hung/in-flight action from the previous session cannot
|
||||
// leave the new session's confirm dialog permanently disabled.
|
||||
setActionBusy(false);
|
||||
}, [sessionId]);
|
||||
}, [runtime, sessionId]);
|
||||
|
||||
const imagesFetcher = useCallback(async () => {
|
||||
const result = await backend.listDockerImages(sessionId);
|
||||
const result = await backend.listDockerImages(sessionId, runtime);
|
||||
if (!result.success || !result.images) {
|
||||
throw new Error(result.error || stableT('systemManager.errors.loadDockerImages'));
|
||||
throw new Error(describeRuntimeFailure(result, stableT, stableT('systemManager.errors.loadDockerImages')));
|
||||
}
|
||||
return result.images;
|
||||
}, [backend, sessionId, stableT]);
|
||||
}, [backend, runtime, sessionId, stableT]);
|
||||
|
||||
const listIntervalMs = Math.max(3, listRefreshIntervalSec) * 1000;
|
||||
const { data: images, error, loading, refresh } = usePolling<DockerImageInfo[]>(
|
||||
@@ -127,7 +130,7 @@ export const DockerImagesPanel = memo(function DockerImagesPanel({
|
||||
listIntervalMs,
|
||||
isVisible || warmupEnabled,
|
||||
(prev, next) => mergePollListByKey(prev, next, dockerImageRowKey, dockerImageInfoEqual),
|
||||
{ poll: isVisible, resetKey: sessionId },
|
||||
{ poll: isVisible, resetKey: `${sessionId}:${runtime}` },
|
||||
);
|
||||
|
||||
const filtered = useMemo(() => {
|
||||
@@ -154,18 +157,19 @@ export const DockerImagesPanel = memo(function DockerImagesPanel({
|
||||
const displayList = useStableListOrder(filtered, dockerImageRowKey, query, compareImages);
|
||||
|
||||
const getImageInspectKey = useCallback((image: DockerImageInfo) => (
|
||||
`${sessionId}:${dockerImageRowKey(image)}`
|
||||
), [sessionId]);
|
||||
`${sessionId}:${runtime}:${dockerImageRowKey(image)}`
|
||||
), [runtime, sessionId]);
|
||||
const fetchImageInspect = useCallback(async (image: DockerImageInfo) => {
|
||||
const result = await backend.dockerImageInspect({
|
||||
sessionId,
|
||||
imageId: image.id.slice(0, 12),
|
||||
runtime,
|
||||
});
|
||||
if (!result.success) {
|
||||
throw new Error(result.error || stableT('systemManager.errors.actionFailed'));
|
||||
throw new Error(describeRuntimeFailure(result, stableT, stableT('systemManager.errors.actionFailed')));
|
||||
}
|
||||
return result.inspect ?? null;
|
||||
}, [backend, sessionId, stableT]);
|
||||
}, [backend, runtime, sessionId, stableT]);
|
||||
const {
|
||||
records: inspectByImageKey,
|
||||
loadRecord: loadImageInspect,
|
||||
@@ -184,15 +188,26 @@ export const DockerImagesPanel = memo(function DockerImagesPanel({
|
||||
const actionGeneration = actionGenerationRef.current;
|
||||
setActionBusy(true);
|
||||
try {
|
||||
const tagged = Boolean(
|
||||
image.repository
|
||||
&& image.tag
|
||||
&& image.repository !== '<none>'
|
||||
&& image.tag !== '<none>',
|
||||
);
|
||||
const result = await backend.dockerImageAction({
|
||||
sessionId,
|
||||
runtime,
|
||||
action: 'rm',
|
||||
imageId: image.id.slice(0, 12),
|
||||
force: image.tag === '<none>',
|
||||
force: !tagged,
|
||||
...(tagged ? { repository: image.repository, tag: image.tag } : {}),
|
||||
});
|
||||
if (actionGenerationRef.current !== actionGeneration) return;
|
||||
if (!result.success) {
|
||||
showSystemManagerError(result.error || t('systemManager.errors.actionFailed'), t('common.error'));
|
||||
showSystemManagerError(
|
||||
describeRuntimeFailure(result, t, t('systemManager.errors.actionFailed')),
|
||||
t('common.error'),
|
||||
);
|
||||
return;
|
||||
}
|
||||
if (selectedId === dockerImageRowKey(image)) {
|
||||
@@ -205,7 +220,7 @@ export const DockerImagesPanel = memo(function DockerImagesPanel({
|
||||
setActionBusy(false);
|
||||
}
|
||||
}
|
||||
}, [backend, getImageInspectKey, invalidateImageInspect, refresh, selectedId, sessionId, t]);
|
||||
}, [backend, getImageInspectKey, invalidateImageInspect, refresh, runtime, selectedId, sessionId, t]);
|
||||
|
||||
const handleRemove = useCallback((image: DockerImageInfo) => {
|
||||
const label = image.name || image.id.slice(0, 12);
|
||||
@@ -216,10 +231,13 @@ export const DockerImagesPanel = memo(function DockerImagesPanel({
|
||||
const actionGeneration = actionGenerationRef.current;
|
||||
setActionBusy(true);
|
||||
try {
|
||||
const result = await backend.dockerImageAction({ sessionId, action: 'prune', all });
|
||||
const result = await backend.dockerImageAction({ sessionId, runtime, action: 'prune', all });
|
||||
if (actionGenerationRef.current !== actionGeneration) return;
|
||||
if (!result.success) {
|
||||
showSystemManagerError(result.error || t('systemManager.errors.actionFailed'), t('common.error'));
|
||||
showSystemManagerError(
|
||||
describeRuntimeFailure(result, t, t('systemManager.errors.actionFailed')),
|
||||
t('common.error'),
|
||||
);
|
||||
return;
|
||||
}
|
||||
await refresh();
|
||||
@@ -228,7 +246,7 @@ export const DockerImagesPanel = memo(function DockerImagesPanel({
|
||||
setActionBusy(false);
|
||||
}
|
||||
}
|
||||
}, [backend, refresh, sessionId, t]);
|
||||
}, [backend, refresh, runtime, sessionId, t]);
|
||||
|
||||
const handlePrune = useCallback((all: boolean) => {
|
||||
setConfirmTarget({ kind: 'prune', all });
|
||||
@@ -237,13 +255,17 @@ export const DockerImagesPanel = memo(function DockerImagesPanel({
|
||||
const handleTagSubmit = async (image: DockerImageInfo, repository: string, tag: string) => {
|
||||
const result = await backend.dockerImageAction({
|
||||
sessionId,
|
||||
runtime,
|
||||
action: 'tag',
|
||||
imageId: image.id.slice(0, 12),
|
||||
repository,
|
||||
tag: tag || 'latest',
|
||||
});
|
||||
if (!result.success) {
|
||||
showSystemManagerError(result.error || t('systemManager.errors.actionFailed'), t('common.error'));
|
||||
showSystemManagerError(
|
||||
describeRuntimeFailure(result, t, t('systemManager.errors.actionFailed')),
|
||||
t('common.error'),
|
||||
);
|
||||
return;
|
||||
}
|
||||
await refresh();
|
||||
|
||||
@@ -1,19 +1,32 @@
|
||||
import { Box, Layers } from 'lucide-react';
|
||||
import React, { memo, useState } from 'react';
|
||||
import { useI18n } from '../../application/i18n/I18nProvider';
|
||||
import { useContainerRuntimeSelection } from '../../application/state/systemManager/useContainerRuntimeSelection';
|
||||
import type { useSystemManagerBackend } from '../../application/state/useSystemManagerBackend';
|
||||
import type { ContainerSourceId } from '../../domain/systemManager/containerRuntime';
|
||||
import type { SessionCapabilities } from '../../domain/systemManager/types';
|
||||
import type { TerminalSession } from '../../types';
|
||||
import { cn } from '../../lib/utils';
|
||||
import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from '../ui/select';
|
||||
import { DockerContainersPanel } from './DockerContainersPanel';
|
||||
import { DockerImagesPanel } from './DockerImagesPanel';
|
||||
import { KubernetesPodsPanel } from './KubernetesPodsPanel';
|
||||
import { SystemPanelShell } from './SystemPanelUi';
|
||||
|
||||
type Backend = ReturnType<typeof useSystemManagerBackend>;
|
||||
type DockerSubTab = 'containers' | 'images';
|
||||
|
||||
const RUNTIME_LABEL: Record<ContainerSourceId, string> = {
|
||||
docker: 'systemManager.runtime.docker',
|
||||
podman: 'systemManager.runtime.podman',
|
||||
nerdctl: 'systemManager.runtime.nerdctl',
|
||||
kubernetes: 'systemManager.runtime.kubernetes',
|
||||
};
|
||||
|
||||
interface DockerManagerTabProps {
|
||||
sessionId: string;
|
||||
parentSession: TerminalSession;
|
||||
capabilities?: SessionCapabilities;
|
||||
isVisible: boolean;
|
||||
warmupEnabled?: boolean;
|
||||
backend: Backend;
|
||||
@@ -21,9 +34,48 @@ interface DockerManagerTabProps {
|
||||
statsRefreshIntervalSec: number;
|
||||
}
|
||||
|
||||
function CompactSelect({
|
||||
value,
|
||||
options,
|
||||
ariaLabel,
|
||||
section,
|
||||
onChange,
|
||||
className,
|
||||
}: {
|
||||
value: string;
|
||||
options: { id: string; label: string }[];
|
||||
ariaLabel: string;
|
||||
section: string;
|
||||
onChange: (value: string) => void;
|
||||
className?: string;
|
||||
}) {
|
||||
return (
|
||||
<Select value={value} onValueChange={onChange}>
|
||||
<SelectTrigger
|
||||
aria-label={ariaLabel}
|
||||
data-section={section}
|
||||
className={cn(
|
||||
"h-6 w-auto max-w-[9.5rem] gap-1 rounded border-border/40 bg-transparent px-2 py-0 text-[11px] shadow-none",
|
||||
className,
|
||||
)}
|
||||
>
|
||||
<SelectValue />
|
||||
</SelectTrigger>
|
||||
<SelectContent>
|
||||
{options.map((option) => (
|
||||
<SelectItem key={option.id} value={option.id} className="text-xs">
|
||||
{option.label}
|
||||
</SelectItem>
|
||||
))}
|
||||
</SelectContent>
|
||||
</Select>
|
||||
);
|
||||
}
|
||||
|
||||
export const DockerManagerTab = memo(function DockerManagerTab({
|
||||
sessionId,
|
||||
parentSession,
|
||||
capabilities,
|
||||
isVisible,
|
||||
warmupEnabled = false,
|
||||
backend,
|
||||
@@ -32,6 +84,26 @@ export const DockerManagerTab = memo(function DockerManagerTab({
|
||||
}: DockerManagerTabProps) {
|
||||
const { t } = useI18n();
|
||||
const [subTab, setSubTab] = useState<DockerSubTab>('containers');
|
||||
const {
|
||||
source,
|
||||
selectSource,
|
||||
contextName,
|
||||
selectContext,
|
||||
namespaceName,
|
||||
setNamespaceName,
|
||||
contexts,
|
||||
namespaces,
|
||||
scopeError,
|
||||
reloadScopes,
|
||||
runtime,
|
||||
detected,
|
||||
showRuntimeSwitcher,
|
||||
} = useContainerRuntimeSelection({
|
||||
sessionId,
|
||||
hostKey: parentSession.hostId || sessionId,
|
||||
capabilities,
|
||||
backend,
|
||||
});
|
||||
|
||||
const tabs: { id: DockerSubTab; icon: typeof Box; label: string }[] = [
|
||||
{ id: 'containers', icon: Box, label: t('systemManager.docker.subTabs.containers') },
|
||||
@@ -40,8 +112,8 @@ export const DockerManagerTab = memo(function DockerManagerTab({
|
||||
|
||||
return (
|
||||
<SystemPanelShell section="system-manager-docker">
|
||||
<div className="shrink-0 flex items-center gap-0.5 px-2 py-1 border-b border-border/30">
|
||||
{tabs.map(({ id, icon: Icon, label }) => (
|
||||
<div className="shrink-0 flex items-center gap-1 px-2 py-1 border-b border-border/30">
|
||||
{source !== 'kubernetes' && tabs.map(({ id, icon: Icon, label }) => (
|
||||
<button
|
||||
key={id}
|
||||
type="button"
|
||||
@@ -57,29 +129,79 @@ export const DockerManagerTab = memo(function DockerManagerTab({
|
||||
{label}
|
||||
</button>
|
||||
))}
|
||||
{showRuntimeSwitcher && source && detected.includes(source) && (
|
||||
<CompactSelect
|
||||
value={source}
|
||||
ariaLabel={t('systemManager.runtime.switch')}
|
||||
section="container-runtime-switcher"
|
||||
className="shrink-0"
|
||||
options={detected.map((id) => ({ id, label: t(RUNTIME_LABEL[id]) }))}
|
||||
onChange={selectSource}
|
||||
/>
|
||||
)}
|
||||
{source === 'kubernetes' && contextName && contexts.some((item) => item.name === contextName) && (
|
||||
<CompactSelect
|
||||
value={contextName}
|
||||
ariaLabel={t('systemManager.k8s.context')}
|
||||
section="k8s-context-select"
|
||||
className="w-full min-w-0 max-w-none flex-1"
|
||||
options={contexts.map((item) => ({ id: item.name, label: item.name }))}
|
||||
onChange={selectContext}
|
||||
/>
|
||||
)}
|
||||
{source === 'kubernetes' && namespaceName && namespaces.includes(namespaceName) && (
|
||||
<CompactSelect
|
||||
value={namespaceName}
|
||||
ariaLabel={t('systemManager.k8s.namespace')}
|
||||
section="k8s-namespace-select"
|
||||
className="shrink-0"
|
||||
options={namespaces.map((name) => ({ id: name, label: name }))}
|
||||
onChange={setNamespaceName}
|
||||
/>
|
||||
)}
|
||||
</div>
|
||||
|
||||
<div className="flex-1 min-h-0 flex flex-col">
|
||||
<div className={cn('flex-1 min-h-0 flex flex-col', subTab !== 'containers' && 'hidden')}>
|
||||
<DockerContainersPanel
|
||||
{source && source !== 'kubernetes' && (
|
||||
<>
|
||||
<div className={cn('flex-1 min-h-0 flex flex-col', subTab !== 'containers' && 'hidden')}>
|
||||
<DockerContainersPanel
|
||||
sessionId={sessionId}
|
||||
parentSession={parentSession}
|
||||
runtime={runtime}
|
||||
isVisible={isVisible && subTab === 'containers'}
|
||||
warmupEnabled={warmupEnabled || (isVisible && subTab !== 'containers')}
|
||||
backend={backend}
|
||||
listRefreshIntervalSec={listRefreshIntervalSec}
|
||||
statsRefreshIntervalSec={statsRefreshIntervalSec}
|
||||
/>
|
||||
</div>
|
||||
<div className={cn('flex-1 min-h-0 flex flex-col', subTab !== 'images' && 'hidden')}>
|
||||
<DockerImagesPanel
|
||||
sessionId={sessionId}
|
||||
runtime={runtime}
|
||||
isVisible={isVisible && subTab === 'images'}
|
||||
warmupEnabled={warmupEnabled || (isVisible && subTab !== 'images')}
|
||||
backend={backend}
|
||||
listRefreshIntervalSec={listRefreshIntervalSec}
|
||||
/>
|
||||
</div>
|
||||
</>
|
||||
)}
|
||||
{source === 'kubernetes' && (
|
||||
<KubernetesPodsPanel
|
||||
sessionId={sessionId}
|
||||
parentSession={parentSession}
|
||||
isVisible={isVisible && subTab === 'containers'}
|
||||
warmupEnabled={warmupEnabled || (isVisible && subTab !== 'containers')}
|
||||
contextName={contextName}
|
||||
namespaceName={namespaceName}
|
||||
scopeError={scopeError}
|
||||
isVisible={isVisible}
|
||||
warmupEnabled={warmupEnabled}
|
||||
backend={backend}
|
||||
listRefreshIntervalSec={listRefreshIntervalSec}
|
||||
statsRefreshIntervalSec={statsRefreshIntervalSec}
|
||||
onReloadScopes={reloadScopes}
|
||||
/>
|
||||
</div>
|
||||
<div className={cn('flex-1 min-h-0 flex flex-col', subTab !== 'images' && 'hidden')}>
|
||||
<DockerImagesPanel
|
||||
sessionId={sessionId}
|
||||
isVisible={isVisible && subTab === 'images'}
|
||||
warmupEnabled={warmupEnabled || (isVisible && subTab !== 'images')}
|
||||
backend={backend}
|
||||
listRefreshIntervalSec={listRefreshIntervalSec}
|
||||
/>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</SystemPanelShell>
|
||||
);
|
||||
|
||||
@@ -0,0 +1,361 @@
|
||||
import { Box, FileText, Info, Terminal, Trash2 } from 'lucide-react';
|
||||
import React, { memo, useCallback, useEffect, useMemo, useState } from 'react';
|
||||
import { useI18n } from '../../application/i18n/I18nProvider';
|
||||
import type { useSystemManagerBackend } from '../../application/state/useSystemManagerBackend';
|
||||
import { usePolling, useStableTranslate } from '../../application/state/useSystemManager';
|
||||
import type { KubectlPodInfo } from '../../domain/systemManager/containerRuntime';
|
||||
import {
|
||||
buildKubectlDescribeCommand,
|
||||
buildKubectlExecShellCommand,
|
||||
buildKubectlLogsCommand,
|
||||
describeRuntimeFailure,
|
||||
podStatusMessageKey,
|
||||
} from '../../domain/systemManager/containerRuntime';
|
||||
import type { TerminalSession } from '../../types';
|
||||
import { Dialog, DialogContent, DialogHeader, DialogTitle } from '../ui/dialog';
|
||||
import { useStableListOrder } from './listStable';
|
||||
import { openInteractiveTerminal } from './openInteractiveTerminal';
|
||||
import { SystemPanelConfirmDialog } from './SystemPanelConfirmDialog';
|
||||
import {
|
||||
SystemPanelEmpty,
|
||||
SystemPanelError,
|
||||
SystemPanelList,
|
||||
SystemPanelLoading,
|
||||
SystemPanelMetaBar,
|
||||
SystemPanelRefreshButton,
|
||||
SystemPanelRoundButton,
|
||||
SystemPanelRow,
|
||||
SystemPanelSearch,
|
||||
SystemPanelStatusBadge,
|
||||
SystemPanelToolbar,
|
||||
} from './SystemPanelUi';
|
||||
import { showSystemManagerError } from './systemManagerToast';
|
||||
|
||||
type Backend = ReturnType<typeof useSystemManagerBackend>;
|
||||
|
||||
interface KubernetesPodsPanelProps {
|
||||
sessionId: string;
|
||||
parentSession: TerminalSession;
|
||||
contextName: string | null;
|
||||
namespaceName: string | null;
|
||||
scopeError?: string | null;
|
||||
isVisible: boolean;
|
||||
warmupEnabled?: boolean;
|
||||
backend: Backend;
|
||||
listRefreshIntervalSec: number;
|
||||
onReloadScopes: () => void;
|
||||
}
|
||||
|
||||
function podTone(status: string): 'success' | 'warning' | 'muted' {
|
||||
if (status === 'Running') return 'success';
|
||||
if (status === 'Pending' || status === 'Completed' || status === 'Succeeded') return 'muted';
|
||||
return 'warning';
|
||||
}
|
||||
|
||||
const KubernetesPodRow = memo(function KubernetesPodRow({
|
||||
pod,
|
||||
selected,
|
||||
pendingDelete,
|
||||
onSelect,
|
||||
onShell,
|
||||
onLogs,
|
||||
onDescribe,
|
||||
onDelete,
|
||||
}: {
|
||||
pod: KubectlPodInfo;
|
||||
selected: boolean;
|
||||
pendingDelete: boolean;
|
||||
onSelect: (pod: KubectlPodInfo) => void;
|
||||
onShell: (pod: KubectlPodInfo) => void;
|
||||
onLogs: (pod: KubectlPodInfo) => void;
|
||||
onDescribe: (pod: KubectlPodInfo) => void;
|
||||
onDelete: (pod: KubectlPodInfo) => void;
|
||||
}) {
|
||||
const { t } = useI18n();
|
||||
const statusKey = podStatusMessageKey(pod.status);
|
||||
const statusLabel = statusKey ? t(statusKey) : pod.status;
|
||||
return (
|
||||
<SystemPanelRow
|
||||
selected={selected}
|
||||
onClick={() => onSelect(pod)}
|
||||
leading={<Box size={14} className="text-muted-foreground" />}
|
||||
title={pod.name}
|
||||
subtitle={`${pod.containers.join(', ') || '—'} · ${t('systemManager.k8s.restarts', { count: String(pod.restarts) })}${pod.age ? ` · ${pod.age}` : ''}`}
|
||||
trailing={(
|
||||
<div className="flex shrink-0 items-center gap-1">
|
||||
<SystemPanelStatusBadge tone={podTone(pod.status)}>{statusLabel}</SystemPanelStatusBadge>
|
||||
{pod.shellContainers.length > 0 && (
|
||||
<SystemPanelRoundButton title={t('systemManager.docker.shell')} onClick={() => onShell(pod)}>
|
||||
<Terminal size={12} />
|
||||
</SystemPanelRoundButton>
|
||||
)}
|
||||
<SystemPanelRoundButton title={t('systemManager.docker.logs')} onClick={() => onLogs(pod)}>
|
||||
<FileText size={12} />
|
||||
</SystemPanelRoundButton>
|
||||
<SystemPanelRoundButton title={t('systemManager.k8s.describe')} onClick={() => onDescribe(pod)}>
|
||||
<Info size={12} />
|
||||
</SystemPanelRoundButton>
|
||||
<SystemPanelRoundButton
|
||||
title={t('systemManager.k8s.delete')}
|
||||
destructive
|
||||
loading={pendingDelete}
|
||||
onClick={() => onDelete(pod)}
|
||||
>
|
||||
<Trash2 size={12} />
|
||||
</SystemPanelRoundButton>
|
||||
</div>
|
||||
)}
|
||||
/>
|
||||
);
|
||||
});
|
||||
|
||||
export const KubernetesPodsPanel = memo(function KubernetesPodsPanel({
|
||||
sessionId,
|
||||
parentSession,
|
||||
contextName,
|
||||
namespaceName,
|
||||
scopeError = null,
|
||||
isVisible,
|
||||
warmupEnabled = false,
|
||||
backend,
|
||||
listRefreshIntervalSec,
|
||||
onReloadScopes,
|
||||
}: KubernetesPodsPanelProps) {
|
||||
const { t } = useI18n();
|
||||
const stableT = useStableTranslate();
|
||||
const [query, setQuery] = useState('');
|
||||
const [selectedName, setSelectedName] = useState<string | null>(null);
|
||||
const [pendingDelete, setPendingDelete] = useState<string | null>(null);
|
||||
const [confirmPod, setConfirmPod] = useState<string | null>(null);
|
||||
const [containerPrompt, setContainerPrompt] = useState<{
|
||||
pod: KubectlPodInfo;
|
||||
action: 'shell' | 'logs';
|
||||
} | null>(null);
|
||||
|
||||
const scopeKey = `${sessionId}:${contextName ?? ''}:${namespaceName ?? ''}`;
|
||||
|
||||
useEffect(() => {
|
||||
setSelectedName(null);
|
||||
setPendingDelete(null);
|
||||
setConfirmPod(null);
|
||||
setContainerPrompt(null);
|
||||
}, [scopeKey]);
|
||||
|
||||
const podsFetcher = useCallback(async () => {
|
||||
if (!contextName || !namespaceName) return [];
|
||||
const result = await backend.kubectl({
|
||||
sessionId,
|
||||
op: 'pods',
|
||||
context: contextName,
|
||||
namespace: namespaceName,
|
||||
});
|
||||
if (!result.success || !result.pods) {
|
||||
throw new Error(describeRuntimeFailure(
|
||||
result,
|
||||
stableT,
|
||||
stableT('systemManager.errors.loadPods'),
|
||||
contextName,
|
||||
));
|
||||
}
|
||||
return result.pods;
|
||||
}, [backend, contextName, namespaceName, sessionId, stableT]);
|
||||
|
||||
const listIntervalMs = Math.max(3, listRefreshIntervalSec) * 1000;
|
||||
const { data: pods, error, loading, refresh } = usePolling<KubectlPodInfo[]>(
|
||||
podsFetcher,
|
||||
listIntervalMs,
|
||||
(isVisible || warmupEnabled) && !!contextName && !!namespaceName,
|
||||
undefined,
|
||||
{ poll: isVisible, resetKey: scopeKey },
|
||||
);
|
||||
|
||||
const matched = useMemo(() => {
|
||||
const q = query.trim().toLowerCase();
|
||||
return (pods ?? []).filter((pod) => {
|
||||
if (!q) return true;
|
||||
const statusKey = podStatusMessageKey(pod.status);
|
||||
const statusLabel = (statusKey ? t(statusKey) : pod.status).toLowerCase();
|
||||
return pod.name.toLowerCase().includes(q)
|
||||
|| pod.status.toLowerCase().includes(q)
|
||||
|| statusLabel.includes(q)
|
||||
|| pod.containers.some((name) => name.toLowerCase().includes(q));
|
||||
});
|
||||
}, [pods, query, t]);
|
||||
|
||||
const displayList = useStableListOrder<KubectlPodInfo, string>(
|
||||
matched,
|
||||
(pod) => pod.name,
|
||||
`${scopeKey}|${query}`,
|
||||
(a, b) => a.name.localeCompare(b.name),
|
||||
);
|
||||
|
||||
const openCommand = useCallback(async (title: string, command: string) => {
|
||||
const result = await openInteractiveTerminal(backend, parentSession, title, command);
|
||||
if (!result.success) {
|
||||
showSystemManagerError(result.error || t('systemManager.errors.actionFailed'), t('common.error'));
|
||||
}
|
||||
}, [backend, parentSession, t]);
|
||||
|
||||
const runPodCommand = useCallback((pod: KubectlPodInfo, action: 'shell' | 'logs' | 'describe', container?: string) => {
|
||||
if (!contextName || !namespaceName) return;
|
||||
if (action === 'describe') {
|
||||
void openCommand(
|
||||
`describe: ${pod.name}`,
|
||||
buildKubectlDescribeCommand(contextName, namespaceName, pod.name, parentSession.shellType),
|
||||
);
|
||||
return;
|
||||
}
|
||||
const containers = action === 'shell' ? pod.shellContainers : pod.containers;
|
||||
if (action === 'shell' && containers.length === 0) return;
|
||||
if (!container && containers.length > 1) {
|
||||
setContainerPrompt({ pod, action });
|
||||
return;
|
||||
}
|
||||
const chosen = container || containers[0];
|
||||
if (action === 'shell') {
|
||||
void openCommand(
|
||||
`pod: ${pod.name}`,
|
||||
buildKubectlExecShellCommand(contextName, namespaceName, pod.name, chosen, parentSession.shellType),
|
||||
);
|
||||
return;
|
||||
}
|
||||
void openCommand(
|
||||
`logs: ${pod.name}`,
|
||||
buildKubectlLogsCommand(contextName, namespaceName, pod.name, chosen, parentSession.shellType),
|
||||
);
|
||||
}, [contextName, namespaceName, openCommand, parentSession.shellType]);
|
||||
|
||||
const deletePod = useCallback(async (name: string) => {
|
||||
if (!contextName || !namespaceName) return;
|
||||
setPendingDelete(name);
|
||||
try {
|
||||
const result = await backend.kubectl({
|
||||
sessionId,
|
||||
op: 'delete',
|
||||
context: contextName,
|
||||
namespace: namespaceName,
|
||||
pod: name,
|
||||
});
|
||||
if (!result.success) {
|
||||
showSystemManagerError(
|
||||
describeRuntimeFailure(result, t, t('systemManager.errors.actionFailed'), contextName),
|
||||
t('common.error'),
|
||||
);
|
||||
return;
|
||||
}
|
||||
if (selectedName === name) setSelectedName(null);
|
||||
await refresh();
|
||||
} finally {
|
||||
setPendingDelete(null);
|
||||
}
|
||||
}, [backend, contextName, namespaceName, refresh, selectedName, sessionId, t]);
|
||||
|
||||
const visibleError = scopeError || error;
|
||||
|
||||
return (
|
||||
<div className="flex flex-col flex-1 min-h-0 overflow-hidden" data-section="kubernetes-pods">
|
||||
<SystemPanelToolbar
|
||||
trailing={(
|
||||
<SystemPanelRefreshButton
|
||||
title={t('history.action.refresh')}
|
||||
loading={loading}
|
||||
onClick={() => {
|
||||
onReloadScopes();
|
||||
void refresh();
|
||||
}}
|
||||
/>
|
||||
)}
|
||||
>
|
||||
<SystemPanelSearch
|
||||
value={query}
|
||||
onChange={setQuery}
|
||||
placeholder={t('systemManager.k8s.search')}
|
||||
/>
|
||||
</SystemPanelToolbar>
|
||||
|
||||
<SystemPanelMetaBar>
|
||||
{t('systemManager.k8s.meta', { count: String(displayList.length) })}
|
||||
</SystemPanelMetaBar>
|
||||
|
||||
<SystemPanelList>
|
||||
{!contextName && !visibleError && (
|
||||
<SystemPanelEmpty icon={Box} message={t('systemManager.k8s.noContext')} />
|
||||
)}
|
||||
{visibleError && (
|
||||
<SystemPanelError
|
||||
message={visibleError}
|
||||
onRetry={() => {
|
||||
onReloadScopes();
|
||||
void refresh();
|
||||
}}
|
||||
retryLabel={t('history.action.retry')}
|
||||
loading={loading}
|
||||
/>
|
||||
)}
|
||||
{!visibleError && contextName && displayList.length === 0 && loading && (
|
||||
<SystemPanelLoading message={t('systemManager.common.loading')} />
|
||||
)}
|
||||
{!visibleError && contextName && namespaceName && displayList.length === 0 && !loading && (
|
||||
<SystemPanelEmpty icon={Box} message={t('systemManager.k8s.empty')} />
|
||||
)}
|
||||
{displayList.map((pod) => (
|
||||
<KubernetesPodRow
|
||||
key={pod.name}
|
||||
pod={pod}
|
||||
selected={selectedName === pod.name}
|
||||
pendingDelete={pendingDelete === pod.name}
|
||||
onSelect={(item) => setSelectedName((current) => current === item.name ? null : item.name)}
|
||||
onShell={(item) => runPodCommand(item, 'shell')}
|
||||
onLogs={(item) => runPodCommand(item, 'logs')}
|
||||
onDescribe={(item) => runPodCommand(item, 'describe')}
|
||||
onDelete={(item) => setConfirmPod(item.name)}
|
||||
/>
|
||||
))}
|
||||
</SystemPanelList>
|
||||
|
||||
<SystemPanelConfirmDialog
|
||||
open={confirmPod !== null}
|
||||
title={t('systemManager.k8s.delete')}
|
||||
message={t('systemManager.k8s.confirmDelete', { name: confirmPod ?? '' })}
|
||||
confirmLabel={t('systemManager.k8s.delete')}
|
||||
destructive
|
||||
busy={pendingDelete !== null}
|
||||
onOpenChange={(open) => { if (!open) setConfirmPod(null); }}
|
||||
onConfirm={() => {
|
||||
const name = confirmPod;
|
||||
setConfirmPod(null);
|
||||
if (!name) return;
|
||||
void deletePod(name);
|
||||
}}
|
||||
/>
|
||||
|
||||
<Dialog open={containerPrompt !== null} onOpenChange={(open) => { if (!open) setContainerPrompt(null); }}>
|
||||
<DialogContent className="sm:max-w-[380px]">
|
||||
<DialogHeader>
|
||||
<DialogTitle>{t('systemManager.k8s.chooseContainer')}</DialogTitle>
|
||||
</DialogHeader>
|
||||
<div className="flex flex-col gap-1">
|
||||
{(containerPrompt?.action === 'shell'
|
||||
? containerPrompt.pod.shellContainers
|
||||
: containerPrompt?.pod.containers
|
||||
)?.map((name) => (
|
||||
<button
|
||||
key={name}
|
||||
type="button"
|
||||
className="rounded-sm px-2 py-1.5 text-left text-sm outline-none transition-colors hover:bg-accent hover:text-accent-foreground focus:bg-accent focus:text-accent-foreground"
|
||||
onClick={() => {
|
||||
const prompt = containerPrompt;
|
||||
setContainerPrompt(null);
|
||||
if (!prompt) return;
|
||||
runPodCommand(prompt.pod, prompt.action, name);
|
||||
}}
|
||||
>
|
||||
{name}
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
</DialogContent>
|
||||
</Dialog>
|
||||
</div>
|
||||
);
|
||||
});
|
||||
@@ -14,6 +14,7 @@ import { resolveCapabilityPanelState } from '../../domain/systemManagerPanelStat
|
||||
import {
|
||||
allowSystemManagerMutations,
|
||||
buildSystemManagerTabs,
|
||||
hasContainerSource,
|
||||
shouldCollectServerStats,
|
||||
} from '../../domain/systemManager/systemTarget';
|
||||
|
||||
@@ -284,13 +285,14 @@ export const SystemManagerSidePanel = memo(function SystemManagerSidePanel({
|
||||
}, [isConnectedSession, isVisible, availableTabsKey, tabBarEl]);
|
||||
|
||||
// Must be defined before early returns to comply with React rules of hooks.
|
||||
const containerSourceReady = hasContainerSource(capabilities);
|
||||
const prevTabRef = React.useRef(resolvedTab);
|
||||
const probingRef = React.useRef(false);
|
||||
React.useEffect(() => {
|
||||
const prev = prevTabRef.current;
|
||||
prevTabRef.current = resolvedTab;
|
||||
if (prev === resolvedTab) return;
|
||||
if (resolvedTab === 'docker' && capabilities?.hasDocker !== true) {
|
||||
if (resolvedTab === 'docker' && !containerSourceReady) {
|
||||
if (!probingRef.current) {
|
||||
probingRef.current = true;
|
||||
refreshCapabilities().finally(() => { probingRef.current = false; });
|
||||
@@ -298,7 +300,7 @@ export const SystemManagerSidePanel = memo(function SystemManagerSidePanel({
|
||||
} else if (resolvedTab === 'tmux' && capabilities?.hasTmux !== true) {
|
||||
void refreshCapabilities();
|
||||
}
|
||||
}, [resolvedTab, capabilities, refreshCapabilities]);
|
||||
}, [resolvedTab, capabilities, containerSourceReady, refreshCapabilities]);
|
||||
|
||||
// Auto-poll for Docker capabilities while Docker tab is active and Docker not yet detected.
|
||||
// Use setTimeout recursion so the next probe only starts after the previous one finishes,
|
||||
@@ -315,7 +317,7 @@ export const SystemManagerSidePanel = memo(function SystemManagerSidePanel({
|
||||
// preventing stale probes from surviving cleanup (unlike cancelledRef which is shared).
|
||||
// First poll is delayed by one interval to avoid overlapping with the tab-switch probe.
|
||||
React.useEffect(() => {
|
||||
if (!isVisible || resolvedTab !== 'docker' || capabilities?.hasDocker === true) return;
|
||||
if (!isVisible || resolvedTab !== 'docker' || containerSourceReady) return;
|
||||
|
||||
let cancelled = false;
|
||||
let timerId: ReturnType<typeof setTimeout>;
|
||||
@@ -344,7 +346,7 @@ export const SystemManagerSidePanel = memo(function SystemManagerSidePanel({
|
||||
cancelled = true;
|
||||
if (timerId) clearTimeout(timerId);
|
||||
};
|
||||
}, [isVisible, resolvedTab, capabilities?.hasDocker, capabilitiesTtlMs]);
|
||||
}, [isVisible, resolvedTab, containerSourceReady, capabilitiesTtlMs]);
|
||||
|
||||
const selectTab = useCallback((id: SystemManagerSubTab) => {
|
||||
setActiveTab(id);
|
||||
@@ -398,7 +400,7 @@ export const SystemManagerSidePanel = memo(function SystemManagerSidePanel({
|
||||
}
|
||||
|
||||
const tmuxReady = capabilities?.hasTmux === true;
|
||||
const dockerReady = capabilities?.hasDocker === true;
|
||||
const dockerReady = containerSourceReady;
|
||||
const gpuReady = capabilities?.hasNvidiaSmi === true || capabilities?.hasNpuSmi === true;
|
||||
const portsReady = (
|
||||
capabilities?.hasSs === true
|
||||
@@ -621,8 +623,10 @@ export const SystemManagerSidePanel = memo(function SystemManagerSidePanel({
|
||||
) : dockerPanelState === 'ready' ? (
|
||||
<div className={cn('flex-1 min-h-0 flex flex-col', resolvedTab !== 'docker' && 'hidden')}>
|
||||
<DockerManagerTab
|
||||
key={session.hostId || sessionId}
|
||||
sessionId={sessionId}
|
||||
parentSession={session}
|
||||
capabilities={capabilities}
|
||||
isVisible={isVisible && resolvedTab === 'docker'}
|
||||
warmupEnabled={isVisible && resolvedTab !== 'docker'}
|
||||
backend={backend}
|
||||
|
||||
@@ -0,0 +1,25 @@
|
||||
import assert from 'node:assert/strict';
|
||||
import test from 'node:test';
|
||||
import { readFileSync } from 'node:fs';
|
||||
|
||||
test('kubernetes keeps the shared runtime select and drops the Containers back button', () => {
|
||||
const tab = readFileSync(new URL('./DockerManagerTab.tsx', import.meta.url), 'utf8');
|
||||
const hook = readFileSync(
|
||||
new URL('../../application/state/systemManager/useContainerRuntimeSelection.ts', import.meta.url),
|
||||
'utf8',
|
||||
);
|
||||
|
||||
assert.equal(tab.includes('container-runtime-back'), false);
|
||||
assert.match(tab, /data-section=\{section\}/);
|
||||
assert.match(tab, /section="container-runtime-switcher"/);
|
||||
assert.match(tab, /section="k8s-context-select"/);
|
||||
assert.match(tab, /section="k8s-namespace-select"/);
|
||||
assert.doesNotMatch(tab, /<div className="flex-1" \/>/);
|
||||
assert.match(tab, /section="k8s-context-select"[\s\S]*className="w-full min-w-0 max-w-none flex-1"/);
|
||||
assert.match(tab, /section="container-runtime-switcher"[\s\S]*className="shrink-0"/);
|
||||
assert.match(tab, /section="k8s-namespace-select"[\s\S]*className="shrink-0"/);
|
||||
assert.match(hook, /const showRuntimeSwitcher = detected\.length > 1;/);
|
||||
assert.doesNotMatch(hook, /source !== 'kubernetes' && detected\.length/);
|
||||
assert.match(hook, /reconcileContainerSource\(/);
|
||||
assert.match(hook, /isAutomaticRuntimeFallback\(remembered, source, available\)/);
|
||||
});
|
||||
@@ -93,3 +93,71 @@ test('openInteractiveTerminal keeps SSH connection reuse for ordinary connected
|
||||
assert.equal(payload.sourceSession.protocol, 'ssh');
|
||||
assert.equal(payload.sourceSession.reuseConnectionFromSessionId, 'session-1');
|
||||
});
|
||||
|
||||
async function withSystemPanels(
|
||||
panels: Array<{ getBoundingClientRect: () => { x: number; y: number; width: number; height: number } }>,
|
||||
run: () => Promise<void>,
|
||||
): Promise<void> {
|
||||
const previous = globalThis.document;
|
||||
globalThis.document = {
|
||||
querySelectorAll(selector: string) {
|
||||
return selector === '[data-section="system-manager-panel"]' ? panels : [];
|
||||
},
|
||||
} as unknown as Document;
|
||||
try {
|
||||
await run();
|
||||
} finally {
|
||||
if (previous === undefined) {
|
||||
delete (globalThis as { document?: Document }).document;
|
||||
} else {
|
||||
globalThis.document = previous;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
test('openInteractiveTerminal passes the system panel rectangle so the shell can avoid it', async () => {
|
||||
const panel = {
|
||||
getBoundingClientRect: () => ({ x: 1000, y: 40, width: 440, height: 860 }),
|
||||
};
|
||||
const payloads: Array<{ avoidClientRect?: { x: number; width: number } }> = [];
|
||||
await withSystemPanels([panel], async () => {
|
||||
await openInteractiveTerminal(
|
||||
{
|
||||
openTerminalPopup: async (payload: { avoidClientRect?: { x: number; width: number } }) => {
|
||||
payloads.push(payload);
|
||||
return { success: true };
|
||||
},
|
||||
} as never,
|
||||
parentSession(),
|
||||
'pod: multi-box',
|
||||
'kubectl exec -it multi-box -- sh',
|
||||
);
|
||||
});
|
||||
|
||||
assert.deepEqual(payloads[0]?.avoidClientRect, { x: 1000, y: 40, width: 440, height: 860 });
|
||||
});
|
||||
|
||||
test('openInteractiveTerminal skips a parked zero-size system panel and avoids the visible one', async () => {
|
||||
const parked = {
|
||||
getBoundingClientRect: () => ({ x: 0, y: 0, width: 0, height: 0 }),
|
||||
};
|
||||
const visible = {
|
||||
getBoundingClientRect: () => ({ x: 1000, y: 40, width: 440, height: 860 }),
|
||||
};
|
||||
const payloads: Array<{ avoidClientRect?: { x: number; y: number; width: number; height: number } }> = [];
|
||||
await withSystemPanels([parked, visible], async () => {
|
||||
await openInteractiveTerminal(
|
||||
{
|
||||
openTerminalPopup: async (payload: { avoidClientRect?: { x: number; y: number; width: number; height: number } }) => {
|
||||
payloads.push(payload);
|
||||
return { success: true };
|
||||
},
|
||||
} as never,
|
||||
parentSession(),
|
||||
'pod: multi-box',
|
||||
'kubectl exec -it multi-box -- sh',
|
||||
);
|
||||
});
|
||||
|
||||
assert.deepEqual(payloads[0]?.avoidClientRect, { x: 1000, y: 40, width: 440, height: 860 });
|
||||
});
|
||||
@@ -6,6 +6,18 @@ import type { useSystemManagerBackend } from '../../application/state/useSystemM
|
||||
|
||||
type Backend = ReturnType<typeof useSystemManagerBackend>;
|
||||
|
||||
function readSystemPanelClientRect(): { x: number; y: number; width: number; height: number } | undefined {
|
||||
if (typeof document === "undefined" || typeof document.querySelectorAll !== "function") return undefined;
|
||||
const panels = document.querySelectorAll('[data-section="system-manager-panel"]');
|
||||
for (const panel of panels) {
|
||||
if (typeof panel.getBoundingClientRect !== "function") continue;
|
||||
const rect = panel.getBoundingClientRect();
|
||||
if (rect.width < 1 || rect.height < 1) continue;
|
||||
return { x: rect.x, y: rect.y, width: rect.width, height: rect.height };
|
||||
}
|
||||
return undefined;
|
||||
}
|
||||
|
||||
function buildPopupTitle(parentSession: TerminalSession, title: string): string {
|
||||
const hostLabel = parentSession.hostLabel.trim();
|
||||
const cleanTitle = title.trim();
|
||||
@@ -62,11 +74,13 @@ export async function openInteractiveTerminal(
|
||||
canReuseConnection,
|
||||
hasIcon: !!options?.icon,
|
||||
});
|
||||
const avoidClientRect = readSystemPanelClientRect();
|
||||
const result = await backend.openTerminalPopup({
|
||||
title: popupTitle,
|
||||
icon: options?.icon,
|
||||
parentSessionId: parentSession.id,
|
||||
startupCommand,
|
||||
...(avoidClientRect ? { avoidClientRect } : {}),
|
||||
sourceSession: buildCommandPopupSourceSession(parentSession, startupCommand, canReuseConnection),
|
||||
});
|
||||
await writeSystemManagerDiagnostic('openInteractiveTerminal result', {
|
||||
|
||||
@@ -49,11 +49,11 @@ test("process and docker confirm dialogs reset when sessionId changes", () => {
|
||||
assert.match(processSource, /}, \[sessionId\]\);/);
|
||||
|
||||
assert.match(containersSource, /setConfirmAction\(null\)/);
|
||||
assert.match(containersSource, /}, \[sessionId\]\);/);
|
||||
assert.match(containersSource, /}, \[runtime, sessionId\]\);/);
|
||||
|
||||
assert.match(imagesSource, /setConfirmTarget\(null\)/);
|
||||
assert.match(imagesSource, /setActionBusy\(false\)/);
|
||||
assert.match(imagesSource, /}, \[sessionId\]\);/);
|
||||
assert.match(imagesSource, /}, \[runtime, sessionId\]\);/);
|
||||
});
|
||||
|
||||
test("ports and services confirm dialogs reset when sessionId changes", () => {
|
||||
|
||||
@@ -0,0 +1,66 @@
|
||||
# Shared AI privacy and reversible masking
|
||||
|
||||
AI privacy masking is **off by default**. Enable it in Settings > AI > Tool Access >
|
||||
AI privacy. This shared policy applies to Netcatty-managed context and tools used by
|
||||
Catty, integrated agents and external MCP clients. It is independent of the External
|
||||
MCP switch. External stdio and Streamable HTTP clients use the same policy, including
|
||||
for the environment resource.
|
||||
|
||||
This is not a system-wide AI traffic filter. Third-party agents' native tools,
|
||||
direct file access and other MCP servers are outside the Netcatty-managed boundary.
|
||||
Local terminal display, saved terminal output and actual host data are not changed.
|
||||
|
||||
## Working loop
|
||||
|
||||
A recognized address or credential in managed context or a tool response becomes an
|
||||
opaque token, for example `[NC_<session>_IP_1]`. The same exact value has the same token
|
||||
within its managed conversation or external MCP connection. A later managed command
|
||||
or file-write argument containing that token is restored locally before permission
|
||||
checks and the approval dialog. The host receives the original value; the agent
|
||||
receives masked managed results.
|
||||
|
||||
Mappings stay in memory and are isolated by managed conversation or external MCP
|
||||
connection. No mapping is saved to disk or shared through the app-wide external chat
|
||||
scope. New conversations and reconnects start a new mapping. Unknown, stale, or
|
||||
other-session tokens are rejected rather than sent to the host.
|
||||
|
||||
After changing privacy settings, start a new managed conversation and reconnect
|
||||
external MCP clients. Existing scopes fail closed instead of silently changing
|
||||
their protection. Settings apply only after main-process validation succeeds. A
|
||||
saved legacy External MCP privacy preference is used only if no shared AI privacy
|
||||
preference exists; applying valid settings saves the shared preference. Corrupt
|
||||
saved settings must be repaired rather than silently reverting to unprotected
|
||||
operation.
|
||||
|
||||
## What is covered
|
||||
|
||||
- Recognized credential assignments, authorization bearer values, URL passwords,
|
||||
familiar API token forms, structured secret fields and complete private-key blocks.
|
||||
- IPv4 and IPv6 addresses, with private/local ranges retained by default. Enable
|
||||
**Redact private IP addresses** to hide them too, or select additional CIDRs.
|
||||
Documentation/test addresses are also masked.
|
||||
- Structured host names and user-supplied regular-expression matches.
|
||||
- Netcatty-managed text context, tool results, nested structured results and errors.
|
||||
|
||||
Custom expressions match whole values and use the existing RE2 engine. Unsupported
|
||||
expressions, malformed CIDRs, or empty matches are rejected. Rules and mapping
|
||||
sizes are bounded; exhaustion blocks the response instead of evicting active tokens.
|
||||
|
||||
Binary/base64 tool attachments are blocked while masking is enabled. This feature
|
||||
does not scan images, archives, encoded/encrypted content, files accessed directly
|
||||
by third-party agents, or other MCP servers' results. It cannot guarantee detection
|
||||
of arbitrary credentials, all hostnames, or intentionally obfuscated data. It is an
|
||||
accidental-disclosure safeguard, not a DLP system or a substitute for the normal
|
||||
tool permission policy.
|
||||
|
||||
## Verification
|
||||
|
||||
Keep External MCP disabled and enable AI privacy. Use synthetic values in a new
|
||||
Catty conversation and a new integrated-agent conversation, then repeat through
|
||||
external stdio and HTTP clients after enabling External MCP: read `192.0.2.10`, read
|
||||
`198.51.100.7`, then read the first again. Tokens should be different for different
|
||||
values and stable for repeated reads within the same scope. Write a returned token
|
||||
back through a managed tool or use it in a managed terminal command; the actual
|
||||
file/terminal should contain the original value, never the token. A token from
|
||||
another conversation/client or an earlier scope must fail without dispatching a
|
||||
command. No protection should be implied for third-party native tool/file access.
|
||||
@@ -11,6 +11,13 @@ import {
|
||||
isNetcattyManagedStartupHistoryCommand,
|
||||
} from './remoteHistory.ts';
|
||||
import { buildDockerExecShellCommand, buildDockerLogsCommand } from './systemManager/dockerShell.ts';
|
||||
import {
|
||||
buildContainerExecShellCommand,
|
||||
buildContainerLogsCommand,
|
||||
buildKubectlDescribeCommand,
|
||||
buildKubectlExecShellCommand,
|
||||
buildKubectlLogsCommand,
|
||||
} from './systemManager/containerRuntime.ts';
|
||||
import { buildTmuxAttachCommand } from './systemManager/tmuxShell.ts';
|
||||
|
||||
test('parseBashHistory: plain lines', () => {
|
||||
@@ -199,8 +206,44 @@ test('isNetcattyAiHistoryCommand: detects AI PTY marker lines', () => {
|
||||
test('isNetcattyManagedStartupHistoryCommand: detects Docker and tmux terminal launch commands', () => {
|
||||
assert.equal(isNetcattyManagedStartupHistoryCommand(buildDockerExecShellCommand('587abcdef123')), true);
|
||||
assert.equal(isNetcattyManagedStartupHistoryCommand(buildDockerLogsCommand('587abcdef123')), true);
|
||||
assert.equal(isNetcattyManagedStartupHistoryCommand(buildContainerLogsCommand('podman', '587abcdef123')), true);
|
||||
assert.equal(
|
||||
isNetcattyManagedStartupHistoryCommand(buildKubectlLogsCommand('kind-dev', 'default', 'api-0')),
|
||||
true,
|
||||
);
|
||||
assert.equal(
|
||||
isNetcattyManagedStartupHistoryCommand(
|
||||
"printf '\\033[H\\033[2J\\033[3J'; _nc_docker_err=$(docker inspect 587abcdef123 2>&1 >/dev/null);",
|
||||
),
|
||||
true,
|
||||
);
|
||||
assert.equal(isNetcattyManagedStartupHistoryCommand(buildTmuxAttachCommand('my-session')), true);
|
||||
assert.equal(isNetcattyManagedStartupHistoryCommand(buildTmuxAttachCommand('my-session', 2)), true);
|
||||
assert.equal(isNetcattyManagedStartupHistoryCommand(buildContainerLogsCommand('podman', 'abc123', 'powershell')), true);
|
||||
assert.equal(isNetcattyManagedStartupHistoryCommand(buildContainerExecShellCommand('nerdctl', 'abc123', 'cmd')), true);
|
||||
assert.equal(
|
||||
isNetcattyManagedStartupHistoryCommand(buildKubectlLogsCommand('kind-kind', 'default', 'api', undefined, 'powershell')),
|
||||
true,
|
||||
);
|
||||
assert.equal(
|
||||
isNetcattyManagedStartupHistoryCommand(buildKubectlDescribeCommand('kind-kind', 'default', 'api', 'cmd')),
|
||||
true,
|
||||
);
|
||||
assert.equal(
|
||||
isNetcattyManagedStartupHistoryCommand(buildKubectlExecShellCommand('kind-kind', 'default', 'api', 'app', 'powershell')),
|
||||
true,
|
||||
);
|
||||
assert.equal(isNetcattyManagedStartupHistoryCommand('podman logs -f abc123'), false);
|
||||
assert.equal(isNetcattyManagedStartupHistoryCommand('kubectl logs api'), false);
|
||||
assert.equal(isNetcattyManagedStartupHistoryCommand('kubectl describe pod api'), false);
|
||||
assert.equal(
|
||||
isNetcattyManagedStartupHistoryCommand('kubectl --context prod -n default logs -f --tail 200 api'),
|
||||
false,
|
||||
);
|
||||
assert.equal(
|
||||
isNetcattyManagedStartupHistoryCommand('kubectl --context prod describe pod api'),
|
||||
false,
|
||||
);
|
||||
assert.equal(isNetcattyManagedStartupHistoryCommand('docker ps -a'), false);
|
||||
assert.equal(isNetcattyManagedStartupHistoryCommand('docker logs -f 587abcdef123'), false);
|
||||
assert.equal(isNetcattyManagedStartupHistoryCommand('docker exec -it 587abcdef123 bash'), false);
|
||||
|
||||
@@ -1,4 +1,5 @@
|
||||
import { RemoteHistoryEntry, RemoteHistorySource } from './models';
|
||||
import { NETCATTY_POPUP_HISTORY_MARKER } from './systemManager/containerRuntime.ts';
|
||||
|
||||
/** Marker prefix Netcatty AI uses when executing commands via the PTY bridge. */
|
||||
export const NETCATTY_AI_HISTORY_MARKER = '__NCMCP_';
|
||||
@@ -9,11 +10,17 @@ export function isNetcattyAiHistoryCommand(command: string): boolean {
|
||||
}
|
||||
|
||||
const NETCATTY_MANAGED_STARTUP_COMMAND =
|
||||
/^(?:sh\s+-c\s+.*printf .*\\033\[H\\033\[2J\\033\[3J.*_nc_docker_err=.*\bdocker\s+inspect\b|printf '\\033\[H\\033\[2J\\033\[3J';\s*(?:_nc_docker_err=.*\bdocker\s+inspect\b|exec\s+(?:docker\s+(?:exec|logs)\b|tmux\s+attach\b)))/;
|
||||
/^(?:sh\s+-c\s+.*printf .*\\033\[H\\033\[2J\\033\[3J.*(?:_nc_(?:docker|rt)_err=.*\b(?:docker|podman|nerdctl)\s+inspect\b|exec\s+kubectl\b)|printf '\\033\[H\\033\[2J\\033\[3J';\s*(?:_nc_(?:docker|rt)_err=.*\b(?:docker|podman|nerdctl)\s+inspect\b|exec\s+(?:(?:docker|podman|nerdctl)\s+(?:exec|logs)\b|tmux\s+attach\b)))/;
|
||||
|
||||
const NETCATTY_WINDOWS_CONTAINER_POPUP_COMMAND =
|
||||
/^(?:\$__nc_popup='__NC_POPUP__'; |set "__NC_POPUP__=1"& )?(?:docker|podman|nerdctl) inspect [a-zA-Z0-9]+ (?:>\$null; if \(\$LASTEXITCODE -eq 0\) \{|>nul && )/;
|
||||
|
||||
/** True when a shell history line came from a Netcatty-managed terminal launch. */
|
||||
export function isNetcattyManagedStartupHistoryCommand(command: string): boolean {
|
||||
return NETCATTY_MANAGED_STARTUP_COMMAND.test(command.trim());
|
||||
const text = command.trim();
|
||||
return text.includes(NETCATTY_POPUP_HISTORY_MARKER)
|
||||
|| NETCATTY_MANAGED_STARTUP_COMMAND.test(text)
|
||||
|| NETCATTY_WINDOWS_CONTAINER_POPUP_COMMAND.test(text);
|
||||
}
|
||||
|
||||
const ZSH_EXTENDED_RECORD = /^: (\d+):\d+;([\s\S]*)$/;
|
||||
|
||||
@@ -0,0 +1,550 @@
|
||||
"use strict";
|
||||
|
||||
const CONTAINER_RUNTIMES = ["docker", "podman", "nerdctl"];
|
||||
const SOURCE_ORDER = ["docker", "podman", "nerdctl", "kubernetes"];
|
||||
|
||||
const CLEAR_STARTUP_OUTPUT = "printf '\\033[H\\033[2J\\033[3J';";
|
||||
const NETCATTY_POPUP_HISTORY_MARKER = "__NC_POPUP__";
|
||||
|
||||
function popupHistoryPrefix(shell) {
|
||||
if (shell === "powershell") return `$__nc_popup='${NETCATTY_POPUP_HISTORY_MARKER}'; `;
|
||||
if (shell === "cmd") return `set "${NETCATTY_POPUP_HISTORY_MARKER}=1"& `;
|
||||
return "";
|
||||
}
|
||||
|
||||
function shQuote(str) {
|
||||
return `'${String(str).replace(/'/g, "'\"'\"'")}'`;
|
||||
}
|
||||
|
||||
function normalizeContainerRuntime(runtime) {
|
||||
return CONTAINER_RUNTIMES.includes(runtime) ? runtime : "docker";
|
||||
}
|
||||
|
||||
function isContainerSource(value) {
|
||||
return SOURCE_ORDER.includes(value);
|
||||
}
|
||||
|
||||
function runtimeBinary(runtime) {
|
||||
return normalizeContainerRuntime(runtime);
|
||||
}
|
||||
|
||||
function buildRuntimeCommand(runtime, args) {
|
||||
return `${runtimeBinary(runtime)} ${String(args || "")}`.trim();
|
||||
}
|
||||
|
||||
function parseRuntimeProbe(stdout) {
|
||||
const text = stdout || "";
|
||||
const hasFlag = (name) => new RegExp(`(?:^|\\r?\\n)${name}=1(?:\\r?\\n|$)`).test(text);
|
||||
return {
|
||||
docker: hasFlag("__NC_DOCKER__"),
|
||||
podman: hasFlag("__NC_PODMAN__"),
|
||||
nerdctl: hasFlag("__NC_NERDCTL__"),
|
||||
kubernetes: hasFlag("__NC_KUBECTL__"),
|
||||
};
|
||||
}
|
||||
|
||||
function sourcesFromCapabilities(capabilities) {
|
||||
if (!capabilities) return [];
|
||||
const present = {
|
||||
docker: capabilities.hasDocker === true || capabilities.docker === true,
|
||||
podman: capabilities.hasPodman === true || capabilities.podman === true,
|
||||
nerdctl: capabilities.hasNerdctl === true || capabilities.nerdctl === true,
|
||||
kubernetes: capabilities.hasKubectl === true || capabilities.kubernetes === true,
|
||||
};
|
||||
return SOURCE_ORDER.filter((id) => present[id]);
|
||||
}
|
||||
|
||||
function pickContainerSource(detected, current, remembered) {
|
||||
const available = SOURCE_ORDER.filter((id) => Array.isArray(detected) && detected.includes(id));
|
||||
if (current && available.includes(current)) return current;
|
||||
if (remembered && available.includes(remembered)) return remembered;
|
||||
return available[0] || null;
|
||||
}
|
||||
|
||||
function rememberedContainerSource(preference, detected) {
|
||||
const source = preference?.source;
|
||||
if (!isContainerSource(source)) return null;
|
||||
const available = Array.isArray(detected) ? detected : [];
|
||||
if (source === "kubernetes" && !available.includes("kubernetes") && CONTAINER_RUNTIMES.includes(preference?.engine)) {
|
||||
return preference.engine;
|
||||
}
|
||||
return source;
|
||||
}
|
||||
|
||||
function reconcileContainerSource(detected, current, preference) {
|
||||
const available = SOURCE_ORDER.filter((id) => Array.isArray(detected) && detected.includes(id));
|
||||
const preferred = rememberedContainerSource(preference, available);
|
||||
if (preferred && available.includes(preferred)) return preferred;
|
||||
return pickContainerSource(available, current, preferred);
|
||||
}
|
||||
|
||||
function isAutomaticRuntimeFallback(preference, source, detected) {
|
||||
if (!preference?.source || !isContainerSource(source) || preference.source === source) return false;
|
||||
const available = Array.isArray(detected) ? detected : [];
|
||||
if (available.includes(preference.source)) return false;
|
||||
const automatic = pickContainerSource(
|
||||
available,
|
||||
null,
|
||||
rememberedContainerSource(preference, available),
|
||||
);
|
||||
return source === automatic;
|
||||
}
|
||||
|
||||
function firstContainerRuntime(detected) {
|
||||
return CONTAINER_RUNTIMES.find((id) => Array.isArray(detected) && detected.includes(id)) || null;
|
||||
}
|
||||
|
||||
function socketNeedles(runtime) {
|
||||
if (runtime === "podman") {
|
||||
return ["podman.sock", "/run/podman", "podman daemon"];
|
||||
}
|
||||
if (runtime === "nerdctl") {
|
||||
return ["containerd.sock", "/run/containerd"];
|
||||
}
|
||||
return ["docker daemon", "docker.sock", "/var/run/docker.sock", "connect to the docker daemon"];
|
||||
}
|
||||
|
||||
function isRuntimeSocketPermissionError(runtime, result) {
|
||||
const text = `${result?.stderr || ""}\n${result?.stdout || ""}\n${result?.error || ""}`.toLowerCase();
|
||||
if (!text.includes("permission denied")) return false;
|
||||
return socketNeedles(normalizeContainerRuntime(runtime)).some((needle) => text.includes(needle));
|
||||
}
|
||||
|
||||
function socketCasePatterns(runtime) {
|
||||
if (runtime === "podman") {
|
||||
return [
|
||||
"*permission\\ denied*podman.sock*",
|
||||
"*podman.sock*permission\\ denied*",
|
||||
"*permission\\ denied*/run/podman*",
|
||||
"*/run/podman*permission\\ denied*",
|
||||
"*permission\\ denied*podman\\ daemon*",
|
||||
"*podman\\ daemon*permission\\ denied*",
|
||||
];
|
||||
}
|
||||
if (runtime === "nerdctl") {
|
||||
return [
|
||||
"*permission\\ denied*containerd.sock*",
|
||||
"*containerd.sock*permission\\ denied*",
|
||||
"*permission\\ denied*/run/containerd*",
|
||||
"*/run/containerd*permission\\ denied*",
|
||||
];
|
||||
}
|
||||
return [
|
||||
"*permission\\ denied*docker\\ daemon*",
|
||||
"*docker\\ daemon*permission\\ denied*",
|
||||
"*permission\\ denied*docker.sock*",
|
||||
"*docker.sock*permission\\ denied*",
|
||||
"*permission\\ denied*/var/run/docker.sock*",
|
||||
"*/var/run/docker.sock*permission\\ denied*",
|
||||
"*permission\\ denied*connect\\ to\\ the\\ docker\\ daemon*",
|
||||
"*connect\\ to\\ the\\ docker\\ daemon*permission\\ denied*",
|
||||
];
|
||||
}
|
||||
|
||||
function sanitizeContainerId(id) {
|
||||
return String(id || "").replace(/[^a-zA-Z0-9]/g, "").slice(0, 64);
|
||||
}
|
||||
|
||||
function containerInteractiveArgs(containerId, shell) {
|
||||
if (shell === "cmd") {
|
||||
return `exec -it ${containerId} sh -c "command -v bash >/dev/null 2>&1 && exec bash || exec sh"`;
|
||||
}
|
||||
return `exec -it ${containerId} sh -c 'command -v bash >/dev/null 2>&1 && exec bash || exec sh'`;
|
||||
}
|
||||
|
||||
function buildContainerCommandWithSudoFallback(runtime, containerId, cliArgs, shell) {
|
||||
const popupShell = normalizePopupShell(shell);
|
||||
const bin = runtimeBinary(runtime);
|
||||
const plainCommand = `${bin} ${cliArgs}`;
|
||||
if (popupShell === "powershell") {
|
||||
return `${popupHistoryPrefix(popupShell)}${bin} inspect ${containerId} >$null; if ($LASTEXITCODE -eq 0) { ${plainCommand} } else { exit $LASTEXITCODE }`;
|
||||
}
|
||||
if (popupShell === "cmd") {
|
||||
return `${popupHistoryPrefix(popupShell)}${bin} inspect ${containerId} >nul && ${plainCommand}`;
|
||||
}
|
||||
const sudoCommand = `sudo ${plainCommand}`;
|
||||
const script = [
|
||||
CLEAR_STARTUP_OUTPUT,
|
||||
`_nc_rt_err=$(${bin} inspect ${containerId} 2>&1 >/dev/null);`,
|
||||
"_nc_rt_status=$?;",
|
||||
`if [ "$_nc_rt_status" -eq 0 ]; then exec ${plainCommand}; fi;`,
|
||||
"_nc_rt_lc=$(printf '%s' \"$_nc_rt_err\" | tr '[:upper:]' '[:lower:]');",
|
||||
"case \"$_nc_rt_lc\" in",
|
||||
`${socketCasePatterns(normalizeContainerRuntime(runtime)).join("|")}) exec ${sudoCommand} ;;`,
|
||||
"*) printf '%s\\n' \"$_nc_rt_err\" >&2; exit \"$_nc_rt_status\" ;;",
|
||||
"esac",
|
||||
].join(" ");
|
||||
return `sh -c ${shQuote(script)}`;
|
||||
}
|
||||
|
||||
function buildContainerExecShellCommand(runtime, containerId, shell) {
|
||||
const safeId = sanitizeContainerId(containerId);
|
||||
if (!safeId) return 'echo "Invalid container id"';
|
||||
return buildContainerCommandWithSudoFallback(
|
||||
runtime,
|
||||
safeId,
|
||||
containerInteractiveArgs(safeId, normalizePopupShell(shell)),
|
||||
shell,
|
||||
);
|
||||
}
|
||||
|
||||
function buildContainerLogsCommand(runtime, containerId, shell) {
|
||||
const safeId = sanitizeContainerId(containerId);
|
||||
if (!safeId) return 'echo "Invalid container id"';
|
||||
return buildContainerCommandWithSudoFallback(runtime, safeId, `logs -f --tail 200 ${safeId}`, shell);
|
||||
}
|
||||
|
||||
function hasControlChar(text) {
|
||||
for (let i = 0; i < text.length; i += 1) {
|
||||
const code = text.charCodeAt(i);
|
||||
if (code <= 31 || code === 127) return true;
|
||||
}
|
||||
return false;
|
||||
}
|
||||
|
||||
function isSafeShellArg(value, shell) {
|
||||
const text = String(value ?? "");
|
||||
if (!text || text.length > 512) return false;
|
||||
if (hasControlChar(text)) return false;
|
||||
// Interactive cmd.exe expands %VAR% inside double quotes, and %% is not a
|
||||
// reliable escape there. With /V:ON it also expands !VAR! inside quotes.
|
||||
// Reject those arguments instead of targeting a substituted context.
|
||||
if (shell === "cmd" && (text.includes("%") || text.includes("!"))) return false;
|
||||
return true;
|
||||
}
|
||||
|
||||
function normalizePopupShell(shell) {
|
||||
return shell === "powershell" || shell === "cmd" ? shell : "posix";
|
||||
}
|
||||
|
||||
function quoteShellArg(shell, value) {
|
||||
const text = String(value);
|
||||
if (shell === "cmd") return `"${text.replace(/"/g, '""')}"`;
|
||||
if (shell === "powershell") return `'${text.replace(/'/g, "''")}'`;
|
||||
return shQuote(text);
|
||||
}
|
||||
|
||||
function kubectlPrefix(context, namespace, shell = "posix") {
|
||||
const popupShell = normalizePopupShell(shell);
|
||||
if (!isSafeShellArg(context, popupShell)) return null;
|
||||
const parts = ["kubectl", "--context", quoteShellArg(popupShell, context)];
|
||||
if (namespace != null && namespace !== "") {
|
||||
if (!isSafeShellArg(namespace, popupShell)) return null;
|
||||
parts.push("-n", quoteShellArg(popupShell, namespace));
|
||||
}
|
||||
return parts.join(" ");
|
||||
}
|
||||
|
||||
function optionalContainerArg(container, shell = "posix") {
|
||||
if (container == null || container === "") return "";
|
||||
if (!isSafeShellArg(container, normalizePopupShell(shell))) return null;
|
||||
return ` -c ${quoteShellArg(normalizePopupShell(shell), container)}`;
|
||||
}
|
||||
|
||||
function isKubectlForbiddenError(message) {
|
||||
return /\bforbidden\b/i.test(String(message || ""));
|
||||
}
|
||||
|
||||
function isKubectlUnreachableError(message) {
|
||||
const text = String(message || "");
|
||||
if (!text.trim() || isKubectlForbiddenError(text)) return false;
|
||||
return /couldn'?t get current server api group list|could not get current server api group list|unable to connect to the server|connection refused|dial tcp|i\/o timeout|no such host|network is unreachable|context deadline exceeded|tls handshake timeout/i.test(text);
|
||||
}
|
||||
|
||||
function isNerdctlUnreachableError(message) {
|
||||
const text = String(message || "").toLowerCase();
|
||||
if (!text.trim()) return false;
|
||||
if (text.includes("rootless containerd") || text.includes("xdg_runtime_dir")) return true;
|
||||
if (text.includes("cannot connect to the docker daemon")) return true;
|
||||
if (!text.includes("containerd")) return false;
|
||||
return /not running|connection refused|no such file|cannot connect|failed to connect|unavailable/.test(text);
|
||||
}
|
||||
|
||||
const POD_STATUS_MESSAGE_KEYS = {
|
||||
Running: "systemManager.k8s.status.Running",
|
||||
Pending: "systemManager.k8s.status.Pending",
|
||||
Completed: "systemManager.k8s.status.Completed",
|
||||
Succeeded: "systemManager.k8s.status.Succeeded",
|
||||
Failed: "systemManager.k8s.status.Failed",
|
||||
Unknown: "systemManager.k8s.status.Unknown",
|
||||
CrashLoopBackOff: "systemManager.k8s.status.CrashLoopBackOff",
|
||||
ContainerCreating: "systemManager.k8s.status.ContainerCreating",
|
||||
Evicted: "systemManager.k8s.status.Evicted",
|
||||
Terminating: "systemManager.k8s.status.Terminating",
|
||||
ImagePullBackOff: "systemManager.k8s.status.ImagePullBackOff",
|
||||
ErrImagePull: "systemManager.k8s.status.ErrImagePull",
|
||||
Error: "systemManager.k8s.status.Error",
|
||||
OOMKilled: "systemManager.k8s.status.OOMKilled",
|
||||
};
|
||||
|
||||
function podStatusMessageKey(status) {
|
||||
return POD_STATUS_MESSAGE_KEYS[String(status || "")] || null;
|
||||
}
|
||||
|
||||
function runtimeFailureMessage(errorCode, context) {
|
||||
if (errorCode === "nerdctlUnreachable") {
|
||||
return { key: "systemManager.errors.nerdctlUnreachable" };
|
||||
}
|
||||
if (errorCode === "clusterUnreachable") {
|
||||
const name = typeof context === "string" ? context.trim() : "";
|
||||
if (name) {
|
||||
return {
|
||||
key: "systemManager.errors.clusterUnreachable",
|
||||
values: { context: name },
|
||||
};
|
||||
}
|
||||
return { key: "systemManager.errors.clusterUnreachableGeneric" };
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
||||
function wrapClearAndExec(command) {
|
||||
return `sh -c ${shQuote(`${CLEAR_STARTUP_OUTPUT} exec ${command}`)}`;
|
||||
}
|
||||
|
||||
function buildKubectlContextsCommand() {
|
||||
return "kubectl config view -o json";
|
||||
}
|
||||
|
||||
function buildKubectlNamespacesCommand(context, shell) {
|
||||
const popupShell = normalizePopupShell(shell);
|
||||
if (!isSafeShellArg(context, popupShell)) return null;
|
||||
return `kubectl --context ${quoteShellArg(popupShell, context)} get namespaces -o json`;
|
||||
}
|
||||
|
||||
function buildKubectlPodsCommand(context, namespace, shell) {
|
||||
const prefix = kubectlPrefix(context, namespace, shell);
|
||||
if (!prefix) return null;
|
||||
return `${prefix} get pods -o json`;
|
||||
}
|
||||
|
||||
function buildKubectlDeletePodCommand(context, namespace, pod, shell) {
|
||||
const popupShell = normalizePopupShell(shell);
|
||||
const prefix = kubectlPrefix(context, namespace, popupShell);
|
||||
if (!prefix || !isSafeShellArg(pod, popupShell)) return null;
|
||||
return `${prefix} delete pod ${quoteShellArg(popupShell, pod)} --wait=false`;
|
||||
}
|
||||
|
||||
function containerShellArg(shell) {
|
||||
if (shell === "cmd") {
|
||||
return 'sh -c "command -v bash >/dev/null 2>&1 && exec bash || exec sh"';
|
||||
}
|
||||
return "sh -c 'command -v bash >/dev/null 2>&1 && exec bash || exec sh'";
|
||||
}
|
||||
|
||||
function finishKubectlPopup(shell, command) {
|
||||
return shell === "posix" ? wrapClearAndExec(command) : `${popupHistoryPrefix(shell)}${command}`;
|
||||
}
|
||||
|
||||
function buildKubectlExecShellCommand(context, namespace, pod, container, shell) {
|
||||
const popupShell = normalizePopupShell(shell);
|
||||
const prefix = kubectlPrefix(context, namespace, popupShell);
|
||||
if (!prefix) return 'echo "Invalid context"';
|
||||
if (!isSafeShellArg(pod, popupShell)) return 'echo "Invalid pod name"';
|
||||
const containerArg = optionalContainerArg(container, popupShell);
|
||||
if (containerArg == null) return 'echo "Invalid container name"';
|
||||
const command = `${prefix} exec -it ${quoteShellArg(popupShell, pod)}${containerArg} -- ${containerShellArg(popupShell)}`;
|
||||
return finishKubectlPopup(popupShell, command);
|
||||
}
|
||||
|
||||
function buildKubectlLogsCommand(context, namespace, pod, container, shell) {
|
||||
const popupShell = normalizePopupShell(shell);
|
||||
const prefix = kubectlPrefix(context, namespace, popupShell);
|
||||
if (!prefix) return 'echo "Invalid context"';
|
||||
if (!isSafeShellArg(pod, popupShell)) return 'echo "Invalid pod name"';
|
||||
const containerArg = optionalContainerArg(container, popupShell);
|
||||
if (containerArg == null) return 'echo "Invalid container name"';
|
||||
const command = `${prefix} logs -f --tail 200 ${quoteShellArg(popupShell, pod)}${containerArg}`;
|
||||
return finishKubectlPopup(popupShell, command);
|
||||
}
|
||||
|
||||
function buildKubectlDescribeCommand(context, namespace, pod, shell) {
|
||||
const popupShell = normalizePopupShell(shell);
|
||||
const prefix = kubectlPrefix(context, namespace, popupShell);
|
||||
if (!prefix) return 'echo "Invalid context"';
|
||||
if (!isSafeShellArg(pod, popupShell)) return 'echo "Invalid pod name"';
|
||||
const command = `${prefix} describe pod ${quoteShellArg(popupShell, pod)}`;
|
||||
return finishKubectlPopup(popupShell, command);
|
||||
}
|
||||
|
||||
function parseJsonValue(text) {
|
||||
const raw = String(text || "");
|
||||
for (let i = 0; i < raw.length; i += 1) {
|
||||
const ch = raw[i];
|
||||
if (ch !== "{" && ch !== "[") continue;
|
||||
try {
|
||||
return JSON.parse(raw.slice(i));
|
||||
} catch {
|
||||
// A bracketed banner such as [WARN] is not the JSON document.
|
||||
}
|
||||
}
|
||||
throw new Error("Invalid JSON");
|
||||
}
|
||||
|
||||
function parseKubeconfigContexts(jsonText) {
|
||||
const parsed = parseJsonValue(jsonText);
|
||||
const current = typeof parsed["current-context"] === "string" ? parsed["current-context"] : "";
|
||||
const contexts = [];
|
||||
const list = Array.isArray(parsed.contexts) ? parsed.contexts : [];
|
||||
for (const entry of list) {
|
||||
const name = typeof entry?.name === "string" ? entry.name : "";
|
||||
if (!name) continue;
|
||||
const rawNamespace = entry?.context?.namespace;
|
||||
const namespace = typeof rawNamespace === "string" && rawNamespace ? rawNamespace : "default";
|
||||
contexts.push({
|
||||
name,
|
||||
namespace,
|
||||
current: name === current,
|
||||
});
|
||||
}
|
||||
return { contexts, current };
|
||||
}
|
||||
|
||||
function parseNamespaceList(jsonText) {
|
||||
const parsed = parseJsonValue(jsonText);
|
||||
const items = Array.isArray(parsed.items) ? parsed.items : [];
|
||||
const names = [];
|
||||
for (const item of items) {
|
||||
const name = item?.metadata?.name;
|
||||
if (typeof name === "string" && name) names.push(name);
|
||||
}
|
||||
return names;
|
||||
}
|
||||
|
||||
function formatPodAge(iso, now = Date.now()) {
|
||||
const timestamp = Date.parse(String(iso || ""));
|
||||
if (!Number.isFinite(timestamp)) return "";
|
||||
const seconds = Math.max(0, Math.floor((now - timestamp) / 1000));
|
||||
if (seconds < 60) return `${seconds}s`;
|
||||
const minutes = Math.floor(seconds / 60);
|
||||
if (minutes < 60) return `${minutes}m`;
|
||||
const hours = Math.floor(minutes / 60);
|
||||
if (hours < 24) return `${hours}h`;
|
||||
return `${Math.floor(hours / 24)}d`;
|
||||
}
|
||||
|
||||
function podDisplayStatus(pod) {
|
||||
if (typeof pod?.metadata?.deletionTimestamp === "string" && pod.metadata.deletionTimestamp) {
|
||||
return "Terminating";
|
||||
}
|
||||
const statuses = [
|
||||
...(Array.isArray(pod?.status?.initContainerStatuses) ? pod.status.initContainerStatuses : []),
|
||||
...(Array.isArray(pod?.status?.containerStatuses) ? pod.status.containerStatuses : []),
|
||||
];
|
||||
for (const status of statuses) {
|
||||
const reason = status?.state?.waiting?.reason;
|
||||
if (reason) return String(reason);
|
||||
}
|
||||
const podReason = typeof pod?.status?.reason === "string" ? pod.status.reason.trim() : "";
|
||||
if (podReason) return podReason;
|
||||
for (const status of statuses) {
|
||||
const reason = status?.state?.terminated?.reason;
|
||||
if (reason && reason !== "Completed") return String(reason);
|
||||
}
|
||||
const phase = String(pod?.status?.phase || "Unknown");
|
||||
if (phase === "Succeeded") return "Completed";
|
||||
return phase;
|
||||
}
|
||||
|
||||
function podContainerNames(pod) {
|
||||
const names = [];
|
||||
const specs = [
|
||||
...(Array.isArray(pod?.spec?.containers) ? pod.spec.containers : []),
|
||||
...(Array.isArray(pod?.spec?.initContainers) ? pod.spec.initContainers : []),
|
||||
...(Array.isArray(pod?.spec?.ephemeralContainers) ? pod.spec.ephemeralContainers : []),
|
||||
];
|
||||
for (const container of specs) {
|
||||
const name = container?.name;
|
||||
if (typeof name === "string" && name && !names.includes(name)) names.push(name);
|
||||
}
|
||||
return names;
|
||||
}
|
||||
|
||||
function podShellContainerNames(pod) {
|
||||
const running = new Set();
|
||||
const statuses = [
|
||||
...(Array.isArray(pod?.status?.containerStatuses) ? pod.status.containerStatuses : []),
|
||||
...(Array.isArray(pod?.status?.initContainerStatuses) ? pod.status.initContainerStatuses : []),
|
||||
...(Array.isArray(pod?.status?.ephemeralContainerStatuses) ? pod.status.ephemeralContainerStatuses : []),
|
||||
];
|
||||
for (const status of statuses) {
|
||||
if (status?.state?.running && typeof status.name === "string" && status.name) {
|
||||
running.add(status.name);
|
||||
}
|
||||
}
|
||||
const ordered = podContainerNames(pod).filter((name) => running.has(name));
|
||||
for (const name of running) {
|
||||
if (!ordered.includes(name)) ordered.push(name);
|
||||
}
|
||||
return ordered;
|
||||
}
|
||||
|
||||
function podRestartCount(pod) {
|
||||
const statuses = [
|
||||
...(Array.isArray(pod?.status?.containerStatuses) ? pod.status.containerStatuses : []),
|
||||
...(Array.isArray(pod?.status?.initContainerStatuses) ? pod.status.initContainerStatuses : []),
|
||||
];
|
||||
let restarts = 0;
|
||||
for (const status of statuses) {
|
||||
const count = Number(status?.restartCount || 0);
|
||||
if (Number.isFinite(count)) restarts += count;
|
||||
}
|
||||
return restarts;
|
||||
}
|
||||
|
||||
function parseKubectlPodList(jsonText, now = Date.now()) {
|
||||
const parsed = parseJsonValue(jsonText);
|
||||
const items = Array.isArray(parsed.items) ? parsed.items : [];
|
||||
const pods = [];
|
||||
for (const pod of items) {
|
||||
const name = typeof pod?.metadata?.name === "string" ? pod.metadata.name : "";
|
||||
if (!name) continue;
|
||||
pods.push({
|
||||
name,
|
||||
namespace: typeof pod?.metadata?.namespace === "string" ? pod.metadata.namespace : "",
|
||||
status: podDisplayStatus(pod),
|
||||
restarts: podRestartCount(pod),
|
||||
age: formatPodAge(pod?.metadata?.creationTimestamp, now),
|
||||
containers: podContainerNames(pod),
|
||||
shellContainers: podShellContainerNames(pod),
|
||||
});
|
||||
}
|
||||
return pods;
|
||||
}
|
||||
|
||||
module.exports = {
|
||||
NETCATTY_POPUP_HISTORY_MARKER,
|
||||
CONTAINER_RUNTIMES,
|
||||
SOURCE_ORDER,
|
||||
normalizeContainerRuntime,
|
||||
isContainerSource,
|
||||
runtimeBinary,
|
||||
buildRuntimeCommand,
|
||||
parseRuntimeProbe,
|
||||
sourcesFromCapabilities,
|
||||
pickContainerSource,
|
||||
rememberedContainerSource,
|
||||
reconcileContainerSource,
|
||||
isAutomaticRuntimeFallback,
|
||||
firstContainerRuntime,
|
||||
isRuntimeSocketPermissionError,
|
||||
sanitizeContainerId,
|
||||
buildContainerExecShellCommand,
|
||||
buildContainerLogsCommand,
|
||||
buildKubectlContextsCommand,
|
||||
buildKubectlNamespacesCommand,
|
||||
buildKubectlPodsCommand,
|
||||
buildKubectlDeletePodCommand,
|
||||
buildKubectlExecShellCommand,
|
||||
buildKubectlLogsCommand,
|
||||
buildKubectlDescribeCommand,
|
||||
isKubectlForbiddenError,
|
||||
isKubectlUnreachableError,
|
||||
isNerdctlUnreachableError,
|
||||
podStatusMessageKey,
|
||||
runtimeFailureMessage,
|
||||
parseKubeconfigContexts,
|
||||
parseNamespaceList,
|
||||
parseKubectlPodList,
|
||||
formatPodAge,
|
||||
};
|
||||
+109
@@ -0,0 +1,109 @@
|
||||
export type ContainerRuntimeId = "docker" | "podman" | "nerdctl";
|
||||
export type ContainerSourceId = ContainerRuntimeId | "kubernetes";
|
||||
|
||||
export interface RuntimeProbeFlags {
|
||||
docker: boolean;
|
||||
podman: boolean;
|
||||
nerdctl: boolean;
|
||||
kubernetes: boolean;
|
||||
}
|
||||
|
||||
export interface KubeContextInfo {
|
||||
name: string;
|
||||
namespace: string;
|
||||
current: boolean;
|
||||
}
|
||||
|
||||
export interface KubectlPodInfo {
|
||||
name: string;
|
||||
namespace: string;
|
||||
status: string;
|
||||
restarts: number;
|
||||
age: string;
|
||||
containers: string[];
|
||||
shellContainers: string[];
|
||||
}
|
||||
|
||||
export const NETCATTY_POPUP_HISTORY_MARKER: string;
|
||||
|
||||
export function normalizeContainerRuntime(runtime: unknown): ContainerRuntimeId;
|
||||
export function isContainerSource(value: unknown): value is ContainerSourceId;
|
||||
export function runtimeBinary(runtime: unknown): string;
|
||||
export function buildRuntimeCommand(runtime: unknown, args: string): string;
|
||||
export function parseRuntimeProbe(stdout: string): RuntimeProbeFlags;
|
||||
export function sourcesFromCapabilities(capabilities: {
|
||||
hasDocker?: boolean;
|
||||
hasPodman?: boolean;
|
||||
hasNerdctl?: boolean;
|
||||
hasKubectl?: boolean;
|
||||
docker?: boolean;
|
||||
podman?: boolean;
|
||||
nerdctl?: boolean;
|
||||
kubernetes?: boolean;
|
||||
} | null | undefined): ContainerSourceId[];
|
||||
export function pickContainerSource(
|
||||
detected: readonly ContainerSourceId[] | null | undefined,
|
||||
current: ContainerSourceId | null | undefined,
|
||||
remembered: ContainerSourceId | null | undefined,
|
||||
): ContainerSourceId | null;
|
||||
export function rememberedContainerSource(
|
||||
preference: { source?: string; engine?: string } | null | undefined,
|
||||
detected: readonly ContainerSourceId[] | null | undefined,
|
||||
): ContainerSourceId | null;
|
||||
export function reconcileContainerSource(
|
||||
detected: readonly ContainerSourceId[] | null | undefined,
|
||||
current: ContainerSourceId | null | undefined,
|
||||
preference: { source?: string; engine?: string } | null | undefined,
|
||||
): ContainerSourceId | null;
|
||||
export function isAutomaticRuntimeFallback(
|
||||
preference: { source?: string; engine?: string } | null | undefined,
|
||||
source: ContainerSourceId | null | undefined,
|
||||
detected: readonly ContainerSourceId[] | null | undefined,
|
||||
): boolean;
|
||||
export function firstContainerRuntime(
|
||||
detected: readonly ContainerSourceId[] | null | undefined,
|
||||
): ContainerRuntimeId | null;
|
||||
export function isRuntimeSocketPermissionError(
|
||||
runtime: unknown,
|
||||
result: { stderr?: string; stdout?: string; error?: string } | null | undefined,
|
||||
): boolean;
|
||||
export function sanitizeContainerId(id: string): string;
|
||||
export function buildContainerExecShellCommand(runtime: unknown, containerId: string, shell?: string): string;
|
||||
export function buildContainerLogsCommand(runtime: unknown, containerId: string, shell?: string): string;
|
||||
export function buildKubectlContextsCommand(): string;
|
||||
export function buildKubectlNamespacesCommand(context: string, shell?: string): string | null;
|
||||
export function buildKubectlPodsCommand(context: string, namespace: string, shell?: string): string | null;
|
||||
export function buildKubectlDeletePodCommand(context: string, namespace: string, pod: string, shell?: string): string | null;
|
||||
export type PopupShell = "posix" | "powershell" | "cmd";
|
||||
export function isKubectlForbiddenError(message: string | null | undefined): boolean;
|
||||
export function isKubectlUnreachableError(message: string | null | undefined): boolean;
|
||||
export function isNerdctlUnreachableError(message: string | null | undefined): boolean;
|
||||
export function podStatusMessageKey(status: string | null | undefined): string | null;
|
||||
export function runtimeFailureMessage(
|
||||
errorCode: string | null | undefined,
|
||||
context?: string | null,
|
||||
): { key: string; values?: Record<string, string> } | null;
|
||||
export function buildKubectlExecShellCommand(
|
||||
context: string,
|
||||
namespace: string,
|
||||
pod: string,
|
||||
container?: string,
|
||||
shell?: string,
|
||||
): string;
|
||||
export function buildKubectlLogsCommand(
|
||||
context: string,
|
||||
namespace: string,
|
||||
pod: string,
|
||||
container?: string,
|
||||
shell?: string,
|
||||
): string;
|
||||
export function buildKubectlDescribeCommand(
|
||||
context: string,
|
||||
namespace: string,
|
||||
pod: string,
|
||||
shell?: string,
|
||||
): string;
|
||||
export function parseKubeconfigContexts(jsonText: string): { contexts: KubeContextInfo[]; current: string };
|
||||
export function parseNamespaceList(jsonText: string): string[];
|
||||
export function parseKubectlPodList(jsonText: string, now?: number): KubectlPodInfo[];
|
||||
export function formatPodAge(iso: string, now?: number): string;
|
||||
@@ -0,0 +1,486 @@
|
||||
"use strict";
|
||||
|
||||
const test = require("node:test");
|
||||
const assert = require("node:assert/strict");
|
||||
const {
|
||||
buildContainerExecShellCommand,
|
||||
buildContainerLogsCommand,
|
||||
buildKubectlDeletePodCommand,
|
||||
buildKubectlDescribeCommand,
|
||||
buildKubectlExecShellCommand,
|
||||
buildKubectlLogsCommand,
|
||||
buildKubectlNamespacesCommand,
|
||||
buildKubectlPodsCommand,
|
||||
buildRuntimeCommand,
|
||||
firstContainerRuntime,
|
||||
formatPodAge,
|
||||
isRuntimeSocketPermissionError,
|
||||
parseKubeconfigContexts,
|
||||
parseKubectlPodList,
|
||||
parseNamespaceList,
|
||||
parseRuntimeProbe,
|
||||
isAutomaticRuntimeFallback,
|
||||
pickContainerSource,
|
||||
reconcileContainerSource,
|
||||
rememberedContainerSource,
|
||||
sourcesFromCapabilities,
|
||||
isKubectlForbiddenError,
|
||||
isKubectlUnreachableError,
|
||||
isNerdctlUnreachableError,
|
||||
podStatusMessageKey,
|
||||
runtimeFailureMessage,
|
||||
} = require("./containerRuntime.shared.cjs");
|
||||
|
||||
test("parseRuntimeProbe reads line-anchored command -v markers", () => {
|
||||
const probe = parseRuntimeProbe([
|
||||
"command -v docker >/dev/null && printf __NC_DOCKER__=1",
|
||||
"__NC_OS__=Linux",
|
||||
"__NC_DOCKER__=1",
|
||||
"__NC_PODMAN__=1",
|
||||
"echo __NC_NERDCTL__=1 trailing",
|
||||
"__NC_KUBECTL__=1",
|
||||
].join("\n"));
|
||||
|
||||
assert.deepEqual(probe, {
|
||||
docker: true,
|
||||
podman: true,
|
||||
nerdctl: false,
|
||||
kubernetes: true,
|
||||
});
|
||||
assert.deepEqual(sourcesFromCapabilities(probe), ["docker", "podman", "kubernetes"]);
|
||||
});
|
||||
|
||||
test("sourcesFromCapabilities keeps a stable runtime order and ignores a docker-only host's missing peers", () => {
|
||||
assert.deepEqual(sourcesFromCapabilities({ hasDocker: true }), ["docker"]);
|
||||
assert.deepEqual(
|
||||
sourcesFromCapabilities({ hasNerdctl: true, hasKubectl: true, hasPodman: true }),
|
||||
["podman", "nerdctl", "kubernetes"],
|
||||
);
|
||||
assert.deepEqual(sourcesFromCapabilities(undefined), []);
|
||||
});
|
||||
|
||||
test("pickContainerSource remembers the last choice per host and falls back when it disappears", () => {
|
||||
assert.equal(
|
||||
pickContainerSource(["docker", "podman", "kubernetes"], null, "podman"),
|
||||
"podman",
|
||||
);
|
||||
assert.equal(
|
||||
pickContainerSource(["docker", "podman"], "kubernetes", "podman"),
|
||||
"podman",
|
||||
);
|
||||
assert.equal(
|
||||
pickContainerSource(["docker", "podman", "kubernetes"], "kubernetes", "docker"),
|
||||
"kubernetes",
|
||||
);
|
||||
assert.equal(pickContainerSource(["nerdctl"], null, "docker"), "nerdctl");
|
||||
assert.equal(
|
||||
rememberedContainerSource({ source: "kubernetes", engine: "podman" }, ["docker", "podman"]),
|
||||
"podman",
|
||||
);
|
||||
assert.equal(
|
||||
rememberedContainerSource({ source: "kubernetes", engine: "podman" }, ["docker", "podman", "kubernetes"]),
|
||||
"kubernetes",
|
||||
);
|
||||
assert.equal(
|
||||
pickContainerSource(
|
||||
["docker", "podman"],
|
||||
"kubernetes",
|
||||
rememberedContainerSource({ source: "kubernetes", engine: "podman" }, ["docker", "podman"]),
|
||||
),
|
||||
"podman",
|
||||
);
|
||||
assert.equal(firstContainerRuntime(["kubernetes", "podman"]), "podman");
|
||||
assert.equal(firstContainerRuntime(["kubernetes"]), null);
|
||||
});
|
||||
|
||||
test("reconcileContainerSource restores a runtime after a probe gap and does not treat the fallback as the choice", () => {
|
||||
const nerdctl = { source: "nerdctl", engine: "nerdctl" };
|
||||
assert.equal(reconcileContainerSource(["docker", "podman"], "nerdctl", nerdctl), "docker");
|
||||
assert.equal(isAutomaticRuntimeFallback(nerdctl, "docker", ["docker", "podman"]), true);
|
||||
assert.equal(
|
||||
reconcileContainerSource(["docker", "podman", "nerdctl"], "docker", nerdctl),
|
||||
"nerdctl",
|
||||
);
|
||||
assert.equal(isAutomaticRuntimeFallback(nerdctl, "podman", ["docker", "podman"]), false);
|
||||
|
||||
const kubernetes = { source: "kubernetes", engine: "podman" };
|
||||
assert.equal(reconcileContainerSource(["docker", "podman"], "kubernetes", kubernetes), "podman");
|
||||
assert.equal(isAutomaticRuntimeFallback(kubernetes, "podman", ["docker", "podman"]), true);
|
||||
assert.equal(
|
||||
reconcileContainerSource(["docker", "podman", "kubernetes"], "podman", kubernetes),
|
||||
"kubernetes",
|
||||
);
|
||||
});
|
||||
|
||||
test("buildRuntimeCommand uses the detected CLI and defaults unknown runtimes to docker", () => {
|
||||
assert.equal(buildRuntimeCommand("docker", "ps -a --format '{{json .}}'"), "docker ps -a --format '{{json .}}'");
|
||||
assert.equal(buildRuntimeCommand("podman", "start abc123"), "podman start abc123");
|
||||
assert.equal(buildRuntimeCommand("nerdctl", "logs -f --tail 200 abc123"), "nerdctl logs -f --tail 200 abc123");
|
||||
assert.equal(buildRuntimeCommand("kubernetes", "ps"), "docker ps");
|
||||
assert.equal(buildRuntimeCommand(undefined, "inspect abc"), "docker inspect abc");
|
||||
});
|
||||
|
||||
test("interactive container commands keep the docker sudo probe and swap the binary", () => {
|
||||
const dockerShell = buildContainerExecShellCommand("docker", "587abcdef123");
|
||||
assert.match(dockerShell, /^sh -c /);
|
||||
assert.match(dockerShell, /docker inspect 587abcdef123/);
|
||||
assert.match(dockerShell, /exec docker exec -it 587abcdef123/);
|
||||
assert.match(dockerShell, /exec sudo docker exec -it 587abcdef123/);
|
||||
assert.match(dockerShell, /permission\\ denied.*docker\.sock.*docker\.sock.*permission\\ denied/);
|
||||
assert.equal(dockerShell.includes("\n"), false);
|
||||
assert.doesNotMatch(dockerShell, /sudo -S/);
|
||||
|
||||
const podmanLogs = buildContainerLogsCommand("podman", "587abcdef123");
|
||||
assert.match(podmanLogs, /podman inspect 587abcdef123/);
|
||||
assert.match(podmanLogs, /exec podman logs -f --tail 200 587abcdef123/);
|
||||
assert.match(podmanLogs, /exec sudo podman logs -f --tail 200 587abcdef123/);
|
||||
assert.match(podmanLogs, /podman\.sock/);
|
||||
assert.doesNotMatch(podmanLogs, /docker/);
|
||||
|
||||
const nerdctlShell = buildContainerExecShellCommand("nerdctl", "abc");
|
||||
assert.match(nerdctlShell, /exec nerdctl exec -it abc/);
|
||||
assert.match(nerdctlShell, /exec sudo nerdctl exec -it abc/);
|
||||
assert.match(nerdctlShell, /containerd\.sock/);
|
||||
});
|
||||
|
||||
test("socket permission fallback is specific to each runtime", () => {
|
||||
assert.equal(
|
||||
isRuntimeSocketPermissionError("docker", { stderr: "dial unix /var/run/docker.sock: connect: permission denied" }),
|
||||
true,
|
||||
);
|
||||
assert.equal(
|
||||
isRuntimeSocketPermissionError("docker", { stderr: "permission denied" }),
|
||||
false,
|
||||
);
|
||||
assert.equal(
|
||||
isRuntimeSocketPermissionError("podman", { stderr: "dial unix /run/podman/podman.sock: connect: permission denied" }),
|
||||
true,
|
||||
);
|
||||
assert.equal(
|
||||
isRuntimeSocketPermissionError("nerdctl", { stderr: "dial unix /run/containerd/containerd.sock: connect: permission denied" }),
|
||||
true,
|
||||
);
|
||||
assert.equal(
|
||||
isRuntimeSocketPermissionError("podman", { stderr: "dial unix /var/run/docker.sock: connect: permission denied" }),
|
||||
false,
|
||||
);
|
||||
});
|
||||
|
||||
test("parseKubeconfigContexts returns names and namespaces without user material", () => {
|
||||
const parsed = parseKubeconfigContexts(JSON.stringify({
|
||||
"current-context": "kind-kind",
|
||||
contexts: [
|
||||
{
|
||||
name: "kind-kind",
|
||||
context: { cluster: "kind-kind", user: "kind-kind", namespace: "app" },
|
||||
},
|
||||
{
|
||||
name: "arn:aws:eks:us-west-2:123:cluster/prod",
|
||||
context: { cluster: "prod", user: "admin" },
|
||||
},
|
||||
],
|
||||
users: [{ name: "admin", user: { token: "secret-token" } }],
|
||||
}));
|
||||
|
||||
assert.equal(parsed.current, "kind-kind");
|
||||
assert.deepEqual(parsed.contexts, [
|
||||
{ name: "kind-kind", namespace: "app", current: true },
|
||||
{ name: "arn:aws:eks:us-west-2:123:cluster/prod", namespace: "default", current: false },
|
||||
]);
|
||||
assert.equal(JSON.stringify(parsed).includes("secret-token"), false);
|
||||
});
|
||||
|
||||
test("parseNamespaceList skips a bracketed banner before the JSON object", () => {
|
||||
const names = parseNamespaceList([
|
||||
"[WARN] connection is using an insecure certificate",
|
||||
'{"items":[{"metadata":{"name":"default"}}]}',
|
||||
].join("\n"));
|
||||
assert.deepEqual(names, ["default"]);
|
||||
});
|
||||
|
||||
test("parseNamespaceList reads metadata names and ignores kubectl warnings before the JSON", () => {
|
||||
const stdout = "Warning: old client\n" + JSON.stringify({
|
||||
items: [
|
||||
{ metadata: { name: "default" } },
|
||||
{ metadata: { name: "kube-system" } },
|
||||
{ metadata: {} },
|
||||
],
|
||||
});
|
||||
assert.deepEqual(parseNamespaceList(stdout), ["default", "kube-system"]);
|
||||
});
|
||||
|
||||
test("parseKubectlPodList reports status, restarts, age, and containers", () => {
|
||||
const now = Date.parse("2026-10-20T00:10:00Z");
|
||||
const pods = parseKubectlPodList(JSON.stringify({
|
||||
items: [
|
||||
{
|
||||
metadata: {
|
||||
name: "api",
|
||||
namespace: "default",
|
||||
creationTimestamp: "2026-10-20T00:08:30Z",
|
||||
},
|
||||
spec: {
|
||||
containers: [{ name: "app" }, { name: "sidecar" }],
|
||||
initContainers: [{ name: "init" }],
|
||||
},
|
||||
status: {
|
||||
phase: "Running",
|
||||
initContainerStatuses: [
|
||||
{ name: "init", restartCount: 0, state: { terminated: { reason: "Completed" } } },
|
||||
],
|
||||
containerStatuses: [
|
||||
{ name: "app", restartCount: 4, state: { waiting: { reason: "CrashLoopBackOff" } } },
|
||||
{ name: "sidecar", restartCount: 1, state: { running: {} } },
|
||||
],
|
||||
},
|
||||
},
|
||||
{
|
||||
metadata: {
|
||||
name: "batch",
|
||||
namespace: "jobs",
|
||||
creationTimestamp: "2026-10-18T00:10:00Z",
|
||||
},
|
||||
spec: { containers: [{ name: "worker" }] },
|
||||
status: {
|
||||
phase: "Succeeded",
|
||||
containerStatuses: [
|
||||
{ name: "worker", restartCount: 0, state: { terminated: { reason: "Completed" } } },
|
||||
],
|
||||
},
|
||||
},
|
||||
],
|
||||
}), now);
|
||||
|
||||
assert.deepEqual(pods, [
|
||||
{
|
||||
name: "api",
|
||||
namespace: "default",
|
||||
status: "CrashLoopBackOff",
|
||||
restarts: 5,
|
||||
age: "1m",
|
||||
containers: ["app", "sidecar", "init"],
|
||||
shellContainers: ["sidecar"],
|
||||
},
|
||||
{
|
||||
name: "batch",
|
||||
namespace: "jobs",
|
||||
status: "Completed",
|
||||
restarts: 0,
|
||||
age: "2d",
|
||||
containers: ["worker"],
|
||||
shellContainers: [],
|
||||
},
|
||||
]);
|
||||
const terminating = parseKubectlPodList(JSON.stringify({
|
||||
items: [{
|
||||
metadata: {
|
||||
name: "api",
|
||||
namespace: "default",
|
||||
creationTimestamp: "2026-10-20T00:08:30Z",
|
||||
deletionTimestamp: "2026-10-20T00:10:00Z",
|
||||
},
|
||||
spec: {
|
||||
containers: [{ name: "app" }],
|
||||
ephemeralContainers: [{ name: "debugger" }, { name: "old-debug" }],
|
||||
},
|
||||
status: {
|
||||
phase: "Running",
|
||||
containerStatuses: [
|
||||
{ name: "app", restartCount: 0, state: { running: {} } },
|
||||
],
|
||||
ephemeralContainerStatuses: [
|
||||
{ name: "debugger", restartCount: 0, state: { running: {} } },
|
||||
{ name: "old-debug", restartCount: 0, state: { terminated: { reason: "Completed" } } },
|
||||
],
|
||||
},
|
||||
}],
|
||||
}), now);
|
||||
const evicted = parseKubectlPodList(JSON.stringify({
|
||||
items: [{
|
||||
metadata: { name: "api", namespace: "default", creationTimestamp: "2026-10-20T00:08:30Z" },
|
||||
spec: { containers: [{ name: "app" }] },
|
||||
status: {
|
||||
phase: "Failed",
|
||||
reason: "Evicted",
|
||||
message: "The node was low on resource: memory.",
|
||||
containerStatuses: [
|
||||
{ name: "app", restartCount: 0, state: { terminated: { reason: "Error", exitCode: 137 } } },
|
||||
],
|
||||
},
|
||||
}],
|
||||
}), now);
|
||||
assert.equal(evicted[0].status, "Evicted");
|
||||
|
||||
assert.equal(terminating[0].status, "Terminating");
|
||||
assert.deepEqual(terminating[0].containers, ["app", "debugger", "old-debug"]);
|
||||
assert.deepEqual(terminating[0].shellContainers, ["app", "debugger"]);
|
||||
|
||||
assert.equal(formatPodAge("2026-10-20T00:09:10Z", now), "50s");
|
||||
assert.equal(formatPodAge("2026-10-20T00:07:00Z", now), "3m");
|
||||
assert.equal(formatPodAge("2026-10-19T22:10:00Z", now), "2h");
|
||||
});
|
||||
|
||||
test("kubectl commands quote remote arguments and reject control characters", () => {
|
||||
assert.equal(
|
||||
buildKubectlPodsCommand("kind-kind", "default"),
|
||||
"kubectl --context 'kind-kind' -n 'default' get pods -o json",
|
||||
);
|
||||
assert.equal(
|
||||
buildKubectlNamespacesCommand("arn:aws:eks:us-west-2:123:cluster/prod"),
|
||||
"kubectl --context 'arn:aws:eks:us-west-2:123:cluster/prod' get namespaces -o json",
|
||||
);
|
||||
assert.equal(
|
||||
buildKubectlDeletePodCommand("ctx", "kube-system", "coredns-a"),
|
||||
"kubectl --context 'ctx' -n 'kube-system' delete pod 'coredns-a' --wait=false",
|
||||
);
|
||||
assert.equal(buildKubectlDeletePodCommand("ctx", "default", "bad\nname"), null);
|
||||
assert.equal(buildKubectlPodsCommand("ok", "bad\nns"), null);
|
||||
assert.equal(
|
||||
buildKubectlNamespacesCommand("o'brien", "powershell"),
|
||||
"kubectl --context 'o''brien' get namespaces -o json",
|
||||
);
|
||||
assert.equal(
|
||||
buildKubectlPodsCommand("o'brien", "default", "powershell"),
|
||||
"kubectl --context 'o''brien' -n 'default' get pods -o json",
|
||||
);
|
||||
assert.match(
|
||||
buildKubectlDeletePodCommand("o'brien", "default", "web", "cmd"),
|
||||
/--context "o'brien"/,
|
||||
);
|
||||
|
||||
const quoted = buildKubectlDeletePodCommand("o'brien", "default", "web");
|
||||
assert.match(quoted, /--context 'o'"'"'brien'/);
|
||||
assert.doesNotMatch(quoted, /--context 'o'brien'/);
|
||||
|
||||
const shell = buildKubectlExecShellCommand("kind-kind", "default", "api", "sidecar");
|
||||
assert.match(shell, /^sh -c /);
|
||||
assert.match(shell, /kubectl/);
|
||||
assert.match(shell, /--context/);
|
||||
assert.match(shell, /kind-kind/);
|
||||
assert.match(shell, /-n/);
|
||||
assert.match(shell, /default/);
|
||||
assert.match(shell, /exec -it/);
|
||||
assert.match(shell, /api/);
|
||||
assert.match(shell, /-c/);
|
||||
assert.match(shell, /sidecar/);
|
||||
assert.match(shell, /command -v bash/);
|
||||
assert.equal(shell.includes("\n"), false);
|
||||
|
||||
const logs = buildKubectlLogsCommand("kind-kind", "default", "api", "app");
|
||||
assert.match(logs, /kubectl/);
|
||||
assert.match(logs, /logs -f --tail 200/);
|
||||
assert.match(logs, /kind-kind/);
|
||||
assert.match(logs, /app/);
|
||||
|
||||
const described = buildKubectlDescribeCommand("kind-kind", "default", "api");
|
||||
assert.match(described, /kubectl/);
|
||||
assert.match(described, /describe pod/);
|
||||
assert.match(described, /kind-kind/);
|
||||
assert.match(described, /api/);
|
||||
assert.equal(buildKubectlExecShellCommand("kind-kind", "default", "bad\npod"), 'echo "Invalid pod name"');
|
||||
});
|
||||
|
||||
test("kubectl popup commands skip the POSIX wrapper on Windows shells", () => {
|
||||
for (const shell of ["powershell", "cmd"]) {
|
||||
const exec = buildKubectlExecShellCommand("kind-kind", "default", "api", "sidecar", shell);
|
||||
const logs = buildKubectlLogsCommand("kind-kind", "default", "api", "app", shell);
|
||||
const described = buildKubectlDescribeCommand("kind-kind", "default", "api", shell);
|
||||
assert.match(exec, /kubectl --context/, shell);
|
||||
assert.match(logs, /kubectl --context/, shell);
|
||||
assert.match(described, /kubectl --context/, shell);
|
||||
assert.match(exec, /__NC_POPUP__/, shell);
|
||||
assert.equal(exec.startsWith("sh -c "), false, shell);
|
||||
assert.match(exec, /exec -it/);
|
||||
assert.match(exec, /sidecar/);
|
||||
assert.match(logs, /logs -f --tail 200/);
|
||||
assert.match(described, /describe pod/);
|
||||
}
|
||||
assert.match(
|
||||
buildKubectlExecShellCommand("kind-kind", "default", "api", "sidecar", "powershell"),
|
||||
/--context 'kind-kind'/,
|
||||
);
|
||||
assert.match(
|
||||
buildKubectlDescribeCommand("kind-kind", "default", "api", "cmd"),
|
||||
/--context "kind-kind"/,
|
||||
);
|
||||
assert.equal(
|
||||
buildKubectlDescribeCommand("prod-%USERNAME%", "default", "api", "cmd"),
|
||||
'echo "Invalid context"',
|
||||
);
|
||||
assert.equal(
|
||||
buildKubectlDescribeCommand("prod!USER!", "default", "api", "cmd"),
|
||||
'echo "Invalid context"',
|
||||
);
|
||||
assert.match(
|
||||
buildKubectlDescribeCommand("prod-%USERNAME%", "default", "api", "powershell"),
|
||||
/--context 'prod-%USERNAME%'/,
|
||||
);
|
||||
assert.equal(
|
||||
buildKubectlNamespacesCommand("prod-%USERNAME%", "powershell"),
|
||||
"kubectl --context 'prod-%USERNAME%' get namespaces -o json",
|
||||
);
|
||||
});
|
||||
|
||||
test("container popup commands inspect in the host shell on Windows", () => {
|
||||
const powershellLogs = buildContainerLogsCommand("podman", "abc123", "powershell");
|
||||
assert.equal(powershellLogs.startsWith("sh -c "), false);
|
||||
assert.match(powershellLogs, /\$__nc_popup='__NC_POPUP__'; podman inspect abc123 >\$null;/);
|
||||
assert.match(powershellLogs, /podman logs -f --tail 200 abc123/);
|
||||
|
||||
const cmdShell = buildContainerExecShellCommand("nerdctl", "abc123", "cmd");
|
||||
assert.equal(cmdShell.startsWith("sh -c "), false);
|
||||
assert.match(cmdShell, /^set "__NC_POPUP__=1"& nerdctl inspect abc123 >nul && nerdctl exec -it abc123/);
|
||||
assert.match(cmdShell, /sh -c "command -v bash/);
|
||||
});
|
||||
|
||||
test("isKubectlForbiddenError matches namespace list denial", () => {
|
||||
assert.equal(
|
||||
isKubectlForbiddenError('Error from server (Forbidden): namespaces is forbidden: User "view" cannot list resource "namespaces"'),
|
||||
true,
|
||||
);
|
||||
assert.equal(isKubectlForbiddenError("Unable to connect to the server"), false);
|
||||
});
|
||||
|
||||
test("unreachable kubectl API text is classified without treating Forbidden as unreachable", () => {
|
||||
const spam = [
|
||||
'memcache.go:381] "Couldn\'t get current server API group list"',
|
||||
'dial tcp 127.0.0.1:6443: connect: connection refused',
|
||||
].join("\n");
|
||||
assert.equal(isKubectlUnreachableError(spam), true);
|
||||
assert.equal(isKubectlUnreachableError("Unable to connect to the server: dial tcp 10.0.0.1:6443: i/o timeout"), true);
|
||||
assert.equal(
|
||||
isKubectlUnreachableError('Error from server (Forbidden): pods is forbidden'),
|
||||
false,
|
||||
);
|
||||
assert.equal(isKubectlUnreachableError(""), false);
|
||||
});
|
||||
|
||||
test("nerdctl containerd failures are classified separately from socket permission errors", () => {
|
||||
assert.equal(
|
||||
isNerdctlUnreachableError('time="2026-10-20T18:31:27+08:00" level=fatal msg="rootless containerd not running? (hint: use containerd-rootless-setuptool.sh install to start rootless containerd): environment variable XDG_RUNTIME_DIR is not set"'),
|
||||
true,
|
||||
);
|
||||
assert.equal(
|
||||
isNerdctlUnreachableError("permission denied while trying to connect to the containerd socket"),
|
||||
false,
|
||||
);
|
||||
assert.equal(isNerdctlUnreachableError("Error: no such container: abc"), false);
|
||||
});
|
||||
|
||||
test("runtime failures map to localized message keys and pod badges use known status keys", () => {
|
||||
assert.deepEqual(runtimeFailureMessage("nerdctlUnreachable"), {
|
||||
key: "systemManager.errors.nerdctlUnreachable",
|
||||
});
|
||||
assert.deepEqual(runtimeFailureMessage("clusterUnreachable", "broken-cluster"), {
|
||||
key: "systemManager.errors.clusterUnreachable",
|
||||
values: { context: "broken-cluster" },
|
||||
});
|
||||
assert.deepEqual(runtimeFailureMessage("clusterUnreachable", " "), {
|
||||
key: "systemManager.errors.clusterUnreachableGeneric",
|
||||
});
|
||||
assert.equal(runtimeFailureMessage("other"), null);
|
||||
assert.equal(podStatusMessageKey("Running"), "systemManager.k8s.status.Running");
|
||||
assert.equal(podStatusMessageKey("CrashLoopBackOff"), "systemManager.k8s.status.CrashLoopBackOff");
|
||||
assert.equal(podStatusMessageKey("Evicted"), "systemManager.k8s.status.Evicted");
|
||||
assert.equal(podStatusMessageKey("Init:0/1"), null);
|
||||
});
|
||||
@@ -0,0 +1,38 @@
|
||||
import { runtimeFailureMessage } from "./containerRuntime.shared.cjs";
|
||||
|
||||
export type {
|
||||
ContainerRuntimeId,
|
||||
ContainerSourceId,
|
||||
KubeContextInfo,
|
||||
KubectlPodInfo,
|
||||
RuntimeProbeFlags,
|
||||
} from "./containerRuntime.shared.cjs";
|
||||
|
||||
export {
|
||||
NETCATTY_POPUP_HISTORY_MARKER,
|
||||
buildContainerExecShellCommand,
|
||||
buildContainerLogsCommand,
|
||||
buildKubectlDescribeCommand,
|
||||
buildKubectlExecShellCommand,
|
||||
buildKubectlLogsCommand,
|
||||
firstContainerRuntime,
|
||||
isContainerSource,
|
||||
isKubectlForbiddenError,
|
||||
isAutomaticRuntimeFallback,
|
||||
pickContainerSource,
|
||||
podStatusMessageKey,
|
||||
reconcileContainerSource,
|
||||
rememberedContainerSource,
|
||||
sourcesFromCapabilities,
|
||||
} from "./containerRuntime.shared.cjs";
|
||||
|
||||
export function describeRuntimeFailure(
|
||||
result: { error?: string; errorCode?: string } | null | undefined,
|
||||
translate: (key: string, values?: Record<string, string>) => string,
|
||||
fallback: string,
|
||||
context?: string | null,
|
||||
): string {
|
||||
const mapped = runtimeFailureMessage(result?.errorCode, context);
|
||||
if (mapped) return translate(mapped.key, mapped.values);
|
||||
return result?.error || fallback;
|
||||
}
|
||||
@@ -1,52 +1,18 @@
|
||||
import {
|
||||
buildContainerExecShellCommand,
|
||||
buildContainerLogsCommand,
|
||||
} from "./containerRuntime.ts";
|
||||
|
||||
/** Sanitize Docker container/image IDs — must match electron/bridges/systemManager/dockerOps.cjs */
|
||||
export function sanitizeDockerContainerId(id: string): string {
|
||||
return String(id || '').replace(/[^a-zA-Z0-9]/g, '').slice(0, 64);
|
||||
}
|
||||
|
||||
const CLEAR_STARTUP_OUTPUT = "printf '\\033[H\\033[2J\\033[3J';";
|
||||
|
||||
function shQuote(value: string): string {
|
||||
return `'${String(value).replace(/'/g, `'"'"'`)}'`;
|
||||
}
|
||||
|
||||
function buildDockerCommandWithSudoFallback(containerId: string, dockerArgs: string): string {
|
||||
const plainCommand = `docker ${dockerArgs}`;
|
||||
const sudoCommand = `sudo ${plainCommand}`;
|
||||
const script = [
|
||||
CLEAR_STARTUP_OUTPUT,
|
||||
`_nc_docker_err=$(docker inspect ${containerId} 2>&1 >/dev/null);`,
|
||||
'_nc_docker_status=$?;',
|
||||
`if [ "$_nc_docker_status" -eq 0 ]; then exec ${plainCommand}; fi;`,
|
||||
'_nc_docker_lc=$(printf \'%s\' "$_nc_docker_err" | tr \'[:upper:]\' \'[:lower:]\');',
|
||||
'case "$_nc_docker_lc" in',
|
||||
[
|
||||
'*permission\\ denied*docker\\ daemon*',
|
||||
'*docker\\ daemon*permission\\ denied*',
|
||||
'*permission\\ denied*docker.sock*',
|
||||
'*docker.sock*permission\\ denied*',
|
||||
'*permission\\ denied*/var/run/docker.sock*',
|
||||
'*/var/run/docker.sock*permission\\ denied*',
|
||||
'*permission\\ denied*connect\\ to\\ the\\ docker\\ daemon*',
|
||||
'*connect\\ to\\ the\\ docker\\ daemon*permission\\ denied*',
|
||||
].join('|') + `) exec ${sudoCommand} ;;`,
|
||||
'*) printf \'%s\\n\' "$_nc_docker_err" >&2; exit "$_nc_docker_status" ;;',
|
||||
'esac',
|
||||
].join(' ');
|
||||
return `sh -c ${shQuote(script)}`;
|
||||
return String(id || "").replace(/[^a-zA-Z0-9]/g, "").slice(0, 64);
|
||||
}
|
||||
|
||||
/** Interactive shell into a container — prefer bash, fall back to sh. */
|
||||
export function buildDockerExecShellCommand(containerId: string): string {
|
||||
const safeId = sanitizeDockerContainerId(containerId);
|
||||
if (!safeId) return 'echo "Invalid container id"';
|
||||
return buildDockerCommandWithSudoFallback(
|
||||
safeId,
|
||||
`exec -it ${safeId} sh -c 'command -v bash >/dev/null 2>&1 && exec bash || exec sh'`,
|
||||
);
|
||||
return buildContainerExecShellCommand("docker", containerId);
|
||||
}
|
||||
|
||||
export function buildDockerLogsCommand(containerId: string): string {
|
||||
const safeId = sanitizeDockerContainerId(containerId);
|
||||
if (!safeId) return 'echo "Invalid container id"';
|
||||
return buildDockerCommandWithSudoFallback(safeId, `logs -f --tail 200 ${safeId}`);
|
||||
return buildContainerLogsCommand("docker", containerId);
|
||||
}
|
||||
@@ -4,6 +4,7 @@ import test from "node:test";
|
||||
import {
|
||||
allowSystemManagerMutations,
|
||||
buildSystemManagerTabs,
|
||||
hasContainerSource,
|
||||
shouldCollectServerStats,
|
||||
shouldShowGpuTab,
|
||||
shouldShowPortsTab,
|
||||
@@ -57,6 +58,23 @@ test("gpu tab appears only after nvidia-smi or npu-smi is detected", () => {
|
||||
);
|
||||
});
|
||||
|
||||
test("the single container tab appears for podman, nerdctl, or kubectl without a docker binary", () => {
|
||||
const networkHost = {
|
||||
id: "host-podman",
|
||||
label: "Podman",
|
||||
hostname: "podman.local",
|
||||
username: "root",
|
||||
tags: [],
|
||||
os: "linux" as const,
|
||||
deviceType: "network" as const,
|
||||
};
|
||||
assert.equal(hasContainerSource(caps()), false);
|
||||
assert.equal(hasContainerSource(caps({ hasPodman: true })), true);
|
||||
assert.equal(buildSystemManagerTabs(networkHost, caps({ hasPodman: true }), null).includes("docker"), true);
|
||||
assert.equal(buildSystemManagerTabs(networkHost, caps({ hasKubectl: true }), null).includes("docker"), true);
|
||||
assert.equal(buildSystemManagerTabs(networkHost, caps(), null).includes("docker"), false);
|
||||
});
|
||||
|
||||
test("ports and services tabs are detect-first like GPU", () => {
|
||||
assert.equal(shouldShowPortsTab(undefined), false);
|
||||
assert.equal(shouldShowPortsTab(caps({ hasSs: true })), true);
|
||||
|
||||
@@ -44,12 +44,19 @@ export function shouldShowTmuxTab(
|
||||
return false;
|
||||
}
|
||||
|
||||
export function hasContainerSource(capabilities: SessionCapabilities | undefined): boolean {
|
||||
return capabilities?.hasDocker === true
|
||||
|| capabilities?.hasPodman === true
|
||||
|| capabilities?.hasNerdctl === true
|
||||
|| capabilities?.hasKubectl === true;
|
||||
}
|
||||
|
||||
export function shouldShowDockerTab(
|
||||
host: Host | null | undefined,
|
||||
capabilities: SessionCapabilities | undefined,
|
||||
session: TerminalSession | null | undefined,
|
||||
): boolean {
|
||||
if (capabilities?.hasDocker === true) return true;
|
||||
if (hasContainerSource(capabilities)) return true;
|
||||
// Network appliances: never show Docker from an OS guess alone.
|
||||
if (isNetworkDeviceTarget(host)) return false;
|
||||
return isDefiniteLinuxTarget(host, capabilities, session);
|
||||
|
||||
@@ -4,6 +4,12 @@ export interface SessionCapabilities {
|
||||
targetOs: TargetOs;
|
||||
hasTmux: boolean;
|
||||
hasDocker: boolean;
|
||||
/** `podman` binary present. A podman-docker shim may also set hasDocker. */
|
||||
hasPodman?: boolean;
|
||||
/** `nerdctl` binary present. */
|
||||
hasNerdctl?: boolean;
|
||||
/** `kubectl` binary present on the remote host. */
|
||||
hasKubectl?: boolean;
|
||||
hasNvidiaSmi: boolean;
|
||||
hasNpuSmi: boolean;
|
||||
/** `ss` binary present (preferred listening-port collector). */
|
||||
@@ -194,7 +200,7 @@ export type DockerContainerAction =
|
||||
|
||||
export type DockerImageManageAction =
|
||||
| { action: 'pull'; imageRef: string }
|
||||
| { action: 'rm'; imageId: string; force?: boolean }
|
||||
| { action: 'rm'; imageId: string; force?: boolean; repository?: string; tag?: string }
|
||||
| { action: 'prune'; all?: boolean }
|
||||
| { action: 'tag'; imageId: string; repository: string; tag?: string };
|
||||
|
||||
@@ -221,6 +227,11 @@ export interface TerminalPopupPayload {
|
||||
parentSessionId: string;
|
||||
sourceSession: import('../../types').TerminalSession;
|
||||
startupCommand: string;
|
||||
/**
|
||||
* System-manager panel rectangle in renderer viewport pixels. The popup is
|
||||
* placed in the remaining source-window area so it does not cover this panel.
|
||||
*/
|
||||
avoidClientRect?: { x: number; y: number; width: number; height: number };
|
||||
localShellType?: import('../../types').TerminalSession['shellType'];
|
||||
/**
|
||||
* When set, the popup attaches to this already-running backend session
|
||||
|
||||
@@ -70,6 +70,7 @@ module.exports = {
|
||||
'domain/terminalPromptSecurity.shared.cjs',
|
||||
// Main-process SSH, SFTP, and port-forward bridges share this matcher.
|
||||
// TypeScript under domain/ is excluded below, so the .cjs must be listed.
|
||||
'domain/systemManager/containerRuntime.shared.cjs',
|
||||
'domain/singleChannelSshBanner.shared.cjs',
|
||||
'domain/singleChannelSshBanner.shared.mjs',
|
||||
'domain/sshServerVendor.mjs',
|
||||
|
||||
@@ -17,6 +17,7 @@ const { appendVaultAgentGuidance, VAULT_NOTES_CLI_GUIDANCE } = require("../share
|
||||
|
||||
const mcpServerBridge = require("./mcpServerBridge.cjs");
|
||||
const { createExternalMcpController } = require("./externalMcpController.cjs");
|
||||
const { registerPrivacyHandlers } = require("./aiBridge/privacyHandlers.cjs");
|
||||
const { getCliLauncherPath, TOOL_CLI_DISCOVERY_ENV_VAR } = require("../cli/discoveryPath.cjs");
|
||||
const { injectCliTransportEnv } = require("../cli/cliChatSession.cjs");
|
||||
const { getExternalMcpDiscoveryFilePath } = require("../cli/externalMcpDiscoveryPath.cjs");
|
||||
@@ -997,6 +998,7 @@ function registerHandlers(ipcMain) {
|
||||
registerAgentDiscoveryHandlers(context);
|
||||
registerAgentProcessHandlers(context);
|
||||
registerSdkStreamHandlers(context);
|
||||
registerPrivacyHandlers(context);
|
||||
|
||||
if (externalMcpController) {
|
||||
externalMcpController.registerHandlers(ipcMain, validateSenderOrSettings);
|
||||
|
||||
@@ -0,0 +1,28 @@
|
||||
"use strict";
|
||||
|
||||
const { test } = require("node:test");
|
||||
const assert = require("node:assert/strict");
|
||||
const { registerPrivacyHandlers } = require("./aiBridge/privacyHandlers.cjs");
|
||||
const { createAiPrivacyService } = require("../shared/aiPrivacyService.cjs");
|
||||
|
||||
test("AI privacy IPC validates sender and shares semantic transforms", async () => {
|
||||
const handlers = new Map();
|
||||
const service = createAiPrivacyService();
|
||||
service.configure({ enabled: true });
|
||||
registerPrivacyHandlers({
|
||||
ipcMain: { handle: (name, handler) => handlers.set(name, handler) },
|
||||
validateSender: (event) => event.trusted === true,
|
||||
aiPrivacyService: service,
|
||||
});
|
||||
const transform = handlers.get("netcatty:ai:privacy:transform");
|
||||
assert.equal(transform({}, {}).ok, false);
|
||||
const request = { chatSessionId: "catty-chat", operation: "redact", value: ["password=synthetic-secret", "192.0.2.1"] };
|
||||
const masked = transform({ trusted: true }, request);
|
||||
assert.equal(masked.ok, true);
|
||||
assert.doesNotMatch(JSON.stringify(masked.value), /synthetic-secret|192\.0\.2\.1/);
|
||||
assert.deepEqual(transform({ trusted: true }, { ...request, operation: "restore", value: masked.value }).value, request.value);
|
||||
handlers.get("netcatty:ai:privacy:clear")({ trusted: true }, request.chatSessionId);
|
||||
assert.equal(transform({ trusted: true }, { ...request, operation: "restore", value: masked.value }).ok, false);
|
||||
service.invalidateConfiguration();
|
||||
assert.equal(handlers.get("netcatty:ai:privacy:status")({ trusted: true }).ok, false);
|
||||
});
|
||||
@@ -6,6 +6,16 @@ const { emitTerminalSessionData } = require("../emitTerminalSessionData.cjs");
|
||||
const { formatSyntheticEcho } = require("../ai/shellUtils.cjs");
|
||||
const { clearSessionFlowState } = require("../terminalFlowAck.cjs");
|
||||
const { remoteDisallowsExecChannelProbe, sessionDisallowsExtraSshChannel, ensureSessionShellKindForExec } = require("../ai/sessionShellKind.cjs");
|
||||
const defaultAiPrivacyService = require("../../shared/aiPrivacyService.cjs");
|
||||
|
||||
function assertCattyPrivacySession(privacyService, chatSessionId) {
|
||||
if (chatSessionId) {
|
||||
// Unlike checkSession/redact, restore cannot create an unpinned session.
|
||||
privacyService.restore(chatSessionId, []);
|
||||
} else if (privacyService.isEnabled()) {
|
||||
throw new Error("AI privacy requires a managed chat session.");
|
||||
}
|
||||
}
|
||||
|
||||
function getWorkerExecutionMeta(mcpServerBridge, sessionId, chatSessionId) {
|
||||
return mcpServerBridge.getSessionMeta?.(sessionId, chatSessionId) || {};
|
||||
@@ -24,6 +34,7 @@ async function proxyCattyExecToWorker({
|
||||
sessionId,
|
||||
command,
|
||||
chatSessionId,
|
||||
beforeExecute,
|
||||
}) {
|
||||
if (!terminalWorkerManager?.request) {
|
||||
return { ok: false, error: "Session not found" };
|
||||
@@ -54,6 +65,7 @@ async function proxyCattyExecToWorker({
|
||||
};
|
||||
|
||||
try {
|
||||
beforeExecute();
|
||||
return await terminalWorkerManager.request("netcatty:ai:exec", {
|
||||
sessionId,
|
||||
command,
|
||||
@@ -63,6 +75,7 @@ async function proxyCattyExecToWorker({
|
||||
commandBlocklist: mcpServerBridge.getCommandBlocklist?.(),
|
||||
}, {
|
||||
webContentsId: event?.sender?.id,
|
||||
beforeExecute,
|
||||
});
|
||||
} catch (err) {
|
||||
return { ok: false, error: err?.message || String(err) };
|
||||
@@ -72,6 +85,7 @@ async function proxyCattyExecToWorker({
|
||||
}
|
||||
|
||||
function registerCattyExecHandlers(ctx) {
|
||||
const privacyService = ctx.aiPrivacyService || defaultAiPrivacyService;
|
||||
with (ctx) {
|
||||
ipcMain.handle("netcatty:ai:exec", async (event, { sessionId, command, chatSessionId }) => {
|
||||
// Validate IPC sender (Issue #17)
|
||||
@@ -82,6 +96,12 @@ function registerCattyExecHandlers(ctx) {
|
||||
if (mcpServerBridge.getPermissionMode() === "observer") {
|
||||
return { ok: false, error: "Execution blocked: permission mode is 'observer'" };
|
||||
}
|
||||
const beforeExecute = () => assertCattyPrivacySession(privacyService, chatSessionId);
|
||||
try {
|
||||
beforeExecute();
|
||||
} catch (err) {
|
||||
return { ok: false, error: err?.message || "AI privacy protection is unavailable." };
|
||||
}
|
||||
const session = sessions?.get(sessionId);
|
||||
if (!session) {
|
||||
return proxyCattyExecToWorker({
|
||||
@@ -91,6 +111,7 @@ function registerCattyExecHandlers(ctx) {
|
||||
sessionId,
|
||||
command,
|
||||
chatSessionId,
|
||||
beforeExecute,
|
||||
});
|
||||
}
|
||||
|
||||
@@ -120,6 +141,7 @@ function registerCattyExecHandlers(ctx) {
|
||||
// (or immediately on a synchronous error/early return).
|
||||
const withLockRelease = (factory) => {
|
||||
try {
|
||||
beforeExecute();
|
||||
const result = factory();
|
||||
return Promise.resolve(result).finally(releaseLock);
|
||||
} catch (err) {
|
||||
@@ -165,6 +187,7 @@ function registerCattyExecHandlers(ctx) {
|
||||
chatSessionId,
|
||||
});
|
||||
if (!probed.ok) return probed;
|
||||
beforeExecute();
|
||||
const safety = mcpServerBridge.checkCommandSafetyForShell(
|
||||
command,
|
||||
mcpServerBridge.resolveSessionBlocklistShellKind(session),
|
||||
@@ -226,6 +249,7 @@ function registerCattyExecHandlers(ctx) {
|
||||
chatSessionId,
|
||||
});
|
||||
if (!probed.ok) return probed;
|
||||
beforeExecute();
|
||||
const safety = mcpServerBridge.checkCommandSafetyForShell(
|
||||
command,
|
||||
mcpServerBridge.resolveSessionBlocklistShellKind(session),
|
||||
@@ -307,10 +331,16 @@ function registerCattyExecHandlers(ctx) {
|
||||
if (!rpcMethod || typeof rpcMethod !== "string") {
|
||||
return { ok: false, error: "rpcMethod is required" };
|
||||
}
|
||||
return mcpServerBridge.dispatchBuiltinRpc(rpcMethod, {
|
||||
...(params || {}),
|
||||
chatSessionId,
|
||||
});
|
||||
const beforeExecute = () => assertCattyPrivacySession(privacyService, chatSessionId);
|
||||
try {
|
||||
beforeExecute();
|
||||
return await mcpServerBridge.dispatchBuiltinRpc(rpcMethod, {
|
||||
...(params || {}),
|
||||
chatSessionId,
|
||||
}, { beforeExecute });
|
||||
} catch (err) {
|
||||
return { ok: false, error: err?.message || "AI privacy protection is unavailable." };
|
||||
}
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
@@ -0,0 +1,154 @@
|
||||
"use strict";
|
||||
|
||||
const assert = require("node:assert/strict");
|
||||
const test = require("node:test");
|
||||
const { registerCattyExecHandlers } = require("./cattyExecHandlers.cjs");
|
||||
const { createAiPrivacyService } = require("../../shared/aiPrivacyService.cjs");
|
||||
|
||||
function deferred() {
|
||||
let resolve;
|
||||
const promise = new Promise((done) => { resolve = done; });
|
||||
return { promise, resolve };
|
||||
}
|
||||
|
||||
function harness(privacyService, overrides = {}) {
|
||||
const handlers = new Map();
|
||||
const effects = [];
|
||||
const locks = [];
|
||||
const mcpServerBridge = {
|
||||
getPermissionMode: () => "auto",
|
||||
getSessionBusyError: () => null,
|
||||
getSessionMeta: () => ({ protocol: "ssh" }),
|
||||
checkCommandSafetyCommonOnly: () => ({ blocked: false }),
|
||||
checkCommandSafetyForShell: () => ({ blocked: false }),
|
||||
resolveSessionBlocklistShellKind: () => "posix",
|
||||
reserveSessionExecution: () => ({ ok: true, token: "lock" }),
|
||||
releaseSessionExecution: () => { locks.push("released"); },
|
||||
activePtyExecs: new Map(),
|
||||
dispatchBuiltinRpc: async (method, params, context) => {
|
||||
context.beforeExecute();
|
||||
effects.push({ method, params });
|
||||
return { ok: true };
|
||||
},
|
||||
...overrides.mcpServerBridge,
|
||||
};
|
||||
registerCattyExecHandlers({
|
||||
ipcMain: { handle: (name, handler) => handlers.set(name, handler) },
|
||||
validateSender: () => true,
|
||||
aiPrivacyService: privacyService,
|
||||
sessions: overrides.sessions || new Map(),
|
||||
terminalWorkerManager: {
|
||||
request: async (channel, payload) => {
|
||||
effects.push({ channel, payload });
|
||||
return { ok: true };
|
||||
},
|
||||
},
|
||||
electronModule: {},
|
||||
safeSend() {},
|
||||
getFreshIdlePrompt: () => "",
|
||||
execViaPty: async () => {
|
||||
effects.push({ terminal: true });
|
||||
return { ok: true };
|
||||
},
|
||||
...overrides,
|
||||
mcpServerBridge,
|
||||
});
|
||||
const event = { sender: { id: 7 } };
|
||||
return {
|
||||
effects,
|
||||
locks,
|
||||
exec: (chatSessionId) => handlers.get("netcatty:ai:exec")(event, {
|
||||
sessionId: "session", command: "echo synthetic", chatSessionId,
|
||||
}),
|
||||
capability: (chatSessionId) => handlers.get("netcatty:ai:capability")(event, {
|
||||
rpcMethod: "netcatty/sftp/write", params: { sessionId: "session", path: "/synthetic" }, chatSessionId,
|
||||
}),
|
||||
};
|
||||
}
|
||||
|
||||
test("Catty main execution requires a live pinned chat when privacy is enabled", async () => {
|
||||
const service = createAiPrivacyService();
|
||||
service.configure({ enabled: true });
|
||||
const ctx = harness(service);
|
||||
assert.equal((await ctx.exec("unseen")).ok, false);
|
||||
assert.equal((await ctx.capability("unseen")).ok, false);
|
||||
assert.equal(service.hasSession("unseen"), false);
|
||||
assert.equal(ctx.effects.length, 0);
|
||||
service.checkSession("pinned");
|
||||
assert.equal((await ctx.exec("pinned")).ok, true);
|
||||
assert.equal((await ctx.capability("pinned")).ok, true);
|
||||
assert.equal(ctx.effects.length, 2);
|
||||
service.configure({ enabled: false });
|
||||
assert.equal((await ctx.exec("pinned")).ok, false);
|
||||
assert.equal((await ctx.capability("pinned")).ok, false);
|
||||
assert.equal(ctx.effects.length, 2);
|
||||
});
|
||||
|
||||
test("Catty legacy no-chat execution is allowed only for initialized disabled privacy", async () => {
|
||||
const loading = harness(createAiPrivacyService({ initialized: false }));
|
||||
assert.equal((await loading.exec()).ok, false);
|
||||
assert.equal((await loading.capability()).ok, false);
|
||||
assert.equal(loading.effects.length, 0);
|
||||
const service = createAiPrivacyService();
|
||||
const ctx = harness(service);
|
||||
assert.equal((await ctx.exec()).ok, true);
|
||||
assert.equal((await ctx.capability()).ok, true);
|
||||
service.configure({ enabled: true });
|
||||
assert.equal((await ctx.exec()).ok, false);
|
||||
assert.equal((await ctx.capability()).ok, false);
|
||||
assert.equal(ctx.effects.length, 2);
|
||||
});
|
||||
|
||||
for (const chatSessionId of ["pinned", undefined]) {
|
||||
test(`Catty capability rechecks policy after dispatcher approval (${chatSessionId || "legacy"})`, async () => {
|
||||
const service = createAiPrivacyService();
|
||||
if (chatSessionId) {
|
||||
service.configure({ enabled: true });
|
||||
service.checkSession(chatSessionId);
|
||||
}
|
||||
const entered = deferred();
|
||||
const approval = deferred();
|
||||
let calls = 0;
|
||||
const ctx = harness(service, {
|
||||
mcpServerBridge: {
|
||||
dispatchBuiltinRpc: async (_method, _params, context) => {
|
||||
entered.resolve();
|
||||
await approval.promise;
|
||||
context.beforeExecute();
|
||||
calls++;
|
||||
return { ok: true };
|
||||
},
|
||||
},
|
||||
});
|
||||
const pending = ctx.capability(chatSessionId);
|
||||
await entered.promise;
|
||||
service.configure({ enabled: !chatSessionId });
|
||||
approval.resolve();
|
||||
assert.equal((await pending).ok, false);
|
||||
assert.equal(calls, 0);
|
||||
});
|
||||
}
|
||||
|
||||
test("Catty terminal rechecks a deleted chat after shell probing and releases its lock", async () => {
|
||||
const service = createAiPrivacyService();
|
||||
service.configure({ enabled: true });
|
||||
service.checkSession("pinned");
|
||||
const entered = deferred();
|
||||
const probe = deferred();
|
||||
const ctx = harness(service, {
|
||||
sessions: new Map([["session", { protocol: "local", shellKind: "posix", pty: { write() {} } }]]),
|
||||
ensureSessionShellKindForExec: async () => {
|
||||
entered.resolve();
|
||||
await probe.promise;
|
||||
return { ok: true };
|
||||
},
|
||||
});
|
||||
const pending = ctx.exec("pinned");
|
||||
await entered.promise;
|
||||
service.clearSession("pinned");
|
||||
const rejected = assert.rejects(pending, /privacy session is unavailable/);
|
||||
probe.resolve();
|
||||
await rejected;
|
||||
assert.equal(ctx.effects.length, 0);
|
||||
assert.deepEqual(ctx.locks, ["released"]);
|
||||
});
|
||||
@@ -2,6 +2,7 @@ const assert = require("node:assert/strict");
|
||||
const test = require("node:test");
|
||||
|
||||
const { registerCattyExecHandlers } = require("./cattyExecHandlers.cjs");
|
||||
const { createAiPrivacyService } = require("../../shared/aiPrivacyService.cjs");
|
||||
|
||||
function createFakeIpcMain() {
|
||||
return {
|
||||
@@ -14,6 +15,8 @@ function createFakeIpcMain() {
|
||||
|
||||
test("catty AI exec proxies to the terminal worker when the real session lives in the worker", async () => {
|
||||
const ipcMain = createFakeIpcMain();
|
||||
const aiPrivacyService = createAiPrivacyService();
|
||||
aiPrivacyService.checkSession("chat-1");
|
||||
const requests = [];
|
||||
const terminalWorkerManager = {
|
||||
request(channel, payload, options) {
|
||||
@@ -55,6 +58,7 @@ test("catty AI exec proxies to the terminal worker when the real session lives i
|
||||
|
||||
registerCattyExecHandlers({
|
||||
ipcMain,
|
||||
aiPrivacyService,
|
||||
validateSender: () => true,
|
||||
sessions: new Map(),
|
||||
terminalWorkerManager,
|
||||
@@ -86,9 +90,10 @@ test("catty AI exec proxies to the terminal worker when the real session lives i
|
||||
sessionMeta: { protocol: "ssh", deviceType: "", hostname: "host.example" },
|
||||
commandBlocklist: [],
|
||||
},
|
||||
options: { webContentsId: 7 },
|
||||
options: { webContentsId: 7, beforeExecute: requests[0].options.beforeExecute },
|
||||
},
|
||||
]);
|
||||
assert.equal(typeof requests[0].options.beforeExecute, "function");
|
||||
assert.deepEqual(locks, [
|
||||
["reserve", "ssh-1", "exec"],
|
||||
["release", "ssh-1", "token-1"],
|
||||
|
||||
@@ -206,6 +206,9 @@ class CodexAppServerConnection {
|
||||
|
||||
async request(method, params = {}, timeoutMs = DEFAULT_REQUEST_TIMEOUT_MS, options = {}) {
|
||||
if (!options.skipStart) await this.start();
|
||||
// Even an initialized connection yields in start(). Revalidate prompt
|
||||
// handoffs after that await, before any JSONL payload reaches the process.
|
||||
options.beforeSend?.();
|
||||
const id = this.nextRequestId++;
|
||||
return new Promise((resolve, reject) => {
|
||||
const timer = setTimeout(() => {
|
||||
|
||||
@@ -198,3 +198,26 @@ test("App Server process keys include executable and environment identity", () =
|
||||
buildCodexAppServerKey("/a/codex", { HOME: "/b" }),
|
||||
);
|
||||
});
|
||||
|
||||
test("prompt guard runs after the connection's own startup await and prevents the JSONL write", async () => {
|
||||
const { createAiPrivacyService } = require("../../../shared/aiPrivacyService.cjs");
|
||||
const privacy = createAiPrivacyService();
|
||||
privacy.checkSession("chat");
|
||||
const child = createFakeChild();
|
||||
const writes = [];
|
||||
child.stdin.on("data", (chunk) => writes.push(String(chunk)));
|
||||
const connection = new CodexAppServerConnection({ binPath: "/bin/codex", spawnImpl: () => child });
|
||||
let release;
|
||||
connection.process = child;
|
||||
connection.start = () => new Promise((resolve) => { release = resolve; });
|
||||
const run = connection.request("turn/start", { input: [{ type: "text", text: "raw-secret" }] }, undefined, {
|
||||
beforeSend: () => privacy.checkSession("chat"),
|
||||
});
|
||||
const rejected = assert.rejects(run, /start a new chat/);
|
||||
privacy.configure({ enabled: true });
|
||||
release();
|
||||
await rejected;
|
||||
assert.deepEqual(writes, []);
|
||||
assert.equal(connection.pending.size, 0);
|
||||
connection.close();
|
||||
});
|
||||
@@ -180,6 +180,7 @@ class CodexAppServerRuntime {
|
||||
connectionFactory,
|
||||
sendInteractionRequest,
|
||||
sendInteractionCleared,
|
||||
transformUserInputAnswers,
|
||||
interruptRequestTimeoutMs = INTERRUPT_REQUEST_TIMEOUT_MS,
|
||||
interruptGraceMs = INTERRUPT_GRACE_MS,
|
||||
} = {}) {
|
||||
@@ -187,6 +188,7 @@ class CodexAppServerRuntime {
|
||||
this.connectionFactory = connectionFactory;
|
||||
this.sendInteractionRequest = sendInteractionRequest;
|
||||
this.sendInteractionCleared = sendInteractionCleared;
|
||||
this.transformUserInputAnswers = transformUserInputAnswers;
|
||||
this.interruptRequestTimeoutMs = interruptRequestTimeoutMs;
|
||||
this.interruptGraceMs = interruptGraceMs;
|
||||
this.connections = new Map();
|
||||
@@ -257,6 +259,7 @@ class CodexAppServerRuntime {
|
||||
emitter,
|
||||
signal,
|
||||
sender,
|
||||
assertSessionValid,
|
||||
}) {
|
||||
const throwIfAborted = () => {
|
||||
if (!signal?.aborted) return;
|
||||
@@ -279,9 +282,11 @@ class CodexAppServerRuntime {
|
||||
config: buildThreadConfig(injectedMcpServers),
|
||||
};
|
||||
|
||||
assertSessionValid?.();
|
||||
const requestOptions = { beforeSend: assertSessionValid };
|
||||
const threadResult = resumeThreadId
|
||||
? await connection.request("thread/resume", { threadId: resumeThreadId, ...threadParams })
|
||||
: await connection.request("thread/start", threadParams);
|
||||
? await connection.request("thread/resume", { threadId: resumeThreadId, ...threadParams }, undefined, requestOptions)
|
||||
: await connection.request("thread/start", threadParams, undefined, requestOptions);
|
||||
throwIfAborted();
|
||||
const threadId = threadResult?.thread?.id || resumeThreadId;
|
||||
if (!threadId) throw new Error("Codex App Server did not return a thread id");
|
||||
@@ -297,6 +302,7 @@ class CodexAppServerRuntime {
|
||||
emitter,
|
||||
signal,
|
||||
sender,
|
||||
assertSessionValid,
|
||||
lastError: null,
|
||||
settled: false,
|
||||
cancelRequested: false,
|
||||
@@ -325,6 +331,7 @@ class CodexAppServerRuntime {
|
||||
}
|
||||
|
||||
try {
|
||||
assertSessionValid?.();
|
||||
const turnResult = await connection.request("turn/start", {
|
||||
threadId,
|
||||
input: buildTurnInput(prompt, attachments),
|
||||
@@ -335,7 +342,7 @@ class CodexAppServerRuntime {
|
||||
model: selection.model || null,
|
||||
effort: selection.effort || null,
|
||||
summary: "concise",
|
||||
});
|
||||
}, undefined, requestOptions);
|
||||
const turnId = turnResult?.turn?.id;
|
||||
if (turnId) this.#assignTurnId(context, turnId);
|
||||
await completion;
|
||||
@@ -388,12 +395,13 @@ class CodexAppServerRuntime {
|
||||
|
||||
const steerPromise = (async () => {
|
||||
try {
|
||||
context.assertSessionValid?.();
|
||||
const response = await context.connection.request("turn/steer", {
|
||||
threadId: context.threadId,
|
||||
expectedTurnId: context.turnId,
|
||||
input: buildTurnInput(prompt, attachments),
|
||||
clientUserMessageId: clientUserMessageId || null,
|
||||
});
|
||||
}, undefined, { beforeSend: context.assertSessionValid });
|
||||
if (context.cancelRequested || context.signal?.aborted || context.settled) {
|
||||
return { status: "cancelled" };
|
||||
}
|
||||
@@ -861,7 +869,13 @@ class CodexAppServerRuntime {
|
||||
respondInteraction(interactionId, response, sender) {
|
||||
const pending = this.pendingInteractions.get(interactionId);
|
||||
if (sender && pending?.context?.sender && pending.context.sender !== sender) return false;
|
||||
return this.#resolveInteraction(interactionId, response);
|
||||
const safeResponse = pending?.kind === "user-input" && this.transformUserInputAnswers
|
||||
? {
|
||||
...response,
|
||||
answers: this.transformUserInputAnswers(response?.answers || {}, pending.context),
|
||||
}
|
||||
: response;
|
||||
return this.#resolveInteraction(interactionId, safeResponse);
|
||||
}
|
||||
|
||||
/**
|
||||
|
||||
@@ -311,6 +311,56 @@ test("runtime routes native approvals and request_user_input responses", async (
|
||||
await run;
|
||||
});
|
||||
|
||||
test("runtime transforms user answers with authoritative pending chat context after sender validation", () => {
|
||||
const responses = [];
|
||||
const sender = {};
|
||||
const transformedContexts = [];
|
||||
const runtime = new CodexAppServerRuntime({
|
||||
transformUserInputAnswers(answers, context) {
|
||||
transformedContexts.push(context.chatSessionId);
|
||||
return { ...answers, question: { answers: ["protected-answer"] } };
|
||||
},
|
||||
});
|
||||
runtime.pendingInteractions.set("private-answer", {
|
||||
interactionId: "private-answer",
|
||||
kind: "user-input",
|
||||
context: { chatSessionId: "chat-private", sender },
|
||||
connection: { respond: (id, result) => responses.push({ id, result }) },
|
||||
rpcId: 90,
|
||||
params: {},
|
||||
});
|
||||
assert.equal(runtime.respondInteraction("private-answer", {
|
||||
answers: { question: { answers: ["private-input"] } },
|
||||
}, {}), false);
|
||||
assert.equal(transformedContexts.length, 0);
|
||||
assert.equal(runtime.respondInteraction("private-answer", {
|
||||
answers: { question: { answers: ["private-input"] } },
|
||||
}, sender), true);
|
||||
assert.deepEqual(transformedContexts, ["chat-private"]);
|
||||
assert.deepEqual(responses, [{ id: 90, result: { answers: { question: { answers: ["protected-answer"] } } } }]);
|
||||
});
|
||||
|
||||
test("failed user answer transformation sends no raw reply and leaves interaction pending", () => {
|
||||
const responses = [];
|
||||
const runtime = new CodexAppServerRuntime({
|
||||
transformUserInputAnswers() { throw new Error("Privacy settings changed"); },
|
||||
});
|
||||
runtime.pendingInteractions.set("private-answer", {
|
||||
interactionId: "private-answer",
|
||||
kind: "user-input",
|
||||
context: { chatSessionId: "chat-private" },
|
||||
connection: { respond: (id, result) => responses.push({ id, result }) },
|
||||
rpcId: 91,
|
||||
params: {},
|
||||
});
|
||||
assert.throws(() => runtime.respondInteraction("private-answer", {
|
||||
answers: { question: { answers: ["private-input"] } },
|
||||
}), /Privacy settings changed/);
|
||||
assert.deepEqual(responses, []);
|
||||
assert.equal(runtime.pendingInteractions.has("private-answer"), true);
|
||||
runtime.close();
|
||||
});
|
||||
|
||||
test("cancelInteractionTimeout re-arms the absolute approval deadline (Catty/MCP style)", async () => {
|
||||
let connection;
|
||||
let interaction;
|
||||
@@ -848,3 +898,45 @@ test("model and file-change normalization preserve UI contract", async () => {
|
||||
assert.deepEqual(catalog.models[1].thinkingLevels, ["low", "high"]);
|
||||
assert.equal(catalog.models[1].defaultThinkingLevel, "high");
|
||||
});
|
||||
|
||||
for (const startupStage of ["connection", "thread"]) {
|
||||
test(`privacy off-to-on while Codex ${startupStage} startup is paused prevents turn/start`, async () => {
|
||||
const { createAiPrivacyService } = require("../../../shared/aiPrivacyService.cjs");
|
||||
const privacy = createAiPrivacyService();
|
||||
privacy.checkSession("chat-privacy-race");
|
||||
let notifyReady;
|
||||
let release;
|
||||
const ready = new Promise((resolve) => { notifyReady = resolve; });
|
||||
const gate = new Promise((resolve) => { release = resolve; });
|
||||
let connection;
|
||||
const runtime = new CodexAppServerRuntime({
|
||||
connectionFactory: (options) => {
|
||||
connection = new FakeConnection(options);
|
||||
if (startupStage === "connection") {
|
||||
connection.start = async () => { notifyReady(); await gate; };
|
||||
} else {
|
||||
const request = connection.request.bind(connection);
|
||||
connection.request = async (method, params) => {
|
||||
if (method === "thread/start") { notifyReady(); await gate; }
|
||||
return request(method, params);
|
||||
};
|
||||
}
|
||||
return connection;
|
||||
},
|
||||
});
|
||||
const run = runtime.runTurn({
|
||||
requestId: "privacy-race", chatSessionId: "chat-privacy-race",
|
||||
prompt: "raw-secret", binPath: "/bin/codex", emitter: createEmitter(),
|
||||
assertSessionValid: () => privacy.checkSession("chat-privacy-race"),
|
||||
});
|
||||
const rejected = assert.rejects(run, /start a new chat/);
|
||||
await ready;
|
||||
privacy.configure({ enabled: true });
|
||||
release();
|
||||
await rejected;
|
||||
assert.equal(connection.requests.some(({ method }) => method === "turn/start"), false);
|
||||
assert.equal(runtime.activeByRequest.size, 0);
|
||||
assert.equal(runtime.activeByThread.size, 0);
|
||||
await runtime.cleanupChatSession("chat-privacy-race");
|
||||
});
|
||||
}
|
||||
@@ -0,0 +1,35 @@
|
||||
"use strict";
|
||||
|
||||
const privacyService = require("../../shared/aiPrivacyService.cjs");
|
||||
|
||||
function registerPrivacyHandlers({ ipcMain, validateSender, aiPrivacyService = privacyService }) {
|
||||
ipcMain.handle("netcatty:ai:privacy:status", (event) => {
|
||||
if (!validateSender(event)) return { ok: false, error: "Unauthorized" };
|
||||
try {
|
||||
return { ok: true, enabled: aiPrivacyService.isEnabled() };
|
||||
} catch (error) {
|
||||
return { ok: false, error: error.message };
|
||||
}
|
||||
});
|
||||
ipcMain.handle("netcatty:ai:privacy:transform", (event, request = {}) => {
|
||||
if (!validateSender(event)) return { ok: false, error: "Unauthorized" };
|
||||
try {
|
||||
if (request.operation !== "redact" && request.operation !== "restore") {
|
||||
throw new Error("Invalid AI privacy operation.");
|
||||
}
|
||||
return {
|
||||
ok: true,
|
||||
value: aiPrivacyService[request.operation](request.chatSessionId, request.value),
|
||||
};
|
||||
} catch (error) {
|
||||
return { ok: false, error: error.message };
|
||||
}
|
||||
});
|
||||
ipcMain.handle("netcatty:ai:privacy:clear", (event, chatSessionId) => {
|
||||
if (!validateSender(event)) return { ok: false, error: "Unauthorized" };
|
||||
aiPrivacyService.clearSession(chatSessionId);
|
||||
return { ok: true };
|
||||
});
|
||||
}
|
||||
|
||||
module.exports = { registerPrivacyHandlers };
|
||||
@@ -233,7 +233,7 @@ function buildClaudePromptInput(prompt, attachments) {
|
||||
* @param {object} args.emitter createStreamEmitter(...)
|
||||
* @param {Function} [args.queryFn] inject @anthropic-ai/claude-agent-sdk query (for tests)
|
||||
*/
|
||||
async function runClaudeTurn({ prompt, attachments, options, emitter, queryFn }) {
|
||||
async function runClaudeTurn({ prompt, attachments, options, emitter, queryFn, assertSessionValid }) {
|
||||
ensureClaudeConfig();
|
||||
let query = queryFn;
|
||||
if (!query) {
|
||||
@@ -246,6 +246,7 @@ async function runClaudeTurn({ prompt, attachments, options, emitter, queryFn })
|
||||
let sessionId = null;
|
||||
let hasContent = false;
|
||||
try {
|
||||
assertSessionValid?.();
|
||||
const stream = query({ prompt: promptInput, options });
|
||||
for await (const message of stream) {
|
||||
if (options.abortController?.signal?.aborted) break;
|
||||
|
||||
@@ -557,7 +557,7 @@ function buildCodebuddyPromptInput(prompt, attachments) {
|
||||
* @param {object} args.emitter createStreamEmitter(...)
|
||||
* @param {Function} [args.queryFn] inject @tencent-ai/agent-sdk query (for tests)
|
||||
*/
|
||||
async function runCodebuddyTurn({ prompt, attachments, options, emitter, queryFn }) {
|
||||
async function runCodebuddyTurn({ prompt, attachments, options, emitter, queryFn, assertSessionValid }) {
|
||||
if (options.abortController?.signal?.aborted) {
|
||||
emitter.emitDone();
|
||||
return { sessionId: null };
|
||||
@@ -585,6 +585,7 @@ async function runCodebuddyTurn({ prompt, attachments, options, emitter, queryFn
|
||||
let queryRef = null;
|
||||
let removeAbortListener = null;
|
||||
try {
|
||||
assertSessionValid?.();
|
||||
queryRef = query({ prompt: promptInput, options });
|
||||
const signal = options.abortController?.signal;
|
||||
const interruptQuery = () => {
|
||||
|
||||
@@ -119,7 +119,7 @@ class CodebuddySessionManager {
|
||||
* @param {string} [args.resumeSessionId] resume an existing session by ID
|
||||
* @returns {Promise<object|null>} session instance or null if V2 unavailable
|
||||
*/
|
||||
async getOrCreateSession({ sessionKey, sessionOptions, resumeSessionId }) {
|
||||
async getOrCreateSession({ sessionKey, sessionOptions, resumeSessionId, assertSessionValid }) {
|
||||
const fingerprint = computeOptionsFingerprint(sessionOptions);
|
||||
const existing = this.sessions.get(sessionKey);
|
||||
if (existing) {
|
||||
@@ -150,6 +150,8 @@ class CodebuddySessionManager {
|
||||
const resumeSession = sdk.unstable_v2_resumeSession;
|
||||
if (!createSession || !resumeSession) return null;
|
||||
|
||||
// Session options can themselves contain system/custom-agent prompts.
|
||||
assertSessionValid?.();
|
||||
let session;
|
||||
try {
|
||||
const callbackState = createSessionCallbackState(sessionOptions);
|
||||
@@ -180,7 +182,7 @@ class CodebuddySessionManager {
|
||||
*/
|
||||
async runTurn({
|
||||
sessionKey, prompt, attachments, options, emitter,
|
||||
sessionOptions, resumeSessionId,
|
||||
sessionOptions, resumeSessionId, assertSessionValid,
|
||||
}) {
|
||||
const signal = options.abortController?.signal;
|
||||
if (signal?.aborted) {
|
||||
@@ -189,7 +191,7 @@ class CodebuddySessionManager {
|
||||
}
|
||||
|
||||
const session = await this.getOrCreateSession({
|
||||
sessionKey, sessionOptions, resumeSessionId,
|
||||
sessionKey, sessionOptions, resumeSessionId, assertSessionValid,
|
||||
});
|
||||
if (!session) {
|
||||
if (signal?.aborted) {
|
||||
@@ -230,6 +232,7 @@ class CodebuddySessionManager {
|
||||
}
|
||||
}
|
||||
|
||||
assertSessionValid?.();
|
||||
try {
|
||||
// Send before the initial connection so resumed sessions suppress
|
||||
// historical replay and stream only the response to this prompt.
|
||||
@@ -238,6 +241,7 @@ class CodebuddySessionManager {
|
||||
} else {
|
||||
// Async iterable of UserMessage — send first message.
|
||||
for await (const msg of promptInput) {
|
||||
assertSessionValid?.();
|
||||
await session.send(msg);
|
||||
}
|
||||
}
|
||||
@@ -245,6 +249,8 @@ class CodebuddySessionManager {
|
||||
// Initial transport setup happens inside send(). Release any acquired
|
||||
// session lock/process before the caller falls back to legacy query().
|
||||
this.closeSession(sessionKey);
|
||||
// Do not reinterpret invalid privacy state as a transport fallback.
|
||||
assertSessionValid?.();
|
||||
if (signal?.aborted) {
|
||||
emitter.emitDone();
|
||||
return { sessionId, usedV2: true };
|
||||
|
||||
@@ -345,7 +345,7 @@ function translateCodexEvent(event, emitter, state) {
|
||||
* @param {Function} [args.CodexCtor] inject Codex class (for tests)
|
||||
*/
|
||||
async function runCodexTurn({
|
||||
prompt, attachments, constructorOptions, threadOptions, resumeThreadId, emitter, signal, CodexCtor,
|
||||
prompt, attachments, constructorOptions, threadOptions, resumeThreadId, emitter, signal, CodexCtor, assertSessionValid,
|
||||
}) {
|
||||
const Codex = CodexCtor || (await import("@openai/codex-sdk")).Codex;
|
||||
const promptInput = buildCodexPromptInput(prompt, attachments);
|
||||
@@ -357,9 +357,12 @@ async function runCodexTurn({
|
||||
? codex.resumeThread(resumeThreadId, threadOptions)
|
||||
: codex.startThread(threadOptions);
|
||||
|
||||
assertSessionValid?.();
|
||||
const { events } = await thread.runStreamed(promptInput, signal ? { signal } : undefined);
|
||||
let hasContent = false;
|
||||
const state = { reasoningOpen: false };
|
||||
// runStreamed returns a lazy iterator; its first read starts execution.
|
||||
assertSessionValid?.();
|
||||
for await (const event of events) {
|
||||
// Capture + emit the resumable thread id as EARLY as possible — it exists
|
||||
// the moment `thread.started` arrives (the first event). Emitting it only at
|
||||
|
||||
@@ -477,6 +477,7 @@ async function runCopilotTurn({
|
||||
emitter,
|
||||
signal,
|
||||
sdkModule,
|
||||
assertSessionValid,
|
||||
}) {
|
||||
let resolvedModule = sdkModule;
|
||||
if (!resolvedModule) {
|
||||
@@ -564,6 +565,7 @@ async function runCopilotTurn({
|
||||
}
|
||||
let final;
|
||||
try {
|
||||
assertSessionValid?.();
|
||||
final = await session.sendAndWait(buildCopilotMessageOptions({ prompt, attachments }));
|
||||
} finally {
|
||||
try { unsubscribe(); } catch { /* best effort */ }
|
||||
|
||||
@@ -477,6 +477,7 @@ async function runCursorCliTurn({
|
||||
workspaceCwd,
|
||||
abortGraceMs = CURSOR_CLI_ABORT_GRACE_MS,
|
||||
forceKillImpl,
|
||||
assertSessionValid,
|
||||
}) {
|
||||
const cliPath = String(binPath || "").trim();
|
||||
if (!cliPath) {
|
||||
@@ -539,6 +540,7 @@ async function runCursorCliTurn({
|
||||
};
|
||||
|
||||
try {
|
||||
assertSessionValid?.();
|
||||
child = spawnCursorCliProcess(spawnImpl, cliPath, args, {
|
||||
cwd: effectiveCwd,
|
||||
env: childEnv,
|
||||
|
||||
@@ -320,7 +320,7 @@ function runCursorTurn(options) {
|
||||
}
|
||||
|
||||
async function runCursorTurnInProcess({
|
||||
prompt, attachments, agentOptions, runtimeEnv, resumeSessionId, emitter, signal, sdkModule,
|
||||
prompt, attachments, agentOptions, runtimeEnv, resumeSessionId, emitter, signal, sdkModule, assertSessionValid, getPrompt,
|
||||
}) {
|
||||
let resolvedModule = sdkModule;
|
||||
if (!resolvedModule) {
|
||||
@@ -370,10 +370,13 @@ async function runCursorTurnInProcess({
|
||||
if (sessionId) emitter.sessionId(sessionId);
|
||||
if (signal?.aborted) return { sessionId };
|
||||
|
||||
const sendMessage = buildCursorSendMessage(prompt, attachments);
|
||||
run = await abortable(withExclusiveProcessEnv(runtimeEnv, () => {
|
||||
run = await abortable(withExclusiveProcessEnv(runtimeEnv, async () => {
|
||||
// The utility worker receives prompt content only after startup and the
|
||||
// main process has revalidated this chat at the handoff boundary.
|
||||
const input = getPrompt ? await getPrompt() : { prompt, attachments };
|
||||
if (signal?.aborted) throw new CursorTurnAbortError();
|
||||
return agent.send(sendMessage);
|
||||
assertSessionValid?.();
|
||||
return agent.send(buildCursorSendMessage(input.prompt, input.attachments));
|
||||
}), signal, (lateRun) => {
|
||||
if (lateRun && typeof lateRun.cancel === "function") {
|
||||
void lateRun.cancel().catch(() => {});
|
||||
|
||||
@@ -12,11 +12,33 @@ for (const method of [
|
||||
}
|
||||
|
||||
let started = false;
|
||||
let pendingPrompt = null;
|
||||
function getPrompt() {
|
||||
return new Promise((resolve, reject) => {
|
||||
if (controller.signal.aborted) {
|
||||
reject(new Error("Cursor turn was interrupted"));
|
||||
return;
|
||||
}
|
||||
pendingPrompt = { resolve, reject };
|
||||
process.parentPort.postMessage({ type: "prompt-request" });
|
||||
});
|
||||
}
|
||||
process.parentPort.on("message", ({ data }) => {
|
||||
if (data?.type === "abort") controller.abort();
|
||||
if (data?.type === "abort") {
|
||||
controller.abort();
|
||||
pendingPrompt?.reject(new Error("Cursor turn was interrupted"));
|
||||
pendingPrompt = null;
|
||||
}
|
||||
if (data?.type === "prompt") {
|
||||
pendingPrompt?.resolve({ prompt: data.prompt, attachments: data.attachments });
|
||||
pendingPrompt = null;
|
||||
}
|
||||
if (data?.type === "start" && !started) {
|
||||
started = true;
|
||||
void runCursorTurnInProcess({ ...data.params, emitter, signal: controller.signal }).then(
|
||||
void runCursorTurnInProcess({
|
||||
...data.params, emitter, signal: controller.signal,
|
||||
getPrompt: data.params.deferPrompt ? getPrompt : undefined,
|
||||
}).then(
|
||||
(result) => process.parentPort.postMessage({ type: "result", result }),
|
||||
(error) => process.parentPort.postMessage({ type: "error", message: error?.message || String(error) }),
|
||||
);
|
||||
|
||||
@@ -12,7 +12,11 @@ function createCursorWorker(options) {
|
||||
|
||||
// Each turn gets its own environment and SDK instance. A stalled SDK network
|
||||
// request cannot retain a main-process environment lock or block another chat.
|
||||
function runCursorWorkerTurn({ emitter, signal, ...params }, createWorker = createCursorWorker) {
|
||||
function runCursorWorkerTurn({ emitter, signal, assertSessionValid, ...params }, createWorker = createCursorWorker) {
|
||||
// Functions cannot cross the utility-process boundary. Keep semantic content
|
||||
// in main until the worker is ready to send, then validate before releasing it.
|
||||
const { prompt, attachments, ...startupParams } = params;
|
||||
const deferPrompt = typeof assertSessionValid === "function";
|
||||
let sessionId = params.resumeSessionId || null;
|
||||
if (signal?.aborted) return Promise.resolve({ sessionId });
|
||||
return new Promise((resolve) => {
|
||||
@@ -30,6 +34,11 @@ function runCursorWorkerTurn({ emitter, signal, ...params }, createWorker = crea
|
||||
clearTimeout(stopTimer);
|
||||
worker.kill();
|
||||
};
|
||||
const privacyFailure = (error) => {
|
||||
emitter.emitError(error?.message || String(error));
|
||||
finish({ sessionId });
|
||||
terminate();
|
||||
};
|
||||
const onAbort = () => {
|
||||
if (spawned) worker.postMessage({ type: "abort" });
|
||||
// Allow run.cancel()/agent.close() first, then contain a stuck startup.
|
||||
@@ -50,10 +59,25 @@ function runCursorWorkerTurn({ emitter, signal, ...params }, createWorker = crea
|
||||
worker.once("spawn", () => {
|
||||
spawned = true;
|
||||
if (settled) terminate();
|
||||
else worker.postMessage({ type: "start", params });
|
||||
else {
|
||||
try {
|
||||
assertSessionValid?.();
|
||||
worker.postMessage({ type: "start", params: deferPrompt ? { ...startupParams, deferPrompt } : params });
|
||||
} catch (error) {
|
||||
privacyFailure(error);
|
||||
}
|
||||
}
|
||||
});
|
||||
worker.on("message", (message) => {
|
||||
if (message?.type === "event") {
|
||||
if (message?.type === "prompt-request") {
|
||||
if (settled || !deferPrompt) return;
|
||||
try {
|
||||
assertSessionValid();
|
||||
worker.postMessage({ type: "prompt", prompt, attachments });
|
||||
} catch (error) {
|
||||
privacyFailure(error);
|
||||
}
|
||||
} else if (message?.type === "event") {
|
||||
if (settled) return;
|
||||
if (message.method === "sessionId") sessionId = message.args[0];
|
||||
const handler = emitter[message.method];
|
||||
|
||||
@@ -111,3 +111,52 @@ test("utility-process fatal errors retain their reason and settle only once", as
|
||||
assert.deepEqual(errors, ["FatalError: v8::Heap"]);
|
||||
assert.equal(kills, 1);
|
||||
});
|
||||
|
||||
test("Cursor worker startup withholds semantic content until main validates the prompt handoff", async () => {
|
||||
const { EventEmitter } = require("node:events");
|
||||
const { createAiPrivacyService } = require("../../../shared/aiPrivacyService.cjs");
|
||||
const privacy = createAiPrivacyService();
|
||||
privacy.checkSession("chat");
|
||||
const child = new EventEmitter();
|
||||
const sent = [];
|
||||
const errors = [];
|
||||
let killed = false;
|
||||
child.postMessage = (message) => sent.push(structuredClone(message));
|
||||
child.kill = () => { killed = true; child.emit("exit", 0); };
|
||||
const turn = runCursorWorkerTurn({
|
||||
prompt: "raw-secret", attachments: [{ data: "raw-image" }],
|
||||
emitter: { emitError: (error) => errors.push(error) },
|
||||
assertSessionValid: () => privacy.checkSession("chat"),
|
||||
}, () => child);
|
||||
child.emit("spawn");
|
||||
assert.equal(sent[0].type, "start");
|
||||
assert.equal(sent[0].params.deferPrompt, true);
|
||||
assert.equal("prompt" in sent[0].params, false);
|
||||
assert.equal("attachments" in sent[0].params, false);
|
||||
assert.equal("assertSessionValid" in sent[0].params, false);
|
||||
privacy.configure({ enabled: true });
|
||||
child.emit("message", { type: "prompt-request" });
|
||||
await turn;
|
||||
assert.equal(sent.length, 1);
|
||||
assert.equal(killed, true);
|
||||
assert.match(errors.join("\n"), /start a new chat/);
|
||||
});
|
||||
|
||||
test("Cursor releases the original prompt only on a valid post-startup request", async () => {
|
||||
const { EventEmitter } = require("node:events");
|
||||
const child = new EventEmitter();
|
||||
const sent = [];
|
||||
let checks = 0;
|
||||
child.postMessage = (message) => sent.push(structuredClone(message));
|
||||
child.kill = () => child.emit("exit", 0);
|
||||
const turn = runCursorWorkerTurn({
|
||||
prompt: "hello", attachments: [], emitter: {},
|
||||
assertSessionValid: () => { checks++; },
|
||||
}, () => child);
|
||||
child.emit("spawn");
|
||||
child.emit("message", { type: "prompt-request" });
|
||||
assert.equal(checks, 2);
|
||||
assert.deepEqual(sent[1], { type: "prompt", prompt: "hello", attachments: [] });
|
||||
child.emit("message", { type: "result", result: { sessionId: null } });
|
||||
await turn;
|
||||
});
|
||||
@@ -19,17 +19,99 @@
|
||||
* { type: 'session-id', sessionId }
|
||||
* { type: 'error', error }
|
||||
*/
|
||||
function createStreamEmitter({ safeSend, sender, requestId }) {
|
||||
const emitEvent = (event) => {
|
||||
function createStreamEmitter({ safeSend, sender, requestId, restoreValue }) {
|
||||
const pending = { "text-delta": "", "reasoning-delta": "" };
|
||||
const restore = typeof restoreValue === "function" ? restoreValue : (value) => value;
|
||||
const streamFields = { "text-delta": "textDelta", "reasoning-delta": "delta" };
|
||||
const displayFields = {
|
||||
"tool-call": ["args", "input"],
|
||||
"tool-result": ["output", "result"],
|
||||
"file-change": ["changes"],
|
||||
"web-search": ["query"],
|
||||
"plan-update": ["items"],
|
||||
warning: ["message"],
|
||||
status: ["message"],
|
||||
error: ["error"],
|
||||
hook: ["toolInput", "error", "reason", "message", "title", "compactSummary"],
|
||||
};
|
||||
const restoreQuestionSchema = (schema) => {
|
||||
if (Array.isArray(schema)) return schema.map(restoreQuestionSchema);
|
||||
if (!schema || typeof schema !== "object") return schema;
|
||||
return Object.fromEntries(Object.entries(schema).map(([key, value]) => [
|
||||
key,
|
||||
["title", "description", "enum", "enumNames"].includes(key)
|
||||
? restore(value)
|
||||
: restoreQuestionSchema(value),
|
||||
]));
|
||||
};
|
||||
const sendEvent = (event) => {
|
||||
safeSend(sender, "netcatty:ai:sdk-agent:event", { requestId, event });
|
||||
};
|
||||
// Providers may split a pseudonym anywhere, including between "[" and "N".
|
||||
// Only the unfinished suffix is retained; ordinary text streams immediately.
|
||||
const restoreDelta = (type, delta, final = false) => {
|
||||
const text = pending[type] + String(delta || "");
|
||||
let end = text.length;
|
||||
if (!final) {
|
||||
const start = text.lastIndexOf("[");
|
||||
if (start >= 0) {
|
||||
const suffix = text.slice(start);
|
||||
if ("[NC_".startsWith(suffix)
|
||||
|| (suffix.startsWith("[NC_") && !suffix.includes("]"))) {
|
||||
if (suffix.length > 128) throw new Error("AI privacy could not restore an incomplete response token.");
|
||||
end = start;
|
||||
}
|
||||
}
|
||||
}
|
||||
pending[type] = text.slice(end);
|
||||
return end ? String(restore(text.slice(0, end))) : "";
|
||||
};
|
||||
const flush = (type) => {
|
||||
if (!pending[type]) return;
|
||||
const text = restoreDelta(type, "", true);
|
||||
if (text) sendEvent({ type, [streamFields[type]]: text });
|
||||
};
|
||||
const emitEvent = (event) => {
|
||||
if (!restoreValue) return sendEvent(event);
|
||||
const field = streamFields[event.type];
|
||||
if (field) {
|
||||
const text = restoreDelta(event.type, event[field]);
|
||||
if (text) sendEvent({ ...event, [field]: text });
|
||||
return;
|
||||
}
|
||||
if (event.type === "reasoning-end") flush("reasoning-delta");
|
||||
const output = { ...event };
|
||||
for (const key of displayFields[event.type] || []) {
|
||||
if (output[key] !== undefined) output[key] = restore(output[key]);
|
||||
}
|
||||
if (event.type === "elicitation-create" && event.request) {
|
||||
output.request = { ...event.request };
|
||||
if (event.request.message !== undefined) output.request.message = restore(event.request.message);
|
||||
if (event.request.requestedSchema) {
|
||||
output.request.requestedSchema = restoreQuestionSchema(event.request.requestedSchema);
|
||||
}
|
||||
}
|
||||
sendEvent(output);
|
||||
};
|
||||
return {
|
||||
emitEvent,
|
||||
emitDone() {
|
||||
if (restoreValue) {
|
||||
flush("text-delta");
|
||||
flush("reasoning-delta");
|
||||
}
|
||||
safeSend(sender, "netcatty:ai:sdk-agent:done", { requestId });
|
||||
},
|
||||
emitError(error) {
|
||||
safeSend(sender, "netcatty:ai:sdk-agent:error", { requestId, error });
|
||||
// Errors can follow an aborted partial token. Never emit that suffix, and
|
||||
// never let a failed restoration recursively prevent error delivery.
|
||||
pending["text-delta"] = "";
|
||||
pending["reasoning-delta"] = "";
|
||||
let restored;
|
||||
try { restored = restore(error); } catch {
|
||||
restored = "AI privacy could not restore the response. Start a new conversation.";
|
||||
}
|
||||
safeSend(sender, "netcatty:ai:sdk-agent:error", { requestId, error: restored });
|
||||
},
|
||||
text(textDelta) {
|
||||
if (textDelta) emitEvent({ type: "text-delta", textDelta });
|
||||
|
||||
@@ -58,3 +58,92 @@ test("convenience helpers emit the canonical event shapes", () => {
|
||||
assert.equal(calls[7].payload.event.totalTokens, 15);
|
||||
assert.equal(calls[9].payload.event.sessionId, "sess-9");
|
||||
});
|
||||
|
||||
const PRIVATE_TOKEN = "[NC_aabbccddeeff_SECRET_1]";
|
||||
function restoreTestValue(value) {
|
||||
if (typeof value === "string") {
|
||||
return value.replace(/\[NC_[^\]]*(?:\]|$)/g, (token) => {
|
||||
if (token !== PRIVATE_TOKEN) throw new Error("Unknown privacy token");
|
||||
return "private-original";
|
||||
});
|
||||
}
|
||||
if (Array.isArray(value)) return value.map(restoreTestValue);
|
||||
if (value && typeof value === "object") {
|
||||
return Object.fromEntries(Object.entries(value).map(([key, entry]) => [key, restoreTestValue(entry)]));
|
||||
}
|
||||
return value;
|
||||
}
|
||||
|
||||
test("privacy restoration buffers tokens at every text and reasoning split boundary", () => {
|
||||
for (const stream of ["text", "reasoning"]) {
|
||||
for (let split = 1; split < PRIVATE_TOKEN.length; split += 1) {
|
||||
const { calls, safeSend } = recordingSend();
|
||||
const emitter = createStreamEmitter({
|
||||
safeSend, sender: {}, requestId: "private", restoreValue: restoreTestValue,
|
||||
});
|
||||
emitter[stream](`Before ${PRIVATE_TOKEN.slice(0, split)}`);
|
||||
emitter[stream](`${PRIVATE_TOKEN.slice(split)} after`);
|
||||
if (stream === "reasoning") emitter.reasoningEnd();
|
||||
emitter.emitDone();
|
||||
const text = calls.map((entry) => entry.payload.event?.textDelta || entry.payload.event?.delta || "").join("");
|
||||
assert.equal(text, "Before private-original after", `${stream} split ${split}`);
|
||||
assert.doesNotMatch(JSON.stringify(calls), /\[NC_/);
|
||||
}
|
||||
}
|
||||
});
|
||||
|
||||
test("privacy restoration supports character-at-a-time tokens and preserves event routing identifiers", () => {
|
||||
const { calls, safeSend } = recordingSend();
|
||||
const emitter = createStreamEmitter({
|
||||
safeSend, sender: {}, requestId: PRIVATE_TOKEN, restoreValue: restoreTestValue,
|
||||
});
|
||||
for (const character of PRIVATE_TOKEN) emitter.emitEvent({ type: "text-delta", textDelta: character });
|
||||
emitter.toolCall(PRIVATE_TOKEN, { command: `echo ${PRIVATE_TOKEN}` }, PRIVATE_TOKEN);
|
||||
emitter.sessionId(PRIVATE_TOKEN);
|
||||
emitter.emitDone();
|
||||
const events = calls.filter((entry) => entry.payload.event).map((entry) => entry.payload.event);
|
||||
assert.equal(events[0].textDelta, "private-original");
|
||||
assert.equal(events[1].toolName, PRIVATE_TOKEN);
|
||||
assert.equal(events[1].toolCallId, PRIVATE_TOKEN);
|
||||
assert.deepEqual(events[1].args, { command: "echo private-original" });
|
||||
assert.equal(events[2].sessionId, PRIVATE_TOKEN);
|
||||
assert.ok(calls.every((entry) => entry.payload.requestId === PRIVATE_TOKEN));
|
||||
});
|
||||
|
||||
test("privacy restoration restores activity fields and never emits an unfinished token on abort", () => {
|
||||
const { calls, safeSend } = recordingSend();
|
||||
const emitter = createStreamEmitter({
|
||||
safeSend, sender: {}, requestId: "private", restoreValue: restoreTestValue,
|
||||
});
|
||||
emitter.toolResult("tool-1", { output: PRIVATE_TOKEN }, "tool");
|
||||
emitter.fileChange("file-1", [{ path: PRIVATE_TOKEN }], "completed");
|
||||
emitter.planUpdate("plan-1", [{ text: PRIVATE_TOKEN }], "running");
|
||||
emitter.webSearch("search-1", PRIVATE_TOKEN, "completed");
|
||||
emitter.warning("warning-1", PRIVATE_TOKEN);
|
||||
emitter.text("Before [NC_aabb");
|
||||
emitter.emitError("Aborted");
|
||||
assert.doesNotMatch(JSON.stringify(calls), /\[NC_/);
|
||||
assert.equal(calls.at(-1).payload.error, "Aborted");
|
||||
assert.match(JSON.stringify(calls), /private-original/);
|
||||
});
|
||||
|
||||
test("privacy restoration fails closed on unknown complete tokens and incomplete final tokens", () => {
|
||||
const { calls, safeSend } = recordingSend();
|
||||
const emitter = createStreamEmitter({
|
||||
safeSend, sender: {}, requestId: "private", restoreValue: restoreTestValue,
|
||||
});
|
||||
assert.throws(() => emitter.text("[NC_other_SECRET_1]"), /Unknown privacy token/);
|
||||
emitter.text("[NC_incomplete");
|
||||
assert.throws(() => emitter.emitDone(), /Unknown privacy token/);
|
||||
emitter.emitError("[NC_other_SECRET_1]");
|
||||
assert.equal(calls.length, 1);
|
||||
assert.match(calls[0].payload.error, /Start a new conversation/);
|
||||
assert.doesNotMatch(JSON.stringify(calls), /\[NC_/);
|
||||
});
|
||||
|
||||
test("disabled emitter does not buffer token-looking literal text", () => {
|
||||
const { calls, safeSend } = recordingSend();
|
||||
const emitter = createStreamEmitter({ safeSend, sender: {}, requestId: "plain" });
|
||||
emitter.text("[NC_");
|
||||
assert.equal(calls[0].payload.event.textDelta, "[NC_");
|
||||
});
|
||||
@@ -548,10 +548,13 @@ async function establishGrokAcpSession(rpc, {
|
||||
toolIntegrationMode,
|
||||
systemContext,
|
||||
agentCapabilities,
|
||||
assertSessionValid,
|
||||
} = {}) {
|
||||
const methods = planGrokAcpSessionEstablish({ resumeSessionId, agentCapabilities });
|
||||
let lastError = null;
|
||||
for (const method of methods) {
|
||||
// session/new carries system context; retries must recheck as well.
|
||||
assertSessionValid?.();
|
||||
try {
|
||||
if (method === "new") {
|
||||
const created = await rpc.request(
|
||||
@@ -929,6 +932,7 @@ async function runGrokAcpTurn({
|
||||
forceKillImpl,
|
||||
// inject for tests: skip real spawn
|
||||
rpcClientFactory,
|
||||
assertSessionValid,
|
||||
}) {
|
||||
const cliPath = String(binPath || "").trim();
|
||||
if (!cliPath) {
|
||||
@@ -975,6 +979,7 @@ async function runGrokAcpTurn({
|
||||
permissionMode,
|
||||
toolIntegrationMode,
|
||||
systemContext: systemPrompt,
|
||||
assertSessionValid,
|
||||
agentCapabilities: parseGrokAcpAgentCapabilities(initResult),
|
||||
});
|
||||
state.sessionId = established.sessionId;
|
||||
@@ -987,6 +992,7 @@ async function runGrokAcpTurn({
|
||||
});
|
||||
// Only accept updates for this turn's prompt (not session/load replay).
|
||||
state.acceptUpdates = true;
|
||||
assertSessionValid?.();
|
||||
const promptResult = await client.request(
|
||||
"session/prompt",
|
||||
buildGrokAcpPromptParams(state.sessionId, effectivePrompt),
|
||||
@@ -1207,6 +1213,7 @@ async function runGrokAcpTurn({
|
||||
permissionMode,
|
||||
toolIntegrationMode,
|
||||
systemContext: systemPrompt,
|
||||
assertSessionValid,
|
||||
agentCapabilities: parseGrokAcpAgentCapabilities(initResult),
|
||||
});
|
||||
state.sessionId = established.sessionId;
|
||||
@@ -1224,6 +1231,7 @@ async function runGrokAcpTurn({
|
||||
// Await the prompt RPC only (no race with promptDone). Process death
|
||||
// rejects via rejectAll on close; success sets turnCompleted + usage in
|
||||
// handleGrokAcpMessage before resolve.
|
||||
assertSessionValid?.();
|
||||
await rpc.request(
|
||||
"session/prompt",
|
||||
buildGrokAcpPromptParams(state.sessionId, effectivePrompt),
|
||||
|
||||
@@ -736,6 +736,7 @@ async function runGrokTurn({
|
||||
mergeMcp,
|
||||
abortGraceMs = GROK_ABORT_GRACE_MS,
|
||||
forceKillImpl,
|
||||
assertSessionValid,
|
||||
}) {
|
||||
const cliPath = String(binPath || "").trim();
|
||||
if (!cliPath) {
|
||||
@@ -789,6 +790,7 @@ async function runGrokTurn({
|
||||
};
|
||||
|
||||
try {
|
||||
assertSessionValid?.();
|
||||
child = spawnGrokProcess(spawnImpl, cliPath, args, {
|
||||
cwd: effectiveCwd,
|
||||
env: childEnv,
|
||||
|
||||
@@ -45,7 +45,7 @@ const DRIVER_REGISTRY = {
|
||||
resume: ctx.resumeSessionId,
|
||||
toolIntegrationMode: ctx.toolIntegrationMode,
|
||||
});
|
||||
return claude.runClaudeTurn({ prompt: ctx.prompt, attachments: ctx.attachments, options, emitter: ctx.emitter });
|
||||
return claude.runClaudeTurn({ prompt: ctx.prompt, attachments: ctx.attachments, options, emitter: ctx.emitter, assertSessionValid: ctx.assertSessionValid });
|
||||
},
|
||||
async listModels(ctx) {
|
||||
return claude.listClaudeModels({
|
||||
@@ -68,6 +68,7 @@ const DRIVER_REGISTRY = {
|
||||
const threadOptions = codex.buildCodexThreadOptions({ cwd: ctx.cwd, model: ctx.model });
|
||||
return codex.runCodexTurn({
|
||||
prompt: ctx.prompt,
|
||||
assertSessionValid: ctx.assertSessionValid,
|
||||
attachments: ctx.attachments,
|
||||
constructorOptions,
|
||||
threadOptions,
|
||||
@@ -91,6 +92,7 @@ const DRIVER_REGISTRY = {
|
||||
});
|
||||
return copilot.runCopilotTurn({
|
||||
prompt: ctx.prompt,
|
||||
assertSessionValid: ctx.assertSessionValid,
|
||||
attachments: ctx.attachments,
|
||||
clientOptions,
|
||||
sessionOptions,
|
||||
@@ -116,6 +118,7 @@ const DRIVER_REGISTRY = {
|
||||
if (authMode === "cli-login") {
|
||||
return cursorCli.runCursorCliTurn({
|
||||
prompt: ctx.prompt,
|
||||
assertSessionValid: ctx.assertSessionValid,
|
||||
binPath: ctx.cursorCliBinPath || ctx.binPath,
|
||||
cwd: ctx.cwd,
|
||||
chatSessionId: ctx.chatSessionId,
|
||||
@@ -138,6 +141,7 @@ const DRIVER_REGISTRY = {
|
||||
});
|
||||
return cursor.runCursorTurn({
|
||||
prompt: ctx.prompt,
|
||||
assertSessionValid: ctx.assertSessionValid,
|
||||
attachments: ctx.attachments,
|
||||
agentOptions,
|
||||
runtimeEnv: ctx.env,
|
||||
@@ -251,6 +255,7 @@ const DRIVER_REGISTRY = {
|
||||
const v2Result = await codebuddySessionManager.runTurn({
|
||||
sessionKey,
|
||||
prompt: ctx.prompt,
|
||||
assertSessionValid: ctx.assertSessionValid,
|
||||
attachments: ctx.attachments,
|
||||
options,
|
||||
emitter: ctx.emitter,
|
||||
@@ -267,6 +272,7 @@ const DRIVER_REGISTRY = {
|
||||
// Fallback: legacy query() per-turn (supports all Options fields).
|
||||
return codebuddy.runCodebuddyTurn({
|
||||
prompt: ctx.prompt,
|
||||
assertSessionValid: ctx.assertSessionValid,
|
||||
attachments: ctx.attachments,
|
||||
options,
|
||||
emitter: ctx.emitter,
|
||||
@@ -282,6 +288,7 @@ const DRIVER_REGISTRY = {
|
||||
return codebuddySessionManager.steer({
|
||||
sessionKey,
|
||||
prompt: ctx.prompt,
|
||||
assertSessionValid: ctx.assertSessionValid,
|
||||
attachments: ctx.attachments,
|
||||
});
|
||||
},
|
||||
@@ -298,6 +305,7 @@ const DRIVER_REGISTRY = {
|
||||
async runTurn(ctx) {
|
||||
return opencode.runOpenCodeTurn({
|
||||
prompt: ctx.prompt,
|
||||
assertSessionValid: ctx.assertSessionValid,
|
||||
systemPrompt: ctx.systemPrompt,
|
||||
attachments: ctx.attachments,
|
||||
cwd: ctx.cwd,
|
||||
@@ -337,6 +345,7 @@ const DRIVER_REGISTRY = {
|
||||
// is handled on ACP (default runtime) via historySeed + session/new.
|
||||
return grok.runGrokTurn({
|
||||
prompt: ctx.prompt,
|
||||
assertSessionValid: ctx.assertSessionValid,
|
||||
binPath: ctx.binPath,
|
||||
cwd: ctx.cwd,
|
||||
model: ctx.model,
|
||||
@@ -351,6 +360,7 @@ const DRIVER_REGISTRY = {
|
||||
}
|
||||
return grokAcp.runGrokAcpTurn({
|
||||
prompt: ctx.prompt,
|
||||
assertSessionValid: ctx.assertSessionValid,
|
||||
systemPrompt: ctx.systemPrompt,
|
||||
binPath: ctx.binPath,
|
||||
cwd: ctx.cwd,
|
||||
@@ -431,6 +441,7 @@ const DRIVER_REGISTRY = {
|
||||
async runTurn(ctx) {
|
||||
return mimo.runMimoTurn({
|
||||
prompt: ctx.prompt,
|
||||
assertSessionValid: ctx.assertSessionValid,
|
||||
systemPrompt: ctx.systemPrompt,
|
||||
attachments: ctx.attachments,
|
||||
cwd: ctx.cwd,
|
||||
|
||||
@@ -113,3 +113,32 @@ 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");
|
||||
});
|
||||
@@ -488,7 +488,7 @@ function createMiMoEmitter(emitter) {
|
||||
async function runMimoTurn({
|
||||
prompt, systemPrompt, attachments, cwd, model, injectedMcpServers, toolIntegrationMode,
|
||||
skillsPathAllowlist, permissionMode = "confirm", chatSessionId, requestApprovalFromRenderer, clearPendingApprovals,
|
||||
resumeSessionId, env, binPath, emitter, abortController, mimoFactory, mimoConfigReader,
|
||||
resumeSessionId, env, binPath, emitter, abortController, mimoFactory, mimoConfigReader, assertSessionValid,
|
||||
}) {
|
||||
const emit = createMiMoEmitter(emitter);
|
||||
const nativeSkillOptions = { mimo: true, env: { ...process.env, ...env }, cwd };
|
||||
@@ -578,6 +578,8 @@ async function runMimoTurn({
|
||||
if (!sessionId) throw new Error("MiMo Code did not create a session");
|
||||
emit.sessionId(sessionId);
|
||||
|
||||
// Keep the guard before the event loop and prompt send (no awaits between).
|
||||
assertSessionValid?.();
|
||||
const stopEventLoopWait = createStopWait();
|
||||
const serverExitWait = createMimoExitWait(instance.server);
|
||||
const answeredPermissions = new Set();
|
||||
|
||||
@@ -554,7 +554,7 @@ function createStopWait() {
|
||||
|
||||
async function runOpenCodeV1Turn({
|
||||
prompt, systemPrompt, attachments, cwd, model, injectedMcpServers, toolIntegrationMode,
|
||||
skillsPathAllowlist, resumeSessionId, env, binPath, emitter, abortController, openCodeFactory,
|
||||
skillsPathAllowlist, resumeSessionId, env, binPath, emitter, abortController, openCodeFactory, assertSessionValid,
|
||||
}) {
|
||||
const config = buildOpenCodeConfig({ model, injectedMcpServers, toolIntegrationMode, skillsPathAllowlist });
|
||||
let opencode = null;
|
||||
@@ -596,6 +596,9 @@ async function runOpenCodeV1Turn({
|
||||
if (!sessionId) throw new Error("OpenCode did not create a session");
|
||||
emitter.sessionId(sessionId);
|
||||
|
||||
// No awaits between this guard and promptAsync below. Validate before
|
||||
// starting the background event loop so failure cannot leave it pending.
|
||||
assertSessionValid?.();
|
||||
const stopEventLoopWait = createStopWait();
|
||||
const eventLoop = (async () => {
|
||||
const iterator = events.stream?.[Symbol.asyncIterator]?.();
|
||||
|
||||
@@ -459,7 +459,7 @@ async function* iterateSse(response, signal) {
|
||||
if (!reader) throw openCodeV2UserError("ai.opencode.v2.unavailable");
|
||||
const decoder = new TextDecoder();
|
||||
const parser = createSseJsonDecoder();
|
||||
const cancelReader = () => { try { void reader.cancel(); } catch {} };
|
||||
const cancelReader = () => { try { void reader.cancel().catch(() => {}); } catch {} };
|
||||
if (signal?.aborted) cancelReader();
|
||||
else signal?.addEventListener("abort", cancelReader, { once: true });
|
||||
try {
|
||||
@@ -991,7 +991,7 @@ async function listOpenCodeV2Models(args = {}) {
|
||||
|
||||
async function runOpenCodeV2Turn({
|
||||
prompt, systemPrompt, attachments, cwd, model, injectedMcpServers, toolIntegrationMode,
|
||||
skillsPathAllowlist, resumeSessionId, env, binPath, emitter, abortController, openCodeV2Factory,
|
||||
skillsPathAllowlist, resumeSessionId, env, binPath, emitter, abortController, openCodeV2Factory, assertSessionValid,
|
||||
} = {}) {
|
||||
const signal = abortController?.signal;
|
||||
const config = buildOpenCodeConfig({ model, injectedMcpServers, toolIntegrationMode, skillsPathAllowlist });
|
||||
@@ -1033,6 +1033,7 @@ async function runOpenCodeV2Turn({
|
||||
emitter.sessionId(sessionId);
|
||||
|
||||
if (systemPrompt) {
|
||||
assertSessionValid?.();
|
||||
const wrote = await client.putInstruction(sessionId, "netcatty", String(systemPrompt), signal);
|
||||
if (!wrote.ok && emitter.warning) emitter.warning("opencode-v2-context", "ai.opencode.v2.contextSkipped");
|
||||
}
|
||||
@@ -1112,6 +1113,7 @@ async function runOpenCodeV2Turn({
|
||||
})();
|
||||
|
||||
try {
|
||||
assertSessionValid?.();
|
||||
await client.prompt(sessionId, promptBody, signal);
|
||||
} catch (error) {
|
||||
failed = true;
|
||||
|
||||
@@ -3,7 +3,8 @@ const assert = require("node:assert/strict");
|
||||
const fs = require("node:fs");
|
||||
const os = require("node:os");
|
||||
const path = require("node:path");
|
||||
const { EventEmitter } = require("node:events");
|
||||
const { EventEmitter, getEventListeners } = require("node:events");
|
||||
const { createAiPrivacyService } = require("../../../shared/aiPrivacyService.cjs");
|
||||
const {
|
||||
buildOpenCodeBasicAuthorization,
|
||||
buildOpenCodeV2ConfigContent,
|
||||
@@ -348,6 +349,30 @@ test("OpenCode 2 client hides undici transport failures", async () => {
|
||||
});
|
||||
});
|
||||
|
||||
test("OpenCode 2 event stream handles rejected reader cancellation on abort", async () => {
|
||||
const abortController = new AbortController();
|
||||
let cancelled = false;
|
||||
const client = createOpenCodeV2Client({
|
||||
baseUrl: "http://127.0.0.1:4096",
|
||||
password: "synthetic-test-password",
|
||||
fetchImpl: async () => new Response(new ReadableStream({
|
||||
start() {},
|
||||
cancel() {
|
||||
cancelled = true;
|
||||
return Promise.reject(new Error("synthetic reader cancellation failure"));
|
||||
},
|
||||
}), { status: 200, headers: { "content-type": "text/event-stream" } }),
|
||||
});
|
||||
const stream = await client.openEventStream(abortController.signal);
|
||||
const next = stream.next();
|
||||
abortController.abort();
|
||||
assert.deepEqual(await next, { value: undefined, done: true });
|
||||
assert.equal(cancelled, true);
|
||||
assert.equal(getEventListeners(abortController.signal, "abort").length, 0);
|
||||
// Let node:test observe any unhandled cancellation rejection.
|
||||
await new Promise((resolve) => setImmediate(resolve));
|
||||
});
|
||||
|
||||
test("listCatalog omits the location query when no directory is set", async () => {
|
||||
const urls = [];
|
||||
const client = createOpenCodeV2Client({
|
||||
@@ -609,6 +634,88 @@ test("runOpenCodeV2Turn posts the prompt only after the event stream is open", a
|
||||
assert.deepEqual(events, ["Hi", "done"]);
|
||||
});
|
||||
|
||||
for (const pausedAt of ["server startup", "session creation", "event stream opening"]) {
|
||||
test(`runOpenCodeTurn v2 rechecks privacy after paused ${pausedAt}`, { timeout: 2000 }, async () => {
|
||||
const privacy = createAiPrivacyService();
|
||||
privacy.checkSession("chat");
|
||||
const abortController = new AbortController();
|
||||
const sends = [];
|
||||
const errors = [];
|
||||
let closed = false;
|
||||
let streamCancelled = false;
|
||||
let entered;
|
||||
let release;
|
||||
const ready = new Promise((resolve) => { entered = resolve; });
|
||||
const waiting = new Promise((resolve) => { release = resolve; });
|
||||
async function pause(stage) {
|
||||
if (stage !== pausedAt) return;
|
||||
entered();
|
||||
await waiting;
|
||||
}
|
||||
const fetchImpl = async (url, init) => {
|
||||
const pathname = new URL(url).pathname;
|
||||
if (pathname === "/api/session") {
|
||||
await pause("session creation");
|
||||
return jsonResponse({ data: { id: "ses_1" } });
|
||||
}
|
||||
if (pathname.includes("/instructions/")) {
|
||||
sends.push(["instruction", JSON.parse(init.body).value]);
|
||||
return new Response(null, { status: 204 });
|
||||
}
|
||||
if (pathname === "/api/event") {
|
||||
await pause("event stream opening");
|
||||
return new Response(new ReadableStream({
|
||||
start() {},
|
||||
cancel() { streamCancelled = true; },
|
||||
}), { status: 200, headers: { "content-type": "text/event-stream" } });
|
||||
}
|
||||
if (pathname.endsWith("/prompt")) {
|
||||
sends.push(["prompt", JSON.parse(init.body).text]);
|
||||
// Keep the unguarded regression finite without an execution event.
|
||||
return jsonResponse({ message: "unexpected prompt" }, 400);
|
||||
}
|
||||
throw new Error(`Unexpected request: ${pathname}`);
|
||||
};
|
||||
const running = runOpenCodeTurn({
|
||||
prompt: "synthetic raw prompt",
|
||||
systemPrompt: "synthetic raw context",
|
||||
abortController,
|
||||
assertSessionValid: () => privacy.checkSession("chat"),
|
||||
versionProbe: async () => ({ major: 2, minor: 1, patch: 0 }),
|
||||
emitter: {
|
||||
sessionId() {},
|
||||
emitDone() { assert.fail("a retired chat must not complete"); },
|
||||
emitError: (message) => errors.push(message),
|
||||
},
|
||||
openCodeV2Factory: async () => {
|
||||
await pause("server startup");
|
||||
return {
|
||||
client: createOpenCodeV2Client({
|
||||
baseUrl: "http://127.0.0.1:4096",
|
||||
password: "synthetic-test-password",
|
||||
fetchImpl,
|
||||
}),
|
||||
server: { close() { closed = true; } },
|
||||
};
|
||||
},
|
||||
});
|
||||
await ready;
|
||||
privacy.configure({ enabled: true });
|
||||
release();
|
||||
await running;
|
||||
|
||||
assert.deepEqual(sends, pausedAt === "event stream opening"
|
||||
? [["instruction", "synthetic raw context"]]
|
||||
: []);
|
||||
assert.equal(errors.length, 1);
|
||||
assert.match(errors[0], /start a new chat/);
|
||||
assert.equal(closed, true);
|
||||
assert.equal(streamCancelled, pausedAt === "event stream opening");
|
||||
assert.equal(abortController.signal.aborted, false);
|
||||
assert.equal(getEventListeners(abortController.signal, "abort").length, 0);
|
||||
});
|
||||
}
|
||||
|
||||
test("runOpenCodeV2Turn reports a rejected prompt without waiting on the event stream", { timeout: 2000 }, async () => {
|
||||
let releaseEvent;
|
||||
const eventOpen = new Promise((resolve) => { releaseEvent = resolve; });
|
||||
|
||||
@@ -0,0 +1,188 @@
|
||||
"use strict";
|
||||
|
||||
const test = require("node:test");
|
||||
const assert = require("node:assert/strict");
|
||||
const { createAiPrivacyService } = require("../../../shared/aiPrivacyService.cjs");
|
||||
|
||||
function fixture() {
|
||||
const privacy = createAiPrivacyService();
|
||||
privacy.checkSession("chat");
|
||||
const errors = [];
|
||||
const sends = [];
|
||||
const emitter = new Proxy({ sessionId() {}, emitError: (error) => errors.push(error) }, {
|
||||
get: (target, key) => target[key] || (() => {}),
|
||||
});
|
||||
return {
|
||||
privacy, errors, sends, emitter,
|
||||
assertSessionValid: () => privacy.checkSession("chat"),
|
||||
enable: () => privacy.configure({ enabled: true }),
|
||||
assertBlocked() {
|
||||
assert.deepEqual(sends, []);
|
||||
assert.match(errors.join("\n"), /start a new chat/);
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
function gate() {
|
||||
let entered;
|
||||
let release;
|
||||
const ready = new Promise((resolve) => { entered = resolve; });
|
||||
const waiting = new Promise((resolve) => { release = resolve; });
|
||||
return { ready, release, async wait() { entered(); await waiting; } };
|
||||
}
|
||||
|
||||
async function toggleDuringStartup(f, pause, run) {
|
||||
await pause.ready;
|
||||
f.enable();
|
||||
pause.release();
|
||||
await run;
|
||||
f.assertBlocked();
|
||||
}
|
||||
|
||||
test("Codex SDK rechecks after runStreamed before starting its lazy outbound iterator", async () => {
|
||||
const { runCodexTurn } = require("./codexDriver.cjs");
|
||||
const f = fixture();
|
||||
const pause = gate();
|
||||
class Codex {
|
||||
startThread() {
|
||||
return {
|
||||
async runStreamed(prompt) {
|
||||
await pause.wait();
|
||||
return { events: (async function* () { f.sends.push(prompt); yield {}; })() };
|
||||
},
|
||||
};
|
||||
}
|
||||
}
|
||||
await toggleDuringStartup(f, pause, runCodexTurn({
|
||||
...f, prompt: "raw-secret", CodexCtor: Codex,
|
||||
}));
|
||||
});
|
||||
|
||||
test("Copilot rechecks after paused session startup before sendAndWait", async () => {
|
||||
const { runCopilotTurn } = require("./copilotDriver.cjs");
|
||||
const f = fixture();
|
||||
const pause = gate();
|
||||
class CopilotClient {
|
||||
async createSession() {
|
||||
await pause.wait();
|
||||
return { sessionId: "native", sendAndWait: (input) => { f.sends.push(input); } };
|
||||
}
|
||||
async stop() {}
|
||||
}
|
||||
await toggleDuringStartup(f, pause, runCopilotTurn({
|
||||
...f, prompt: "raw-secret", sdkModule: { CopilotClient },
|
||||
}));
|
||||
});
|
||||
|
||||
test("Cursor rechecks inside the environment lock after paused Agent.create", async () => {
|
||||
const { runCursorTurnInProcess } = require("./cursorDriver.cjs");
|
||||
const f = fixture();
|
||||
const pause = gate();
|
||||
const Agent = {
|
||||
async create() {
|
||||
await pause.wait();
|
||||
return { agentId: "native", send: (input) => { f.sends.push(input); }, close() {} };
|
||||
},
|
||||
};
|
||||
await toggleDuringStartup(f, pause, runCursorTurnInProcess({
|
||||
...f, prompt: "raw-secret", sdkModule: { Agent }, agentOptions: {},
|
||||
}));
|
||||
});
|
||||
|
||||
test("CodeBuddy validates prompt-bearing session options after asynchronous SDK loading", async () => {
|
||||
const { CodebuddySessionManager } = require("./codebuddySessionManager.cjs");
|
||||
const f = fixture();
|
||||
const pause = gate();
|
||||
const manager = new CodebuddySessionManager({
|
||||
loadSdk: async () => {
|
||||
await pause.wait();
|
||||
return {
|
||||
unstable_v2_createSession: (input) => { f.sends.push(input); },
|
||||
unstable_v2_resumeSession() {},
|
||||
};
|
||||
},
|
||||
});
|
||||
const run = manager.runTurn({ ...f, sessionKey: "chat", prompt: "raw-secret", options: {}, sessionOptions: { systemPrompt: "raw-secret" } });
|
||||
const rejected = assert.rejects(run, /start a new chat/);
|
||||
await pause.ready;
|
||||
f.enable();
|
||||
pause.release();
|
||||
await rejected;
|
||||
assert.deepEqual(f.sends, []);
|
||||
});
|
||||
|
||||
test("CodeBuddy rechecks before sending into a session obtained asynchronously", async () => {
|
||||
const { CodebuddySessionManager } = require("./codebuddySessionManager.cjs");
|
||||
const f = fixture();
|
||||
const pause = gate();
|
||||
const manager = new CodebuddySessionManager();
|
||||
manager.getOrCreateSession = async () => {
|
||||
await pause.wait();
|
||||
return { sessionId: "native", send: (input) => { f.sends.push(input); } };
|
||||
};
|
||||
await toggleDuringStartup(f, pause, manager.runTurn({
|
||||
...f, sessionKey: "chat", prompt: "raw-secret", options: {}, sessionOptions: {},
|
||||
}));
|
||||
});
|
||||
|
||||
for (const backend of ["opencode", "mimo"]) {
|
||||
test(`${backend} rechecks after paused session creation before promptAsync`, async () => {
|
||||
const runTurn = backend === "opencode"
|
||||
? require("./opencodeDriver.cjs").runOpenCodeTurn
|
||||
: require("./mimoDriver.cjs").runMimoTurn;
|
||||
const f = fixture();
|
||||
const pause = gate();
|
||||
let closed = false;
|
||||
const factory = async () => ({
|
||||
client: {
|
||||
global: { event: async () => ({ stream: (async function* () { yield { type: "server.connected" }; })() }) },
|
||||
session: {
|
||||
create: async () => { await pause.wait(); return { id: "native" }; },
|
||||
promptAsync: (input) => { f.sends.push(input); return Promise.resolve({}); },
|
||||
},
|
||||
},
|
||||
server: { close() { closed = true; } },
|
||||
});
|
||||
await toggleDuringStartup(f, pause, runTurn({
|
||||
...f, prompt: "raw-secret", systemPrompt: "raw-system",
|
||||
[backend === "opencode" ? "openCodeFactory" : "mimoFactory"]: factory,
|
||||
}));
|
||||
assert.equal(closed, true);
|
||||
});
|
||||
}
|
||||
|
||||
for (const pausedMethod of ["initialize", "session/new"]) {
|
||||
test(`Grok ACP validates after paused ${pausedMethod} before releasing semantic content`, async () => {
|
||||
const { runGrokAcpTurn } = require("./grokAcpDriver.cjs");
|
||||
const f = fixture();
|
||||
const pause = gate();
|
||||
const run = runGrokAcpTurn({
|
||||
...f, prompt: "raw-secret", systemPrompt: "raw-system", binPath: "/bin/grok",
|
||||
rpcClientFactory: () => ({
|
||||
async request(method, params) {
|
||||
if (method === pausedMethod) await pause.wait();
|
||||
else if (method === "session/new" || method === "session/prompt") f.sends.push({ method, params });
|
||||
return method === "initialize" ? {} : { sessionId: "native" };
|
||||
},
|
||||
}),
|
||||
});
|
||||
await toggleDuringStartup(f, pause, run);
|
||||
});
|
||||
}
|
||||
|
||||
test("query and argv drivers reject invalid sessions at their outbound handoff", async (t) => {
|
||||
const cases = [
|
||||
["claude", (f) => require("./claudeDriver.cjs").runClaudeTurn({ ...f, options: {}, queryFn: (input) => { f.sends.push(input); } })],
|
||||
["codebuddy", (f) => require("./codebuddyDriver.cjs").runCodebuddyTurn({ ...f, options: {}, queryFn: (input) => { f.sends.push(input); } })],
|
||||
["cursor-cli", (f) => require("./cursorCliDriver.cjs").runCursorCliTurn({ ...f, binPath: "/bin/cursor", workspaceCwd: "/tmp", spawnImpl: (...input) => { f.sends.push(input); } })],
|
||||
["grok-cli", (f) => require("./grokDriver.cjs").runGrokTurn({ ...f, binPath: "/bin/grok", spawnImpl: (...input) => { f.sends.push(input); } })],
|
||||
];
|
||||
for (const [backend, run] of cases) {
|
||||
await t.test(backend, async () => {
|
||||
const f = fixture();
|
||||
f.enable();
|
||||
await run({ ...f, prompt: "raw-secret" });
|
||||
f.assertBlocked();
|
||||
});
|
||||
}
|
||||
});
|
||||
@@ -602,7 +602,58 @@ function shouldReplaySdkHistory({
|
||||
return !hasInMemorySession;
|
||||
}
|
||||
|
||||
const PRIVATE_RESUME_ERROR = "AI privacy cannot safely resume this agent session. Start a new conversation.";
|
||||
const PRIVATE_ATTACHMENT_ERROR = "AI privacy does not support direct agent attachments. Remove the attachment before continuing.";
|
||||
|
||||
function assertPrivateAttachments(attachments, privacyEnabled) {
|
||||
// Every staged file can also be read directly by the agent runtime. Even a
|
||||
// text MIME label does not make its raw base64/file payload a protected path.
|
||||
if (privacyEnabled && Array.isArray(attachments) && attachments.length > 0) {
|
||||
throw new Error(PRIVATE_ATTACHMENT_ERROR);
|
||||
}
|
||||
}
|
||||
|
||||
/** Batch discovery while preserving original schema/question identifiers.
|
||||
*
|
||||
* Each scalar carries its complete key ancestry so credentials.value remains
|
||||
* secret. All scalars are inspected together, including earlier unlabelled
|
||||
* copies of a later password field. The redacted wrappers are discarded:
|
||||
* original routing keys never have to cross the content transformation.
|
||||
*/
|
||||
function redactSdkAnswerContent(privacyService, chatSessionId, value) {
|
||||
const content = [];
|
||||
let nodes = 0;
|
||||
const prepare = (entry, keys, depth = 0) => {
|
||||
if (++nodes > 100000 || depth > 100) {
|
||||
throw new Error("AI privacy could not safely process this value");
|
||||
}
|
||||
if (Array.isArray(entry)) {
|
||||
const children = entry.map((item) => prepare(item, keys, depth + 1));
|
||||
return (protectedContent) => children.map((rebuild) => rebuild(protectedContent));
|
||||
}
|
||||
if (entry && typeof entry === "object") {
|
||||
const children = Object.entries(entry).map(([key, item]) => [key, prepare(item, [...keys, key], depth + 1)]);
|
||||
return (protectedContent) => Object.fromEntries(children.map(([key, rebuild]) => [key, rebuild(protectedContent)]));
|
||||
}
|
||||
if (entry === undefined) return () => undefined;
|
||||
const index = content.length;
|
||||
content.push(keys.reduceRight((nested, key) => ({ [key]: nested }), entry));
|
||||
return (protectedContent) => {
|
||||
let result = protectedContent[index];
|
||||
// Every contextual wrapper has exactly one field. Its possibly masked
|
||||
// name is irrelevant; this avoids numeric-key reordering ambiguity too.
|
||||
for (let depth = 0; depth < keys.length; depth += 1) {
|
||||
result = Object.values(result)[0];
|
||||
}
|
||||
return result;
|
||||
};
|
||||
};
|
||||
const rebuild = prepare(value, []);
|
||||
return rebuild(privacyService.redact(chatSessionId, content));
|
||||
}
|
||||
|
||||
function registerSdkStreamHandlers(ctx) {
|
||||
const privacyService = ctx.aiPrivacyService || require("../../../shared/aiPrivacyService.cjs");
|
||||
const injectCliEnv = typeof ctx.withCliDiscoveryEnv === "function"
|
||||
? ctx.withCliDiscoveryEnv
|
||||
: (env) => env;
|
||||
@@ -612,12 +663,40 @@ function registerSdkStreamHandlers(ctx) {
|
||||
const sdkRequestSessions = new Map(); // requestId -> chatSessionId
|
||||
const sdkRequestRuntimes = new Map(); // requestId -> { backendKey, codexRuntime }
|
||||
const sdkSessionIds = new Map(); // chatSessionId -> last sessionId
|
||||
// Native histories outlive the app, but the private reverse map does not.
|
||||
// Only native IDs observed in this process under the current chat mapping
|
||||
// are eligible for a protected resume.
|
||||
const sdkPrivateSessionIds = new Map();
|
||||
const codexAppServerRuntime = new CodexAppServerRuntime({
|
||||
appVersion: electronModule?.app?.getVersion?.() || "0.0.0",
|
||||
transformUserInputAnswers(answers, context) {
|
||||
privacyService.checkSession(context.chatSessionId);
|
||||
return redactSdkAnswerContent(privacyService, context.chatSessionId, answers);
|
||||
},
|
||||
sendInteractionRequest(payload, context) {
|
||||
const sender = context?.sender;
|
||||
if (!sender || sender.isDestroyed?.()) return false;
|
||||
safeSend(sender, "netcatty:ai:codex-app-server:interaction-request", payload);
|
||||
// Native approval arguments must remain exact: accepting them cannot
|
||||
// replace the command/path that Codex will actually execute.
|
||||
const displayed = { ...payload };
|
||||
if (privacyService.checkSession(context.chatSessionId)) {
|
||||
const restore = (value) => privacyService.restore(context.chatSessionId, value);
|
||||
if (Array.isArray(displayed.questions)) {
|
||||
displayed.questions = displayed.questions.map((question) => ({
|
||||
...question,
|
||||
...(typeof question.question === "string" ? { question: restore(question.question) } : {}),
|
||||
...(typeof question.header === "string" ? { header: restore(question.header) } : {}),
|
||||
...(Array.isArray(question.options) ? {
|
||||
options: question.options.map((option) => ({
|
||||
...option,
|
||||
...(typeof option.label === "string" ? { label: restore(option.label) } : {}),
|
||||
...(typeof option.description === "string" ? { description: restore(option.description) } : {}),
|
||||
})),
|
||||
} : {}),
|
||||
}));
|
||||
}
|
||||
}
|
||||
safeSend(sender, "netcatty:ai:codex-app-server:interaction-request", displayed);
|
||||
return true;
|
||||
},
|
||||
sendInteractionCleared(payload, context) {
|
||||
@@ -655,8 +734,17 @@ function registerSdkStreamHandlers(ctx) {
|
||||
sdkRequestSessions.set(requestId, chatSessionId);
|
||||
mcpServerBridge.setChatSessionCancelled?.(chatSessionId, false);
|
||||
|
||||
const emitter = createStreamEmitter({ safeSend, sender: event.sender, requestId });
|
||||
let privacyEnabled = false;
|
||||
let emitter = createStreamEmitter({ safeSend, sender: event.sender, requestId });
|
||||
try {
|
||||
privacyEnabled = privacyService.checkSession(chatSessionId);
|
||||
if (privacyEnabled) {
|
||||
emitter = createStreamEmitter({
|
||||
safeSend, sender: event.sender, requestId,
|
||||
restoreValue: (value) => privacyService.restore(chatSessionId, value),
|
||||
});
|
||||
}
|
||||
assertPrivateAttachments(payload?.images, privacyEnabled);
|
||||
const shellEnv = await getShellEnv();
|
||||
const effectiveMode = normalizeToolIntegrationMode(toolIntegrationMode);
|
||||
setToolIntegrationMode(effectiveMode);
|
||||
@@ -799,6 +887,10 @@ function registerSdkStreamHandlers(ctx) {
|
||||
cliMode: cursorCliMode,
|
||||
hasConfiguredCommand,
|
||||
});
|
||||
if (privacyEnabled && resumeSessionId
|
||||
&& sdkPrivateSessionIds.get(sdkSessionKey) !== resumeSessionId) {
|
||||
throw new Error(PRIVATE_RESUME_ERROR);
|
||||
}
|
||||
const stagedAttachments = [];
|
||||
const replayHistory = shouldReplaySdkHistory({
|
||||
backendKey,
|
||||
@@ -840,6 +932,10 @@ function registerSdkStreamHandlers(ctx) {
|
||||
...emitter,
|
||||
sessionId(sessionId) {
|
||||
if (sessionId) {
|
||||
// Record before turn completion so Stop cannot lose the native
|
||||
// ID or its private-history provenance.
|
||||
sdkSessionIds.set(sdkSessionKey, sessionId);
|
||||
if (privacyEnabled) sdkPrivateSessionIds.set(sdkSessionKey, sessionId);
|
||||
emitter.emitEvent({
|
||||
type: "session-id",
|
||||
sessionId,
|
||||
@@ -874,9 +970,41 @@ function registerSdkStreamHandlers(ctx) {
|
||||
contextualPrompt,
|
||||
systemContext,
|
||||
});
|
||||
// Environment/MCP initialization awaited above. A privacy toggle
|
||||
// during that work must not send a prompt under the old policy.
|
||||
privacyService.checkSession(chatSessionId);
|
||||
let protectedHistorySeed = historySeed;
|
||||
let protectedAgents = agents;
|
||||
if (privacyEnabled) {
|
||||
// Discover secrets across every semantic prompt surface before
|
||||
// masking any of them. A password defined only in the system
|
||||
// context must also protect its earlier bare copy in user text.
|
||||
const semanticText = [promptPlacement.prompt, promptPlacement.systemPrompt || "", historySeed];
|
||||
const agentFields = [];
|
||||
if (agents && typeof agents === "object" && !Array.isArray(agents)) {
|
||||
protectedAgents = Object.fromEntries(Object.entries(agents).map(([name, definition]) => {
|
||||
if (!definition || typeof definition !== "object") return [name, definition];
|
||||
const protectedDefinition = { ...definition };
|
||||
for (const field of ["prompt", "description"]) {
|
||||
if (typeof definition[field] !== "string") continue;
|
||||
agentFields.push({ target: protectedDefinition, field, index: semanticText.length });
|
||||
semanticText.push(definition[field]);
|
||||
}
|
||||
return [name, protectedDefinition];
|
||||
}));
|
||||
}
|
||||
const protectedText = privacyService.redact(chatSessionId, semanticText);
|
||||
promptPlacement.prompt = protectedText[0];
|
||||
if (promptPlacement.systemPrompt !== undefined) promptPlacement.systemPrompt = protectedText[1];
|
||||
protectedHistorySeed = protectedText[2];
|
||||
for (const { target, field, index } of agentFields) target[field] = protectedText[index];
|
||||
}
|
||||
const commonTurnContext = {
|
||||
requestId,
|
||||
chatSessionId,
|
||||
// Drivers recheck after their own asynchronous startup, at the
|
||||
// final Netcatty-owned prompt handoff (including disabled turns).
|
||||
assertSessionValid: () => privacyService.checkSession(chatSessionId),
|
||||
...promptPlacement,
|
||||
cwd: cwd || process.cwd(),
|
||||
model: model || undefined,
|
||||
@@ -886,7 +1014,7 @@ function registerSdkStreamHandlers(ctx) {
|
||||
cursorAuthMode: backendKey === "cursor" ? cursorAuthMode : undefined,
|
||||
cursorCliBinPath: backendKey === "cursor" ? cursorCliBinPath : undefined,
|
||||
grokRuntime: backendKey === "grok" ? grokRuntime : undefined,
|
||||
historySeed: backendKey === "grok" ? historySeed : undefined,
|
||||
historySeed: backendKey === "grok" ? protectedHistorySeed : undefined,
|
||||
getTempDir: () => tempDirBridge.getTempDir(),
|
||||
injectedMcpServers,
|
||||
claudeSettings,
|
||||
@@ -913,7 +1041,7 @@ function registerSdkStreamHandlers(ctx) {
|
||||
maxBudgetUsd: maxBudgetUsd || undefined,
|
||||
fallbackModel: fallbackModel || undefined,
|
||||
sandbox: sandbox || undefined,
|
||||
agents: agents || undefined,
|
||||
agents: protectedAgents || undefined,
|
||||
outputFormat: outputFormat || undefined,
|
||||
enableFileCheckpointing: enableFileCheckpointing != null ? enableFileCheckpointing : undefined,
|
||||
traceId: traceId || undefined,
|
||||
@@ -926,7 +1054,10 @@ function registerSdkStreamHandlers(ctx) {
|
||||
|
||||
// Persist any new session id for resume on the next turn.
|
||||
const newSessionId = result?.sessionId || result?.threadId;
|
||||
if (newSessionId) sdkSessionIds.set(sdkSessionKey, newSessionId);
|
||||
if (newSessionId) {
|
||||
sdkSessionIds.set(sdkSessionKey, newSessionId);
|
||||
if (privacyEnabled) sdkPrivateSessionIds.set(sdkSessionKey, newSessionId);
|
||||
}
|
||||
|
||||
return { ok: true };
|
||||
} catch (err) {
|
||||
@@ -1188,6 +1319,13 @@ function registerSdkStreamHandlers(ctx) {
|
||||
return { status: "unsupported" };
|
||||
}
|
||||
|
||||
let privacyEnabled;
|
||||
try {
|
||||
privacyEnabled = privacyService.checkSession(chatSessionId);
|
||||
assertPrivateAttachments(payload?.images, privacyEnabled);
|
||||
} catch (error) {
|
||||
return { status: "failed", message: error.message };
|
||||
}
|
||||
const stagedAttachments = [];
|
||||
const steerPrompt = buildSdkTurnPrompt({
|
||||
prompt,
|
||||
@@ -1197,9 +1335,15 @@ function registerSdkStreamHandlers(ctx) {
|
||||
onStagedAttachment: (attachment) => stagedAttachments.push(attachment),
|
||||
});
|
||||
mcpServerBridge.updateAttachmentMetadata?.(stagedAttachments, chatSessionId);
|
||||
let protectedSteerPrompt;
|
||||
try {
|
||||
protectedSteerPrompt = privacyEnabled ? privacyService.redact(chatSessionId, steerPrompt) : steerPrompt;
|
||||
} catch (error) {
|
||||
return { status: "failed", message: error.message };
|
||||
}
|
||||
const result = await codexAppServerRuntime.steerTurn(requestId, {
|
||||
chatSessionId,
|
||||
prompt: steerPrompt,
|
||||
prompt: protectedSteerPrompt,
|
||||
attachments: stagedAttachments,
|
||||
clientUserMessageId,
|
||||
});
|
||||
@@ -1244,6 +1388,10 @@ function registerSdkStreamHandlers(ctx) {
|
||||
sdkRequestRuntimes.delete(requestId);
|
||||
}
|
||||
deleteSdkSessionKeysForChat(sdkSessionIds, chatSessionId);
|
||||
// This also runs when sync replaces runtime state for a retained chat.
|
||||
// Keep privacy mappings and native-history provenance until explicit chat
|
||||
// deletion clears the shared privacy service. Its tombstone then prevents
|
||||
// any stale native ID from being reused.
|
||||
codebuddySessionManager.closeForChat(chatSessionId);
|
||||
await codexAppServerRuntime.cleanupChatSession(chatSessionId);
|
||||
await mcpServerBridge.cleanupScopedMetadata(chatSessionId);
|
||||
@@ -1252,8 +1400,12 @@ function registerSdkStreamHandlers(ctx) {
|
||||
|
||||
ipcMain.handle("netcatty:ai:codex-app-server:interaction-response", async (event, payload) => {
|
||||
if (!validateSender(event)) return { ok: false, error: "Unauthorized IPC sender" };
|
||||
const ok = codexAppServerRuntime.respondInteraction(payload?.interactionId, payload, event.sender);
|
||||
return ok ? { ok: true } : { ok: false, error: "Interaction not found" };
|
||||
try {
|
||||
const ok = codexAppServerRuntime.respondInteraction(payload?.interactionId, payload, event.sender);
|
||||
return ok ? { ok: true } : { ok: false, error: "Interaction not found" };
|
||||
} catch (error) {
|
||||
return { ok: false, error: error.message };
|
||||
}
|
||||
});
|
||||
|
||||
ipcMain.handle("netcatty:ai:codex-app-server:interaction-cancel-timeout", async (event, payload) => {
|
||||
@@ -1349,9 +1501,20 @@ function registerSdkStreamHandlers(ctx) {
|
||||
if (!validateSender(event)) return { ok: false, error: "Unauthorized IPC sender" };
|
||||
const { elicitationId, action, content } = payload || {};
|
||||
if (!elicitationId) return { ok: false, error: "Missing elicitationId" };
|
||||
const pending = codebuddySessionManager.elicitationPending.get(elicitationId);
|
||||
if (!pending) return { ok: false, error: "Elicitation not found or already resolved" };
|
||||
let protectedContent = content;
|
||||
if (content !== undefined && content !== null) {
|
||||
try {
|
||||
privacyService.checkSession(pending.chatSessionId);
|
||||
protectedContent = redactSdkAnswerContent(privacyService, pending.chatSessionId, content);
|
||||
} catch (error) {
|
||||
return { ok: false, error: error.message };
|
||||
}
|
||||
}
|
||||
const resolved = codebuddySessionManager.resolveElicitation(elicitationId, {
|
||||
action: action || "cancel",
|
||||
content: content || undefined,
|
||||
content: protectedContent || undefined,
|
||||
});
|
||||
return resolved ? { ok: true } : { ok: false, error: "Elicitation not found or already resolved" };
|
||||
});
|
||||
|
||||
@@ -32,6 +32,13 @@ function registerWithStubbedCtx(overrides = {}) {
|
||||
ipcMain: { handle: (channel, fn) => handlers.set(channel, fn) },
|
||||
electronModule: undefined,
|
||||
validateSender: () => true,
|
||||
aiPrivacyService: {
|
||||
isEnabled: () => false,
|
||||
checkSession: () => false,
|
||||
redact: (_chatId, value) => value,
|
||||
restore: (_chatId, value) => value,
|
||||
clearSession: () => {},
|
||||
},
|
||||
mcpServerBridge: {
|
||||
setChatSessionCancelled: () => {},
|
||||
cancelPtyExecsForSession: () => {},
|
||||
@@ -44,6 +51,442 @@ function registerWithStubbedCtx(overrides = {}) {
|
||||
return { handlers, ctx };
|
||||
}
|
||||
|
||||
function createTestPrivacy(enabled = true) {
|
||||
const sessions = new Map();
|
||||
const cleared = new Set();
|
||||
const checkSession = (chatId) => {
|
||||
if (cleared.has(chatId)) throw new Error("AI privacy session was cleared. Start a new conversation.");
|
||||
const prior = sessions.get(chatId);
|
||||
if (prior && prior.enabled !== enabled) throw new Error("AI privacy settings changed. Start a new conversation.");
|
||||
if (!prior) sessions.set(chatId, { enabled, values: new Map(), tokens: new Map() });
|
||||
return enabled;
|
||||
};
|
||||
const mapValues = (value, fn) => {
|
||||
if (typeof value === "string") return fn(value);
|
||||
if (Array.isArray(value)) return value.map((entry) => mapValues(entry, fn));
|
||||
if (value && typeof value === "object") {
|
||||
return Object.fromEntries(Object.entries(value).map(([key, entry]) => [key, mapValues(entry, fn)]));
|
||||
}
|
||||
return value;
|
||||
};
|
||||
return {
|
||||
isEnabled: () => enabled,
|
||||
hasSession: (chatId) => sessions.has(chatId),
|
||||
setEnabled: (value) => { enabled = value; },
|
||||
checkSession,
|
||||
clearSession(chatId) { sessions.delete(chatId); cleared.add(chatId); },
|
||||
redact(chatId, value) {
|
||||
if (!checkSession(chatId)) return value;
|
||||
const state = sessions.get(chatId);
|
||||
return mapValues(value, (text) => text.replace(/secret-[a-z]+/g, (original) => {
|
||||
if (!state.values.has(original)) {
|
||||
const token = `[NC_${chatId}_SECRET_${state.values.size + 1}]`;
|
||||
state.values.set(original, token);
|
||||
state.tokens.set(token, original);
|
||||
}
|
||||
return state.values.get(original);
|
||||
}));
|
||||
},
|
||||
restore(chatId, value) {
|
||||
if (!checkSession(chatId)) return value;
|
||||
const state = sessions.get(chatId);
|
||||
return mapValues(value, (text) => text.replace(/\[NC_[^\]]*(?:\]|$)/g, (token) => {
|
||||
if (!state.tokens.has(token)) throw new Error("Unknown AI privacy token.");
|
||||
return state.tokens.get(token);
|
||||
}));
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
function createPrivacyStreamHarness(privacy = createTestPrivacy(), runTurn) {
|
||||
const turns = [];
|
||||
const messages = [];
|
||||
const driver = {
|
||||
async runTurn(context) {
|
||||
turns.push(context);
|
||||
if (runTurn) return runTurn(context);
|
||||
context.emitter.sessionId("native-1");
|
||||
context.emitter.text(context.prompt);
|
||||
context.emitter.emitDone();
|
||||
return { sessionId: "native-1" };
|
||||
},
|
||||
};
|
||||
const registered = registerWithStubbedCtx({
|
||||
aiPrivacyService: privacy,
|
||||
safeSend: (_sender, channel, payload) => messages.push({ channel, payload }),
|
||||
getShellEnv: async () => ({}),
|
||||
normalizeToolIntegrationMode: (mode) => mode || "mcp",
|
||||
setToolIntegrationMode: () => {},
|
||||
normalizeAgentEnv: (env) => ({ ...env }),
|
||||
normalizeClaudeCodeExecutableEnvForSdk: (env) => env,
|
||||
resolveCliFromPath: (backend) => `/bin/${backend}`,
|
||||
normalizeCliPathForPlatform: (value) => value,
|
||||
resolveSdkBinPath: (backend) => `/bin/${backend}`,
|
||||
resolveClaudeCodeExecutableForSdk: (value) => value,
|
||||
resolveCodexExecutableForSdk: (value) => value,
|
||||
resolveCodebuddyExecutableForSdk: (value) => value,
|
||||
addCodexExecutableEnvForSdk: (env) => env,
|
||||
buildExternalAgentSystemContext: ({ userSkillsContext, defaultTargetSession }) => (
|
||||
`System ${userSkillsContext || ""} ${defaultTargetSession?.hostname || ""}`
|
||||
),
|
||||
buildExternalAgentContextualPrompt: ({ prompt, userSkillsContext, defaultTargetSession }) => (
|
||||
`System ${userSkillsContext || ""} ${defaultTargetSession?.hostname || ""}\n\n${prompt}`
|
||||
),
|
||||
getDriver: () => driver,
|
||||
mcpServerBridge: {
|
||||
setChatSessionCancelled: () => {},
|
||||
cancelPtyExecsForSession: () => {},
|
||||
cancelWorkerBackgroundJobsForSession: () => {},
|
||||
clearPendingApprovals: () => {},
|
||||
cleanupScopedMetadata: async () => {},
|
||||
getOrCreateHost: async () => 1234,
|
||||
getScopedSessionIds: () => ["terminal-1"],
|
||||
buildMcpServerConfig: (_port, _ids, chatId) => ({
|
||||
name: "netcatty", command: "/bin/node", env: [{ name: "CHAT_ID", value: chatId }],
|
||||
}),
|
||||
updateAttachmentMetadata: () => {},
|
||||
},
|
||||
});
|
||||
const stream = (overrides = {}) => registered.handlers.get("netcatty:ai:sdk-agent:stream")(
|
||||
{ sender: {} },
|
||||
{
|
||||
requestId: `request-${turns.length + 1}`,
|
||||
chatSessionId: "chat-1",
|
||||
sdkBackend: "claude",
|
||||
prompt: "Use secret-user",
|
||||
cwd: "/secret-runtime",
|
||||
agentEnv: { RUNTIME_AUTH: "secret-auth" },
|
||||
userSkillsContext: "secret-skill",
|
||||
defaultTargetSession: { hostname: "secret-host" },
|
||||
historyMessages: [{ role: "user", content: "Previous secret-history" }],
|
||||
...overrides,
|
||||
},
|
||||
);
|
||||
return { ...registered, stream, turns, messages, privacy };
|
||||
}
|
||||
|
||||
test("SDK privacy protects assembled prompt/history/system content across drivers without touching runtime routing", async () => {
|
||||
for (const backend of ["claude", "codex", "copilot", "cursor", "codebuddy", "opencode", "grok", "mimo"]) {
|
||||
const h = createPrivacyStreamHarness();
|
||||
const result = await h.stream({ sdkBackend: backend });
|
||||
assert.equal(result.ok, true, `${backend}: ${result.error}`);
|
||||
assert.equal(h.turns.length, 1);
|
||||
const turn = h.turns[0];
|
||||
assert.doesNotMatch(turn.prompt, /secret-/);
|
||||
assert.doesNotMatch(turn.systemPrompt || "", /secret-/);
|
||||
assert.equal(turn.cwd, "/secret-runtime");
|
||||
assert.equal(turn.env.RUNTIME_AUTH, "secret-auth");
|
||||
assert.equal(turn.chatSessionId, "chat-1");
|
||||
assert.match(JSON.stringify(h.messages), /secret-user/, "local human output is restored");
|
||||
assert.doesNotMatch(JSON.stringify(h.messages), /\[NC_/, "aliases do not escape into local display");
|
||||
}
|
||||
});
|
||||
|
||||
test("SDK privacy preserves aliases and safe native resume across turns, including Grok history fallback", async () => {
|
||||
const h = createPrivacyStreamHarness();
|
||||
assert.equal((await h.stream({ sdkBackend: "grok" })).ok, true);
|
||||
assert.equal((await h.stream({ sdkBackend: "grok" })).ok, true);
|
||||
assert.equal(h.turns[1].resumeSessionId, "native-1");
|
||||
const token = h.privacy.redact("chat-1", "secret-user");
|
||||
assert.ok(h.turns[0].prompt.includes(token));
|
||||
assert.ok(h.turns[1].prompt.includes(token));
|
||||
assert.doesNotMatch(h.turns[1].historySeed, /secret-history/);
|
||||
assert.match(h.turns[1].historySeed, /\[NC_/);
|
||||
});
|
||||
|
||||
test("SDK adapters round trip through the real shared privacy service", async () => {
|
||||
const { createAiPrivacyService } = require("../../../shared/aiPrivacyService.cjs");
|
||||
const privacy = createAiPrivacyService();
|
||||
privacy.configure({ enabled: true, patterns: ["secret-[a-z]+"] });
|
||||
const h = createPrivacyStreamHarness(privacy);
|
||||
assert.equal((await h.stream()).ok, true);
|
||||
assert.equal((await h.stream()).ok, true);
|
||||
assert.equal(h.turns[1].resumeSessionId, "native-1");
|
||||
assert.doesNotMatch(h.turns[0].prompt, /secret-/);
|
||||
assert.match(JSON.stringify(h.messages), /secret-user/);
|
||||
assert.doesNotMatch(JSON.stringify(h.messages), /\[NC_/);
|
||||
});
|
||||
|
||||
test("CodeBuddy nested agent prompts are protected without changing agent names, tools or model IDs", async () => {
|
||||
const h = createPrivacyStreamHarness();
|
||||
const agents = {
|
||||
"secret-agent": {
|
||||
prompt: "Use secret-user",
|
||||
description: "Access secret-host",
|
||||
tools: ["secret-tool"],
|
||||
model: "secret-model",
|
||||
},
|
||||
};
|
||||
assert.equal((await h.stream({ sdkBackend: "codebuddy", agents })).ok, true);
|
||||
const definition = h.turns[0].agents["secret-agent"];
|
||||
assert.doesNotMatch(definition.prompt, /secret-user/);
|
||||
assert.doesNotMatch(definition.description, /secret-host/);
|
||||
assert.deepEqual(definition.tools, ["secret-tool"]);
|
||||
assert.equal(definition.model, "secret-model");
|
||||
});
|
||||
|
||||
test("real privacy discovers system and custom-agent secrets before masking any prompt surface", async () => {
|
||||
const { createAiPrivacyService } = require("../../../shared/aiPrivacyService.cjs");
|
||||
for (const source of ["system", "custom-agent"]) {
|
||||
const service = createAiPrivacyService();
|
||||
service.configure({ enabled: true });
|
||||
const batches = [];
|
||||
const privacy = {
|
||||
...service,
|
||||
redact(chatId, value) { batches.push(value); return service.redact(chatId, value); },
|
||||
};
|
||||
const h = createPrivacyStreamHarness(privacy);
|
||||
const result = await h.stream({
|
||||
sdkBackend: source === "system" ? "opencode" : "codebuddy",
|
||||
prompt: "Use synthetic-secret",
|
||||
historyMessages: [{ role: "user", content: "Earlier synthetic-secret" }],
|
||||
userSkillsContext: source === "system" ? "password=synthetic-secret" : "",
|
||||
agents: {
|
||||
helper: {
|
||||
prompt: "Check synthetic-secret",
|
||||
description: source === "custom-agent" ? "password=synthetic-secret" : "Helper",
|
||||
},
|
||||
},
|
||||
});
|
||||
assert.equal(result.ok, true, result.error);
|
||||
const turn = h.turns[0];
|
||||
assert.doesNotMatch(turn.prompt, /synthetic-secret/);
|
||||
assert.doesNotMatch(turn.systemPrompt, /synthetic-secret/);
|
||||
assert.doesNotMatch(turn.agents.helper.prompt, /synthetic-secret/);
|
||||
assert.doesNotMatch(turn.agents.helper.description, /synthetic-secret/);
|
||||
assert.equal(batches.length, 1, "all semantic prompt surfaces share one discovery pass");
|
||||
assert.ok(Array.isArray(batches[0]));
|
||||
}
|
||||
});
|
||||
|
||||
test("real privacy retains mapping and native resume provenance through runtime-only cleanup", async () => {
|
||||
const { createAiPrivacyService } = require("../../../shared/aiPrivacyService.cjs");
|
||||
const privacy = createAiPrivacyService();
|
||||
privacy.configure({ enabled: true, patterns: ["secret-[a-z]+"] });
|
||||
const h = createPrivacyStreamHarness(privacy);
|
||||
assert.equal((await h.stream()).ok, true);
|
||||
const token = privacy.redact("chat-1", "secret-user");
|
||||
await h.handlers.get("netcatty:ai:sdk-agent:cleanup")({}, { chatSessionId: "chat-1" });
|
||||
assert.equal(privacy.hasSession("chat-1"), true);
|
||||
assert.equal(privacy.restore("chat-1", token), "secret-user");
|
||||
const resumed = await h.stream({ existingSessionId: "native-1" });
|
||||
assert.equal(resumed.ok, true, resumed.error);
|
||||
assert.equal(h.turns[1].resumeSessionId, "native-1");
|
||||
assert.ok(h.turns[1].prompt.includes(token));
|
||||
privacy.clearSession("chat-1");
|
||||
const deleted = await h.stream({ existingSessionId: "native-1" });
|
||||
assert.equal(deleted.ok, false);
|
||||
assert.match(deleted.error, /new chat|new conversation/i);
|
||||
assert.equal(h.turns.length, 2);
|
||||
});
|
||||
|
||||
test("privacy policy change while environment initialization awaits blocks the provider call", async () => {
|
||||
const h = createPrivacyStreamHarness(createTestPrivacy(false));
|
||||
let release;
|
||||
let notifyStarted;
|
||||
const started = new Promise((resolve) => { notifyStarted = resolve; });
|
||||
const gate = new Promise((resolve) => { release = resolve; });
|
||||
h.ctx.getShellEnv = async () => { notifyStarted(); await gate; return {}; };
|
||||
const run = h.stream();
|
||||
await started;
|
||||
h.privacy.setEnabled(true);
|
||||
release();
|
||||
const result = await run;
|
||||
assert.equal(result.ok, false);
|
||||
assert.match(result.error, /settings changed/);
|
||||
assert.equal(h.turns.length, 0);
|
||||
assert.equal(h.ctx.sdkActiveStreams.size, 0);
|
||||
});
|
||||
|
||||
test("native session emitted before a stopped turn remains eligible for private resume", async () => {
|
||||
let count = 0;
|
||||
const h = createPrivacyStreamHarness(createTestPrivacy(), async (context) => {
|
||||
context.emitter.sessionId("native-stopped");
|
||||
if (++count === 1) throw new Error("Stopped");
|
||||
context.emitter.emitDone();
|
||||
return { sessionId: "native-stopped" };
|
||||
});
|
||||
assert.equal((await h.stream()).ok, false);
|
||||
assert.equal((await h.stream()).ok, true);
|
||||
assert.equal(h.turns[1].resumeSessionId, "native-stopped");
|
||||
});
|
||||
|
||||
test("SDK privacy refuses cold native resumes even if Catty already established a chat mapping", async () => {
|
||||
const privacy = createTestPrivacy();
|
||||
privacy.redact("chat-1", "secret-user");
|
||||
const h = createPrivacyStreamHarness(privacy);
|
||||
const result = await h.stream({ existingSessionId: "persisted-native-thread" });
|
||||
assert.equal(result.ok, false);
|
||||
assert.match(result.error, /Start a new conversation/);
|
||||
assert.equal(h.turns.length, 0);
|
||||
});
|
||||
|
||||
test("SDK privacy blocks direct image and file attachments before driver or staging", async () => {
|
||||
for (const image of [
|
||||
{ mediaType: "image/png", base64Data: "c2VjcmV0", filename: "image.png" },
|
||||
{ mediaType: "text/plain", base64Data: "c2VjcmV0", filename: "notes.txt" },
|
||||
{ filePath: "/private/image.png", mediaType: "image/png" },
|
||||
]) {
|
||||
const h = createPrivacyStreamHarness();
|
||||
const result = await h.stream({ images: [image] });
|
||||
assert.equal(result.ok, false);
|
||||
assert.match(result.error, /does not support direct agent attachments/);
|
||||
assert.equal(h.turns.length, 0);
|
||||
}
|
||||
});
|
||||
|
||||
test("SDK privacy changes require a new chat and cancellation preserves the existing map", async () => {
|
||||
const h = createPrivacyStreamHarness();
|
||||
assert.equal((await h.stream()).ok, true);
|
||||
await h.handlers.get("netcatty:ai:sdk-agent:cancel")({}, { requestId: "missing", chatSessionId: "chat-1" });
|
||||
assert.equal(h.privacy.hasSession("chat-1"), true);
|
||||
h.privacy.setEnabled(false);
|
||||
const result = await h.stream();
|
||||
assert.equal(result.ok, false);
|
||||
assert.match(result.error, /settings changed/);
|
||||
assert.equal(h.turns.length, 1);
|
||||
await h.handlers.get("netcatty:ai:sdk-agent:cleanup")({}, { chatSessionId: "chat-1" });
|
||||
assert.equal(h.privacy.hasSession("chat-1"), true, "runtime cleanup is not chat deletion");
|
||||
});
|
||||
|
||||
test("disabled SDK privacy leaves prompts, native resumes, and output untouched", async () => {
|
||||
const h = createPrivacyStreamHarness(createTestPrivacy(false));
|
||||
const result = await h.stream({ existingSessionId: "existing-native-thread", prompt: "literal [NC_" });
|
||||
assert.equal(result.ok, true);
|
||||
assert.equal(h.turns[0].resumeSessionId, "existing-native-thread");
|
||||
assert.match(h.turns[0].prompt, /secret-skill/);
|
||||
assert.match(JSON.stringify(h.messages), /literal \[NC_/);
|
||||
});
|
||||
|
||||
test("Codex app-server start and steering use the same privacy scope", async () => {
|
||||
const h = createPrivacyStreamHarness();
|
||||
h.ctx.codexAppServerRuntime.runTurn = async (context) => {
|
||||
h.turns.push(context);
|
||||
context.emitter.sessionId("app-native-1");
|
||||
return { threadId: "app-native-1" };
|
||||
};
|
||||
assert.equal((await h.stream({ sdkBackend: "codex", codexRuntime: "app-server" })).ok, true);
|
||||
const token = h.privacy.redact("chat-1", "secret-user");
|
||||
assert.ok(h.turns[0].prompt.includes(token));
|
||||
h.ctx.sdkRequestSessions.set("active-request", "chat-1");
|
||||
h.ctx.sdkRequestRuntimes.set("active-request", {
|
||||
backendKey: "codex", codexRuntime: "app-server", toolIntegrationMode: "mcp",
|
||||
});
|
||||
let steered;
|
||||
h.ctx.codexAppServerRuntime.steerTurn = async (_requestId, input) => {
|
||||
steered = input;
|
||||
return { status: "accepted" };
|
||||
};
|
||||
const steer = h.handlers.get("netcatty:ai:sdk-agent:steer");
|
||||
const result = await steer({}, {
|
||||
requestId: "active-request", chatSessionId: "chat-1",
|
||||
clientUserMessageId: "user-1", prompt: "Also secret-user",
|
||||
});
|
||||
assert.equal(result.status, "accepted");
|
||||
assert.equal(steered.prompt, `Also ${token}`);
|
||||
assert.equal(steered.clientUserMessageId, "user-1");
|
||||
const blocked = await steer({}, {
|
||||
requestId: "active-request", chatSessionId: "chat-1", clientUserMessageId: "user-2",
|
||||
prompt: "image", images: [{ mediaType: "image/png", base64Data: "aA==" }],
|
||||
});
|
||||
assert.equal(blocked.status, "failed");
|
||||
});
|
||||
|
||||
test("Codex and CodeBuddy user answers are masked using the pending chat without rewriting field IDs", async () => {
|
||||
const h = createPrivacyStreamHarness();
|
||||
h.privacy.checkSession("chat-1");
|
||||
const content = { "secret-question": { answers: ["secret-user"] } };
|
||||
const answers = h.ctx.codexAppServerRuntime.transformUserInputAnswers(content, { chatSessionId: "chat-1" });
|
||||
assert.deepEqual(Object.keys(answers), ["secret-question"]);
|
||||
assert.equal(answers["secret-question"].answers[0], h.privacy.redact("chat-1", "secret-user"));
|
||||
let response;
|
||||
h.ctx.codebuddySessionManager.elicitationPending.set("elicit-private", {
|
||||
chatSessionId: "chat-1",
|
||||
resolve: (value) => { response = value; },
|
||||
});
|
||||
const result = await h.handlers.get("netcatty:ai:sdk-agent:elicitation-response")({}, {
|
||||
elicitationId: "elicit-private", action: "accept", content,
|
||||
});
|
||||
assert.equal(result.ok, true);
|
||||
assert.deepEqual(response.content, answers);
|
||||
});
|
||||
|
||||
test("real privacy batches user answers with secret ancestry and preserves all question IDs", async () => {
|
||||
const { createAiPrivacyService } = require("../../../shared/aiPrivacyService.cjs");
|
||||
for (const route of ["codex", "codebuddy"]) {
|
||||
for (const content of [
|
||||
{ echo: "synthetic-secret", password: "synthetic-secret" },
|
||||
{ echo: "synthetic-secret", credentials: { value: "synthetic-secret" } },
|
||||
{
|
||||
"synthetic-secret": { answers: ["synthetic-secret"] },
|
||||
credentials: { value: "synthetic-secret" },
|
||||
},
|
||||
]) {
|
||||
const service = createAiPrivacyService();
|
||||
service.configure({ enabled: true });
|
||||
let batches = 0;
|
||||
const privacy = {
|
||||
...service,
|
||||
redact(chatId, value) { batches += 1; return service.redact(chatId, value); },
|
||||
};
|
||||
const h = createPrivacyStreamHarness(privacy);
|
||||
let protectedContent;
|
||||
if (route === "codex") {
|
||||
protectedContent = h.ctx.codexAppServerRuntime.transformUserInputAnswers(content, { chatSessionId: "chat-1" });
|
||||
} else {
|
||||
h.ctx.codebuddySessionManager.elicitationPending.set("batch-private", {
|
||||
chatSessionId: "chat-1",
|
||||
resolve(response) { protectedContent = response.content; },
|
||||
});
|
||||
const result = await h.handlers.get("netcatty:ai:sdk-agent:elicitation-response")({}, {
|
||||
elicitationId: "batch-private", action: "accept", content,
|
||||
});
|
||||
assert.equal(result.ok, true, result.error);
|
||||
}
|
||||
const compare = (original, masked) => {
|
||||
if (typeof original === "string") {
|
||||
assert.notEqual(masked, original);
|
||||
assert.equal(service.restore("chat-1", masked), original);
|
||||
} else if (Array.isArray(original)) {
|
||||
original.forEach((entry, index) => compare(entry, masked[index]));
|
||||
} else {
|
||||
assert.deepEqual(Object.keys(masked), Object.keys(original));
|
||||
Object.entries(original).forEach(([key, entry]) => compare(entry, masked[key]));
|
||||
}
|
||||
};
|
||||
compare(content, protectedContent);
|
||||
assert.equal(batches, 1, "answer values share a single secret-discovery pass");
|
||||
}
|
||||
}
|
||||
});
|
||||
|
||||
test("Codex native approval arguments remain exact while question display restores content and preserves IDs", () => {
|
||||
const h = createPrivacyStreamHarness();
|
||||
const token = h.privacy.redact("chat-1", "secret-user");
|
||||
const nativeArgs = {
|
||||
command: `echo ${token}`,
|
||||
cwd: `/work/${token}`,
|
||||
commandActions: [{ type: "read", path: token }],
|
||||
itemId: token,
|
||||
};
|
||||
h.ctx.codexAppServerRuntime.sendInteractionRequest({
|
||||
interactionId: token,
|
||||
args: nativeArgs,
|
||||
questions: [{
|
||||
id: token, header: token, question: token,
|
||||
options: [{ label: token, description: token }],
|
||||
}],
|
||||
}, { chatSessionId: "chat-1", sender: {} });
|
||||
const displayed = h.messages.at(-1).payload;
|
||||
assert.equal(displayed.interactionId, token);
|
||||
assert.strictEqual(displayed.args, nativeArgs);
|
||||
assert.equal(displayed.args.itemId, token);
|
||||
assert.equal(displayed.args.command, `echo ${token}`);
|
||||
assert.equal(displayed.questions[0].id, token);
|
||||
assert.equal(displayed.questions[0].question, "secret-user");
|
||||
assert.equal(displayed.questions[0].options[0].label, "secret-user");
|
||||
});
|
||||
|
||||
test("sdk-agent:cleanup aborts and removes request entries for the target chat only", async () => {
|
||||
const { handlers, ctx } = registerWithStubbedCtx();
|
||||
|
||||
@@ -1607,3 +2050,28 @@ test("list-models persists a first current-only success for later failures", asy
|
||||
cleanup();
|
||||
}
|
||||
});
|
||||
|
||||
test("SDK adapter passes a live privacy guard through asynchronous driver startup even when initially disabled", async () => {
|
||||
const { createAiPrivacyService } = require("../../../shared/aiPrivacyService.cjs");
|
||||
const privacy = createAiPrivacyService();
|
||||
let notifyReady;
|
||||
let release;
|
||||
let sent = false;
|
||||
const ready = new Promise((resolve) => { notifyReady = resolve; });
|
||||
const gate = new Promise((resolve) => { release = resolve; });
|
||||
const h = createPrivacyStreamHarness(privacy, async (context) => {
|
||||
notifyReady();
|
||||
await gate;
|
||||
context.assertSessionValid();
|
||||
sent = true;
|
||||
});
|
||||
const run = h.stream();
|
||||
await ready;
|
||||
privacy.configure({ enabled: true });
|
||||
release();
|
||||
const result = await run;
|
||||
assert.equal(result.ok, false);
|
||||
assert.match(result.error, /start a new chat/);
|
||||
assert.equal(sent, false);
|
||||
assert.equal(h.ctx.sdkActiveStreams.size, 0);
|
||||
});
|
||||
@@ -4,6 +4,7 @@ const test = require('node:test');
|
||||
const { configureTerminalSessionDataEmitter, emitTerminalSessionData } = require('./emitTerminalSessionData.cjs');
|
||||
const { registerCattyExecHandlers } = require('./aiBridge/cattyExecHandlers.cjs');
|
||||
const { execViaPty } = require('./ai/ptyExec.cjs');
|
||||
const { createAiPrivacyService } = require('../shared/aiPrivacyService.cjs');
|
||||
// Keep bridge discovery writes/deletes off the installed app's live file.
|
||||
const { isolateCliDiscoveryFile } = require('./cliDiscoveryTestIsolation.cjs');
|
||||
isolateCliDiscoveryFile();
|
||||
@@ -46,7 +47,10 @@ for (const surface of ['catty', 'mcp-exec', 'mcp-job']) {
|
||||
let pending;
|
||||
if (surface === 'catty') {
|
||||
const handlers = new Map();
|
||||
const aiPrivacyService = createAiPrivacyService();
|
||||
aiPrivacyService.checkSession('chat');
|
||||
registerCattyExecHandlers({ ipcMain: { handle: (key, fn) => handlers.set(key, fn) },
|
||||
aiPrivacyService,
|
||||
validateSender: () => true, sessions, mcpServerBridge: bridge, electronModule,
|
||||
safeSend: (target, ...args) => target.send(...args), execViaPty, getFreshIdlePrompt: () => '' });
|
||||
pending = handlers.get('netcatty:ai:exec')({ sender: contents }, { sessionId: 'session', command: 'echo test', chatSessionId: 'chat' });
|
||||
|
||||
@@ -1,5 +1,7 @@
|
||||
"use strict";
|
||||
|
||||
const { createPrivacyBoundary } = require("../../mcp/externalMcpPrivacyBoundary.cjs");
|
||||
|
||||
/**
|
||||
* Optional Streamable HTTP endpoint for External MCP.
|
||||
* Tool calls go through the same progressive MCP registry and
|
||||
@@ -188,6 +190,7 @@ function createExternalMcpHttpServer(deps = {}) {
|
||||
}
|
||||
|
||||
function createSessionServer() {
|
||||
const protectedRpcCall = createPrivacyBoundary(rpcCall, deps.getRedactionConfig || (() => undefined));
|
||||
const {
|
||||
McpServer,
|
||||
getCatalogToolDescription,
|
||||
@@ -205,7 +208,7 @@ function createExternalMcpHttpServer(deps = {}) {
|
||||
"netcatty://context",
|
||||
{ description: "Current Netcatty workspace context: connected hosts, session IDs, and environment description." },
|
||||
async () => {
|
||||
const ctx = await rpcCall("netcatty/getContext", { chatSessionId });
|
||||
const ctx = await protectedRpcCall("netcatty/getContext", { chatSessionId });
|
||||
return {
|
||||
contents: [{
|
||||
uri: "netcatty://context",
|
||||
@@ -216,7 +219,7 @@ function createExternalMcpHttpServer(deps = {}) {
|
||||
},
|
||||
);
|
||||
registerProgressiveMcpTools(server, {
|
||||
rpcCall,
|
||||
rpcCall: protectedRpcCall,
|
||||
scopeParams: { chatSessionId },
|
||||
guardWriteOperation,
|
||||
catalogDescription(toolName, fallback) {
|
||||
|
||||
@@ -251,6 +251,40 @@ describe("external MCP streamable HTTP server", () => {
|
||||
}
|
||||
});
|
||||
|
||||
it("isolates reversible masking per HTTP session and covers the environment resource", async () => {
|
||||
const writes = [];
|
||||
active = await startServer({
|
||||
getRedactionConfig: () => ({ enabled: true }),
|
||||
dispatchRpc: async (method, params) => {
|
||||
if (method === "netcatty/exec") {
|
||||
writes.push(params.command);
|
||||
return { ok: true, stdout: params.command, exitCode: 0 };
|
||||
}
|
||||
return { ok: true, text: "192.0.2.10", password: "synthetic-password" };
|
||||
},
|
||||
});
|
||||
const clients = [1, 2].map(() => new Client({ name: "privacy-test", version: "1" }));
|
||||
try {
|
||||
for (const client of clients) {
|
||||
await client.connect(new StreamableHTTPClientTransport(new URL(active.url), {
|
||||
requestInit: { headers: { Authorization: `Bearer ${TOKEN}` } },
|
||||
}));
|
||||
}
|
||||
const result = await clients[0].callTool({ name: "get_environment", arguments: {} });
|
||||
const data = JSON.parse(result.content[0].text);
|
||||
assert.doesNotMatch(JSON.stringify(result), /192\.0\.2\.10|synthetic-password/);
|
||||
const resource = await clients[0].readResource({ uri: "netcatty://context" });
|
||||
assert.equal(JSON.parse(resource.contents[0].text).text, data.text);
|
||||
await clients[0].callTool({ name: "terminal_execute", arguments: { sessionId: "s", command: `echo ${data.text}` } });
|
||||
assert.deepEqual(writes, ["echo 192.0.2.10"]);
|
||||
const rejected = await clients[1].callTool({ name: "terminal_execute", arguments: { sessionId: "s", command: `echo ${data.text}` } });
|
||||
assert.equal(rejected.isError, true);
|
||||
assert.equal(writes.length, 1);
|
||||
} finally {
|
||||
for (const client of clients) await client.close();
|
||||
}
|
||||
});
|
||||
|
||||
it("expires an HTTP session that never sends DELETE", async () => {
|
||||
active = await startServer({ sessionIdleMs: 40, sessionSweepMs: 15 });
|
||||
const initialized = await postJson(active.url, {
|
||||
|
||||
@@ -1,6 +1,8 @@
|
||||
"use strict";
|
||||
|
||||
const crypto = require("node:crypto");
|
||||
const { normalizeRedactionConfig } = require("../mcp/externalMcpRedaction.cjs");
|
||||
const aiPrivacyService = require("../shared/aiPrivacyService.cjs");
|
||||
|
||||
const {
|
||||
getExternalMcpDiscoveryFilePath,
|
||||
@@ -81,6 +83,7 @@ function createExternalMcpController(options = {}) {
|
||||
|
||||
let discoveryFilePath = null;
|
||||
let enabled = false;
|
||||
let redaction = normalizeRedactionConfig();
|
||||
let state = "disabled";
|
||||
let error = null;
|
||||
let mode = EXTERNAL_MCP_MODE_TEMPORARY;
|
||||
@@ -175,6 +178,7 @@ function createExternalMcpController(options = {}) {
|
||||
isEnabled: () => isEnabled(),
|
||||
recordActivity,
|
||||
getPermissionMode: () => getBridge()?.getPermissionMode?.() || "confirm",
|
||||
getRedactionConfig: () => redaction,
|
||||
attachExternalClient: (client) => {
|
||||
const bridge = getBridge();
|
||||
if (typeof bridge?.attachExternalMcpClient === "function") {
|
||||
@@ -192,6 +196,7 @@ function createExternalMcpController(options = {}) {
|
||||
chatSessionId: deps.chatSessionId,
|
||||
}, {
|
||||
externalSocket: context?.externalClient || null,
|
||||
beforeExecute: aiPrivacyService.createPolicyGuard(),
|
||||
});
|
||||
},
|
||||
});
|
||||
@@ -316,6 +321,7 @@ function createExternalMcpController(options = {}) {
|
||||
hostRunning,
|
||||
error,
|
||||
http: buildHttpStatus(),
|
||||
redaction,
|
||||
};
|
||||
}
|
||||
|
||||
@@ -332,6 +338,7 @@ function createExternalMcpController(options = {}) {
|
||||
pid: process.pid,
|
||||
permissionMode: bridge.getPermissionMode?.() || "confirm",
|
||||
chatSessionId: deps.chatSessionId,
|
||||
redaction,
|
||||
});
|
||||
}
|
||||
|
||||
@@ -542,6 +549,26 @@ function createExternalMcpController(options = {}) {
|
||||
}
|
||||
|
||||
function setConfig(config = {}) {
|
||||
if (config.redaction !== undefined) {
|
||||
let nextRedaction;
|
||||
try {
|
||||
nextRedaction = normalizeRedactionConfig(config.redaction);
|
||||
} catch {
|
||||
if (config.redaction === null) aiPrivacyService.invalidateConfiguration();
|
||||
return { ...buildStatus(), ok: false, error: "Invalid privacy rules. Check CIDRs and regular expressions." };
|
||||
}
|
||||
if (JSON.stringify(nextRedaction) !== JSON.stringify(redaction)) {
|
||||
const previousRedaction = redaction;
|
||||
redaction = nextRedaction;
|
||||
try {
|
||||
writeDiscoveryFromBridge();
|
||||
} catch {
|
||||
redaction = previousRedaction;
|
||||
return { ...buildStatus(), ok: false, error: "Privacy settings could not be saved. Retry before reconnecting clients." };
|
||||
}
|
||||
}
|
||||
aiPrivacyService.configure(nextRedaction);
|
||||
}
|
||||
const nextMode = config.mode == null ? mode : normalizeMode(config.mode);
|
||||
const nextIdleTimeoutMinutes = config.idleTimeoutMinutes == null
|
||||
? idleTimeoutMinutes
|
||||
@@ -750,7 +777,8 @@ function createExternalMcpController(options = {}) {
|
||||
});
|
||||
ipcMain.handle("netcatty:external-mcp:set-config", async (event, payload) => {
|
||||
if (!guard(event)) return { ok: false, error: "Unauthorized IPC sender" };
|
||||
setConfig(payload || {});
|
||||
const status = setConfig(payload || {});
|
||||
if (!status.ok) return status;
|
||||
await whenHttpSettled();
|
||||
return getStatus();
|
||||
});
|
||||
|
||||
@@ -101,6 +101,38 @@ describe("externalMcpController", () => {
|
||||
return { controller, bridge };
|
||||
}
|
||||
|
||||
it("validates privacy config before updating discovery and propagates IPC failures", async () => {
|
||||
const { controller } = createController();
|
||||
await controller.setEnabled(true);
|
||||
const valid = { enabled: true, redactPrivateIps: true, cidrs: ["10.0.0.0/8"], patterns: ["internal-[a-z]+"] };
|
||||
assert.equal(controller.setConfig({ redaction: valid }).ok, true);
|
||||
assert.deepEqual(written.at(-1).options.redaction, valid);
|
||||
const handlers = new Map();
|
||||
controller.registerHandlers({ handle: (name, handler) => handlers.set(name, handler) });
|
||||
const result = await handlers.get("netcatty:external-mcp:set-config")({}, { redaction: { enabled: true, cidrs: ["bad-value"] } });
|
||||
assert.equal(result.ok, false);
|
||||
assert.deepEqual(controller.getStatus().redaction, valid);
|
||||
assert.deepEqual(written.at(-1).options.redaction, valid);
|
||||
assert.ok(!result.error.includes("bad-value"));
|
||||
});
|
||||
|
||||
it("retries discovery persistence after a failed privacy change", async () => {
|
||||
let fail = false;
|
||||
let saved;
|
||||
const { controller } = createController({ writeDiscovery: (_path, options) => {
|
||||
if (fail) throw new Error("disk full");
|
||||
saved = options;
|
||||
} });
|
||||
await controller.setEnabled(true);
|
||||
fail = true;
|
||||
assert.equal(controller.setConfig({ redaction: { enabled: true } }).ok, false);
|
||||
assert.equal(controller.getStatus().redaction.enabled, false);
|
||||
assert.equal(saved.redaction.enabled, false);
|
||||
fail = false;
|
||||
assert.equal(controller.setConfig({ redaction: { enabled: true } }).ok, true);
|
||||
assert.equal(saved.redaction.enabled, true);
|
||||
});
|
||||
|
||||
it("enables external mode, writes discovery, and syncs sessions", async () => {
|
||||
const { controller, bridge } = createController();
|
||||
const status = await controller.setEnabled(true);
|
||||
|
||||
@@ -63,6 +63,9 @@ const DISCOVERY_SELF_HEAL_INTERVAL_MS = 15000;
|
||||
|
||||
// Track which sockets have completed authentication
|
||||
const authenticatedSockets = new WeakSet();
|
||||
const managedPrivacyScopes = new WeakMap();
|
||||
const aiPrivacyService = require("../shared/aiPrivacyService.cjs");
|
||||
const { createManagedPrivacyBoundary } = require("../shared/managedPrivacyBoundary.cjs");
|
||||
// Sockets authenticated with the External MCP token (or that used the reserved scope).
|
||||
const externalMcpSockets = new Set();
|
||||
const sessionApprovedExternalSockets = new WeakSet();
|
||||
@@ -1440,7 +1443,7 @@ async function handleMessage(socket, line) {
|
||||
}
|
||||
|
||||
const { id, method, params } = msg;
|
||||
debugLog("handleMessage", { id, method, params });
|
||||
debugLog("handleMessage", { id, method });
|
||||
if (id == null || !method) return;
|
||||
|
||||
// ── Authentication gate ──
|
||||
@@ -1513,9 +1516,25 @@ async function handleMessage(socket, line) {
|
||||
);
|
||||
}
|
||||
notifyExternalMcpActivity(method, callParams);
|
||||
const result = await dispatch(method, callParams, {
|
||||
externalSocket: externalMcpSockets.has(socket) ? socket : null,
|
||||
});
|
||||
let result;
|
||||
if (isExternalScope || externalMcpSockets.has(socket)) {
|
||||
result = await dispatch(method, callParams, {
|
||||
externalSocket: socket,
|
||||
beforeExecute: aiPrivacyService.createPolicyGuard(),
|
||||
});
|
||||
} else {
|
||||
const boundScope = managedPrivacyScopes.get(socket);
|
||||
if (boundScope && boundScope !== callParams.chatSessionId) {
|
||||
throw new Error("Managed agent connection cannot change its chat session.");
|
||||
}
|
||||
if (aiPrivacyService.isEnabled() && callParams.chatSessionId) {
|
||||
managedPrivacyScopes.set(socket, callParams.chatSessionId);
|
||||
}
|
||||
result = await createManagedPrivacyBoundary(
|
||||
(rpcMethod, rpcParams, context) => dispatch(rpcMethod, rpcParams, context),
|
||||
aiPrivacyService,
|
||||
)(method, callParams);
|
||||
}
|
||||
const response = JSON.stringify({ jsonrpc: "2.0", id, result }) + "\n";
|
||||
if (!socket.destroyed) socket.write(response);
|
||||
} catch (err) {
|
||||
@@ -1789,7 +1808,7 @@ function buildTerminalToolGuidance(toolHints) {
|
||||
return "";
|
||||
}
|
||||
|
||||
async function handleWorkerTerminalExec(params = {}) {
|
||||
async function handleWorkerTerminalExec(params = {}, beforeExecute) {
|
||||
const { sessionId, command } = params;
|
||||
if (!sessionId || !command) throw new Error("sessionId and command are required");
|
||||
if (typeof command !== "string" || !command.trim()) {
|
||||
@@ -1837,7 +1856,7 @@ async function handleWorkerTerminalExec(params = {}) {
|
||||
sessionMeta: meta,
|
||||
enforceWallTimeout: true,
|
||||
commandBlocklist,
|
||||
}, {});
|
||||
}, typeof beforeExecute === "function" ? { beforeExecute } : {});
|
||||
} catch (err) {
|
||||
return { ok: false, error: err?.message || String(err) };
|
||||
} finally {
|
||||
@@ -1846,7 +1865,7 @@ async function handleWorkerTerminalExec(params = {}) {
|
||||
}
|
||||
}
|
||||
|
||||
async function handleWorkerJobStart(params = {}) {
|
||||
async function handleWorkerJobStart(params = {}, beforeExecute) {
|
||||
const { sessionId, command } = params;
|
||||
if (!sessionId || !command) throw new Error("sessionId and command are required");
|
||||
if (typeof command !== "string" || !command.trim()) {
|
||||
@@ -1888,7 +1907,7 @@ async function handleWorkerJobStart(params = {}) {
|
||||
commandTimeoutMs,
|
||||
sessionMeta: meta,
|
||||
commandBlocklist,
|
||||
}, {});
|
||||
}, typeof beforeExecute === "function" ? { beforeExecute } : {});
|
||||
if (result?.ok && result.jobId) {
|
||||
if (pendingStart.cancelled || closingTerminalSessions.has(sessionId)) {
|
||||
await waitForWorkerCleanup(terminalWorkerManager.request("netcatty:ai:jobStop", {
|
||||
@@ -2236,11 +2255,14 @@ async function dispatch(method, params, approvalContext = {}) {
|
||||
if (!handler) {
|
||||
throw new Error(`Unknown method: ${method}`);
|
||||
}
|
||||
// Approval may have waited across a privacy-policy change. Check at execution,
|
||||
// not only when the managed request entered the dispatcher.
|
||||
approvalContext.beforeExecute?.();
|
||||
if (capability?.id === "terminal.execute" && params?.sessionId && !sessions?.get?.(params.sessionId)) {
|
||||
return await handleWorkerTerminalExec(params);
|
||||
return await handleWorkerTerminalExec(params, approvalContext.beforeExecute);
|
||||
}
|
||||
if (capability?.id === "terminal.start" && params?.sessionId && !sessions?.get?.(params.sessionId)) {
|
||||
return await handleWorkerJobStart(params);
|
||||
return await handleWorkerJobStart(params, approvalContext.beforeExecute);
|
||||
}
|
||||
if (capability?.id === "terminal.poll" && workerBackgroundJobs.has(params?.jobId)) {
|
||||
return await handleWorkerJobPoll(params);
|
||||
@@ -2248,7 +2270,7 @@ async function dispatch(method, params, approvalContext = {}) {
|
||||
if (capability?.id === "terminal.stop" && workerBackgroundJobs.has(params?.jobId)) {
|
||||
return await handleWorkerJobStop(params);
|
||||
}
|
||||
return await handler(params);
|
||||
return await handler(params, approvalContext);
|
||||
} finally {
|
||||
if (activityStarted) {
|
||||
sessionIdleManager.endActivity(params?.chatSessionId, params.sessionId);
|
||||
|
||||
@@ -8,6 +8,7 @@ const test = require("node:test");
|
||||
const { createBackgroundJobApi } = require("./mcpServerBridge/backgroundJobs.cjs");
|
||||
const { createExecHandlerApi } = require("./mcpServerBridge/execHandlers.cjs");
|
||||
const { PROBE_OUTPUT_MARKER } = require("./ai/sessionShellKind.cjs");
|
||||
const { createAiPrivacyService } = require("../shared/aiPrivacyService.cjs");
|
||||
const {
|
||||
checkBlocklistForShell,
|
||||
resolveSessionBlocklistShellKind,
|
||||
@@ -266,6 +267,37 @@ test("MCP exec probes an unclassified PowerShell session before the authoritativ
|
||||
assert.equal(session._loginShellKind, "powershell");
|
||||
});
|
||||
|
||||
for (const route of ["pty", "channel", "job"]) {
|
||||
test(`MCP ${route} rechecks privacy after shell probing before command handoff`, async () => {
|
||||
const privacy = createAiPrivacyService();
|
||||
privacy.checkSession("chat");
|
||||
const deferred = createDeferredShellProbeConn();
|
||||
const session = { protocol: "ssh", conn: deferred.conn };
|
||||
if (route !== "channel") session.stream = new FakePty();
|
||||
const ctx = createExecHandlerTestContext({
|
||||
sessions: new Map([["ssh", session]]),
|
||||
backgroundJobs: new Map(),
|
||||
});
|
||||
const effects = [];
|
||||
ctx.execViaPty = (...args) => { effects.push(args); return { ok: true }; };
|
||||
ctx.execViaChannel = (...args) => { effects.push(args); return { ok: true }; };
|
||||
ctx.startPtyJob = (...args) => { effects.push(args); return { resultPromise: Promise.resolve({}) }; };
|
||||
const api = createExecHandlerApi(ctx);
|
||||
const handler = route === "job" ? api.handleJobStart : api.handleExec;
|
||||
const pending = handler({
|
||||
sessionId: "ssh", command: "echo synthetic", chatSessionId: "chat",
|
||||
}, { beforeExecute: () => privacy.restore("chat", []) });
|
||||
await nextTick();
|
||||
privacy.configure({ enabled: true });
|
||||
deferred.release();
|
||||
const result = await Promise.resolve(pending).catch((error) => ({ ok: false, error: error.message }));
|
||||
assert.equal(result.ok, false);
|
||||
assert.match(result.error, /start a new chat/);
|
||||
assert.deepEqual(effects, []);
|
||||
assert.equal(ctx.activeSessionExecutions.size, 0);
|
||||
});
|
||||
}
|
||||
|
||||
|
||||
test("expired local and unknown jobs do not claim a lost worker route", (t) => {
|
||||
const original = process.env.NETCATTY_JOB_DIAG;
|
||||
|
||||
@@ -8,8 +8,11 @@ const net = require("node:net");
|
||||
const { createNdjsonRpcClient } = require("../capabilities/rpcTransport.cjs");
|
||||
const tempDirBridge = require("./tempDirBridge.cjs");
|
||||
const bridge = require("./mcpServerBridge.cjs");
|
||||
const privacy = require("../shared/aiPrivacyService.cjs");
|
||||
|
||||
test("external MCP approval names the host and isolates connection and host grants", async (t) => {
|
||||
privacy.configure({ enabled: false });
|
||||
let executions = 0;
|
||||
const dir = fs.mkdtempSync(`${tempDirBridge.getTempFilePath("host-approval")}-`);
|
||||
const clients = [];
|
||||
t.after(() => {
|
||||
@@ -30,7 +33,7 @@ test("external MCP approval names the host and isolates connection and host gran
|
||||
sessions: new Map(),
|
||||
cliDiscoveryFilePath: path.join(dir, "discovery.json"),
|
||||
terminalWorkerManager: {
|
||||
request: async () => ({ ok: true, stdout: "done", stderr: "", exitCode: 0 }),
|
||||
request: async () => { executions += 1; return { ok: true, stdout: "done", stderr: "", exitCode: 0 }; },
|
||||
},
|
||||
});
|
||||
bridge.setMainWindowGetter(() => ({
|
||||
@@ -141,6 +144,16 @@ test("external MCP approval names the host and isolates connection and host gran
|
||||
bridge.resolveApprovalFromRenderer(localRequest.approvalId, false);
|
||||
assert.equal((await localCall).ok, false);
|
||||
|
||||
const policyApproval = nextApproval();
|
||||
const policyCall = exec(clientC, "local-test");
|
||||
const rejectedPolicyCall = assert.rejects(policyCall, /settings changed/);
|
||||
const policyRequest = await policyApproval;
|
||||
const executedBeforeChange = executions;
|
||||
privacy.configure({ enabled: true });
|
||||
bridge.resolveApprovalFromRenderer(policyRequest.approvalId, true, "once");
|
||||
await rejectedPolicyCall;
|
||||
assert.equal(executions, executedBeforeChange, "external approvals must not execute across policy changes");
|
||||
|
||||
const staleApproval = nextApproval();
|
||||
const staleCall = exec(clientC, "local-test");
|
||||
const staleRequest = await staleApproval;
|
||||
|
||||
@@ -0,0 +1,120 @@
|
||||
"use strict";
|
||||
|
||||
const { test } = require("node:test");
|
||||
const assert = require("node:assert/strict");
|
||||
const fs = require("node:fs");
|
||||
const os = require("node:os");
|
||||
const path = require("node:path");
|
||||
const net = require("node:net");
|
||||
const { createNdjsonRpcClient } = require("../capabilities/rpcTransport.cjs");
|
||||
const bridge = require("./mcpServerBridge.cjs");
|
||||
const privacy = require("../shared/aiPrivacyService.cjs");
|
||||
|
||||
test("managed TCP restores prompt aliases before approval and masks results across reconnects", { timeout: 10000 }, async (t) => {
|
||||
const dir = fs.mkdtempSync(path.join(os.tmpdir(), "managed-privacy-"));
|
||||
const clients = [];
|
||||
const commands = [];
|
||||
let pendingApproval;
|
||||
let receiveApproval;
|
||||
t.after(() => {
|
||||
for (const client of clients) client.close();
|
||||
bridge.setMainWindowGetter(() => null);
|
||||
bridge.cleanup();
|
||||
privacy.configure({ enabled: false });
|
||||
fs.rmSync(dir, { recursive: true, force: true });
|
||||
});
|
||||
privacy.configure({ enabled: true });
|
||||
const token = privacy.redact("managed-chat", "192.0.2.13");
|
||||
bridge.init({
|
||||
sessions: new Map(),
|
||||
cliDiscoveryFilePath: path.join(dir, "discovery.json"),
|
||||
terminalWorkerManager: {
|
||||
request: async (_method, payload) => {
|
||||
commands.push(payload);
|
||||
return { ok: true, stdout: "192.0.2.13", stderr: "", exitCode: 0 };
|
||||
},
|
||||
},
|
||||
});
|
||||
bridge.setMainWindowGetter(() => ({
|
||||
isDestroyed: () => false,
|
||||
webContents: { id: 99, send(channel, payload) {
|
||||
if (channel === "netcatty:ai:mcp:approval-request") {
|
||||
pendingApproval = payload;
|
||||
receiveApproval?.(payload);
|
||||
}
|
||||
} },
|
||||
}));
|
||||
bridge.setPermissionMode("confirm");
|
||||
bridge.setCommandBlocklist([]);
|
||||
bridge.updateSessionMetadata([{ sessionId: "ssh", hostname: "smoke.example", protocol: "ssh", connected: true }], "managed-chat");
|
||||
const port = await bridge.getOrCreateHost();
|
||||
const authToken = bridge.buildMcpServerConfig(port, [], "managed-chat").env.find((entry) => entry.name === "NETCATTY_MCP_TOKEN").value;
|
||||
async function connect() {
|
||||
const socket = net.createConnection({ host: "127.0.0.1", port });
|
||||
await new Promise((resolve, reject) => { socket.once("connect", resolve); socket.once("error", reject); });
|
||||
const client = createNdjsonRpcClient({ socket });
|
||||
clients.push(client);
|
||||
await client.call("auth/verify", { token: authToken });
|
||||
return client;
|
||||
}
|
||||
const client = await connect();
|
||||
const approval = new Promise((resolve) => { receiveApproval = resolve; });
|
||||
const request = client.call("netcatty/exec", { chatSessionId: "managed-chat", sessionId: "ssh", command: `echo ${token}` });
|
||||
const approved = await approval;
|
||||
assert.equal(approved.args.command, "echo 192.0.2.13");
|
||||
bridge.resolveApprovalFromRenderer(pendingApproval.approvalId, true, "once");
|
||||
assert.equal((await request).stdout, token);
|
||||
assert.equal(commands.length, 1);
|
||||
const next = await connect();
|
||||
const context = await next.call("netcatty/getContext", { chatSessionId: "managed-chat" });
|
||||
assert.doesNotMatch(JSON.stringify(context), /smoke\.example/);
|
||||
await assert.rejects(next.call("netcatty/getContext", { chatSessionId: "other-chat" }), /cannot change/);
|
||||
|
||||
const callsBeforeApproval = commands.length;
|
||||
const nextApproval = new Promise((resolve) => { receiveApproval = resolve; });
|
||||
const staleRequest = client.call("netcatty/exec", { chatSessionId: "managed-chat", sessionId: "ssh", command: `echo ${token}` });
|
||||
const rejected = assert.rejects(staleRequest, /settings changed|unavailable/);
|
||||
const staleApproval = await nextApproval;
|
||||
privacy.configure({ enabled: false });
|
||||
bridge.resolveApprovalFromRenderer(staleApproval.approvalId, true, "once");
|
||||
await rejected;
|
||||
assert.equal(commands.length, callsBeforeApproval, "policy changes during approval must prevent execution");
|
||||
|
||||
});
|
||||
|
||||
test("privacy-disabled TCP preserves legacy multi-scope and unscoped calls on one socket", { timeout: 10000 }, async (t) => {
|
||||
const dir = fs.mkdtempSync(path.join(os.tmpdir(), "managed-privacy-off-"));
|
||||
let client;
|
||||
t.after(() => {
|
||||
client?.close();
|
||||
bridge.cleanup();
|
||||
fs.rmSync(dir, { recursive: true, force: true });
|
||||
});
|
||||
// Production waits for the saved policy, even when it is explicitly off.
|
||||
privacy.configure({ enabled: false });
|
||||
bridge.init({
|
||||
sessions: new Map([
|
||||
["off-terminal-one", { hostname: "one.example", pty: { write() {} } }],
|
||||
["off-terminal-two", { hostname: "two.example", pty: { write() {} } }],
|
||||
]),
|
||||
cliDiscoveryFilePath: path.join(dir, "discovery.json"),
|
||||
});
|
||||
bridge.setPermissionMode("observer");
|
||||
bridge.updateSessionMetadata([{ sessionId: "off-terminal-one", hostname: "one.example" }], "off-chat-one");
|
||||
bridge.updateSessionMetadata([{ sessionId: "off-terminal-two", hostname: "two.example" }], "off-chat-two");
|
||||
const port = await bridge.getOrCreateHost();
|
||||
const authToken = bridge.buildMcpServerConfig(port, [], "off-chat-one").env
|
||||
.find((entry) => entry.name === "NETCATTY_MCP_TOKEN").value;
|
||||
const socket = net.createConnection({ host: "127.0.0.1", port });
|
||||
await new Promise((resolve, reject) => { socket.once("connect", resolve); socket.once("error", reject); });
|
||||
client = createNdjsonRpcClient({ socket });
|
||||
await client.call("auth/verify", { token: authToken });
|
||||
const one = await client.call("netcatty/getContext", { chatSessionId: "off-chat-one" });
|
||||
const two = await client.call("netcatty/getContext", { chatSessionId: "off-chat-two" });
|
||||
const unscoped = await client.call("netcatty/getContext", { scopedSessionIds: ["off-terminal-one"] });
|
||||
assert.equal(one.hosts[0].sessionId, "off-terminal-one");
|
||||
assert.equal(two.hosts[0].sessionId, "off-terminal-two");
|
||||
assert.equal(unscoped.hosts[0].sessionId, "off-terminal-one");
|
||||
assert.equal(one.hosts[0].hostname, "one.example");
|
||||
assert.equal(two.hosts[0].hostname, "two.example");
|
||||
});
|
||||
@@ -0,0 +1,264 @@
|
||||
"use strict";
|
||||
|
||||
const assert = require("node:assert/strict");
|
||||
const test = require("node:test");
|
||||
const { createSftpHandlerApi } = require("./mcpServerBridge/sftpHandlers.cjs");
|
||||
const { createAiPrivacyService } = require("../shared/aiPrivacyService.cjs");
|
||||
|
||||
function deferred() {
|
||||
let resolve;
|
||||
const promise = new Promise((done) => { resolve = done; });
|
||||
return { promise, resolve };
|
||||
}
|
||||
|
||||
const operations = [
|
||||
["handleSftpList", {}],
|
||||
["handleSftpRead", { path: "/synthetic" }],
|
||||
["handleSftpWrite", { path: "/synthetic", content: "synthetic" }],
|
||||
["handleSftpDownload", { remotePath: "/synthetic", localPath: "/synthetic-local" }],
|
||||
["handleSftpUpload", { remotePath: "/synthetic", localPath: "/synthetic-local" }],
|
||||
["handleSftpMkdir", { path: "/synthetic" }],
|
||||
["handleSftpDelete", { path: "/synthetic" }],
|
||||
["handleSftpRename", { oldPath: "/synthetic", newPath: "/synthetic-new" }],
|
||||
["handleSftpStat", { path: "/synthetic" }],
|
||||
["handleSftpChmod", { path: "/synthetic", mode: "600" }],
|
||||
["handleSftpHome", {}],
|
||||
];
|
||||
|
||||
for (const worker of [false, true]) {
|
||||
for (const [method, args] of operations) {
|
||||
test(`${method} blocks a policy change during ${worker ? "worker" : "main"} SFTP open`, async () => {
|
||||
const privacy = createAiPrivacyService();
|
||||
privacy.configure({ enabled: true });
|
||||
privacy.checkSession("chat");
|
||||
const entered = deferred();
|
||||
const release = deferred();
|
||||
const effects = [];
|
||||
const closed = [];
|
||||
const open = async () => {
|
||||
entered.resolve();
|
||||
await release.promise;
|
||||
return { sftpId: "synthetic-handle" };
|
||||
};
|
||||
const bridge = new Proxy({
|
||||
openSftpForSession: open,
|
||||
closeSftp: async (_event, payload) => { closed.push(payload); },
|
||||
}, {
|
||||
get: (target, key) => target[key] || (async (_event, payload) => { effects.push({ key, payload }); }),
|
||||
});
|
||||
const api = createSftpHandlerApi({
|
||||
sessions: new Map(worker ? [] : [["ssh", {}]]),
|
||||
terminalWorkerManager: worker ? {
|
||||
request: async (channel, payload) => {
|
||||
if (channel === "netcatty:sftp:openForSession") return open();
|
||||
if (channel === "netcatty:sftp:close") { closed.push(payload); return; }
|
||||
effects.push({ channel, payload });
|
||||
},
|
||||
} : null,
|
||||
sftpBridge: bridge,
|
||||
transferBridge: null,
|
||||
commandTimeoutMs: 1000,
|
||||
registerSftpOp: () => () => {},
|
||||
createTransferId: () => "synthetic-transfer",
|
||||
reportTransferEvent() {},
|
||||
});
|
||||
const pending = api[method]({ sessionId: "ssh", chatSessionId: "chat", ...args }, {
|
||||
beforeExecute: () => privacy.restore("chat", []),
|
||||
});
|
||||
const rejected = assert.rejects(pending, /start a new chat/);
|
||||
await entered.promise;
|
||||
privacy.configure({ enabled: false });
|
||||
release.resolve();
|
||||
await rejected;
|
||||
assert.deepEqual(effects, []);
|
||||
assert.equal(closed.length, 1, "the opened handle must still be released");
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
for (const method of ["handleSftpDownload", "handleSftpUpload"]) {
|
||||
for (const changePolicy of [false, true]) {
|
||||
test(`${method} ${changePolicy ? "blocks changed" : "preserves unchanged"} privacy after main transfer admission`, async () => {
|
||||
const privacy = createAiPrivacyService();
|
||||
privacy.configure({ enabled: true });
|
||||
privacy.checkSession("chat");
|
||||
const entered = deferred();
|
||||
const release = deferred();
|
||||
const effects = [];
|
||||
const opens = [];
|
||||
const closed = [];
|
||||
const admitted = [];
|
||||
const transferBridge = {
|
||||
getGlobalTransferConcurrency: () => 1,
|
||||
async runAdmittedTransfer(event, payload, _progress, runner) {
|
||||
admitted.push(payload);
|
||||
entered.resolve();
|
||||
await release.promise;
|
||||
return runner
|
||||
? runner()
|
||||
: transferBridge.startTransfer(event, { ...payload, skipAdmission: true });
|
||||
},
|
||||
startTransfer(event, payload) {
|
||||
if (payload.skipAdmission !== true) {
|
||||
return transferBridge.runAdmittedTransfer(event, payload);
|
||||
}
|
||||
effects.push(payload);
|
||||
return Promise.resolve({ transferId: payload.transferId });
|
||||
},
|
||||
};
|
||||
const api = createSftpHandlerApi({
|
||||
sessions: new Map([["ssh", { hostId: "synthetic-host" }]]),
|
||||
terminalWorkerManager: null,
|
||||
sftpBridge: {
|
||||
openSftpForSession: async (_event, payload) => {
|
||||
opens.push(payload);
|
||||
return { sftpId: "synthetic-handle" };
|
||||
},
|
||||
closeSftp: async (_event, payload) => { closed.push(payload); },
|
||||
},
|
||||
transferBridge,
|
||||
commandTimeoutMs: 1000,
|
||||
registerSftpOp: () => () => {},
|
||||
createTransferId: () => "synthetic-transfer",
|
||||
reportTransferEvent() {},
|
||||
});
|
||||
const pending = api[method]({
|
||||
sessionId: "ssh", chatSessionId: "chat",
|
||||
remotePath: "/synthetic-remote", localPath: "/synthetic-local",
|
||||
}, { beforeExecute: () => privacy.restore("chat", []) });
|
||||
const rejected = changePolicy ? assert.rejects(pending, /start a new chat/) : null;
|
||||
await entered.promise;
|
||||
if (changePolicy) privacy.configure({ enabled: false });
|
||||
release.resolve();
|
||||
if (rejected) await rejected;
|
||||
else assert.equal((await pending).ok, true);
|
||||
assert.equal(admitted.length, 1, "the transfer must acquire only one admission slot");
|
||||
const hostField = method === "handleSftpDownload" ? "sourceHostId" : "targetHostId";
|
||||
assert.equal(admitted[0][hostField], "synthetic-host");
|
||||
assert.equal(admitted[0].globalConcurrency, 1);
|
||||
assert.equal(effects.length, changePolicy ? 0 : 1);
|
||||
assert.equal(opens.length, changePolicy ? 0 : 1);
|
||||
assert.equal(closed.length, opens.length);
|
||||
if (!changePolicy) assert.equal(effects[0].skipAdmission, true);
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
for (const method of ["handleSftpDownload", "handleSftpUpload"]) {
|
||||
for (const cancelQueued of [false, true]) {
|
||||
test(`${method} ${cancelQueued ? "cancels" : "completes"} through real admission with chat cleanup`, { timeout: 5000 }, async (t) => {
|
||||
const realTransfers = require("./transferBridge.cjs");
|
||||
const { createBackgroundJobApi } = require("./mcpServerBridge/backgroundJobs.cjs");
|
||||
const previousLimit = realTransfers.getGlobalTransferConcurrency();
|
||||
realTransfers.setGlobalTransferConcurrency(1);
|
||||
const hostId = `synthetic-${method}-${cancelQueued}`;
|
||||
const transferId = `${hostId}-transfer`;
|
||||
const releaseBlocker = deferred();
|
||||
const queued = deferred();
|
||||
const blocker = realTransfers.runAdmittedTransfer(
|
||||
{ sender: { send() {} } },
|
||||
{ transferId: `${hostId}-blocker`, sourceHostId: hostId },
|
||||
undefined,
|
||||
() => releaseBlocker.promise,
|
||||
);
|
||||
const privacy = createAiPrivacyService();
|
||||
privacy.configure({ enabled: true });
|
||||
privacy.checkSession("chat");
|
||||
const registryContext = {
|
||||
activeSessionSftpOps: new Map(), closingTerminalSessions: new Map(), activeSftpOpSeq: 0,
|
||||
};
|
||||
const registry = createBackgroundJobApi(registryContext);
|
||||
const effects = [];
|
||||
const opens = [];
|
||||
const closed = [];
|
||||
const events = [];
|
||||
let admissionCount = 0;
|
||||
let registrationCount = 0;
|
||||
let releaseCount = 0;
|
||||
let cancelCount = 0;
|
||||
let pending;
|
||||
t.after(async () => {
|
||||
releaseBlocker.resolve();
|
||||
await blocker;
|
||||
await pending;
|
||||
realTransfers.setGlobalTransferConcurrency(previousLimit);
|
||||
});
|
||||
const api = createSftpHandlerApi({
|
||||
sessions: new Map([["ssh", { hostId }]]),
|
||||
terminalWorkerManager: null,
|
||||
sftpBridge: {
|
||||
openSftpForSession: async () => { opens.push(true); return { sftpId: "synthetic-handle" }; },
|
||||
closeSftp: async () => { closed.push(true); },
|
||||
},
|
||||
transferBridge: {
|
||||
getGlobalTransferConcurrency: () => 1,
|
||||
runAdmittedTransfer: (...args) => {
|
||||
admissionCount++;
|
||||
return realTransfers.runAdmittedTransfer(...args);
|
||||
},
|
||||
cancelTransfer: (...args) => {
|
||||
cancelCount++;
|
||||
return realTransfers.cancelTransfer(...args);
|
||||
},
|
||||
startTransfer: async (_event, payload) => {
|
||||
assert.equal(payload.skipAdmission, true);
|
||||
effects.push(payload);
|
||||
return { transferId };
|
||||
},
|
||||
},
|
||||
commandTimeoutMs: 1000,
|
||||
registerSftpOp: (...args) => {
|
||||
registrationCount++;
|
||||
const unregister = registry.registerSftpOp(...args);
|
||||
return () => { releaseCount++; unregister(); };
|
||||
},
|
||||
createTransferId: () => transferId,
|
||||
reportTransferEvent: (event) => {
|
||||
events.push(event);
|
||||
if (event.type === "queued" && event.transferId === transferId) queued.resolve();
|
||||
},
|
||||
});
|
||||
let settled = false;
|
||||
pending = api[method]({
|
||||
sessionId: "ssh", chatSessionId: "chat",
|
||||
remotePath: "/synthetic-remote", localPath: "/synthetic-local",
|
||||
}, { beforeExecute: () => privacy.restore("chat", []) }).then((result) => {
|
||||
settled = true;
|
||||
return result;
|
||||
});
|
||||
await queued.promise;
|
||||
assert.equal(opens.length, 0);
|
||||
if (cancelQueued) {
|
||||
await registry.cancelSftpOpsForSession("chat");
|
||||
await new Promise((resolve) => setImmediate(resolve));
|
||||
assert.equal(settled, true, "queued cancellation must settle before the occupied slot is released");
|
||||
assert.deepEqual(await pending, { ok: false, cancelled: true, transferId });
|
||||
assert.equal(cancelCount, 1);
|
||||
assert.equal(events.filter((event) => event.type === "cancelled").length, 1);
|
||||
} else {
|
||||
releaseBlocker.resolve();
|
||||
await blocker;
|
||||
assert.equal((await pending).ok, true);
|
||||
assert.equal(cancelCount, 0);
|
||||
assert.equal(events.filter((event) => event.type === "completed").length, 1);
|
||||
}
|
||||
releaseBlocker.resolve();
|
||||
await blocker;
|
||||
await new Promise((resolve) => setImmediate(resolve));
|
||||
assert.equal(admissionCount, 1);
|
||||
assert.equal(effects.length, cancelQueued ? 0 : 1);
|
||||
assert.equal(opens.length, effects.length);
|
||||
assert.equal(closed.length, opens.length);
|
||||
assert.equal(registryContext.activeSessionSftpOps.size, 0);
|
||||
assert.equal(registrationCount, cancelQueued ? 1 : 2);
|
||||
assert.equal(releaseCount, registrationCount, "each cancellation registration is released exactly once");
|
||||
assert.equal(realTransfers._getPendingCancelCountForTests(), 0);
|
||||
assert.equal(await realTransfers.runAdmittedTransfer(
|
||||
{ sender: { send() {} } },
|
||||
{ transferId: `${hostId}-next`, sourceHostId: hostId },
|
||||
undefined,
|
||||
async () => "slot released",
|
||||
), "slot released");
|
||||
});
|
||||
}
|
||||
}
|
||||
@@ -198,7 +198,8 @@ function createCapabilityRpcDispatcher(deps) {
|
||||
const hostOpenGeneration = capability.id === "vault.host.open"
|
||||
? deps.captureHostOpenScope?.(params?.chatSessionId)
|
||||
: null;
|
||||
const result = await handler(params);
|
||||
approvalContext.beforeExecute?.();
|
||||
const result = await handler(params, approvalContext);
|
||||
if (capability.id === "vault.host.open" && result?.ok !== false && result?.sessionId) {
|
||||
await deps.onHostOpened?.(params?.chatSessionId, result.sessionId, hostOpenGeneration, result);
|
||||
}
|
||||
|
||||
@@ -445,3 +445,44 @@ test("implemented vault capabilities do not return CAPABILITY_NOT_IMPLEMENTED",
|
||||
const result = await dispatch("vault/host/get", { hostId: "host-1" });
|
||||
assert.notEqual(result.code, "CAPABILITY_NOT_IMPLEMENTED");
|
||||
});
|
||||
|
||||
test("managed privacy is revalidated after capability approval and before writes", async () => {
|
||||
const { createAiPrivacyService } = require("../../shared/aiPrivacyService.cjs");
|
||||
const { createManagedPrivacyBoundary } = require("../../shared/managedPrivacyBoundary.cjs");
|
||||
const privacy = createAiPrivacyService();
|
||||
privacy.configure({ enabled: true });
|
||||
const token = privacy.redact("approval-chat", "192.0.2.14");
|
||||
let invoked = false;
|
||||
const dispatch = createManagedPrivacyBoundary(createTestDispatcher({
|
||||
evaluatePermissionWithGrants: () => ({ allowed: true, requiresApproval: true }),
|
||||
requestApprovalFromRenderer: async (_tool, args) => {
|
||||
assert.equal(args.notes, "192.0.2.14");
|
||||
privacy.configure({ enabled: false });
|
||||
return true;
|
||||
},
|
||||
invokeVaultAgent: async () => { invoked = true; return { ok: true }; },
|
||||
}), privacy);
|
||||
await assert.rejects(dispatch("public/vault/hostNotes/set", {
|
||||
chatSessionId: "approval-chat", hostId: "host-1", notes: token,
|
||||
}), /settings changed|unavailable/);
|
||||
assert.equal(invoked, false);
|
||||
});
|
||||
|
||||
test("managed session close revalidates privacy after asynchronous close preparation", async () => {
|
||||
const { createAiPrivacyService } = require("../../shared/aiPrivacyService.cjs");
|
||||
const { createManagedPrivacyBoundary } = require("../../shared/managedPrivacyBoundary.cjs");
|
||||
const privacy = createAiPrivacyService();
|
||||
privacy.configure({ enabled: true });
|
||||
let invoked = false;
|
||||
let outcome;
|
||||
const dispatch = createManagedPrivacyBoundary(createTestDispatcher({
|
||||
beforeSessionClose: async () => { privacy.configure({ enabled: false }); },
|
||||
afterSessionClose: async (_params, result) => { outcome = result; },
|
||||
invokeVaultAgent: async () => { invoked = true; return { ok: true }; },
|
||||
}), privacy);
|
||||
await assert.rejects(dispatch("public/session/close", {
|
||||
chatSessionId: "closing-chat", sessionId: "session-1",
|
||||
}), /settings changed|unavailable/);
|
||||
assert.equal(invoked, false);
|
||||
assert.equal(outcome.closed, false);
|
||||
});
|
||||
@@ -73,7 +73,7 @@ function createExecHandlerApi(ctx) {
|
||||
};
|
||||
}
|
||||
|
||||
function handleExec(params) {
|
||||
function handleExec(params, executionContext = {}) {
|
||||
const resolved = resolveExecContext(params);
|
||||
if (!resolved.ok) return resolved;
|
||||
const {
|
||||
@@ -103,6 +103,7 @@ function createExecHandlerApi(ctx) {
|
||||
|
||||
const runExecution = (factory) => {
|
||||
try {
|
||||
executionContext.beforeExecute?.();
|
||||
return Promise.resolve(factory()).finally(() => {
|
||||
releaseSessionExecution(sessionId, sessionToken);
|
||||
executionLock.release();
|
||||
@@ -138,6 +139,7 @@ function createExecHandlerApi(ctx) {
|
||||
chatSessionId,
|
||||
});
|
||||
if (!probed.ok) return probed;
|
||||
executionContext.beforeExecute?.();
|
||||
const safety = checkCommandSafetyForShell(command, resolveSessionBlocklistShellKind(session));
|
||||
if (safety.blocked) {
|
||||
debugLog("handleExec:blocklisted", { sessionId, matchedPattern: safety.matchedPattern });
|
||||
@@ -182,6 +184,7 @@ function createExecHandlerApi(ctx) {
|
||||
chatSessionId,
|
||||
});
|
||||
if (!probed.ok) return probed;
|
||||
executionContext.beforeExecute?.();
|
||||
const safety = checkCommandSafetyForShell(command, resolveSessionBlocklistShellKind(session));
|
||||
if (safety.blocked) {
|
||||
debugLog("handleExec:blocklisted", { sessionId, matchedPattern: safety.matchedPattern });
|
||||
@@ -217,7 +220,7 @@ function createExecHandlerApi(ctx) {
|
||||
return { ok: false, error: "Session does not support command execution" };
|
||||
}
|
||||
|
||||
function handleJobStart(params) {
|
||||
function handleJobStart(params, executionContext = {}) {
|
||||
const resolved = resolveExecContext(params);
|
||||
if (!resolved.ok) return resolved;
|
||||
const {
|
||||
@@ -313,6 +316,7 @@ function createExecHandlerApi(ctx) {
|
||||
|
||||
let handle;
|
||||
try {
|
||||
executionContext.beforeExecute?.();
|
||||
handle = startPtyJob(ptyStream, command, {
|
||||
onInterrupt: () => clearSessionFlowState(session),
|
||||
// Intentionally do NOT register in activePtyExecs: terminal_start jobs
|
||||
|
||||
@@ -100,6 +100,7 @@ function createSftpHandlerApi(ctx) {
|
||||
|
||||
try {
|
||||
const manager = getWorkerManager();
|
||||
options.beforeExecute?.();
|
||||
pendingOpenPromise = manager.request("netcatty:sftp:openForSession", {
|
||||
sessionId: params.sessionId,
|
||||
encodingStateKey,
|
||||
@@ -122,6 +123,7 @@ function createSftpHandlerApi(ctx) {
|
||||
const workerPayload = options.buildWorkerPayload
|
||||
? options.buildWorkerPayload(workerParams, sftpId)
|
||||
: { ...workerParams, sftpId, timeoutMs };
|
||||
options.beforeExecute?.();
|
||||
const value = await requestWorkerSftp(workerChannel, workerPayload, { timeoutMs, operationName });
|
||||
if (cancellationError) throw cancellationError;
|
||||
return value;
|
||||
@@ -202,6 +204,7 @@ function createSftpHandlerApi(ctx) {
|
||||
}, timeoutMs);
|
||||
}
|
||||
|
||||
options.beforeExecute?.();
|
||||
const opened = await sftpBridge.openSftpForSession(null, {
|
||||
sessionId: params.sessionId,
|
||||
encodingStateKey,
|
||||
@@ -223,7 +226,10 @@ function createSftpHandlerApi(ctx) {
|
||||
abortSignal: abortController.signal,
|
||||
timeoutMs,
|
||||
};
|
||||
const value = await Promise.resolve().then(() => action(payload));
|
||||
const value = await Promise.resolve().then(() => {
|
||||
options.beforeExecute?.();
|
||||
return action(payload);
|
||||
});
|
||||
if (timeoutError) {
|
||||
throw timeoutError;
|
||||
}
|
||||
@@ -254,37 +260,68 @@ function createSftpHandlerApi(ctx) {
|
||||
}
|
||||
}
|
||||
|
||||
async function handleSftpList(params) {
|
||||
async function runAdmittedSessionTransfer(params, event, payload, runner) {
|
||||
let queued = false;
|
||||
let cancelled = false;
|
||||
let registered = true;
|
||||
const cancelledResult = () => ({ transferId: payload.transferId, error: "Transfer cancelled", cancelled: true });
|
||||
const unregister = registerSftpOp(params?.chatSessionId, params?.sessionId, () => {
|
||||
cancelled = true;
|
||||
if (queued) return transferBridge.cancelTransfer?.(null, { transferId: payload.transferId });
|
||||
});
|
||||
const releaseRegistration = () => {
|
||||
if (!registered) return;
|
||||
registered = false;
|
||||
unregister();
|
||||
};
|
||||
try {
|
||||
// Registration may synchronously cancel an already-closing terminal.
|
||||
if (cancelled) return cancelledResult();
|
||||
queued = true;
|
||||
return await transferBridge.runAdmittedTransfer(event, payload, undefined, async () => {
|
||||
queued = false;
|
||||
releaseRegistration();
|
||||
if (cancelled) return cancelledResult();
|
||||
// The runner registers handle cleanup synchronously before its first
|
||||
// await, so admission and active-operation cancellation never overlap.
|
||||
return runner();
|
||||
});
|
||||
} finally {
|
||||
releaseRegistration();
|
||||
}
|
||||
}
|
||||
|
||||
async function handleSftpList(params, executionContext = {}) {
|
||||
const entries = await withSessionBackedSftp(
|
||||
params,
|
||||
(payload) => sftpBridge.listSftp(null, payload),
|
||||
{ timeoutMs: commandTimeoutMs, operationName: "SFTP list", workerChannel: "netcatty:sftp:list" },
|
||||
{ ...executionContext, timeoutMs: commandTimeoutMs, operationName: "SFTP list", workerChannel: "netcatty:sftp:list" },
|
||||
);
|
||||
return { ok: true, entries };
|
||||
}
|
||||
|
||||
async function handleSftpRead(params) {
|
||||
async function handleSftpRead(params, executionContext = {}) {
|
||||
if (!params?.path) throw new Error("path is required");
|
||||
const content = await withSessionBackedSftp(
|
||||
params,
|
||||
(payload) => sftpBridge.readSftp(null, payload),
|
||||
{ timeoutMs: commandTimeoutMs, operationName: "SFTP read", workerChannel: "netcatty:sftp:read" },
|
||||
{ ...executionContext, timeoutMs: commandTimeoutMs, operationName: "SFTP read", workerChannel: "netcatty:sftp:read" },
|
||||
);
|
||||
return { ok: true, path: params.path, content };
|
||||
}
|
||||
|
||||
async function handleSftpWrite(params) {
|
||||
async function handleSftpWrite(params, executionContext = {}) {
|
||||
if (!params?.path) throw new Error("path is required");
|
||||
if (typeof params?.content !== "string") throw new Error("content is required");
|
||||
await withSessionBackedSftp(
|
||||
params,
|
||||
(payload) => sftpBridge.writeSftp(null, payload),
|
||||
{ timeoutMs: commandTimeoutMs, operationName: "SFTP write", workerChannel: "netcatty:sftp:write" },
|
||||
{ ...executionContext, timeoutMs: commandTimeoutMs, operationName: "SFTP write", workerChannel: "netcatty:sftp:write" },
|
||||
);
|
||||
return { ok: true, path: params.path };
|
||||
}
|
||||
|
||||
async function handleSftpDownload(params) {
|
||||
async function handleSftpDownload(params, executionContext = {}) {
|
||||
if (!params?.remotePath || !params?.localPath) {
|
||||
throw new Error("remotePath and localPath are required");
|
||||
}
|
||||
@@ -297,6 +334,7 @@ function createSftpHandlerApi(ctx) {
|
||||
sourceHostId,
|
||||
});
|
||||
try {
|
||||
let cancellationReported = false;
|
||||
const sender = {
|
||||
send(channel, payload) {
|
||||
if (channel === "netcatty:transfer:progress") {
|
||||
@@ -308,13 +346,14 @@ function createSftpHandlerApi(ctx) {
|
||||
} else if (channel === "netcatty:transfer:paused") {
|
||||
reportTransferEvent({ type: "paused", ...payload });
|
||||
} else if (channel === "netcatty:transfer:cancelled") {
|
||||
reportTransferEvent({ type: "cancelled", ...payload, endedAt: Date.now() });
|
||||
if (!cancellationReported) reportTransferEvent({ type: "cancelled", ...payload, endedAt: Date.now() });
|
||||
cancellationReported = true;
|
||||
}
|
||||
},
|
||||
};
|
||||
const useOuterAdmission = !!(
|
||||
shouldProxySessionBackedSftpToWorker(params) && transferBridge?.runAdmittedTransfer
|
||||
);
|
||||
// Admit before opening the handle so the execution guard runs after
|
||||
// queue waits for main-process transfers as well as worker transfers.
|
||||
const useOuterAdmission = typeof transferBridge?.runAdmittedTransfer === "function";
|
||||
const runDownload = (skipAdmission) => withSessionBackedSftp(
|
||||
params,
|
||||
(payload) => transferBridge
|
||||
@@ -333,6 +372,7 @@ function createSftpHandlerApi(ctx) {
|
||||
})
|
||||
: sftpBridge.downloadSftpToLocal(null, payload),
|
||||
{
|
||||
...executionContext,
|
||||
timeoutMs: commandTimeoutMs,
|
||||
operationName: "SFTP download",
|
||||
workerChannel: transferBridge ? "netcatty:transfer:start" : "netcatty:sftp:downloadToLocal",
|
||||
@@ -351,16 +391,16 @@ function createSftpHandlerApi(ctx) {
|
||||
);
|
||||
const result = await (
|
||||
useOuterAdmission
|
||||
? transferBridge.runAdmittedTransfer(
|
||||
? runAdmittedSessionTransfer(
|
||||
params,
|
||||
{ sender },
|
||||
{ transferId, sourceHostId, globalConcurrency: transferBridge.getGlobalTransferConcurrency?.() },
|
||||
undefined,
|
||||
() => runDownload(true),
|
||||
)
|
||||
: runDownload(false)
|
||||
);
|
||||
if (result?.cancelled || result?.error === "Transfer cancelled") {
|
||||
reportTransferEvent({ type: "cancelled", transferId, endedAt: Date.now() });
|
||||
if (!cancellationReported) reportTransferEvent({ type: "cancelled", transferId, endedAt: Date.now() });
|
||||
return { ok: false, cancelled: true, transferId };
|
||||
}
|
||||
if (result?.error) throw new Error(result.error);
|
||||
@@ -372,7 +412,7 @@ function createSftpHandlerApi(ctx) {
|
||||
}
|
||||
}
|
||||
|
||||
async function handleSftpUpload(params) {
|
||||
async function handleSftpUpload(params, executionContext = {}) {
|
||||
if (!params?.remotePath || !params?.localPath) {
|
||||
throw new Error("remotePath and localPath are required");
|
||||
}
|
||||
@@ -385,6 +425,7 @@ function createSftpHandlerApi(ctx) {
|
||||
targetHostId,
|
||||
});
|
||||
try {
|
||||
let cancellationReported = false;
|
||||
const sender = {
|
||||
send(channel, payload) {
|
||||
if (channel === "netcatty:transfer:progress") {
|
||||
@@ -396,13 +437,12 @@ function createSftpHandlerApi(ctx) {
|
||||
} else if (channel === "netcatty:transfer:paused") {
|
||||
reportTransferEvent({ type: "paused", ...payload });
|
||||
} else if (channel === "netcatty:transfer:cancelled") {
|
||||
reportTransferEvent({ type: "cancelled", ...payload, endedAt: Date.now() });
|
||||
if (!cancellationReported) reportTransferEvent({ type: "cancelled", ...payload, endedAt: Date.now() });
|
||||
cancellationReported = true;
|
||||
}
|
||||
},
|
||||
};
|
||||
const useOuterAdmissionUpload = !!(
|
||||
shouldProxySessionBackedSftpToWorker(params) && transferBridge?.runAdmittedTransfer
|
||||
);
|
||||
const useOuterAdmissionUpload = typeof transferBridge?.runAdmittedTransfer === "function";
|
||||
const runUpload = (skipAdmission) => withSessionBackedSftp(
|
||||
params,
|
||||
(payload) => transferBridge
|
||||
@@ -421,6 +461,7 @@ function createSftpHandlerApi(ctx) {
|
||||
})
|
||||
: sftpBridge.uploadLocalToSftp(null, payload),
|
||||
{
|
||||
...executionContext,
|
||||
timeoutMs: commandTimeoutMs,
|
||||
operationName: "SFTP upload",
|
||||
workerChannel: transferBridge ? "netcatty:transfer:start" : "netcatty:sftp:uploadLocal",
|
||||
@@ -439,16 +480,16 @@ function createSftpHandlerApi(ctx) {
|
||||
);
|
||||
const result = await (
|
||||
useOuterAdmissionUpload
|
||||
? transferBridge.runAdmittedTransfer(
|
||||
? runAdmittedSessionTransfer(
|
||||
params,
|
||||
{ sender },
|
||||
{ transferId, targetHostId, globalConcurrency: transferBridge.getGlobalTransferConcurrency?.() },
|
||||
undefined,
|
||||
() => runUpload(true),
|
||||
)
|
||||
: runUpload(false)
|
||||
);
|
||||
if (result?.cancelled || result?.error === "Transfer cancelled") {
|
||||
reportTransferEvent({ type: "cancelled", transferId, endedAt: Date.now() });
|
||||
if (!cancellationReported) reportTransferEvent({ type: "cancelled", transferId, endedAt: Date.now() });
|
||||
return { ok: false, cancelled: true, transferId };
|
||||
}
|
||||
if (result?.error) throw new Error(result.error);
|
||||
@@ -460,63 +501,63 @@ function createSftpHandlerApi(ctx) {
|
||||
}
|
||||
}
|
||||
|
||||
async function handleSftpMkdir(params) {
|
||||
async function handleSftpMkdir(params, executionContext = {}) {
|
||||
if (!params?.path) throw new Error("path is required");
|
||||
await withSessionBackedSftp(
|
||||
params,
|
||||
(payload) => sftpBridge.mkdirSftp(null, payload),
|
||||
{ timeoutMs: commandTimeoutMs, operationName: "SFTP mkdir", workerChannel: "netcatty:sftp:mkdir" },
|
||||
{ ...executionContext, timeoutMs: commandTimeoutMs, operationName: "SFTP mkdir", workerChannel: "netcatty:sftp:mkdir" },
|
||||
);
|
||||
return { ok: true, path: params.path };
|
||||
}
|
||||
|
||||
async function handleSftpDelete(params) {
|
||||
async function handleSftpDelete(params, executionContext = {}) {
|
||||
if (!params?.path) throw new Error("path is required");
|
||||
await withSessionBackedSftp(
|
||||
params,
|
||||
(payload) => sftpBridge.deleteSftp(null, payload),
|
||||
{ timeoutMs: commandTimeoutMs, operationName: "SFTP delete", workerChannel: "netcatty:sftp:delete" },
|
||||
{ ...executionContext, timeoutMs: commandTimeoutMs, operationName: "SFTP delete", workerChannel: "netcatty:sftp:delete" },
|
||||
);
|
||||
return { ok: true, path: params.path };
|
||||
}
|
||||
|
||||
async function handleSftpRename(params) {
|
||||
async function handleSftpRename(params, executionContext = {}) {
|
||||
if (!params?.oldPath || !params?.newPath) {
|
||||
throw new Error("oldPath and newPath are required");
|
||||
}
|
||||
await withSessionBackedSftp(
|
||||
params,
|
||||
(payload) => sftpBridge.renameSftp(null, payload),
|
||||
{ timeoutMs: commandTimeoutMs, operationName: "SFTP rename", workerChannel: "netcatty:sftp:rename" },
|
||||
{ ...executionContext, timeoutMs: commandTimeoutMs, operationName: "SFTP rename", workerChannel: "netcatty:sftp:rename" },
|
||||
);
|
||||
return { ok: true, oldPath: params.oldPath, newPath: params.newPath };
|
||||
}
|
||||
|
||||
async function handleSftpStat(params) {
|
||||
async function handleSftpStat(params, executionContext = {}) {
|
||||
if (!params?.path) throw new Error("path is required");
|
||||
const stat = await withSessionBackedSftp(
|
||||
params,
|
||||
(payload) => sftpBridge.statSftp(null, payload),
|
||||
{ timeoutMs: commandTimeoutMs, operationName: "SFTP stat", workerChannel: "netcatty:sftp:stat" },
|
||||
{ ...executionContext, timeoutMs: commandTimeoutMs, operationName: "SFTP stat", workerChannel: "netcatty:sftp:stat" },
|
||||
);
|
||||
return { ok: true, stat };
|
||||
}
|
||||
|
||||
async function handleSftpChmod(params) {
|
||||
async function handleSftpChmod(params, executionContext = {}) {
|
||||
if (!params?.path || !params?.mode) throw new Error("path and mode are required");
|
||||
await withSessionBackedSftp(
|
||||
params,
|
||||
(payload) => sftpBridge.chmodSftp(null, payload),
|
||||
{ timeoutMs: commandTimeoutMs, operationName: "SFTP chmod", workerChannel: "netcatty:sftp:chmod" },
|
||||
{ ...executionContext, timeoutMs: commandTimeoutMs, operationName: "SFTP chmod", workerChannel: "netcatty:sftp:chmod" },
|
||||
);
|
||||
return { ok: true, path: params.path, mode: params.mode };
|
||||
}
|
||||
|
||||
async function handleSftpHome(params) {
|
||||
async function handleSftpHome(params, executionContext = {}) {
|
||||
const result = await withSessionBackedSftp(
|
||||
params,
|
||||
(payload) => sftpBridge.getSftpHomeDir(null, payload),
|
||||
{ timeoutMs: commandTimeoutMs, operationName: "SFTP home", workerChannel: "netcatty:sftp:homeDir" },
|
||||
{ ...executionContext, timeoutMs: commandTimeoutMs, operationName: "SFTP home", workerChannel: "netcatty:sftp:homeDir" },
|
||||
);
|
||||
if (!result?.success) {
|
||||
throw new Error(result?.error || "Could not determine home directory");
|
||||
|
||||
@@ -98,6 +98,34 @@ function runWhenProxyConnectionReady(socket, callback) {
|
||||
callback();
|
||||
}
|
||||
|
||||
// Bytes after the proxy reply (often the SSH banner) can share the reply's
|
||||
// TCP packet. Put them back for ssh2. Pause first so the tail stays queued
|
||||
// until the SSH client resumes the socket.
|
||||
function restoreTrailingSocketBytes(socket, trailing) {
|
||||
if (!trailing || trailing.length === 0) return;
|
||||
socket.pause();
|
||||
socket.unshift(Buffer.from(trailing));
|
||||
}
|
||||
|
||||
// SOCKS5 connect reply: VER REP RSV ATYP BND.ADDR BND.PORT.
|
||||
// Returns the reply size, null when the buffer is short, or -1 when ATYP is
|
||||
// not a form we can bound.
|
||||
function socks5ConnectReplyLength(buffer) {
|
||||
if (buffer.length < 4) return null;
|
||||
const addressType = buffer[3];
|
||||
let addressLength;
|
||||
if (addressType === 0x01) addressLength = 4;
|
||||
else if (addressType === 0x04) addressLength = 16;
|
||||
else if (addressType === 0x03) {
|
||||
if (buffer.length < 5) return null;
|
||||
addressLength = 1 + buffer[4];
|
||||
} else {
|
||||
return -1;
|
||||
}
|
||||
const total = 4 + addressLength + 2;
|
||||
return buffer.length < total ? null : total;
|
||||
}
|
||||
|
||||
function createProxyCommandSocket(proxy, targetHost, targetPort, options = {}) {
|
||||
const command = substituteProxyCommand(proxy.command, targetHost, targetPort, { platform: process.platform }).trim();
|
||||
if (!command) return Promise.reject(new Error("ProxyCommand is required"));
|
||||
@@ -238,16 +266,18 @@ function createProxySocket(proxy, targetHost, targetPort, options = {}) {
|
||||
const connectRequest = `CONNECT ${targetHost}:${targetPort} HTTP/1.1\r\nHost: ${targetHost}:${targetPort}\r\n${authHeader}\r\n`;
|
||||
socket.write(connectRequest);
|
||||
|
||||
let response = '';
|
||||
let pending = Buffer.alloc(0);
|
||||
const onData = (data) => {
|
||||
response += data.toString();
|
||||
if (response.includes('\r\n\r\n')) {
|
||||
socket.removeListener('data', onData);
|
||||
if (response.startsWith('HTTP/1.1 200') || response.startsWith('HTTP/1.0 200')) {
|
||||
settleResolve();
|
||||
} else {
|
||||
settleReject(new Error(`HTTP proxy error: ${response.split('\r\n')[0]}`));
|
||||
}
|
||||
pending = pending.length === 0 ? data : Buffer.concat([pending, data]);
|
||||
const headerEnd = pending.indexOf('\r\n\r\n');
|
||||
if (headerEnd === -1) return;
|
||||
socket.removeListener('data', onData);
|
||||
const header = pending.toString('latin1', 0, headerEnd);
|
||||
if (header.startsWith('HTTP/1.1 200') || header.startsWith('HTTP/1.0 200')) {
|
||||
restoreTrailingSocketBytes(socket, pending.subarray(headerEnd + 4));
|
||||
settleResolve();
|
||||
} else {
|
||||
settleReject(new Error(`HTTP proxy error: ${header.split('\r\n')[0]}`));
|
||||
}
|
||||
};
|
||||
socket.on('data', onData);
|
||||
@@ -265,6 +295,7 @@ function createProxySocket(proxy, targetHost, targetPort, options = {}) {
|
||||
socket.write(Buffer.from([0x05, authMethods.length, ...authMethods]));
|
||||
|
||||
let step = 'greeting';
|
||||
let connectReply = Buffer.alloc(0);
|
||||
const onData = (data) => {
|
||||
if (step === 'greeting') {
|
||||
if (data[0] !== 0x05) {
|
||||
@@ -298,8 +329,18 @@ function createProxySocket(proxy, targetHost, targetPort, options = {}) {
|
||||
step = 'connect';
|
||||
sendConnectRequest();
|
||||
} else if (step === 'connect') {
|
||||
connectReply = connectReply.length === 0
|
||||
? data
|
||||
: Buffer.concat([connectReply, data]);
|
||||
const replyLength = socks5ConnectReplyLength(connectReply);
|
||||
if (replyLength === null) return;
|
||||
socket.removeListener('data', onData);
|
||||
if (data[1] === 0x00) {
|
||||
if (replyLength < 0) {
|
||||
settleReject(new Error('Invalid SOCKS5 connect reply'));
|
||||
return;
|
||||
}
|
||||
if (connectReply[1] === 0x00) {
|
||||
restoreTrailingSocketBytes(socket, connectReply.subarray(replyLength));
|
||||
settleResolve();
|
||||
} else {
|
||||
const errors = {
|
||||
@@ -312,7 +353,7 @@ function createProxySocket(proxy, targetHost, targetPort, options = {}) {
|
||||
0x07: 'Command not supported',
|
||||
0x08: 'Address type not supported',
|
||||
};
|
||||
settleReject(new Error(`SOCKS5 error: ${errors[data[1]] || 'Unknown'}`));
|
||||
settleReject(new Error(`SOCKS5 error: ${errors[connectReply[1]] || 'Unknown'}`));
|
||||
}
|
||||
}
|
||||
};
|
||||
|
||||
@@ -3,6 +3,7 @@ const assert = require("node:assert/strict");
|
||||
const { EventEmitter } = require("node:events");
|
||||
const { once } = require("node:events");
|
||||
const net = require("node:net");
|
||||
const { Client } = require("ssh2");
|
||||
|
||||
const {
|
||||
createProxySocket,
|
||||
@@ -10,6 +11,52 @@ const {
|
||||
substituteProxyCommand,
|
||||
} = require("./proxyUtils.cjs");
|
||||
|
||||
const SSH_BANNER = Buffer.from("SSH-2.0-Fixture\r\n");
|
||||
|
||||
function listen(server) {
|
||||
return new Promise((resolve, reject) => {
|
||||
server.once("error", reject);
|
||||
server.listen(0, "127.0.0.1", () => resolve(server.address().port));
|
||||
});
|
||||
}
|
||||
|
||||
function closeServer(server) {
|
||||
return new Promise((resolve) => server.close(resolve));
|
||||
}
|
||||
|
||||
// ssh2 only records the peer banner after it parses an identification line.
|
||||
// "Connection lost before handshake" means those bytes never reached it.
|
||||
function remoteIdent(socket) {
|
||||
return new Promise((resolve, reject) => {
|
||||
const client = new Client();
|
||||
let settled = false;
|
||||
const finish = (error, ident) => {
|
||||
if (settled) return;
|
||||
settled = true;
|
||||
clearTimeout(timer);
|
||||
setImmediate(() => {
|
||||
try { client.destroy(); } catch { /* ignore */ }
|
||||
});
|
||||
if (error) reject(error);
|
||||
else resolve(ident);
|
||||
};
|
||||
const timer = setTimeout(() => finish(new Error("Timed out waiting for remote SSH ident")), 1500);
|
||||
timer.unref();
|
||||
client.on("error", (err) => {
|
||||
if (/Connection lost before handshake/.test(err.message)) finish(err);
|
||||
});
|
||||
client.connect({
|
||||
sock: socket,
|
||||
username: "fixture",
|
||||
readyTimeout: 1500,
|
||||
debug(message) {
|
||||
const match = /Remote ident: (.*)/.exec(message);
|
||||
if (match) finish(null, match[1]);
|
||||
},
|
||||
});
|
||||
});
|
||||
}
|
||||
|
||||
test("substituteProxyCommand replaces OpenSSH-style host and port tokens for POSIX shells", () => {
|
||||
assert.equal(
|
||||
substituteProxyCommand(
|
||||
@@ -183,3 +230,57 @@ test("createProxySocket times out stalled HTTP proxy handshakes", async (t) => {
|
||||
);
|
||||
assert.equal(socketDestroyed, true);
|
||||
});
|
||||
|
||||
test("HTTP CONNECT keeps an SSH banner coalesced with the proxy reply", async (t) => {
|
||||
const sockets = new Set();
|
||||
const proxy = net.createServer((socket) => {
|
||||
sockets.add(socket);
|
||||
socket.on("error", () => {});
|
||||
socket.once("data", () => {
|
||||
socket.write(Buffer.concat([
|
||||
Buffer.from("HTTP/1.1 200 Connection Established\r\n\r\n"),
|
||||
SSH_BANNER,
|
||||
]));
|
||||
});
|
||||
});
|
||||
t.after(async () => {
|
||||
for (const socket of sockets) socket.destroy();
|
||||
await closeServer(proxy);
|
||||
});
|
||||
const port = await listen(proxy);
|
||||
const socket = await createProxySocket(
|
||||
{ type: "http", host: "127.0.0.1", port },
|
||||
"203.0.113.10",
|
||||
22,
|
||||
);
|
||||
sockets.add(socket);
|
||||
assert.match(await remoteIdent(socket), /SSH-2\.0-Fixture/);
|
||||
});
|
||||
|
||||
test("SOCKS5 keeps an SSH banner coalesced with the connect reply", async (t) => {
|
||||
const sockets = new Set();
|
||||
const proxy = net.createServer((socket) => {
|
||||
sockets.add(socket);
|
||||
socket.on("error", () => {});
|
||||
socket.once("data", () => {
|
||||
socket.write(Buffer.from([0x05, 0x00]));
|
||||
socket.once("data", () => {
|
||||
// IPv4 bind address is 10 bytes; the banner shares this write.
|
||||
const reply = Buffer.from([0x05, 0x00, 0x00, 0x01, 127, 0, 0, 1, 0, 22]);
|
||||
socket.write(Buffer.concat([reply, SSH_BANNER]));
|
||||
});
|
||||
});
|
||||
});
|
||||
t.after(async () => {
|
||||
for (const socket of sockets) socket.destroy();
|
||||
await closeServer(proxy);
|
||||
});
|
||||
const port = await listen(proxy);
|
||||
const socket = await createProxySocket(
|
||||
{ type: "socks5", host: "127.0.0.1", port },
|
||||
"203.0.113.10",
|
||||
22,
|
||||
);
|
||||
sockets.add(socket);
|
||||
assert.match(await remoteIdent(socket), /SSH-2\.0-Fixture/);
|
||||
});
|
||||
@@ -27,100 +27,279 @@ function dockerCommandError(result, fallback) {
|
||||
return (result?.stderr || result?.error || "").trim() || fallback;
|
||||
}
|
||||
|
||||
function isDockerSocketPermissionError(result) {
|
||||
const text = `${result?.stderr || ""}\n${result?.stdout || ""}\n${result?.error || ""}`.toLowerCase();
|
||||
if (!text.includes("permission denied")) return false;
|
||||
return text.includes("docker daemon")
|
||||
|| text.includes("docker.sock")
|
||||
|| text.includes("/var/run/docker.sock")
|
||||
|| text.includes("connect to the docker daemon");
|
||||
}
|
||||
|
||||
function getSessionSudoPassword(session) {
|
||||
return typeof session?.systemManagerSudoPassword === "string" && session.systemManagerSudoPassword.length > 0
|
||||
? session.systemManagerSudoPassword
|
||||
: null;
|
||||
}
|
||||
|
||||
function buildDockerCommand(args) {
|
||||
return `docker ${args}`.trim();
|
||||
const {
|
||||
buildRuntimeCommand,
|
||||
isNerdctlUnreachableError,
|
||||
isRuntimeSocketPermissionError,
|
||||
normalizeContainerRuntime,
|
||||
runtimeBinary,
|
||||
} = require("../../../domain/systemManager/containerRuntime.shared.cjs");
|
||||
|
||||
function userFacingRuntimeFailure(runtime, result, fallback) {
|
||||
const raw = dockerCommandError(result, fallback);
|
||||
if (runtime === "nerdctl" && isNerdctlUnreachableError(raw)) {
|
||||
console.error(`[netcatty] nerdctl: ${raw}`);
|
||||
return { success: false, errorCode: "nerdctlUnreachable" };
|
||||
}
|
||||
return {
|
||||
success: false,
|
||||
error: raw,
|
||||
stderr: result?.stderr,
|
||||
};
|
||||
}
|
||||
|
||||
function buildPasswordlessSudoDockerCommand(args) {
|
||||
return `sudo ${buildDockerCommand(args)}`;
|
||||
function failurePayload(result) {
|
||||
const payload = { success: false };
|
||||
if (typeof result?.error === "string" && result.error) payload.error = result.error;
|
||||
if (typeof result?.errorCode === "string" && result.errorCode) payload.errorCode = result.errorCode;
|
||||
if (!payload.error && !payload.errorCode) payload.error = "command failed";
|
||||
return payload;
|
||||
}
|
||||
|
||||
function buildSudoDockerCommand(args) {
|
||||
return `sudo -S -p '' ${buildDockerCommand(args)}`;
|
||||
}
|
||||
|
||||
function parseDockerContainers(stdout) {
|
||||
const containers = [];
|
||||
for (const line of (stdout || "").split("\n")) {
|
||||
const trimmed = line.trim();
|
||||
if (!trimmed) continue;
|
||||
function parseJsonRecords(stdout) {
|
||||
const raw = String(stdout || "").trim();
|
||||
if (!raw) return [];
|
||||
if (raw.startsWith("[") || raw.startsWith("{")) {
|
||||
try {
|
||||
const parsed = JSON.parse(raw);
|
||||
if (Array.isArray(parsed)) return parsed.filter((row) => row && typeof row === "object");
|
||||
if (parsed && typeof parsed === "object") return [parsed];
|
||||
} catch {
|
||||
// Fall through to JSON lines when the CLI emits one object per line.
|
||||
}
|
||||
}
|
||||
const rows = [];
|
||||
for (const line of raw.split("\n")) {
|
||||
const trimmed = line.trim().replace(/,$/, "");
|
||||
if (!trimmed || trimmed === "[" || trimmed === "]") continue;
|
||||
try {
|
||||
const row = JSON.parse(trimmed);
|
||||
containers.push({
|
||||
id: row.ID || row.Id || "",
|
||||
name: (row.Names || row.Name || "").replace(/^\//, ""),
|
||||
image: row.Image || "",
|
||||
status: row.Status || row.State || "",
|
||||
state: row.State || "",
|
||||
ports: row.Ports || "",
|
||||
createdAt: row.CreatedAt || row.Created || "",
|
||||
});
|
||||
if (Array.isArray(row)) rows.push(...row.filter((item) => item && typeof item === "object"));
|
||||
else if (row && typeof row === "object") rows.push(row);
|
||||
} catch {
|
||||
// skip malformed line
|
||||
}
|
||||
}
|
||||
return containers;
|
||||
return rows;
|
||||
}
|
||||
|
||||
function portInRange(value) {
|
||||
const port = Number(value);
|
||||
return Number.isInteger(port) && port >= 1 && port <= 65535 ? port : null;
|
||||
}
|
||||
|
||||
function formatHostBind(ip, hostLabel) {
|
||||
const bind = ip || "0.0.0.0";
|
||||
const host = bind.includes(":") && !bind.startsWith("[") ? `[${bind}]` : bind;
|
||||
return `${host}:${hostLabel}`;
|
||||
}
|
||||
|
||||
function formatPortMapping(mapping) {
|
||||
if (!mapping || typeof mapping !== "object") return [];
|
||||
const containerPort = portInRange(mapping.container_port ?? mapping.containerPort ?? mapping.ContainerPort ?? mapping.PrivatePort);
|
||||
if (containerPort === null) return [];
|
||||
const hostPort = portInRange(mapping.host_port ?? mapping.hostPort ?? mapping.HostPort ?? mapping.PublicPort);
|
||||
const requestedRange = Number(mapping.range ?? mapping.Range);
|
||||
const range = Number.isInteger(requestedRange) && requestedRange > 1 ? requestedRange : 1;
|
||||
if (containerPort + range - 1 > 65535) return [];
|
||||
if (hostPort !== null && hostPort + range - 1 > 65535) return [];
|
||||
const hostIp = String(mapping.host_ip ?? mapping.hostIP ?? mapping.HostIP ?? mapping.HostIp ?? mapping.IP ?? "").trim();
|
||||
const protocols = String(mapping.protocol ?? mapping.Protocol ?? mapping.Type ?? "tcp")
|
||||
.split(",")
|
||||
.map((item) => item.trim())
|
||||
.filter(Boolean);
|
||||
const protocolList = protocols.length > 0 ? protocols : ["tcp"];
|
||||
const containerLabel = range > 1 ? `${containerPort}-${containerPort + range - 1}` : String(containerPort);
|
||||
const hostLabel = hostPort === null
|
||||
? ""
|
||||
: (range > 1 ? `${hostPort}-${hostPort + range - 1}` : String(hostPort));
|
||||
return protocolList.map((protocol) => {
|
||||
const keys = [];
|
||||
for (let offset = 0; offset < range; offset += 1) keys.push(`${containerPort + offset}/${protocol}`);
|
||||
const label = hostLabel
|
||||
? `${formatHostBind(hostIp, hostLabel)}->${containerLabel}/${protocol}`
|
||||
: `${containerLabel}/${protocol}`;
|
||||
return { label, keys };
|
||||
});
|
||||
}
|
||||
|
||||
function formatExposedPorts(exposed, published) {
|
||||
if (!exposed || typeof exposed !== "object" || Array.isArray(exposed)) return [];
|
||||
const entries = [];
|
||||
for (const [key, value] of Object.entries(exposed)) {
|
||||
const slash = key.indexOf("/");
|
||||
if (slash > 0) {
|
||||
const port = portInRange(key.slice(0, slash));
|
||||
const protocol = key.slice(slash + 1).trim() || "tcp";
|
||||
if (port !== null) entries.push({ port, protocol });
|
||||
continue;
|
||||
}
|
||||
const port = portInRange(key);
|
||||
if (port === null) continue;
|
||||
const protocols = Array.isArray(value) && value.length > 0 ? value : ["tcp"];
|
||||
for (const protocol of protocols) {
|
||||
const name = String(protocol || "tcp").trim();
|
||||
if (name) entries.push({ port, protocol: name });
|
||||
}
|
||||
}
|
||||
entries.sort((left, right) => left.port - right.port || left.protocol.localeCompare(right.protocol));
|
||||
const labels = [];
|
||||
for (const entry of entries) {
|
||||
const label = `${entry.port}/${entry.protocol}`;
|
||||
if (published.has(label) || labels.includes(label)) continue;
|
||||
labels.push(label);
|
||||
}
|
||||
return labels;
|
||||
}
|
||||
|
||||
function formatContainerPorts(row) {
|
||||
const ports = row.Ports ?? row.ports;
|
||||
if (typeof ports === "string") return ports;
|
||||
const labels = [];
|
||||
const published = new Set();
|
||||
if (Array.isArray(ports)) {
|
||||
for (const mapping of ports) {
|
||||
for (const formatted of formatPortMapping(mapping)) {
|
||||
if (labels.includes(formatted.label)) continue;
|
||||
labels.push(formatted.label);
|
||||
for (const key of formatted.keys) published.add(key);
|
||||
}
|
||||
}
|
||||
}
|
||||
labels.push(...formatExposedPorts(row.ExposedPorts ?? row.exposedPorts, published));
|
||||
return labels.join(", ");
|
||||
}
|
||||
|
||||
function parseDockerContainers(stdout) {
|
||||
return parseJsonRecords(stdout).map((row) => ({
|
||||
id: row.ID || row.Id || "",
|
||||
name: String(Array.isArray(row.Names) ? (row.Names[0] || "") : (row.Names || row.Name || "")).replace(/^\//, ""),
|
||||
image: row.Image || "",
|
||||
status: row.Status || row.State || "",
|
||||
state: row.State || "",
|
||||
ports: formatContainerPorts(row),
|
||||
createdAt: row.CreatedAt || row.Created || "",
|
||||
})).filter((container) => container.id || container.name);
|
||||
}
|
||||
|
||||
function parsePercent(value) {
|
||||
if (typeof value === "number" && Number.isFinite(value)) return value;
|
||||
return parseFloat(String(value || "0").replace("%", "")) || 0;
|
||||
}
|
||||
|
||||
function sumNetworkBytes(network) {
|
||||
if (!network || typeof network !== "object" || Array.isArray(network)) return null;
|
||||
let rx = 0;
|
||||
let tx = 0;
|
||||
let saw = false;
|
||||
for (const stats of Object.values(network)) {
|
||||
if (!stats || typeof stats !== "object") continue;
|
||||
const rxBytes = stats.RxBytes ?? stats.rx_bytes;
|
||||
const txBytes = stats.TxBytes ?? stats.tx_bytes;
|
||||
if (typeof rxBytes === "number" && Number.isFinite(rxBytes)) {
|
||||
rx += rxBytes;
|
||||
saw = true;
|
||||
}
|
||||
if (typeof txBytes === "number" && Number.isFinite(txBytes)) {
|
||||
tx += txBytes;
|
||||
saw = true;
|
||||
}
|
||||
}
|
||||
return saw ? { rx, tx } : null;
|
||||
}
|
||||
|
||||
function parseDockerStats(stdout) {
|
||||
const stats = [];
|
||||
for (const line of (stdout || "").split("\n")) {
|
||||
const trimmed = line.trim();
|
||||
if (!trimmed) continue;
|
||||
try {
|
||||
const row = JSON.parse(trimmed);
|
||||
stats.push({
|
||||
id: row.ID || row.Container || "",
|
||||
name: row.Name || "",
|
||||
cpuPercent: parseFloat(String(row.CPUPerc || "0").replace("%", "")) || 0,
|
||||
memUsage: row.MemUsage || "",
|
||||
memPercent: parseFloat(String(row.MemPerc || "0").replace("%", "")) || 0,
|
||||
netIO: row.NetIO || "",
|
||||
blockIO: row.BlockIO || "",
|
||||
pids: Number(row.PIDs || row.Pids || 0) || 0,
|
||||
});
|
||||
} catch {
|
||||
// skip
|
||||
}
|
||||
return parseJsonRecords(stdout).map((row) => {
|
||||
const memUsage = typeof row.MemUsage === "number" && typeof row.MemLimit === "number"
|
||||
? `${formatImageSize(row.MemUsage)} / ${formatImageSize(row.MemLimit)}`
|
||||
: String(row.MemUsage || row.mem_usage || "");
|
||||
const networkTotals = sumNetworkBytes(row.Network || row.network);
|
||||
const netIO = row.NetIO || row.net_io || row.netio
|
||||
|| (networkTotals
|
||||
? `${formatImageSize(networkTotals.rx)} / ${formatImageSize(networkTotals.tx)}`
|
||||
: (typeof row.NetInput === "number" && typeof row.NetOutput === "number"
|
||||
? `${formatImageSize(row.NetInput)} / ${formatImageSize(row.NetOutput)}`
|
||||
: ""));
|
||||
const blockIO = row.BlockIO || row.block_io
|
||||
|| (typeof row.BlockInput === "number" && typeof row.BlockOutput === "number"
|
||||
? `${formatImageSize(row.BlockInput)} / ${formatImageSize(row.BlockOutput)}`
|
||||
: "");
|
||||
return {
|
||||
id: String(row.ID || row.Id || row.id || row.ContainerID || row.Container || ""),
|
||||
name: String(row.Name || row.name || ""),
|
||||
cpuPercent: parsePercent(row.CPUPerc ?? row.cpu_percent ?? row.CPU),
|
||||
memUsage,
|
||||
memPercent: parsePercent(row.MemPerc ?? row.mem_percent),
|
||||
netIO: String(netIO),
|
||||
blockIO: String(blockIO),
|
||||
pids: Number(row.PIDs || row.Pids || row.pids || 0) || 0,
|
||||
};
|
||||
}).filter((stat) => stat.id || stat.name);
|
||||
}
|
||||
|
||||
function splitImageName(name) {
|
||||
const text = String(name || "");
|
||||
const slash = text.lastIndexOf("/");
|
||||
const colon = text.lastIndexOf(":");
|
||||
if (colon > slash) {
|
||||
return { repository: text.slice(0, colon), tag: text.slice(colon + 1) };
|
||||
}
|
||||
return stats;
|
||||
return { repository: text, tag: "" };
|
||||
}
|
||||
|
||||
function imageAliases(row) {
|
||||
if (row.Repository || row.Tag) {
|
||||
return [{ repository: String(row.Repository || ""), tag: String(row.Tag || "") }];
|
||||
}
|
||||
const names = Array.isArray(row.Names) && row.Names.length > 0
|
||||
? row.Names
|
||||
: (Array.isArray(row.RepoTags) ? row.RepoTags : []);
|
||||
const aliases = names.filter(Boolean).map((name) => splitImageName(name));
|
||||
return aliases.length > 0 ? aliases : [{ repository: "", tag: "" }];
|
||||
}
|
||||
|
||||
function formatImageSize(size) {
|
||||
if (typeof size !== "number" || !Number.isFinite(size)) return String(size || "");
|
||||
const units = ["B", "KB", "MB", "GB", "TB"];
|
||||
let value = size;
|
||||
let unit = 0;
|
||||
while (value >= 1024 && unit < units.length - 1) {
|
||||
value /= 1024;
|
||||
unit += 1;
|
||||
}
|
||||
const rounded = unit === 0 || value >= 10 ? Math.round(value) : Math.round(value * 10) / 10;
|
||||
return `${rounded}${units[unit]}`;
|
||||
}
|
||||
|
||||
function formatImageCreated(row) {
|
||||
if (row.CreatedAt) return String(row.CreatedAt);
|
||||
if (row.CreatedSince) return String(row.CreatedSince);
|
||||
if (typeof row.Created === "number" && Number.isFinite(row.Created)) {
|
||||
const ms = row.Created > 1e12 ? row.Created : row.Created * 1000;
|
||||
return new Date(ms).toISOString();
|
||||
}
|
||||
if (typeof row.Created === "string") return row.Created;
|
||||
return "";
|
||||
}
|
||||
|
||||
function parseDockerImages(stdout) {
|
||||
const images = [];
|
||||
for (const line of (stdout || "").split("\n")) {
|
||||
const trimmed = line.trim();
|
||||
if (!trimmed) continue;
|
||||
try {
|
||||
const row = JSON.parse(trimmed);
|
||||
const repository = row.Repository || "";
|
||||
const tag = row.Tag || "";
|
||||
images.push({
|
||||
id: row.ID || row.Id || "",
|
||||
repository,
|
||||
tag,
|
||||
size: row.Size || "",
|
||||
createdAt: row.CreatedAt || row.CreatedSince || "",
|
||||
digest: row.Digest || "",
|
||||
name: repository && tag ? `${repository}:${tag}` : repository || tag || row.ID || "",
|
||||
});
|
||||
} catch {
|
||||
// skip
|
||||
for (const row of parseJsonRecords(stdout)) {
|
||||
const id = String(row.ID || row.Id || "");
|
||||
const size = formatImageSize(row.Size);
|
||||
const createdAt = formatImageCreated(row);
|
||||
const digest = String(row.Digest || (Array.isArray(row.RepoDigests) ? (row.RepoDigests[0] || "") : ""));
|
||||
for (const alias of imageAliases(row)) {
|
||||
const { repository, tag } = alias;
|
||||
const name = repository && tag ? `${repository}:${tag}` : repository || tag || id;
|
||||
if (!id && !name) continue;
|
||||
images.push({ id, repository, tag, size, createdAt, digest, name });
|
||||
}
|
||||
}
|
||||
return images;
|
||||
@@ -168,19 +347,21 @@ function summarizeContainerInspect(info) {
|
||||
}
|
||||
|
||||
function createDockerOpsApi({ execOnSession, getSession }) {
|
||||
async function runDocker(event, sessionId, args, timeoutMs = 15000) {
|
||||
const cmd = buildDockerCommand(args);
|
||||
async function runDocker(event, sessionId, args, timeoutMs = 15000, runtime) {
|
||||
const safeRuntime = normalizeContainerRuntime(runtime);
|
||||
const bin = runtimeBinary(safeRuntime);
|
||||
const cmd = buildRuntimeCommand(safeRuntime, args);
|
||||
const result = await execOnSession(event, sessionId, cmd, timeoutMs);
|
||||
if (isSuccessfulCommandResult(result)) return result;
|
||||
|
||||
if (isDockerSocketPermissionError(result)) {
|
||||
if (isRuntimeSocketPermissionError(safeRuntime, result)) {
|
||||
const sudoPassword = getSessionSudoPassword(getSession?.(sessionId));
|
||||
let lastSudoResult = null;
|
||||
|
||||
const nopasswdResult = await execOnSession(
|
||||
event,
|
||||
sessionId,
|
||||
buildPasswordlessSudoDockerCommand(args),
|
||||
`sudo ${cmd}`,
|
||||
timeoutMs,
|
||||
);
|
||||
if (isSuccessfulCommandResult(nopasswdResult)) return nopasswdResult;
|
||||
@@ -190,7 +371,7 @@ function createDockerOpsApi({ execOnSession, getSession }) {
|
||||
const sudoResult = await execOnSession(
|
||||
event,
|
||||
sessionId,
|
||||
buildSudoDockerCommand(args),
|
||||
`sudo -S -p '' ${cmd}`,
|
||||
timeoutMs,
|
||||
{ stdin: `${sudoPassword}\n` },
|
||||
);
|
||||
@@ -198,33 +379,34 @@ function createDockerOpsApi({ execOnSession, getSession }) {
|
||||
lastSudoResult = sudoResult;
|
||||
}
|
||||
|
||||
return {
|
||||
success: false,
|
||||
error: dockerCommandError(lastSudoResult, `sudo docker exited with code ${lastSudoResult?.code}`),
|
||||
stderr: lastSudoResult?.stderr,
|
||||
};
|
||||
return userFacingRuntimeFailure(
|
||||
safeRuntime,
|
||||
lastSudoResult,
|
||||
`sudo ${bin} exited with code ${lastSudoResult?.code}`,
|
||||
);
|
||||
}
|
||||
|
||||
if (!result.success) return result;
|
||||
if (!result.success) {
|
||||
if (safeRuntime === "nerdctl" && isNerdctlUnreachableError(dockerCommandError(result, ""))) {
|
||||
return userFacingRuntimeFailure(safeRuntime, result, `${bin} failed`);
|
||||
}
|
||||
return result;
|
||||
}
|
||||
if (result.code !== 0 && result.code !== null && result.code !== undefined) {
|
||||
return {
|
||||
success: false,
|
||||
error: dockerCommandError(result, `docker exited with code ${result.code}`),
|
||||
stderr: result.stderr,
|
||||
};
|
||||
return userFacingRuntimeFailure(safeRuntime, result, `${bin} exited with code ${result.code}`);
|
||||
}
|
||||
return result;
|
||||
}
|
||||
|
||||
async function listContainers(event, sessionId) {
|
||||
const result = await runDocker(event, sessionId, "ps -a --format '{{json .}}'", 12000);
|
||||
if (!result.success) return { success: false, error: result.error };
|
||||
async function listContainers(event, sessionId, runtime) {
|
||||
const result = await runDocker(event, sessionId, "ps -a --format '{{json .}}'", 12000, runtime);
|
||||
if (!result.success) return failurePayload(result);
|
||||
return { success: true, containers: parseDockerContainers(result.stdout) };
|
||||
}
|
||||
|
||||
async function listImages(event, sessionId) {
|
||||
const result = await runDocker(event, sessionId, "images --format '{{json .}}'", 12000);
|
||||
if (!result.success) return { success: false, error: result.error };
|
||||
async function listImages(event, sessionId, runtime) {
|
||||
const result = await runDocker(event, sessionId, "images --format '{{json .}}'", 12000, runtime);
|
||||
if (!result.success) return failurePayload(result);
|
||||
return { success: true, images: parseDockerImages(result.stdout) };
|
||||
}
|
||||
|
||||
@@ -238,8 +420,9 @@ function createDockerOpsApi({ execOnSession, getSession }) {
|
||||
sessionId,
|
||||
`stats --no-stream --format '{{json .}}' ${idArg}`.trim(),
|
||||
15000,
|
||||
payload?.runtime,
|
||||
);
|
||||
if (!result.success) return { success: false, error: result.error };
|
||||
if (!result.success) return failurePayload(result);
|
||||
return { success: true, stats: parseDockerStats(result.stdout) };
|
||||
}
|
||||
|
||||
@@ -247,8 +430,8 @@ function createDockerOpsApi({ execOnSession, getSession }) {
|
||||
const { sessionId, containerId } = payload || {};
|
||||
if (!sessionId || !containerId) return { success: false, error: "Missing params" };
|
||||
const safeId = sanitizeDockerId(containerId);
|
||||
const result = await runDocker(event, sessionId, `inspect ${safeId}`, 10000);
|
||||
if (!result.success) return { success: false, error: result.error };
|
||||
const result = await runDocker(event, sessionId, `inspect ${safeId}`, 10000, payload?.runtime);
|
||||
if (!result.success) return failurePayload(result);
|
||||
try {
|
||||
const parsed = JSON.parse(result.stdout || "[]");
|
||||
const info = Array.isArray(parsed) ? parsed[0] : parsed;
|
||||
@@ -262,8 +445,8 @@ function createDockerOpsApi({ execOnSession, getSession }) {
|
||||
const { sessionId, imageId } = payload || {};
|
||||
if (!sessionId || !imageId) return { success: false, error: "Missing params" };
|
||||
const safeId = sanitizeDockerId(imageId);
|
||||
const result = await runDocker(event, sessionId, `image inspect ${safeId}`, 10000);
|
||||
if (!result.success) return { success: false, error: result.error };
|
||||
const result = await runDocker(event, sessionId, `image inspect ${safeId}`, 10000, payload?.runtime);
|
||||
if (!result.success) return failurePayload(result);
|
||||
try {
|
||||
const parsed = JSON.parse(result.stdout || "[]");
|
||||
const info = Array.isArray(parsed) ? parsed[0] : parsed;
|
||||
@@ -278,25 +461,26 @@ function createDockerOpsApi({ execOnSession, getSession }) {
|
||||
if (!sessionId || !containerId || !action) return { success: false, error: "Missing params" };
|
||||
const safeId = sanitizeDockerId(containerId);
|
||||
|
||||
const runtime = payload?.runtime;
|
||||
switch (action) {
|
||||
case "start":
|
||||
return runDocker(event, sessionId, `start ${safeId}`);
|
||||
return runDocker(event, sessionId, `start ${safeId}`, 15000, runtime);
|
||||
case "stop":
|
||||
return runDocker(event, sessionId, `stop ${safeId}`);
|
||||
return runDocker(event, sessionId, `stop ${safeId}`, 15000, runtime);
|
||||
case "restart":
|
||||
return runDocker(event, sessionId, `restart ${safeId}`);
|
||||
return runDocker(event, sessionId, `restart ${safeId}`, 15000, runtime);
|
||||
case "rm":
|
||||
return runDocker(event, sessionId, `rm -f ${safeId}`);
|
||||
return runDocker(event, sessionId, `rm -f ${safeId}`, 15000, runtime);
|
||||
case "pause":
|
||||
return runDocker(event, sessionId, `pause ${safeId}`);
|
||||
return runDocker(event, sessionId, `pause ${safeId}`, 15000, runtime);
|
||||
case "unpause":
|
||||
return runDocker(event, sessionId, `unpause ${safeId}`);
|
||||
return runDocker(event, sessionId, `unpause ${safeId}`, 15000, runtime);
|
||||
case "kill":
|
||||
return runDocker(event, sessionId, `kill ${safeId}`);
|
||||
return runDocker(event, sessionId, `kill ${safeId}`, 15000, runtime);
|
||||
case "rename": {
|
||||
const next = sanitizeContainerName(newName);
|
||||
if (!next) return { success: false, error: "Invalid container name" };
|
||||
return runDocker(event, sessionId, `rename ${safeId} ${shQuote(next)}`);
|
||||
return runDocker(event, sessionId, `rename ${safeId} ${shQuote(next)}`, 15000, runtime);
|
||||
}
|
||||
default:
|
||||
return { success: false, error: `Invalid container action: ${action}` };
|
||||
@@ -307,21 +491,30 @@ function createDockerOpsApi({ execOnSession, getSession }) {
|
||||
const { sessionId, action, imageRef, imageId, force, all, repository, tag } = payload || {};
|
||||
if (!sessionId || !action) return { success: false, error: "Missing params" };
|
||||
|
||||
const runtime = payload?.runtime;
|
||||
switch (action) {
|
||||
case "pull": {
|
||||
const ref = sanitizeImageRef(imageRef);
|
||||
if (!ref) return { success: false, error: "Missing image reference" };
|
||||
return runDocker(event, sessionId, `pull ${shQuote(ref)}`, 600000);
|
||||
return runDocker(event, sessionId, `pull ${shQuote(ref)}`, 600000, runtime);
|
||||
}
|
||||
case "rm": {
|
||||
const repo = String(repository || "").trim();
|
||||
const tagName = String(tag || "").trim();
|
||||
const tagged = repo && tagName && repo !== "<none>" && tagName !== "<none>";
|
||||
if (tagged) {
|
||||
const ref = sanitizeImageRef(`${repo}:${tagName}`);
|
||||
if (!ref) return { success: false, error: "Invalid image reference" };
|
||||
return runDocker(event, sessionId, `rmi ${shQuote(ref)}`, 15000, runtime);
|
||||
}
|
||||
const safeId = sanitizeDockerId(imageId);
|
||||
if (!safeId) return { success: false, error: "Missing image id" };
|
||||
const forceFlag = force ? " -f" : "";
|
||||
return runDocker(event, sessionId, `rmi${forceFlag} ${safeId}`);
|
||||
return runDocker(event, sessionId, `rmi${forceFlag} ${safeId}`, 15000, runtime);
|
||||
}
|
||||
case "prune": {
|
||||
const allFlag = all ? " -a" : "";
|
||||
return runDocker(event, sessionId, `image prune${allFlag} -f`, 120000);
|
||||
return runDocker(event, sessionId, `image prune${allFlag} -f`, 120000, runtime);
|
||||
}
|
||||
case "tag": {
|
||||
const safeId = sanitizeDockerId(imageId);
|
||||
@@ -332,6 +525,8 @@ function createDockerOpsApi({ execOnSession, getSession }) {
|
||||
event,
|
||||
sessionId,
|
||||
`tag ${safeId} ${shQuote(`${repo}:${tagName}`)}`,
|
||||
15000,
|
||||
runtime,
|
||||
);
|
||||
}
|
||||
default:
|
||||
|
||||
Loaded 100 of 152 files, more files were not shown because too many files have changed in this diff.
Show more
Reference in new issue
Block a user