mirror of
https://github.com/binaricat/Netcatty.git
synced 2026-11-01 11:22:54 +00:00
Compare commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
b4b64d034d | ||
|
|
bac4dee640 | ||
|
|
fc795e720b | ||
|
|
83fce7cf39 | ||
|
|
7612142914 | ||
|
|
4a828bff71 |
No files matched your search
@@ -9,12 +9,6 @@
|
||||
<a href="https://netcatty.app"><strong>netcatty.app</strong></a>
|
||||
</p>
|
||||
|
||||
<p align="center">
|
||||
<a href="https://trendshift.io/repositories/18391?utm_source=trendshift-badge&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.
|
||||
|
||||
@@ -377,14 +377,6 @@ function AppViewInner({ domains }: AppViewProps) {
|
||||
appendHostToWorkspace(workspaceId, resolveWorkspaceAppendHost(host), rootDir);
|
||||
}, [appendHostToWorkspace, hosts, resolveWorkspaceAppendHost, workspaces]);
|
||||
|
||||
const pendingKeyboardRequest = keyboardInteractiveQueue[0] || null;
|
||||
const pendingTotpUri = pendingKeyboardRequest?.totpUri;
|
||||
const handleFillTotp = useCallback(async () => {
|
||||
if (!pendingTotpUri) return null;
|
||||
const result = await netcattyBridge.get()?.previewTotp?.({ uri: pendingTotpUri });
|
||||
return result?.ok && result.code ? result.code : null;
|
||||
}, [pendingTotpUri]);
|
||||
|
||||
const isPeerSessionWindow = typeof window !== 'undefined'
|
||||
&& window.location.hash.startsWith('#/session-window');
|
||||
const externalMcpToggle = useExternalMcpToggleState();
|
||||
@@ -1032,8 +1024,6 @@ function AppViewInner({ domains }: AppViewProps) {
|
||||
request={keyboardInteractiveQueue[0] || null}
|
||||
onSubmit={handleKeyboardInteractiveSubmit}
|
||||
onCancel={handleKeyboardInteractiveCancel}
|
||||
canFillTotp={Boolean(pendingTotpUri)}
|
||||
onFillTotp={handleFillTotp}
|
||||
/>
|
||||
{/* Indicator when more 2FA requests are pending */}
|
||||
{keyboardInteractiveQueue.length > 1 && (
|
||||
|
||||
@@ -554,9 +554,6 @@ export function useAppStartupEffects(ctx: StartupEffectsContext) {
|
||||
hostname: request.hostname,
|
||||
savedPassword: request.savedPassword,
|
||||
allowSavePassword: request.allowSavePassword !== false,
|
||||
...(typeof request.totpUri === "string" && request.totpUri && !request.totpUri.startsWith("enc:v1:")
|
||||
? { totpUri: request.totpUri }
|
||||
: {}),
|
||||
}]);
|
||||
});
|
||||
const unsubscribeCancelled = bridge.onKeyboardInteractiveCancelled?.((event) => {
|
||||
|
||||
@@ -289,14 +289,6 @@ export const enAiMessages: Messages = {
|
||||
'ai.opencode.customPathPlaceholder': 'e.g. /usr/local/bin/opencode',
|
||||
'ai.opencode.check': 'Check',
|
||||
'ai.opencode.resetPath': 'Reset',
|
||||
'ai.opencode.v2.startupTimeout': 'OpenCode 2 did not become ready in time. Confirm `opencode serve` works in a terminal, then retry.',
|
||||
'ai.opencode.v2.exited': 'OpenCode 2 stopped before the chat could start.',
|
||||
'ai.opencode.v2.unauthorized': 'OpenCode 2 refused the connection. Retry the chat.',
|
||||
'ai.opencode.v2.unavailable': 'OpenCode 2 could not be reached. Check that it is installed and try again.',
|
||||
'ai.opencode.v2.sessionFailed': 'OpenCode 2 could not finish this turn.',
|
||||
'ai.opencode.v2.empty': 'OpenCode 2 returned an empty response. Run `opencode` in a terminal to configure authentication and models.',
|
||||
'ai.opencode.v2.contextSkipped': 'OpenCode 2 could not apply the Netcatty conversation context.',
|
||||
'ai.opencode.v2.mcpSkipped': 'OpenCode 2 could not attach an MCP tool. The chat will continue without it.',
|
||||
|
||||
// AI MiMo Code (Xiaomi MiMo Code CLI — OpenCode fork)
|
||||
'ai.mimo.title': 'MiMo Code',
|
||||
@@ -384,22 +376,6 @@ export const enAiMessages: Messages = {
|
||||
'ai.externalMcp.help.ariaLabel': 'External MCP help',
|
||||
'ai.externalMcp.security': 'Security',
|
||||
'ai.externalMcp.security.description': 'Listens on 127.0.0.1 with a rotating token, reuses AI Permission Mode for writes, and removes discovery when disabled. No OAuth — this is a local desktop bridge.',
|
||||
"ai.privacy.title": "AI privacy",
|
||||
"ai.privacy.description": "Filters known sensitive text patterns in Netcatty-managed context and tool results for Catty, integrated agents and external MCP clients. Off by default. It cannot detect every secret. Binary tool responses are blocked while enabled.",
|
||||
"ai.privacy.boundary": "Only Netcatty-managed data and tools are covered. Third-party agents' native tools, direct file access and other MCP servers remain outside this boundary.",
|
||||
"ai.privacy.enable": "Enable text redaction",
|
||||
"ai.privacy.privateIps": "Redact private IP addresses",
|
||||
"ai.privacy.privateIps.description": "Also hide private and local IP addresses in returned text.",
|
||||
"ai.privacy.cidrs": "Additional IP ranges (CIDR)",
|
||||
"ai.privacy.cidrs.description": "One IPv4 or IPv6 CIDR per line. Leave empty to use only built-in rules.",
|
||||
"ai.privacy.patterns": "Custom regular expressions",
|
||||
"ai.privacy.patterns.description": "One RE2-compatible pattern per line, without / delimiters or flags. Unsupported or unsafe patterns are rejected.",
|
||||
"ai.privacy.connectionScope": "Placeholders stay stable within one managed conversation or external MCP connection. Original values are restored only through Netcatty-managed tool calls in that same scope.",
|
||||
"ai.privacy.reconnect": "Apply changes, then start a new managed conversation and reconnect external MCP clients. Existing conversations and connections must not reuse placeholders.",
|
||||
"ai.privacy.apply": "Apply",
|
||||
"ai.privacy.applying": "Applying...",
|
||||
"ai.privacy.applied": "AI privacy settings applied. Start a new managed conversation and reconnect external MCP clients.",
|
||||
"ai.privacy.failed": "AI privacy settings were not applied.",
|
||||
'ai.externalMcp.http.title': 'Streamable HTTP',
|
||||
'ai.externalMcp.http.description': 'Serve the same External MCP tools over Streamable HTTP for clients that cannot launch the stdio CLI, such as Codex inside WSL. Off by default.',
|
||||
'ai.externalMcp.http.enable': 'Enable HTTP',
|
||||
|
||||
@@ -864,8 +864,8 @@ Highlight the focused split pane:
|
||||
'settings.terminal.systemManager.processRefreshInterval.desc': 'How often to refresh the process list in the system manager side panel.',
|
||||
'settings.terminal.systemManager.tmuxRefreshInterval': 'tmux session refresh',
|
||||
'settings.terminal.systemManager.tmuxRefreshInterval.desc': 'How often to refresh the tmux session list.',
|
||||
'settings.terminal.systemManager.dockerListRefreshInterval': 'Container list refresh',
|
||||
'settings.terminal.systemManager.dockerListRefreshInterval.desc': 'How often to refresh container and pod lists.',
|
||||
'settings.terminal.systemManager.dockerListRefreshInterval': 'Docker container list refresh',
|
||||
'settings.terminal.systemManager.dockerListRefreshInterval.desc': 'How often to refresh the Docker container list.',
|
||||
'settings.terminal.systemManager.dockerStatsRefreshInterval': 'Docker stats refresh',
|
||||
'settings.terminal.systemManager.dockerStatsRefreshInterval.desc': 'How often to refresh Docker container CPU/memory/network stats.',
|
||||
'settings.terminal.serverStats.show': 'Show Server Stats',
|
||||
@@ -1180,7 +1180,6 @@ Highlight the focused split pane:
|
||||
'vault.hosts.moveSelected': 'Move to group ({count})',
|
||||
'vault.hosts.moveSelected.description': 'Move the selected hosts to an existing group, or leave them ungrouped. Selected groups are not moved.',
|
||||
'vault.hosts.moveMultiple.success': 'Moved {count} hosts to {group}',
|
||||
'vault.hosts.dragSelectionCount': '{count} hosts',
|
||||
'vault.hosts.moveToGroup.success': 'Moved {host} to {group}',
|
||||
'vault.hosts.errors.nameRequired': 'Host name is required.',
|
||||
'vault.hosts.empty.title': 'Set up your hosts',
|
||||
|
||||
@@ -26,11 +26,6 @@ export const enSystemManagerMessages: Messages = {
|
||||
'systemManager.errors.loadDocker': 'Failed to load containers',
|
||||
'systemManager.errors.loadDockerStats': 'Failed to load container stats',
|
||||
'systemManager.errors.loadDockerImages': 'Failed to load images',
|
||||
'systemManager.errors.loadKubernetes': 'Failed to load Kubernetes resources',
|
||||
'systemManager.errors.loadPods': 'Failed to load pods',
|
||||
'systemManager.errors.nerdctlUnreachable': "nerdctl can't reach containerd",
|
||||
'systemManager.errors.clusterUnreachable': "Can't reach the cluster for context {{context}}",
|
||||
'systemManager.errors.clusterUnreachableGeneric': "Can't reach the cluster",
|
||||
'systemManager.errors.loadOverview': 'Failed to load system overview',
|
||||
'systemManager.errors.loadGpu': 'Failed to load GPU / NPU stats',
|
||||
'systemManager.errors.loadPorts': 'Failed to load listening ports',
|
||||
@@ -137,7 +132,7 @@ export const enSystemManagerMessages: Messages = {
|
||||
'systemManager.tmux.newWindowPlaceholder': 'Window name (optional)',
|
||||
'systemManager.tmux.noWindows': 'No windows',
|
||||
'systemManager.tmux.unavailable': 'tmux is not available on this host',
|
||||
'systemManager.docker.unavailable': 'No container runtime (Docker, Podman, nerdctl, or kubectl) was detected on this host',
|
||||
'systemManager.docker.unavailable': 'Docker is not available on this host',
|
||||
'systemManager.tmux.windowsMismatch': 'Session reports {{count}} window(s) but list-windows returned none',
|
||||
'systemManager.tmux.lastCommand': 'last command: {{command}}',
|
||||
'systemManager.tmux.noPanes': 'No panes',
|
||||
@@ -199,36 +194,6 @@ export const enSystemManagerMessages: Messages = {
|
||||
'systemManager.docker.imagesMeta': '{{count}} image(s)',
|
||||
'systemManager.docker.start': 'Start',
|
||||
'systemManager.docker.stop': 'Stop',
|
||||
'systemManager.runtime.switch': 'Container runtime',
|
||||
'systemManager.runtime.docker': 'Docker',
|
||||
'systemManager.runtime.podman': 'Podman',
|
||||
'systemManager.runtime.nerdctl': 'nerdctl',
|
||||
'systemManager.runtime.kubernetes': 'Kubernetes',
|
||||
'systemManager.k8s.context': 'Context',
|
||||
'systemManager.k8s.namespace': 'Namespace',
|
||||
'systemManager.k8s.empty': 'No pods found',
|
||||
'systemManager.k8s.search': 'Search pods…',
|
||||
'systemManager.k8s.meta': '{{count}} pod(s)',
|
||||
'systemManager.k8s.describe': 'Describe',
|
||||
'systemManager.k8s.delete': 'Delete pod',
|
||||
'systemManager.k8s.confirmDelete': 'Delete pod "{{name}}"?',
|
||||
'systemManager.k8s.restarts': '{{count}} restarts',
|
||||
'systemManager.k8s.chooseContainer': 'Choose a container',
|
||||
'systemManager.k8s.noContext': 'No Kubernetes context on this host',
|
||||
'systemManager.k8s.status.Running': 'Running',
|
||||
'systemManager.k8s.status.Pending': 'Pending',
|
||||
'systemManager.k8s.status.Completed': 'Completed',
|
||||
'systemManager.k8s.status.Succeeded': 'Succeeded',
|
||||
'systemManager.k8s.status.Failed': 'Failed',
|
||||
'systemManager.k8s.status.Unknown': 'Unknown',
|
||||
'systemManager.k8s.status.CrashLoopBackOff': 'CrashLoopBackOff',
|
||||
'systemManager.k8s.status.ContainerCreating': 'ContainerCreating',
|
||||
'systemManager.k8s.status.Terminating': 'Terminating',
|
||||
'systemManager.k8s.status.ImagePullBackOff': 'ImagePullBackOff',
|
||||
'systemManager.k8s.status.ErrImagePull': 'ErrImagePull',
|
||||
'systemManager.k8s.status.Error': 'Error',
|
||||
'systemManager.k8s.status.Evicted': 'Evicted',
|
||||
'systemManager.k8s.status.OOMKilled': 'OOMKilled',
|
||||
|
||||
'systemManager.ports.unavailable': 'No listening-port tools (ss / netstat) detected on this host.',
|
||||
'systemManager.ports.loading': 'Loading listening ports…',
|
||||
|
||||
@@ -912,13 +912,11 @@ export const enTerminalMessages: Messages = {
|
||||
'keyboard.interactive.desc': 'The server requires additional authentication.',
|
||||
'keyboard.interactive.descWithHost': 'The server {hostname} requires additional authentication.',
|
||||
'keyboard.interactive.response': 'Response',
|
||||
'keyboard.interactive.verificationCode': 'Verification code',
|
||||
'keyboard.interactive.enterCode': 'Enter verification code',
|
||||
'keyboard.interactive.enterResponse': 'Enter response',
|
||||
'keyboard.interactive.submit': 'Submit',
|
||||
'keyboard.interactive.verifying': 'Verifying...',
|
||||
'keyboard.interactive.savePassword': 'Save password',
|
||||
'keyboard.interactive.fillTotp': 'Fill verification code',
|
||||
|
||||
// Passphrase Modal for encrypted SSH keys
|
||||
'passphrase.title': 'SSH Key Passphrase',
|
||||
|
||||
@@ -880,35 +880,6 @@ export const enVaultMessages: Messages = {
|
||||
'hostDetails.auth.certificate.desc': 'Use the selected SSH certificate, with the saved password as a fallback when present.',
|
||||
'hostDetails.auth.mfaFirst': 'Prefer interactive auth',
|
||||
'hostDetails.auth.mfaFirst.desc': 'For servers that require password and MFA through keyboard-interactive.',
|
||||
'hostDetails.totp.title': 'Authenticator (TOTP)',
|
||||
'hostDetails.totp.enable': 'Save a TOTP secret for this host',
|
||||
'hostDetails.totp.setup': 'Set up…',
|
||||
'hostDetails.totp.edit': 'Edit',
|
||||
'hostDetails.totp.modalTitle': 'Set up authenticator',
|
||||
'hostDetails.totp.replacePlaceholder': 'Paste a new secret to replace the current one',
|
||||
'hostDetails.totp.warning': 'Anyone who can open this vault gets this code and the password. Use it only on a personal device.',
|
||||
'hostDetails.totp.secret': 'Secret or otpauth URI',
|
||||
'hostDetails.totp.secretPlaceholder': 'Paste otpauth://totp/... or a Base32 secret',
|
||||
'hostDetails.totp.importQr': 'Import QR image',
|
||||
'hostDetails.totp.scanScreen': 'Scan QR code from screen',
|
||||
'hostDetails.totp.scanning': 'Scanning screens…',
|
||||
'hostDetails.totp.importedAccount': 'Imported {account}',
|
||||
'hostDetails.totp.preview': 'Current code',
|
||||
'hostDetails.totp.countdown': '{seconds}s remaining',
|
||||
'hostDetails.totp.autoSubmit': 'Auto-submit on known TOTP prompts',
|
||||
'hostDetails.totp.autoSubmitDesc': 'Submits once per connection, and only when the prompt exactly matches JumpServer, Google Authenticator PAM, or your pattern.',
|
||||
'hostDetails.totp.promptPattern': 'Custom prompt pattern',
|
||||
'hostDetails.totp.promptPatternPlaceholder': 'Optional RE2 regular expression',
|
||||
'hostDetails.totp.clear': 'Remove authenticator',
|
||||
'hostDetails.totp.removeTitle': 'Remove authenticator?',
|
||||
'hostDetails.totp.removeMessage': 'This host will no longer store this TOTP secret.',
|
||||
'hostDetails.totp.error.hotp': 'HOTP is not supported. Use a TOTP secret.',
|
||||
'hostDetails.totp.error.migration': 'otpauth-migration QR codes are not supported. Use the service binding QR code for a single account.',
|
||||
'hostDetails.totp.error.invalid': 'Could not read a TOTP secret from that value.',
|
||||
'hostDetails.totp.error.notFound': 'No TOTP QR code was found.',
|
||||
'hostDetails.totp.error.permission': 'Screen recording permission is required. Allow Netcatty to record the screen in System Settings, then try again.',
|
||||
'hostDetails.totp.error.unavailable': 'Screen capture is not available.',
|
||||
'hostDetails.totp.error.pattern': 'The custom prompt pattern is not a valid RE2 regular expression.',
|
||||
'hostDetails.auth.credentialRequired': 'Select a key or certificate before saving this login method.',
|
||||
'hostDetails.identity.suggestions': 'Identities',
|
||||
'hostDetails.identity.missing': 'Identity not found',
|
||||
|
||||
@@ -367,22 +367,6 @@ export const esAiMessages: Messages = {
|
||||
'ai.externalMcp.help.ariaLabel': 'Ayuda del MCP externo',
|
||||
'ai.externalMcp.security': 'Seguridad',
|
||||
'ai.externalMcp.security.description': 'Escucha en 127.0.0.1 con un token rotativo, reutiliza el modo de permisos de IA para las escrituras y elimina el descubrimiento al desactivarse. Sin OAuth: este es un puente local de escritorio.',
|
||||
"ai.privacy.title": "Privacidad de IA",
|
||||
"ai.privacy.description": "Filtra patrones de texto sensibles conocidos en el contexto y los resultados de herramientas gestionados por Netcatty para Catty, agentes integrados y clientes MCP externos. Desactivado de forma predeterminada. No detecta todos los secretos. Las respuestas binarias de herramientas se bloquean mientras está activo.",
|
||||
"ai.privacy.boundary": "Solo cubre los datos y las herramientas gestionados por Netcatty. Las herramientas nativas de agentes de terceros, su acceso directo a archivos y otros servidores MCP quedan fuera de esta protección.",
|
||||
"ai.privacy.enable": "Activar ocultación de texto",
|
||||
"ai.privacy.privateIps": "Ocultar direcciones IP privadas",
|
||||
"ai.privacy.privateIps.description": "Oculta también las direcciones IP privadas y locales en el texto devuelto.",
|
||||
"ai.privacy.cidrs": "Rangos IP adicionales (CIDR)",
|
||||
"ai.privacy.cidrs.description": "Un CIDR IPv4 o IPv6 por línea. Déjalo vacío para usar solo las reglas integradas.",
|
||||
"ai.privacy.patterns": "Expresiones regulares personalizadas",
|
||||
"ai.privacy.patterns.description": "Un patrón compatible con RE2 por línea, sin delimitadores / ni indicadores. Los patrones incompatibles o inseguros se rechazan.",
|
||||
"ai.privacy.connectionScope": "Los marcadores son estables dentro de una conversación gestionada o una conexión MCP externa. Los valores originales solo se restauran mediante llamadas a herramientas gestionadas por Netcatty dentro del mismo ámbito.",
|
||||
"ai.privacy.reconnect": "Aplica los cambios, inicia una nueva conversación gestionada y vuelve a conectar los clientes MCP externos. Las conversaciones y conexiones existentes no deben reutilizar marcadores.",
|
||||
"ai.privacy.apply": "Aplicar",
|
||||
"ai.privacy.applying": "Aplicando...",
|
||||
"ai.privacy.applied": "Configuración de privacidad de IA aplicada. Inicia una nueva conversación gestionada y vuelve a conectar los clientes MCP externos.",
|
||||
"ai.privacy.failed": "No se aplicó la configuración de privacidad de IA.",
|
||||
'ai.externalMcp.http.title': 'Streamable HTTP',
|
||||
'ai.externalMcp.http.description': 'Expone las mismas herramientas de MCP externo por Streamable HTTP para clientes que no pueden iniciar el CLI stdio, como Codex dentro de WSL. Desactivado de forma predeterminada.',
|
||||
'ai.externalMcp.http.enable': 'Activar HTTP',
|
||||
|
||||
@@ -779,8 +779,8 @@ Resalta el panel dividido enfocado:
|
||||
'settings.terminal.systemManager.processRefreshInterval.desc': 'Con qué frecuencia actualizar la lista de procesos en el panel lateral del administrador del sistema.',
|
||||
'settings.terminal.systemManager.tmuxRefreshInterval': 'Actualización de sesiones tmux',
|
||||
'settings.terminal.systemManager.tmuxRefreshInterval.desc': 'Con qué frecuencia actualizar la lista de sesiones tmux.',
|
||||
'settings.terminal.systemManager.dockerListRefreshInterval': 'Actualización de la lista de contenedores',
|
||||
'settings.terminal.systemManager.dockerListRefreshInterval.desc': 'Con qué frecuencia actualizar las listas de contenedores y pods.',
|
||||
'settings.terminal.systemManager.dockerListRefreshInterval': 'Actualización de la lista de contenedores Docker',
|
||||
'settings.terminal.systemManager.dockerListRefreshInterval.desc': 'Con qué frecuencia actualizar la lista de contenedores Docker.',
|
||||
'settings.terminal.systemManager.dockerStatsRefreshInterval': 'Actualización de estadísticas Docker',
|
||||
'settings.terminal.systemManager.dockerStatsRefreshInterval.desc': 'Con qué frecuencia actualizar las estadísticas de CPU/memoria/red de los contenedores Docker.',
|
||||
'settings.terminal.serverStats.show': 'Mostrar estadísticas del servidor',
|
||||
@@ -1139,7 +1139,6 @@ Resalta el panel dividido enfocado:
|
||||
'vault.hosts.moveSelected': 'Mover al grupo ({count})',
|
||||
'vault.hosts.moveSelected.description': 'Mueve los hosts seleccionados a un grupo existente o los deja sin grupo. Los grupos seleccionados no se mueven.',
|
||||
'vault.hosts.moveMultiple.success': 'Se movieron {count} hosts a {group}',
|
||||
'vault.hosts.dragSelectionCount': '{count} hosts',
|
||||
'vault.hosts.moveToGroup.success': 'Se movió {host} a {group}',
|
||||
'vault.hosts.errors.nameRequired': 'Se requiere el nombre del host.',
|
||||
'vault.hosts.empty.title': 'Configura tus hosts',
|
||||
|
||||
@@ -26,8 +26,6 @@ export const esSystemManagerMessages: Messages = {
|
||||
'systemManager.errors.loadDocker': 'No se pudieron cargar los contenedores',
|
||||
'systemManager.errors.loadDockerStats': 'No se pudieron cargar las estadísticas de los contenedores',
|
||||
'systemManager.errors.loadDockerImages': 'No se pudieron cargar las imágenes',
|
||||
'systemManager.errors.loadKubernetes': 'No se pudieron cargar los recursos de Kubernetes',
|
||||
'systemManager.errors.loadPods': 'No se pudieron cargar los pods',
|
||||
'systemManager.errors.loadOverview': 'No se pudo cargar el resumen del sistema',
|
||||
'systemManager.errors.loadGpu': 'No se pudieron cargar las estadísticas de GPU / NPU',
|
||||
'systemManager.errors.loadPorts': 'No se pudieron cargar los puertos en escucha',
|
||||
@@ -134,7 +132,7 @@ export const esSystemManagerMessages: Messages = {
|
||||
'systemManager.tmux.newWindowPlaceholder': 'Nombre de la ventana (opcional)',
|
||||
'systemManager.tmux.noWindows': 'No hay ventanas',
|
||||
'systemManager.tmux.unavailable': 'tmux no está disponible en este host',
|
||||
'systemManager.docker.unavailable': 'No se detectó ningún runtime de contenedores (Docker, Podman, nerdctl o kubectl) en este host',
|
||||
'systemManager.docker.unavailable': 'Docker no está disponible en este host',
|
||||
'systemManager.tmux.windowsMismatch': 'La sesión reporta {{count}} ventana(s), pero list-windows no devolvió ninguna',
|
||||
'systemManager.tmux.lastCommand': 'último comando: {{command}}',
|
||||
'systemManager.tmux.noPanes': 'No hay paneles',
|
||||
@@ -196,22 +194,6 @@ export const esSystemManagerMessages: Messages = {
|
||||
'systemManager.docker.imagesMeta': '{{count}} imagen(es)',
|
||||
'systemManager.docker.start': 'Iniciar',
|
||||
'systemManager.docker.stop': 'Detener',
|
||||
'systemManager.runtime.switch': 'Runtime de contenedores',
|
||||
'systemManager.runtime.docker': 'Docker',
|
||||
'systemManager.runtime.podman': 'Podman',
|
||||
'systemManager.runtime.nerdctl': 'nerdctl',
|
||||
'systemManager.runtime.kubernetes': 'Kubernetes',
|
||||
'systemManager.k8s.context': 'Contexto',
|
||||
'systemManager.k8s.namespace': 'Espacio de nombres',
|
||||
'systemManager.k8s.empty': 'No se encontraron pods',
|
||||
'systemManager.k8s.search': 'Buscar pods…',
|
||||
'systemManager.k8s.meta': '{{count}} pod(s)',
|
||||
'systemManager.k8s.describe': 'Describir',
|
||||
'systemManager.k8s.delete': 'Eliminar pod',
|
||||
'systemManager.k8s.confirmDelete': '¿Eliminar el pod "{{name}}"?',
|
||||
'systemManager.k8s.restarts': '{{count}} reinicios',
|
||||
'systemManager.k8s.chooseContainer': 'Elegir un contenedor',
|
||||
'systemManager.k8s.noContext': 'No hay un contexto de Kubernetes en este host',
|
||||
|
||||
'systemManager.ports.unavailable': 'No se detectaron herramientas de puertos en escucha (ss / netstat) en este host.',
|
||||
'systemManager.ports.loading': 'Cargando puertos en escucha…',
|
||||
|
||||
@@ -874,13 +874,11 @@ export const esTerminalMessages: Messages = {
|
||||
'keyboard.interactive.desc': 'El servidor requiere autenticación adicional.',
|
||||
'keyboard.interactive.descWithHost': 'El servidor {hostname} requiere autenticación adicional.',
|
||||
'keyboard.interactive.response': 'Respuesta',
|
||||
'keyboard.interactive.verificationCode': 'Código de verificación',
|
||||
'keyboard.interactive.enterCode': 'Ingresa el código de verificación',
|
||||
'keyboard.interactive.enterResponse': 'Ingresa la respuesta',
|
||||
'keyboard.interactive.submit': 'Enviar',
|
||||
'keyboard.interactive.verifying': 'Verificando...',
|
||||
'keyboard.interactive.savePassword': 'Guardar contraseña',
|
||||
'keyboard.interactive.fillTotp': 'Rellenar código de verificación',
|
||||
|
||||
// Passphrase Modal for encrypted SSH keys
|
||||
'passphrase.title': 'Frase de contraseña de la clave SSH',
|
||||
|
||||
@@ -870,35 +870,6 @@ export const esVaultMessages: Messages = {
|
||||
'hostDetails.auth.certificate.desc': 'Usa el certificado SSH seleccionado, con la contraseña guardada como respaldo cuando esté presente.',
|
||||
'hostDetails.auth.mfaFirst': 'Preferir autenticación interactiva',
|
||||
'hostDetails.auth.mfaFirst.desc': 'Para servidores que requieren contraseña y MFA a través de keyboard-interactive.',
|
||||
'hostDetails.totp.title': 'Autenticador (TOTP)',
|
||||
'hostDetails.totp.enable': 'Guardar un secreto TOTP para este host',
|
||||
'hostDetails.totp.setup': 'Configurar…',
|
||||
'hostDetails.totp.edit': 'Editar',
|
||||
'hostDetails.totp.modalTitle': 'Configurar autenticador',
|
||||
'hostDetails.totp.replacePlaceholder': 'Pega un secreto nuevo para reemplazar el actual',
|
||||
'hostDetails.totp.warning': 'Quien abra esta caja fuerte obtiene este código y la contraseña. Úsalo solo en un dispositivo personal.',
|
||||
'hostDetails.totp.secret': 'Secreto o URI otpauth',
|
||||
'hostDetails.totp.secretPlaceholder': 'Pega otpauth://totp/... o un secreto Base32',
|
||||
'hostDetails.totp.importQr': 'Importar imagen QR',
|
||||
'hostDetails.totp.scanScreen': 'Escanear código QR de la pantalla',
|
||||
'hostDetails.totp.scanning': 'Escaneando pantallas…',
|
||||
'hostDetails.totp.importedAccount': 'Importado: {account}',
|
||||
'hostDetails.totp.preview': 'Código actual',
|
||||
'hostDetails.totp.countdown': 'Quedan {seconds} s',
|
||||
'hostDetails.totp.autoSubmit': 'Enviar automáticamente en avisos TOTP conocidos',
|
||||
'hostDetails.totp.autoSubmitDesc': 'Se envía una sola vez por conexión y solo si el aviso coincide exactamente con JumpServer, Google Authenticator PAM o tu patrón.',
|
||||
'hostDetails.totp.promptPattern': 'Patrón de aviso personalizado',
|
||||
'hostDetails.totp.promptPatternPlaceholder': 'Expresión regular RE2 opcional',
|
||||
'hostDetails.totp.clear': 'Quitar autenticador',
|
||||
'hostDetails.totp.removeTitle': '¿Quitar el autenticador?',
|
||||
'hostDetails.totp.removeMessage': 'Este host ya no guardará este secreto TOTP.',
|
||||
'hostDetails.totp.error.hotp': 'HOTP no es compatible. Usa un secreto TOTP.',
|
||||
'hostDetails.totp.error.migration': 'Los códigos otpauth-migration no son compatibles. Usa el código QR de vinculación de una sola cuenta.',
|
||||
'hostDetails.totp.error.invalid': 'No se pudo leer un secreto TOTP de ese valor.',
|
||||
'hostDetails.totp.error.notFound': 'No se encontró un código QR TOTP.',
|
||||
'hostDetails.totp.error.permission': 'Se necesita permiso de grabación de pantalla. Permite que Netcatty grabe la pantalla en Ajustes del sistema e inténtalo de nuevo.',
|
||||
'hostDetails.totp.error.unavailable': 'La captura de pantalla no está disponible.',
|
||||
'hostDetails.totp.error.pattern': 'El patrón personalizado no es una expresión regular RE2 válida.',
|
||||
'hostDetails.auth.credentialRequired': 'Selecciona una clave o un certificado antes de guardar este método de inicio de sesión.',
|
||||
'hostDetails.identity.suggestions': 'Identidades',
|
||||
'hostDetails.identity.missing': 'Identidad no encontrada',
|
||||
|
||||
@@ -1,23 +0,0 @@
|
||||
import assert from 'node:assert/strict';
|
||||
import test from 'node:test';
|
||||
import en from './en.ts';
|
||||
import es from './es.ts';
|
||||
import ru from './ru.ts';
|
||||
import zhCN from './zh-CN.ts';
|
||||
import zhTW from './zh-TW.ts';
|
||||
|
||||
const KEYS = [
|
||||
'title', 'description', 'boundary', 'enable', 'privateIps', 'privateIps.description',
|
||||
'cidrs', 'cidrs.description', 'patterns', 'patterns.description',
|
||||
'connectionScope', 'reconnect', 'apply', 'applying', 'applied', 'failed',
|
||||
];
|
||||
|
||||
test('Shared AI privacy settings are localized in every supported locale', () => {
|
||||
for (const [name, messages] of Object.entries({ en, es, ru, 'zh-CN': zhCN, 'zh-TW': zhTW })) {
|
||||
assert.deepEqual(
|
||||
KEYS.filter((key) => !messages[`ai.privacy.${key}`]),
|
||||
[],
|
||||
`${name} is missing privacy settings labels`,
|
||||
);
|
||||
}
|
||||
});
|
||||
@@ -1,39 +0,0 @@
|
||||
import test from 'node:test';
|
||||
import assert from 'node:assert/strict';
|
||||
import en from './en';
|
||||
import zhCN from './zh-CN';
|
||||
import zhTW from './zh-TW';
|
||||
|
||||
const KEYS = [
|
||||
'ai.opencode.v2.startupTimeout',
|
||||
'ai.opencode.v2.exited',
|
||||
'ai.opencode.v2.unauthorized',
|
||||
'ai.opencode.v2.unavailable',
|
||||
'ai.opencode.v2.sessionFailed',
|
||||
'ai.opencode.v2.empty',
|
||||
'ai.opencode.v2.contextSkipped',
|
||||
'ai.opencode.v2.mcpSkipped',
|
||||
];
|
||||
|
||||
test('OpenCode 2 strings exist in English, Simplified Chinese, and Taiwan Chinese', () => {
|
||||
for (const key of KEYS) {
|
||||
assert.equal(typeof en[key], 'string', `en missing ${key}`);
|
||||
assert.equal(typeof zhCN[key], 'string', `zh-CN missing ${key}`);
|
||||
assert.equal(typeof zhTW[key], 'string', `zh-TW missing ${key}`);
|
||||
assert.notEqual(en[key], zhCN[key]);
|
||||
assert.notEqual(en[key], zhTW[key]);
|
||||
assert.notEqual(zhCN[key], zhTW[key]);
|
||||
}
|
||||
});
|
||||
|
||||
test('Taiwan OpenCode 2 wording uses Taiwan terms', () => {
|
||||
const text = KEYS.map((key) => zhTW[key]).join('\n');
|
||||
assert.match(text, /終端機/);
|
||||
assert.match(text, /連線/);
|
||||
assert.match(text, /設定/);
|
||||
assert.match(text, /傳回/);
|
||||
assert.equal(text.includes('终端'), false);
|
||||
assert.equal(text.includes('连接'), false);
|
||||
assert.equal(text.includes('设置'), false);
|
||||
assert.equal(text.includes('返回'), false);
|
||||
});
|
||||
@@ -323,22 +323,6 @@ export const ruAiMessages: Messages = {
|
||||
'ai.externalMcp.help.ariaLabel': 'Справка External MCP',
|
||||
'ai.externalMcp.security': 'Безопасность',
|
||||
'ai.externalMcp.security.description': 'Слушает только 127.0.0.1 с ротацией token, использует AI Permission Mode для записи и удаляет discovery при отключении.',
|
||||
"ai.privacy.title": "Конфиденциальность ИИ",
|
||||
"ai.privacy.description": "Фильтрует известные шаблоны конфиденциального текста в контексте и результатах инструментов, управляемых Netcatty, для Catty, встроенных агентов и внешних MCP-клиентов. По умолчанию отключено. Не обнаруживает все секреты. При включении двоичные ответы инструментов блокируются.",
|
||||
"ai.privacy.boundary": "Защищаются только данные и инструменты, управляемые Netcatty. Собственные инструменты сторонних агентов, их прямой доступ к файлам и другие MCP-серверы не входят в эту область.",
|
||||
"ai.privacy.enable": "Включить скрытие текста",
|
||||
"ai.privacy.privateIps": "Скрывать частные IP-адреса",
|
||||
"ai.privacy.privateIps.description": "Также скрывает частные и локальные IP-адреса в возвращаемом тексте.",
|
||||
"ai.privacy.cidrs": "Дополнительные диапазоны IP (CIDR)",
|
||||
"ai.privacy.cidrs.description": "Один CIDR IPv4 или IPv6 на строку. Оставьте пустым для использования только встроенных правил.",
|
||||
"ai.privacy.patterns": "Пользовательские регулярные выражения",
|
||||
"ai.privacy.patterns.description": "Один RE2-совместимый шаблон на строку, без разделителей / и флагов. Неподдерживаемые или небезопасные шаблоны отклоняются.",
|
||||
"ai.privacy.connectionScope": "Маркеры постоянны в пределах одного управляемого диалога или внешнего MCP-подключения. Исходные значения восстанавливаются только через вызовы инструментов, управляемых Netcatty, в той же области.",
|
||||
"ai.privacy.reconnect": "Примените изменения, начните новый управляемый диалог и переподключите внешние MCP-клиенты. Существующие диалоги и подключения не должны повторно использовать маркеры.",
|
||||
"ai.privacy.apply": "Применить",
|
||||
"ai.privacy.applying": "Применение...",
|
||||
"ai.privacy.applied": "Настройки конфиденциальности ИИ применены. Начните новый управляемый диалог и переподключите внешние MCP-клиенты.",
|
||||
"ai.privacy.failed": "Настройки конфиденциальности ИИ не применены.",
|
||||
'ai.externalMcp.http.title': 'Streamable HTTP',
|
||||
'ai.externalMcp.http.description': 'Отдает те же инструменты External MCP по Streamable HTTP клиентам, которые не могут запустить stdio CLI, например Codex в WSL. По умолчанию выключено.',
|
||||
'ai.externalMcp.http.enable': 'Включить HTTP',
|
||||
|
||||
@@ -817,8 +817,8 @@ export const ruCoreMessages: Messages = {
|
||||
'settings.terminal.systemManager.processRefreshInterval.desc': 'Как часто обновлять список процессов в боковой панели системного менеджера.',
|
||||
'settings.terminal.systemManager.tmuxRefreshInterval': 'Обновление сессий tmux',
|
||||
'settings.terminal.systemManager.tmuxRefreshInterval.desc': 'Как часто обновлять список сессий tmux.',
|
||||
'settings.terminal.systemManager.dockerListRefreshInterval': 'Обновление списка контейнеров',
|
||||
'settings.terminal.systemManager.dockerListRefreshInterval.desc': 'Как часто обновлять списки контейнеров и подов.',
|
||||
'settings.terminal.systemManager.dockerListRefreshInterval': 'Обновление списка контейнеров Docker',
|
||||
'settings.terminal.systemManager.dockerListRefreshInterval.desc': 'Как часто обновлять список контейнеров Docker.',
|
||||
'settings.terminal.systemManager.dockerStatsRefreshInterval': 'Обновление статистики Docker',
|
||||
'settings.terminal.systemManager.dockerStatsRefreshInterval.desc': 'Как часто обновлять CPU/память/сеть контейнеров Docker.',
|
||||
'settings.terminal.serverStats.show': 'Показывать статистику сервера',
|
||||
|
||||
@@ -26,8 +26,6 @@ export const ruSystemManagerMessages: Messages = {
|
||||
'systemManager.errors.loadDocker': 'Не удалось загрузить контейнеры',
|
||||
'systemManager.errors.loadDockerStats': 'Не удалось загрузить статистику контейнеров',
|
||||
'systemManager.errors.loadDockerImages': 'Не удалось загрузить образы',
|
||||
'systemManager.errors.loadKubernetes': 'Не удалось загрузить ресурсы Kubernetes',
|
||||
'systemManager.errors.loadPods': 'Не удалось загрузить поды',
|
||||
'systemManager.errors.loadOverview': 'Не удалось загрузить обзор системы',
|
||||
'systemManager.errors.loadGpu': 'Не удалось загрузить статистику GPU / NPU',
|
||||
'systemManager.errors.loadPorts': 'Не удалось загрузить слушающие порты',
|
||||
@@ -134,7 +132,7 @@ export const ruSystemManagerMessages: Messages = {
|
||||
'systemManager.tmux.newWindowPlaceholder': 'Имя окна (необязательно)',
|
||||
'systemManager.tmux.noWindows': 'Нет окон',
|
||||
'systemManager.tmux.unavailable': 'tmux недоступен на этом хосте',
|
||||
'systemManager.docker.unavailable': 'На этом хосте не обнаружена среда контейнеров (Docker, Podman, nerdctl или kubectl)',
|
||||
'systemManager.docker.unavailable': 'Docker недоступен на этом хосте',
|
||||
'systemManager.tmux.windowsMismatch': 'В сессии указано {{count}} окон, но list-windows ничего не вернул',
|
||||
'systemManager.tmux.lastCommand': 'последняя команда: {{command}}',
|
||||
'systemManager.tmux.noPanes': 'Нет панелей',
|
||||
@@ -196,22 +194,6 @@ export const ruSystemManagerMessages: Messages = {
|
||||
'systemManager.docker.imagesMeta': '{{count}} образов',
|
||||
'systemManager.docker.start': 'Запустить',
|
||||
'systemManager.docker.stop': 'Остановить',
|
||||
'systemManager.runtime.switch': 'Среда выполнения контейнеров',
|
||||
'systemManager.runtime.docker': 'Docker',
|
||||
'systemManager.runtime.podman': 'Podman',
|
||||
'systemManager.runtime.nerdctl': 'nerdctl',
|
||||
'systemManager.runtime.kubernetes': 'Kubernetes',
|
||||
'systemManager.k8s.context': 'Контекст',
|
||||
'systemManager.k8s.namespace': 'Пространство имён',
|
||||
'systemManager.k8s.empty': 'Поды не найдены',
|
||||
'systemManager.k8s.search': 'Поиск подов…',
|
||||
'systemManager.k8s.meta': '{{count}} под(ов)',
|
||||
'systemManager.k8s.describe': 'Описание',
|
||||
'systemManager.k8s.delete': 'Удалить под',
|
||||
'systemManager.k8s.confirmDelete': 'Удалить под «{{name}}»?',
|
||||
'systemManager.k8s.restarts': '{{count}} перезапусков',
|
||||
'systemManager.k8s.chooseContainer': 'Выберите контейнер',
|
||||
'systemManager.k8s.noContext': 'На этом хосте нет контекста Kubernetes',
|
||||
|
||||
'systemManager.ports.unavailable': 'На этом хосте не обнаружены инструменты портов (ss / netstat).',
|
||||
'systemManager.ports.loading': 'Загрузка слушающих портов…',
|
||||
|
||||
@@ -919,13 +919,11 @@ export const ruTerminalMessages: Messages = {
|
||||
'keyboard.interactive.desc': 'Сервер требует дополнительную аутентификацию.',
|
||||
'keyboard.interactive.descWithHost': 'Сервер {hostname} требует дополнительную аутентификацию.',
|
||||
'keyboard.interactive.response': 'Ответ',
|
||||
'keyboard.interactive.verificationCode': 'Код подтверждения',
|
||||
'keyboard.interactive.enterCode': 'Введите код подтверждения',
|
||||
'keyboard.interactive.enterResponse': 'Введите ответ',
|
||||
'keyboard.interactive.submit': 'Отправить',
|
||||
'keyboard.interactive.verifying': 'Проверка...',
|
||||
'keyboard.interactive.savePassword': 'Сохранить пароль',
|
||||
'keyboard.interactive.fillTotp': 'Подставить код подтверждения',
|
||||
|
||||
// Passphrase Modal for encrypted SSH keys
|
||||
'passphrase.title': 'Парольная фраза SSH-ключа',
|
||||
|
||||
@@ -131,7 +131,6 @@ export const ruVaultMessages: Messages = {
|
||||
'vault.hosts.moveSelected': 'Переместить в группу ({count})',
|
||||
'vault.hosts.moveSelected.description': 'Переместить выбранные хосты в существующую группу или оставить без группы. Выбранные группы не перемещаются.',
|
||||
'vault.hosts.moveMultiple.success': 'Перемещено {count} хостов в {group}',
|
||||
'vault.hosts.dragSelectionCount': '{count} хостов',
|
||||
'vault.hosts.moveToGroup.success': 'Хост {host} перемещён в {group}',
|
||||
'vault.hosts.empty.title': 'Настройте свои хосты',
|
||||
'vault.hosts.empty.desc': 'Сохраняйте хосты, чтобы быстро подключаться к серверам, виртуальным машинам и контейнерам.',
|
||||
@@ -919,35 +918,6 @@ export const ruVaultMessages: Messages = {
|
||||
'hostDetails.auth.certificate.desc': 'Использовать выбранный SSH-сертификат и сохранённый пароль как запасной вариант.',
|
||||
'hostDetails.auth.mfaFirst': 'Сначала интерактивная аутентификация',
|
||||
'hostDetails.auth.mfaFirst.desc': 'Для серверов, которым нужны пароль и MFA через keyboard-interactive.',
|
||||
'hostDetails.totp.title': 'Аутентификатор (TOTP)',
|
||||
'hostDetails.totp.enable': 'Сохранить секрет TOTP для этого хоста',
|
||||
'hostDetails.totp.setup': 'Настроить…',
|
||||
'hostDetails.totp.edit': 'Изменить',
|
||||
'hostDetails.totp.modalTitle': 'Настройка аутентификатора',
|
||||
'hostDetails.totp.replacePlaceholder': 'Вставьте новый секрет, чтобы заменить текущий',
|
||||
'hostDetails.totp.warning': 'Тот, кто откроет это хранилище, получит этот код и пароль. Используйте только на личном устройстве.',
|
||||
'hostDetails.totp.secret': 'Секрет или URI otpauth',
|
||||
'hostDetails.totp.secretPlaceholder': 'Вставьте otpauth://totp/... или секрет Base32',
|
||||
'hostDetails.totp.importQr': 'Импорт изображения QR',
|
||||
'hostDetails.totp.scanScreen': 'Сканировать QR-код с экрана',
|
||||
'hostDetails.totp.scanning': 'Сканирование экранов…',
|
||||
'hostDetails.totp.importedAccount': 'Импортировано: {account}',
|
||||
'hostDetails.totp.preview': 'Текущий код',
|
||||
'hostDetails.totp.countdown': 'Осталось {seconds} с',
|
||||
'hostDetails.totp.autoSubmit': 'Автоотправка на известных запросах TOTP',
|
||||
'hostDetails.totp.autoSubmitDesc': 'Отправляется один раз за подключение и только если запрос точно совпадает с JumpServer, Google Authenticator PAM или вашим шаблоном.',
|
||||
'hostDetails.totp.promptPattern': 'Свой шаблон запроса',
|
||||
'hostDetails.totp.promptPatternPlaceholder': 'Необязательное регулярное выражение RE2',
|
||||
'hostDetails.totp.clear': 'Удалить аутентификатор',
|
||||
'hostDetails.totp.removeTitle': 'Удалить аутентификатор?',
|
||||
'hostDetails.totp.removeMessage': 'Этот хост больше не будет хранить этот секрет TOTP.',
|
||||
'hostDetails.totp.error.hotp': 'HOTP не поддерживается. Используйте секрет TOTP.',
|
||||
'hostDetails.totp.error.migration': 'Коды otpauth-migration не поддерживаются. Используйте QR-код привязки одной учетной записи.',
|
||||
'hostDetails.totp.error.invalid': 'Не удалось прочитать секрет TOTP.',
|
||||
'hostDetails.totp.error.notFound': 'QR-код TOTP не найден.',
|
||||
'hostDetails.totp.error.permission': 'Нужно разрешение на запись экрана. Разрешите Netcatty запись экрана в системных настройках и повторите попытку.',
|
||||
'hostDetails.totp.error.unavailable': 'Захват экрана недоступен.',
|
||||
'hostDetails.totp.error.pattern': 'Пользовательский шаблон не является допустимым регулярным выражением RE2.',
|
||||
'hostDetails.auth.credentialRequired': 'Перед сохранением этого способа входа выберите ключ или сертификат.',
|
||||
'hostDetails.identity.suggestions': 'Идентификаторы',
|
||||
'hostDetails.identity.missing': 'Идентификатор не найден',
|
||||
|
||||
@@ -289,14 +289,6 @@ export const zhCNAiMessages: Messages = {
|
||||
'ai.opencode.customPathPlaceholder': '例如 /usr/local/bin/opencode',
|
||||
'ai.opencode.check': '检查',
|
||||
'ai.opencode.resetPath': '重置',
|
||||
'ai.opencode.v2.startupTimeout': 'OpenCode 2 未能及时就绪。请先在终端确认 `opencode serve` 可以启动,然后重试。',
|
||||
'ai.opencode.v2.exited': 'OpenCode 2 在对话开始前已退出。',
|
||||
'ai.opencode.v2.unauthorized': 'OpenCode 2 拒绝了连接。请重试这次对话。',
|
||||
'ai.opencode.v2.unavailable': '无法连接到 OpenCode 2。请确认已安装后再试。',
|
||||
'ai.opencode.v2.sessionFailed': 'OpenCode 2 未能完成这一轮对话。',
|
||||
'ai.opencode.v2.empty': 'OpenCode 2 没有返回内容。请在终端运行 `opencode` 完成登录和模型配置。',
|
||||
'ai.opencode.v2.contextSkipped': 'OpenCode 2 无法应用 Netcatty 的对话上下文。',
|
||||
'ai.opencode.v2.mcpSkipped': 'OpenCode 2 无法接入 MCP 工具,对话会继续,但没有该工具。',
|
||||
|
||||
// AI MiMo Code(小米 MiMo Code CLI,OpenCode 分支)
|
||||
'ai.mimo.title': 'MiMo Code',
|
||||
@@ -384,22 +376,6 @@ export const zhCNAiMessages: Messages = {
|
||||
'ai.externalMcp.help.ariaLabel': '对外 MCP 使用说明',
|
||||
'ai.externalMcp.security': '安全',
|
||||
'ai.externalMcp.security.description': '仅监听 127.0.0.1,使用轮换 token,写操作复用 AI 权限模式,关闭时删除 discovery。不做 OAuth——这是本机桌面桥接。',
|
||||
"ai.privacy.title": "AI 隐私",
|
||||
"ai.privacy.description": "为 Catty、集成 Agent 和外部 MCP 客户端过滤 Netcatty 管理的上下文及工具结果中已知的敏感文本模式。默认关闭,无法识别所有机密。启用时会阻止二进制工具响应。",
|
||||
"ai.privacy.boundary": "仅覆盖 Netcatty 管理的数据和工具。第三方 Agent 的原生工具、直接读取的文件及其他 MCP 服务器不在保护范围内。",
|
||||
"ai.privacy.enable": "启用文本脱敏",
|
||||
"ai.privacy.privateIps": "隐藏私有 IP 地址",
|
||||
"ai.privacy.privateIps.description": "同时隐藏返回文本中的私有和本地 IP 地址。",
|
||||
"ai.privacy.cidrs": "附加 IP 网段(CIDR)",
|
||||
"ai.privacy.cidrs.description": "每行一个 IPv4 或 IPv6 CIDR。留空则仅使用内置规则。",
|
||||
"ai.privacy.patterns": "自定义正则表达式",
|
||||
"ai.privacy.patterns.description": "每行一个兼容 RE2 的模式,不含 / 分隔符或标志。不支持或不安全的模式会被拒绝。",
|
||||
"ai.privacy.connectionScope": "占位符在同一个受管对话或外部 MCP 连接内保持稳定。只有同一范围内的 Netcatty 受管工具调用才能还原原始值。",
|
||||
"ai.privacy.reconnect": "应用更改后,请新建受管对话并重新连接外部 MCP 客户端。已有对话和连接不得复用占位符。",
|
||||
"ai.privacy.apply": "应用",
|
||||
"ai.privacy.applying": "正在应用...",
|
||||
"ai.privacy.applied": "AI 隐私设置已应用。请新建受管对话并重新连接外部 MCP 客户端。",
|
||||
"ai.privacy.failed": "AI 隐私设置未应用。",
|
||||
'ai.externalMcp.http.title': 'Streamable HTTP',
|
||||
'ai.externalMcp.http.description': '用 Streamable HTTP 提供同一套对外 MCP 工具,方便无法启动 stdio CLI 的客户端,例如 WSL 里的 Codex。默认关闭。',
|
||||
'ai.externalMcp.http.enable': '启用 HTTP',
|
||||
|
||||
@@ -785,7 +785,6 @@ Vaults 标签保持扁平,不使用这块背景。
|
||||
'vault.hosts.moveSelected': '移动到分组 ({count})',
|
||||
'vault.hosts.moveSelected.description': '将选中的主机移动到现有分组,也可移出分组。选中的分组本身不会移动。',
|
||||
'vault.hosts.moveMultiple.success': '已将 {count} 个主机移动到 {group}',
|
||||
'vault.hosts.dragSelectionCount': '{count} 个主机',
|
||||
'vault.hosts.moveToGroup.success': '已将 {host} 移动到 {group}',
|
||||
'vault.hosts.errors.nameRequired': '主机名称不能为空。',
|
||||
'vault.hosts.empty.title': '设置你的主机',
|
||||
|
||||
@@ -26,11 +26,6 @@ export const zhCnSystemManagerMessages: Messages = {
|
||||
'systemManager.errors.loadDocker': '加载容器列表失败',
|
||||
'systemManager.errors.loadDockerStats': '加载容器性能数据失败',
|
||||
'systemManager.errors.loadDockerImages': '加载镜像列表失败',
|
||||
'systemManager.errors.loadKubernetes': '无法加载 Kubernetes 资源',
|
||||
'systemManager.errors.loadPods': '无法加载 Pod',
|
||||
'systemManager.errors.nerdctlUnreachable': 'nerdctl 无法连接 containerd',
|
||||
'systemManager.errors.clusterUnreachable': '无法连接到上下文 {{context}} 的集群',
|
||||
'systemManager.errors.clusterUnreachableGeneric': '无法连接到集群',
|
||||
'systemManager.errors.loadOverview': '加载系统概览失败',
|
||||
'systemManager.errors.loadGpu': '加载 GPU / NPU 状态失败',
|
||||
'systemManager.errors.loadPorts': '加载监听端口失败',
|
||||
@@ -137,7 +132,7 @@ export const zhCnSystemManagerMessages: Messages = {
|
||||
'systemManager.tmux.newWindowPlaceholder': '窗口名称(可选)',
|
||||
'systemManager.tmux.noWindows': '没有窗口',
|
||||
'systemManager.tmux.unavailable': '此主机未检测到 tmux',
|
||||
'systemManager.docker.unavailable': '此主机未检测到容器运行时(Docker、Podman、nerdctl 或 kubectl)',
|
||||
'systemManager.docker.unavailable': '此主机未检测到 Docker',
|
||||
'systemManager.tmux.windowsMismatch': '会话显示有 {{count}} 个窗口,但 list-windows 未返回任何窗口',
|
||||
'systemManager.tmux.lastCommand': '最后执行的命令:{{command}}',
|
||||
'systemManager.tmux.noPanes': '没有面板',
|
||||
@@ -199,36 +194,6 @@ export const zhCnSystemManagerMessages: Messages = {
|
||||
'systemManager.docker.imagesMeta': '{{count}} 个镜像',
|
||||
'systemManager.docker.start': '启动',
|
||||
'systemManager.docker.stop': '停止',
|
||||
'systemManager.runtime.switch': '容器运行时',
|
||||
'systemManager.runtime.docker': 'Docker',
|
||||
'systemManager.runtime.podman': 'Podman',
|
||||
'systemManager.runtime.nerdctl': 'nerdctl',
|
||||
'systemManager.runtime.kubernetes': 'Kubernetes',
|
||||
'systemManager.k8s.context': '上下文',
|
||||
'systemManager.k8s.namespace': '命名空间',
|
||||
'systemManager.k8s.empty': '未找到 Pod',
|
||||
'systemManager.k8s.search': '搜索 Pod…',
|
||||
'systemManager.k8s.meta': '{{count}} 个 Pod',
|
||||
'systemManager.k8s.describe': '描述',
|
||||
'systemManager.k8s.delete': '删除 Pod',
|
||||
'systemManager.k8s.confirmDelete': '确定删除 Pod「{{name}}」?',
|
||||
'systemManager.k8s.restarts': '重启 {{count}} 次',
|
||||
'systemManager.k8s.chooseContainer': '选择容器',
|
||||
'systemManager.k8s.noContext': '此主机没有 Kubernetes 上下文',
|
||||
'systemManager.k8s.status.Running': '运行中',
|
||||
'systemManager.k8s.status.Pending': '等待中',
|
||||
'systemManager.k8s.status.Completed': '已完成',
|
||||
'systemManager.k8s.status.Succeeded': '已成功',
|
||||
'systemManager.k8s.status.Failed': '失败',
|
||||
'systemManager.k8s.status.Unknown': '未知',
|
||||
'systemManager.k8s.status.CrashLoopBackOff': '崩溃循环',
|
||||
'systemManager.k8s.status.ContainerCreating': '创建中',
|
||||
'systemManager.k8s.status.Terminating': '终止中',
|
||||
'systemManager.k8s.status.ImagePullBackOff': '镜像拉取退避',
|
||||
'systemManager.k8s.status.ErrImagePull': '拉取镜像失败',
|
||||
'systemManager.k8s.status.Error': '错误',
|
||||
'systemManager.k8s.status.Evicted': '已驱逐',
|
||||
'systemManager.k8s.status.OOMKilled': '内存不足',
|
||||
|
||||
'systemManager.ports.unavailable': '此主机未检测到端口工具(ss / netstat)。',
|
||||
'systemManager.ports.loading': '正在加载监听端口…',
|
||||
|
||||
@@ -487,8 +487,8 @@ export const zhCNTerminalMessages: Messages = {
|
||||
'settings.terminal.systemManager.processRefreshInterval.desc': '系统管理侧栏中进程列表的刷新频率。',
|
||||
'settings.terminal.systemManager.tmuxRefreshInterval': 'tmux 会话刷新间隔',
|
||||
'settings.terminal.systemManager.tmuxRefreshInterval.desc': 'tmux 会话列表的刷新频率。',
|
||||
'settings.terminal.systemManager.dockerListRefreshInterval': '容器列表刷新间隔',
|
||||
'settings.terminal.systemManager.dockerListRefreshInterval.desc': '容器与 Pod 列表的刷新频率。',
|
||||
'settings.terminal.systemManager.dockerListRefreshInterval': 'Docker 容器列表刷新间隔',
|
||||
'settings.terminal.systemManager.dockerListRefreshInterval.desc': 'Docker 容器列表的刷新频率。',
|
||||
'settings.terminal.systemManager.dockerStatsRefreshInterval': 'Docker 性能数据刷新间隔',
|
||||
'settings.terminal.systemManager.dockerStatsRefreshInterval.desc': 'Docker 容器 CPU/内存/网络指标的刷新频率。',
|
||||
|
||||
@@ -1003,13 +1003,11 @@ export const zhCNTerminalMessages: Messages = {
|
||||
'keyboard.interactive.desc': '服务器需要额外的身份验证。',
|
||||
'keyboard.interactive.descWithHost': '服务器 {hostname} 需要额外的身份验证。',
|
||||
'keyboard.interactive.response': '响应',
|
||||
'keyboard.interactive.verificationCode': '验证码',
|
||||
'keyboard.interactive.enterCode': '输入验证码',
|
||||
'keyboard.interactive.enterResponse': '输入响应',
|
||||
'keyboard.interactive.submit': '提交',
|
||||
'keyboard.interactive.verifying': '验证中...',
|
||||
'keyboard.interactive.savePassword': '保存密码',
|
||||
'keyboard.interactive.fillTotp': '填入验证码',
|
||||
|
||||
// Passphrase Modal for encrypted SSH keys
|
||||
'passphrase.title': 'SSH 密钥密码',
|
||||
|
||||
@@ -290,35 +290,6 @@ export const zhCNVaultMessages: Messages = {
|
||||
'hostDetails.auth.certificate.desc': '使用选中的 SSH 证书;如果同时保存了密码,可在失败后尝试密码。',
|
||||
'hostDetails.auth.mfaFirst': '优先交互式认证',
|
||||
'hostDetails.auth.mfaFirst.desc': '适用于必须通过 keyboard-interactive 输入密码和 MFA 的服务器。',
|
||||
'hostDetails.totp.title': '验证器 (TOTP)',
|
||||
'hostDetails.totp.enable': '为这台主机保存 TOTP 密钥',
|
||||
'hostDetails.totp.setup': '设置…',
|
||||
'hostDetails.totp.edit': '编辑',
|
||||
'hostDetails.totp.modalTitle': '设置验证器',
|
||||
'hostDetails.totp.replacePlaceholder': '粘贴新密钥以替换当前密钥',
|
||||
'hostDetails.totp.warning': '能打开这个保险库的人会同时得到验证码和密码。请只在个人设备上使用。',
|
||||
'hostDetails.totp.secret': '密钥或 otpauth URI',
|
||||
'hostDetails.totp.secretPlaceholder': '粘贴 otpauth://totp/... 或 Base32 密钥',
|
||||
'hostDetails.totp.importQr': '导入二维码图片',
|
||||
'hostDetails.totp.scanScreen': '从屏幕扫描二维码',
|
||||
'hostDetails.totp.scanning': '正在扫描屏幕…',
|
||||
'hostDetails.totp.importedAccount': '已导入 {account}',
|
||||
'hostDetails.totp.preview': '当前验证码',
|
||||
'hostDetails.totp.countdown': '剩余 {seconds} 秒',
|
||||
'hostDetails.totp.autoSubmit': '在已知 TOTP 提示上自动提交',
|
||||
'hostDetails.totp.autoSubmitDesc': '每次连接只提交一次,且提示必须精确匹配 JumpServer、Google Authenticator PAM,或你填写的正则。',
|
||||
'hostDetails.totp.promptPattern': '自定义提示正则',
|
||||
'hostDetails.totp.promptPatternPlaceholder': '可选的 RE2 正则表达式',
|
||||
'hostDetails.totp.clear': '移除验证器',
|
||||
'hostDetails.totp.removeTitle': '移除验证器?',
|
||||
'hostDetails.totp.removeMessage': '这台主机将不再保存这个 TOTP 密钥。',
|
||||
'hostDetails.totp.error.hotp': '不支持 HOTP。请使用 TOTP 密钥。',
|
||||
'hostDetails.totp.error.migration': '不支持 otpauth-migration 二维码。请使用服务提供的单个账号绑定二维码。',
|
||||
'hostDetails.totp.error.invalid': '无法从该内容读取 TOTP 密钥。',
|
||||
'hostDetails.totp.error.notFound': '没有找到 TOTP 二维码。',
|
||||
'hostDetails.totp.error.permission': '需要屏幕录制权限。请在系统设置中允许 Netcatty 录制屏幕后再试。',
|
||||
'hostDetails.totp.error.unavailable': '无法使用屏幕捕获。',
|
||||
'hostDetails.totp.error.pattern': '自定义提示正则不是有效的 RE2 正则表达式。',
|
||||
'hostDetails.auth.credentialRequired': '保存这种登录方式前,请先选择密钥或证书。',
|
||||
'hostDetails.identity.suggestions': '身份',
|
||||
'hostDetails.identity.missing': '身份不存在',
|
||||
|
||||
@@ -280,14 +280,6 @@ export const zhTWAiMessages: Messages = {
|
||||
'ai.opencode.customPathPlaceholder': '例如 /usr/local/bin/opencode',
|
||||
'ai.opencode.check': '檢查',
|
||||
'ai.opencode.resetPath': '重設',
|
||||
'ai.opencode.v2.startupTimeout': 'OpenCode 2 未能及時就緒。請先在終端機確認 `opencode serve` 可以啟動,然後重試。',
|
||||
'ai.opencode.v2.exited': 'OpenCode 2 在對話開始前已結束。',
|
||||
'ai.opencode.v2.unauthorized': 'OpenCode 2 拒絕了連線。請重試這次對話。',
|
||||
'ai.opencode.v2.unavailable': '無法連線到 OpenCode 2。請確認已安裝後再試。',
|
||||
'ai.opencode.v2.sessionFailed': 'OpenCode 2 未能完成這一輪對話。',
|
||||
'ai.opencode.v2.empty': 'OpenCode 2 沒有傳回內容。請在終端機執行 `opencode` 完成登入和模型設定。',
|
||||
'ai.opencode.v2.contextSkipped': 'OpenCode 2 無法套用 Netcatty 的對話內容。',
|
||||
'ai.opencode.v2.mcpSkipped': 'OpenCode 2 無法掛接 MCP 工具,對話會繼續,但沒有該工具。',
|
||||
|
||||
// AI MiMo Code(小米 MiMo Code CLI,OpenCode 分支)
|
||||
'ai.mimo.title': 'MiMo Code',
|
||||
@@ -374,22 +366,6 @@ export const zhTWAiMessages: Messages = {
|
||||
'ai.externalMcp.help.ariaLabel': '對外 MCP 使用說明',
|
||||
'ai.externalMcp.security': '安全',
|
||||
'ai.externalMcp.security.description': '僅監聽 127.0.0.1,使用輪替 token,寫入操作重用 AI 權限模式,關閉時刪除 discovery。',
|
||||
"ai.privacy.title": "AI 隱私",
|
||||
"ai.privacy.description": "為 Catty、整合 Agent 和外部 MCP 用戶端過濾 Netcatty 管理的上下文及工具結果中已知的敏感文字模式。預設關閉,無法識別所有機密。啟用時會封鎖二進位工具回應。",
|
||||
"ai.privacy.boundary": "僅涵蓋 Netcatty 管理的資料和工具。第三方 Agent 的原生工具、直接讀取的檔案及其他 MCP 伺服器不在保護範圍內。",
|
||||
"ai.privacy.enable": "啟用文字去識別化",
|
||||
"ai.privacy.privateIps": "隱藏私有 IP 位址",
|
||||
"ai.privacy.privateIps.description": "同時隱藏回傳文字中的私有和本機 IP 位址。",
|
||||
"ai.privacy.cidrs": "額外 IP 網段(CIDR)",
|
||||
"ai.privacy.cidrs.description": "每行一個 IPv4 或 IPv6 CIDR。留空則僅使用內建規則。",
|
||||
"ai.privacy.patterns": "自訂正規表示式",
|
||||
"ai.privacy.patterns.description": "每行一個相容 RE2 的模式,不含 / 分隔符號或旗標。不支援或不安全的模式會遭拒絕。",
|
||||
"ai.privacy.connectionScope": "預留位置在同一個受管對話或外部 MCP 連線內保持穩定。只有同一範圍內的 Netcatty 受管工具呼叫才能還原原始值。",
|
||||
"ai.privacy.reconnect": "套用變更後,請建立新的受管對話並重新連接外部 MCP 用戶端。現有對話和連線不得重複使用預留位置。",
|
||||
"ai.privacy.apply": "套用",
|
||||
"ai.privacy.applying": "正在套用...",
|
||||
"ai.privacy.applied": "AI 隱私設定已套用。請建立新的受管對話並重新連接外部 MCP 用戶端。",
|
||||
"ai.privacy.failed": "AI 隱私設定未套用。",
|
||||
'ai.externalMcp.http.title': 'Streamable HTTP',
|
||||
'ai.externalMcp.http.description': '以 Streamable HTTP 提供同一套對外 MCP 工具,給無法啟動 stdio CLI 的用戶端使用,例如 WSL 裡的 Codex。預設關閉。',
|
||||
'ai.externalMcp.http.enable': '啟用 HTTP',
|
||||
|
||||
@@ -787,7 +787,6 @@ Vaults 分頁維持扁平,不使用這塊背景。
|
||||
'vault.hosts.moveSelected': '移動到群組 ({count})',
|
||||
'vault.hosts.moveSelected.description': '將選取的主機移動到現有群組,也可移出群組。選取的群組本身不會移動。',
|
||||
'vault.hosts.moveMultiple.success': '已將 {count} 個主機移動到 {group}',
|
||||
'vault.hosts.dragSelectionCount': '{count} 個主機',
|
||||
'vault.hosts.moveToGroup.success': '已將 {host} 移動到 {group}',
|
||||
'vault.hosts.errors.nameRequired': '主機名稱不能為空。',
|
||||
'vault.hosts.empty.title': '設定你的主機',
|
||||
|
||||
@@ -26,11 +26,6 @@ export const zhTwSystemManagerMessages: Messages = {
|
||||
'systemManager.errors.loadDocker': '載入容器列表失敗',
|
||||
'systemManager.errors.loadDockerStats': '載入容器效能資料失敗',
|
||||
'systemManager.errors.loadDockerImages': '載入映像列表失敗',
|
||||
'systemManager.errors.loadKubernetes': '無法載入 Kubernetes 資源',
|
||||
'systemManager.errors.loadPods': '無法載入 Pod',
|
||||
'systemManager.errors.nerdctlUnreachable': 'nerdctl 無法連線到 containerd',
|
||||
'systemManager.errors.clusterUnreachable': '無法連到內容 {{context}} 的叢集',
|
||||
'systemManager.errors.clusterUnreachableGeneric': '無法連到叢集',
|
||||
'systemManager.errors.loadOverview': '載入系統概覽失敗',
|
||||
'systemManager.errors.loadGpu': '載入 GPU / NPU 狀態失敗',
|
||||
'systemManager.errors.loadPorts': '載入監聽連接埠失敗',
|
||||
@@ -137,7 +132,7 @@ export const zhTwSystemManagerMessages: Messages = {
|
||||
'systemManager.tmux.newWindowPlaceholder': '視窗名稱(可選)',
|
||||
'systemManager.tmux.noWindows': '沒有視窗',
|
||||
'systemManager.tmux.unavailable': '此主機未偵測到 tmux',
|
||||
'systemManager.docker.unavailable': '此主機未偵測到容器執行環境(Docker、Podman、nerdctl 或 kubectl)',
|
||||
'systemManager.docker.unavailable': '此主機未偵測到 Docker',
|
||||
'systemManager.tmux.windowsMismatch': '工作階段顯示有 {{count}} 個視窗,但 list-windows 未傳回任何視窗',
|
||||
'systemManager.tmux.lastCommand': '最後執行的指令:{{command}}',
|
||||
'systemManager.tmux.noPanes': '沒有窗格',
|
||||
@@ -199,36 +194,6 @@ export const zhTwSystemManagerMessages: Messages = {
|
||||
'systemManager.docker.imagesMeta': '{{count}} 個映像',
|
||||
'systemManager.docker.start': '啟動',
|
||||
'systemManager.docker.stop': '停止',
|
||||
'systemManager.runtime.switch': '容器執行環境',
|
||||
'systemManager.runtime.docker': 'Docker',
|
||||
'systemManager.runtime.podman': 'Podman',
|
||||
'systemManager.runtime.nerdctl': 'nerdctl',
|
||||
'systemManager.runtime.kubernetes': 'Kubernetes',
|
||||
'systemManager.k8s.context': '內容',
|
||||
'systemManager.k8s.namespace': '命名空間',
|
||||
'systemManager.k8s.empty': '沒有 Pod',
|
||||
'systemManager.k8s.search': '搜尋 Pod…',
|
||||
'systemManager.k8s.meta': '{{count}} 個 Pod',
|
||||
'systemManager.k8s.describe': '描述',
|
||||
'systemManager.k8s.delete': '刪除 Pod',
|
||||
'systemManager.k8s.confirmDelete': '確定刪除 Pod「{{name}}」?',
|
||||
'systemManager.k8s.restarts': '重新啟動 {{count}} 次',
|
||||
'systemManager.k8s.chooseContainer': '選擇容器',
|
||||
'systemManager.k8s.noContext': '此主機沒有 Kubernetes 內容',
|
||||
'systemManager.k8s.status.Running': '執行中',
|
||||
'systemManager.k8s.status.Pending': '等待中',
|
||||
'systemManager.k8s.status.Completed': '已完成',
|
||||
'systemManager.k8s.status.Succeeded': '已成功',
|
||||
'systemManager.k8s.status.Failed': '失敗',
|
||||
'systemManager.k8s.status.Unknown': '未知',
|
||||
'systemManager.k8s.status.CrashLoopBackOff': '當機重試',
|
||||
'systemManager.k8s.status.ContainerCreating': '建立中',
|
||||
'systemManager.k8s.status.Terminating': '終止中',
|
||||
'systemManager.k8s.status.ImagePullBackOff': '映像拉取退避',
|
||||
'systemManager.k8s.status.ErrImagePull': '拉取映像失敗',
|
||||
'systemManager.k8s.status.Error': '錯誤',
|
||||
'systemManager.k8s.status.Evicted': '已驅逐',
|
||||
'systemManager.k8s.status.OOMKilled': '記憶體不足',
|
||||
|
||||
'systemManager.ports.unavailable': '此主機未偵測到連接埠工具(ss / netstat)。',
|
||||
'systemManager.ports.loading': '正在載入監聽連接埠…',
|
||||
|
||||
@@ -487,8 +487,8 @@ export const zhTWTerminalMessages: Messages = {
|
||||
'settings.terminal.systemManager.processRefreshInterval.desc': '系統管理側欄中程序列表的重新整理頻率。',
|
||||
'settings.terminal.systemManager.tmuxRefreshInterval': 'tmux 工作階段重新整理間隔',
|
||||
'settings.terminal.systemManager.tmuxRefreshInterval.desc': 'tmux 工作階段列表的重新整理頻率。',
|
||||
'settings.terminal.systemManager.dockerListRefreshInterval': '容器列表重新整理間隔',
|
||||
'settings.terminal.systemManager.dockerListRefreshInterval.desc': '容器與 Pod 列表的重新整理頻率。',
|
||||
'settings.terminal.systemManager.dockerListRefreshInterval': 'Docker 容器列表重新整理間隔',
|
||||
'settings.terminal.systemManager.dockerListRefreshInterval.desc': 'Docker 容器列表的重新整理頻率。',
|
||||
'settings.terminal.systemManager.dockerStatsRefreshInterval': 'Docker 效能資料重新整理間隔',
|
||||
'settings.terminal.systemManager.dockerStatsRefreshInterval.desc': 'Docker 容器 CPU/記憶體/網路指標的重新整理頻率。',
|
||||
|
||||
@@ -1003,13 +1003,11 @@ export const zhTWTerminalMessages: Messages = {
|
||||
'keyboard.interactive.desc': '伺服器需要額外的身分驗證。',
|
||||
'keyboard.interactive.descWithHost': '伺服器 {hostname} 需要額外的身分驗證。',
|
||||
'keyboard.interactive.response': '回應',
|
||||
'keyboard.interactive.verificationCode': '驗證碼',
|
||||
'keyboard.interactive.enterCode': '輸入驗證碼',
|
||||
'keyboard.interactive.enterResponse': '輸入回應',
|
||||
'keyboard.interactive.submit': '送出',
|
||||
'keyboard.interactive.submit': '提交',
|
||||
'keyboard.interactive.verifying': '驗證中...',
|
||||
'keyboard.interactive.savePassword': '儲存密碼',
|
||||
'keyboard.interactive.fillTotp': '填入驗證碼',
|
||||
|
||||
// Passphrase Modal for encrypted SSH keys
|
||||
'passphrase.title': 'SSH 金鑰密碼',
|
||||
|
||||
@@ -290,35 +290,6 @@ export const zhTWVaultMessages: Messages = {
|
||||
'hostDetails.auth.certificate.desc': '使用選取的 SSH 憑證;如果同時儲存了密碼,可在失敗後嘗試密碼。',
|
||||
'hostDetails.auth.mfaFirst': '優先互動式驗證',
|
||||
'hostDetails.auth.mfaFirst.desc': '適用於必須透過 keyboard-interactive 輸入密碼和 MFA 的伺服器。',
|
||||
'hostDetails.totp.title': '驗證器 (TOTP)',
|
||||
'hostDetails.totp.enable': '為這台主機儲存 TOTP 金鑰',
|
||||
'hostDetails.totp.setup': '設定…',
|
||||
'hostDetails.totp.edit': '編輯',
|
||||
'hostDetails.totp.modalTitle': '設定驗證器',
|
||||
'hostDetails.totp.replacePlaceholder': '貼上新金鑰以取代目前的金鑰',
|
||||
'hostDetails.totp.warning': '能開啟這個保險庫的人會同時取得驗證碼和密碼。請只在個人裝置上使用。',
|
||||
'hostDetails.totp.secret': '金鑰或 otpauth URI',
|
||||
'hostDetails.totp.secretPlaceholder': '貼上 otpauth://totp/... 或 Base32 金鑰',
|
||||
'hostDetails.totp.importQr': '匯入 QR 碼圖片',
|
||||
'hostDetails.totp.scanScreen': '從螢幕掃描 QR 碼',
|
||||
'hostDetails.totp.scanning': '正在掃描螢幕…',
|
||||
'hostDetails.totp.importedAccount': '已匯入 {account}',
|
||||
'hostDetails.totp.preview': '目前驗證碼',
|
||||
'hostDetails.totp.countdown': '剩餘 {seconds} 秒',
|
||||
'hostDetails.totp.autoSubmit': '在已知 TOTP 提示上自動送出',
|
||||
'hostDetails.totp.autoSubmitDesc': '每次連線只送出一次,且提示必須精確符合 JumpServer、Google Authenticator PAM,或你填寫的規則運算式。',
|
||||
'hostDetails.totp.promptPattern': '自訂提示規則',
|
||||
'hostDetails.totp.promptPatternPlaceholder': '可選的 RE2 規則運算式',
|
||||
'hostDetails.totp.clear': '移除驗證器',
|
||||
'hostDetails.totp.removeTitle': '移除驗證器?',
|
||||
'hostDetails.totp.removeMessage': '這台主機將不再保存這個 TOTP 金鑰。',
|
||||
'hostDetails.totp.error.hotp': '不支援 HOTP。請使用 TOTP 金鑰。',
|
||||
'hostDetails.totp.error.migration': '不支援 otpauth-migration QR 碼。請使用服務提供的單一帳號綁定 QR 碼。',
|
||||
'hostDetails.totp.error.invalid': '無法從該內容讀取 TOTP 金鑰。',
|
||||
'hostDetails.totp.error.notFound': '沒有找到 TOTP QR 碼。',
|
||||
'hostDetails.totp.error.permission': '需要螢幕錄製權限。請在系統設定中允許 Netcatty 錄製螢幕後再試。',
|
||||
'hostDetails.totp.error.unavailable': '無法使用螢幕擷取。',
|
||||
'hostDetails.totp.error.pattern': '自訂提示規則不是有效的 RE2 規則運算式。',
|
||||
'hostDetails.auth.credentialRequired': '儲存這種登入方式前,請先選擇金鑰或憑證。',
|
||||
'hostDetails.identity.suggestions': '身分',
|
||||
'hostDetails.identity.missing': '身分不存在',
|
||||
|
||||
@@ -1,21 +1,10 @@
|
||||
import assert from 'node:assert/strict';
|
||||
import test from 'node:test';
|
||||
import { createAiPrivacyService } from '../../electron/shared/aiPrivacyService.cjs';
|
||||
import {
|
||||
hasReversibleModelPrivacy,
|
||||
setReversibleModelPrivacy,
|
||||
} from '../../infrastructure/ai/harness/modelSecretRedaction';
|
||||
import type { AISession } from '../../infrastructure/ai/types';
|
||||
import { localStorageAdapter } from '../../infrastructure/persistence/localStorageAdapter';
|
||||
import {
|
||||
cleanupClosedTerminalSessions,
|
||||
cleanupDeletedAIChatSessions,
|
||||
cleanupOrphanedAISessions,
|
||||
cleanupSdkAgentSessions,
|
||||
latestAIActiveSessionMapSnapshot,
|
||||
latestAISessionsSnapshot,
|
||||
setLatestAIActiveSessionMapSnapshot,
|
||||
setLatestAISessionsSnapshot,
|
||||
} from './aiStateSnapshots';
|
||||
|
||||
test('orphan cleanup keeps durable Catty output while explicit deletion removes it', async () => {
|
||||
@@ -72,92 +61,6 @@ function makeSession(id: string, updatedAt: number, messages: unknown[]): AISess
|
||||
} as never;
|
||||
}
|
||||
|
||||
test('orphan cleanup preserves resumable privacy maps until explicit chat deletion', (t) => {
|
||||
const service = createAiPrivacyService({ maxSessions: 3 });
|
||||
service.configure({ enabled: true });
|
||||
const sessions = [
|
||||
{ ...makeSession('privacy-orphan', 1, []), scope: { type: 'terminal' as const, targetId: 'closed' } },
|
||||
{ ...makeSession('privacy-live', 2, []), scope: { type: 'terminal' as const, targetId: 'open' } },
|
||||
{ ...makeSession('privacy-resumed', 3, []), scope: { type: 'terminal' as const, targetId: 'closed' } },
|
||||
];
|
||||
const aliases = sessions.map(session => {
|
||||
setReversibleModelPrivacy(session.id, true);
|
||||
return service.redact(session.id, '8.8.8.8');
|
||||
});
|
||||
const sdkCleanups: string[] = [];
|
||||
const privacyCleanups: string[] = [];
|
||||
const outputCleanups: string[] = [];
|
||||
const previousWindow = globalThis.window;
|
||||
const previousSessions = latestAISessionsSnapshot;
|
||||
const previousActiveSessionMap = latestAIActiveSessionMapSnapshot;
|
||||
t.mock.method(localStorageAdapter, 'write', () => true);
|
||||
Object.defineProperty(globalThis, 'window', {
|
||||
configurable: true,
|
||||
value: {
|
||||
dispatchEvent: () => true,
|
||||
netcatty: {
|
||||
aiSdkAgentCleanup: async (id: string) => {
|
||||
sdkCleanups.push(id);
|
||||
return { ok: true };
|
||||
},
|
||||
aiPrivacyClearSession: async (id: string) => {
|
||||
privacyCleanups.push(id);
|
||||
service.clearSession(id);
|
||||
return { ok: true };
|
||||
},
|
||||
deleteChatToolOutputsTemp: async (id: string) => {
|
||||
outputCleanups.push(id);
|
||||
return { deletedCount: 1 };
|
||||
},
|
||||
},
|
||||
},
|
||||
});
|
||||
setLatestAISessionsSnapshot(sessions);
|
||||
setLatestAIActiveSessionMapSnapshot({
|
||||
'terminal:closed': 'privacy-orphan',
|
||||
'terminal:open': 'privacy-live',
|
||||
'terminal:resumed-into': 'privacy-resumed',
|
||||
});
|
||||
|
||||
try {
|
||||
cleanupOrphanedAISessions(new Set(['open', 'resumed-into']));
|
||||
|
||||
assert.equal(latestAISessionsSnapshot, sessions);
|
||||
assert.deepEqual(latestAIActiveSessionMapSnapshot, {
|
||||
'terminal:open': 'privacy-live',
|
||||
'terminal:resumed-into': 'privacy-resumed',
|
||||
});
|
||||
assert.deepEqual(sdkCleanups, ['privacy-orphan']);
|
||||
assert.deepEqual(privacyCleanups, []);
|
||||
assert.deepEqual(outputCleanups, []);
|
||||
for (const [index, session] of sessions.entries()) {
|
||||
assert.equal(hasReversibleModelPrivacy(session.id), true);
|
||||
assert.equal(service.redact(session.id, '8.8.8.8'), aliases[index]);
|
||||
assert.equal(service.restore(session.id, aliases[index]), '8.8.8.8');
|
||||
}
|
||||
assert.throws(() => service.checkSession('privacy-next'), /session limit reached/);
|
||||
|
||||
cleanupDeletedAIChatSessions(['privacy-orphan']);
|
||||
|
||||
assert.deepEqual(privacyCleanups, ['privacy-orphan']);
|
||||
assert.deepEqual(outputCleanups, ['privacy-orphan']);
|
||||
assert.equal(hasReversibleModelPrivacy('privacy-orphan'), false);
|
||||
assert.equal(service.hasSession('privacy-orphan'), false);
|
||||
assert.throws(() => service.checkSession('privacy-orphan'), /start a new chat/);
|
||||
assert.equal(service.checkSession('privacy-next'), true);
|
||||
for (const [index, session] of sessions.entries()) {
|
||||
if (session.id === 'privacy-orphan') continue;
|
||||
assert.equal(hasReversibleModelPrivacy(session.id), true);
|
||||
assert.equal(service.restore(session.id, aliases[index]), '8.8.8.8');
|
||||
}
|
||||
} finally {
|
||||
for (const session of sessions) setReversibleModelPrivacy(session.id, false);
|
||||
setLatestAISessionsSnapshot(previousSessions ?? []);
|
||||
setLatestAIActiveSessionMapSnapshot(previousActiveSessionMap ?? {});
|
||||
Object.defineProperty(globalThis, 'window', { configurable: true, value: previousWindow });
|
||||
}
|
||||
});
|
||||
|
||||
test('serializeSessionsForStorage strips oldest ciphertext before dropping visible sessions', async () => {
|
||||
const { serializeSessionsForStorage } = await import('./aiStateSnapshots');
|
||||
const ciphertext = 'gAAAA'.repeat(50000); // ~250 KB of ciphertext per message
|
||||
|
||||
@@ -30,7 +30,6 @@ import {
|
||||
isMissingDirectoryReplacePathError,
|
||||
promoteDirectoryReplaceStage as promoteDirectoryReplacePaths,
|
||||
} from "./directoryReplacePromotion";
|
||||
import { unwrapSftpIpcError } from "./errors";
|
||||
import { sftpTransferCenterStore } from "../sftpTransferCenterStore";
|
||||
|
||||
export interface DedicatedResumeDeps {
|
||||
@@ -180,11 +179,9 @@ export async function openTransferSftpSession(
|
||||
// Restart/resume owns an independent SSH dial. Normal pooled transfer opens
|
||||
// leave reuseTransport enabled so the main-process registry can reuse an
|
||||
// authenticated endpoint even when no terminal tab exists.
|
||||
// No SFTP-page host-key dialog owns this session id. An untrusted key must
|
||||
// fail the transfer immediately instead of sitting on the verifier TTL.
|
||||
const openOpts = wantDedicated
|
||||
? { ...credentials, reuseTransport: false as const, hostKeyUnattended: true as const }
|
||||
: { ...credentials, hostKeyUnattended: true as const };
|
||||
? { ...credentials, reuseTransport: false as const }
|
||||
: credentials;
|
||||
|
||||
// The main-process auth driver owns method ordering, fallback, MFA and
|
||||
// retry. A renderer-side key-then-password retry performs a second full SSH
|
||||
@@ -743,7 +740,7 @@ async function resumeSingleFileWithDedicatedSession(
|
||||
const err = error as Error & { dedicatedAttention?: boolean; resetCheckpoint?: boolean };
|
||||
return {
|
||||
success: false,
|
||||
error: unwrapSftpIpcError(error),
|
||||
error: err instanceof Error ? err.message : String(error),
|
||||
needsAttention: !!err.dedicatedAttention,
|
||||
resetCheckpoint: !!err.resetCheckpoint,
|
||||
};
|
||||
|
||||
@@ -38,20 +38,6 @@ test("runWithTransferRetry does not retry permanent errors", async () => {
|
||||
assert.equal(attempts, 1);
|
||||
});
|
||||
|
||||
test("a dead SFTP channel is not retried into another dial (#3310)", async () => {
|
||||
assert.equal(isTransientTransferError(new Error("SFTP channel closed")), false);
|
||||
assert.equal(isTransientTransferError(new Error("SFTP session not found")), true);
|
||||
let attempts = 0;
|
||||
await assert.rejects(
|
||||
() => runWithTransferRetry(async () => {
|
||||
attempts += 1;
|
||||
throw new Error("SFTP channel closed");
|
||||
}, { retries: 1, delayMs: 0 }),
|
||||
/SFTP channel closed/,
|
||||
);
|
||||
assert.equal(attempts, 1, "channel death must not schedule a reconnect");
|
||||
});
|
||||
|
||||
test("runWithTransferRetry does not retry cancel", async () => {
|
||||
let attempts = 0;
|
||||
await assert.rejects(
|
||||
|
||||
@@ -6,9 +6,6 @@ import { isSessionError } from "./errors";
|
||||
* path) should not be retried.
|
||||
*/
|
||||
export function isTransientTransferError(err: unknown): boolean {
|
||||
// The channel is gone. Retrying opens a new dial, and an OTP host then
|
||||
// sits on keyboard-interactive while the transfer stays active.
|
||||
if (err instanceof Error && /sftp channel closed/i.test(err.message)) return false;
|
||||
if (isSessionError(err)) return true;
|
||||
if (!(err instanceof Error)) return false;
|
||||
const msg = err.message.toLowerCase();
|
||||
|
||||
@@ -4,7 +4,6 @@ import { netcattyBridge } from "../../../infrastructure/services/netcattyBridge"
|
||||
import type { Host, Identity, KnownHost, SftpConnection, SftpFileEntry, SftpFilenameEncoding, SSHKey } from "../../../domain/models";
|
||||
import { createKnownHostFromHostKeyInfo } from "../../../domain/knownHosts";
|
||||
import type { SftpHostKeyInfo, SftpHostKeyVerificationState, SftpPane } from "./types";
|
||||
import { unwrapSftpIpcError } from "./errors";
|
||||
import { useSftpDirectoryListing } from "./useSftpDirectoryListing";
|
||||
import { useSftpHostCredentials } from "./useSftpHostCredentials";
|
||||
import { buildCacheKey, getSharedRemoteHostCache, setSharedRemoteHostCache } from "./sharedRemoteHostCache";
|
||||
@@ -958,7 +957,7 @@ export const useSftpConnections = ({
|
||||
if (!isTargetConnectionAtPath(startPath)) return;
|
||||
updateTargetTab((prev) => ({
|
||||
...prev,
|
||||
error: unwrapSftpIpcError(err) || "Failed to list directory",
|
||||
error: err instanceof Error ? err.message : "Failed to list directory",
|
||||
loading: false,
|
||||
reconnecting: false,
|
||||
}));
|
||||
@@ -1264,14 +1263,14 @@ export const useSftpConnections = ({
|
||||
? {
|
||||
...prev.connection,
|
||||
status: "error",
|
||||
error: unwrapSftpIpcError(err) || "Connection failed",
|
||||
error: err instanceof Error ? err.message : "Connection failed",
|
||||
}
|
||||
: null,
|
||||
files: isReconnectAttempt ? [] : prev.files,
|
||||
selectedFiles: isReconnectAttempt ? new Set<string>() : prev.selectedFiles,
|
||||
error: isReconnectAttempt
|
||||
? "sftp.error.reconnectFailed"
|
||||
: (unwrapSftpIpcError(err) || "Connection failed"),
|
||||
: (err instanceof Error ? err.message : "Connection failed"),
|
||||
loading: false,
|
||||
reconnecting: false,
|
||||
}));
|
||||
|
||||
@@ -3,7 +3,6 @@ import type { Host, Identity, KnownHost, SSHKey, TerminalSettings } from "../../
|
||||
import { isEncryptedCredentialPlaceholder, sanitizeCredentialValue } from "../../../domain/credentials";
|
||||
import { resolveBridgeKeyAuth, resolveBridgeSshAgentAuth, resolveHostAuth } from "../../../domain/sshAuth";
|
||||
import { resolveHostKeepalive } from "../../../domain/host";
|
||||
import { connectionTotpFields } from "../../../domain/totpConfig";
|
||||
import { resolveHostSshConnectionTimeouts } from "../../../domain/sshConnectionTimeouts";
|
||||
import {
|
||||
findIncompleteProxyIdentityId,
|
||||
@@ -122,7 +121,6 @@ export const buildSftpHostCredentials = ({
|
||||
authMethod: jumpAuth.authMethod,
|
||||
requiresMfa: !!jumpHost.requiresMfa,
|
||||
password: jumpPassword,
|
||||
...connectionTotpFields(jumpHost),
|
||||
privateKey: jumpKeyAuth.privateKey,
|
||||
certificate: jumpKey?.certificate,
|
||||
passphrase: jumpKeyAuth.passphrase,
|
||||
@@ -184,7 +182,6 @@ export const buildSftpHostCredentials = ({
|
||||
requiresMfa: !!host.requiresMfa,
|
||||
port: host.port || 22,
|
||||
password,
|
||||
...connectionTotpFields(host),
|
||||
privateKey: keyAuth.privateKey,
|
||||
certificate: key?.certificate,
|
||||
passphrase: keyAuth.passphrase,
|
||||
|
||||
@@ -56,166 +56,6 @@ test(`failed ${direction} connection replaces Waiting with the actual error (#36
|
||||
});
|
||||
}
|
||||
|
||||
test("upload open uses known hosts trusted after the transfer callback was created (#3310)", async () => {
|
||||
const seen: string[] = [];
|
||||
let fingerprint = "";
|
||||
let ops: ReturnType<typeof useSftpTransfers> | undefined;
|
||||
let renderer: ReactTestRenderer | undefined;
|
||||
let firstStart: ReturnType<typeof useSftpTransfers>["startTransfer"] | undefined;
|
||||
const restore = installGlobals({});
|
||||
function Probe() {
|
||||
const captured = fingerprint;
|
||||
const acquireTransferSession = React.useCallback(async () => {
|
||||
seen.push(captured);
|
||||
throw new Error(`known-hosts:${captured || "empty"}`);
|
||||
}, [captured]);
|
||||
ops = useSftpTransfers({
|
||||
ownerId: "known-hosts-after-trust-3310",
|
||||
getActivePane: (side) => makePane(side === "left" ? "right" : "left"),
|
||||
getPaneByConnectionId: () => null,
|
||||
getTabByConnectionId: () => null,
|
||||
updateTab: () => undefined,
|
||||
refresh: async () => undefined,
|
||||
clearCacheForConnection: () => undefined,
|
||||
handleSessionError: () => undefined,
|
||||
sftpSessionsRef: { current: new Map() },
|
||||
connectionCacheKeyMapRef: { current: new Map() },
|
||||
listLocalFiles: async () => [],
|
||||
listRemoteFiles: async () => [],
|
||||
acquireTransferSession,
|
||||
});
|
||||
if (!firstStart) firstStart = ops.startTransfer;
|
||||
return null;
|
||||
}
|
||||
try {
|
||||
await act(async () => { renderer = create(React.createElement(Probe)); });
|
||||
fingerprint = "trusted-this-session";
|
||||
await act(async () => { renderer?.update(React.createElement(Probe)); });
|
||||
await act(async () => {
|
||||
await firstStart!(
|
||||
[{ name: "upload.txt", isDirectory: false }],
|
||||
"left",
|
||||
"right",
|
||||
);
|
||||
});
|
||||
assert.deepEqual(seen, ["trusted-this-session"]);
|
||||
const task = sftpTransferCenterStore.getOwnerTasks("known-hosts-after-trust-3310")[0];
|
||||
assert.equal(task?.error, "known-hosts:trusted-this-session");
|
||||
} finally {
|
||||
await act(async () => { renderer?.unmount(); });
|
||||
restore();
|
||||
}
|
||||
});
|
||||
|
||||
test("channel death fails the upload without a second session open (#3310)", async () => {
|
||||
let acquires = 0;
|
||||
let streams = 0;
|
||||
const restore = installGlobals({
|
||||
statLocal: async () => ({ size: 48, lastModified: 1 }),
|
||||
statSftp: async () => null,
|
||||
startStreamTransfer: async () => {
|
||||
streams += 1;
|
||||
return { error: "SFTP channel closed" };
|
||||
},
|
||||
pauseTransfer: async () => ({ success: false, reason: "Transfer is no longer active" }),
|
||||
resumeTransfer: async () => ({ success: false, reason: "Transfer is no longer active" }),
|
||||
});
|
||||
let ops: ReturnType<typeof useSftpTransfers> | undefined;
|
||||
let renderer: ReactTestRenderer | undefined;
|
||||
const ownerId = "channel-death-no-reconnect-3310";
|
||||
function Probe() {
|
||||
ops = useSftpTransfers({
|
||||
ownerId,
|
||||
getActivePane: (side) => makePane(side === "left" ? "right" : "left"),
|
||||
getPaneByConnectionId: () => null,
|
||||
getTabByConnectionId: () => null,
|
||||
updateTab: () => undefined,
|
||||
refresh: async () => undefined,
|
||||
clearCacheForConnection: () => undefined,
|
||||
handleSessionError: () => undefined,
|
||||
sftpSessionsRef: { current: new Map() },
|
||||
connectionCacheKeyMapRef: { current: new Map() },
|
||||
listLocalFiles: async () => [],
|
||||
listRemoteFiles: async () => [],
|
||||
acquireTransferSession: async () => {
|
||||
acquires += 1;
|
||||
return {
|
||||
sftpId: "sftp-borrowed",
|
||||
release: () => undefined,
|
||||
discard: () => undefined,
|
||||
};
|
||||
},
|
||||
});
|
||||
return null;
|
||||
}
|
||||
try {
|
||||
await act(async () => { renderer = create(React.createElement(Probe)); });
|
||||
await act(async () => {
|
||||
await ops!.startTransfer(
|
||||
[{ name: "upload.txt", isDirectory: false }],
|
||||
"left",
|
||||
"right",
|
||||
);
|
||||
});
|
||||
assert.equal(streams, 1, "channel death must not start another transfer dial");
|
||||
assert.equal(acquires, 2, "work lease plus the file lease, and no retry open");
|
||||
const task = sftpTransferCenterStore.getOwnerTasks(ownerId)[0];
|
||||
assert.equal(task?.status, "failed");
|
||||
assert.equal(task?.error, "SFTP channel closed");
|
||||
assert.equal(
|
||||
sftpTransferCenterStore.getOwnerTasks(ownerId)
|
||||
.filter((row) => ["pending", "queued", "transferring", "pausing"].includes(row.status))
|
||||
.length,
|
||||
0,
|
||||
);
|
||||
} finally {
|
||||
await act(async () => { renderer?.unmount(); });
|
||||
restore();
|
||||
}
|
||||
});
|
||||
|
||||
test("transfer failure strips the Electron IPC wrapper (#3310)", async () => {
|
||||
const restore = installGlobals({});
|
||||
let ops: ReturnType<typeof useSftpTransfers> | undefined;
|
||||
let renderer: ReactTestRenderer | undefined;
|
||||
const ownerId = "ipc-prefix-3310";
|
||||
function Probe() {
|
||||
ops = useSftpTransfers({
|
||||
ownerId,
|
||||
getActivePane: (side) => makePane(side === "left" ? "right" : "left"),
|
||||
getPaneByConnectionId: () => null,
|
||||
getTabByConnectionId: () => null,
|
||||
updateTab: () => undefined,
|
||||
refresh: async () => undefined,
|
||||
clearCacheForConnection: () => undefined,
|
||||
handleSessionError: () => undefined,
|
||||
sftpSessionsRef: { current: new Map() },
|
||||
connectionCacheKeyMapRef: { current: new Map() },
|
||||
listLocalFiles: async () => [],
|
||||
listRemoteFiles: async () => [],
|
||||
acquireTransferSession: async () => {
|
||||
throw new Error("Error invoking remote method 'netcatty:sftp:open': Error: Host key verification required for 127.0.0.1");
|
||||
},
|
||||
});
|
||||
return null;
|
||||
}
|
||||
try {
|
||||
await act(async () => { renderer = create(React.createElement(Probe)); });
|
||||
await act(async () => {
|
||||
await ops!.startTransfer(
|
||||
[{ name: "upload.txt", isDirectory: false }],
|
||||
"left",
|
||||
"right",
|
||||
);
|
||||
});
|
||||
const task = sftpTransferCenterStore.getOwnerTasks(ownerId)[0];
|
||||
assert.equal(task?.error, "Host key verification required for 127.0.0.1");
|
||||
} finally {
|
||||
await act(async () => { renderer?.unmount(); });
|
||||
restore();
|
||||
}
|
||||
});
|
||||
|
||||
type StartOptions = {
|
||||
transferId: string;
|
||||
sourcePath: string;
|
||||
|
||||
@@ -24,7 +24,7 @@ import { netcattyBridge } from "../../../infrastructure/services/netcattyBridge"
|
||||
import { logger } from "../../../lib/logger";
|
||||
import { sftpTransferCenterStore } from "../sftpTransferCenterStore";
|
||||
import { SftpPane } from "./types";
|
||||
import { isMissingStatError, unwrapSftpIpcError } from "./errors";
|
||||
import { isMissingStatError } from "./errors";
|
||||
import { useSftpDirectoryTransferOps } from "./transferDirectoryOps";
|
||||
import { useSftpTransferConflictOps } from "./transferConflictOps";
|
||||
import { useSftpTransferTaskOps } from "./transferTaskOps";
|
||||
@@ -191,21 +191,6 @@ export const useSftpTransfers = ({
|
||||
const { t } = useI18n();
|
||||
const tRef = useRef(t);
|
||||
tRef.current = t;
|
||||
// startTransfer keeps the processTransfer closure from its first render.
|
||||
// Read the latest acquire fn so a host key trusted after that render is
|
||||
// included in the transfer open, instead of the empty list from mount.
|
||||
const acquireTransferSessionRef = useRef(acquireTransferSession);
|
||||
acquireTransferSessionRef.current = acquireTransferSession;
|
||||
const acquireTransferSessionLive = useCallback<NonNullable<UseSftpTransfersParams["acquireTransferSession"]>>(
|
||||
(hostId, transferId, connectHost) => {
|
||||
const acquire = acquireTransferSessionRef.current;
|
||||
if (!acquire) {
|
||||
return Promise.reject(new Error("SFTP transfer session acquisition is unavailable"));
|
||||
}
|
||||
return acquire(hostId, transferId, connectHost);
|
||||
},
|
||||
[],
|
||||
);
|
||||
// Ref so progress flushes always see the latest visibility without rebinding.
|
||||
const surfaceVisibleRef = useRef(surfaceVisible);
|
||||
surfaceVisibleRef.current = surfaceVisible;
|
||||
@@ -483,7 +468,7 @@ export const useSftpTransfers = ({
|
||||
setTransfers,
|
||||
listLocalFiles,
|
||||
listRemoteFiles,
|
||||
acquireTransferSession: acquireTransferSession ? acquireTransferSessionLive : undefined,
|
||||
acquireTransferSession,
|
||||
});
|
||||
|
||||
const processTransfer = async (
|
||||
@@ -617,9 +602,8 @@ export const useSftpTransfers = ({
|
||||
// survive browse park and tab hide (pool holders, not panel map ids).
|
||||
if (!sourcePane.connection?.isLocal) {
|
||||
const sourceHostId = task.sourceHostId || sourcePane.connection?.hostId;
|
||||
const acquireSourceSession = acquireTransferSessionRef.current;
|
||||
if (acquireSourceSession && sourceHostId) {
|
||||
sourceWorkLease = await acquireSourceSession(sourceHostId, `${task.id}:work-source`);
|
||||
if (acquireTransferSession && sourceHostId) {
|
||||
sourceWorkLease = await acquireTransferSession(sourceHostId, `${task.id}:work-source`);
|
||||
sourceSftpId = sourceWorkLease.sftpId;
|
||||
}
|
||||
if (!sourceSftpId) {
|
||||
@@ -631,9 +615,8 @@ export const useSftpTransfers = ({
|
||||
|
||||
if (!targetPane.connection?.isLocal) {
|
||||
const targetHostId = task.targetHostId || targetPane.connection?.hostId;
|
||||
const acquireTargetSession = acquireTransferSessionRef.current;
|
||||
if (acquireTargetSession && targetHostId) {
|
||||
targetWorkLease = await acquireTargetSession(targetHostId, `${task.id}:work-target`);
|
||||
if (acquireTransferSession && targetHostId) {
|
||||
targetWorkLease = await acquireTransferSession(targetHostId, `${task.id}:work-target`);
|
||||
targetSftpId = targetWorkLease.sftpId;
|
||||
}
|
||||
if (!targetSftpId) {
|
||||
@@ -1139,7 +1122,7 @@ export const useSftpTransfers = ({
|
||||
|
||||
updateTask({
|
||||
status: "failed",
|
||||
error: unwrapSftpIpcError(err) || "Transfer failed",
|
||||
error: err instanceof Error ? err.message : "Transfer failed",
|
||||
endTime: Date.now(),
|
||||
speed: 0,
|
||||
});
|
||||
@@ -1524,8 +1507,8 @@ export const useSftpTransfers = ({
|
||||
try {
|
||||
const result = await retryExternalDragDropFileUpload(task, {
|
||||
getBrowseSftpId: (connectionId) => sftpSessionsRef.current.get(connectionId),
|
||||
acquireTransferSession: acquireTransferSessionRef.current
|
||||
? (hostId, id) => acquireTransferSessionLive(hostId, id)
|
||||
acquireTransferSession: acquireTransferSession
|
||||
? (hostId, id) => acquireTransferSession(hostId, id)
|
||||
: undefined,
|
||||
startStreamTransfer: async (options) => {
|
||||
const bridge = netcattyBridge.get();
|
||||
@@ -2017,9 +2000,8 @@ export const useSftpTransfers = ({
|
||||
let sourceWorkLease: TransferConnectionLease | null = null;
|
||||
let workingSourceSftpId = params.sftpId;
|
||||
try {
|
||||
const acquireDownloadSession = acquireTransferSessionRef.current;
|
||||
if (acquireDownloadSession && params.sourceHostId) {
|
||||
sourceWorkLease = await acquireDownloadSession(
|
||||
if (acquireTransferSession && params.sourceHostId) {
|
||||
sourceWorkLease = await acquireTransferSession(
|
||||
params.sourceHostId,
|
||||
`${task.id}:work-source`,
|
||||
);
|
||||
@@ -2085,7 +2067,7 @@ export const useSftpTransfers = ({
|
||||
const isCancelled = cancelledTasksRef.current.has(task.id);
|
||||
// Clean up cancelled task tracking to prevent memory leak
|
||||
if (isCancelled) cancelledTasksRef.current.delete(task.id);
|
||||
const errMsg = unwrapSftpIpcError(err);
|
||||
const errMsg = err instanceof Error ? err.message : String(err);
|
||||
patchDownload({
|
||||
status: isCancelled ? "cancelled" : "failed",
|
||||
error: isCancelled ? undefined : errMsg,
|
||||
|
||||
@@ -1,39 +0,0 @@
|
||||
import type { ContainerRuntimeId, ContainerSourceId } from '../../../domain/systemManager/containerRuntime';
|
||||
import { isContainerSource } from '../../../domain/systemManager/containerRuntime';
|
||||
import { STORAGE_KEY_CONTAINER_RUNTIME } from '../../../infrastructure/config/storageKeys';
|
||||
import { localStorageAdapter } from '../../../infrastructure/persistence/localStorageAdapter';
|
||||
|
||||
export interface ContainerRuntimePreference {
|
||||
source: ContainerSourceId;
|
||||
engine?: ContainerRuntimeId;
|
||||
context?: string;
|
||||
namespace?: string;
|
||||
}
|
||||
|
||||
type PreferenceStore = Record<string, ContainerRuntimePreference>;
|
||||
|
||||
const CONTAINER_ENGINES = new Set<ContainerRuntimeId>(['docker', 'podman', 'nerdctl']);
|
||||
|
||||
function readStore(): PreferenceStore {
|
||||
const value = localStorageAdapter.read<PreferenceStore>(STORAGE_KEY_CONTAINER_RUNTIME);
|
||||
return value && typeof value === 'object' ? value : {};
|
||||
}
|
||||
|
||||
export function readContainerRuntimePreference(hostId: string): ContainerRuntimePreference | null {
|
||||
if (!hostId) return null;
|
||||
const entry = readStore()[hostId];
|
||||
if (!entry || !isContainerSource(entry.source)) return null;
|
||||
return {
|
||||
source: entry.source,
|
||||
engine: entry.engine && CONTAINER_ENGINES.has(entry.engine) ? entry.engine : undefined,
|
||||
context: typeof entry.context === 'string' ? entry.context : undefined,
|
||||
namespace: typeof entry.namespace === 'string' ? entry.namespace : undefined,
|
||||
};
|
||||
}
|
||||
|
||||
export function writeContainerRuntimePreference(hostId: string, entry: ContainerRuntimePreference): void {
|
||||
if (!hostId || !isContainerSource(entry.source)) return;
|
||||
const store = readStore();
|
||||
store[hostId] = entry;
|
||||
localStorageAdapter.write(STORAGE_KEY_CONTAINER_RUNTIME, store);
|
||||
}
|
||||
@@ -1,225 +0,0 @@
|
||||
import { useCallback, useEffect, useRef, useState } from 'react';
|
||||
import type { useSystemManagerBackend } from '../useSystemManagerBackend';
|
||||
import { useStableTranslate } from '../useSystemManager';
|
||||
import type {
|
||||
ContainerRuntimeId,
|
||||
ContainerSourceId,
|
||||
KubeContextInfo,
|
||||
} from '../../../domain/systemManager/containerRuntime';
|
||||
import {
|
||||
describeRuntimeFailure,
|
||||
firstContainerRuntime,
|
||||
isAutomaticRuntimeFallback,
|
||||
isKubectlForbiddenError,
|
||||
reconcileContainerSource,
|
||||
sourcesFromCapabilities,
|
||||
} from '../../../domain/systemManager/containerRuntime';
|
||||
import type { SessionCapabilities } from '../../../domain/systemManager/types';
|
||||
import {
|
||||
readContainerRuntimePreference,
|
||||
writeContainerRuntimePreference,
|
||||
} from './containerRuntimePreference';
|
||||
|
||||
type Backend = ReturnType<typeof useSystemManagerBackend>;
|
||||
|
||||
interface UseContainerRuntimeSelectionOptions {
|
||||
sessionId: string;
|
||||
hostKey: string;
|
||||
capabilities?: SessionCapabilities;
|
||||
backend: Backend;
|
||||
}
|
||||
|
||||
export function useContainerRuntimeSelection({
|
||||
sessionId,
|
||||
hostKey,
|
||||
capabilities,
|
||||
backend,
|
||||
}: UseContainerRuntimeSelectionOptions) {
|
||||
const stableT = useStableTranslate();
|
||||
const detected = sourcesFromCapabilities(capabilities);
|
||||
const detectedKey = detected.join('|');
|
||||
const [source, setSource] = useState<ContainerSourceId | null>(() => {
|
||||
const remembered = readContainerRuntimePreference(hostKey);
|
||||
return reconcileContainerSource(detected, null, remembered);
|
||||
});
|
||||
const [contextName, setContextName] = useState<string | null>(() => (
|
||||
readContainerRuntimePreference(hostKey)?.context ?? null
|
||||
));
|
||||
const [namespaceName, setNamespaceName] = useState<string | null>(() => (
|
||||
readContainerRuntimePreference(hostKey)?.namespace ?? null
|
||||
));
|
||||
const [contexts, setContexts] = useState<KubeContextInfo[]>([]);
|
||||
const [namespaces, setNamespaces] = useState<string[]>([]);
|
||||
const [scopeError, setScopeError] = useState<string | null>(null);
|
||||
const [scopeNonce, setScopeNonce] = useState(0);
|
||||
const skipPersist = useRef(true);
|
||||
const namespaceNameRef = useRef(namespaceName);
|
||||
namespaceNameRef.current = namespaceName;
|
||||
|
||||
useEffect(() => {
|
||||
skipPersist.current = true;
|
||||
const remembered = readContainerRuntimePreference(hostKey);
|
||||
setSource(reconcileContainerSource(detected, null, remembered));
|
||||
setContextName(remembered?.context ?? null);
|
||||
setNamespaceName(remembered?.namespace ?? null);
|
||||
setContexts([]);
|
||||
setNamespaces([]);
|
||||
setScopeError(null);
|
||||
// Detection is reconciled by the next effect so a host switch does not
|
||||
// keep the previous host's in-memory selection.
|
||||
// eslint-disable-next-line react-hooks/exhaustive-deps
|
||||
}, [hostKey]);
|
||||
|
||||
useEffect(() => {
|
||||
const available = sourcesFromCapabilities(capabilities);
|
||||
setSource((current) => reconcileContainerSource(
|
||||
available,
|
||||
current,
|
||||
readContainerRuntimePreference(hostKey),
|
||||
));
|
||||
}, [capabilities, detectedKey, hostKey]);
|
||||
|
||||
useEffect(() => {
|
||||
if (skipPersist.current) {
|
||||
skipPersist.current = false;
|
||||
return;
|
||||
}
|
||||
if (!source) return;
|
||||
const remembered = readContainerRuntimePreference(hostKey);
|
||||
const available = sourcesFromCapabilities(capabilities);
|
||||
if (isAutomaticRuntimeFallback(remembered, source, available)) return;
|
||||
const engine = source === 'kubernetes'
|
||||
? (remembered?.engine ?? firstContainerRuntime(available) ?? undefined)
|
||||
: source;
|
||||
writeContainerRuntimePreference(hostKey, {
|
||||
source,
|
||||
engine,
|
||||
context: contextName || undefined,
|
||||
namespace: namespaceName || undefined,
|
||||
});
|
||||
}, [capabilities, contextName, hostKey, namespaceName, source]);
|
||||
|
||||
useEffect(() => {
|
||||
if (source !== 'kubernetes') return undefined;
|
||||
let cancelled = false;
|
||||
void backend.kubectl({ sessionId, op: 'contexts' }).then((result) => {
|
||||
if (cancelled) return;
|
||||
if (!result.success || !result.contexts) {
|
||||
setContexts([]);
|
||||
setScopeError(describeRuntimeFailure(
|
||||
result,
|
||||
stableT,
|
||||
stableT('systemManager.errors.loadKubernetes'),
|
||||
contextName,
|
||||
));
|
||||
return;
|
||||
}
|
||||
setScopeError(null);
|
||||
const available = result.contexts;
|
||||
const kept = contextName && available.some((item) => item.name === contextName)
|
||||
? contextName
|
||||
: null;
|
||||
const next = kept
|
||||
?? available.find((item) => item.current)?.name
|
||||
?? available[0]?.name
|
||||
?? null;
|
||||
setContexts(available);
|
||||
setContextName(next);
|
||||
if (!kept) {
|
||||
const configured = available.find((item) => item.name === next)?.namespace || 'default';
|
||||
setNamespaceName(next ? configured : null);
|
||||
setNamespaces([]);
|
||||
}
|
||||
});
|
||||
return () => {
|
||||
cancelled = true;
|
||||
};
|
||||
}, [backend, contextName, sessionId, source, scopeNonce, stableT]);
|
||||
|
||||
useEffect(() => {
|
||||
if (source !== 'kubernetes' || !contextName) return undefined;
|
||||
let cancelled = false;
|
||||
void backend.kubectl({
|
||||
sessionId,
|
||||
op: 'namespaces',
|
||||
context: contextName,
|
||||
}).then((result) => {
|
||||
if (cancelled) return;
|
||||
if (!result.success || !result.namespaces) {
|
||||
const message = result.error || '';
|
||||
if (isKubectlForbiddenError(message)) {
|
||||
const configured = contexts.find((item) => item.name === contextName)?.namespace || 'default';
|
||||
const fallback = namespaceNameRef.current || configured;
|
||||
setScopeError(null);
|
||||
setNamespaceName((current) => current || fallback);
|
||||
setNamespaces([fallback]);
|
||||
return;
|
||||
}
|
||||
setScopeError(describeRuntimeFailure(
|
||||
result,
|
||||
stableT,
|
||||
stableT('systemManager.errors.loadKubernetes'),
|
||||
contextName,
|
||||
));
|
||||
return;
|
||||
}
|
||||
setScopeError(null);
|
||||
setNamespaces(result.namespaces);
|
||||
setNamespaceName((current) => {
|
||||
if (current && result.namespaces?.includes(current)) return current;
|
||||
const configured = contexts.find((item) => item.name === contextName)?.namespace;
|
||||
if (configured && result.namespaces?.includes(configured)) return configured;
|
||||
if (result.namespaces?.includes('default')) return 'default';
|
||||
return result.namespaces?.[0] ?? null;
|
||||
});
|
||||
});
|
||||
return () => {
|
||||
cancelled = true;
|
||||
};
|
||||
}, [backend, contextName, contexts, sessionId, source, scopeNonce, stableT]);
|
||||
|
||||
const selectSource = useCallback((value: string) => {
|
||||
if (value !== 'docker' && value !== 'podman' && value !== 'nerdctl' && value !== 'kubernetes') return;
|
||||
setSource(value);
|
||||
const remembered = readContainerRuntimePreference(hostKey);
|
||||
const available = sourcesFromCapabilities(capabilities);
|
||||
const engine = value === 'kubernetes'
|
||||
? (remembered?.engine ?? firstContainerRuntime(available) ?? undefined)
|
||||
: value;
|
||||
writeContainerRuntimePreference(hostKey, {
|
||||
source: value,
|
||||
engine,
|
||||
context: contextName || undefined,
|
||||
namespace: namespaceName || undefined,
|
||||
});
|
||||
}, [capabilities, contextName, hostKey, namespaceName]);
|
||||
|
||||
const selectContext = useCallback((value: string) => {
|
||||
setContextName(value);
|
||||
const configured = contexts.find((item) => item.name === value)?.namespace ?? null;
|
||||
setNamespaceName(configured);
|
||||
}, [contexts]);
|
||||
|
||||
const reloadScopes = useCallback(() => {
|
||||
setScopeNonce((value) => value + 1);
|
||||
}, []);
|
||||
|
||||
const runtime: ContainerRuntimeId = source && source !== 'kubernetes' ? source : 'docker';
|
||||
const showRuntimeSwitcher = detected.length > 1;
|
||||
|
||||
return {
|
||||
source,
|
||||
selectSource,
|
||||
contextName,
|
||||
selectContext,
|
||||
namespaceName,
|
||||
setNamespaceName,
|
||||
contexts,
|
||||
namespaces,
|
||||
scopeError,
|
||||
reloadScopes,
|
||||
runtime,
|
||||
detected,
|
||||
showRuntimeSwitcher,
|
||||
};
|
||||
}
|
||||
@@ -1,7 +1,7 @@
|
||||
import assert from 'node:assert/strict';
|
||||
import { describe, it } from 'node:test';
|
||||
import { createSessionIdleManager } from '../../electron/bridges/mcpServerBridge/sessionIdleManager.cjs';
|
||||
import { STORAGE_KEY_AI_SESSION_IDLE_TIMEOUT_MINUTES, STORAGE_KEY_AI_PRIVACY, STORAGE_KEY_AI_EXTERNAL_MCP_REDACTION, STORAGE_KEY_AI_EXTERNAL_MCP_ENABLED, STORAGE_KEY_AI_EXTERNAL_MCP_MODE } from '../../infrastructure/config/storageKeys';
|
||||
import { STORAGE_KEY_AI_SESSION_IDLE_TIMEOUT_MINUTES } from '../../infrastructure/config/storageKeys';
|
||||
import { localStorageAdapter } from '../../infrastructure/persistence/localStorageAdapter';
|
||||
import {
|
||||
readExternalMcpHttpAllowCustomBind,
|
||||
@@ -16,10 +16,6 @@ import {
|
||||
} from "./useExternalMcpToggleState.ts";
|
||||
import {
|
||||
EXTERNAL_MCP_RUNTIME_STATUS_POLL_MS,
|
||||
applyAIPrivacySettings,
|
||||
readAIPrivacySettings,
|
||||
applyExternalMcpRedactionSettings,
|
||||
readExternalMcpRedactionSettings,
|
||||
bumpExternalMcpEnableGenerationForTests,
|
||||
createExternalMcpStartupSyncPlan,
|
||||
getExternalMcpEnableGenerationForTests,
|
||||
@@ -120,12 +116,11 @@ describe('useExternalMcpToggleState helpers', () => {
|
||||
externalMcpSetConfig: (config: { sessionIdleTimeoutMinutes: number }) => {
|
||||
received.push(config.sessionIdleTimeoutMinutes);
|
||||
manager.setTimeoutMinutes(config.sessionIdleTimeoutMinutes);
|
||||
return { ok: true };
|
||||
},
|
||||
};
|
||||
await syncExternalMcpStartupState(bridge);
|
||||
assert.equal(manager.getTimeoutMinutes(), expected, 'startup keeps the intended timeout');
|
||||
await syncExternalMcpConfig(bridge);
|
||||
syncExternalMcpConfig(bridge);
|
||||
assert.deepEqual(received, [expected, expected]);
|
||||
assert.equal(manager.getTimeoutMinutes(), expected, 'config sync keeps the intended timeout');
|
||||
assert.equal(
|
||||
@@ -457,334 +452,3 @@ describe("external MCP HTTP settings", () => {
|
||||
}
|
||||
});
|
||||
});
|
||||
|
||||
|
||||
describe('Shared AI privacy settings', () => {
|
||||
const settings = {
|
||||
enabled: true,
|
||||
redactPrivateIps: true,
|
||||
cidrs: ['192.0.2.0/24'],
|
||||
patterns: ['INTERNAL_[A-Z0-9]+'],
|
||||
};
|
||||
|
||||
it('defaults off and persists only after successful bridge validation', async () => {
|
||||
const restore = installMemoryLocalStorage();
|
||||
try {
|
||||
assert.deepEqual(readExternalMcpRedactionSettings(), {
|
||||
enabled: false, redactPrivateIps: false, cidrs: [], patterns: [],
|
||||
});
|
||||
let resolveConfig: (value: unknown) => void;
|
||||
const apply = applyExternalMcpRedactionSettings(settings, {
|
||||
externalMcpSetConfig: async (config) => {
|
||||
assert.deepEqual(config, { redaction: settings });
|
||||
return new Promise((resolve) => { resolveConfig = resolve; });
|
||||
},
|
||||
});
|
||||
assert.equal(localStorageAdapter.readString(STORAGE_KEY_AI_PRIVACY), null);
|
||||
resolveConfig!({ ok: true });
|
||||
await apply;
|
||||
assert.deepEqual(readExternalMcpRedactionSettings(), settings);
|
||||
assert.deepEqual(localStorageAdapter.read(STORAGE_KEY_AI_PRIVACY), settings);
|
||||
assert.equal(localStorageAdapter.readString(STORAGE_KEY_AI_EXTERNAL_MCP_REDACTION), null);
|
||||
} finally {
|
||||
restore();
|
||||
}
|
||||
});
|
||||
|
||||
it('reads legacy privacy preferences until a validated shared preference is applied', async () => {
|
||||
const restore = installMemoryLocalStorage();
|
||||
try {
|
||||
localStorageAdapter.write(STORAGE_KEY_AI_EXTERNAL_MCP_REDACTION, settings);
|
||||
assert.deepEqual(readAIPrivacySettings(), settings);
|
||||
assert.equal(localStorageAdapter.readString(STORAGE_KEY_AI_PRIVACY), null, 'reads must not migrate or discard data');
|
||||
const changed = { ...settings, redactPrivateIps: false };
|
||||
await applyAIPrivacySettings(changed, { externalMcpSetConfig: async () => ({ ok: true }) });
|
||||
assert.deepEqual(readAIPrivacySettings(), changed);
|
||||
assert.deepEqual(readExternalMcpRedactionSettings(), changed, 'legacy API must read the same shared policy');
|
||||
assert.deepEqual(localStorageAdapter.read(STORAGE_KEY_AI_PRIVACY), changed);
|
||||
assert.deepEqual(localStorageAdapter.read(STORAGE_KEY_AI_EXTERNAL_MCP_REDACTION), settings, 'keep legacy storage intact');
|
||||
} finally {
|
||||
restore();
|
||||
}
|
||||
});
|
||||
|
||||
it('never falls back to legacy settings when a shared preference exists, even if corrupt', () => {
|
||||
const restore = installMemoryLocalStorage();
|
||||
try {
|
||||
localStorageAdapter.write(STORAGE_KEY_AI_EXTERNAL_MCP_REDACTION, settings);
|
||||
const disabled = { enabled: false, redactPrivateIps: false, cidrs: [], patterns: [] };
|
||||
localStorageAdapter.write(STORAGE_KEY_AI_PRIVACY, disabled);
|
||||
assert.deepEqual(readAIPrivacySettings(), disabled);
|
||||
localStorageAdapter.writeString(STORAGE_KEY_AI_PRIVACY, '{');
|
||||
assert.throws(readAIPrivacySettings, /Invalid saved AI privacy settings/);
|
||||
localStorageAdapter.remove(STORAGE_KEY_AI_PRIVACY);
|
||||
localStorageAdapter.writeString(STORAGE_KEY_AI_EXTERNAL_MCP_REDACTION, '{');
|
||||
assert.throws(readAIPrivacySettings, /Invalid saved AI privacy settings/);
|
||||
} finally {
|
||||
restore();
|
||||
}
|
||||
});
|
||||
|
||||
it('restores the shared policy even when External MCP is disabled', async () => {
|
||||
const restore = installMemoryLocalStorage();
|
||||
try {
|
||||
resetExternalMcpStartupReadyForTests();
|
||||
localStorageAdapter.writeBoolean(STORAGE_KEY_AI_EXTERNAL_MCP_ENABLED, false);
|
||||
localStorageAdapter.write(STORAGE_KEY_AI_PRIVACY, settings);
|
||||
const calls: string[] = [];
|
||||
const plan = await syncExternalMcpStartupState({
|
||||
externalMcpSetConfig: async (config) => {
|
||||
assert.deepEqual(config.redaction, settings);
|
||||
calls.push('shared-policy');
|
||||
return { ok: true };
|
||||
},
|
||||
externalMcpSetEnabled: async (enabled) => { calls.push(`external:${enabled}`); },
|
||||
});
|
||||
assert.deepEqual(calls, ['shared-policy', 'external:false']);
|
||||
assert.equal(plan.runtimeEnabled, false);
|
||||
assert.deepEqual(readAIPrivacySettings(), settings);
|
||||
} finally {
|
||||
restore();
|
||||
}
|
||||
});
|
||||
|
||||
it('does not persist rejected, unverified or failed privacy changes', async () => {
|
||||
const restore = installMemoryLocalStorage();
|
||||
try {
|
||||
localStorageAdapter.write(STORAGE_KEY_AI_PRIVACY, settings);
|
||||
const changed = { ...settings, patterns: ['['] };
|
||||
for (const result of [{ ok: false, error: 'Invalid regular expression' }, { ok: false, error: '' }, undefined, {}]) {
|
||||
await assert.rejects(applyExternalMcpRedactionSettings(changed, {
|
||||
externalMcpSetConfig: async () => result,
|
||||
}));
|
||||
assert.deepEqual(readExternalMcpRedactionSettings(), settings);
|
||||
}
|
||||
await assert.rejects(applyExternalMcpRedactionSettings(changed, {
|
||||
externalMcpSetConfig: async () => { throw new Error('Disconnected'); },
|
||||
}), /Disconnected/);
|
||||
assert.deepEqual(readExternalMcpRedactionSettings(), settings);
|
||||
} finally {
|
||||
restore();
|
||||
}
|
||||
});
|
||||
|
||||
it('syncs saved privacy settings before persistent startup enable', async () => {
|
||||
const restore = installMemoryLocalStorage();
|
||||
try {
|
||||
resetExternalMcpStartupReadyForTests();
|
||||
localStorageAdapter.writeBoolean(STORAGE_KEY_AI_EXTERNAL_MCP_ENABLED, true);
|
||||
localStorageAdapter.writeString(STORAGE_KEY_AI_EXTERNAL_MCP_MODE, 'persistent');
|
||||
localStorageAdapter.write(STORAGE_KEY_AI_PRIVACY, settings);
|
||||
const calls: string[] = [];
|
||||
await syncExternalMcpStartupState({
|
||||
externalMcpSetConfig: async (config) => {
|
||||
assert.deepEqual(config.redaction, settings);
|
||||
calls.push('config');
|
||||
return { ok: true };
|
||||
},
|
||||
externalMcpSetEnabled: async (enabled) => { calls.push(`enabled:${enabled}`); },
|
||||
});
|
||||
assert.deepEqual(calls, ['config', 'enabled:true']);
|
||||
} finally {
|
||||
restore();
|
||||
}
|
||||
});
|
||||
|
||||
it('fails closed at startup when saved privacy settings are malformed', async () => {
|
||||
const restore = installMemoryLocalStorage();
|
||||
try {
|
||||
for (const saved of ['{', 'null', '{}', '{"enabled":"true"}', '{"enabled":false,"redactPrivateIps":false,"cidrs":[],"patterns":[2]}', '{"enabled":false,"redactPrivateIps":false,"cidrs":[],"patterns":[],"unexpected":true}']) {
|
||||
resetExternalMcpStartupReadyForTests();
|
||||
localStorageAdapter.writeBoolean(STORAGE_KEY_AI_EXTERNAL_MCP_ENABLED, true);
|
||||
localStorageAdapter.writeString(STORAGE_KEY_AI_EXTERNAL_MCP_MODE, 'persistent');
|
||||
localStorageAdapter.writeString(STORAGE_KEY_AI_PRIVACY, saved);
|
||||
const enabledCalls: boolean[] = [];
|
||||
let configCalls = 0;
|
||||
const bridge = {
|
||||
externalMcpSetConfig: async (config: unknown) => {
|
||||
assert.deepEqual(config, { redaction: null });
|
||||
configCalls += 1;
|
||||
return { ok: false, error: 'Shared AI privacy configuration is unavailable.' };
|
||||
},
|
||||
externalMcpSetEnabled: async (enabled: boolean) => { enabledCalls.push(enabled); },
|
||||
};
|
||||
const plan = await syncExternalMcpStartupState(bridge);
|
||||
assert.equal(plan.runtimeEnabled, false);
|
||||
assert.match(plan.configError!, /Invalid saved/);
|
||||
assert.equal(configCalls, 1, 'invalidate shared privacy instead of applying unprotected defaults');
|
||||
assert.deepEqual(enabledCalls, [false]);
|
||||
assert.equal(readExternalMcpStoredEnabled(), false);
|
||||
await assert.rejects(syncExternalMcpConfig(bridge), /Invalid saved/);
|
||||
assert.equal(configCalls, 2);
|
||||
}
|
||||
} finally {
|
||||
restore();
|
||||
}
|
||||
});
|
||||
|
||||
it('fails closed when the backend rejects or cannot verify startup config', async () => {
|
||||
const restore = installMemoryLocalStorage();
|
||||
try {
|
||||
for (const result of [{ ok: false, error: 'Invalid CIDR' }, { ok: false, error: '' }, undefined, {}]) {
|
||||
resetExternalMcpStartupReadyForTests();
|
||||
localStorageAdapter.writeBoolean(STORAGE_KEY_AI_EXTERNAL_MCP_ENABLED, true);
|
||||
localStorageAdapter.writeString(STORAGE_KEY_AI_EXTERNAL_MCP_MODE, 'persistent');
|
||||
localStorageAdapter.write(STORAGE_KEY_AI_PRIVACY, settings);
|
||||
const enabledCalls: boolean[] = [];
|
||||
const plan = await syncExternalMcpStartupState({
|
||||
externalMcpSetConfig: async () => result,
|
||||
externalMcpSetEnabled: async (enabled) => { enabledCalls.push(enabled); },
|
||||
});
|
||||
assert.equal(plan.runtimeEnabled, false);
|
||||
assert.ok(plan.configError);
|
||||
assert.deepEqual(enabledCalls, [false]);
|
||||
assert.deepEqual(readExternalMcpRedactionSettings(), settings);
|
||||
}
|
||||
} finally {
|
||||
restore();
|
||||
}
|
||||
});
|
||||
|
||||
it('preserves the corrupt-settings error if shared policy invalidation cannot reach the bridge', async () => {
|
||||
const restore = installMemoryLocalStorage();
|
||||
try {
|
||||
resetExternalMcpStartupReadyForTests();
|
||||
localStorageAdapter.writeString(STORAGE_KEY_AI_PRIVACY, '{');
|
||||
const calls: boolean[] = [];
|
||||
const bridge = {
|
||||
externalMcpSetConfig: async (config: unknown) => {
|
||||
assert.deepEqual(config, { redaction: null });
|
||||
throw new Error('Disconnected');
|
||||
},
|
||||
externalMcpSetEnabled: async (enabled: boolean) => { calls.push(enabled); },
|
||||
};
|
||||
const plan = await syncExternalMcpStartupState(bridge);
|
||||
assert.match(plan.configError!, /Invalid saved AI privacy settings/);
|
||||
assert.deepEqual(calls, [false]);
|
||||
await assert.rejects(syncExternalMcpConfig(bridge), /Invalid saved AI privacy settings/);
|
||||
assert.equal(localStorageAdapter.readString(STORAGE_KEY_AI_PRIVACY), '{');
|
||||
} finally {
|
||||
restore();
|
||||
}
|
||||
});
|
||||
|
||||
it('does not enable from a stale privacy snapshot changed during config sync', async () => {
|
||||
const restore = installMemoryLocalStorage();
|
||||
try {
|
||||
resetExternalMcpStartupReadyForTests();
|
||||
localStorageAdapter.writeBoolean(STORAGE_KEY_AI_EXTERNAL_MCP_ENABLED, true);
|
||||
localStorageAdapter.writeString(STORAGE_KEY_AI_EXTERNAL_MCP_MODE, 'persistent');
|
||||
const calls: boolean[] = [];
|
||||
const plan = await syncExternalMcpStartupState({
|
||||
externalMcpSetConfig: async () => {
|
||||
localStorageAdapter.write(STORAGE_KEY_AI_PRIVACY, settings);
|
||||
return { ok: true };
|
||||
},
|
||||
externalMcpSetEnabled: async (enabled) => { calls.push(enabled); },
|
||||
});
|
||||
assert.deepEqual(calls, [false]);
|
||||
assert.match(plan.configError!, /changed during startup/);
|
||||
await assert.rejects(syncExternalMcpConfig({
|
||||
externalMcpSetConfig: async () => {
|
||||
localStorageAdapter.write(STORAGE_KEY_AI_PRIVACY, { ...settings, cidrs: [] });
|
||||
return { ok: true };
|
||||
},
|
||||
}), /settings changed/);
|
||||
} finally {
|
||||
restore();
|
||||
}
|
||||
});
|
||||
|
||||
it('fails closed on a startup config transport failure', async () => {
|
||||
const restore = installMemoryLocalStorage();
|
||||
try {
|
||||
resetExternalMcpStartupReadyForTests();
|
||||
localStorageAdapter.writeBoolean(STORAGE_KEY_AI_EXTERNAL_MCP_ENABLED, true);
|
||||
localStorageAdapter.writeString(STORAGE_KEY_AI_EXTERNAL_MCP_MODE, 'persistent');
|
||||
const calls: boolean[] = [];
|
||||
const plan = await syncExternalMcpStartupState({
|
||||
externalMcpSetConfig: async () => { throw new Error('Disconnected'); },
|
||||
externalMcpSetEnabled: async (enabled) => { calls.push(enabled); },
|
||||
});
|
||||
assert.deepEqual(calls, [false]);
|
||||
assert.equal(plan.configError, 'Disconnected');
|
||||
} finally {
|
||||
restore();
|
||||
}
|
||||
});
|
||||
});
|
||||
|
||||
describe('External MCP manual enable privacy gate', () => {
|
||||
it('waits for validated config and keeps rejected or superseded enables off', async () => {
|
||||
const React = await import('react');
|
||||
const { act, create } = await import('react-test-renderer');
|
||||
const { useExternalMcpToggleState } = await import('./useExternalMcpToggleState.ts');
|
||||
const restoreStorage = installMemoryLocalStorage();
|
||||
const previousWindow = Object.getOwnPropertyDescriptor(globalThis, 'window');
|
||||
const previousAct = Object.getOwnPropertyDescriptor(globalThis, 'IS_REACT_ACT_ENVIRONMENT');
|
||||
const events = new EventTarget();
|
||||
let resolveConfig!: (value: unknown) => void;
|
||||
const calls: string[] = [];
|
||||
const bridge = {
|
||||
externalMcpSetConfig: async () => {
|
||||
calls.push('config');
|
||||
return new Promise((resolve) => { resolveConfig = resolve; });
|
||||
},
|
||||
externalMcpSetEnabled: async (enabled: boolean) => {
|
||||
calls.push(`enabled:${enabled}`);
|
||||
return { ok: true, enabled };
|
||||
},
|
||||
};
|
||||
Object.defineProperty(globalThis, 'IS_REACT_ACT_ENVIRONMENT', { value: true, configurable: true });
|
||||
Object.defineProperty(globalThis, 'window', {
|
||||
value: {
|
||||
netcatty: bridge,
|
||||
location: { hash: '#/settings' },
|
||||
addEventListener: events.addEventListener.bind(events),
|
||||
removeEventListener: events.removeEventListener.bind(events),
|
||||
dispatchEvent: events.dispatchEvent.bind(events),
|
||||
setInterval,
|
||||
clearInterval,
|
||||
},
|
||||
configurable: true,
|
||||
});
|
||||
let hook!: ReturnType<typeof useExternalMcpToggleState>;
|
||||
const Capture = () => { hook = useExternalMcpToggleState(); return null; };
|
||||
let renderer: ReturnType<typeof create> | undefined;
|
||||
try {
|
||||
resetExternalMcpStartupReadyForTests();
|
||||
await act(async () => { renderer = create(React.createElement(Capture)); });
|
||||
let pending!: Promise<void>;
|
||||
await act(async () => { pending = hook.setEnabled(true); });
|
||||
assert.deepEqual(calls, ['config']);
|
||||
assert.equal(hook.enabled, false);
|
||||
assert.equal(readExternalMcpStoredEnabled(), false);
|
||||
await act(async () => { resolveConfig({ ok: false, error: 'Invalid privacy rules' }); await pending; });
|
||||
assert.deepEqual(calls, ['config', 'enabled:false']);
|
||||
assert.equal(hook.enabled, false);
|
||||
assert.match(hook.error!, /Invalid privacy rules/);
|
||||
|
||||
calls.length = 0;
|
||||
await act(async () => { pending = hook.setEnabled(true); });
|
||||
await act(async () => { await hook.setEnabled(false); });
|
||||
await act(async () => { resolveConfig({ ok: true }); await pending; });
|
||||
assert.deepEqual(calls, ['config', 'enabled:false']);
|
||||
assert.equal(hook.enabled, false, 'a newer disable wins over a pending enable');
|
||||
|
||||
calls.length = 0;
|
||||
await act(async () => { pending = hook.setEnabled(true); });
|
||||
await act(async () => { resolveConfig({ ok: true }); await pending; });
|
||||
assert.deepEqual(calls, ['config', 'enabled:true']);
|
||||
assert.equal(hook.enabled, true);
|
||||
assert.equal(readExternalMcpStoredEnabled(), true);
|
||||
assert.equal(hook.error, null);
|
||||
} finally {
|
||||
if (renderer) await act(async () => renderer!.unmount());
|
||||
if (previousWindow) Object.defineProperty(globalThis, 'window', previousWindow);
|
||||
else Reflect.deleteProperty(globalThis, 'window');
|
||||
if (previousAct) Object.defineProperty(globalThis, 'IS_REACT_ACT_ENVIRONMENT', previousAct);
|
||||
else Reflect.deleteProperty(globalThis, 'IS_REACT_ACT_ENVIRONMENT');
|
||||
restoreStorage();
|
||||
}
|
||||
});
|
||||
});
|
||||
@@ -10,10 +10,8 @@ import {
|
||||
STORAGE_KEY_AI_EXTERNAL_MCP_HTTP_PORT,
|
||||
STORAGE_KEY_AI_EXTERNAL_MCP_IDLE_TIMEOUT_MINUTES,
|
||||
STORAGE_KEY_AI_EXTERNAL_MCP_MODE,
|
||||
STORAGE_KEY_AI_EXTERNAL_MCP_REDACTION,
|
||||
STORAGE_KEY_AI_EXTERNAL_MCP_SILENT_SESSIONS,
|
||||
STORAGE_KEY_AI_PERMISSION_MODE,
|
||||
STORAGE_KEY_AI_PRIVACY,
|
||||
STORAGE_KEY_AI_SESSION_IDLE_TIMEOUT_MINUTES,
|
||||
} from '../../infrastructure/config/storageKeys';
|
||||
import type { AIPermissionMode } from '../../infrastructure/ai/types';
|
||||
@@ -41,7 +39,6 @@ let externalMcpStartupReadyWaitPromise: Promise<void> | null = null;
|
||||
let externalMcpStartupReadyWaitResolve: (() => void) | null = null;
|
||||
/** Bumps on every intentional enable/disable so stale startup applies lose. */
|
||||
let externalMcpEnableGeneration = 0;
|
||||
let externalMcpConfigError: string | null = null;
|
||||
|
||||
function getWindowHash(hash?: string): string {
|
||||
if (typeof hash === 'string') return hash;
|
||||
@@ -79,7 +76,6 @@ export function resetExternalMcpStartupReadyForTests(): void {
|
||||
externalMcpStartupReadyWaitPromise = null;
|
||||
externalMcpStartupReadyWaitResolve = null;
|
||||
externalMcpEnableGeneration = 0;
|
||||
externalMcpConfigError = null;
|
||||
externalMcpStartupSyncPromise = null;
|
||||
}
|
||||
|
||||
@@ -114,138 +110,6 @@ export function waitForExternalMcpStartupReady(hash?: string): Promise<void> {
|
||||
return externalMcpStartupReadyWaitPromise;
|
||||
}
|
||||
|
||||
export type AIPrivacySettings = {
|
||||
enabled: boolean;
|
||||
redactPrivateIps: boolean;
|
||||
cidrs: string[];
|
||||
patterns: string[];
|
||||
};
|
||||
|
||||
export function defaultAIPrivacySettings(): AIPrivacySettings {
|
||||
return { enabled: false, redactPrivateIps: false, cidrs: [], patterns: [] };
|
||||
}
|
||||
|
||||
/** Missing settings are opt-out; corrupt settings must never silently opt out. */
|
||||
export function readAIPrivacySettings(): AIPrivacySettings {
|
||||
// The shared key wins, including invalid data: never fall back to a weaker legacy policy.
|
||||
// Leave the legacy value intact for older installs; Apply writes only the validated shared key.
|
||||
const stored = localStorageAdapter.readString(STORAGE_KEY_AI_PRIVACY)
|
||||
?? localStorageAdapter.readString(STORAGE_KEY_AI_EXTERNAL_MCP_REDACTION);
|
||||
if (stored === null) return defaultAIPrivacySettings();
|
||||
let value: unknown;
|
||||
try {
|
||||
value = JSON.parse(stored);
|
||||
} catch {
|
||||
throw new Error('Invalid saved AI privacy settings. Review and apply the settings again.');
|
||||
}
|
||||
if (!value || typeof value !== 'object' || Array.isArray(value)) {
|
||||
throw new Error('Invalid saved AI privacy settings. Review and apply the settings again.');
|
||||
}
|
||||
const settings = value as Record<string, unknown>;
|
||||
if (
|
||||
Object.keys(settings).some((key) => !['enabled', 'redactPrivateIps', 'cidrs', 'patterns'].includes(key))
|
||||
|| typeof settings.enabled !== 'boolean'
|
||||
|| typeof settings.redactPrivateIps !== 'boolean'
|
||||
|| !Array.isArray(settings.cidrs) || !settings.cidrs.every((item) => typeof item === 'string')
|
||||
|| !Array.isArray(settings.patterns) || !settings.patterns.every((item) => typeof item === 'string')
|
||||
) {
|
||||
throw new Error('Invalid saved AI privacy settings. Review and apply the settings again.');
|
||||
}
|
||||
return {
|
||||
enabled: settings.enabled,
|
||||
redactPrivateIps: settings.redactPrivateIps,
|
||||
cidrs: [...settings.cidrs],
|
||||
patterns: [...settings.patterns],
|
||||
};
|
||||
}
|
||||
|
||||
function configResponseError(
|
||||
result: unknown,
|
||||
unverifiedMessage = 'External MCP settings could not be verified. The bridge has not been enabled.',
|
||||
): string | null {
|
||||
if (result && typeof result === 'object' && 'ok' in result && result.ok === true) return null;
|
||||
if (result && typeof result === 'object' && 'error' in result && typeof result.error === 'string' && result.error) {
|
||||
return result.error;
|
||||
}
|
||||
return unverifiedMessage;
|
||||
}
|
||||
|
||||
/** Validate in the main process before committing any privacy preference. */
|
||||
export async function applyAIPrivacySettings(
|
||||
settings: AIPrivacySettings,
|
||||
bridge: ExternalMcpBridge | undefined = netcattyBridge.get(),
|
||||
): Promise<void> {
|
||||
const result = await bridge?.externalMcpSetConfig?.({ redaction: settings });
|
||||
const error = configResponseError(result, 'AI privacy settings could not be verified. Try applying them again.');
|
||||
if (error) throw new Error(error);
|
||||
if (!localStorageAdapter.write(STORAGE_KEY_AI_PRIVACY, settings)) {
|
||||
throw new Error('AI privacy settings could not be saved. Apply them again before restarting.');
|
||||
}
|
||||
externalMcpConfigError = null;
|
||||
emitAIStateChanged(STORAGE_KEY_AI_PRIVACY);
|
||||
}
|
||||
|
||||
function readAIPrivacyState() {
|
||||
try {
|
||||
return { settings: readAIPrivacySettings(), error: null as string | null };
|
||||
} catch (error) {
|
||||
return {
|
||||
settings: defaultAIPrivacySettings(),
|
||||
error: error instanceof Error ? error.message : String(error),
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
export function useAIPrivacyState() {
|
||||
const [state, setState] = useState(readAIPrivacyState);
|
||||
const [applying, setApplying] = useState(false);
|
||||
const apply = useCallback(async (settings: AIPrivacySettings): Promise<boolean> => {
|
||||
setApplying(true);
|
||||
try {
|
||||
await applyAIPrivacySettings(settings);
|
||||
setState({ settings, error: null });
|
||||
return true;
|
||||
} catch (error) {
|
||||
setState((current) => ({
|
||||
...current,
|
||||
error: error instanceof Error ? error.message : String(error),
|
||||
}));
|
||||
return false;
|
||||
} finally {
|
||||
setApplying(false);
|
||||
}
|
||||
}, []);
|
||||
|
||||
useEffect(() => {
|
||||
const onStorage = (event: StorageEvent) => {
|
||||
if (event.key === STORAGE_KEY_AI_PRIVACY || event.key === STORAGE_KEY_AI_EXTERNAL_MCP_REDACTION) {
|
||||
setState(readAIPrivacyState());
|
||||
}
|
||||
};
|
||||
const onLocalChange = (event: Event) => {
|
||||
const key = (event as CustomEvent<{ key?: string }>).detail?.key;
|
||||
if (key === STORAGE_KEY_AI_PRIVACY || key === STORAGE_KEY_AI_EXTERNAL_MCP_REDACTION) {
|
||||
setState(readAIPrivacyState());
|
||||
}
|
||||
};
|
||||
window.addEventListener('storage', onStorage);
|
||||
window.addEventListener(AI_STATE_CHANGED_EVENT, onLocalChange);
|
||||
return () => {
|
||||
window.removeEventListener('storage', onStorage);
|
||||
window.removeEventListener(AI_STATE_CHANGED_EVENT, onLocalChange);
|
||||
};
|
||||
}, []);
|
||||
|
||||
return { ...state, applying, apply };
|
||||
}
|
||||
|
||||
/** Compatibility exports for existing External MCP configuration callers. One shared policy only. */
|
||||
export type ExternalMcpRedactionSettings = AIPrivacySettings;
|
||||
export const defaultExternalMcpRedactionSettings = defaultAIPrivacySettings;
|
||||
export const readExternalMcpRedactionSettings = readAIPrivacySettings;
|
||||
export const applyExternalMcpRedactionSettings = applyAIPrivacySettings;
|
||||
export const useExternalMcpRedactionState = useAIPrivacyState;
|
||||
|
||||
export type ExternalMcpHttpSettings = {
|
||||
enabled: boolean;
|
||||
port: number;
|
||||
@@ -255,11 +119,9 @@ export type ExternalMcpHttpSettings = {
|
||||
};
|
||||
|
||||
type ExternalMcpConfig = {
|
||||
mode?: ExternalMcpMode;
|
||||
idleTimeoutMinutes?: number;
|
||||
sessionIdleTimeoutMinutes?: number;
|
||||
/** null invalidates shared privacy when persisted settings cannot be read safely. */
|
||||
redaction?: AIPrivacySettings | null;
|
||||
mode: ExternalMcpMode;
|
||||
idleTimeoutMinutes: number;
|
||||
sessionIdleTimeoutMinutes: number;
|
||||
httpEnabled?: boolean;
|
||||
httpPort?: number;
|
||||
httpAllowCustomBind?: boolean;
|
||||
@@ -284,7 +146,6 @@ export type ExternalMcpStartupSyncPlan = {
|
||||
runtimeEnabled: boolean;
|
||||
storedEnabled: boolean;
|
||||
shouldPersistStoredEnabled: boolean;
|
||||
configError?: string;
|
||||
};
|
||||
|
||||
export function normalizeExternalMcpMode(value: string | null): ExternalMcpMode {
|
||||
@@ -524,7 +385,6 @@ export function createExternalMcpStartupSyncPlan({
|
||||
mode,
|
||||
idleTimeoutMinutes,
|
||||
sessionIdleTimeoutMinutes,
|
||||
redaction = defaultAIPrivacySettings(),
|
||||
http = {
|
||||
enabled: false,
|
||||
port: DEFAULT_EXTERNAL_MCP_HTTP_PORT,
|
||||
@@ -538,7 +398,6 @@ export function createExternalMcpStartupSyncPlan({
|
||||
idleTimeoutMinutes: number;
|
||||
sessionIdleTimeoutMinutes: number;
|
||||
http?: ExternalMcpHttpSettings;
|
||||
redaction?: AIPrivacySettings;
|
||||
}): ExternalMcpStartupSyncPlan {
|
||||
const runtimeEnabled = shouldStartExternalMcpOnStartup({ enabled, mode });
|
||||
const storedEnabled = runtimeEnabled;
|
||||
@@ -547,7 +406,6 @@ export function createExternalMcpStartupSyncPlan({
|
||||
mode,
|
||||
idleTimeoutMinutes,
|
||||
sessionIdleTimeoutMinutes,
|
||||
redaction,
|
||||
...httpConfigFromSettings(http),
|
||||
},
|
||||
runtimeEnabled,
|
||||
@@ -557,44 +415,23 @@ export function createExternalMcpStartupSyncPlan({
|
||||
}
|
||||
|
||||
export function readExternalMcpStartupSyncPlan(): ExternalMcpStartupSyncPlan {
|
||||
let redaction = defaultAIPrivacySettings();
|
||||
let configError: string | undefined;
|
||||
try {
|
||||
redaction = readAIPrivacySettings();
|
||||
} catch (error) {
|
||||
configError = error instanceof Error ? error.message : String(error);
|
||||
}
|
||||
const plan = createExternalMcpStartupSyncPlan({
|
||||
return createExternalMcpStartupSyncPlan({
|
||||
enabled: readExternalMcpStoredEnabled(),
|
||||
mode: readExternalMcpMode(),
|
||||
idleTimeoutMinutes: readExternalMcpIdleTimeoutMinutes(),
|
||||
sessionIdleTimeoutMinutes: readSessionIdleTimeoutMinutes(),
|
||||
http: readExternalMcpHttpSettings(),
|
||||
redaction,
|
||||
});
|
||||
return configError ? { ...plan, runtimeEnabled: false, configError } : plan;
|
||||
}
|
||||
|
||||
export async function syncExternalMcpConfig(
|
||||
bridge: ExternalMcpBridge | undefined = netcattyBridge.get(),
|
||||
): Promise<void> {
|
||||
const plan = readExternalMcpStartupSyncPlan();
|
||||
if (plan.configError) {
|
||||
// A corrupt saved policy must block all managed agents, not only External MCP.
|
||||
// Keep the original recovery error even if notifying the main process fails.
|
||||
try {
|
||||
await bridge?.externalMcpSetConfig?.({ redaction: null });
|
||||
} catch {
|
||||
// The saved policy still needs user repair regardless of bridge availability.
|
||||
}
|
||||
throw new Error(plan.configError);
|
||||
}
|
||||
const result = await bridge?.externalMcpSetConfig?.(plan.config);
|
||||
const error = configResponseError(result);
|
||||
if (error) throw new Error(error);
|
||||
if (JSON.stringify(readAIPrivacySettings()) !== JSON.stringify(plan.config.redaction)) {
|
||||
throw new Error('AI privacy settings changed. Try enabling the bridge again.');
|
||||
}
|
||||
export function syncExternalMcpConfig(bridge: ExternalMcpBridge | undefined = netcattyBridge.get()): void {
|
||||
const http = readExternalMcpHttpSettings();
|
||||
void bridge?.externalMcpSetConfig?.({
|
||||
mode: readExternalMcpMode(),
|
||||
idleTimeoutMinutes: readExternalMcpIdleTimeoutMinutes(),
|
||||
sessionIdleTimeoutMinutes: readSessionIdleTimeoutMinutes(),
|
||||
...httpConfigFromSettings(http),
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -616,18 +453,10 @@ export async function syncExternalMcpStartupState(
|
||||
} catch {
|
||||
// Permission sync is best-effort; the main process keeps its safe default.
|
||||
}
|
||||
let configError = initialPlan.configError;
|
||||
try {
|
||||
if (configError) {
|
||||
// Explicit invalidation is distinct from a rejected draft Apply, which keeps
|
||||
// the previous working policy. Never replace corrupt saved data with defaults.
|
||||
await Promise.resolve(bridge?.externalMcpSetConfig?.({ redaction: null }));
|
||||
} else {
|
||||
const result = await Promise.resolve(bridge?.externalMcpSetConfig?.(initialPlan.config));
|
||||
configError = configResponseError(result) ?? undefined;
|
||||
}
|
||||
} catch (error) {
|
||||
configError ??= error instanceof Error ? error.message : String(error);
|
||||
await Promise.resolve(bridge?.externalMcpSetConfig?.(initialPlan.config));
|
||||
} catch {
|
||||
// Config sync is best-effort; continue with enable/disable reconcile.
|
||||
}
|
||||
|
||||
// A user toggle during config await invalidates this startup apply.
|
||||
@@ -636,18 +465,6 @@ export async function syncExternalMcpStartupState(
|
||||
}
|
||||
|
||||
const plan = readExternalMcpStartupSyncPlan();
|
||||
if (JSON.stringify(plan.config.redaction) !== JSON.stringify(initialPlan.config.redaction)) {
|
||||
configError = 'AI privacy settings changed during startup. Enable the bridge again.';
|
||||
}
|
||||
if (configError || plan.configError) {
|
||||
// A rejected or unverified config must never expose an unprotected runtime.
|
||||
plan.configError = configError || plan.configError;
|
||||
plan.runtimeEnabled = false;
|
||||
plan.storedEnabled = false;
|
||||
plan.shouldPersistStoredEnabled = readExternalMcpStoredEnabled();
|
||||
}
|
||||
externalMcpConfigError = plan.configError ?? null;
|
||||
emitAIStateChanged(STORAGE_KEY_AI_PRIVACY);
|
||||
if (plan.shouldPersistStoredEnabled) {
|
||||
localStorageAdapter.writeBoolean(STORAGE_KEY_AI_EXTERNAL_MCP_ENABLED, plan.storedEnabled);
|
||||
emitAIStateChanged(STORAGE_KEY_AI_EXTERNAL_MCP_ENABLED);
|
||||
@@ -699,47 +516,24 @@ export function useExternalMcpToggleState() {
|
||||
emitAIStateChanged(STORAGE_KEY_AI_EXTERNAL_MCP_ENABLED);
|
||||
}, []);
|
||||
|
||||
const [error, setError] = useState<string | null>(() => externalMcpConfigError);
|
||||
|
||||
const setEnabled = useCallback(async (nextEnabled: boolean) => {
|
||||
// Peer session windows mirror storage, but never own the main lifecycle.
|
||||
if (isPeerSessionWindow) {
|
||||
persistEnabled(nextEnabled);
|
||||
return;
|
||||
}
|
||||
if (!nextEnabled) persistEnabled(false);
|
||||
const generation = ++externalMcpEnableGeneration;
|
||||
setError(null);
|
||||
externalMcpConfigError = null;
|
||||
const bridge = netcattyBridge.get();
|
||||
try {
|
||||
// Even a manual enable must apply and verify saved privacy settings first.
|
||||
if (nextEnabled) await syncExternalMcpConfig(bridge);
|
||||
if (generation !== externalMcpEnableGeneration) return;
|
||||
const result = await bridge?.externalMcpSetEnabled?.(nextEnabled);
|
||||
if (nextEnabled && result && typeof result === 'object' && 'ok' in result && result.ok === false) {
|
||||
throw new Error('error' in result && typeof result.error === 'string' && result.error
|
||||
? result.error : 'External MCP could not be enabled.');
|
||||
}
|
||||
if (generation === externalMcpEnableGeneration) persistEnabled(nextEnabled);
|
||||
} catch (cause) {
|
||||
if (generation !== externalMcpEnableGeneration) return;
|
||||
externalMcpConfigError = cause instanceof Error ? cause.message : String(cause);
|
||||
setError(externalMcpConfigError);
|
||||
if (nextEnabled) {
|
||||
persistEnabled(false);
|
||||
await Promise.resolve(bridge?.externalMcpSetEnabled?.(false)).catch(() => undefined);
|
||||
}
|
||||
} finally {
|
||||
const setEnabled = useCallback((nextEnabled: boolean) => {
|
||||
persistEnabled(nextEnabled);
|
||||
// Peer session windows can mirror the stored switch, but they must not own
|
||||
// the main-process External MCP lifecycle.
|
||||
if (isPeerSessionWindow) return;
|
||||
externalMcpEnableGeneration += 1;
|
||||
// Re-emit after the lifecycle IPC settles so consumers that refetch runtime
|
||||
// status (e.g. Tool Access guidance) do not act on the pre-IPC storage
|
||||
// event, which can still observe the previous enabled state.
|
||||
void Promise.resolve(netcattyBridge.get()?.externalMcpSetEnabled?.(nextEnabled)).finally(() => {
|
||||
emitAIStateChanged(STORAGE_KEY_AI_EXTERNAL_MCP_ENABLED);
|
||||
}
|
||||
});
|
||||
}, [isPeerSessionWindow, persistEnabled]);
|
||||
|
||||
useEffect(() => {
|
||||
const syncFromStorage = () => {
|
||||
const nextEnabled = readExternalMcpStoredEnabled();
|
||||
setEnabledRaw(nextEnabled);
|
||||
setError(externalMcpConfigError);
|
||||
};
|
||||
|
||||
const handleStorage = (event: StorageEvent) => {
|
||||
@@ -748,7 +542,7 @@ export function useExternalMcpToggleState() {
|
||||
};
|
||||
const handleLocalStateChanged = (event: Event) => {
|
||||
const key = (event as CustomEvent<{ key?: string }>).detail?.key;
|
||||
if (key !== STORAGE_KEY_AI_EXTERNAL_MCP_ENABLED && key !== STORAGE_KEY_AI_EXTERNAL_MCP_REDACTION) return;
|
||||
if (key !== STORAGE_KEY_AI_EXTERNAL_MCP_ENABLED) return;
|
||||
syncFromStorage();
|
||||
};
|
||||
|
||||
@@ -793,5 +587,5 @@ export function useExternalMcpToggleState() {
|
||||
};
|
||||
}, [enabled, isPeerSessionWindow, persistEnabled]);
|
||||
|
||||
return { enabled, setEnabled, error };
|
||||
return { enabled, setEnabled };
|
||||
}
|
||||
@@ -1,5 +1,4 @@
|
||||
import { useCallback, useMemo } from 'react';
|
||||
import type { ContainerRuntimeId } from '../../domain/systemManager/containerRuntime';
|
||||
import type {
|
||||
DockerContainerAction,
|
||||
DockerImageManageAction,
|
||||
@@ -95,27 +94,23 @@ export function useSystemManagerBackend() {
|
||||
return bridge.tmuxAction(options);
|
||||
}, []);
|
||||
|
||||
const listDockerContainers = useCallback(async (sessionId: string, runtime?: ContainerRuntimeId) => {
|
||||
const listDockerContainers = useCallback(async (sessionId: string) => {
|
||||
const bridge = netcattyBridge.get();
|
||||
if (!bridge?.listDockerContainers) {
|
||||
return { success: false as const, error: 'listDockerContainers unavailable' };
|
||||
}
|
||||
return bridge.listDockerContainers(sessionId, runtime);
|
||||
return bridge.listDockerContainers(sessionId);
|
||||
}, []);
|
||||
|
||||
const listDockerImages = useCallback(async (sessionId: string, runtime?: ContainerRuntimeId) => {
|
||||
const listDockerImages = useCallback(async (sessionId: string) => {
|
||||
const bridge = netcattyBridge.get();
|
||||
if (!bridge?.listDockerImages) {
|
||||
return { success: false as const, error: 'listDockerImages unavailable' };
|
||||
}
|
||||
return bridge.listDockerImages(sessionId, runtime);
|
||||
return bridge.listDockerImages(sessionId);
|
||||
}, []);
|
||||
|
||||
const getDockerStats = useCallback(async (options: {
|
||||
sessionId: string;
|
||||
ids?: string[];
|
||||
runtime?: ContainerRuntimeId;
|
||||
}) => {
|
||||
const getDockerStats = useCallback(async (options: { sessionId: string; ids?: string[] }) => {
|
||||
const bridge = netcattyBridge.get();
|
||||
if (!bridge?.getDockerStats) {
|
||||
return { success: false as const, error: 'getDockerStats unavailable' };
|
||||
@@ -160,11 +155,7 @@ export function useSystemManagerBackend() {
|
||||
return bridge.systemServiceAction(options);
|
||||
}, []);
|
||||
|
||||
const dockerInspect = useCallback(async (options: {
|
||||
sessionId: string;
|
||||
containerId: string;
|
||||
runtime?: ContainerRuntimeId;
|
||||
}) => {
|
||||
const dockerInspect = useCallback(async (options: { sessionId: string; containerId: string }) => {
|
||||
const bridge = netcattyBridge.get();
|
||||
if (!bridge?.dockerInspect) {
|
||||
return { success: false as const, error: 'dockerInspect unavailable' };
|
||||
@@ -172,11 +163,7 @@ export function useSystemManagerBackend() {
|
||||
return bridge.dockerInspect(options);
|
||||
}, []);
|
||||
|
||||
const dockerImageInspect = useCallback(async (options: {
|
||||
sessionId: string;
|
||||
imageId: string;
|
||||
runtime?: ContainerRuntimeId;
|
||||
}) => {
|
||||
const dockerImageInspect = useCallback(async (options: { sessionId: string; imageId: string }) => {
|
||||
const bridge = netcattyBridge.get();
|
||||
if (!bridge?.dockerImageInspect) {
|
||||
return { success: false as const, error: 'dockerImageInspect unavailable' };
|
||||
@@ -189,7 +176,6 @@ export function useSystemManagerBackend() {
|
||||
containerId: string;
|
||||
action: DockerContainerAction;
|
||||
newName?: string;
|
||||
runtime?: ContainerRuntimeId;
|
||||
}) => {
|
||||
const bridge = netcattyBridge.get();
|
||||
if (!bridge?.dockerAction) {
|
||||
@@ -198,10 +184,7 @@ export function useSystemManagerBackend() {
|
||||
return bridge.dockerAction(options);
|
||||
}, []);
|
||||
|
||||
const dockerImageAction = useCallback(async (options: {
|
||||
sessionId: string;
|
||||
runtime?: ContainerRuntimeId;
|
||||
} & DockerImageManageAction) => {
|
||||
const dockerImageAction = useCallback(async (options: { sessionId: string } & DockerImageManageAction) => {
|
||||
const bridge = netcattyBridge.get();
|
||||
if (!bridge?.dockerImageAction) {
|
||||
return { success: false as const, error: 'dockerImageAction unavailable' };
|
||||
@@ -209,21 +192,6 @@ export function useSystemManagerBackend() {
|
||||
return bridge.dockerImageAction(options);
|
||||
}, []);
|
||||
|
||||
const kubectl = useCallback(async (options: {
|
||||
sessionId: string;
|
||||
op: 'contexts' | 'namespaces' | 'pods' | 'delete';
|
||||
context?: string;
|
||||
namespace?: string;
|
||||
pod?: string;
|
||||
shell?: string;
|
||||
}) => {
|
||||
const bridge = netcattyBridge.get();
|
||||
if (!bridge?.kubectl) {
|
||||
return { success: false as const, error: 'kubectl unavailable' };
|
||||
}
|
||||
return bridge.kubectl(options);
|
||||
}, []);
|
||||
|
||||
const openTerminalPopup = useCallback(async (
|
||||
payload: Parameters<NonNullable<NetcattyBridge['openTerminalPopup']>>[0],
|
||||
) => {
|
||||
@@ -255,7 +223,6 @@ export function useSystemManagerBackend() {
|
||||
dockerImageInspect,
|
||||
dockerAction,
|
||||
dockerImageAction,
|
||||
kubectl,
|
||||
openTerminalPopup,
|
||||
}), [
|
||||
probeSystemCapabilities,
|
||||
@@ -278,7 +245,6 @@ export function useSystemManagerBackend() {
|
||||
dockerImageInspect,
|
||||
dockerAction,
|
||||
dockerImageAction,
|
||||
kubectl,
|
||||
openTerminalPopup,
|
||||
]);
|
||||
}
|
||||
@@ -2,7 +2,6 @@ import React from "react";
|
||||
import { ChevronDown, Eye, EyeOff, FileKey, FolderLock, FolderOpen, Key, KeyRound, MapPin, Plus, Shapes, Shield, Trash2, User, X } from "lucide-react";
|
||||
import type { Host } from "../types";
|
||||
import { applyHostAuthMethodSelection } from "../domain/sshAuth";
|
||||
import { HostTotpFields } from "./HostTotpFields";
|
||||
import { HostIconPicker } from "./HostIconPicker";
|
||||
import { Button } from "./ui/button";
|
||||
import { Combobox } from "./ui/combobox";
|
||||
@@ -102,7 +101,6 @@ export const HostDetailsConnectionSections: React.FC<HostDetailsConnectionSectio
|
||||
distroOptions,
|
||||
effectiveFormDistro,
|
||||
getDistroOptionLabel,
|
||||
totpFlushRef,
|
||||
}) => {
|
||||
const selectAuthMethod = (authMethod: "auto" | "password" | "key" | "certificate") => {
|
||||
setForm((previous: Host) => applyEffectiveHostAuthMethodSelection(
|
||||
@@ -741,13 +739,6 @@ export const HostDetailsConnectionSections: React.FC<HostDetailsConnectionSectio
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
<HostTotpFields
|
||||
t={t}
|
||||
form={form}
|
||||
setForm={setForm}
|
||||
effectiveProtocol={effectiveProtocol}
|
||||
flushRef={totpFlushRef}
|
||||
/>
|
||||
</div>
|
||||
</HostDetailsSection>
|
||||
|
||||
|
||||
@@ -92,7 +92,7 @@ test("HostDetailsPanel labels command proxy summaries consistently", () => {
|
||||
assert.match(markup, /ProxyCommand/);
|
||||
assert.doesNotMatch(markup, /COMMAND/);
|
||||
assert.doesNotMatch(markup, /cloudflared access ssh/);
|
||||
assert.doesNotMatch(markup, /--token secret/);
|
||||
assert.doesNotMatch(markup, /secret/);
|
||||
});
|
||||
|
||||
test("HostDetailsPanel keeps explicitly cleared telnet credentials empty", () => {
|
||||
|
||||
@@ -44,7 +44,6 @@ import {
|
||||
} from "./ui/aside-panel";
|
||||
import { HostDetailsAdvancedSections } from "./HostDetailsAdvancedSections";
|
||||
import { detachEffectiveHostIdentity, HostDetailsConnectionSections } from "./HostDetailsConnectionSections";
|
||||
import type { HostTotpFlushResult } from "./HostTotpFields";
|
||||
import {
|
||||
LINUX_DISTRO_OPTION_IDS,
|
||||
parseOptionalPortInput,
|
||||
@@ -164,8 +163,6 @@ const HostDetailsPanel: React.FC<HostDetailsPanelPropsWithResize> = ({
|
||||
};
|
||||
const { checkSshAgent } = useApplicationBackend();
|
||||
const baselineSnippetsRef = useRef(snippets);
|
||||
const totpFlushRef = useRef<(() => Promise<HostTotpFlushResult>) | null>(null);
|
||||
const savingHostRef = useRef(false);
|
||||
const baselineHydratedRef = useRef(snippets.length > 0);
|
||||
const openingQueueIdsRef = useRef<string[]>([]);
|
||||
const [form, setForm] = useState<Host>(
|
||||
@@ -537,12 +534,7 @@ const HostDetailsPanel: React.FC<HostDetailsPanelPropsWithResize> = ({
|
||||
? pluginConnectionValid && Boolean(sanitizePluginConnection(form.pluginConnection, form.protocol))
|
||||
: hasHostname;
|
||||
|
||||
const handleSubmit = async () => {
|
||||
if (savingHostRef.current) return;
|
||||
savingHostRef.current = true;
|
||||
try {
|
||||
const flushed = await totpFlushRef.current?.();
|
||||
if (flushed && !flushed.ok) return;
|
||||
const handleSubmit = () => {
|
||||
const hostname = form.hostname.trim()
|
||||
|| (pluginProtocolActive ? form.label.trim() || form.pluginConnection?.providerId || "plugin" : "");
|
||||
if (!hostname || !canSaveHost) return;
|
||||
@@ -623,10 +615,6 @@ const HostDetailsPanel: React.FC<HostDetailsPanelPropsWithResize> = ({
|
||||
authPolicyVersion: 1,
|
||||
managedSourceId: finalManagedSourceId,
|
||||
};
|
||||
if (flushed?.ok) {
|
||||
if (flushed.totp) cleaned.totp = flushed.totp;
|
||||
else delete cleaned.totp;
|
||||
}
|
||||
if (pluginProtocolActive) {
|
||||
const pluginConnection = sanitizePluginConnection(cleaned.pluginConnection, cleaned.protocol);
|
||||
if (!pluginConnection) return;
|
||||
@@ -754,9 +742,6 @@ const HostDetailsPanel: React.FC<HostDetailsPanelPropsWithResize> = ({
|
||||
}
|
||||
}
|
||||
onSave(stripBuiltInConnectionFieldsForPluginHost(cleaned));
|
||||
} finally {
|
||||
savingHostRef.current = false;
|
||||
}
|
||||
};
|
||||
|
||||
const handleCreateGroup = () => {
|
||||
@@ -1152,7 +1137,6 @@ const HostDetailsPanel: React.FC<HostDetailsPanelPropsWithResize> = ({
|
||||
t={t}
|
||||
form={form}
|
||||
setForm={setForm}
|
||||
totpFlushRef={totpFlushRef}
|
||||
update={update}
|
||||
groupDefaults={effectiveGroupDefaults}
|
||||
effectiveAuthMethod={effectiveAuthMethod}
|
||||
|
||||
@@ -1,104 +0,0 @@
|
||||
import assert from "node:assert/strict";
|
||||
import test from "node:test";
|
||||
import { readFileSync } from "node:fs";
|
||||
import { JSDOM } from "jsdom";
|
||||
import type { Host } from "../domain/models";
|
||||
|
||||
test("host totp setup stays on the shared setting row and opens a dialog", async () => {
|
||||
const dom = new JSDOM("<!doctype html><html><body><div id=\"root\"></div></body></html>", {
|
||||
url: "http://localhost",
|
||||
pretendToBeVisual: true,
|
||||
});
|
||||
const previous = new Map<string, PropertyDescriptor | undefined>();
|
||||
const install = (key: string, value: unknown) => {
|
||||
previous.set(key, Object.getOwnPropertyDescriptor(globalThis, key));
|
||||
Object.defineProperty(globalThis, key, { configurable: true, writable: true, value });
|
||||
};
|
||||
class ResizeObserverStub {
|
||||
observe() {}
|
||||
unobserve() {}
|
||||
disconnect() {}
|
||||
}
|
||||
install("window", dom.window);
|
||||
install("document", dom.window.document);
|
||||
install("navigator", dom.window.navigator);
|
||||
install("HTMLElement", dom.window.HTMLElement);
|
||||
install("HTMLInputElement", dom.window.HTMLInputElement);
|
||||
install("HTMLButtonElement", dom.window.HTMLButtonElement);
|
||||
install("Element", dom.window.Element);
|
||||
install("Node", dom.window.Node);
|
||||
install("MutationObserver", dom.window.MutationObserver);
|
||||
install("Event", dom.window.Event);
|
||||
install("CustomEvent", dom.window.CustomEvent);
|
||||
install("MouseEvent", dom.window.MouseEvent);
|
||||
install("KeyboardEvent", dom.window.KeyboardEvent);
|
||||
install("FocusEvent", dom.window.FocusEvent);
|
||||
install("PointerEvent", dom.window.PointerEvent);
|
||||
install("getComputedStyle", dom.window.getComputedStyle.bind(dom.window));
|
||||
install("requestAnimationFrame", dom.window.requestAnimationFrame.bind(dom.window));
|
||||
install("cancelAnimationFrame", dom.window.cancelAnimationFrame.bind(dom.window));
|
||||
install("ResizeObserver", ResizeObserverStub);
|
||||
install("IS_REACT_ACT_ENVIRONMENT", true);
|
||||
Object.assign(dom.window, { netcatty: {} });
|
||||
|
||||
const { act } = await import("react");
|
||||
const { createRoot } = await import("react-dom/client");
|
||||
const { HostTotpFields } = await import("./HostTotpFields.tsx");
|
||||
const { TooltipProvider } = await import("./ui/tooltip.tsx");
|
||||
|
||||
const form = {
|
||||
id: "host-1",
|
||||
label: "Jump",
|
||||
hostname: "127.0.0.1",
|
||||
port: 22,
|
||||
username: "root",
|
||||
protocol: "ssh",
|
||||
} as Host;
|
||||
let latest = form;
|
||||
const root = createRoot(dom.window.document.getElementById("root")!);
|
||||
const t = (key: string) => key;
|
||||
try {
|
||||
await act(async () => {
|
||||
root.render(
|
||||
<TooltipProvider>
|
||||
<HostTotpFields
|
||||
t={t}
|
||||
form={latest}
|
||||
setForm={(updater) => {
|
||||
latest = typeof updater === "function" ? updater(latest) : updater;
|
||||
}}
|
||||
effectiveProtocol="ssh"
|
||||
/>
|
||||
</TooltipProvider>,
|
||||
);
|
||||
});
|
||||
const markup = dom.window.document.body.innerHTML;
|
||||
assert.match(markup, /hostDetails\.totp\.setup/);
|
||||
assert.doesNotMatch(markup, /rounded-md border border-border\/60 p-3/);
|
||||
assert.equal(dom.window.document.querySelector("[role='dialog']"), null);
|
||||
assert.equal(dom.window.document.querySelector("[role='switch']"), null);
|
||||
|
||||
const button = [...dom.window.document.querySelectorAll("button")]
|
||||
.find((node) => node.textContent?.includes("hostDetails.totp.setup"));
|
||||
assert.ok(button);
|
||||
await act(async () => { button.click(); });
|
||||
assert.ok(dom.window.document.body.textContent?.includes("hostDetails.totp.modalTitle"));
|
||||
assert.ok(dom.window.document.body.innerHTML.includes("hostDetails.totp.secretPlaceholder"));
|
||||
assert.equal(latest.totp, undefined);
|
||||
const fields = readFileSync(new URL("./HostTotpFields.tsx", import.meta.url), "utf8");
|
||||
assert.match(fields, /label=\{t\("hostDetails\.totp\.title"\)\}/);
|
||||
assert.match(fields, /setConfirmClear\(true\)/);
|
||||
assert.match(fields, /variant="secondary"/);
|
||||
const sections = readFileSync(new URL("./HostDetailsConnectionSections.tsx", import.meta.url), "utf8");
|
||||
const passwordAt = sections.indexOf("hostDetails.password.placeholder");
|
||||
const totpAt = sections.indexOf("<HostTotpFields");
|
||||
assert.ok(passwordAt > 0 && totpAt > passwordAt);
|
||||
} finally {
|
||||
await act(async () => { root.unmount(); });
|
||||
await new Promise((resolve) => setTimeout(resolve, 50));
|
||||
for (const [key, descriptor] of previous) {
|
||||
if (descriptor) Object.defineProperty(globalThis, key, descriptor);
|
||||
else delete (globalThis as Record<string, unknown>)[key];
|
||||
}
|
||||
}
|
||||
});
|
||||
@@ -1,568 +0,0 @@
|
||||
import React, { useEffect, useRef, useState } from "react";
|
||||
import jsQR from "jsqr";
|
||||
import { RE2JS } from "re2js";
|
||||
import type { Host, HostTotpConfig } from "../domain/models";
|
||||
import { netcattyBridge } from "@/infrastructure/services/netcattyBridge.ts";
|
||||
import { Button } from "./ui/button";
|
||||
import {
|
||||
Dialog,
|
||||
DialogContent,
|
||||
DialogDescription,
|
||||
DialogFooter,
|
||||
DialogHeader,
|
||||
DialogTitle,
|
||||
} from "./ui/dialog";
|
||||
import { HostDetailsSettingRow } from "./host-details";
|
||||
import { SystemPanelConfirmDialog } from "./systemManager/SystemPanelConfirmDialog";
|
||||
import { Input } from "./ui/input";
|
||||
import { Label } from "./ui/label";
|
||||
import { Switch } from "./ui/switch";
|
||||
|
||||
type Preview = {
|
||||
uri: string;
|
||||
code: string;
|
||||
secondsRemaining: number;
|
||||
issuer?: string;
|
||||
label?: string;
|
||||
};
|
||||
|
||||
export type HostTotpFlushResult =
|
||||
| { ok: true; totp: HostTotpConfig | undefined }
|
||||
| { ok: false };
|
||||
|
||||
type HostTotpFieldsProps = {
|
||||
t: (key: string, params?: Record<string, string | number>) => string;
|
||||
form: Host;
|
||||
setForm: (updater: Host | ((host: Host) => Host)) => void;
|
||||
effectiveProtocol?: string;
|
||||
flushRef?: React.MutableRefObject<(() => Promise<HostTotpFlushResult>) | null>;
|
||||
};
|
||||
|
||||
const HIDDEN_PROTOCOLS = new Set(["telnet", "serial"]);
|
||||
|
||||
function errorKey(error: string | undefined): string {
|
||||
switch (error) {
|
||||
case "hotp":
|
||||
return "hostDetails.totp.error.hotp";
|
||||
case "migration":
|
||||
return "hostDetails.totp.error.migration";
|
||||
case "not-found":
|
||||
return "hostDetails.totp.error.notFound";
|
||||
case "screen-permission-denied":
|
||||
return "hostDetails.totp.error.permission";
|
||||
case "unavailable":
|
||||
return "hostDetails.totp.error.unavailable";
|
||||
case "pattern":
|
||||
return "hostDetails.totp.error.pattern";
|
||||
default:
|
||||
return "hostDetails.totp.error.invalid";
|
||||
}
|
||||
}
|
||||
|
||||
async function decodeQrFile(file: File): Promise<string> {
|
||||
const bitmap = await createImageBitmap(file);
|
||||
try {
|
||||
const canvas = document.createElement("canvas");
|
||||
canvas.width = bitmap.width;
|
||||
canvas.height = bitmap.height;
|
||||
const context = canvas.getContext("2d", { willReadFrequently: true });
|
||||
if (!context) throw new Error("unavailable");
|
||||
context.drawImage(bitmap, 0, 0);
|
||||
const image = context.getImageData(0, 0, canvas.width, canvas.height);
|
||||
return jsQR(image.data, image.width, image.height)?.data || "";
|
||||
} finally {
|
||||
bitmap.close?.();
|
||||
}
|
||||
}
|
||||
|
||||
function validPattern(value: string): string | null {
|
||||
const trimmed = value.trim();
|
||||
if (!trimmed) return "";
|
||||
if (trimmed.length > 200) return null;
|
||||
try {
|
||||
RE2JS.compile(trimmed);
|
||||
return trimmed;
|
||||
} catch {
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
function importedAccount(preview: Preview): string {
|
||||
const issuer = preview.issuer?.trim() || "";
|
||||
const label = preview.label?.trim() || "";
|
||||
if (issuer && label && !label.toLowerCase().startsWith(issuer.toLowerCase())) {
|
||||
return `${issuer} (${label})`;
|
||||
}
|
||||
return label || issuer;
|
||||
}
|
||||
|
||||
function useLiveTotp(source: string | undefined, hostId: string): Preview | null {
|
||||
const [preview, setPreview] = useState<Preview | null>(null);
|
||||
const hostRef = useRef(hostId);
|
||||
hostRef.current = hostId;
|
||||
|
||||
useEffect(() => {
|
||||
if (!source) {
|
||||
setPreview(null);
|
||||
return undefined;
|
||||
}
|
||||
const expectedHostId = hostId;
|
||||
let cancelled = false;
|
||||
const bridge = netcattyBridge.get();
|
||||
if (!bridge?.previewTotp) return undefined;
|
||||
const endsAtRef = { current: 0 };
|
||||
let refreshed = false;
|
||||
const apply = (next: Preview) => {
|
||||
if (cancelled || hostRef.current !== expectedHostId) return;
|
||||
endsAtRef.current = Date.now() + next.secondsRemaining * 1000;
|
||||
refreshed = false;
|
||||
setPreview(next);
|
||||
};
|
||||
void bridge.previewTotp({ text: source }).then((result) => {
|
||||
if (!result?.ok || !result.uri || !result.code) return;
|
||||
apply({
|
||||
uri: result.uri,
|
||||
code: result.code,
|
||||
secondsRemaining: result.secondsRemaining || result.period || 30,
|
||||
issuer: result.issuer,
|
||||
label: result.label,
|
||||
});
|
||||
}).catch(() => undefined);
|
||||
const timer = setInterval(() => {
|
||||
if (cancelled || !endsAtRef.current) return;
|
||||
const left = Math.max(0, Math.ceil((endsAtRef.current - Date.now()) / 1000));
|
||||
setPreview((current) => (
|
||||
current && current.secondsRemaining !== left
|
||||
? { ...current, secondsRemaining: left }
|
||||
: current
|
||||
));
|
||||
if (left !== 0 || refreshed) return;
|
||||
refreshed = true;
|
||||
void bridge.previewTotp({ text: source }).then((result) => {
|
||||
if (!result?.ok || !result.uri || !result.code) return;
|
||||
apply({
|
||||
uri: result.uri,
|
||||
code: result.code,
|
||||
secondsRemaining: result.secondsRemaining || result.period || 30,
|
||||
issuer: result.issuer,
|
||||
label: result.label,
|
||||
});
|
||||
}).catch(() => undefined);
|
||||
}, 250);
|
||||
return () => {
|
||||
cancelled = true;
|
||||
clearInterval(timer);
|
||||
};
|
||||
}, [hostId, source]);
|
||||
|
||||
return source ? preview : null;
|
||||
}
|
||||
|
||||
export const HostTotpFields: React.FC<HostTotpFieldsProps> = ({
|
||||
t,
|
||||
form,
|
||||
setForm,
|
||||
effectiveProtocol,
|
||||
flushRef,
|
||||
}) => {
|
||||
const configured = Boolean(form.totp?.uri);
|
||||
const [setupOpen, setSetupOpen] = useState(false);
|
||||
const [draft, setDraft] = useState("");
|
||||
const [pattern, setPattern] = useState(form.totp?.promptPattern || "");
|
||||
const [autoSubmit, setAutoSubmit] = useState(form.totp?.autoSubmit !== false);
|
||||
const [modalUri, setModalUri] = useState<string | undefined>(undefined);
|
||||
const [error, setError] = useState<string | null>(null);
|
||||
const [busy, setBusy] = useState(false);
|
||||
const [scanning, setScanning] = useState(false);
|
||||
const [confirmClear, setConfirmClear] = useState(false);
|
||||
const fileRef = useRef<HTMLInputElement>(null);
|
||||
const hostIdRef = useRef(form.id);
|
||||
const formRef = useRef(form);
|
||||
const setupOpenRef = useRef(setupOpen);
|
||||
const draftRef = useRef(draft);
|
||||
const patternRef = useRef(pattern);
|
||||
const autoSubmitRef = useRef(autoSubmit);
|
||||
const modalUriRef = useRef(modalUri);
|
||||
const requestRef = useRef(0);
|
||||
formRef.current = form;
|
||||
setupOpenRef.current = setupOpen;
|
||||
draftRef.current = draft;
|
||||
patternRef.current = pattern;
|
||||
autoSubmitRef.current = autoSubmit;
|
||||
modalUriRef.current = modalUri;
|
||||
|
||||
const [trackedHostId, setTrackedHostId] = useState(form.id);
|
||||
if (form.id !== trackedHostId) {
|
||||
setTrackedHostId(form.id);
|
||||
setSetupOpen(false);
|
||||
setDraft("");
|
||||
setPattern(form.totp?.promptPattern || "");
|
||||
setAutoSubmit(form.totp?.autoSubmit !== false);
|
||||
setModalUri(undefined);
|
||||
setError(null);
|
||||
setBusy(false);
|
||||
setScanning(false);
|
||||
setConfirmClear(false);
|
||||
draftRef.current = "";
|
||||
patternRef.current = form.totp?.promptPattern || "";
|
||||
autoSubmitRef.current = form.totp?.autoSubmit !== false;
|
||||
modalUriRef.current = undefined;
|
||||
setupOpenRef.current = false;
|
||||
}
|
||||
hostIdRef.current = form.id;
|
||||
|
||||
const rowPreview = useLiveTotp(form.totp?.uri, form.id);
|
||||
const modalPreview = useLiveTotp(setupOpen ? (modalUri || form.totp?.uri) : undefined, form.id);
|
||||
|
||||
const writeTotp = (next: HostTotpConfig | undefined) => {
|
||||
const expectedHostId = hostIdRef.current;
|
||||
setForm((current) => {
|
||||
if (current.id !== expectedHostId) return current;
|
||||
const updated = { ...current };
|
||||
if (next?.uri) updated.totp = next;
|
||||
else delete updated.totp;
|
||||
return updated;
|
||||
});
|
||||
};
|
||||
|
||||
const previewText = async (text: string): Promise<string | null> => {
|
||||
const expectedHostId = hostIdRef.current;
|
||||
const requestId = ++requestRef.current;
|
||||
const bridge = netcattyBridge.get();
|
||||
if (!bridge?.previewTotp) {
|
||||
setError("unavailable");
|
||||
return null;
|
||||
}
|
||||
setBusy(true);
|
||||
try {
|
||||
const result = await bridge.previewTotp({ text });
|
||||
if (hostIdRef.current !== expectedHostId || requestRef.current !== requestId) return null;
|
||||
if (!result?.ok || !result.uri || !result.code) {
|
||||
setError(result?.error || "invalid");
|
||||
return null;
|
||||
}
|
||||
setError(null);
|
||||
setModalUri(result.uri);
|
||||
modalUriRef.current = result.uri;
|
||||
setDraft("");
|
||||
draftRef.current = "";
|
||||
return result.uri;
|
||||
} catch {
|
||||
if (hostIdRef.current === expectedHostId && requestRef.current === requestId) setError("unavailable");
|
||||
return null;
|
||||
} finally {
|
||||
if (hostIdRef.current === expectedHostId && requestRef.current === requestId) setBusy(false);
|
||||
}
|
||||
};
|
||||
|
||||
const commitSetup = async (options?: { requireSecret?: boolean }): Promise<HostTotpFlushResult> => {
|
||||
const expectedHostId = hostIdRef.current;
|
||||
const patternValue = validPattern(patternRef.current);
|
||||
if (patternValue == null) {
|
||||
setError("pattern");
|
||||
return { ok: false };
|
||||
}
|
||||
let uri = modalUriRef.current || formRef.current.totp?.uri;
|
||||
const text = draftRef.current.trim();
|
||||
if (text) {
|
||||
uri = await previewText(text) || undefined;
|
||||
if (hostIdRef.current !== expectedHostId) return { ok: false };
|
||||
if (!uri) return { ok: false };
|
||||
}
|
||||
if (!uri) {
|
||||
if (options?.requireSecret) {
|
||||
setError("invalid");
|
||||
return { ok: false };
|
||||
}
|
||||
return { ok: true, totp: formRef.current.totp };
|
||||
}
|
||||
const next: HostTotpConfig = {
|
||||
uri,
|
||||
autoSubmit: autoSubmitRef.current,
|
||||
...(patternValue ? { promptPattern: patternValue } : {}),
|
||||
};
|
||||
writeTotp(next);
|
||||
setSetupOpen(false);
|
||||
setupOpenRef.current = false;
|
||||
setDraft("");
|
||||
draftRef.current = "";
|
||||
setModalUri(undefined);
|
||||
modalUriRef.current = undefined;
|
||||
setError(null);
|
||||
return { ok: true, totp: next };
|
||||
};
|
||||
const commitRef = useRef(commitSetup);
|
||||
commitRef.current = commitSetup;
|
||||
|
||||
useEffect(() => {
|
||||
if (!flushRef) return undefined;
|
||||
const flush = async (): Promise<HostTotpFlushResult> => {
|
||||
const expectedHostId = hostIdRef.current;
|
||||
if (hostIdRef.current !== expectedHostId) return { ok: false };
|
||||
if (!setupOpenRef.current) return { ok: true, totp: formRef.current.totp };
|
||||
return commitRef.current();
|
||||
};
|
||||
flushRef.current = flush;
|
||||
return () => {
|
||||
if (flushRef.current === flush) flushRef.current = null;
|
||||
};
|
||||
}, [flushRef]);
|
||||
|
||||
const openSetup = () => {
|
||||
setPattern(form.totp?.promptPattern || "");
|
||||
patternRef.current = form.totp?.promptPattern || "";
|
||||
setAutoSubmit(form.totp?.autoSubmit !== false);
|
||||
autoSubmitRef.current = form.totp?.autoSubmit !== false;
|
||||
setDraft("");
|
||||
draftRef.current = "";
|
||||
setModalUri(undefined);
|
||||
modalUriRef.current = undefined;
|
||||
setError(null);
|
||||
setSetupOpen(true);
|
||||
setupOpenRef.current = true;
|
||||
};
|
||||
|
||||
const cancelSetup = () => {
|
||||
requestRef.current += 1;
|
||||
setSetupOpen(false);
|
||||
setupOpenRef.current = false;
|
||||
setDraft("");
|
||||
draftRef.current = "";
|
||||
setModalUri(undefined);
|
||||
modalUriRef.current = undefined;
|
||||
setError(null);
|
||||
setBusy(false);
|
||||
setScanning(false);
|
||||
};
|
||||
|
||||
const clearTotp = () => {
|
||||
cancelSetup();
|
||||
writeTotp(undefined);
|
||||
};
|
||||
|
||||
if (effectiveProtocol && HIDDEN_PROTOCOLS.has(effectiveProtocol)) return null;
|
||||
|
||||
const scanScreen = async () => {
|
||||
const expectedHostId = form.id;
|
||||
const requestId = ++requestRef.current;
|
||||
const bridge = netcattyBridge.get();
|
||||
if (!bridge?.scanTotpQrFromScreen) {
|
||||
setError("unavailable");
|
||||
return;
|
||||
}
|
||||
setBusy(true);
|
||||
setScanning(true);
|
||||
try {
|
||||
const result = await bridge.scanTotpQrFromScreen();
|
||||
if (hostIdRef.current !== expectedHostId || requestRef.current !== requestId) return;
|
||||
if (!result?.ok || !result.uri || !result.code) {
|
||||
setError(result?.error || "not-found");
|
||||
return;
|
||||
}
|
||||
setError(null);
|
||||
setModalUri(result.uri);
|
||||
modalUriRef.current = result.uri;
|
||||
setDraft("");
|
||||
draftRef.current = "";
|
||||
} catch {
|
||||
if (hostIdRef.current === expectedHostId && requestRef.current === requestId) setError("unavailable");
|
||||
} finally {
|
||||
if (hostIdRef.current === expectedHostId && requestRef.current === requestId) {
|
||||
setBusy(false);
|
||||
setScanning(false);
|
||||
}
|
||||
}
|
||||
};
|
||||
|
||||
return (
|
||||
<>
|
||||
<HostDetailsSettingRow
|
||||
label={t("hostDetails.totp.title")}
|
||||
hint={t("hostDetails.totp.warning")}
|
||||
>
|
||||
<div className="flex items-center gap-2">
|
||||
{rowPreview && (
|
||||
<span
|
||||
className="font-mono text-xs font-normal tabular-nums tracking-widest text-muted-foreground"
|
||||
title={t("hostDetails.totp.countdown", { seconds: rowPreview.secondsRemaining })}
|
||||
>
|
||||
{rowPreview.code}
|
||||
</span>
|
||||
)}
|
||||
<Button
|
||||
type="button"
|
||||
variant="ghost"
|
||||
size="sm"
|
||||
className="h-7 px-2"
|
||||
onClick={openSetup}
|
||||
>
|
||||
{configured ? t("hostDetails.totp.edit") : t("hostDetails.totp.setup")}
|
||||
</Button>
|
||||
</div>
|
||||
</HostDetailsSettingRow>
|
||||
|
||||
<Dialog open={setupOpen} onOpenChange={(open) => { if (!open) cancelSetup(); }}>
|
||||
<DialogContent className="sm:max-w-[440px]">
|
||||
<DialogHeader>
|
||||
<DialogTitle>{t("hostDetails.totp.modalTitle")}</DialogTitle>
|
||||
<DialogDescription>{t("hostDetails.totp.warning")}</DialogDescription>
|
||||
</DialogHeader>
|
||||
<div className="space-y-3">
|
||||
<div className="space-y-2">
|
||||
<Label htmlFor="host-totp-secret">{t("hostDetails.totp.secret")}</Label>
|
||||
<Input
|
||||
id="host-totp-secret"
|
||||
value={draft}
|
||||
onChange={(event) => {
|
||||
setDraft(event.target.value);
|
||||
draftRef.current = event.target.value;
|
||||
}}
|
||||
onBlur={() => {
|
||||
if (draftRef.current.trim()) void previewText(draftRef.current);
|
||||
}}
|
||||
onKeyDown={(event) => {
|
||||
if (event.key === "Enter") {
|
||||
event.preventDefault();
|
||||
void commitSetup({ requireSecret: true });
|
||||
}
|
||||
}}
|
||||
placeholder={configured ? t("hostDetails.totp.replacePlaceholder") : t("hostDetails.totp.secretPlaceholder")}
|
||||
aria-label={t("hostDetails.totp.secret")}
|
||||
className="h-8 font-mono text-xs"
|
||||
disabled={busy}
|
||||
spellCheck={false}
|
||||
autoFocus
|
||||
/>
|
||||
</div>
|
||||
<div className="flex flex-wrap gap-2">
|
||||
<Button
|
||||
type="button"
|
||||
variant="secondary"
|
||||
size="sm"
|
||||
className="h-8 focus:bg-secondary focus:text-secondary-foreground focus-visible:bg-secondary focus-visible:text-secondary-foreground"
|
||||
disabled={busy}
|
||||
onClick={() => fileRef.current?.click()}
|
||||
>
|
||||
{t("hostDetails.totp.importQr")}
|
||||
</Button>
|
||||
<Button
|
||||
type="button"
|
||||
variant="secondary"
|
||||
size="sm"
|
||||
className="h-8 focus:bg-secondary focus:text-secondary-foreground focus-visible:bg-secondary focus-visible:text-secondary-foreground"
|
||||
disabled={busy}
|
||||
onClick={() => { void scanScreen(); }}
|
||||
>
|
||||
{scanning ? t("hostDetails.totp.scanning") : t("hostDetails.totp.scanScreen")}
|
||||
</Button>
|
||||
<input
|
||||
ref={fileRef}
|
||||
type="file"
|
||||
accept="image/*"
|
||||
className="hidden"
|
||||
onChange={(event) => {
|
||||
const file = event.target.files?.[0];
|
||||
event.target.value = "";
|
||||
if (!file) return;
|
||||
const expectedHostId = form.id;
|
||||
const requestId = ++requestRef.current;
|
||||
setBusy(true);
|
||||
void decodeQrFile(file)
|
||||
.then((text) => {
|
||||
if (hostIdRef.current !== expectedHostId || requestRef.current !== requestId) return;
|
||||
if (!text) {
|
||||
setError("not-found");
|
||||
setBusy(false);
|
||||
return;
|
||||
}
|
||||
return previewText(text);
|
||||
})
|
||||
.catch(() => {
|
||||
if (hostIdRef.current !== expectedHostId || requestRef.current !== requestId) return;
|
||||
setError("invalid");
|
||||
setBusy(false);
|
||||
});
|
||||
}}
|
||||
/>
|
||||
</div>
|
||||
{modalPreview && (
|
||||
<div className="space-y-1">
|
||||
{importedAccount(modalPreview) ? (
|
||||
<p className="text-sm text-foreground">
|
||||
{t("hostDetails.totp.importedAccount", { account: importedAccount(modalPreview) })}
|
||||
</p>
|
||||
) : null}
|
||||
<p className="text-sm text-muted-foreground">
|
||||
{t("hostDetails.totp.preview")}
|
||||
{" "}
|
||||
<span className="font-mono tracking-widest text-foreground">{modalPreview.code}</span>
|
||||
<span className="ml-2">{t("hostDetails.totp.countdown", { seconds: modalPreview.secondsRemaining })}</span>
|
||||
</p>
|
||||
</div>
|
||||
)}
|
||||
{error && <p className="text-xs text-destructive">{t(errorKey(error))}</p>}
|
||||
<div className="flex items-center justify-between gap-3">
|
||||
<div className="min-w-0">
|
||||
<p className="text-sm font-medium text-foreground">{t("hostDetails.totp.autoSubmit")}</p>
|
||||
<p className="text-xs text-muted-foreground">{t("hostDetails.totp.autoSubmitDesc")}</p>
|
||||
</div>
|
||||
<Switch
|
||||
checked={autoSubmit}
|
||||
onCheckedChange={(checked) => {
|
||||
setAutoSubmit(checked);
|
||||
autoSubmitRef.current = checked;
|
||||
}}
|
||||
aria-label={t("hostDetails.totp.autoSubmit")}
|
||||
/>
|
||||
</div>
|
||||
<div className="space-y-2">
|
||||
<Label htmlFor="host-totp-pattern">{t("hostDetails.totp.promptPattern")}</Label>
|
||||
<Input
|
||||
id="host-totp-pattern"
|
||||
value={pattern}
|
||||
onChange={(event) => {
|
||||
setPattern(event.target.value);
|
||||
patternRef.current = event.target.value;
|
||||
setError((current) => (current === "pattern" ? null : current));
|
||||
}}
|
||||
placeholder={t("hostDetails.totp.promptPatternPlaceholder")}
|
||||
aria-label={t("hostDetails.totp.promptPattern")}
|
||||
className="h-8 font-mono text-xs"
|
||||
spellCheck={false}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
<DialogFooter className="gap-2 sm:justify-between">
|
||||
{configured ? (
|
||||
<Button type="button" variant="ghost" onClick={() => setConfirmClear(true)}>
|
||||
{t("hostDetails.totp.clear")}
|
||||
</Button>
|
||||
) : <span />}
|
||||
<div className="flex gap-2">
|
||||
<Button type="button" variant="secondary" onClick={cancelSetup}>
|
||||
{t("common.cancel")}
|
||||
</Button>
|
||||
<Button type="button" onClick={() => { void commitSetup({ requireSecret: true }); }} disabled={busy}>
|
||||
{t("common.save")}
|
||||
</Button>
|
||||
</div>
|
||||
</DialogFooter>
|
||||
</DialogContent>
|
||||
</Dialog>
|
||||
{confirmClear ? (
|
||||
<SystemPanelConfirmDialog
|
||||
open
|
||||
destructive
|
||||
title={t("hostDetails.totp.removeTitle")}
|
||||
message={t("hostDetails.totp.removeMessage")}
|
||||
confirmLabel={t("hostDetails.totp.clear")}
|
||||
onOpenChange={(open) => { if (!open) setConfirmClear(false); }}
|
||||
onConfirm={() => {
|
||||
setConfirmClear(false);
|
||||
clearTotp();
|
||||
}}
|
||||
/>
|
||||
) : null}
|
||||
</>
|
||||
);
|
||||
};
|
||||
@@ -235,7 +235,6 @@ test("HostTreeView exposes selected hosts to keyboard and assistive technology",
|
||||
assert.match(markup, /data-host-id="host-1"[^>]*data-tree-depth="0"/);
|
||||
assert.match(markup, /data-host-id="host-1"[^>]*aria-level="1"/);
|
||||
assert.match(markup, /data-host-id="host-1"[^>]*tabindex="0"/);
|
||||
assert.match(markup, /data-host-id="host-1"[^>]*draggable="true"/);
|
||||
});
|
||||
|
||||
test("HostTreeView exposes only one tree item in the tab order", () => {
|
||||
|
||||
@@ -17,7 +17,6 @@ import {
|
||||
} from '../domain/hostClickBehavior';
|
||||
import { sortByVaultOrder } from '../domain/vaultOrder';
|
||||
import { getVaultHostRowSubtitle } from '../domain/localShellHost';
|
||||
import { isVaultHostDraggable } from '../domain/vaultHostDrag';
|
||||
import { STORAGE_KEY_VAULT_HOSTS_TREE_EXPANDED } from '../infrastructure/config/storageKeys';
|
||||
import { GroupConfig, GroupNode, Host } from '../types';
|
||||
import { Collapsible, CollapsibleContent, CollapsibleTrigger } from './ui/collapsible';
|
||||
@@ -26,8 +25,6 @@ import { ContextMenu, ContextMenuTrigger } from './ui/context-menu';
|
||||
import { DistroAvatar } from './DistroAvatar';
|
||||
import { HostNotesIndicator } from './host/HostNotesIndicator';
|
||||
import { Button } from './ui/button';
|
||||
import { handleVaultHostDropToGroup } from './vault/vaultReorderDrag';
|
||||
import { beginVaultHostCardDrag } from './vault/vaultHostDragImage';
|
||||
import { VaultTreeGroupRow, VaultTreeItemRow } from './vault/VaultTreeRow';
|
||||
|
||||
const getTreeGroupDropIntent = (
|
||||
@@ -296,7 +293,6 @@ interface HostTreeViewProps {
|
||||
onEditGroup: (groupPath: string) => void;
|
||||
onDeleteGroup: (groupPath: string) => void;
|
||||
moveHostToGroup: (hostId: string, groupPath: string | null) => void;
|
||||
moveHostsToGroup?: (hostIds: ReadonlySet<string>, groupPath: string | null) => void;
|
||||
moveGroup: (sourcePath: string, targetParent: string | null) => void;
|
||||
commitInlineGroupRename?: (name: string) => boolean | void | Promise<boolean | void>;
|
||||
cancelInlineGroupEdit?: () => void;
|
||||
@@ -338,7 +334,6 @@ interface TreeNodeProps {
|
||||
onEditGroup: (groupPath: string) => void;
|
||||
onDeleteGroup: (groupPath: string) => void;
|
||||
moveHostToGroup: (hostId: string, groupPath: string | null) => void;
|
||||
moveHostsToGroup?: (hostIds: ReadonlySet<string>, groupPath: string | null) => void;
|
||||
moveGroup: (sourcePath: string, targetParent: string | null) => void;
|
||||
commitInlineGroupRename?: (name: string) => boolean | void | Promise<boolean | void>;
|
||||
cancelInlineGroupEdit?: () => void;
|
||||
@@ -386,7 +381,6 @@ const TreeNode: React.FC<TreeNodeProps> = ({
|
||||
onEditGroup,
|
||||
onDeleteGroup,
|
||||
moveHostToGroup,
|
||||
moveHostsToGroup,
|
||||
moveGroup,
|
||||
managedGroupPaths,
|
||||
onUnmanageGroup,
|
||||
@@ -532,14 +526,9 @@ const TreeNode: React.FC<TreeNodeProps> = ({
|
||||
e.preventDefault();
|
||||
e.stopPropagation();
|
||||
setDragOverDropTarget?.(null);
|
||||
if (handleVaultHostDropToGroup({
|
||||
dataTransfer: e.dataTransfer,
|
||||
groupPath: node.path,
|
||||
moveHostToGroup,
|
||||
moveHostsToGroup,
|
||||
resetHostDragState: () => {},
|
||||
})) return;
|
||||
const hostId = e.dataTransfer.getData("host-id");
|
||||
const groupPath = e.dataTransfer.getData("group-path");
|
||||
if (hostId) moveHostToGroup(hostId, node.path);
|
||||
if (groupPath && getTreeGroupDropIntent(e.currentTarget, e.clientY) === "inside") {
|
||||
moveGroup(groupPath, node.path);
|
||||
}
|
||||
@@ -609,7 +598,6 @@ const TreeNode: React.FC<TreeNodeProps> = ({
|
||||
onEditGroup={onEditGroup}
|
||||
onDeleteGroup={onDeleteGroup}
|
||||
moveHostToGroup={moveHostToGroup}
|
||||
moveHostsToGroup={moveHostsToGroup}
|
||||
moveGroup={moveGroup}
|
||||
managedGroupPaths={managedGroupPaths}
|
||||
onUnmanageGroup={onUnmanageGroup}
|
||||
@@ -786,16 +774,11 @@ const HostTreeItem: React.FC<HostTreeItemProps> = ({
|
||||
? 0
|
||||
: -1}
|
||||
onFocus={() => onActiveTreeItemChange(`host:${host.id}`)}
|
||||
draggable={isVaultHostDraggable(Boolean(isMultiSelectMode), selectedHostIds, host.id)}
|
||||
draggable={!isMultiSelectMode}
|
||||
onDragStart={(e) => {
|
||||
const ids = beginVaultHostCardDrag({
|
||||
dataTransfer: e.dataTransfer,
|
||||
hostId: host.id,
|
||||
isMultiSelectMode: Boolean(isMultiSelectMode),
|
||||
selectedHostIds,
|
||||
countLabel: (count) => t("vault.hosts.dragSelectionCount", { count }),
|
||||
});
|
||||
if (!ids) e.preventDefault();
|
||||
// copyMove: vault reorder uses move; focus-sidebar append uses copy.
|
||||
e.dataTransfer.effectAllowed = "copyMove";
|
||||
e.dataTransfer.setData("host-id", host.id);
|
||||
}}
|
||||
onClick={() => {
|
||||
const action = resolveHostActivateAction({
|
||||
@@ -926,7 +909,6 @@ const HostTreeViewInner: React.FC<HostTreeViewProps> = ({
|
||||
onEditGroup,
|
||||
onDeleteGroup,
|
||||
moveHostToGroup,
|
||||
moveHostsToGroup,
|
||||
moveGroup,
|
||||
managedGroupPaths,
|
||||
onUnmanageGroup,
|
||||
@@ -1237,7 +1219,6 @@ const HostTreeViewInner: React.FC<HostTreeViewProps> = ({
|
||||
onEditGroup={onEditGroup}
|
||||
onDeleteGroup={onDeleteGroup}
|
||||
moveHostToGroup={moveHostToGroup}
|
||||
moveHostsToGroup={moveHostsToGroup}
|
||||
moveGroup={moveGroup}
|
||||
managedGroupPaths={managedGroupPaths}
|
||||
onUnmanageGroup={onUnmanageGroup}
|
||||
@@ -1330,7 +1311,6 @@ function hostTreeViewAreEqual(prev: HostTreeViewProps, next: HostTreeViewProps):
|
||||
&& prev.onEditGroup === next.onEditGroup
|
||||
&& prev.onDeleteGroup === next.onDeleteGroup
|
||||
&& prev.moveHostToGroup === next.moveHostToGroup
|
||||
&& prev.moveHostsToGroup === next.moveHostsToGroup
|
||||
&& prev.moveGroup === next.moveGroup
|
||||
&& prev.commitInlineGroupRename === next.commitInlineGroupRename
|
||||
&& prev.cancelInlineGroupEdit === next.cancelInlineGroupEdit
|
||||
|
||||
@@ -4,94 +4,48 @@
|
||||
import test from "node:test";
|
||||
import assert from "node:assert/strict";
|
||||
import { readFileSync } from "node:fs";
|
||||
import { createRequire } from "node:module";
|
||||
|
||||
const require = createRequire(import.meta.url);
|
||||
|
||||
import {
|
||||
keyboardInteractiveFieldLabel,
|
||||
keyboardInteractiveGuidance,
|
||||
totpFillPlacement,
|
||||
visibleTotpFillPlacements,
|
||||
} from "./KeyboardInteractiveModal.tsx";
|
||||
import { formatKeyboardInteractiveServerPrompt } from "./KeyboardInteractiveModal.tsx";
|
||||
|
||||
const modalSource = readFileSync(new URL("./KeyboardInteractiveModal.tsx", import.meta.url), "utf8");
|
||||
|
||||
test("keyboardInteractiveFieldLabel uses the cleaned last prompt line", () => {
|
||||
assert.equal(keyboardInteractiveFieldLabel("Secondary Authentication Password:", "Verification code"), "Secondary Authentication Password");
|
||||
assert.equal(keyboardInteractiveFieldLabel("JumpServer:\n| SMS code:", "Verification code"), "SMS code");
|
||||
assert.equal(keyboardInteractiveFieldLabel("[OTP Code]: ", "Verification code"), "OTP Code");
|
||||
assert.equal(keyboardInteractiveFieldLabel(" ", "Verification code"), "Verification code");
|
||||
test("formatKeyboardInteractiveServerPrompt preserves server instructions and prompt labels", () => {
|
||||
const text = formatKeyboardInteractiveServerPrompt({
|
||||
hostname: "192.168.9.138",
|
||||
name: "Keyboard-interactive authentication prompts from server",
|
||||
instructions: "为保障主机安全,请输入二次认证密码,如有疑问,请联系xxx,电话xxx。",
|
||||
prompts: [
|
||||
{
|
||||
prompt: "Secondary Authentication Password:",
|
||||
echo: false,
|
||||
},
|
||||
],
|
||||
});
|
||||
|
||||
assert.equal(
|
||||
text,
|
||||
[
|
||||
"Keyboard-interactive authentication prompts from server:",
|
||||
"| 为保障主机安全,请输入二次认证密码,如有疑问,请联系xxx,电话xxx。",
|
||||
"| Secondary Authentication Password:",
|
||||
].join("\n"),
|
||||
);
|
||||
});
|
||||
|
||||
test("keyboard-interactive dialog shows a field label and an inline TOTP action", async () => {
|
||||
assert.doesNotMatch(modalSource, /<pre/);
|
||||
assert.match(modalSource, /keyboardInteractiveFieldLabel\(/);
|
||||
assert.match(modalSource, /keyboardInteractiveGuidance\(/);
|
||||
assert.match(modalSource, /data-totp-fill=\{fillPlacement\}/);
|
||||
assert.match(modalSource, /variant="secondary"/);
|
||||
assert.doesNotMatch(modalSource, /variant=\{fillPlacement/);
|
||||
assert.match(modalSource, /totpPrompt\.shared\.mjs/);
|
||||
const shared = require("../domain/totpPrompt.shared.cjs") as { NON_TOTP_PROMPT: RegExp };
|
||||
const runtime = require("../domain/totp.shared.cjs") as { NON_TOTP_PROMPT: RegExp };
|
||||
const esm = await import("../domain/totpPrompt.shared.mjs");
|
||||
assert.equal(shared.NON_TOTP_PROMPT, runtime.NON_TOTP_PROMPT);
|
||||
assert.equal(esm.NON_TOTP_PROMPT.source, shared.NON_TOTP_PROMPT.source);
|
||||
assert.doesNotMatch(modalSource, /ring-primary/);
|
||||
assert.doesNotMatch(modalSource, /className="w-full"/);
|
||||
assert.doesNotMatch(modalSource, /onTypeTotp/);
|
||||
});
|
||||
|
||||
test("keyboardInteractiveGuidance keeps directions that are not already the field label", () => {
|
||||
assert.equal(keyboardInteractiveGuidance({
|
||||
name: "bastion",
|
||||
hostname: "bastion",
|
||||
test("formatKeyboardInteractiveServerPrompt omits hostname-only fallback prompts", () => {
|
||||
const text = formatKeyboardInteractiveServerPrompt({
|
||||
hostname: "192.168.9.138",
|
||||
name: "192.168.9.138",
|
||||
instructions: "",
|
||||
prompts: [{ prompt: "Verification code:", echo: false }],
|
||||
}), "");
|
||||
assert.equal(keyboardInteractiveGuidance({
|
||||
name: "Duo Security",
|
||||
hostname: "vpn.example",
|
||||
instructions: "1. Duo Push\n2. SMS\nPasscode or option (1-3):",
|
||||
prompts: [{ prompt: "Passcode or option (1-3):", echo: true }],
|
||||
}), "Duo Security\n1. Duo Push\n2. SMS");
|
||||
assert.equal(keyboardInteractiveGuidance({
|
||||
name: "host",
|
||||
hostname: "other",
|
||||
instructions: "| Verification code:",
|
||||
prompts: [{ prompt: "Verification code:", echo: false }],
|
||||
}), "host");
|
||||
assert.equal(keyboardInteractiveGuidance({
|
||||
name: "bastion",
|
||||
hostname: "bastion",
|
||||
instructions: "",
|
||||
prompts: [{ prompt: "1. OTP\n2. SMS\nOption> ", echo: true }],
|
||||
}), "1. OTP\n2. SMS");
|
||||
assert.equal(keyboardInteractiveGuidance({
|
||||
name: "bastion",
|
||||
hostname: "bastion",
|
||||
instructions: "1. OTP",
|
||||
prompts: [{ prompt: "1. OTP\nOption>", echo: true }],
|
||||
}), "1. OTP");
|
||||
});
|
||||
prompts: [
|
||||
{
|
||||
prompt: "Password:",
|
||||
echo: false,
|
||||
},
|
||||
],
|
||||
});
|
||||
|
||||
test("TOTP fill stays off password prompts and prefers verification prompts", () => {
|
||||
assert.equal(totpFillPlacement({ prompt: "Password:", echo: false }), "hidden");
|
||||
assert.equal(totpFillPlacement({ prompt: "Verification code:", echo: false }), "preferred");
|
||||
assert.equal(totpFillPlacement({ prompt: "[OTP Code]:", echo: false }), "preferred");
|
||||
assert.equal(totpFillPlacement({ prompt: "One-time password:", echo: false }), "preferred");
|
||||
assert.equal(totpFillPlacement({ prompt: "SMS code:", echo: false }), "hidden");
|
||||
assert.equal(totpFillPlacement({ prompt: "1. OTP\n2. SMS\nOption> ", echo: true }), "hidden");
|
||||
assert.equal(totpFillPlacement({ prompt: "[Y/n]", echo: true }), "hidden");
|
||||
assert.equal(totpFillPlacement({ prompt: "1. OTP\nVerification code:", echo: false }), "preferred");
|
||||
assert.equal(totpFillPlacement({ prompt: "Response:", echo: true }), "available");
|
||||
assert.deepEqual(visibleTotpFillPlacements([
|
||||
{ prompt: "Password:", echo: false },
|
||||
{ prompt: "Verification code:", echo: false },
|
||||
]), ["hidden", "preferred"]);
|
||||
assert.deepEqual(visibleTotpFillPlacements([
|
||||
{ prompt: "Password:", echo: false },
|
||||
]), ["hidden"]);
|
||||
assert.equal(text, "");
|
||||
});
|
||||
|
||||
test("keyboard-interactive modal cannot be dismissed by outside click or Escape", () => {
|
||||
|
||||
@@ -5,7 +5,6 @@
|
||||
*/
|
||||
import { Eye, EyeOff, KeyRound, Loader2 } from "lucide-react";
|
||||
import React, { useCallback, useEffect, useMemo, useState } from "react";
|
||||
import { NON_TOTP_PROMPT } from "../domain/totpPrompt.shared.mjs";
|
||||
import { useI18n } from "../application/i18n/I18nProvider";
|
||||
import { Button } from "./ui/button";
|
||||
import {
|
||||
@@ -35,54 +34,35 @@ export interface KeyboardInteractiveRequest {
|
||||
savedPassword?: string | null;
|
||||
/** When false, hide save-password UI (second-factor / EDR challenges). Default true. */
|
||||
allowSavePassword?: boolean;
|
||||
/** otpauth URI from the connection attempt that opened this dialog. */
|
||||
totpUri?: string;
|
||||
}
|
||||
|
||||
/** Last meaningful line of a server prompt, without the raw banner. */
|
||||
export function keyboardInteractiveFieldLabel(prompt: string | undefined, fallback: string): string {
|
||||
const lines = String(prompt || "").split(/\r?\n/).map((line) => line.trim()).filter(Boolean);
|
||||
const last = lines.at(-1) || "";
|
||||
const cleaned = last
|
||||
.replace(/^[\s||>•*-]+/, "")
|
||||
.replace(/[::]\s*$/, "")
|
||||
.trim()
|
||||
.replace(/^\[(.+)\]$/, "$1")
|
||||
.trim();
|
||||
return cleaned || fallback;
|
||||
}
|
||||
type KeyboardInteractiveServerPromptInput = Pick<
|
||||
KeyboardInteractiveRequest,
|
||||
"name" | "instructions" | "prompts" | "hostname"
|
||||
>;
|
||||
|
||||
function guidanceKeys(text: string): string[] {
|
||||
const trimmed = text.trim().toLowerCase();
|
||||
const label = keyboardInteractiveFieldLabel(text, "").toLowerCase();
|
||||
return [trimmed, label].filter(Boolean);
|
||||
}
|
||||
|
||||
/** Server directions that are not already the field label. Plain text, not a prompt dump. */
|
||||
export function keyboardInteractiveGuidance(request: Pick<KeyboardInteractiveRequest, "name" | "instructions" | "prompts" | "hostname">): string {
|
||||
const labelKeys = new Set<string>();
|
||||
const promptLines: string[] = [];
|
||||
for (const prompt of request.prompts || []) {
|
||||
const parts = String(prompt?.prompt || "").split(/\r?\n/).map((line) => line.trim()).filter(Boolean);
|
||||
const last = parts.at(-1) || "";
|
||||
for (const key of guidanceKeys(last)) labelKeys.add(key);
|
||||
promptLines.push(...parts.slice(0, -1));
|
||||
}
|
||||
const seen = new Set<string>();
|
||||
/** Formats the server-supplied keyboard-interactive prompt block for display. */
|
||||
export function formatKeyboardInteractiveServerPrompt(request: KeyboardInteractiveServerPromptInput): string {
|
||||
const lines: string[] = [];
|
||||
const push = (text: string) => {
|
||||
const line = text.trim().replace(/^\|\s*/, "");
|
||||
if (!line) return;
|
||||
const keys = guidanceKeys(line);
|
||||
if (keys.some((key) => labelKeys.has(key) || seen.has(key))) return;
|
||||
for (const key of keys) seen.add(key);
|
||||
lines.push(line);
|
||||
};
|
||||
const hostname = (request.hostname || "").trim().toLowerCase();
|
||||
const name = (request.name || "").trim();
|
||||
if (name && name.toLowerCase() !== hostname) push(name);
|
||||
for (const part of String(request.instructions || "").split(/\r?\n/)) push(part);
|
||||
for (const part of promptLines) push(part);
|
||||
const name = request.name?.trim();
|
||||
const hostname = request.hostname?.trim();
|
||||
const instructions = request.instructions?.trim();
|
||||
const hasServerText = !!instructions || !!(name && name !== hostname);
|
||||
if (!hasServerText) return "";
|
||||
if (name && name !== hostname) {
|
||||
lines.push(name.endsWith(":") ? name : `${name}:`);
|
||||
}
|
||||
if (instructions) {
|
||||
for (const line of instructions.split(/\r?\n/).map((part) => part.trim()).filter(Boolean)) {
|
||||
lines.push(`| ${line}`);
|
||||
}
|
||||
}
|
||||
for (const prompt of request.prompts || []) {
|
||||
const promptText = prompt.prompt?.trim();
|
||||
if (promptText) {
|
||||
lines.push(`| ${promptText}`);
|
||||
}
|
||||
}
|
||||
return lines.join("\n");
|
||||
}
|
||||
|
||||
@@ -127,43 +107,12 @@ interface KeyboardInteractiveModalProps {
|
||||
request: KeyboardInteractiveRequest | null;
|
||||
onSubmit: (requestId: string, responses: string[], savePassword?: string) => boolean | Promise<boolean>;
|
||||
onCancel: (requestId: string) => boolean | Promise<boolean>;
|
||||
canFillTotp?: boolean;
|
||||
onFillTotp?: () => Promise<string | null> | string | null;
|
||||
}
|
||||
|
||||
const OTP_PROMPT = /otp|one[-\s]?time|totp|verification|passcode|authenticator|\bmfa\b|\b2fa\b|two[-\s]?factor|验证码|驗證碼|动态口令|動態口令|二次认证|二次認證|二次验证|二次驗證/i;
|
||||
|
||||
export type TotpFillPlacement = "preferred" | "available" | "hidden";
|
||||
|
||||
function lastPromptLine(prompt: string | undefined): string {
|
||||
const lines = String(prompt || "").split(/\r?\n/).map((line) => line.trim()).filter(Boolean);
|
||||
return lines.at(-1) || "";
|
||||
}
|
||||
|
||||
/** Where a TOTP fill action may appear. Password prompts never receive the code. */
|
||||
export function totpFillPlacement(prompt: KeyboardInteractivePrompt): TotpFillPlacement {
|
||||
const rawLast = lastPromptLine(prompt.prompt);
|
||||
const label = keyboardInteractiveFieldLabel(prompt.prompt, "");
|
||||
if (!label || NON_TOTP_PROMPT.test(rawLast) || NON_TOTP_PROMPT.test(label)) return "hidden";
|
||||
if (OTP_PROMPT.test(label)) return "preferred";
|
||||
if (isAPasswordPrompt({ prompt: label, echo: prompt.echo })) return "hidden";
|
||||
if (/\b(password|passwd|passphrase)\b/i.test(label)) return "hidden";
|
||||
return "available";
|
||||
}
|
||||
|
||||
export function visibleTotpFillPlacements(prompts: KeyboardInteractivePrompt[]): TotpFillPlacement[] {
|
||||
const placements = prompts.map(totpFillPlacement);
|
||||
const hasPreferred = placements.some((placement) => placement === "preferred");
|
||||
if (!hasPreferred) return placements;
|
||||
return placements.map((placement) => (placement === "preferred" ? placement : "hidden"));
|
||||
}
|
||||
|
||||
export const KeyboardInteractiveModal: React.FC<KeyboardInteractiveModalProps> = ({
|
||||
request,
|
||||
onSubmit,
|
||||
onCancel,
|
||||
canFillTotp = false,
|
||||
onFillTotp,
|
||||
}) => {
|
||||
const { t } = useI18n();
|
||||
const [responses, setResponses] = useState<string[]>([]);
|
||||
@@ -236,15 +185,6 @@ export const KeyboardInteractiveModal: React.FC<KeyboardInteractiveModalProps> =
|
||||
}
|
||||
}, [request, onCancel, isSubmitting]);
|
||||
|
||||
const handleFillTotp = useCallback(async (index: number) => {
|
||||
if (!request || isSubmitting || !onFillTotp) return;
|
||||
const prompt = request.prompts[index];
|
||||
if (!prompt || totpFillPlacement(prompt) === "hidden") return;
|
||||
const code = await onFillTotp();
|
||||
if (!code) return;
|
||||
handleResponseChange(index, code);
|
||||
}, [request, isSubmitting, onFillTotp, handleResponseChange]);
|
||||
|
||||
const handleKeyDown = useCallback(
|
||||
(e: React.KeyboardEvent) => {
|
||||
if (e.key === "Enter" && !isSubmitting) {
|
||||
@@ -257,14 +197,12 @@ export const KeyboardInteractiveModal: React.FC<KeyboardInteractiveModalProps> =
|
||||
|
||||
if (!request) return null;
|
||||
|
||||
const serverPromptText = formatKeyboardInteractiveServerPrompt(request);
|
||||
const title = t("keyboard.interactive.title");
|
||||
const description =
|
||||
request.hostname
|
||||
? t("keyboard.interactive.descWithHost", { hostname: request.hostname })
|
||||
: t("keyboard.interactive.desc");
|
||||
const fillPlacements = canFillTotp ? visibleTotpFillPlacements(request.prompts) : [];
|
||||
const preferredFillIndex = fillPlacements.findIndex((placement) => placement === "preferred");
|
||||
const guidance = keyboardInteractiveGuidance(request);
|
||||
|
||||
return (
|
||||
<Dialog open={!!request} onOpenChange={() => {/* intentionally non-dismissable */}}>
|
||||
@@ -289,40 +227,26 @@ export const KeyboardInteractiveModal: React.FC<KeyboardInteractiveModalProps> =
|
||||
</DialogHeader>
|
||||
|
||||
<div className="space-y-4 py-2">
|
||||
{guidance ? (
|
||||
<p className="max-h-36 overflow-auto whitespace-pre-wrap text-sm leading-relaxed text-muted-foreground [overflow-wrap:anywhere]">
|
||||
{guidance}
|
||||
</p>
|
||||
) : null}
|
||||
{serverPromptText && (
|
||||
<pre className="max-h-36 overflow-auto rounded-md border border-border/60 bg-muted/45 px-3 py-2 text-xs leading-relaxed text-foreground whitespace-pre-wrap [overflow-wrap:anywhere]">
|
||||
{serverPromptText}
|
||||
</pre>
|
||||
)}
|
||||
|
||||
{request.prompts.map((prompt, index) => {
|
||||
const isPassword = !prompt.echo;
|
||||
const showPassword = showPasswords[index];
|
||||
const promptLabel = keyboardInteractiveFieldLabel(
|
||||
prompt.prompt,
|
||||
t("keyboard.interactive.verificationCode"),
|
||||
);
|
||||
const fillPlacement = fillPlacements[index] ?? "hidden";
|
||||
const showPromptLabel = !(serverPromptText && request.prompts.length === 1);
|
||||
// Clean up prompt text (remove trailing colon and whitespace)
|
||||
const promptLabel = prompt.prompt.replace(/:\s*$/, "").trim();
|
||||
|
||||
return (
|
||||
<div key={index} className="space-y-2">
|
||||
<div className="flex items-center justify-between gap-2">
|
||||
{showPromptLabel && (
|
||||
<Label htmlFor={`ki-prompt-${index}`}>
|
||||
{promptLabel}
|
||||
{promptLabel || t("keyboard.interactive.response")}
|
||||
</Label>
|
||||
{fillPlacement !== "hidden" && (
|
||||
<Button
|
||||
type="button"
|
||||
variant="secondary"
|
||||
size="sm"
|
||||
className="h-7 shrink-0 px-2 font-normal focus:bg-secondary focus:text-secondary-foreground focus-visible:bg-secondary focus-visible:text-secondary-foreground"
|
||||
data-totp-fill={fillPlacement}
|
||||
onClick={() => { void handleFillTotp(index); }}
|
||||
disabled={isSubmitting}
|
||||
>
|
||||
{t("keyboard.interactive.fillTotp")}
|
||||
</Button>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
<div className="relative">
|
||||
<Input
|
||||
id={`ki-prompt-${index}`}
|
||||
@@ -332,7 +256,7 @@ export const KeyboardInteractiveModal: React.FC<KeyboardInteractiveModalProps> =
|
||||
onKeyDown={handleKeyDown}
|
||||
placeholder=""
|
||||
className={isPassword ? "pr-10" : undefined}
|
||||
autoFocus={preferredFillIndex >= 0 ? index === preferredFillIndex : index === 0}
|
||||
autoFocus={index === 0}
|
||||
disabled={isSubmitting}
|
||||
/>
|
||||
{isPassword && (
|
||||
@@ -364,7 +288,7 @@ export const KeyboardInteractiveModal: React.FC<KeyboardInteractiveModalProps> =
|
||||
</div>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="flex items-center justify-between pt-2">
|
||||
<Button
|
||||
|
||||
@@ -4,7 +4,6 @@ import { applyGroupDefaults, resolveGroupDefaults } from "../domain/groupConfig"
|
||||
import { sanitizeCredentialValue } from "../domain/credentials";
|
||||
import { hasBridgeSshCredentials, resolveBridgeKeyAuth, resolveBridgeSshAgentAuth, resolveHostAuth } from "../domain/sshAuth";
|
||||
import { resolveHostSshConnectionTimeouts } from "../domain/sshConnectionTimeouts";
|
||||
import { connectionTotpFields } from "../domain/totpConfig";
|
||||
import { cn } from "../lib/utils";
|
||||
import { Button } from "./ui/button";
|
||||
import { Collapsible, CollapsibleContent, CollapsibleTrigger } from "./ui/collapsible";
|
||||
@@ -259,7 +258,6 @@ export const KeychainExportPanel: React.FC<KeychainExportPanelProps> = ({
|
||||
sshAuthReadyTimeoutMs: connectionTimeouts.authReadyTimeoutSeconds * 1000,
|
||||
enableKeyboardInteractive: true,
|
||||
requiresMfa: !!effectiveExportHost.requiresMfa,
|
||||
...connectionTotpFields(effectiveExportHost),
|
||||
sessionId: `export-key:${effectiveExportHost.id}:${panel.key.id}`,
|
||||
});
|
||||
|
||||
|
||||
@@ -8,7 +8,6 @@ import {
|
||||
Search,
|
||||
Terminal,
|
||||
TerminalSquare,
|
||||
Zap,
|
||||
} from "lucide-react";
|
||||
import React, { memo, useCallback, useEffect, useMemo, useRef, useState } from "react";
|
||||
import { useI18n } from "../application/i18n/I18nProvider";
|
||||
@@ -610,8 +609,8 @@ const QuickSwitcherInner: React.FC<QuickSwitcherProps> = ({
|
||||
className={rowClass}
|
||||
onClick={() => handleItemSelect(item)}
|
||||
>
|
||||
<div className="shrink-0 flex items-center justify-center bg-primary text-primary-foreground h-5 w-5 rounded-[5px]">
|
||||
<Zap className="h-3 w-3" fill="currentColor" />
|
||||
<div className="flex h-6 w-6 shrink-0 items-center justify-center rounded text-muted-foreground">
|
||||
<Terminal size={16} />
|
||||
</div>
|
||||
<span className="min-w-0 truncate text-sm font-medium">{item.title}</span>
|
||||
</div>
|
||||
|
||||
+7
-15
@@ -59,7 +59,7 @@ import { MULTILINE_PASTE_CONFIRM_MIN_LINES_DEFAULT } from "../domain/terminalPas
|
||||
import { isPluginHostProtocol } from "../domain/pluginConnection";
|
||||
import { clearTerminalBootEpoch, setTerminalBootEpoch } from "../domain/terminalBootEpoch";
|
||||
import {
|
||||
foldTerminalPromptSecurityOutput,
|
||||
appendTerminalPromptSecurityTail,
|
||||
isConfirmedTerminalShellPrompt,
|
||||
isUntrustedTerminalInputPrompt,
|
||||
} from "../domain/terminalPromptSecurity";
|
||||
@@ -452,8 +452,6 @@ const TerminalComponent: React.FC<TerminalProps> = ({
|
||||
), [sessionId]);
|
||||
useEffect(() => () => clearTerminalBroadcastUserInput(sessionId), [sessionId]);
|
||||
const sensitivePromptOutputTailRef = useRef("");
|
||||
const heredocPromptOriginRef = useRef("");
|
||||
const promptSecurityPendingCrRef = useRef(false);
|
||||
const [activeScriptRun, setActiveScriptRun] = useState<import('@/types/global/netcatty-bridge-script.d.ts').ScriptRun | undefined>(undefined);
|
||||
const dismissedScriptRunIdsRef = useRef(new Set<string>());
|
||||
|
||||
@@ -2013,14 +2011,10 @@ const TerminalComponent: React.FC<TerminalProps> = ({
|
||||
chunk: string,
|
||||
meta?: TerminalSessionDataMeta,
|
||||
) => {
|
||||
const folded = foldTerminalPromptSecurityOutput({
|
||||
origin: heredocPromptOriginRef.current,
|
||||
current: sensitivePromptOutputTailRef.current,
|
||||
pendingCr: promptSecurityPendingCrRef.current,
|
||||
}, chunk);
|
||||
heredocPromptOriginRef.current = folded.origin;
|
||||
sensitivePromptOutputTailRef.current = folded.current;
|
||||
promptSecurityPendingCrRef.current = folded.pendingCr;
|
||||
sensitivePromptOutputTailRef.current = appendTerminalPromptSecurityTail(
|
||||
sensitivePromptOutputTailRef.current,
|
||||
chunk,
|
||||
);
|
||||
const promptSecurityOptions = {
|
||||
allowHostStyleGreaterThan: isNetworkDevice,
|
||||
alternateScreen: termRef.current
|
||||
@@ -2036,19 +2030,17 @@ const TerminalComponent: React.FC<TerminalProps> = ({
|
||||
autocompleteCloseRef.current?.();
|
||||
} else {
|
||||
sensitivePromptOutputTailRef.current = "";
|
||||
heredocPromptOriginRef.current = "";
|
||||
promptSecurityPendingCrRef.current = false;
|
||||
}
|
||||
return;
|
||||
} else if (isUntrustedTerminalInputPrompt(
|
||||
folded.promptText,
|
||||
sensitivePromptOutputTailRef.current,
|
||||
promptSecurityOptions,
|
||||
)) {
|
||||
passwordPromptActiveRef.current = true;
|
||||
passwordPromptActiveRef.source = "heuristic";
|
||||
autocompleteCloseRef.current?.();
|
||||
} else if (isConfirmedTerminalShellPrompt(
|
||||
folded.promptText,
|
||||
sensitivePromptOutputTailRef.current,
|
||||
promptSecurityOptions,
|
||||
)) {
|
||||
passwordPromptActiveRef.current = false;
|
||||
|
||||
@@ -47,30 +47,7 @@ test("plugin importer merge and commit workflow is owned by application state",
|
||||
assert.match(pluginImporterCommitHookSource, /buildPluginImporterSafePreview/);
|
||||
});
|
||||
|
||||
test("host list section receives marquee selection and batched group move", () => {
|
||||
const sectionStart = vaultViewLayoutSource.indexOf("<VaultHostListSection");
|
||||
const sectionEnd = vaultViewLayoutSource.indexOf("/>", sectionStart);
|
||||
const section = vaultViewLayoutSource.slice(sectionStart, sectionEnd);
|
||||
assert.match(section, /\bmoveHostsToGroup,/);
|
||||
assert.match(section, /\bsetIsMultiSelectMode,/);
|
||||
assert.match(section, /\bsetSelectedHostIds,/);
|
||||
});
|
||||
|
||||
test("Vault import entry is disabled while an import is running", () => {
|
||||
assert.match(vaultViewLayoutSource, /disabled=\{importProgress\?\.status === "running"\}/);
|
||||
assert.match(vaultViewLayoutSource, /if \(importProgress\?\.status === "running"\) return;/);
|
||||
});
|
||||
|
||||
test("new host menu keeps the local shell icon at the same size as its siblings", () => {
|
||||
const menuStart = vaultViewLayoutSource.indexOf('<DropdownContent className="flex w-max min-w-44 flex-col" align="end" alignToParent>');
|
||||
assert.ok(menuStart >= 0);
|
||||
const menuEnd = vaultViewLayoutSource.indexOf("</DropdownContent>", menuStart);
|
||||
const menu = vaultViewLayoutSource.slice(menuStart, menuEnd);
|
||||
assert.match(menu, /className="flex w-max min-w-44 flex-col"/);
|
||||
assert.doesNotMatch(menu, /className="w-44"/);
|
||||
assert.match(menu, /<TerminalSquare size=\{14\} className="shrink-0" \/>/);
|
||||
assert.match(menu, /<FolderTree size=\{14\} className="shrink-0" \/>/);
|
||||
assert.match(menu, /<Upload size=\{14\} className="shrink-0" \/>/);
|
||||
assert.match(menu, /<Download size=\{14\} className="shrink-0" \/>/);
|
||||
assert.doesNotMatch(menu, /className="w-full justify-start gap-2"/);
|
||||
});
|
||||
@@ -51,7 +51,6 @@ import { CopilotCliCard } from "./ai/CopilotCliCard";
|
||||
import { CodebuddyCard } from "./ai/CodebuddyCard";
|
||||
import { SafetySettings } from "./ai/SafetySettings";
|
||||
import { ExternalMcpCard } from "./ai/ExternalMcpCard";
|
||||
import { AIPrivacySection } from "./ai/AIPrivacySection";
|
||||
import { ToolAccessGuidance } from "./ai/ToolAccessGuidance";
|
||||
import { PermissionGrantsSettings } from "./ai/PermissionGrantsSettings";
|
||||
import { useAIPermissionGrantsState } from "../../../application/state/useAIPermissionGrantsState";
|
||||
@@ -1298,8 +1297,6 @@ const SettingsAITab: React.FC<SettingsAITabProps> = ({
|
||||
</SettingCard>
|
||||
</SettingsSection>
|
||||
|
||||
<AIPrivacySection />
|
||||
|
||||
<SettingsSection anchorId="ai-external-mcp" title={t('ai.externalMcp.title')}>
|
||||
<ExternalMcpCard />
|
||||
</SettingsSection>
|
||||
|
||||
@@ -1,183 +0,0 @@
|
||||
import assert from 'node:assert/strict';
|
||||
import { readFile } from 'node:fs/promises';
|
||||
import test from 'node:test';
|
||||
import React from 'react';
|
||||
import { act, create, type ReactTestRenderer } from 'react-test-renderer';
|
||||
import { I18nProvider } from '../../../../application/i18n/I18nProvider';
|
||||
import { STORAGE_KEY_AI_PRIVACY } from '../../../../infrastructure/config/storageKeys';
|
||||
import { AIPrivacySection } from './AIPrivacySection';
|
||||
|
||||
function installEnvironment(setConfig: (config: unknown) => Promise<unknown>) {
|
||||
const values = new Map<string, string>();
|
||||
const events = new EventTarget();
|
||||
const globals = {
|
||||
IS_REACT_ACT_ENVIRONMENT: true,
|
||||
localStorage: {
|
||||
getItem: (key: string) => values.get(key) ?? null,
|
||||
setItem: (key: string, value: string) => values.set(key, value),
|
||||
},
|
||||
window: {
|
||||
netcatty: { externalMcpSetConfig: setConfig },
|
||||
addEventListener: events.addEventListener.bind(events),
|
||||
removeEventListener: events.removeEventListener.bind(events),
|
||||
dispatchEvent: events.dispatchEvent.bind(events),
|
||||
},
|
||||
};
|
||||
const previous = new Map<string, PropertyDescriptor | undefined>();
|
||||
for (const [name, value] of Object.entries(globals)) {
|
||||
previous.set(name, Object.getOwnPropertyDescriptor(globalThis, name));
|
||||
Object.defineProperty(globalThis, name, { value, configurable: true, writable: true });
|
||||
}
|
||||
return {
|
||||
values,
|
||||
restore: () => {
|
||||
for (const [name, descriptor] of previous) {
|
||||
if (descriptor) Object.defineProperty(globalThis, name, descriptor);
|
||||
else Reflect.deleteProperty(globalThis, name);
|
||||
}
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
function view() {
|
||||
return <I18nProvider locale="en"><AIPrivacySection /></I18nProvider>;
|
||||
}
|
||||
|
||||
test('privacy settings default off and explain their limits and connection scope', async () => {
|
||||
const env = installEnvironment(async () => ({ ok: true }));
|
||||
let renderer: ReactTestRenderer | undefined;
|
||||
try {
|
||||
await act(async () => { renderer = create(view()); });
|
||||
const markup = JSON.stringify(renderer!.toJSON());
|
||||
assert.equal(renderer!.root.findAllByType('button').filter((node) => node.props['aria-checked'] === false).length, 2);
|
||||
assert.match(markup, /cannot detect every secret/);
|
||||
assert.match(markup, /AI privacy/);
|
||||
assert.match(markup, /Catty, integrated agents and external MCP clients/);
|
||||
assert.match(markup, /Only Netcatty-managed data and tools are covered/);
|
||||
assert.match(markup, /native tools, direct file access and other MCP servers/);
|
||||
assert.match(markup, /Binary tool responses are blocked/);
|
||||
assert.match(markup, /one managed conversation or external MCP connection/);
|
||||
assert.match(markup, /reconnect external MCP clients/);
|
||||
assert.match(markup, /start a new managed conversation/);
|
||||
assert.match(markup, /Additional IP ranges/);
|
||||
assert.match(markup, /Custom regular expressions/);
|
||||
} finally {
|
||||
if (renderer) await act(async () => { renderer!.unmount(); });
|
||||
env.restore();
|
||||
}
|
||||
});
|
||||
|
||||
test('AI privacy is shared Tool Access UI, not nested under External MCP', async () => {
|
||||
const settingsTab = await readFile(new URL('../SettingsAITab.tsx', import.meta.url), 'utf8');
|
||||
const externalCard = await readFile(new URL('./ExternalMcpCard.tsx', import.meta.url), 'utf8');
|
||||
const toolsTab = settingsTab.split('<TabsContent value="tools"')[1].split('</TabsContent>')[0];
|
||||
assert.match(toolsTab, /<\/SettingsSection>\s*<AIPrivacySection \/>\s*<SettingsSection anchorId="ai-external-mcp"/);
|
||||
assert.doesNotMatch(externalCard, /AIPrivacySection|ExternalMcpRedactionSection/);
|
||||
});
|
||||
|
||||
test('privacy drafts apply together, retain rejected input and persist only validated settings', async () => {
|
||||
const calls: unknown[] = [];
|
||||
let accepted = false;
|
||||
const env = installEnvironment(async (config) => {
|
||||
calls.push(config);
|
||||
return accepted ? { ok: true } : { ok: false, error: 'Invalid privacy rules.' };
|
||||
});
|
||||
let renderer: ReactTestRenderer | undefined;
|
||||
try {
|
||||
await act(async () => { renderer = create(view()); });
|
||||
const root = renderer!.root;
|
||||
const privacySwitch = () => root.findAllByType('button').find((node) => (
|
||||
node.props['aria-label'] === 'Enable text redaction'
|
||||
))!;
|
||||
const apply = () => root.findAllByType('button').find((node) => node.props.children === 'Apply')!;
|
||||
await act(async () => { privacySwitch().props.onClick(); });
|
||||
await act(async () => {
|
||||
root.findAllByType('textarea')[0].props.onChange({ currentTarget: { value: '192.0.2.0/24\n\n2001:db8::/32' } });
|
||||
root.findAllByType('textarea')[1].props.onChange({ currentTarget: { value: '[' } });
|
||||
});
|
||||
assert.equal(calls.length, 0, 'draft edits must not change runtime or storage');
|
||||
assert.equal(env.values.get(STORAGE_KEY_AI_PRIVACY), undefined);
|
||||
await act(async () => { apply().props.onClick(); });
|
||||
assert.equal(root.findAllByType('textarea')[1].props.value, '[');
|
||||
assert.match(root.findByProps({ role: 'alert' }).children.join(''), /Invalid privacy rules/);
|
||||
assert.equal(env.values.get(STORAGE_KEY_AI_PRIVACY), undefined);
|
||||
|
||||
accepted = true;
|
||||
await act(async () => {
|
||||
root.findAllByType('textarea')[1].props.onChange({ currentTarget: { value: 'INTERNAL_[A-Z0-9]+' } });
|
||||
});
|
||||
await act(async () => { apply().props.onClick(); });
|
||||
const saved = JSON.parse(env.values.get(STORAGE_KEY_AI_PRIVACY)!);
|
||||
assert.deepEqual(saved, {
|
||||
enabled: true,
|
||||
redactPrivateIps: false,
|
||||
cidrs: ['192.0.2.0/24', '2001:db8::/32'],
|
||||
patterns: ['INTERNAL_[A-Z0-9]+'],
|
||||
});
|
||||
assert.deepEqual(calls[1], { redaction: saved });
|
||||
assert.equal(root.findAllByProps({ role: 'alert' }).length, 0);
|
||||
assert.match(root.findByProps({ role: 'status' }).children.join(''), /new managed conversation/);
|
||||
} finally {
|
||||
if (renderer) await act(async () => { renderer!.unmount(); });
|
||||
env.restore();
|
||||
}
|
||||
});
|
||||
|
||||
test('malformed saved privacy settings remain visible as a recoverable error', async () => {
|
||||
const env = installEnvironment(async () => ({ ok: true }));
|
||||
let renderer: ReactTestRenderer | undefined;
|
||||
try {
|
||||
env.values.set(STORAGE_KEY_AI_PRIVACY, '{');
|
||||
await act(async () => { renderer = create(view()); });
|
||||
assert.match(renderer!.root.findByProps({ role: 'alert' }).children.join(''), /Invalid saved AI privacy settings/);
|
||||
assert.ok(renderer!.root.findAllByType('button').some((node) => node.props.children === 'Apply'));
|
||||
assert.equal(env.values.get(STORAGE_KEY_AI_PRIVACY), '{');
|
||||
} finally {
|
||||
if (renderer) await act(async () => { renderer!.unmount(); });
|
||||
env.restore();
|
||||
}
|
||||
});
|
||||
|
||||
test('privacy apply disables draft controls until shared validation settles', async () => {
|
||||
let resolveConfig!: (result: unknown) => void;
|
||||
const env = installEnvironment(() => new Promise((resolve) => { resolveConfig = resolve; }));
|
||||
let renderer: ReactTestRenderer | undefined;
|
||||
try {
|
||||
await act(async () => { renderer = create(view()); });
|
||||
const root = renderer!.root;
|
||||
await act(async () => {
|
||||
root.findAllByType('button').find((node) => node.props['aria-label'] === 'Enable text redaction')!.props.onClick();
|
||||
});
|
||||
await act(async () => {
|
||||
root.findAllByType('button').find((node) => node.props.children === 'Apply')!.props.onClick();
|
||||
});
|
||||
assert.ok(root.findAllByType('button').every((node) => node.props.disabled));
|
||||
assert.ok(root.findAllByType('textarea').every((node) => node.props.disabled));
|
||||
assert.equal(env.values.get(STORAGE_KEY_AI_PRIVACY), undefined);
|
||||
await act(async () => { resolveConfig({ ok: true }); });
|
||||
assert.ok(root.findAllByType('button').every((node) => !node.props.disabled));
|
||||
assert.equal(JSON.parse(env.values.get(STORAGE_KEY_AI_PRIVACY)!).enabled, true);
|
||||
} finally {
|
||||
if (renderer) await act(async () => { renderer!.unmount(); });
|
||||
env.restore();
|
||||
}
|
||||
});
|
||||
|
||||
test('shared privacy controls refresh when another window changes the shared preference', async () => {
|
||||
const env = installEnvironment(async () => ({ ok: true }));
|
||||
let renderer: ReactTestRenderer | undefined;
|
||||
try {
|
||||
await act(async () => { renderer = create(view()); });
|
||||
const settings = { enabled: true, redactPrivateIps: true, cidrs: ['192.0.2.0/24'], patterns: ['INTERNAL_.*'] };
|
||||
env.values.set(STORAGE_KEY_AI_PRIVACY, JSON.stringify(settings));
|
||||
const event = new Event('storage');
|
||||
Object.defineProperty(event, 'key', { value: STORAGE_KEY_AI_PRIVACY });
|
||||
await act(async () => { window.dispatchEvent(event); });
|
||||
assert.equal(renderer!.root.findAllByType('button').filter((node) => node.props['aria-checked'] === true).length, 2);
|
||||
assert.equal(renderer!.root.findAllByType('textarea')[0].props.value, '192.0.2.0/24');
|
||||
assert.equal(renderer!.root.findAllByType('textarea')[1].props.value, 'INTERNAL_.*');
|
||||
} finally {
|
||||
if (renderer) await act(async () => { renderer!.unmount(); });
|
||||
env.restore();
|
||||
}
|
||||
});
|
||||
@@ -1,105 +0,0 @@
|
||||
import React, { useEffect, useState } from "react";
|
||||
import { useI18n } from "../../../../application/i18n/I18nProvider";
|
||||
import { useAIPrivacyState } from "../../../../application/state/useExternalMcpToggleState";
|
||||
import { Button } from "../../../ui/button";
|
||||
import { SettingCard, SettingRow, SettingsSection, Toggle } from "../../settings-ui";
|
||||
|
||||
export const AIPrivacySection: React.FC = () => {
|
||||
const { t } = useI18n();
|
||||
const { settings, error, applying, apply } = useAIPrivacyState();
|
||||
const [enabled, setEnabled] = useState(settings.enabled);
|
||||
const [privateIps, setPrivateIps] = useState(settings.redactPrivateIps);
|
||||
const [cidrs, setCidrs] = useState(settings.cidrs.join("\n"));
|
||||
const [patterns, setPatterns] = useState(settings.patterns.join("\n"));
|
||||
const [applied, setApplied] = useState(false);
|
||||
|
||||
useEffect(() => {
|
||||
setEnabled(settings.enabled);
|
||||
setPrivateIps(settings.redactPrivateIps);
|
||||
setCidrs(settings.cidrs.join("\n"));
|
||||
setPatterns(settings.patterns.join("\n"));
|
||||
}, [settings]);
|
||||
|
||||
const onApply = async () => {
|
||||
setApplied(false);
|
||||
const saved = await apply({
|
||||
enabled,
|
||||
redactPrivateIps: privateIps,
|
||||
cidrs: cidrs.split(/\r?\n/).map((line) => line.trim()).filter(Boolean),
|
||||
patterns: patterns.split(/\r?\n/).filter((line) => line.trim().length > 0),
|
||||
});
|
||||
setApplied(saved);
|
||||
};
|
||||
|
||||
return (
|
||||
<SettingsSection anchorId="ai-privacy" title={t("ai.privacy.title")}>
|
||||
<p className="text-xs text-muted-foreground leading-5">{t("ai.privacy.description")}</p>
|
||||
<p className="text-xs text-muted-foreground leading-5">{t("ai.privacy.boundary")}</p>
|
||||
<SettingCard divided className="rounded-md border-border/60 bg-background/70">
|
||||
<SettingRow label={t("ai.privacy.enable")}>
|
||||
<Toggle
|
||||
checked={enabled}
|
||||
onChange={(next) => { setEnabled(next); setApplied(false); }}
|
||||
ariaLabel={t("ai.privacy.enable")}
|
||||
disabled={applying}
|
||||
/>
|
||||
</SettingRow>
|
||||
<SettingRow
|
||||
label={t("ai.privacy.privateIps")}
|
||||
description={t("ai.privacy.privateIps.description")}
|
||||
>
|
||||
<Toggle
|
||||
checked={privateIps}
|
||||
onChange={(next) => { setPrivateIps(next); setApplied(false); }}
|
||||
ariaLabel={t("ai.privacy.privateIps")}
|
||||
disabled={applying}
|
||||
/>
|
||||
</SettingRow>
|
||||
</SettingCard>
|
||||
<div className="space-y-3 rounded-md border border-border/60 bg-background/50 p-3">
|
||||
<label className="block space-y-1.5">
|
||||
<span className="text-xs font-medium">{t("ai.privacy.cidrs")}</span>
|
||||
<span className="block text-xs text-muted-foreground">{t("ai.privacy.cidrs.description")}</span>
|
||||
<textarea
|
||||
value={cidrs}
|
||||
onChange={(event) => { setCidrs(event.currentTarget.value); setApplied(false); }}
|
||||
aria-label={t("ai.privacy.cidrs")}
|
||||
placeholder={"192.0.2.0/24\n2001:db8::/32"}
|
||||
rows={3}
|
||||
spellCheck={false}
|
||||
disabled={applying}
|
||||
className="w-full resize-y rounded-md border border-border/60 bg-background px-2 py-1.5 font-mono text-xs focus-visible:outline-none focus-visible:ring-1 focus-visible:ring-ring"
|
||||
/>
|
||||
</label>
|
||||
<label className="block space-y-1.5">
|
||||
<span className="text-xs font-medium">{t("ai.privacy.patterns")}</span>
|
||||
<span className="block text-xs text-muted-foreground">{t("ai.privacy.patterns.description")}</span>
|
||||
<textarea
|
||||
value={patterns}
|
||||
onChange={(event) => { setPatterns(event.currentTarget.value); setApplied(false); }}
|
||||
aria-label={t("ai.privacy.patterns")}
|
||||
placeholder="INTERNAL_[A-Z0-9]+"
|
||||
rows={3}
|
||||
spellCheck={false}
|
||||
disabled={applying}
|
||||
className="w-full resize-y rounded-md border border-border/60 bg-background px-2 py-1.5 font-mono text-xs focus-visible:outline-none focus-visible:ring-1 focus-visible:ring-ring"
|
||||
/>
|
||||
</label>
|
||||
<p className="text-xs text-muted-foreground leading-5">{t("ai.privacy.connectionScope")}</p>
|
||||
<div className="flex items-center justify-between gap-3">
|
||||
<p className="text-xs text-amber-500">{t("ai.privacy.reconnect")}</p>
|
||||
<Button size="sm" variant="outline" onClick={() => { void onApply(); }} disabled={applying}>
|
||||
{t(applying ? "ai.privacy.applying" : "ai.privacy.apply")}
|
||||
</Button>
|
||||
</div>
|
||||
{error ? (
|
||||
<p role="alert" className="text-xs text-destructive">
|
||||
{t("ai.privacy.failed")} {error}
|
||||
</p>
|
||||
) : applied ? (
|
||||
<p role="status" className="text-xs text-emerald-500">{t("ai.privacy.applied")}</p>
|
||||
) : null}
|
||||
</div>
|
||||
</SettingsSection>
|
||||
);
|
||||
};
|
||||
@@ -252,7 +252,7 @@ export function buildCursorSnippet(launcherPath: string, discoveryPath?: string
|
||||
|
||||
export const ExternalMcpCard: React.FC = () => {
|
||||
const { t } = useI18n();
|
||||
const { enabled, setEnabled, error: configError } = useExternalMcpToggleState();
|
||||
const { enabled, setEnabled } = useExternalMcpToggleState();
|
||||
const [mode, setModeRaw] = useState<ExternalMcpMode>(() => readExternalMcpMode());
|
||||
const [idleTimeoutMinutes, setIdleTimeoutRaw] = useState<number>(() => readExternalMcpIdleTimeoutMinutes());
|
||||
const [focusOnHostOpen, setFocusOnHostOpenRaw] = useState<boolean>(() => readExternalMcpFocusOnHostOpen());
|
||||
@@ -681,7 +681,8 @@ export const ExternalMcpCard: React.FC = () => {
|
||||
checked={enabled}
|
||||
onChange={(nextEnabled) => {
|
||||
setActionMessage(null);
|
||||
void setEnabled(nextEnabled).then(() => refreshStatus());
|
||||
setEnabled(nextEnabled);
|
||||
window.setTimeout(() => { void refreshStatus(); }, 0);
|
||||
}}
|
||||
/>
|
||||
</div>
|
||||
@@ -988,8 +989,8 @@ export const ExternalMcpCard: React.FC = () => {
|
||||
{actionMessage.text}
|
||||
</div>
|
||||
) : null}
|
||||
{configError || status?.error ? (
|
||||
<div role="alert" className="text-xs text-destructive">{configError || status?.error}</div>
|
||||
{status?.error ? (
|
||||
<div className="text-xs text-destructive">{status.error}</div>
|
||||
) : null}
|
||||
</div>
|
||||
);
|
||||
|
||||
@@ -177,7 +177,6 @@ export interface NetcattyAiBridge {
|
||||
mode?: 'temporary' | 'persistent';
|
||||
idleTimeoutMinutes?: number;
|
||||
sessionIdleTimeoutMinutes?: number;
|
||||
redaction?: import("../../../../application/state/useExternalMcpToggleState").ExternalMcpRedactionSettings;
|
||||
httpEnabled?: boolean;
|
||||
httpPort?: number;
|
||||
httpAllowCustomBind?: boolean;
|
||||
|
||||
@@ -7,8 +7,7 @@ import type { TerminalSession } from '../../types';
|
||||
import type { DockerContainerAction, DockerContainerInfo, DockerStatInfo, TerminalPopupIcon } from '../../domain/systemManager/types';
|
||||
import { dockerContainerInfoEqual } from '../../domain/systemManager/pollEquals';
|
||||
import { getContainerFlags, getContainerTone } from '../../domain/systemManager/containerState';
|
||||
import type { ContainerRuntimeId } from '../../domain/systemManager/containerRuntime';
|
||||
import { buildContainerExecShellCommand, buildContainerLogsCommand, describeRuntimeFailure } from '../../domain/systemManager/containerRuntime';
|
||||
import { buildDockerExecShellCommand, buildDockerLogsCommand } from '../../domain/systemManager/dockerShell';
|
||||
import { DockerContainerDetail } from './DockerContainerDetail';
|
||||
import { DockerImageIcon } from './DockerImageIcon';
|
||||
import { useStableListOrder, mergePollListByKey } from './listStable';
|
||||
@@ -60,7 +59,6 @@ async function buildContainerPopupIcon(image: string): Promise<TerminalPopupIcon
|
||||
interface DockerContainersPanelProps {
|
||||
sessionId: string;
|
||||
parentSession: TerminalSession;
|
||||
runtime?: ContainerRuntimeId;
|
||||
isVisible: boolean;
|
||||
warmupEnabled?: boolean;
|
||||
backend: Backend;
|
||||
@@ -159,7 +157,6 @@ const DockerContainerRow = memo(function DockerContainerRow({
|
||||
export const DockerContainersPanel = memo(function DockerContainersPanel({
|
||||
sessionId,
|
||||
parentSession,
|
||||
runtime = 'docker',
|
||||
isVisible,
|
||||
warmupEnabled = false,
|
||||
backend,
|
||||
@@ -182,15 +179,15 @@ export const DockerContainersPanel = memo(function DockerContainersPanel({
|
||||
setConfirmAction(null);
|
||||
setPendingAction(null);
|
||||
setSelectedId(null);
|
||||
}, [runtime, sessionId]);
|
||||
}, [sessionId]);
|
||||
|
||||
const containersFetcher = useCallback(async () => {
|
||||
const result = await backend.listDockerContainers(sessionId, runtime);
|
||||
const result = await backend.listDockerContainers(sessionId);
|
||||
if (!result.success || !result.containers) {
|
||||
throw new Error(describeRuntimeFailure(result, stableT, stableT('systemManager.errors.loadDocker')));
|
||||
throw new Error(result.error || stableT('systemManager.errors.loadDocker'));
|
||||
}
|
||||
return result.containers;
|
||||
}, [backend, runtime, sessionId, stableT]);
|
||||
}, [backend, sessionId, stableT]);
|
||||
|
||||
const listIntervalMs = Math.max(3, listRefreshIntervalSec) * 1000;
|
||||
const { data: containers, error, loading, refresh } = usePolling<DockerContainerInfo[]>(
|
||||
@@ -198,7 +195,7 @@ export const DockerContainersPanel = memo(function DockerContainersPanel({
|
||||
listIntervalMs,
|
||||
isVisible || warmupEnabled,
|
||||
(prev, next) => mergePollListByKey(prev, next, (c) => c.id, dockerContainerInfoEqual),
|
||||
{ poll: isVisible, resetKey: `${sessionId}:${runtime}` },
|
||||
{ poll: isVisible, resetKey: sessionId },
|
||||
);
|
||||
|
||||
const matched = useMemo<DockerContainerInfo[]>(() => {
|
||||
@@ -243,12 +240,12 @@ export const DockerContainersPanel = memo(function DockerContainersPanel({
|
||||
);
|
||||
const statsFetcher = useCallback(async () => {
|
||||
if (statContainerIds.length === 0) return [];
|
||||
const result = await backend.getDockerStats({ sessionId, ids: statContainerIds, runtime });
|
||||
const result = await backend.getDockerStats({ sessionId, ids: statContainerIds });
|
||||
if (!result.success || !result.stats) {
|
||||
throw new Error(describeRuntimeFailure(result, stableT, stableT('systemManager.errors.loadDockerStats')));
|
||||
throw new Error(result.error || stableT('systemManager.errors.loadDockerStats'));
|
||||
}
|
||||
return result.stats;
|
||||
}, [backend, runtime, sessionId, stableT, statContainerIds]);
|
||||
}, [backend, sessionId, stableT, statContainerIds]);
|
||||
|
||||
const statsIntervalMs = Math.max(2, statsRefreshIntervalSec) * 1000;
|
||||
const { data: stats, loading: statsLoading } = usePolling<DockerStatInfo[]>(
|
||||
@@ -269,19 +266,18 @@ export const DockerContainersPanel = memo(function DockerContainersPanel({
|
||||
}, [stats]);
|
||||
|
||||
const getContainerInspectKey = useCallback((container: DockerContainerInfo) => (
|
||||
`${sessionId}:${runtime}:${container.id}`
|
||||
), [runtime, sessionId]);
|
||||
`${sessionId}:${container.id}`
|
||||
), [sessionId]);
|
||||
const fetchContainerInspect = useCallback(async (container: DockerContainerInfo) => {
|
||||
const result = await backend.dockerInspect({
|
||||
sessionId,
|
||||
containerId: container.id.slice(0, 12),
|
||||
runtime,
|
||||
});
|
||||
if (!result.success) {
|
||||
throw new Error(describeRuntimeFailure(result, stableT, stableT('systemManager.errors.actionFailed')));
|
||||
throw new Error(result.error || stableT('systemManager.errors.actionFailed'));
|
||||
}
|
||||
return result.inspect ?? null;
|
||||
}, [backend, runtime, sessionId, stableT]);
|
||||
}, [backend, sessionId, stableT]);
|
||||
const {
|
||||
records: inspectByContainerId,
|
||||
loadRecord: loadContainerInspect,
|
||||
@@ -309,12 +305,9 @@ export const DockerContainersPanel = memo(function DockerContainersPanel({
|
||||
}
|
||||
setPendingAction({ id: containerId, action });
|
||||
try {
|
||||
const result = await backend.dockerAction({ sessionId, containerId, action, newName, runtime });
|
||||
const result = await backend.dockerAction({ sessionId, containerId, action, newName });
|
||||
if (!result.success) {
|
||||
showSystemManagerError(
|
||||
describeRuntimeFailure(result, t, t('systemManager.errors.actionFailed')),
|
||||
t('common.error'),
|
||||
);
|
||||
showSystemManagerError(result.error || t('systemManager.errors.actionFailed'), t('common.error'));
|
||||
return;
|
||||
}
|
||||
const affectedContainer = (containers ?? []).find((container) => (
|
||||
@@ -339,7 +332,6 @@ export const DockerContainersPanel = memo(function DockerContainersPanel({
|
||||
invalidateContainerInspectMatching,
|
||||
refresh,
|
||||
refreshContainerInspect,
|
||||
runtime,
|
||||
sessionId,
|
||||
t,
|
||||
]);
|
||||
@@ -368,7 +360,7 @@ export const DockerContainersPanel = memo(function DockerContainersPanel({
|
||||
backend,
|
||||
parentSession,
|
||||
`docker: ${container.name || id}`,
|
||||
buildContainerExecShellCommand(runtime, id, parentSession.shellType),
|
||||
buildDockerExecShellCommand(id),
|
||||
{ icon: await buildContainerPopupIcon(container.image) },
|
||||
);
|
||||
if (!result.success) {
|
||||
@@ -380,7 +372,7 @@ export const DockerContainersPanel = memo(function DockerContainersPanel({
|
||||
});
|
||||
showSystemManagerError(result.error || t('systemManager.errors.actionFailed'), t('common.error'));
|
||||
}
|
||||
}, [backend, parentSession, runtime, sessionId, t]);
|
||||
}, [backend, parentSession, sessionId, t]);
|
||||
|
||||
const openLogs = useCallback(async (container: DockerContainerInfo) => {
|
||||
const id = container.id.slice(0, 12);
|
||||
@@ -395,7 +387,7 @@ export const DockerContainersPanel = memo(function DockerContainersPanel({
|
||||
backend,
|
||||
parentSession,
|
||||
`logs: ${container.name || id}`,
|
||||
buildContainerLogsCommand(runtime, id, parentSession.shellType),
|
||||
buildDockerLogsCommand(id),
|
||||
{ icon: await buildContainerPopupIcon(container.image) },
|
||||
);
|
||||
if (!result.success) {
|
||||
@@ -407,7 +399,7 @@ export const DockerContainersPanel = memo(function DockerContainersPanel({
|
||||
});
|
||||
showSystemManagerError(result.error || t('systemManager.errors.actionFailed'), t('common.error'));
|
||||
}
|
||||
}, [backend, parentSession, runtime, sessionId, t]);
|
||||
}, [backend, parentSession, sessionId, t]);
|
||||
|
||||
return (
|
||||
<div className="flex flex-col flex-1 min-h-0 overflow-hidden" data-section="docker-containers">
|
||||
|
||||
@@ -2,7 +2,6 @@ import { Layers, Loader2, Tag, Trash2 } from 'lucide-react';
|
||||
import React, { memo, useCallback, useEffect, useMemo, useRef, useState } from 'react';
|
||||
import { useI18n } from '../../application/i18n/I18nProvider';
|
||||
import type { useSystemManagerBackend } from '../../application/state/useSystemManagerBackend';
|
||||
import { describeRuntimeFailure, type ContainerRuntimeId } from '../../domain/systemManager/containerRuntime';
|
||||
import { dockerImageRowKey, type DockerImageInfo } from '../../domain/systemManager/types';
|
||||
import { dockerImageInfoEqual } from '../../domain/systemManager/pollEquals';
|
||||
import { DockerImageIcon } from './DockerImageIcon';
|
||||
@@ -36,7 +35,6 @@ type PendingImageConfirm =
|
||||
|
||||
interface DockerImagesPanelProps {
|
||||
sessionId: string;
|
||||
runtime?: ContainerRuntimeId;
|
||||
isVisible: boolean;
|
||||
warmupEnabled?: boolean;
|
||||
backend: Backend;
|
||||
@@ -91,7 +89,6 @@ const DockerImageRow = memo(function DockerImageRow({
|
||||
|
||||
export const DockerImagesPanel = memo(function DockerImagesPanel({
|
||||
sessionId,
|
||||
runtime = 'docker',
|
||||
isVisible,
|
||||
warmupEnabled = false,
|
||||
backend,
|
||||
@@ -114,15 +111,15 @@ export const DockerImagesPanel = memo(function DockerImagesPanel({
|
||||
// Clear busy so a hung/in-flight action from the previous session cannot
|
||||
// leave the new session's confirm dialog permanently disabled.
|
||||
setActionBusy(false);
|
||||
}, [runtime, sessionId]);
|
||||
}, [sessionId]);
|
||||
|
||||
const imagesFetcher = useCallback(async () => {
|
||||
const result = await backend.listDockerImages(sessionId, runtime);
|
||||
const result = await backend.listDockerImages(sessionId);
|
||||
if (!result.success || !result.images) {
|
||||
throw new Error(describeRuntimeFailure(result, stableT, stableT('systemManager.errors.loadDockerImages')));
|
||||
throw new Error(result.error || stableT('systemManager.errors.loadDockerImages'));
|
||||
}
|
||||
return result.images;
|
||||
}, [backend, runtime, sessionId, stableT]);
|
||||
}, [backend, sessionId, stableT]);
|
||||
|
||||
const listIntervalMs = Math.max(3, listRefreshIntervalSec) * 1000;
|
||||
const { data: images, error, loading, refresh } = usePolling<DockerImageInfo[]>(
|
||||
@@ -130,7 +127,7 @@ export const DockerImagesPanel = memo(function DockerImagesPanel({
|
||||
listIntervalMs,
|
||||
isVisible || warmupEnabled,
|
||||
(prev, next) => mergePollListByKey(prev, next, dockerImageRowKey, dockerImageInfoEqual),
|
||||
{ poll: isVisible, resetKey: `${sessionId}:${runtime}` },
|
||||
{ poll: isVisible, resetKey: sessionId },
|
||||
);
|
||||
|
||||
const filtered = useMemo(() => {
|
||||
@@ -157,19 +154,18 @@ export const DockerImagesPanel = memo(function DockerImagesPanel({
|
||||
const displayList = useStableListOrder(filtered, dockerImageRowKey, query, compareImages);
|
||||
|
||||
const getImageInspectKey = useCallback((image: DockerImageInfo) => (
|
||||
`${sessionId}:${runtime}:${dockerImageRowKey(image)}`
|
||||
), [runtime, sessionId]);
|
||||
`${sessionId}:${dockerImageRowKey(image)}`
|
||||
), [sessionId]);
|
||||
const fetchImageInspect = useCallback(async (image: DockerImageInfo) => {
|
||||
const result = await backend.dockerImageInspect({
|
||||
sessionId,
|
||||
imageId: image.id.slice(0, 12),
|
||||
runtime,
|
||||
});
|
||||
if (!result.success) {
|
||||
throw new Error(describeRuntimeFailure(result, stableT, stableT('systemManager.errors.actionFailed')));
|
||||
throw new Error(result.error || stableT('systemManager.errors.actionFailed'));
|
||||
}
|
||||
return result.inspect ?? null;
|
||||
}, [backend, runtime, sessionId, stableT]);
|
||||
}, [backend, sessionId, stableT]);
|
||||
const {
|
||||
records: inspectByImageKey,
|
||||
loadRecord: loadImageInspect,
|
||||
@@ -188,26 +184,15 @@ export const DockerImagesPanel = memo(function DockerImagesPanel({
|
||||
const actionGeneration = actionGenerationRef.current;
|
||||
setActionBusy(true);
|
||||
try {
|
||||
const tagged = Boolean(
|
||||
image.repository
|
||||
&& image.tag
|
||||
&& image.repository !== '<none>'
|
||||
&& image.tag !== '<none>',
|
||||
);
|
||||
const result = await backend.dockerImageAction({
|
||||
sessionId,
|
||||
runtime,
|
||||
action: 'rm',
|
||||
imageId: image.id.slice(0, 12),
|
||||
force: !tagged,
|
||||
...(tagged ? { repository: image.repository, tag: image.tag } : {}),
|
||||
force: image.tag === '<none>',
|
||||
});
|
||||
if (actionGenerationRef.current !== actionGeneration) return;
|
||||
if (!result.success) {
|
||||
showSystemManagerError(
|
||||
describeRuntimeFailure(result, t, t('systemManager.errors.actionFailed')),
|
||||
t('common.error'),
|
||||
);
|
||||
showSystemManagerError(result.error || t('systemManager.errors.actionFailed'), t('common.error'));
|
||||
return;
|
||||
}
|
||||
if (selectedId === dockerImageRowKey(image)) {
|
||||
@@ -220,7 +205,7 @@ export const DockerImagesPanel = memo(function DockerImagesPanel({
|
||||
setActionBusy(false);
|
||||
}
|
||||
}
|
||||
}, [backend, getImageInspectKey, invalidateImageInspect, refresh, runtime, selectedId, sessionId, t]);
|
||||
}, [backend, getImageInspectKey, invalidateImageInspect, refresh, selectedId, sessionId, t]);
|
||||
|
||||
const handleRemove = useCallback((image: DockerImageInfo) => {
|
||||
const label = image.name || image.id.slice(0, 12);
|
||||
@@ -231,13 +216,10 @@ export const DockerImagesPanel = memo(function DockerImagesPanel({
|
||||
const actionGeneration = actionGenerationRef.current;
|
||||
setActionBusy(true);
|
||||
try {
|
||||
const result = await backend.dockerImageAction({ sessionId, runtime, action: 'prune', all });
|
||||
const result = await backend.dockerImageAction({ sessionId, action: 'prune', all });
|
||||
if (actionGenerationRef.current !== actionGeneration) return;
|
||||
if (!result.success) {
|
||||
showSystemManagerError(
|
||||
describeRuntimeFailure(result, t, t('systemManager.errors.actionFailed')),
|
||||
t('common.error'),
|
||||
);
|
||||
showSystemManagerError(result.error || t('systemManager.errors.actionFailed'), t('common.error'));
|
||||
return;
|
||||
}
|
||||
await refresh();
|
||||
@@ -246,7 +228,7 @@ export const DockerImagesPanel = memo(function DockerImagesPanel({
|
||||
setActionBusy(false);
|
||||
}
|
||||
}
|
||||
}, [backend, refresh, runtime, sessionId, t]);
|
||||
}, [backend, refresh, sessionId, t]);
|
||||
|
||||
const handlePrune = useCallback((all: boolean) => {
|
||||
setConfirmTarget({ kind: 'prune', all });
|
||||
@@ -255,17 +237,13 @@ export const DockerImagesPanel = memo(function DockerImagesPanel({
|
||||
const handleTagSubmit = async (image: DockerImageInfo, repository: string, tag: string) => {
|
||||
const result = await backend.dockerImageAction({
|
||||
sessionId,
|
||||
runtime,
|
||||
action: 'tag',
|
||||
imageId: image.id.slice(0, 12),
|
||||
repository,
|
||||
tag: tag || 'latest',
|
||||
});
|
||||
if (!result.success) {
|
||||
showSystemManagerError(
|
||||
describeRuntimeFailure(result, t, t('systemManager.errors.actionFailed')),
|
||||
t('common.error'),
|
||||
);
|
||||
showSystemManagerError(result.error || t('systemManager.errors.actionFailed'), t('common.error'));
|
||||
return;
|
||||
}
|
||||
await refresh();
|
||||
|
||||
@@ -1,32 +1,19 @@
|
||||
import { Box, Layers } from 'lucide-react';
|
||||
import React, { memo, useState } from 'react';
|
||||
import { useI18n } from '../../application/i18n/I18nProvider';
|
||||
import { useContainerRuntimeSelection } from '../../application/state/systemManager/useContainerRuntimeSelection';
|
||||
import type { useSystemManagerBackend } from '../../application/state/useSystemManagerBackend';
|
||||
import type { ContainerSourceId } from '../../domain/systemManager/containerRuntime';
|
||||
import type { SessionCapabilities } from '../../domain/systemManager/types';
|
||||
import type { TerminalSession } from '../../types';
|
||||
import { cn } from '../../lib/utils';
|
||||
import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from '../ui/select';
|
||||
import { DockerContainersPanel } from './DockerContainersPanel';
|
||||
import { DockerImagesPanel } from './DockerImagesPanel';
|
||||
import { KubernetesPodsPanel } from './KubernetesPodsPanel';
|
||||
import { SystemPanelShell } from './SystemPanelUi';
|
||||
|
||||
type Backend = ReturnType<typeof useSystemManagerBackend>;
|
||||
type DockerSubTab = 'containers' | 'images';
|
||||
|
||||
const RUNTIME_LABEL: Record<ContainerSourceId, string> = {
|
||||
docker: 'systemManager.runtime.docker',
|
||||
podman: 'systemManager.runtime.podman',
|
||||
nerdctl: 'systemManager.runtime.nerdctl',
|
||||
kubernetes: 'systemManager.runtime.kubernetes',
|
||||
};
|
||||
|
||||
interface DockerManagerTabProps {
|
||||
sessionId: string;
|
||||
parentSession: TerminalSession;
|
||||
capabilities?: SessionCapabilities;
|
||||
isVisible: boolean;
|
||||
warmupEnabled?: boolean;
|
||||
backend: Backend;
|
||||
@@ -34,48 +21,9 @@ interface DockerManagerTabProps {
|
||||
statsRefreshIntervalSec: number;
|
||||
}
|
||||
|
||||
function CompactSelect({
|
||||
value,
|
||||
options,
|
||||
ariaLabel,
|
||||
section,
|
||||
onChange,
|
||||
className,
|
||||
}: {
|
||||
value: string;
|
||||
options: { id: string; label: string }[];
|
||||
ariaLabel: string;
|
||||
section: string;
|
||||
onChange: (value: string) => void;
|
||||
className?: string;
|
||||
}) {
|
||||
return (
|
||||
<Select value={value} onValueChange={onChange}>
|
||||
<SelectTrigger
|
||||
aria-label={ariaLabel}
|
||||
data-section={section}
|
||||
className={cn(
|
||||
"h-6 w-auto max-w-[9.5rem] gap-1 rounded border-border/40 bg-transparent px-2 py-0 text-[11px] shadow-none",
|
||||
className,
|
||||
)}
|
||||
>
|
||||
<SelectValue />
|
||||
</SelectTrigger>
|
||||
<SelectContent>
|
||||
{options.map((option) => (
|
||||
<SelectItem key={option.id} value={option.id} className="text-xs">
|
||||
{option.label}
|
||||
</SelectItem>
|
||||
))}
|
||||
</SelectContent>
|
||||
</Select>
|
||||
);
|
||||
}
|
||||
|
||||
export const DockerManagerTab = memo(function DockerManagerTab({
|
||||
sessionId,
|
||||
parentSession,
|
||||
capabilities,
|
||||
isVisible,
|
||||
warmupEnabled = false,
|
||||
backend,
|
||||
@@ -84,26 +32,6 @@ export const DockerManagerTab = memo(function DockerManagerTab({
|
||||
}: DockerManagerTabProps) {
|
||||
const { t } = useI18n();
|
||||
const [subTab, setSubTab] = useState<DockerSubTab>('containers');
|
||||
const {
|
||||
source,
|
||||
selectSource,
|
||||
contextName,
|
||||
selectContext,
|
||||
namespaceName,
|
||||
setNamespaceName,
|
||||
contexts,
|
||||
namespaces,
|
||||
scopeError,
|
||||
reloadScopes,
|
||||
runtime,
|
||||
detected,
|
||||
showRuntimeSwitcher,
|
||||
} = useContainerRuntimeSelection({
|
||||
sessionId,
|
||||
hostKey: parentSession.hostId || sessionId,
|
||||
capabilities,
|
||||
backend,
|
||||
});
|
||||
|
||||
const tabs: { id: DockerSubTab; icon: typeof Box; label: string }[] = [
|
||||
{ id: 'containers', icon: Box, label: t('systemManager.docker.subTabs.containers') },
|
||||
@@ -112,8 +40,8 @@ export const DockerManagerTab = memo(function DockerManagerTab({
|
||||
|
||||
return (
|
||||
<SystemPanelShell section="system-manager-docker">
|
||||
<div className="shrink-0 flex items-center gap-1 px-2 py-1 border-b border-border/30">
|
||||
{source !== 'kubernetes' && tabs.map(({ id, icon: Icon, label }) => (
|
||||
<div className="shrink-0 flex items-center gap-0.5 px-2 py-1 border-b border-border/30">
|
||||
{tabs.map(({ id, icon: Icon, label }) => (
|
||||
<button
|
||||
key={id}
|
||||
type="button"
|
||||
@@ -129,79 +57,29 @@ export const DockerManagerTab = memo(function DockerManagerTab({
|
||||
{label}
|
||||
</button>
|
||||
))}
|
||||
{showRuntimeSwitcher && source && detected.includes(source) && (
|
||||
<CompactSelect
|
||||
value={source}
|
||||
ariaLabel={t('systemManager.runtime.switch')}
|
||||
section="container-runtime-switcher"
|
||||
className="shrink-0"
|
||||
options={detected.map((id) => ({ id, label: t(RUNTIME_LABEL[id]) }))}
|
||||
onChange={selectSource}
|
||||
/>
|
||||
)}
|
||||
{source === 'kubernetes' && contextName && contexts.some((item) => item.name === contextName) && (
|
||||
<CompactSelect
|
||||
value={contextName}
|
||||
ariaLabel={t('systemManager.k8s.context')}
|
||||
section="k8s-context-select"
|
||||
className="w-full min-w-0 max-w-none flex-1"
|
||||
options={contexts.map((item) => ({ id: item.name, label: item.name }))}
|
||||
onChange={selectContext}
|
||||
/>
|
||||
)}
|
||||
{source === 'kubernetes' && namespaceName && namespaces.includes(namespaceName) && (
|
||||
<CompactSelect
|
||||
value={namespaceName}
|
||||
ariaLabel={t('systemManager.k8s.namespace')}
|
||||
section="k8s-namespace-select"
|
||||
className="shrink-0"
|
||||
options={namespaces.map((name) => ({ id: name, label: name }))}
|
||||
onChange={setNamespaceName}
|
||||
/>
|
||||
)}
|
||||
</div>
|
||||
|
||||
<div className="flex-1 min-h-0 flex flex-col">
|
||||
{source && source !== 'kubernetes' && (
|
||||
<>
|
||||
<div className={cn('flex-1 min-h-0 flex flex-col', subTab !== 'containers' && 'hidden')}>
|
||||
<DockerContainersPanel
|
||||
sessionId={sessionId}
|
||||
parentSession={parentSession}
|
||||
runtime={runtime}
|
||||
isVisible={isVisible && subTab === 'containers'}
|
||||
warmupEnabled={warmupEnabled || (isVisible && subTab !== 'containers')}
|
||||
backend={backend}
|
||||
listRefreshIntervalSec={listRefreshIntervalSec}
|
||||
statsRefreshIntervalSec={statsRefreshIntervalSec}
|
||||
/>
|
||||
</div>
|
||||
<div className={cn('flex-1 min-h-0 flex flex-col', subTab !== 'images' && 'hidden')}>
|
||||
<DockerImagesPanel
|
||||
sessionId={sessionId}
|
||||
runtime={runtime}
|
||||
isVisible={isVisible && subTab === 'images'}
|
||||
warmupEnabled={warmupEnabled || (isVisible && subTab !== 'images')}
|
||||
backend={backend}
|
||||
listRefreshIntervalSec={listRefreshIntervalSec}
|
||||
/>
|
||||
</div>
|
||||
</>
|
||||
)}
|
||||
{source === 'kubernetes' && (
|
||||
<KubernetesPodsPanel
|
||||
<div className={cn('flex-1 min-h-0 flex flex-col', subTab !== 'containers' && 'hidden')}>
|
||||
<DockerContainersPanel
|
||||
sessionId={sessionId}
|
||||
parentSession={parentSession}
|
||||
contextName={contextName}
|
||||
namespaceName={namespaceName}
|
||||
scopeError={scopeError}
|
||||
isVisible={isVisible}
|
||||
warmupEnabled={warmupEnabled}
|
||||
isVisible={isVisible && subTab === 'containers'}
|
||||
warmupEnabled={warmupEnabled || (isVisible && subTab !== 'containers')}
|
||||
backend={backend}
|
||||
listRefreshIntervalSec={listRefreshIntervalSec}
|
||||
onReloadScopes={reloadScopes}
|
||||
statsRefreshIntervalSec={statsRefreshIntervalSec}
|
||||
/>
|
||||
)}
|
||||
</div>
|
||||
<div className={cn('flex-1 min-h-0 flex flex-col', subTab !== 'images' && 'hidden')}>
|
||||
<DockerImagesPanel
|
||||
sessionId={sessionId}
|
||||
isVisible={isVisible && subTab === 'images'}
|
||||
warmupEnabled={warmupEnabled || (isVisible && subTab !== 'images')}
|
||||
backend={backend}
|
||||
listRefreshIntervalSec={listRefreshIntervalSec}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
</SystemPanelShell>
|
||||
);
|
||||
|
||||
@@ -1,361 +0,0 @@
|
||||
import { Box, FileText, Info, Terminal, Trash2 } from 'lucide-react';
|
||||
import React, { memo, useCallback, useEffect, useMemo, useState } from 'react';
|
||||
import { useI18n } from '../../application/i18n/I18nProvider';
|
||||
import type { useSystemManagerBackend } from '../../application/state/useSystemManagerBackend';
|
||||
import { usePolling, useStableTranslate } from '../../application/state/useSystemManager';
|
||||
import type { KubectlPodInfo } from '../../domain/systemManager/containerRuntime';
|
||||
import {
|
||||
buildKubectlDescribeCommand,
|
||||
buildKubectlExecShellCommand,
|
||||
buildKubectlLogsCommand,
|
||||
describeRuntimeFailure,
|
||||
podStatusMessageKey,
|
||||
} from '../../domain/systemManager/containerRuntime';
|
||||
import type { TerminalSession } from '../../types';
|
||||
import { Dialog, DialogContent, DialogHeader, DialogTitle } from '../ui/dialog';
|
||||
import { useStableListOrder } from './listStable';
|
||||
import { openInteractiveTerminal } from './openInteractiveTerminal';
|
||||
import { SystemPanelConfirmDialog } from './SystemPanelConfirmDialog';
|
||||
import {
|
||||
SystemPanelEmpty,
|
||||
SystemPanelError,
|
||||
SystemPanelList,
|
||||
SystemPanelLoading,
|
||||
SystemPanelMetaBar,
|
||||
SystemPanelRefreshButton,
|
||||
SystemPanelRoundButton,
|
||||
SystemPanelRow,
|
||||
SystemPanelSearch,
|
||||
SystemPanelStatusBadge,
|
||||
SystemPanelToolbar,
|
||||
} from './SystemPanelUi';
|
||||
import { showSystemManagerError } from './systemManagerToast';
|
||||
|
||||
type Backend = ReturnType<typeof useSystemManagerBackend>;
|
||||
|
||||
interface KubernetesPodsPanelProps {
|
||||
sessionId: string;
|
||||
parentSession: TerminalSession;
|
||||
contextName: string | null;
|
||||
namespaceName: string | null;
|
||||
scopeError?: string | null;
|
||||
isVisible: boolean;
|
||||
warmupEnabled?: boolean;
|
||||
backend: Backend;
|
||||
listRefreshIntervalSec: number;
|
||||
onReloadScopes: () => void;
|
||||
}
|
||||
|
||||
function podTone(status: string): 'success' | 'warning' | 'muted' {
|
||||
if (status === 'Running') return 'success';
|
||||
if (status === 'Pending' || status === 'Completed' || status === 'Succeeded') return 'muted';
|
||||
return 'warning';
|
||||
}
|
||||
|
||||
const KubernetesPodRow = memo(function KubernetesPodRow({
|
||||
pod,
|
||||
selected,
|
||||
pendingDelete,
|
||||
onSelect,
|
||||
onShell,
|
||||
onLogs,
|
||||
onDescribe,
|
||||
onDelete,
|
||||
}: {
|
||||
pod: KubectlPodInfo;
|
||||
selected: boolean;
|
||||
pendingDelete: boolean;
|
||||
onSelect: (pod: KubectlPodInfo) => void;
|
||||
onShell: (pod: KubectlPodInfo) => void;
|
||||
onLogs: (pod: KubectlPodInfo) => void;
|
||||
onDescribe: (pod: KubectlPodInfo) => void;
|
||||
onDelete: (pod: KubectlPodInfo) => void;
|
||||
}) {
|
||||
const { t } = useI18n();
|
||||
const statusKey = podStatusMessageKey(pod.status);
|
||||
const statusLabel = statusKey ? t(statusKey) : pod.status;
|
||||
return (
|
||||
<SystemPanelRow
|
||||
selected={selected}
|
||||
onClick={() => onSelect(pod)}
|
||||
leading={<Box size={14} className="text-muted-foreground" />}
|
||||
title={pod.name}
|
||||
subtitle={`${pod.containers.join(', ') || '—'} · ${t('systemManager.k8s.restarts', { count: String(pod.restarts) })}${pod.age ? ` · ${pod.age}` : ''}`}
|
||||
trailing={(
|
||||
<div className="flex shrink-0 items-center gap-1">
|
||||
<SystemPanelStatusBadge tone={podTone(pod.status)}>{statusLabel}</SystemPanelStatusBadge>
|
||||
{pod.shellContainers.length > 0 && (
|
||||
<SystemPanelRoundButton title={t('systemManager.docker.shell')} onClick={() => onShell(pod)}>
|
||||
<Terminal size={12} />
|
||||
</SystemPanelRoundButton>
|
||||
)}
|
||||
<SystemPanelRoundButton title={t('systemManager.docker.logs')} onClick={() => onLogs(pod)}>
|
||||
<FileText size={12} />
|
||||
</SystemPanelRoundButton>
|
||||
<SystemPanelRoundButton title={t('systemManager.k8s.describe')} onClick={() => onDescribe(pod)}>
|
||||
<Info size={12} />
|
||||
</SystemPanelRoundButton>
|
||||
<SystemPanelRoundButton
|
||||
title={t('systemManager.k8s.delete')}
|
||||
destructive
|
||||
loading={pendingDelete}
|
||||
onClick={() => onDelete(pod)}
|
||||
>
|
||||
<Trash2 size={12} />
|
||||
</SystemPanelRoundButton>
|
||||
</div>
|
||||
)}
|
||||
/>
|
||||
);
|
||||
});
|
||||
|
||||
export const KubernetesPodsPanel = memo(function KubernetesPodsPanel({
|
||||
sessionId,
|
||||
parentSession,
|
||||
contextName,
|
||||
namespaceName,
|
||||
scopeError = null,
|
||||
isVisible,
|
||||
warmupEnabled = false,
|
||||
backend,
|
||||
listRefreshIntervalSec,
|
||||
onReloadScopes,
|
||||
}: KubernetesPodsPanelProps) {
|
||||
const { t } = useI18n();
|
||||
const stableT = useStableTranslate();
|
||||
const [query, setQuery] = useState('');
|
||||
const [selectedName, setSelectedName] = useState<string | null>(null);
|
||||
const [pendingDelete, setPendingDelete] = useState<string | null>(null);
|
||||
const [confirmPod, setConfirmPod] = useState<string | null>(null);
|
||||
const [containerPrompt, setContainerPrompt] = useState<{
|
||||
pod: KubectlPodInfo;
|
||||
action: 'shell' | 'logs';
|
||||
} | null>(null);
|
||||
|
||||
const scopeKey = `${sessionId}:${contextName ?? ''}:${namespaceName ?? ''}`;
|
||||
|
||||
useEffect(() => {
|
||||
setSelectedName(null);
|
||||
setPendingDelete(null);
|
||||
setConfirmPod(null);
|
||||
setContainerPrompt(null);
|
||||
}, [scopeKey]);
|
||||
|
||||
const podsFetcher = useCallback(async () => {
|
||||
if (!contextName || !namespaceName) return [];
|
||||
const result = await backend.kubectl({
|
||||
sessionId,
|
||||
op: 'pods',
|
||||
context: contextName,
|
||||
namespace: namespaceName,
|
||||
});
|
||||
if (!result.success || !result.pods) {
|
||||
throw new Error(describeRuntimeFailure(
|
||||
result,
|
||||
stableT,
|
||||
stableT('systemManager.errors.loadPods'),
|
||||
contextName,
|
||||
));
|
||||
}
|
||||
return result.pods;
|
||||
}, [backend, contextName, namespaceName, sessionId, stableT]);
|
||||
|
||||
const listIntervalMs = Math.max(3, listRefreshIntervalSec) * 1000;
|
||||
const { data: pods, error, loading, refresh } = usePolling<KubectlPodInfo[]>(
|
||||
podsFetcher,
|
||||
listIntervalMs,
|
||||
(isVisible || warmupEnabled) && !!contextName && !!namespaceName,
|
||||
undefined,
|
||||
{ poll: isVisible, resetKey: scopeKey },
|
||||
);
|
||||
|
||||
const matched = useMemo(() => {
|
||||
const q = query.trim().toLowerCase();
|
||||
return (pods ?? []).filter((pod) => {
|
||||
if (!q) return true;
|
||||
const statusKey = podStatusMessageKey(pod.status);
|
||||
const statusLabel = (statusKey ? t(statusKey) : pod.status).toLowerCase();
|
||||
return pod.name.toLowerCase().includes(q)
|
||||
|| pod.status.toLowerCase().includes(q)
|
||||
|| statusLabel.includes(q)
|
||||
|| pod.containers.some((name) => name.toLowerCase().includes(q));
|
||||
});
|
||||
}, [pods, query, t]);
|
||||
|
||||
const displayList = useStableListOrder<KubectlPodInfo, string>(
|
||||
matched,
|
||||
(pod) => pod.name,
|
||||
`${scopeKey}|${query}`,
|
||||
(a, b) => a.name.localeCompare(b.name),
|
||||
);
|
||||
|
||||
const openCommand = useCallback(async (title: string, command: string) => {
|
||||
const result = await openInteractiveTerminal(backend, parentSession, title, command);
|
||||
if (!result.success) {
|
||||
showSystemManagerError(result.error || t('systemManager.errors.actionFailed'), t('common.error'));
|
||||
}
|
||||
}, [backend, parentSession, t]);
|
||||
|
||||
const runPodCommand = useCallback((pod: KubectlPodInfo, action: 'shell' | 'logs' | 'describe', container?: string) => {
|
||||
if (!contextName || !namespaceName) return;
|
||||
if (action === 'describe') {
|
||||
void openCommand(
|
||||
`describe: ${pod.name}`,
|
||||
buildKubectlDescribeCommand(contextName, namespaceName, pod.name, parentSession.shellType),
|
||||
);
|
||||
return;
|
||||
}
|
||||
const containers = action === 'shell' ? pod.shellContainers : pod.containers;
|
||||
if (action === 'shell' && containers.length === 0) return;
|
||||
if (!container && containers.length > 1) {
|
||||
setContainerPrompt({ pod, action });
|
||||
return;
|
||||
}
|
||||
const chosen = container || containers[0];
|
||||
if (action === 'shell') {
|
||||
void openCommand(
|
||||
`pod: ${pod.name}`,
|
||||
buildKubectlExecShellCommand(contextName, namespaceName, pod.name, chosen, parentSession.shellType),
|
||||
);
|
||||
return;
|
||||
}
|
||||
void openCommand(
|
||||
`logs: ${pod.name}`,
|
||||
buildKubectlLogsCommand(contextName, namespaceName, pod.name, chosen, parentSession.shellType),
|
||||
);
|
||||
}, [contextName, namespaceName, openCommand, parentSession.shellType]);
|
||||
|
||||
const deletePod = useCallback(async (name: string) => {
|
||||
if (!contextName || !namespaceName) return;
|
||||
setPendingDelete(name);
|
||||
try {
|
||||
const result = await backend.kubectl({
|
||||
sessionId,
|
||||
op: 'delete',
|
||||
context: contextName,
|
||||
namespace: namespaceName,
|
||||
pod: name,
|
||||
});
|
||||
if (!result.success) {
|
||||
showSystemManagerError(
|
||||
describeRuntimeFailure(result, t, t('systemManager.errors.actionFailed'), contextName),
|
||||
t('common.error'),
|
||||
);
|
||||
return;
|
||||
}
|
||||
if (selectedName === name) setSelectedName(null);
|
||||
await refresh();
|
||||
} finally {
|
||||
setPendingDelete(null);
|
||||
}
|
||||
}, [backend, contextName, namespaceName, refresh, selectedName, sessionId, t]);
|
||||
|
||||
const visibleError = scopeError || error;
|
||||
|
||||
return (
|
||||
<div className="flex flex-col flex-1 min-h-0 overflow-hidden" data-section="kubernetes-pods">
|
||||
<SystemPanelToolbar
|
||||
trailing={(
|
||||
<SystemPanelRefreshButton
|
||||
title={t('history.action.refresh')}
|
||||
loading={loading}
|
||||
onClick={() => {
|
||||
onReloadScopes();
|
||||
void refresh();
|
||||
}}
|
||||
/>
|
||||
)}
|
||||
>
|
||||
<SystemPanelSearch
|
||||
value={query}
|
||||
onChange={setQuery}
|
||||
placeholder={t('systemManager.k8s.search')}
|
||||
/>
|
||||
</SystemPanelToolbar>
|
||||
|
||||
<SystemPanelMetaBar>
|
||||
{t('systemManager.k8s.meta', { count: String(displayList.length) })}
|
||||
</SystemPanelMetaBar>
|
||||
|
||||
<SystemPanelList>
|
||||
{!contextName && !visibleError && (
|
||||
<SystemPanelEmpty icon={Box} message={t('systemManager.k8s.noContext')} />
|
||||
)}
|
||||
{visibleError && (
|
||||
<SystemPanelError
|
||||
message={visibleError}
|
||||
onRetry={() => {
|
||||
onReloadScopes();
|
||||
void refresh();
|
||||
}}
|
||||
retryLabel={t('history.action.retry')}
|
||||
loading={loading}
|
||||
/>
|
||||
)}
|
||||
{!visibleError && contextName && displayList.length === 0 && loading && (
|
||||
<SystemPanelLoading message={t('systemManager.common.loading')} />
|
||||
)}
|
||||
{!visibleError && contextName && namespaceName && displayList.length === 0 && !loading && (
|
||||
<SystemPanelEmpty icon={Box} message={t('systemManager.k8s.empty')} />
|
||||
)}
|
||||
{displayList.map((pod) => (
|
||||
<KubernetesPodRow
|
||||
key={pod.name}
|
||||
pod={pod}
|
||||
selected={selectedName === pod.name}
|
||||
pendingDelete={pendingDelete === pod.name}
|
||||
onSelect={(item) => setSelectedName((current) => current === item.name ? null : item.name)}
|
||||
onShell={(item) => runPodCommand(item, 'shell')}
|
||||
onLogs={(item) => runPodCommand(item, 'logs')}
|
||||
onDescribe={(item) => runPodCommand(item, 'describe')}
|
||||
onDelete={(item) => setConfirmPod(item.name)}
|
||||
/>
|
||||
))}
|
||||
</SystemPanelList>
|
||||
|
||||
<SystemPanelConfirmDialog
|
||||
open={confirmPod !== null}
|
||||
title={t('systemManager.k8s.delete')}
|
||||
message={t('systemManager.k8s.confirmDelete', { name: confirmPod ?? '' })}
|
||||
confirmLabel={t('systemManager.k8s.delete')}
|
||||
destructive
|
||||
busy={pendingDelete !== null}
|
||||
onOpenChange={(open) => { if (!open) setConfirmPod(null); }}
|
||||
onConfirm={() => {
|
||||
const name = confirmPod;
|
||||
setConfirmPod(null);
|
||||
if (!name) return;
|
||||
void deletePod(name);
|
||||
}}
|
||||
/>
|
||||
|
||||
<Dialog open={containerPrompt !== null} onOpenChange={(open) => { if (!open) setContainerPrompt(null); }}>
|
||||
<DialogContent className="sm:max-w-[380px]">
|
||||
<DialogHeader>
|
||||
<DialogTitle>{t('systemManager.k8s.chooseContainer')}</DialogTitle>
|
||||
</DialogHeader>
|
||||
<div className="flex flex-col gap-1">
|
||||
{(containerPrompt?.action === 'shell'
|
||||
? containerPrompt.pod.shellContainers
|
||||
: containerPrompt?.pod.containers
|
||||
)?.map((name) => (
|
||||
<button
|
||||
key={name}
|
||||
type="button"
|
||||
className="rounded-sm px-2 py-1.5 text-left text-sm outline-none transition-colors hover:bg-accent hover:text-accent-foreground focus:bg-accent focus:text-accent-foreground"
|
||||
onClick={() => {
|
||||
const prompt = containerPrompt;
|
||||
setContainerPrompt(null);
|
||||
if (!prompt) return;
|
||||
runPodCommand(prompt.pod, prompt.action, name);
|
||||
}}
|
||||
>
|
||||
{name}
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
</DialogContent>
|
||||
</Dialog>
|
||||
</div>
|
||||
);
|
||||
});
|
||||
@@ -14,7 +14,6 @@ import { resolveCapabilityPanelState } from '../../domain/systemManagerPanelStat
|
||||
import {
|
||||
allowSystemManagerMutations,
|
||||
buildSystemManagerTabs,
|
||||
hasContainerSource,
|
||||
shouldCollectServerStats,
|
||||
} from '../../domain/systemManager/systemTarget';
|
||||
|
||||
@@ -285,14 +284,13 @@ export const SystemManagerSidePanel = memo(function SystemManagerSidePanel({
|
||||
}, [isConnectedSession, isVisible, availableTabsKey, tabBarEl]);
|
||||
|
||||
// Must be defined before early returns to comply with React rules of hooks.
|
||||
const containerSourceReady = hasContainerSource(capabilities);
|
||||
const prevTabRef = React.useRef(resolvedTab);
|
||||
const probingRef = React.useRef(false);
|
||||
React.useEffect(() => {
|
||||
const prev = prevTabRef.current;
|
||||
prevTabRef.current = resolvedTab;
|
||||
if (prev === resolvedTab) return;
|
||||
if (resolvedTab === 'docker' && !containerSourceReady) {
|
||||
if (resolvedTab === 'docker' && capabilities?.hasDocker !== true) {
|
||||
if (!probingRef.current) {
|
||||
probingRef.current = true;
|
||||
refreshCapabilities().finally(() => { probingRef.current = false; });
|
||||
@@ -300,7 +298,7 @@ export const SystemManagerSidePanel = memo(function SystemManagerSidePanel({
|
||||
} else if (resolvedTab === 'tmux' && capabilities?.hasTmux !== true) {
|
||||
void refreshCapabilities();
|
||||
}
|
||||
}, [resolvedTab, capabilities, containerSourceReady, refreshCapabilities]);
|
||||
}, [resolvedTab, capabilities, refreshCapabilities]);
|
||||
|
||||
// Auto-poll for Docker capabilities while Docker tab is active and Docker not yet detected.
|
||||
// Use setTimeout recursion so the next probe only starts after the previous one finishes,
|
||||
@@ -317,7 +315,7 @@ export const SystemManagerSidePanel = memo(function SystemManagerSidePanel({
|
||||
// preventing stale probes from surviving cleanup (unlike cancelledRef which is shared).
|
||||
// First poll is delayed by one interval to avoid overlapping with the tab-switch probe.
|
||||
React.useEffect(() => {
|
||||
if (!isVisible || resolvedTab !== 'docker' || containerSourceReady) return;
|
||||
if (!isVisible || resolvedTab !== 'docker' || capabilities?.hasDocker === true) return;
|
||||
|
||||
let cancelled = false;
|
||||
let timerId: ReturnType<typeof setTimeout>;
|
||||
@@ -346,7 +344,7 @@ export const SystemManagerSidePanel = memo(function SystemManagerSidePanel({
|
||||
cancelled = true;
|
||||
if (timerId) clearTimeout(timerId);
|
||||
};
|
||||
}, [isVisible, resolvedTab, containerSourceReady, capabilitiesTtlMs]);
|
||||
}, [isVisible, resolvedTab, capabilities?.hasDocker, capabilitiesTtlMs]);
|
||||
|
||||
const selectTab = useCallback((id: SystemManagerSubTab) => {
|
||||
setActiveTab(id);
|
||||
@@ -400,7 +398,7 @@ export const SystemManagerSidePanel = memo(function SystemManagerSidePanel({
|
||||
}
|
||||
|
||||
const tmuxReady = capabilities?.hasTmux === true;
|
||||
const dockerReady = containerSourceReady;
|
||||
const dockerReady = capabilities?.hasDocker === true;
|
||||
const gpuReady = capabilities?.hasNvidiaSmi === true || capabilities?.hasNpuSmi === true;
|
||||
const portsReady = (
|
||||
capabilities?.hasSs === true
|
||||
@@ -623,10 +621,8 @@ export const SystemManagerSidePanel = memo(function SystemManagerSidePanel({
|
||||
) : dockerPanelState === 'ready' ? (
|
||||
<div className={cn('flex-1 min-h-0 flex flex-col', resolvedTab !== 'docker' && 'hidden')}>
|
||||
<DockerManagerTab
|
||||
key={session.hostId || sessionId}
|
||||
sessionId={sessionId}
|
||||
parentSession={session}
|
||||
capabilities={capabilities}
|
||||
isVisible={isVisible && resolvedTab === 'docker'}
|
||||
warmupEnabled={isVisible && resolvedTab !== 'docker'}
|
||||
backend={backend}
|
||||
|
||||
@@ -1,25 +0,0 @@
|
||||
import assert from 'node:assert/strict';
|
||||
import test from 'node:test';
|
||||
import { readFileSync } from 'node:fs';
|
||||
|
||||
test('kubernetes keeps the shared runtime select and drops the Containers back button', () => {
|
||||
const tab = readFileSync(new URL('./DockerManagerTab.tsx', import.meta.url), 'utf8');
|
||||
const hook = readFileSync(
|
||||
new URL('../../application/state/systemManager/useContainerRuntimeSelection.ts', import.meta.url),
|
||||
'utf8',
|
||||
);
|
||||
|
||||
assert.equal(tab.includes('container-runtime-back'), false);
|
||||
assert.match(tab, /data-section=\{section\}/);
|
||||
assert.match(tab, /section="container-runtime-switcher"/);
|
||||
assert.match(tab, /section="k8s-context-select"/);
|
||||
assert.match(tab, /section="k8s-namespace-select"/);
|
||||
assert.doesNotMatch(tab, /<div className="flex-1" \/>/);
|
||||
assert.match(tab, /section="k8s-context-select"[\s\S]*className="w-full min-w-0 max-w-none flex-1"/);
|
||||
assert.match(tab, /section="container-runtime-switcher"[\s\S]*className="shrink-0"/);
|
||||
assert.match(tab, /section="k8s-namespace-select"[\s\S]*className="shrink-0"/);
|
||||
assert.match(hook, /const showRuntimeSwitcher = detected\.length > 1;/);
|
||||
assert.doesNotMatch(hook, /source !== 'kubernetes' && detected\.length/);
|
||||
assert.match(hook, /reconcileContainerSource\(/);
|
||||
assert.match(hook, /isAutomaticRuntimeFallback\(remembered, source, available\)/);
|
||||
});
|
||||
@@ -93,71 +93,3 @@ test('openInteractiveTerminal keeps SSH connection reuse for ordinary connected
|
||||
assert.equal(payload.sourceSession.protocol, 'ssh');
|
||||
assert.equal(payload.sourceSession.reuseConnectionFromSessionId, 'session-1');
|
||||
});
|
||||
|
||||
async function withSystemPanels(
|
||||
panels: Array<{ getBoundingClientRect: () => { x: number; y: number; width: number; height: number } }>,
|
||||
run: () => Promise<void>,
|
||||
): Promise<void> {
|
||||
const previous = globalThis.document;
|
||||
globalThis.document = {
|
||||
querySelectorAll(selector: string) {
|
||||
return selector === '[data-section="system-manager-panel"]' ? panels : [];
|
||||
},
|
||||
} as unknown as Document;
|
||||
try {
|
||||
await run();
|
||||
} finally {
|
||||
if (previous === undefined) {
|
||||
delete (globalThis as { document?: Document }).document;
|
||||
} else {
|
||||
globalThis.document = previous;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
test('openInteractiveTerminal passes the system panel rectangle so the shell can avoid it', async () => {
|
||||
const panel = {
|
||||
getBoundingClientRect: () => ({ x: 1000, y: 40, width: 440, height: 860 }),
|
||||
};
|
||||
const payloads: Array<{ avoidClientRect?: { x: number; width: number } }> = [];
|
||||
await withSystemPanels([panel], async () => {
|
||||
await openInteractiveTerminal(
|
||||
{
|
||||
openTerminalPopup: async (payload: { avoidClientRect?: { x: number; width: number } }) => {
|
||||
payloads.push(payload);
|
||||
return { success: true };
|
||||
},
|
||||
} as never,
|
||||
parentSession(),
|
||||
'pod: multi-box',
|
||||
'kubectl exec -it multi-box -- sh',
|
||||
);
|
||||
});
|
||||
|
||||
assert.deepEqual(payloads[0]?.avoidClientRect, { x: 1000, y: 40, width: 440, height: 860 });
|
||||
});
|
||||
|
||||
test('openInteractiveTerminal skips a parked zero-size system panel and avoids the visible one', async () => {
|
||||
const parked = {
|
||||
getBoundingClientRect: () => ({ x: 0, y: 0, width: 0, height: 0 }),
|
||||
};
|
||||
const visible = {
|
||||
getBoundingClientRect: () => ({ x: 1000, y: 40, width: 440, height: 860 }),
|
||||
};
|
||||
const payloads: Array<{ avoidClientRect?: { x: number; y: number; width: number; height: number } }> = [];
|
||||
await withSystemPanels([parked, visible], async () => {
|
||||
await openInteractiveTerminal(
|
||||
{
|
||||
openTerminalPopup: async (payload: { avoidClientRect?: { x: number; y: number; width: number; height: number } }) => {
|
||||
payloads.push(payload);
|
||||
return { success: true };
|
||||
},
|
||||
} as never,
|
||||
parentSession(),
|
||||
'pod: multi-box',
|
||||
'kubectl exec -it multi-box -- sh',
|
||||
);
|
||||
});
|
||||
|
||||
assert.deepEqual(payloads[0]?.avoidClientRect, { x: 1000, y: 40, width: 440, height: 860 });
|
||||
});
|
||||
@@ -6,18 +6,6 @@ import type { useSystemManagerBackend } from '../../application/state/useSystemM
|
||||
|
||||
type Backend = ReturnType<typeof useSystemManagerBackend>;
|
||||
|
||||
function readSystemPanelClientRect(): { x: number; y: number; width: number; height: number } | undefined {
|
||||
if (typeof document === "undefined" || typeof document.querySelectorAll !== "function") return undefined;
|
||||
const panels = document.querySelectorAll('[data-section="system-manager-panel"]');
|
||||
for (const panel of panels) {
|
||||
if (typeof panel.getBoundingClientRect !== "function") continue;
|
||||
const rect = panel.getBoundingClientRect();
|
||||
if (rect.width < 1 || rect.height < 1) continue;
|
||||
return { x: rect.x, y: rect.y, width: rect.width, height: rect.height };
|
||||
}
|
||||
return undefined;
|
||||
}
|
||||
|
||||
function buildPopupTitle(parentSession: TerminalSession, title: string): string {
|
||||
const hostLabel = parentSession.hostLabel.trim();
|
||||
const cleanTitle = title.trim();
|
||||
@@ -74,13 +62,11 @@ export async function openInteractiveTerminal(
|
||||
canReuseConnection,
|
||||
hasIcon: !!options?.icon,
|
||||
});
|
||||
const avoidClientRect = readSystemPanelClientRect();
|
||||
const result = await backend.openTerminalPopup({
|
||||
title: popupTitle,
|
||||
icon: options?.icon,
|
||||
parentSessionId: parentSession.id,
|
||||
startupCommand,
|
||||
...(avoidClientRect ? { avoidClientRect } : {}),
|
||||
sourceSession: buildCommandPopupSourceSession(parentSession, startupCommand, canReuseConnection),
|
||||
});
|
||||
await writeSystemManagerDiagnostic('openInteractiveTerminal result', {
|
||||
|
||||
@@ -49,11 +49,11 @@ test("process and docker confirm dialogs reset when sessionId changes", () => {
|
||||
assert.match(processSource, /}, \[sessionId\]\);/);
|
||||
|
||||
assert.match(containersSource, /setConfirmAction\(null\)/);
|
||||
assert.match(containersSource, /}, \[runtime, sessionId\]\);/);
|
||||
assert.match(containersSource, /}, \[sessionId\]\);/);
|
||||
|
||||
assert.match(imagesSource, /setConfirmTarget\(null\)/);
|
||||
assert.match(imagesSource, /setActionBusy\(false\)/);
|
||||
assert.match(imagesSource, /}, \[runtime, sessionId\]\);/);
|
||||
assert.match(imagesSource, /}, \[sessionId\]\);/);
|
||||
});
|
||||
|
||||
test("ports and services confirm dialogs reset when sessionId changes", () => {
|
||||
|
||||
@@ -37,7 +37,6 @@ import {
|
||||
sanitizeCredentialValue,
|
||||
} from "../../../domain/credentials";
|
||||
import { resolveBridgeSshAgentAuth, resolveHostAuth } from "../../../domain/sshAuth";
|
||||
import { connectionTotpFields } from "../../../domain/totpConfig";
|
||||
import { requiresFidoSshAgentAuth } from "../../../domain/fidoSsh";
|
||||
import {
|
||||
hostRestrictsExtraSshChannels,
|
||||
@@ -479,7 +478,6 @@ export const createTerminalSessionStarters = (ctx: TerminalSessionStartersContex
|
||||
authMethod: jumpAuth.authMethod,
|
||||
requiresMfa: !!jumpHost.requiresMfa,
|
||||
password: jumpPassword,
|
||||
...connectionTotpFields(jumpHost),
|
||||
privateKey: resolveBridgePrivateKeyMaterial(jumpKey, {
|
||||
forceAgent: jumpAgentAuth.useSshAgent === true && !jumpKey?.certificate,
|
||||
}),
|
||||
@@ -707,7 +705,6 @@ export const createTerminalSessionStarters = (ctx: TerminalSessionStartersContex
|
||||
requiresMfa: !!ctx.host.requiresMfa,
|
||||
port: ctx.host.port || 22,
|
||||
password: attempt.password,
|
||||
...connectionTotpFields(ctx.host),
|
||||
sftpReuseOptions: sftpReuseOptions?.password || sftpReuseOptions?.username !== effectiveUsername
|
||||
? undefined
|
||||
: sftpReuseOptions,
|
||||
|
||||
@@ -1,24 +0,0 @@
|
||||
import assert from "node:assert/strict";
|
||||
import { readFileSync } from "node:fs";
|
||||
import test from "node:test";
|
||||
|
||||
const toastSource = readFileSync(new URL("./toast.tsx", import.meta.url), "utf8");
|
||||
|
||||
test("toast surfaces follow app theme tokens instead of OS palette fills", () => {
|
||||
const styles = toastSource.slice(
|
||||
toastSource.indexOf("const TOAST_STYLES"),
|
||||
toastSource.indexOf("export const ToastProvider"),
|
||||
);
|
||||
|
||||
assert.match(styles, /success: 'border-emerald-600'/);
|
||||
assert.match(styles, /error: 'border-red-600'/);
|
||||
assert.match(styles, /warning: 'border-yellow-600'/);
|
||||
assert.match(styles, /info: 'border-blue-600'/);
|
||||
// `dark:` is prefers-color-scheme, so these fills stay pale while
|
||||
// text-foreground / text-muted-foreground follow the `.dark` class.
|
||||
assert.doesNotMatch(styles, /bg-(?:emerald|red|yellow|blue)-50/);
|
||||
assert.doesNotMatch(styles, /dark:bg-(?:emerald|red|yellow|blue)-950/);
|
||||
assert.match(toastSource, /"bg-card animate-in/);
|
||||
assert.match(toastSource, /text-sm font-medium text-foreground/);
|
||||
assert.match(toastSource, /text-sm text-muted-foreground break-words/);
|
||||
});
|
||||
@@ -82,15 +82,11 @@ const TOAST_ICONS: Record<ToastType, React.ReactNode> = {
|
||||
info: <Info className="h-4 w-4 text-blue-500" />,
|
||||
};
|
||||
|
||||
// Colored `bg-*-50` / `dark:bg-*-950` fills follow the OS color scheme.
|
||||
// Title and body use `.dark` tokens (`text-foreground`, `text-muted-foreground`),
|
||||
// so an app-dark / OS-light session paints light text on a pale fill.
|
||||
// The container's `bg-card` tracks the app theme; the border and icon mark the variant.
|
||||
const TOAST_STYLES: Record<ToastType, string> = {
|
||||
success: 'border-emerald-600',
|
||||
error: 'border-red-600',
|
||||
warning: 'border-yellow-600',
|
||||
info: 'border-blue-600',
|
||||
success: 'border-emerald-600 bg-emerald-50 dark:bg-emerald-950',
|
||||
error: 'border-red-600 bg-red-50 dark:bg-red-950',
|
||||
warning: 'border-yellow-600 bg-yellow-50 dark:bg-yellow-950',
|
||||
info: 'border-blue-600 bg-blue-50 dark:bg-blue-950',
|
||||
};
|
||||
|
||||
export const ToastProvider: React.FC<{ children: React.ReactNode }> = ({ children }) => {
|
||||
|
||||
@@ -360,36 +360,6 @@ test("VaultHostListSection exposes normal group cards to keyboard", () => {
|
||||
assert.match(markup, /data-group-path="production"[^>]*tabindex="0"/);
|
||||
});
|
||||
|
||||
test("VaultHostListSection keeps selected host cards draggable and leaves unselected cards in place", () => {
|
||||
const otherHost = makeHost("other-host", "Other Router");
|
||||
const markup = renderHostList({
|
||||
viewMode: "list",
|
||||
displayedGroups: [],
|
||||
displayedHosts: [mainHost, otherHost],
|
||||
visibleDisplayedHosts: [mainHost, otherHost],
|
||||
isMultiSelectMode: true,
|
||||
selectedHostIds: new Set([mainHost.id]),
|
||||
});
|
||||
|
||||
assert.match(markup, /data-host-id="main-host"[^>]*draggable="true"/);
|
||||
assert.match(markup, /data-host-id="other-host"[^>]*draggable="false"/);
|
||||
const source = readFileSync(new URL("./VaultHostListSection.tsx", import.meta.url), "utf8");
|
||||
assert.match(source, /useVaultHostMarquee/);
|
||||
assert.match(source, /data-vault-marquee/);
|
||||
assert.match(source, /moveHostsToGroup/);
|
||||
});
|
||||
|
||||
test("VaultHostListSection keeps a normal host card draggable outside multi-select", () => {
|
||||
const markup = renderHostList({
|
||||
viewMode: "grid",
|
||||
displayedGroups: [],
|
||||
displayedHosts: [mainHost],
|
||||
visibleDisplayedHosts: [mainHost],
|
||||
});
|
||||
|
||||
assert.match(markup, /data-host-id="main-host"[^>]*draggable="true"/);
|
||||
});
|
||||
|
||||
test("VaultHostListSection exposes ungrouped hosts to keyboard and assistive technology", () => {
|
||||
const markup = renderHostList({
|
||||
viewMode: "list",
|
||||
|
||||
@@ -27,10 +27,7 @@ import {
|
||||
import type { GroupNode, Host } from "../../domain/models";
|
||||
import { isPluginHostProtocol } from "../../domain/pluginConnection";
|
||||
import { OpenDualPaneSftpMenuItem, OpenHostBrowserMenuItem } from "../host/HostTreeContextMenus";
|
||||
import { VAULT_HOST_DRAG_IDS, isVaultHostDraggable } from "../../domain/vaultHostDrag";
|
||||
import { getVaultHostRowSubtitle } from "../../domain/localShellHost";
|
||||
import { beginVaultHostCardDrag } from "./vaultHostDragImage";
|
||||
import { useVaultHostMarquee } from "./useVaultHostMarquee";
|
||||
|
||||
type VaultHostListSectionContext = Record<string, any>;
|
||||
|
||||
@@ -57,15 +54,12 @@ const isRelatedTargetInside = (
|
||||
};
|
||||
|
||||
const EMPTY_GROUP_PATH_SET = new Set<string>();
|
||||
const EMPTY_HOST_ID_SET: ReadonlySet<string> = new Set();
|
||||
const noopSetMultiSelectMode = (_enabled: boolean) => {};
|
||||
const noopSetSelectedHostIds = (_ids: Set<string>) => {};
|
||||
|
||||
export function VaultHostListSection({ ctx }: { ctx: VaultHostListSectionContext }) {
|
||||
const { Badge, Boolean, Button, cancelInlineGroupEdit, CheckSquare, ClipboardCopy, Clock, cn, commitInlineGroupRename, ContextMenu, ContextMenuContent, ContextMenuItem, ContextMenuTrigger, Copy, displayedGroups, displayedHosts, DistroAvatar, Edit2, FileSymlink, FolderPlus, FolderTree, getDropTargetClasses, getEffectiveHostDistro, groupConfigs, groupedDisplayHosts, handleCopyCredentials, handleCopyHostname, handleDuplicateHost, handleEditGroupConfig, handleEditHost, handleHostConnect, handleOpenHostBrowser, hostClickBehavior: hostClickBehaviorProp, handleUnmanageGroup, hasHostsSidePanel, hostListScrollRef, HostTreeView, isHostsSectionActive, isMultiSelectMode, lastPinnedId, LayoutGrid, managedGroupPaths, moveGroup, moveHostToGroup, moveHostsToGroup, onDeleteHost, Pin, pinnedHosts, Plug, recentHosts, reorderGroup, reorderHost, sanitizeHost, search, selectedGroupPath, selectedGroupPaths, selectedHostIds, selectedTags, sessionCount, setDeleteTargetPath, setDragOverDropTarget, setGroupDragOverDropTarget, setIsDeleteGroupOpen, setIsMultiSelectMode = noopSetMultiSelectMode, setIsNewFolderOpen, setLastPinnedId, setNewFolderName, setSelectedGroupPath, setSelectedHostIds = noopSetSelectedHostIds, setTargetParentPath, shouldHideEmptyRootHostsSection, showRecentHosts, sortMode, Square, Star, startInlineDeleteGroup, startInlineNewGroup, startInlineRenameGroup, t, toggleGroupSelection, toggleHostPinned, toggleHostSelection, Trash2, treeExpandedState, treeViewGroupTree, treeViewHosts, viewMode, visibleDisplayedHosts } = ctx;
|
||||
const { Badge, Boolean, Button, cancelInlineGroupEdit, CheckSquare, ClipboardCopy, Clock, cn, commitInlineGroupRename, ContextMenu, ContextMenuContent, ContextMenuItem, ContextMenuTrigger, Copy, displayedGroups, displayedHosts, DistroAvatar, Edit2, FileSymlink, FolderPlus, FolderTree, getDropTargetClasses, getEffectiveHostDistro, groupConfigs, groupedDisplayHosts, handleCopyCredentials, handleCopyHostname, handleDuplicateHost, handleEditGroupConfig, handleEditHost, handleHostConnect, handleOpenHostBrowser, hostClickBehavior: hostClickBehaviorProp, handleUnmanageGroup, hasHostsSidePanel, hostListScrollRef, HostTreeView, isHostsSectionActive, isMultiSelectMode, lastPinnedId, LayoutGrid, managedGroupPaths, moveGroup, moveHostToGroup, onDeleteHost, Pin, pinnedHosts, Plug, recentHosts, reorderGroup, reorderHost, sanitizeHost, search, selectedGroupPath, selectedGroupPaths, selectedHostIds, selectedTags, sessionCount, setDeleteTargetPath, setDragOverDropTarget, setGroupDragOverDropTarget, setIsDeleteGroupOpen, setIsNewFolderOpen, setLastPinnedId, setNewFolderName, setSelectedGroupPath, setTargetParentPath, shouldHideEmptyRootHostsSection, showRecentHosts, sortMode, Square, Star, startInlineDeleteGroup, startInlineNewGroup, startInlineRenameGroup, t, toggleGroupSelection, toggleHostPinned, toggleHostSelection, Trash2, treeExpandedState, treeViewGroupTree, treeViewHosts, viewMode, visibleDisplayedHosts } = ctx;
|
||||
const hostClickBehavior: HostClickBehavior = hostClickBehaviorProp === 'select' ? 'select' : 'connect';
|
||||
const multiSelectedGroupPaths: Set<string> = selectedGroupPaths ?? EMPTY_GROUP_PATH_SET;
|
||||
const [draggingHostIds, setDraggingHostIds] = React.useState<ReadonlySet<string> | null>(null);
|
||||
const [draggingHostId, setDraggingHostId] = React.useState<string | null>(null);
|
||||
const draggingHostIdRef = React.useRef<string | null>(null);
|
||||
const lastPreviewReorderRef = React.useRef<string | null>(null);
|
||||
const prepareGridLayoutAnimation = useVaultGridLayoutAnimation(hostListScrollRef);
|
||||
@@ -126,19 +120,6 @@ export function VaultHostListSection({ ctx }: { ctx: VaultHostListSectionContext
|
||||
setFocusedGroupPath(null);
|
||||
}, [selectedGroupPath, viewMode, hostClickBehavior]);
|
||||
|
||||
const {
|
||||
marqueeRect,
|
||||
onPointerDown: onMarqueePointerDown,
|
||||
onClickCapture: onMarqueeClickCapture,
|
||||
} = useVaultHostMarquee({
|
||||
containerRef: hostListScrollRef,
|
||||
viewMode,
|
||||
isMultiSelectMode: Boolean(isMultiSelectMode),
|
||||
selectedHostIds: selectedHostIds ?? EMPTY_HOST_ID_SET,
|
||||
setIsMultiSelectMode,
|
||||
setSelectedHostIds,
|
||||
});
|
||||
|
||||
const activateHost = React.useCallback((host: Host) => {
|
||||
const action = resolveHostActivateAction({
|
||||
behavior: hostClickBehavior,
|
||||
@@ -303,25 +284,17 @@ export function VaultHostListSection({ ctx }: { ctx: VaultHostListSectionContext
|
||||
|
||||
const resetHostDragState = React.useCallback(() => {
|
||||
draggingHostIdRef.current = null;
|
||||
setDraggingHostIds(null);
|
||||
setDraggingHostId(null);
|
||||
lastPreviewReorderRef.current = null;
|
||||
setDragOverDropTarget(null);
|
||||
}, [setDragOverDropTarget]);
|
||||
|
||||
const handleHostDragStart = React.useCallback((e: React.DragEvent, hostId: string) => {
|
||||
const ids = beginVaultHostCardDrag({
|
||||
dataTransfer: e.dataTransfer,
|
||||
hostId,
|
||||
isMultiSelectMode: !!isMultiSelectMode,
|
||||
selectedHostIds,
|
||||
countLabel: (count) => t("vault.hosts.dragSelectionCount", { count }),
|
||||
});
|
||||
if (!ids) {
|
||||
e.preventDefault();
|
||||
return;
|
||||
}
|
||||
// copyMove: vault reorder uses move; focus-sidebar append uses copy.
|
||||
e.dataTransfer.effectAllowed = "copyMove";
|
||||
e.dataTransfer.setData("host-id", hostId);
|
||||
draggingHostIdRef.current = hostId;
|
||||
setDraggingHostIds(new Set(ids));
|
||||
setDraggingHostId(hostId);
|
||||
lastPreviewReorderRef.current = null;
|
||||
|
||||
// Grid preview reorder can move the card into another virtual row, and rows
|
||||
@@ -335,7 +308,7 @@ export function VaultHostListSection({ ctx }: { ctx: VaultHostListSectionContext
|
||||
resetHostDragState();
|
||||
};
|
||||
sourceNode.addEventListener("dragend", handleNativeDragEnd);
|
||||
}, [isMultiSelectMode, resetHostDragState, selectedHostIds, t]);
|
||||
}, [resetHostDragState]);
|
||||
|
||||
const renderHostEditButton = (host: any, compact = false) => (
|
||||
<Button
|
||||
@@ -378,21 +351,14 @@ export function VaultHostListSection({ ctx }: { ctx: VaultHostListSectionContext
|
||||
return <div
|
||||
ref={hostListScrollRef}
|
||||
className={cn(
|
||||
"relative flex-1 overflow-auto px-4 pb-4 [&>:not([data-vault-marquee])~:not([data-vault-marquee])]:mt-3",
|
||||
"flex-1 overflow-auto px-4 pb-4 space-y-3",
|
||||
viewMode === "tree" ? "pt-1.5" : "pt-0",
|
||||
!isHostsSectionActive && "hidden",
|
||||
marqueeRect && "select-none",
|
||||
)}
|
||||
data-section="vault-host-list"
|
||||
onClick={handleHostListClick}
|
||||
onClickCapture={onMarqueeClickCapture}
|
||||
onPointerDown={onMarqueePointerDown}
|
||||
onDragOverCapture={(e) => {
|
||||
const target = (e.target as Element | null)?.closest("[data-host-id], [data-group-path]");
|
||||
if (hasVaultDragType(e.dataTransfer, VAULT_HOST_DRAG_IDS)) {
|
||||
const groupTarget = target instanceof HTMLElement && target.hasAttribute("data-group-path");
|
||||
if (!groupTarget) return;
|
||||
}
|
||||
if (target) e.preventDefault();
|
||||
if (!(target instanceof HTMLElement)) return;
|
||||
const draggedGroupPath = e.dataTransfer.getData("group-path");
|
||||
@@ -426,10 +392,6 @@ export function VaultHostListSection({ ctx }: { ctx: VaultHostListSectionContext
|
||||
}}
|
||||
onDragOver={(e) => {
|
||||
const target = (e.target as Element | null)?.closest("[data-host-id], [data-group-path]");
|
||||
if (hasVaultDragType(e.dataTransfer, VAULT_HOST_DRAG_IDS)) {
|
||||
const groupTarget = target instanceof HTMLElement && target.hasAttribute("data-group-path");
|
||||
if (!groupTarget) return;
|
||||
}
|
||||
if (!(target instanceof HTMLElement) || viewMode === "grid") return;
|
||||
const draggedGroupPath = e.dataTransfer.getData("group-path");
|
||||
const isDraggingGroup = hasVaultDragType(e.dataTransfer, "group-path");
|
||||
@@ -457,12 +419,7 @@ export function VaultHostListSection({ ctx }: { ctx: VaultHostListSectionContext
|
||||
if (!(target instanceof HTMLElement)) return;
|
||||
const targetHostId = target.getAttribute("data-host-id");
|
||||
const targetGroupPath = target.getAttribute("data-group-path");
|
||||
if (
|
||||
draggedHostId
|
||||
&& targetHostId
|
||||
&& draggedHostId !== targetHostId
|
||||
&& !hasVaultDragType(e.dataTransfer, VAULT_HOST_DRAG_IDS)
|
||||
) {
|
||||
if (draggedHostId && targetHostId && draggedHostId !== targetHostId) {
|
||||
e.preventDefault();
|
||||
e.stopPropagation();
|
||||
const position = getVaultDropPosition(target, e.clientX, e.clientY, viewMode === "grid");
|
||||
@@ -490,19 +447,6 @@ export function VaultHostListSection({ ctx }: { ctx: VaultHostListSectionContext
|
||||
resetHostDragState();
|
||||
}}
|
||||
>
|
||||
{marqueeRect && (
|
||||
<div
|
||||
data-vault-marquee="true"
|
||||
aria-hidden="true"
|
||||
className="pointer-events-none absolute z-20 border border-primary/80 bg-primary/15"
|
||||
style={{
|
||||
left: marqueeRect.left,
|
||||
top: marqueeRect.top,
|
||||
width: marqueeRect.right - marqueeRect.left,
|
||||
height: marqueeRect.bottom - marqueeRect.top,
|
||||
}}
|
||||
/>
|
||||
)}
|
||||
{viewMode !== "tree" && (
|
||||
<section className="space-y-2 pt-2">
|
||||
<div className="flex items-center gap-2 text-sm font-semibold">
|
||||
@@ -532,7 +476,6 @@ export function VaultHostListSection({ ctx }: { ctx: VaultHostListSectionContext
|
||||
setDragOverDropTarget,
|
||||
moveGroup,
|
||||
moveHostToGroup,
|
||||
moveHostsToGroup,
|
||||
resetHostDragState,
|
||||
});
|
||||
}}
|
||||
@@ -603,7 +546,7 @@ export function VaultHostListSection({ ctx }: { ctx: VaultHostListSectionContext
|
||||
viewMode === "grid"
|
||||
? cn(
|
||||
"soft-card elevate rounded-xl h-[68px] px-3 py-2 will-change-transform transition-[opacity,box-shadow,border-color,background-color] duration-150",
|
||||
draggingHostIds?.has(host.id) && "opacity-45",
|
||||
draggingHostId === host.id && "opacity-45",
|
||||
hostCardFocusClassName(viewMode, isHostFocusSelected(host.id)),
|
||||
)
|
||||
: cn(
|
||||
@@ -622,7 +565,7 @@ export function VaultHostListSection({ ctx }: { ctx: VaultHostListSectionContext
|
||||
tabIndex={getHostTabIndex(host.id)}
|
||||
style={lastPinnedId === host.id ? { animation: "pop-in 0.4s cubic-bezier(0.34, 1.56, 0.64, 1) both" } : undefined}
|
||||
onAnimationEnd={() => { if (lastPinnedId === host.id) setLastPinnedId(null); }}
|
||||
draggable={isVaultHostDraggable(Boolean(isMultiSelectMode), selectedHostIds, host.id)}
|
||||
draggable={!isMultiSelectMode}
|
||||
onDragStart={(e) => handleHostDragStart(e, host.id)}
|
||||
onClick={() => {
|
||||
activateHost(safeHost);
|
||||
@@ -729,7 +672,7 @@ export function VaultHostListSection({ ctx }: { ctx: VaultHostListSectionContext
|
||||
viewMode === "grid"
|
||||
? cn(
|
||||
"soft-card elevate rounded-xl h-[68px] px-3 py-2 will-change-transform transition-[opacity,box-shadow,border-color,background-color] duration-150",
|
||||
draggingHostIds?.has(host.id) && "opacity-45",
|
||||
draggingHostId === host.id && "opacity-45",
|
||||
hostCardFocusClassName(viewMode, isHostFocusSelected(host.id)),
|
||||
)
|
||||
: cn(
|
||||
@@ -746,7 +689,7 @@ export function VaultHostListSection({ ctx }: { ctx: VaultHostListSectionContext
|
||||
? selectedHostIds.has(host.id)
|
||||
: undefined}
|
||||
tabIndex={getHostTabIndex(host.id)}
|
||||
draggable={isVaultHostDraggable(Boolean(isMultiSelectMode), selectedHostIds, host.id)}
|
||||
draggable={!isMultiSelectMode}
|
||||
onDragStart={(e) => handleHostDragStart(e, host.id)}
|
||||
onClick={() => {
|
||||
activateHost(safeHost);
|
||||
@@ -851,7 +794,6 @@ export function VaultHostListSection({ ctx }: { ctx: VaultHostListSectionContext
|
||||
dataTransfer: e.dataTransfer,
|
||||
groupPath: selectedGroupPath,
|
||||
moveHostToGroup,
|
||||
moveHostsToGroup,
|
||||
resetHostDragState,
|
||||
})) return;
|
||||
const groupPath = e.dataTransfer.getData("group-path");
|
||||
@@ -932,7 +874,6 @@ export function VaultHostListSection({ ctx }: { ctx: VaultHostListSectionContext
|
||||
dataTransfer: e.dataTransfer,
|
||||
groupPath: node.path,
|
||||
moveHostToGroup,
|
||||
moveHostsToGroup,
|
||||
resetHostDragState,
|
||||
})) return;
|
||||
const groupPath =
|
||||
@@ -1042,7 +983,6 @@ export function VaultHostListSection({ ctx }: { ctx: VaultHostListSectionContext
|
||||
cancelInlineGroupEdit={cancelInlineGroupEdit}
|
||||
onDeleteGroup={startInlineDeleteGroup}
|
||||
moveHostToGroup={moveHostToGroup}
|
||||
moveHostsToGroup={moveHostsToGroup}
|
||||
moveGroup={moveGroup}
|
||||
managedGroupPaths={managedGroupPaths}
|
||||
onUnmanageGroup={handleUnmanageGroup}
|
||||
@@ -1101,7 +1041,7 @@ export function VaultHostListSection({ ctx }: { ctx: VaultHostListSectionContext
|
||||
viewMode === "grid"
|
||||
? cn(
|
||||
"soft-card elevate rounded-xl h-[68px] px-3 py-2 will-change-transform transition-[opacity,box-shadow,border-color,background-color] duration-150",
|
||||
draggingHostIds?.has(host.id) && "opacity-45",
|
||||
draggingHostId === host.id && "opacity-45",
|
||||
hostCardFocusClassName(viewMode, isHostFocusSelected(host.id)),
|
||||
)
|
||||
: cn(
|
||||
@@ -1118,7 +1058,7 @@ export function VaultHostListSection({ ctx }: { ctx: VaultHostListSectionContext
|
||||
? selectedHostIds.has(host.id)
|
||||
: undefined}
|
||||
tabIndex={getHostTabIndex(host.id)}
|
||||
draggable={isVaultHostDraggable(Boolean(isMultiSelectMode), selectedHostIds, host.id)}
|
||||
draggable={!isMultiSelectMode}
|
||||
onDragStart={(e) => handleHostDragStart(e, host.id)}
|
||||
onClick={() => {
|
||||
activateHost(safeHost);
|
||||
@@ -1252,7 +1192,7 @@ export function VaultHostListSection({ ctx }: { ctx: VaultHostListSectionContext
|
||||
viewMode === "grid"
|
||||
? cn(
|
||||
"soft-card elevate rounded-xl h-[68px] px-3 py-2 will-change-transform transition-[opacity,box-shadow,border-color,background-color] duration-150",
|
||||
draggingHostIds?.has(host.id) && "opacity-45",
|
||||
draggingHostId === host.id && "opacity-45",
|
||||
hostCardFocusClassName(viewMode, isHostFocusSelected(host.id)),
|
||||
)
|
||||
: cn(
|
||||
@@ -1269,7 +1209,7 @@ export function VaultHostListSection({ ctx }: { ctx: VaultHostListSectionContext
|
||||
? selectedHostIds.has(host.id)
|
||||
: undefined}
|
||||
tabIndex={getHostTabIndex(host.id)}
|
||||
draggable={isVaultHostDraggable(Boolean(isMultiSelectMode), selectedHostIds, host.id)}
|
||||
draggable={!isMultiSelectMode}
|
||||
onDragStart={(e) => handleHostDragStart(e, host.id)}
|
||||
onClick={() => {
|
||||
activateHost(safeHost);
|
||||
|
||||
@@ -1007,43 +1007,42 @@ export function VaultViewLayout({ ctx }: { ctx: VaultViewLayoutContext }) {
|
||||
</Button>
|
||||
</DropdownTrigger>
|
||||
</div>
|
||||
{/* Fixed w-44 flex-shrinks the shell icon when the label is longer than the siblings. */}
|
||||
<DropdownContent className="flex w-max min-w-44 flex-col" align="end" alignToParent>
|
||||
<DropdownContent className="w-44" align="end" alignToParent>
|
||||
<Button
|
||||
variant="ghost"
|
||||
className="justify-start gap-2"
|
||||
className="w-full justify-start gap-2"
|
||||
onClick={handleNewLocalShellHost}
|
||||
>
|
||||
<TerminalSquare size={14} className="shrink-0" /> {t("vault.hosts.newLocalShell")}
|
||||
<TerminalSquare size={14} /> {t("vault.hosts.newLocalShell")}
|
||||
</Button>
|
||||
<Button
|
||||
variant="ghost"
|
||||
className="justify-start gap-2"
|
||||
className="w-full justify-start gap-2"
|
||||
onClick={() => {
|
||||
setTargetParentPath(selectedGroupPath);
|
||||
setNewFolderName("");
|
||||
setIsNewFolderOpen(true);
|
||||
}}
|
||||
>
|
||||
<FolderTree size={14} className="shrink-0" /> {t("vault.hosts.newGroup")}
|
||||
<FolderTree size={14} /> {t("vault.hosts.newGroup")}
|
||||
</Button>
|
||||
<Button
|
||||
variant="ghost"
|
||||
className="justify-start gap-2"
|
||||
className="w-full justify-start gap-2"
|
||||
disabled={importProgress?.status === "running"}
|
||||
onClick={() => {
|
||||
if (importProgress?.status === "running") return;
|
||||
setIsImportOpen(true);
|
||||
}}
|
||||
>
|
||||
<Upload size={14} className="shrink-0" /> {t("vault.hosts.import")}
|
||||
<Upload size={14} /> {t("vault.hosts.import")}
|
||||
</Button>
|
||||
<Button
|
||||
variant="ghost"
|
||||
className="justify-start gap-2"
|
||||
className="w-full justify-start gap-2"
|
||||
onClick={handleExportHosts}
|
||||
>
|
||||
<Download size={14} className="shrink-0" /> {t("vault.hosts.export")}
|
||||
<Download size={14} /> {t("vault.hosts.export")}
|
||||
</Button>
|
||||
</DropdownContent>
|
||||
</Dropdown>
|
||||
@@ -1237,7 +1236,6 @@ export function VaultViewLayout({ ctx }: { ctx: VaultViewLayoutContext }) {
|
||||
managedGroupPaths,
|
||||
moveGroup,
|
||||
moveHostToGroup,
|
||||
moveHostsToGroup,
|
||||
onDeleteHost,
|
||||
Pin,
|
||||
pinnedHosts,
|
||||
@@ -1257,12 +1255,10 @@ export function VaultViewLayout({ ctx }: { ctx: VaultViewLayoutContext }) {
|
||||
setDragOverDropTarget,
|
||||
setGroupDragOverDropTarget,
|
||||
setIsDeleteGroupOpen,
|
||||
setIsMultiSelectMode,
|
||||
setIsNewFolderOpen,
|
||||
setLastPinnedId,
|
||||
setNewFolderName,
|
||||
setSelectedGroupPath,
|
||||
setSelectedHostIds,
|
||||
setTargetParentPath,
|
||||
shouldHideEmptyRootHostsSection,
|
||||
showRecentHosts,
|
||||
|
||||
@@ -3,7 +3,6 @@ import test from 'node:test';
|
||||
import React from 'react';
|
||||
import { act, create, type ReactTestRenderer } from 'react-test-renderer';
|
||||
import type { Host, ManagedSource } from '../../types';
|
||||
import { handleVaultHostDropToGroup } from './vaultReorderDrag';
|
||||
import { useVaultGroupDragHandlers } from './useVaultGroupDragHandlers';
|
||||
|
||||
const host = (id: string, extra: Partial<Host> = {}): Host => ({
|
||||
@@ -45,37 +44,6 @@ test('mixed protocols entering and leaving managed groups retain single-host mov
|
||||
}, writes);
|
||||
});
|
||||
|
||||
test('a multi-id group drop publishes the whole selection through the batched move', async () => {
|
||||
const original = [host('ssh'), host('telnet', { protocol: 'telnet' }), host('local', { protocol: 'local' })];
|
||||
const sources = [{ id: 'parent', groupName: 'Managed' }, { id: 'child', groupName: 'Managed/Nested' }] as ManagedSource[];
|
||||
const writes: Host[][] = [];
|
||||
await withActions(original, sources, actions => {
|
||||
let handled = false;
|
||||
act(() => {
|
||||
handled = handleVaultHostDropToGroup({
|
||||
dataTransfer: {
|
||||
getData: (type: string) => type === 'host-ids' ? JSON.stringify(['ssh', 'telnet', 'local']) : '',
|
||||
},
|
||||
groupPath: 'Managed/Nested',
|
||||
moveHostToGroup: () => {
|
||||
throw new Error('multi-id drop must not move hosts one at a time');
|
||||
},
|
||||
moveHostsToGroup: actions.moveHostsToGroup,
|
||||
resetHostDragState: () => {},
|
||||
});
|
||||
});
|
||||
assert.equal(handled, true);
|
||||
assert.equal(writes.length, 1);
|
||||
assert.deepEqual(writes[0].map(item => item.group), ['Managed/Nested', 'Managed/Nested', 'Managed/Nested']);
|
||||
assert.equal(writes[0][0].managedSourceId, 'child');
|
||||
assert.equal(writes[0][0].label, 'Hostssh');
|
||||
assert.equal(writes[0][1].managedSourceId, undefined);
|
||||
assert.equal(writes[0][1].label, 'Host telnet');
|
||||
assert.equal(writes[0][2].managedSourceId, undefined);
|
||||
assert.equal(writes[0][2].label, 'Host local');
|
||||
}, writes);
|
||||
});
|
||||
|
||||
test('same-group, missing and empty selections do not publish changes', async () => {
|
||||
const writes: Host[][] = [];
|
||||
await withActions([host('a')], [], actions => {
|
||||
|
||||
@@ -1,277 +0,0 @@
|
||||
import React, { useCallback, useEffect, useRef, useState } from "react";
|
||||
|
||||
import {
|
||||
clientPointToContent,
|
||||
contentRectFromClientBox,
|
||||
exceedsMarqueeThreshold,
|
||||
hostIdsIntersectingMarquee,
|
||||
isPointerOnNativeScrollbar,
|
||||
marqueeAutoScrollDelta,
|
||||
marqueeCancelSelection,
|
||||
marqueeCommitSelection,
|
||||
marqueeRectFromPoints,
|
||||
marqueeSelectionMode,
|
||||
mergeMarqueeSelection,
|
||||
rememberHostPlacements,
|
||||
shouldStartVaultMarquee,
|
||||
type HostPlacement,
|
||||
type MarqueeContentFrame,
|
||||
type MarqueeRect,
|
||||
} from "../../domain/vaultMarqueeSelection";
|
||||
|
||||
const MARQUEE_BLOCKED_SELECTOR = [
|
||||
"[data-host-id]",
|
||||
"[data-group-path]",
|
||||
"button",
|
||||
"input",
|
||||
"textarea",
|
||||
"select",
|
||||
"a",
|
||||
"label",
|
||||
"[role='scrollbar']",
|
||||
].join(",");
|
||||
|
||||
const selectionSignature = (ids: ReadonlySet<string>) => [...ids].sort().join("\0");
|
||||
|
||||
const elementFromEventTarget = (target: EventTarget | null): Element | null => {
|
||||
if (target instanceof Element) return target;
|
||||
if (typeof Node !== "undefined" && target instanceof Node) return target.parentElement;
|
||||
return null;
|
||||
};
|
||||
|
||||
const isBlockedMarqueeTarget = (target: EventTarget | null): boolean => {
|
||||
const element = elementFromEventTarget(target);
|
||||
if (!element) return true;
|
||||
return Boolean(element.closest(MARQUEE_BLOCKED_SELECTOR));
|
||||
};
|
||||
|
||||
const readFrame = (container: HTMLElement): MarqueeContentFrame => {
|
||||
const rect = container.getBoundingClientRect();
|
||||
return {
|
||||
left: rect.left,
|
||||
top: rect.top,
|
||||
scrollLeft: container.scrollLeft,
|
||||
scrollTop: container.scrollTop,
|
||||
clientLeft: container.clientLeft,
|
||||
clientTop: container.clientTop,
|
||||
};
|
||||
};
|
||||
|
||||
const readVisibleHostPlacements = (container: HTMLElement): HostPlacement[] => {
|
||||
const frame = readFrame(container);
|
||||
const placements: HostPlacement[] = [];
|
||||
container.querySelectorAll<HTMLElement>("[data-host-id]").forEach((node, index) => {
|
||||
const id = node.dataset.hostId;
|
||||
if (!id) return;
|
||||
const box = node.getBoundingClientRect();
|
||||
placements.push({
|
||||
key: node.dataset.vaultGridItem || `${id}:${index}`,
|
||||
id,
|
||||
rect: contentRectFromClientBox(box, frame),
|
||||
});
|
||||
});
|
||||
return placements;
|
||||
};
|
||||
|
||||
interface UseVaultHostMarqueeOptions {
|
||||
containerRef: React.RefObject<HTMLElement | null>;
|
||||
viewMode: string;
|
||||
isMultiSelectMode: boolean;
|
||||
selectedHostIds: ReadonlySet<string>;
|
||||
setIsMultiSelectMode?: (value: boolean) => void;
|
||||
setSelectedHostIds?: (ids: Set<string>) => void;
|
||||
}
|
||||
|
||||
export function useVaultHostMarquee({
|
||||
containerRef,
|
||||
viewMode,
|
||||
isMultiSelectMode,
|
||||
selectedHostIds,
|
||||
setIsMultiSelectMode,
|
||||
setSelectedHostIds,
|
||||
}: UseVaultHostMarqueeOptions) {
|
||||
const [marqueeRect, setMarqueeRect] = useState<MarqueeRect | null>(null);
|
||||
const suppressClickRef = useRef(false);
|
||||
const sessionRef = useRef(0);
|
||||
const stopGestureRef = useRef<(() => void) | null>(null);
|
||||
|
||||
useEffect(() => () => stopGestureRef.current?.(), []);
|
||||
|
||||
const onPointerDown = useCallback((event: React.PointerEvent<HTMLElement>) => {
|
||||
if (suppressClickRef.current) suppressClickRef.current = false;
|
||||
const container = containerRef.current;
|
||||
if (!container || !setSelectedHostIds || !setIsMultiSelectMode) return;
|
||||
const bounds = container.getBoundingClientRect();
|
||||
if (!shouldStartVaultMarquee({
|
||||
viewMode,
|
||||
button: event.button,
|
||||
pointerType: event.pointerType,
|
||||
onScrollbar: isPointerOnNativeScrollbar({
|
||||
rectLeft: bounds.left,
|
||||
rectTop: bounds.top,
|
||||
clientLeft: container.clientLeft,
|
||||
clientTop: container.clientTop,
|
||||
clientWidth: container.clientWidth,
|
||||
clientHeight: container.clientHeight,
|
||||
}, event.clientX, event.clientY),
|
||||
blockedTarget: isBlockedMarqueeTarget(event.target),
|
||||
})) return;
|
||||
|
||||
stopGestureRef.current?.();
|
||||
const startContent = clientPointToContent(
|
||||
{ x: event.clientX, y: event.clientY },
|
||||
readFrame(container),
|
||||
);
|
||||
const baseline = new Set(selectedHostIds);
|
||||
const wasMultiSelectMode = Boolean(isMultiSelectMode);
|
||||
const mode = marqueeSelectionMode(event);
|
||||
const cache = new Map<string, HostPlacement>();
|
||||
const session = sessionRef.current + 1;
|
||||
sessionRef.current = session;
|
||||
let active = false;
|
||||
let scrollDelta = 0;
|
||||
let frameId = 0;
|
||||
let lastClientX = event.clientX;
|
||||
let lastClientY = event.clientY;
|
||||
let lastSignature = selectionSignature(baseline);
|
||||
let lastMode = wasMultiSelectMode;
|
||||
|
||||
const publish = (nextIds: Set<string>, modeEnabled: boolean) => {
|
||||
const signature = selectionSignature(nextIds);
|
||||
if (signature !== lastSignature) {
|
||||
lastSignature = signature;
|
||||
setSelectedHostIds(nextIds);
|
||||
}
|
||||
if (modeEnabled !== lastMode) {
|
||||
lastMode = modeEnabled;
|
||||
setIsMultiSelectMode(modeEnabled);
|
||||
}
|
||||
};
|
||||
|
||||
const applyAt = (clientX: number, clientY: number) => {
|
||||
if (sessionRef.current !== session) return;
|
||||
const current = clientPointToContent({ x: clientX, y: clientY }, readFrame(container));
|
||||
if (!active && !exceedsMarqueeThreshold(startContent, current)) return;
|
||||
active = true;
|
||||
const rect = marqueeRectFromPoints(startContent, current);
|
||||
rememberHostPlacements(cache, readVisibleHostPlacements(container));
|
||||
const hits = hostIdsIntersectingMarquee(rect, cache.values());
|
||||
const next = marqueeCommitSelection({
|
||||
wasMultiSelectMode,
|
||||
nextIds: mergeMarqueeSelection(baseline, hits, mode),
|
||||
});
|
||||
setMarqueeRect(rect);
|
||||
publish(next.selectedHostIds, next.isMultiSelectMode);
|
||||
};
|
||||
|
||||
const listeners: {
|
||||
move?: (event: PointerEvent) => void;
|
||||
up?: () => void;
|
||||
cancel?: () => void;
|
||||
key?: (event: KeyboardEvent) => void;
|
||||
scroll?: () => void;
|
||||
} = {};
|
||||
let stopGesture = () => {};
|
||||
|
||||
const finishListeners = () => {
|
||||
if (listeners.move) window.removeEventListener("pointermove", listeners.move);
|
||||
if (listeners.up) window.removeEventListener("pointerup", listeners.up);
|
||||
if (listeners.cancel) window.removeEventListener("pointercancel", listeners.cancel);
|
||||
if (listeners.key) window.removeEventListener("keydown", listeners.key);
|
||||
if (listeners.scroll) container.removeEventListener("scroll", listeners.scroll);
|
||||
if (frameId) cancelAnimationFrame(frameId);
|
||||
frameId = 0;
|
||||
scrollDelta = 0;
|
||||
if (stopGestureRef.current === stopGesture) stopGestureRef.current = null;
|
||||
};
|
||||
|
||||
stopGesture = () => {
|
||||
if (sessionRef.current !== session) return;
|
||||
sessionRef.current += 1;
|
||||
finishListeners();
|
||||
setMarqueeRect(null);
|
||||
};
|
||||
|
||||
const end = (cancel: boolean) => {
|
||||
if (sessionRef.current !== session) return;
|
||||
const wasActive = active;
|
||||
stopGesture();
|
||||
if (!wasActive) return;
|
||||
suppressClickRef.current = true;
|
||||
if (!cancel) return;
|
||||
const restored = marqueeCancelSelection({
|
||||
wasMultiSelectMode,
|
||||
baselineIds: baseline,
|
||||
});
|
||||
lastSignature = "";
|
||||
lastMode = !restored.isMultiSelectMode;
|
||||
publish(restored.selectedHostIds, restored.isMultiSelectMode);
|
||||
};
|
||||
|
||||
const tick = () => {
|
||||
frameId = 0;
|
||||
if (!scrollDelta || sessionRef.current !== session) return;
|
||||
container.scrollTop += scrollDelta;
|
||||
frameId = window.requestAnimationFrame(tick);
|
||||
};
|
||||
|
||||
listeners.move = (pointerEvent: PointerEvent) => {
|
||||
lastClientX = pointerEvent.clientX;
|
||||
lastClientY = pointerEvent.clientY;
|
||||
applyAt(pointerEvent.clientX, pointerEvent.clientY);
|
||||
if (active) {
|
||||
pointerEvent.preventDefault();
|
||||
window.getSelection?.()?.removeAllRanges();
|
||||
}
|
||||
const nextBounds = container.getBoundingClientRect();
|
||||
scrollDelta = active
|
||||
? marqueeAutoScrollDelta(pointerEvent.clientY, nextBounds.top, nextBounds.bottom)
|
||||
: 0;
|
||||
if (scrollDelta && !frameId) frameId = window.requestAnimationFrame(tick);
|
||||
if (!scrollDelta && frameId) {
|
||||
cancelAnimationFrame(frameId);
|
||||
frameId = 0;
|
||||
}
|
||||
};
|
||||
|
||||
listeners.scroll = () => {
|
||||
if (!active) return;
|
||||
applyAt(lastClientX, lastClientY);
|
||||
};
|
||||
listeners.up = () => end(false);
|
||||
listeners.cancel = () => end(true);
|
||||
listeners.key = (keyEvent: KeyboardEvent) => {
|
||||
if (keyEvent.key !== "Escape") return;
|
||||
if (!active) {
|
||||
end(false);
|
||||
return;
|
||||
}
|
||||
keyEvent.preventDefault();
|
||||
keyEvent.stopPropagation();
|
||||
end(true);
|
||||
};
|
||||
|
||||
stopGestureRef.current = stopGesture;
|
||||
window.addEventListener("pointermove", listeners.move);
|
||||
window.addEventListener("pointerup", listeners.up);
|
||||
window.addEventListener("pointercancel", listeners.cancel);
|
||||
window.addEventListener("keydown", listeners.key);
|
||||
container.addEventListener("scroll", listeners.scroll, { passive: true });
|
||||
}, [
|
||||
containerRef,
|
||||
isMultiSelectMode,
|
||||
selectedHostIds,
|
||||
setIsMultiSelectMode,
|
||||
setSelectedHostIds,
|
||||
viewMode,
|
||||
]);
|
||||
|
||||
const onClickCapture = useCallback((event: React.MouseEvent<HTMLElement>) => {
|
||||
if (!suppressClickRef.current) return;
|
||||
suppressClickRef.current = false;
|
||||
event.preventDefault();
|
||||
event.stopPropagation();
|
||||
}, []);
|
||||
|
||||
return { marqueeRect, onPointerDown, onClickCapture };
|
||||
}
|
||||
@@ -1,42 +0,0 @@
|
||||
import {
|
||||
applyVaultHostDragData,
|
||||
resolveVaultHostDragIds,
|
||||
} from "../../domain/vaultHostDrag";
|
||||
|
||||
export function attachVaultHostDragBadge(dataTransfer: DataTransfer, label: string): void {
|
||||
if (typeof document === "undefined") return;
|
||||
const badge = document.createElement("div");
|
||||
badge.textContent = label;
|
||||
badge.setAttribute("data-vault-host-drag-badge", "true");
|
||||
badge.style.position = "fixed";
|
||||
badge.style.top = "-1000px";
|
||||
badge.style.left = "0";
|
||||
badge.style.zIndex = "2147483647";
|
||||
badge.style.padding = "2px 8px";
|
||||
badge.style.borderRadius = "999px";
|
||||
badge.style.background = "#111827";
|
||||
badge.style.color = "#ffffff";
|
||||
badge.style.font = "12px/16px sans-serif";
|
||||
badge.style.pointerEvents = "none";
|
||||
document.body.appendChild(badge);
|
||||
dataTransfer.setDragImage(badge, badge.offsetWidth / 2, badge.offsetHeight / 2);
|
||||
window.setTimeout(() => badge.remove(), 0);
|
||||
}
|
||||
|
||||
export function beginVaultHostCardDrag(input: {
|
||||
dataTransfer: DataTransfer;
|
||||
hostId: string;
|
||||
isMultiSelectMode: boolean;
|
||||
selectedHostIds: ReadonlySet<string> | undefined;
|
||||
countLabel: (count: number) => string;
|
||||
}): string[] | null {
|
||||
const ids = resolveVaultHostDragIds({
|
||||
hostId: input.hostId,
|
||||
isMultiSelectMode: input.isMultiSelectMode,
|
||||
selectedHostIds: input.selectedHostIds ?? new Set<string>(),
|
||||
});
|
||||
if (!ids) return null;
|
||||
applyVaultHostDragData(input.dataTransfer, ids);
|
||||
if (ids.length > 1) attachVaultHostDragBadge(input.dataTransfer, input.countLabel(ids.length));
|
||||
return ids;
|
||||
}
|
||||
@@ -115,58 +115,6 @@ test("group host drop resets host drag state after moving the host to the group"
|
||||
]);
|
||||
});
|
||||
|
||||
test("group drop of a multi-id payload batches every selected host once", () => {
|
||||
const singleCalls: string[] = [];
|
||||
const batchCalls: Array<{ ids: string[]; group: string | null }> = [];
|
||||
|
||||
const handled = handleVaultHostDropToGroup({
|
||||
dataTransfer: {
|
||||
getData: (type: string) => (
|
||||
type === "host-ids" ? JSON.stringify(["a", "b", "c"]) : type === "host-id" ? "a" : ""
|
||||
),
|
||||
},
|
||||
groupPath: "team/nested",
|
||||
moveHostToGroup: (hostId, targetPath) => {
|
||||
singleCalls.push(`${hostId}:${String(targetPath)}`);
|
||||
},
|
||||
moveHostsToGroup: (hostIds, targetPath) => {
|
||||
batchCalls.push({ ids: [...hostIds], group: targetPath });
|
||||
},
|
||||
resetHostDragState: () => {
|
||||
singleCalls.push("reset");
|
||||
},
|
||||
});
|
||||
|
||||
assert.equal(handled, true);
|
||||
assert.deepEqual(singleCalls, ["reset"]);
|
||||
assert.deepEqual(batchCalls, [{ ids: ["a", "b", "c"], group: "team/nested" }]);
|
||||
});
|
||||
|
||||
test("root drop of a multi-id payload batches every selected host into all hosts", () => {
|
||||
const calls: string[] = [];
|
||||
|
||||
handleVaultRootDrop({
|
||||
dataTransfer: {
|
||||
getData: (type: string) => (type === "host-ids" ? JSON.stringify(["a", "b"]) : ""),
|
||||
},
|
||||
preventDefault: () => calls.push("preventDefault"),
|
||||
setDragOverDropTarget: () => calls.push("drop:null"),
|
||||
moveGroup: () => calls.push("group"),
|
||||
moveHostToGroup: () => calls.push("single"),
|
||||
moveHostsToGroup: (hostIds, targetPath) => {
|
||||
calls.push(`batch:${[...hostIds].join(",")}:${String(targetPath)}`);
|
||||
},
|
||||
resetHostDragState: () => calls.push("resetHostDragState"),
|
||||
});
|
||||
|
||||
assert.deepEqual(calls, [
|
||||
"preventDefault",
|
||||
"drop:null",
|
||||
"batch:a,b:null",
|
||||
"resetHostDragState",
|
||||
]);
|
||||
});
|
||||
|
||||
test("group drop without a host leaves host drag state alone", () => {
|
||||
const calls: string[] = [];
|
||||
|
||||
|
||||
@@ -1,10 +1,5 @@
|
||||
import React from "react";
|
||||
|
||||
import {
|
||||
dispatchVaultHostGroupMove,
|
||||
readVaultDraggedHostIds,
|
||||
} from "../../domain/vaultHostDrag";
|
||||
|
||||
export type VaultDropPosition = "before" | "after";
|
||||
export type VaultDropIntent = VaultDropPosition | "inside";
|
||||
export type VaultDropAxis = "x" | "y";
|
||||
@@ -52,7 +47,6 @@ export const handleVaultRootDrop = ({
|
||||
setDragOverDropTarget,
|
||||
moveGroup,
|
||||
moveHostToGroup,
|
||||
moveHostsToGroup,
|
||||
resetHostDragState,
|
||||
}: {
|
||||
dataTransfer: Pick<DataTransfer, "getData">;
|
||||
@@ -60,20 +54,15 @@ export const handleVaultRootDrop = ({
|
||||
setDragOverDropTarget: (target: null) => void;
|
||||
moveGroup: (groupPath: string, targetParentPath: string | null) => void;
|
||||
moveHostToGroup: (hostId: string, targetPath: string | null) => void;
|
||||
moveHostsToGroup?: (hostIds: ReadonlySet<string>, targetPath: string | null) => void;
|
||||
resetHostDragState: () => void;
|
||||
}) => {
|
||||
preventDefault();
|
||||
setDragOverDropTarget(null);
|
||||
const groupPath = dataTransfer.getData("group-path");
|
||||
const hostIds = readVaultDraggedHostIds((type) => dataTransfer.getData(type));
|
||||
const hostId = dataTransfer.getData("host-id");
|
||||
if (groupPath) moveGroup(groupPath, null);
|
||||
if (dispatchVaultHostGroupMove({
|
||||
hostIds,
|
||||
groupPath: null,
|
||||
moveHostToGroup,
|
||||
moveHostsToGroup,
|
||||
})) {
|
||||
if (hostId) {
|
||||
moveHostToGroup(hostId, null);
|
||||
resetHostDragState();
|
||||
}
|
||||
};
|
||||
@@ -82,23 +71,17 @@ export const handleVaultHostDropToGroup = ({
|
||||
dataTransfer,
|
||||
groupPath,
|
||||
moveHostToGroup,
|
||||
moveHostsToGroup,
|
||||
resetHostDragState,
|
||||
}: {
|
||||
dataTransfer: Pick<DataTransfer, "getData">;
|
||||
groupPath: string | null;
|
||||
moveHostToGroup?: (hostId: string, targetPath: string | null) => void;
|
||||
moveHostsToGroup?: (hostIds: ReadonlySet<string>, targetPath: string | null) => void;
|
||||
moveHostToGroup: (hostId: string, targetPath: string | null) => void;
|
||||
resetHostDragState: () => void;
|
||||
}) => {
|
||||
const moved = dispatchVaultHostGroupMove({
|
||||
hostIds: readVaultDraggedHostIds((type) => dataTransfer.getData(type)),
|
||||
groupPath,
|
||||
moveHostToGroup,
|
||||
moveHostsToGroup,
|
||||
});
|
||||
if (!moved) return false;
|
||||
const hostId = dataTransfer.getData("host-id");
|
||||
if (!hostId) return false;
|
||||
|
||||
moveHostToGroup(hostId, groupPath);
|
||||
resetHostDragState();
|
||||
return true;
|
||||
};
|
||||
|
||||
@@ -1,66 +0,0 @@
|
||||
# Shared AI privacy and reversible masking
|
||||
|
||||
AI privacy masking is **off by default**. Enable it in Settings > AI > Tool Access >
|
||||
AI privacy. This shared policy applies to Netcatty-managed context and tools used by
|
||||
Catty, integrated agents and external MCP clients. It is independent of the External
|
||||
MCP switch. External stdio and Streamable HTTP clients use the same policy, including
|
||||
for the environment resource.
|
||||
|
||||
This is not a system-wide AI traffic filter. Third-party agents' native tools,
|
||||
direct file access and other MCP servers are outside the Netcatty-managed boundary.
|
||||
Local terminal display, saved terminal output and actual host data are not changed.
|
||||
|
||||
## Working loop
|
||||
|
||||
A recognized address or credential in managed context or a tool response becomes an
|
||||
opaque token, for example `[NC_<session>_IP_1]`. The same exact value has the same token
|
||||
within its managed conversation or external MCP connection. A later managed command
|
||||
or file-write argument containing that token is restored locally before permission
|
||||
checks and the approval dialog. The host receives the original value; the agent
|
||||
receives masked managed results.
|
||||
|
||||
Mappings stay in memory and are isolated by managed conversation or external MCP
|
||||
connection. No mapping is saved to disk or shared through the app-wide external chat
|
||||
scope. New conversations and reconnects start a new mapping. Unknown, stale, or
|
||||
other-session tokens are rejected rather than sent to the host.
|
||||
|
||||
After changing privacy settings, start a new managed conversation and reconnect
|
||||
external MCP clients. Existing scopes fail closed instead of silently changing
|
||||
their protection. Settings apply only after main-process validation succeeds. A
|
||||
saved legacy External MCP privacy preference is used only if no shared AI privacy
|
||||
preference exists; applying valid settings saves the shared preference. Corrupt
|
||||
saved settings must be repaired rather than silently reverting to unprotected
|
||||
operation.
|
||||
|
||||
## What is covered
|
||||
|
||||
- Recognized credential assignments, authorization bearer values, URL passwords,
|
||||
familiar API token forms, structured secret fields and complete private-key blocks.
|
||||
- IPv4 and IPv6 addresses, with private/local ranges retained by default. Enable
|
||||
**Redact private IP addresses** to hide them too, or select additional CIDRs.
|
||||
Documentation/test addresses are also masked.
|
||||
- Structured host names and user-supplied regular-expression matches.
|
||||
- Netcatty-managed text context, tool results, nested structured results and errors.
|
||||
|
||||
Custom expressions match whole values and use the existing RE2 engine. Unsupported
|
||||
expressions, malformed CIDRs, or empty matches are rejected. Rules and mapping
|
||||
sizes are bounded; exhaustion blocks the response instead of evicting active tokens.
|
||||
|
||||
Binary/base64 tool attachments are blocked while masking is enabled. This feature
|
||||
does not scan images, archives, encoded/encrypted content, files accessed directly
|
||||
by third-party agents, or other MCP servers' results. It cannot guarantee detection
|
||||
of arbitrary credentials, all hostnames, or intentionally obfuscated data. It is an
|
||||
accidental-disclosure safeguard, not a DLP system or a substitute for the normal
|
||||
tool permission policy.
|
||||
|
||||
## Verification
|
||||
|
||||
Keep External MCP disabled and enable AI privacy. Use synthetic values in a new
|
||||
Catty conversation and a new integrated-agent conversation, then repeat through
|
||||
external stdio and HTTP clients after enabling External MCP: read `192.0.2.10`, read
|
||||
`198.51.100.7`, then read the first again. Tokens should be different for different
|
||||
values and stable for repeated reads within the same scope. Write a returned token
|
||||
back through a managed tool or use it in a managed terminal command; the actual
|
||||
file/terminal should contain the original value, never the token. A token from
|
||||
another conversation/client or an earlier scope must fail without dispatching a
|
||||
command. No protection should be implied for third-party native tool/file access.
|
||||
@@ -109,29 +109,6 @@ test("stripSyncPayloadEncryptedCredentials clears device-bound placeholders for
|
||||
assert.equal(findSyncPayloadEncryptedCredentialPaths(stripped).length, 0);
|
||||
});
|
||||
|
||||
test("TOTP secrets follow the same encrypted sync recovery path as passwords", () => {
|
||||
const withTotp = samplePayload({
|
||||
hosts: [{
|
||||
...samplePayload().hosts[0]!,
|
||||
totp: { uri: ENC, autoSubmit: true },
|
||||
}],
|
||||
});
|
||||
assert.deepEqual(findSyncPayloadEncryptedCredentialPaths(withTotp), [
|
||||
"hosts[0].password",
|
||||
"hosts[0].totp.uri",
|
||||
"keys[0].privateKey",
|
||||
]);
|
||||
const stripped = stripSyncPayloadEncryptedCredentials(withTotp);
|
||||
assert.equal(stripped.hosts[0]?.totp, undefined);
|
||||
const healed = healPoisonedSecretsForMerge(withTotp, samplePayload({
|
||||
hosts: [{
|
||||
...samplePayload().hosts[0]!,
|
||||
totp: { uri: "otpauth://totp/Example:alice?secret=GEZDGNBVGY3TQOJQGEZDGNBVGY3TQOJQ" },
|
||||
}],
|
||||
}), null);
|
||||
assert.match(healed.hosts[0]?.totp?.uri || "", /^otpauth:\/\/totp\//);
|
||||
});
|
||||
|
||||
test("healPoisonedSecretsForMerge keeps usable preferred passwords over poisoned enc:v1", () => {
|
||||
const poisoned = samplePayload();
|
||||
const preferred = samplePayload({
|
||||
|
||||
@@ -151,9 +151,6 @@ export const findSyncPayloadEncryptedCredentialPaths = (
|
||||
if (isEncryptedCredentialPlaceholder(host.proxyConfig?.password)) {
|
||||
issues.push(`hosts[${index}].proxyConfig.password`);
|
||||
}
|
||||
if (isEncryptedCredentialPlaceholder(host.totp?.uri)) {
|
||||
issues.push(`hosts[${index}].totp.uri`);
|
||||
}
|
||||
});
|
||||
|
||||
payload.keys.forEach((key, index) => {
|
||||
@@ -211,9 +208,6 @@ export const stripSyncPayloadEncryptedCredentials = (
|
||||
const { password: _removed, ...proxyRest } = next.proxyConfig;
|
||||
next.proxyConfig = proxyRest;
|
||||
}
|
||||
if (next.totp && isEncryptedCredentialPlaceholder(next.totp.uri)) {
|
||||
delete next.totp;
|
||||
}
|
||||
return next;
|
||||
});
|
||||
|
||||
@@ -336,15 +330,6 @@ export const healPoisonedSecretsForMerge = (
|
||||
next.proxyConfig = proxyRest;
|
||||
}
|
||||
}
|
||||
if (next.totp && isEncryptedCredentialPlaceholder(next.totp.uri)) {
|
||||
const healed = healPoisonedCredential(
|
||||
preferredHost,
|
||||
preferredHost?.totp?.uri,
|
||||
fallbackHost?.totp?.uri,
|
||||
);
|
||||
if (healed !== undefined) next.totp = { ...next.totp, uri: healed };
|
||||
else delete next.totp;
|
||||
}
|
||||
return next;
|
||||
});
|
||||
|
||||
|
||||
@@ -8,7 +8,6 @@ import {
|
||||
resolveProxyConfigAuth,
|
||||
} from "./proxyProfiles";
|
||||
import { isEncryptedCredentialPlaceholder, sanitizeCredentialValue } from "./credentials";
|
||||
import { connectionTotpFields } from "./totpConfig";
|
||||
import { resolveEffectiveTerminalHost, resolveTerminalChainHosts } from "./terminalHostResolution";
|
||||
|
||||
/** Test-mode connection timeouts — a probe should fail fast, not wait out the
|
||||
@@ -159,7 +158,6 @@ const buildTestJumpHost = (
|
||||
authMethod: jumpAuth.authMethod,
|
||||
requiresMfa: !!chainHost.requiresMfa,
|
||||
password: sanitizeCredentialValue(jumpAuth.password),
|
||||
...connectionTotpFields(chainHost),
|
||||
privateKey: jumpKey?.source === "reference" ? undefined : sanitizeCredentialValue(jumpKey?.privateKey),
|
||||
certificate: jumpKey?.certificate,
|
||||
passphrase: sanitizeCredentialValue(jumpAuth.passphrase || jumpKey?.passphrase),
|
||||
@@ -310,7 +308,6 @@ export const buildHostConnectionTestPlan = (
|
||||
requiresMfa: !!effectiveHost.requiresMfa,
|
||||
port: effectiveHost.port || 22,
|
||||
password: effectivePassword,
|
||||
...connectionTotpFields(effectiveHost),
|
||||
privateKey: key?.source === "reference" ? undefined : sanitizeCredentialValue(key?.privateKey),
|
||||
certificate: key?.certificate,
|
||||
publicKey: key?.publicKey,
|
||||
|
||||
@@ -157,15 +157,6 @@ export interface SftpBookmark {
|
||||
|
||||
export type HostAuthMethod = 'auto' | 'password' | 'key' | 'certificate';
|
||||
|
||||
/** Per-host TOTP authenticator. `uri` is an otpauth://totp URI and holds the secret. */
|
||||
export interface HostTotpConfig {
|
||||
uri: string;
|
||||
/** Defaults to true. False keeps the dialog fill button and skips auto-submit. */
|
||||
autoSubmit?: boolean;
|
||||
/** Extra RE2 regular expression matched against the KI prompt. */
|
||||
promptPattern?: string;
|
||||
}
|
||||
|
||||
export type HostOperatingSystem = 'linux' | 'windows' | 'macos' | 'freebsd' | 'unknown';
|
||||
export type HostOsSelection = 'auto' | HostOperatingSystem;
|
||||
|
||||
@@ -207,9 +198,6 @@ export interface Host {
|
||||
authPolicyVersion?: 1;
|
||||
// Prefer keyboard-interactive before the password method for MFA/PAM hosts.
|
||||
requiresMfa?: boolean;
|
||||
// Optional per-host TOTP authenticator. The secret lives in `uri` and is
|
||||
// encrypted and synced with the same path as `password`.
|
||||
totp?: HostTotpConfig;
|
||||
// Use the local SSH agent for login. This is separate from agentForwarding,
|
||||
// which exposes the local agent to the remote host after login.
|
||||
useSshAgent?: boolean;
|
||||
|
||||
@@ -6,9 +6,7 @@ import {
|
||||
isSafePluginAuthenticationUrl,
|
||||
pluginProtocolForProvider,
|
||||
sanitizePluginConnection,
|
||||
stripBuiltInConnectionFieldsForPluginHost,
|
||||
} from './pluginConnection.ts';
|
||||
import type { Host } from './models';
|
||||
|
||||
test('plugin connection profiles preserve opaque configuration when their provider is absent', () => {
|
||||
const providerId = 'com.example.transport.connection';
|
||||
@@ -61,30 +59,6 @@ test('plugin credentials are selectable only after secure catalog publication',
|
||||
);
|
||||
});
|
||||
|
||||
test('plugin hosts drop a built-in TOTP secret the plugin UI cannot manage', () => {
|
||||
const totp = { uri: 'otpauth://totp/Example:root?secret=GEZDGNBVGY3TQOJQGEZDGNBVGY3TQOJQ' };
|
||||
const base = {
|
||||
id: 'host-1',
|
||||
label: 'Host',
|
||||
hostname: 'host.test',
|
||||
username: 'root',
|
||||
tags: [],
|
||||
os: 'linux',
|
||||
password: 'secret',
|
||||
requiresMfa: true,
|
||||
totp,
|
||||
} as Host;
|
||||
const pluginHost = stripBuiltInConnectionFieldsForPluginHost({
|
||||
...base,
|
||||
protocol: pluginProtocolForProvider('com.example.transport.connection'),
|
||||
});
|
||||
assert.equal(pluginHost.totp, undefined);
|
||||
assert.equal(pluginHost.password, undefined);
|
||||
assert.equal(pluginHost.requiresMfa, undefined);
|
||||
assert.equal(pluginHost.hostname, 'host.test');
|
||||
assert.equal(stripBuiltInConnectionFieldsForPluginHost({ ...base, protocol: 'ssh' }).totp, totp);
|
||||
});
|
||||
|
||||
test('plugin authentication URLs require HTTPS except for loopback HTTP callbacks', () => {
|
||||
assert.equal(isSafePluginAuthenticationUrl('https://login.example.com/authorize'), true);
|
||||
assert.equal(isSafePluginAuthenticationUrl('http://localhost:44123/callback'), true);
|
||||
|
||||
@@ -125,7 +125,6 @@ export function stripBuiltInConnectionFieldsForPluginHost(host: Host): Host {
|
||||
identityFileId: _identityFileId,
|
||||
identityFilePaths: _identityFilePaths,
|
||||
password: _password,
|
||||
totp: _totp,
|
||||
savePassword: _savePassword,
|
||||
authMethod: _authMethod,
|
||||
authPolicyVersion: _authPolicyVersion,
|
||||
|
||||
@@ -11,13 +11,6 @@ import {
|
||||
isNetcattyManagedStartupHistoryCommand,
|
||||
} from './remoteHistory.ts';
|
||||
import { buildDockerExecShellCommand, buildDockerLogsCommand } from './systemManager/dockerShell.ts';
|
||||
import {
|
||||
buildContainerExecShellCommand,
|
||||
buildContainerLogsCommand,
|
||||
buildKubectlDescribeCommand,
|
||||
buildKubectlExecShellCommand,
|
||||
buildKubectlLogsCommand,
|
||||
} from './systemManager/containerRuntime.ts';
|
||||
import { buildTmuxAttachCommand } from './systemManager/tmuxShell.ts';
|
||||
|
||||
test('parseBashHistory: plain lines', () => {
|
||||
@@ -206,44 +199,8 @@ test('isNetcattyAiHistoryCommand: detects AI PTY marker lines', () => {
|
||||
test('isNetcattyManagedStartupHistoryCommand: detects Docker and tmux terminal launch commands', () => {
|
||||
assert.equal(isNetcattyManagedStartupHistoryCommand(buildDockerExecShellCommand('587abcdef123')), true);
|
||||
assert.equal(isNetcattyManagedStartupHistoryCommand(buildDockerLogsCommand('587abcdef123')), true);
|
||||
assert.equal(isNetcattyManagedStartupHistoryCommand(buildContainerLogsCommand('podman', '587abcdef123')), true);
|
||||
assert.equal(
|
||||
isNetcattyManagedStartupHistoryCommand(buildKubectlLogsCommand('kind-dev', 'default', 'api-0')),
|
||||
true,
|
||||
);
|
||||
assert.equal(
|
||||
isNetcattyManagedStartupHistoryCommand(
|
||||
"printf '\\033[H\\033[2J\\033[3J'; _nc_docker_err=$(docker inspect 587abcdef123 2>&1 >/dev/null);",
|
||||
),
|
||||
true,
|
||||
);
|
||||
assert.equal(isNetcattyManagedStartupHistoryCommand(buildTmuxAttachCommand('my-session')), true);
|
||||
assert.equal(isNetcattyManagedStartupHistoryCommand(buildTmuxAttachCommand('my-session', 2)), true);
|
||||
assert.equal(isNetcattyManagedStartupHistoryCommand(buildContainerLogsCommand('podman', 'abc123', 'powershell')), true);
|
||||
assert.equal(isNetcattyManagedStartupHistoryCommand(buildContainerExecShellCommand('nerdctl', 'abc123', 'cmd')), true);
|
||||
assert.equal(
|
||||
isNetcattyManagedStartupHistoryCommand(buildKubectlLogsCommand('kind-kind', 'default', 'api', undefined, 'powershell')),
|
||||
true,
|
||||
);
|
||||
assert.equal(
|
||||
isNetcattyManagedStartupHistoryCommand(buildKubectlDescribeCommand('kind-kind', 'default', 'api', 'cmd')),
|
||||
true,
|
||||
);
|
||||
assert.equal(
|
||||
isNetcattyManagedStartupHistoryCommand(buildKubectlExecShellCommand('kind-kind', 'default', 'api', 'app', 'powershell')),
|
||||
true,
|
||||
);
|
||||
assert.equal(isNetcattyManagedStartupHistoryCommand('podman logs -f abc123'), false);
|
||||
assert.equal(isNetcattyManagedStartupHistoryCommand('kubectl logs api'), false);
|
||||
assert.equal(isNetcattyManagedStartupHistoryCommand('kubectl describe pod api'), false);
|
||||
assert.equal(
|
||||
isNetcattyManagedStartupHistoryCommand('kubectl --context prod -n default logs -f --tail 200 api'),
|
||||
false,
|
||||
);
|
||||
assert.equal(
|
||||
isNetcattyManagedStartupHistoryCommand('kubectl --context prod describe pod api'),
|
||||
false,
|
||||
);
|
||||
assert.equal(isNetcattyManagedStartupHistoryCommand('docker ps -a'), false);
|
||||
assert.equal(isNetcattyManagedStartupHistoryCommand('docker logs -f 587abcdef123'), false);
|
||||
assert.equal(isNetcattyManagedStartupHistoryCommand('docker exec -it 587abcdef123 bash'), false);
|
||||
|
||||
@@ -1,5 +1,4 @@
|
||||
import { RemoteHistoryEntry, RemoteHistorySource } from './models';
|
||||
import { NETCATTY_POPUP_HISTORY_MARKER } from './systemManager/containerRuntime.ts';
|
||||
|
||||
/** Marker prefix Netcatty AI uses when executing commands via the PTY bridge. */
|
||||
export const NETCATTY_AI_HISTORY_MARKER = '__NCMCP_';
|
||||
@@ -10,17 +9,11 @@ export function isNetcattyAiHistoryCommand(command: string): boolean {
|
||||
}
|
||||
|
||||
const NETCATTY_MANAGED_STARTUP_COMMAND =
|
||||
/^(?:sh\s+-c\s+.*printf .*\\033\[H\\033\[2J\\033\[3J.*(?:_nc_(?:docker|rt)_err=.*\b(?:docker|podman|nerdctl)\s+inspect\b|exec\s+kubectl\b)|printf '\\033\[H\\033\[2J\\033\[3J';\s*(?:_nc_(?:docker|rt)_err=.*\b(?:docker|podman|nerdctl)\s+inspect\b|exec\s+(?:(?:docker|podman|nerdctl)\s+(?:exec|logs)\b|tmux\s+attach\b)))/;
|
||||
|
||||
const NETCATTY_WINDOWS_CONTAINER_POPUP_COMMAND =
|
||||
/^(?:\$__nc_popup='__NC_POPUP__'; |set "__NC_POPUP__=1"& )?(?:docker|podman|nerdctl) inspect [a-zA-Z0-9]+ (?:>\$null; if \(\$LASTEXITCODE -eq 0\) \{|>nul && )/;
|
||||
/^(?:sh\s+-c\s+.*printf .*\\033\[H\\033\[2J\\033\[3J.*_nc_docker_err=.*\bdocker\s+inspect\b|printf '\\033\[H\\033\[2J\\033\[3J';\s*(?:_nc_docker_err=.*\bdocker\s+inspect\b|exec\s+(?:docker\s+(?:exec|logs)\b|tmux\s+attach\b)))/;
|
||||
|
||||
/** True when a shell history line came from a Netcatty-managed terminal launch. */
|
||||
export function isNetcattyManagedStartupHistoryCommand(command: string): boolean {
|
||||
const text = command.trim();
|
||||
return text.includes(NETCATTY_POPUP_HISTORY_MARKER)
|
||||
|| NETCATTY_MANAGED_STARTUP_COMMAND.test(text)
|
||||
|| NETCATTY_WINDOWS_CONTAINER_POPUP_COMMAND.test(text);
|
||||
return NETCATTY_MANAGED_STARTUP_COMMAND.test(command.trim());
|
||||
}
|
||||
|
||||
const ZSH_EXTENDED_RECORD = /^: (\d+):\d+;([\s\S]*)$/;
|
||||
|
||||
@@ -1,550 +0,0 @@
|
||||
"use strict";
|
||||
|
||||
const CONTAINER_RUNTIMES = ["docker", "podman", "nerdctl"];
|
||||
const SOURCE_ORDER = ["docker", "podman", "nerdctl", "kubernetes"];
|
||||
|
||||
const CLEAR_STARTUP_OUTPUT = "printf '\\033[H\\033[2J\\033[3J';";
|
||||
const NETCATTY_POPUP_HISTORY_MARKER = "__NC_POPUP__";
|
||||
|
||||
function popupHistoryPrefix(shell) {
|
||||
if (shell === "powershell") return `$__nc_popup='${NETCATTY_POPUP_HISTORY_MARKER}'; `;
|
||||
if (shell === "cmd") return `set "${NETCATTY_POPUP_HISTORY_MARKER}=1"& `;
|
||||
return "";
|
||||
}
|
||||
|
||||
function shQuote(str) {
|
||||
return `'${String(str).replace(/'/g, "'\"'\"'")}'`;
|
||||
}
|
||||
|
||||
function normalizeContainerRuntime(runtime) {
|
||||
return CONTAINER_RUNTIMES.includes(runtime) ? runtime : "docker";
|
||||
}
|
||||
|
||||
function isContainerSource(value) {
|
||||
return SOURCE_ORDER.includes(value);
|
||||
}
|
||||
|
||||
function runtimeBinary(runtime) {
|
||||
return normalizeContainerRuntime(runtime);
|
||||
}
|
||||
|
||||
function buildRuntimeCommand(runtime, args) {
|
||||
return `${runtimeBinary(runtime)} ${String(args || "")}`.trim();
|
||||
}
|
||||
|
||||
function parseRuntimeProbe(stdout) {
|
||||
const text = stdout || "";
|
||||
const hasFlag = (name) => new RegExp(`(?:^|\\r?\\n)${name}=1(?:\\r?\\n|$)`).test(text);
|
||||
return {
|
||||
docker: hasFlag("__NC_DOCKER__"),
|
||||
podman: hasFlag("__NC_PODMAN__"),
|
||||
nerdctl: hasFlag("__NC_NERDCTL__"),
|
||||
kubernetes: hasFlag("__NC_KUBECTL__"),
|
||||
};
|
||||
}
|
||||
|
||||
function sourcesFromCapabilities(capabilities) {
|
||||
if (!capabilities) return [];
|
||||
const present = {
|
||||
docker: capabilities.hasDocker === true || capabilities.docker === true,
|
||||
podman: capabilities.hasPodman === true || capabilities.podman === true,
|
||||
nerdctl: capabilities.hasNerdctl === true || capabilities.nerdctl === true,
|
||||
kubernetes: capabilities.hasKubectl === true || capabilities.kubernetes === true,
|
||||
};
|
||||
return SOURCE_ORDER.filter((id) => present[id]);
|
||||
}
|
||||
|
||||
function pickContainerSource(detected, current, remembered) {
|
||||
const available = SOURCE_ORDER.filter((id) => Array.isArray(detected) && detected.includes(id));
|
||||
if (current && available.includes(current)) return current;
|
||||
if (remembered && available.includes(remembered)) return remembered;
|
||||
return available[0] || null;
|
||||
}
|
||||
|
||||
function rememberedContainerSource(preference, detected) {
|
||||
const source = preference?.source;
|
||||
if (!isContainerSource(source)) return null;
|
||||
const available = Array.isArray(detected) ? detected : [];
|
||||
if (source === "kubernetes" && !available.includes("kubernetes") && CONTAINER_RUNTIMES.includes(preference?.engine)) {
|
||||
return preference.engine;
|
||||
}
|
||||
return source;
|
||||
}
|
||||
|
||||
function reconcileContainerSource(detected, current, preference) {
|
||||
const available = SOURCE_ORDER.filter((id) => Array.isArray(detected) && detected.includes(id));
|
||||
const preferred = rememberedContainerSource(preference, available);
|
||||
if (preferred && available.includes(preferred)) return preferred;
|
||||
return pickContainerSource(available, current, preferred);
|
||||
}
|
||||
|
||||
function isAutomaticRuntimeFallback(preference, source, detected) {
|
||||
if (!preference?.source || !isContainerSource(source) || preference.source === source) return false;
|
||||
const available = Array.isArray(detected) ? detected : [];
|
||||
if (available.includes(preference.source)) return false;
|
||||
const automatic = pickContainerSource(
|
||||
available,
|
||||
null,
|
||||
rememberedContainerSource(preference, available),
|
||||
);
|
||||
return source === automatic;
|
||||
}
|
||||
|
||||
function firstContainerRuntime(detected) {
|
||||
return CONTAINER_RUNTIMES.find((id) => Array.isArray(detected) && detected.includes(id)) || null;
|
||||
}
|
||||
|
||||
function socketNeedles(runtime) {
|
||||
if (runtime === "podman") {
|
||||
return ["podman.sock", "/run/podman", "podman daemon"];
|
||||
}
|
||||
if (runtime === "nerdctl") {
|
||||
return ["containerd.sock", "/run/containerd"];
|
||||
}
|
||||
return ["docker daemon", "docker.sock", "/var/run/docker.sock", "connect to the docker daemon"];
|
||||
}
|
||||
|
||||
function isRuntimeSocketPermissionError(runtime, result) {
|
||||
const text = `${result?.stderr || ""}\n${result?.stdout || ""}\n${result?.error || ""}`.toLowerCase();
|
||||
if (!text.includes("permission denied")) return false;
|
||||
return socketNeedles(normalizeContainerRuntime(runtime)).some((needle) => text.includes(needle));
|
||||
}
|
||||
|
||||
function socketCasePatterns(runtime) {
|
||||
if (runtime === "podman") {
|
||||
return [
|
||||
"*permission\\ denied*podman.sock*",
|
||||
"*podman.sock*permission\\ denied*",
|
||||
"*permission\\ denied*/run/podman*",
|
||||
"*/run/podman*permission\\ denied*",
|
||||
"*permission\\ denied*podman\\ daemon*",
|
||||
"*podman\\ daemon*permission\\ denied*",
|
||||
];
|
||||
}
|
||||
if (runtime === "nerdctl") {
|
||||
return [
|
||||
"*permission\\ denied*containerd.sock*",
|
||||
"*containerd.sock*permission\\ denied*",
|
||||
"*permission\\ denied*/run/containerd*",
|
||||
"*/run/containerd*permission\\ denied*",
|
||||
];
|
||||
}
|
||||
return [
|
||||
"*permission\\ denied*docker\\ daemon*",
|
||||
"*docker\\ daemon*permission\\ denied*",
|
||||
"*permission\\ denied*docker.sock*",
|
||||
"*docker.sock*permission\\ denied*",
|
||||
"*permission\\ denied*/var/run/docker.sock*",
|
||||
"*/var/run/docker.sock*permission\\ denied*",
|
||||
"*permission\\ denied*connect\\ to\\ the\\ docker\\ daemon*",
|
||||
"*connect\\ to\\ the\\ docker\\ daemon*permission\\ denied*",
|
||||
];
|
||||
}
|
||||
|
||||
function sanitizeContainerId(id) {
|
||||
return String(id || "").replace(/[^a-zA-Z0-9]/g, "").slice(0, 64);
|
||||
}
|
||||
|
||||
function containerInteractiveArgs(containerId, shell) {
|
||||
if (shell === "cmd") {
|
||||
return `exec -it ${containerId} sh -c "command -v bash >/dev/null 2>&1 && exec bash || exec sh"`;
|
||||
}
|
||||
return `exec -it ${containerId} sh -c 'command -v bash >/dev/null 2>&1 && exec bash || exec sh'`;
|
||||
}
|
||||
|
||||
function buildContainerCommandWithSudoFallback(runtime, containerId, cliArgs, shell) {
|
||||
const popupShell = normalizePopupShell(shell);
|
||||
const bin = runtimeBinary(runtime);
|
||||
const plainCommand = `${bin} ${cliArgs}`;
|
||||
if (popupShell === "powershell") {
|
||||
return `${popupHistoryPrefix(popupShell)}${bin} inspect ${containerId} >$null; if ($LASTEXITCODE -eq 0) { ${plainCommand} } else { exit $LASTEXITCODE }`;
|
||||
}
|
||||
if (popupShell === "cmd") {
|
||||
return `${popupHistoryPrefix(popupShell)}${bin} inspect ${containerId} >nul && ${plainCommand}`;
|
||||
}
|
||||
const sudoCommand = `sudo ${plainCommand}`;
|
||||
const script = [
|
||||
CLEAR_STARTUP_OUTPUT,
|
||||
`_nc_rt_err=$(${bin} inspect ${containerId} 2>&1 >/dev/null);`,
|
||||
"_nc_rt_status=$?;",
|
||||
`if [ "$_nc_rt_status" -eq 0 ]; then exec ${plainCommand}; fi;`,
|
||||
"_nc_rt_lc=$(printf '%s' \"$_nc_rt_err\" | tr '[:upper:]' '[:lower:]');",
|
||||
"case \"$_nc_rt_lc\" in",
|
||||
`${socketCasePatterns(normalizeContainerRuntime(runtime)).join("|")}) exec ${sudoCommand} ;;`,
|
||||
"*) printf '%s\\n' \"$_nc_rt_err\" >&2; exit \"$_nc_rt_status\" ;;",
|
||||
"esac",
|
||||
].join(" ");
|
||||
return `sh -c ${shQuote(script)}`;
|
||||
}
|
||||
|
||||
function buildContainerExecShellCommand(runtime, containerId, shell) {
|
||||
const safeId = sanitizeContainerId(containerId);
|
||||
if (!safeId) return 'echo "Invalid container id"';
|
||||
return buildContainerCommandWithSudoFallback(
|
||||
runtime,
|
||||
safeId,
|
||||
containerInteractiveArgs(safeId, normalizePopupShell(shell)),
|
||||
shell,
|
||||
);
|
||||
}
|
||||
|
||||
function buildContainerLogsCommand(runtime, containerId, shell) {
|
||||
const safeId = sanitizeContainerId(containerId);
|
||||
if (!safeId) return 'echo "Invalid container id"';
|
||||
return buildContainerCommandWithSudoFallback(runtime, safeId, `logs -f --tail 200 ${safeId}`, shell);
|
||||
}
|
||||
|
||||
function hasControlChar(text) {
|
||||
for (let i = 0; i < text.length; i += 1) {
|
||||
const code = text.charCodeAt(i);
|
||||
if (code <= 31 || code === 127) return true;
|
||||
}
|
||||
return false;
|
||||
}
|
||||
|
||||
function isSafeShellArg(value, shell) {
|
||||
const text = String(value ?? "");
|
||||
if (!text || text.length > 512) return false;
|
||||
if (hasControlChar(text)) return false;
|
||||
// Interactive cmd.exe expands %VAR% inside double quotes, and %% is not a
|
||||
// reliable escape there. With /V:ON it also expands !VAR! inside quotes.
|
||||
// Reject those arguments instead of targeting a substituted context.
|
||||
if (shell === "cmd" && (text.includes("%") || text.includes("!"))) return false;
|
||||
return true;
|
||||
}
|
||||
|
||||
function normalizePopupShell(shell) {
|
||||
return shell === "powershell" || shell === "cmd" ? shell : "posix";
|
||||
}
|
||||
|
||||
function quoteShellArg(shell, value) {
|
||||
const text = String(value);
|
||||
if (shell === "cmd") return `"${text.replace(/"/g, '""')}"`;
|
||||
if (shell === "powershell") return `'${text.replace(/'/g, "''")}'`;
|
||||
return shQuote(text);
|
||||
}
|
||||
|
||||
function kubectlPrefix(context, namespace, shell = "posix") {
|
||||
const popupShell = normalizePopupShell(shell);
|
||||
if (!isSafeShellArg(context, popupShell)) return null;
|
||||
const parts = ["kubectl", "--context", quoteShellArg(popupShell, context)];
|
||||
if (namespace != null && namespace !== "") {
|
||||
if (!isSafeShellArg(namespace, popupShell)) return null;
|
||||
parts.push("-n", quoteShellArg(popupShell, namespace));
|
||||
}
|
||||
return parts.join(" ");
|
||||
}
|
||||
|
||||
function optionalContainerArg(container, shell = "posix") {
|
||||
if (container == null || container === "") return "";
|
||||
if (!isSafeShellArg(container, normalizePopupShell(shell))) return null;
|
||||
return ` -c ${quoteShellArg(normalizePopupShell(shell), container)}`;
|
||||
}
|
||||
|
||||
function isKubectlForbiddenError(message) {
|
||||
return /\bforbidden\b/i.test(String(message || ""));
|
||||
}
|
||||
|
||||
function isKubectlUnreachableError(message) {
|
||||
const text = String(message || "");
|
||||
if (!text.trim() || isKubectlForbiddenError(text)) return false;
|
||||
return /couldn'?t get current server api group list|could not get current server api group list|unable to connect to the server|connection refused|dial tcp|i\/o timeout|no such host|network is unreachable|context deadline exceeded|tls handshake timeout/i.test(text);
|
||||
}
|
||||
|
||||
function isNerdctlUnreachableError(message) {
|
||||
const text = String(message || "").toLowerCase();
|
||||
if (!text.trim()) return false;
|
||||
if (text.includes("rootless containerd") || text.includes("xdg_runtime_dir")) return true;
|
||||
if (text.includes("cannot connect to the docker daemon")) return true;
|
||||
if (!text.includes("containerd")) return false;
|
||||
return /not running|connection refused|no such file|cannot connect|failed to connect|unavailable/.test(text);
|
||||
}
|
||||
|
||||
const POD_STATUS_MESSAGE_KEYS = {
|
||||
Running: "systemManager.k8s.status.Running",
|
||||
Pending: "systemManager.k8s.status.Pending",
|
||||
Completed: "systemManager.k8s.status.Completed",
|
||||
Succeeded: "systemManager.k8s.status.Succeeded",
|
||||
Failed: "systemManager.k8s.status.Failed",
|
||||
Unknown: "systemManager.k8s.status.Unknown",
|
||||
CrashLoopBackOff: "systemManager.k8s.status.CrashLoopBackOff",
|
||||
ContainerCreating: "systemManager.k8s.status.ContainerCreating",
|
||||
Evicted: "systemManager.k8s.status.Evicted",
|
||||
Terminating: "systemManager.k8s.status.Terminating",
|
||||
ImagePullBackOff: "systemManager.k8s.status.ImagePullBackOff",
|
||||
ErrImagePull: "systemManager.k8s.status.ErrImagePull",
|
||||
Error: "systemManager.k8s.status.Error",
|
||||
OOMKilled: "systemManager.k8s.status.OOMKilled",
|
||||
};
|
||||
|
||||
function podStatusMessageKey(status) {
|
||||
return POD_STATUS_MESSAGE_KEYS[String(status || "")] || null;
|
||||
}
|
||||
|
||||
function runtimeFailureMessage(errorCode, context) {
|
||||
if (errorCode === "nerdctlUnreachable") {
|
||||
return { key: "systemManager.errors.nerdctlUnreachable" };
|
||||
}
|
||||
if (errorCode === "clusterUnreachable") {
|
||||
const name = typeof context === "string" ? context.trim() : "";
|
||||
if (name) {
|
||||
return {
|
||||
key: "systemManager.errors.clusterUnreachable",
|
||||
values: { context: name },
|
||||
};
|
||||
}
|
||||
return { key: "systemManager.errors.clusterUnreachableGeneric" };
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
||||
function wrapClearAndExec(command) {
|
||||
return `sh -c ${shQuote(`${CLEAR_STARTUP_OUTPUT} exec ${command}`)}`;
|
||||
}
|
||||
|
||||
function buildKubectlContextsCommand() {
|
||||
return "kubectl config view -o json";
|
||||
}
|
||||
|
||||
function buildKubectlNamespacesCommand(context, shell) {
|
||||
const popupShell = normalizePopupShell(shell);
|
||||
if (!isSafeShellArg(context, popupShell)) return null;
|
||||
return `kubectl --context ${quoteShellArg(popupShell, context)} get namespaces -o json`;
|
||||
}
|
||||
|
||||
function buildKubectlPodsCommand(context, namespace, shell) {
|
||||
const prefix = kubectlPrefix(context, namespace, shell);
|
||||
if (!prefix) return null;
|
||||
return `${prefix} get pods -o json`;
|
||||
}
|
||||
|
||||
function buildKubectlDeletePodCommand(context, namespace, pod, shell) {
|
||||
const popupShell = normalizePopupShell(shell);
|
||||
const prefix = kubectlPrefix(context, namespace, popupShell);
|
||||
if (!prefix || !isSafeShellArg(pod, popupShell)) return null;
|
||||
return `${prefix} delete pod ${quoteShellArg(popupShell, pod)} --wait=false`;
|
||||
}
|
||||
|
||||
function containerShellArg(shell) {
|
||||
if (shell === "cmd") {
|
||||
return 'sh -c "command -v bash >/dev/null 2>&1 && exec bash || exec sh"';
|
||||
}
|
||||
return "sh -c 'command -v bash >/dev/null 2>&1 && exec bash || exec sh'";
|
||||
}
|
||||
|
||||
function finishKubectlPopup(shell, command) {
|
||||
return shell === "posix" ? wrapClearAndExec(command) : `${popupHistoryPrefix(shell)}${command}`;
|
||||
}
|
||||
|
||||
function buildKubectlExecShellCommand(context, namespace, pod, container, shell) {
|
||||
const popupShell = normalizePopupShell(shell);
|
||||
const prefix = kubectlPrefix(context, namespace, popupShell);
|
||||
if (!prefix) return 'echo "Invalid context"';
|
||||
if (!isSafeShellArg(pod, popupShell)) return 'echo "Invalid pod name"';
|
||||
const containerArg = optionalContainerArg(container, popupShell);
|
||||
if (containerArg == null) return 'echo "Invalid container name"';
|
||||
const command = `${prefix} exec -it ${quoteShellArg(popupShell, pod)}${containerArg} -- ${containerShellArg(popupShell)}`;
|
||||
return finishKubectlPopup(popupShell, command);
|
||||
}
|
||||
|
||||
function buildKubectlLogsCommand(context, namespace, pod, container, shell) {
|
||||
const popupShell = normalizePopupShell(shell);
|
||||
const prefix = kubectlPrefix(context, namespace, popupShell);
|
||||
if (!prefix) return 'echo "Invalid context"';
|
||||
if (!isSafeShellArg(pod, popupShell)) return 'echo "Invalid pod name"';
|
||||
const containerArg = optionalContainerArg(container, popupShell);
|
||||
if (containerArg == null) return 'echo "Invalid container name"';
|
||||
const command = `${prefix} logs -f --tail 200 ${quoteShellArg(popupShell, pod)}${containerArg}`;
|
||||
return finishKubectlPopup(popupShell, command);
|
||||
}
|
||||
|
||||
function buildKubectlDescribeCommand(context, namespace, pod, shell) {
|
||||
const popupShell = normalizePopupShell(shell);
|
||||
const prefix = kubectlPrefix(context, namespace, popupShell);
|
||||
if (!prefix) return 'echo "Invalid context"';
|
||||
if (!isSafeShellArg(pod, popupShell)) return 'echo "Invalid pod name"';
|
||||
const command = `${prefix} describe pod ${quoteShellArg(popupShell, pod)}`;
|
||||
return finishKubectlPopup(popupShell, command);
|
||||
}
|
||||
|
||||
function parseJsonValue(text) {
|
||||
const raw = String(text || "");
|
||||
for (let i = 0; i < raw.length; i += 1) {
|
||||
const ch = raw[i];
|
||||
if (ch !== "{" && ch !== "[") continue;
|
||||
try {
|
||||
return JSON.parse(raw.slice(i));
|
||||
} catch {
|
||||
// A bracketed banner such as [WARN] is not the JSON document.
|
||||
}
|
||||
}
|
||||
throw new Error("Invalid JSON");
|
||||
}
|
||||
|
||||
function parseKubeconfigContexts(jsonText) {
|
||||
const parsed = parseJsonValue(jsonText);
|
||||
const current = typeof parsed["current-context"] === "string" ? parsed["current-context"] : "";
|
||||
const contexts = [];
|
||||
const list = Array.isArray(parsed.contexts) ? parsed.contexts : [];
|
||||
for (const entry of list) {
|
||||
const name = typeof entry?.name === "string" ? entry.name : "";
|
||||
if (!name) continue;
|
||||
const rawNamespace = entry?.context?.namespace;
|
||||
const namespace = typeof rawNamespace === "string" && rawNamespace ? rawNamespace : "default";
|
||||
contexts.push({
|
||||
name,
|
||||
namespace,
|
||||
current: name === current,
|
||||
});
|
||||
}
|
||||
return { contexts, current };
|
||||
}
|
||||
|
||||
function parseNamespaceList(jsonText) {
|
||||
const parsed = parseJsonValue(jsonText);
|
||||
const items = Array.isArray(parsed.items) ? parsed.items : [];
|
||||
const names = [];
|
||||
for (const item of items) {
|
||||
const name = item?.metadata?.name;
|
||||
if (typeof name === "string" && name) names.push(name);
|
||||
}
|
||||
return names;
|
||||
}
|
||||
|
||||
function formatPodAge(iso, now = Date.now()) {
|
||||
const timestamp = Date.parse(String(iso || ""));
|
||||
if (!Number.isFinite(timestamp)) return "";
|
||||
const seconds = Math.max(0, Math.floor((now - timestamp) / 1000));
|
||||
if (seconds < 60) return `${seconds}s`;
|
||||
const minutes = Math.floor(seconds / 60);
|
||||
if (minutes < 60) return `${minutes}m`;
|
||||
const hours = Math.floor(minutes / 60);
|
||||
if (hours < 24) return `${hours}h`;
|
||||
return `${Math.floor(hours / 24)}d`;
|
||||
}
|
||||
|
||||
function podDisplayStatus(pod) {
|
||||
if (typeof pod?.metadata?.deletionTimestamp === "string" && pod.metadata.deletionTimestamp) {
|
||||
return "Terminating";
|
||||
}
|
||||
const statuses = [
|
||||
...(Array.isArray(pod?.status?.initContainerStatuses) ? pod.status.initContainerStatuses : []),
|
||||
...(Array.isArray(pod?.status?.containerStatuses) ? pod.status.containerStatuses : []),
|
||||
];
|
||||
for (const status of statuses) {
|
||||
const reason = status?.state?.waiting?.reason;
|
||||
if (reason) return String(reason);
|
||||
}
|
||||
const podReason = typeof pod?.status?.reason === "string" ? pod.status.reason.trim() : "";
|
||||
if (podReason) return podReason;
|
||||
for (const status of statuses) {
|
||||
const reason = status?.state?.terminated?.reason;
|
||||
if (reason && reason !== "Completed") return String(reason);
|
||||
}
|
||||
const phase = String(pod?.status?.phase || "Unknown");
|
||||
if (phase === "Succeeded") return "Completed";
|
||||
return phase;
|
||||
}
|
||||
|
||||
function podContainerNames(pod) {
|
||||
const names = [];
|
||||
const specs = [
|
||||
...(Array.isArray(pod?.spec?.containers) ? pod.spec.containers : []),
|
||||
...(Array.isArray(pod?.spec?.initContainers) ? pod.spec.initContainers : []),
|
||||
...(Array.isArray(pod?.spec?.ephemeralContainers) ? pod.spec.ephemeralContainers : []),
|
||||
];
|
||||
for (const container of specs) {
|
||||
const name = container?.name;
|
||||
if (typeof name === "string" && name && !names.includes(name)) names.push(name);
|
||||
}
|
||||
return names;
|
||||
}
|
||||
|
||||
function podShellContainerNames(pod) {
|
||||
const running = new Set();
|
||||
const statuses = [
|
||||
...(Array.isArray(pod?.status?.containerStatuses) ? pod.status.containerStatuses : []),
|
||||
...(Array.isArray(pod?.status?.initContainerStatuses) ? pod.status.initContainerStatuses : []),
|
||||
...(Array.isArray(pod?.status?.ephemeralContainerStatuses) ? pod.status.ephemeralContainerStatuses : []),
|
||||
];
|
||||
for (const status of statuses) {
|
||||
if (status?.state?.running && typeof status.name === "string" && status.name) {
|
||||
running.add(status.name);
|
||||
}
|
||||
}
|
||||
const ordered = podContainerNames(pod).filter((name) => running.has(name));
|
||||
for (const name of running) {
|
||||
if (!ordered.includes(name)) ordered.push(name);
|
||||
}
|
||||
return ordered;
|
||||
}
|
||||
|
||||
function podRestartCount(pod) {
|
||||
const statuses = [
|
||||
...(Array.isArray(pod?.status?.containerStatuses) ? pod.status.containerStatuses : []),
|
||||
...(Array.isArray(pod?.status?.initContainerStatuses) ? pod.status.initContainerStatuses : []),
|
||||
];
|
||||
let restarts = 0;
|
||||
for (const status of statuses) {
|
||||
const count = Number(status?.restartCount || 0);
|
||||
if (Number.isFinite(count)) restarts += count;
|
||||
}
|
||||
return restarts;
|
||||
}
|
||||
|
||||
function parseKubectlPodList(jsonText, now = Date.now()) {
|
||||
const parsed = parseJsonValue(jsonText);
|
||||
const items = Array.isArray(parsed.items) ? parsed.items : [];
|
||||
const pods = [];
|
||||
for (const pod of items) {
|
||||
const name = typeof pod?.metadata?.name === "string" ? pod.metadata.name : "";
|
||||
if (!name) continue;
|
||||
pods.push({
|
||||
name,
|
||||
namespace: typeof pod?.metadata?.namespace === "string" ? pod.metadata.namespace : "",
|
||||
status: podDisplayStatus(pod),
|
||||
restarts: podRestartCount(pod),
|
||||
age: formatPodAge(pod?.metadata?.creationTimestamp, now),
|
||||
containers: podContainerNames(pod),
|
||||
shellContainers: podShellContainerNames(pod),
|
||||
});
|
||||
}
|
||||
return pods;
|
||||
}
|
||||
|
||||
module.exports = {
|
||||
NETCATTY_POPUP_HISTORY_MARKER,
|
||||
CONTAINER_RUNTIMES,
|
||||
SOURCE_ORDER,
|
||||
normalizeContainerRuntime,
|
||||
isContainerSource,
|
||||
runtimeBinary,
|
||||
buildRuntimeCommand,
|
||||
parseRuntimeProbe,
|
||||
sourcesFromCapabilities,
|
||||
pickContainerSource,
|
||||
rememberedContainerSource,
|
||||
reconcileContainerSource,
|
||||
isAutomaticRuntimeFallback,
|
||||
firstContainerRuntime,
|
||||
isRuntimeSocketPermissionError,
|
||||
sanitizeContainerId,
|
||||
buildContainerExecShellCommand,
|
||||
buildContainerLogsCommand,
|
||||
buildKubectlContextsCommand,
|
||||
buildKubectlNamespacesCommand,
|
||||
buildKubectlPodsCommand,
|
||||
buildKubectlDeletePodCommand,
|
||||
buildKubectlExecShellCommand,
|
||||
buildKubectlLogsCommand,
|
||||
buildKubectlDescribeCommand,
|
||||
isKubectlForbiddenError,
|
||||
isKubectlUnreachableError,
|
||||
isNerdctlUnreachableError,
|
||||
podStatusMessageKey,
|
||||
runtimeFailureMessage,
|
||||
parseKubeconfigContexts,
|
||||
parseNamespaceList,
|
||||
parseKubectlPodList,
|
||||
formatPodAge,
|
||||
};
|
||||
-109
@@ -1,109 +0,0 @@
|
||||
export type ContainerRuntimeId = "docker" | "podman" | "nerdctl";
|
||||
export type ContainerSourceId = ContainerRuntimeId | "kubernetes";
|
||||
|
||||
export interface RuntimeProbeFlags {
|
||||
docker: boolean;
|
||||
podman: boolean;
|
||||
nerdctl: boolean;
|
||||
kubernetes: boolean;
|
||||
}
|
||||
|
||||
export interface KubeContextInfo {
|
||||
name: string;
|
||||
namespace: string;
|
||||
current: boolean;
|
||||
}
|
||||
|
||||
export interface KubectlPodInfo {
|
||||
name: string;
|
||||
namespace: string;
|
||||
status: string;
|
||||
restarts: number;
|
||||
age: string;
|
||||
containers: string[];
|
||||
shellContainers: string[];
|
||||
}
|
||||
|
||||
export const NETCATTY_POPUP_HISTORY_MARKER: string;
|
||||
|
||||
export function normalizeContainerRuntime(runtime: unknown): ContainerRuntimeId;
|
||||
export function isContainerSource(value: unknown): value is ContainerSourceId;
|
||||
export function runtimeBinary(runtime: unknown): string;
|
||||
export function buildRuntimeCommand(runtime: unknown, args: string): string;
|
||||
export function parseRuntimeProbe(stdout: string): RuntimeProbeFlags;
|
||||
export function sourcesFromCapabilities(capabilities: {
|
||||
hasDocker?: boolean;
|
||||
hasPodman?: boolean;
|
||||
hasNerdctl?: boolean;
|
||||
hasKubectl?: boolean;
|
||||
docker?: boolean;
|
||||
podman?: boolean;
|
||||
nerdctl?: boolean;
|
||||
kubernetes?: boolean;
|
||||
} | null | undefined): ContainerSourceId[];
|
||||
export function pickContainerSource(
|
||||
detected: readonly ContainerSourceId[] | null | undefined,
|
||||
current: ContainerSourceId | null | undefined,
|
||||
remembered: ContainerSourceId | null | undefined,
|
||||
): ContainerSourceId | null;
|
||||
export function rememberedContainerSource(
|
||||
preference: { source?: string; engine?: string } | null | undefined,
|
||||
detected: readonly ContainerSourceId[] | null | undefined,
|
||||
): ContainerSourceId | null;
|
||||
export function reconcileContainerSource(
|
||||
detected: readonly ContainerSourceId[] | null | undefined,
|
||||
current: ContainerSourceId | null | undefined,
|
||||
preference: { source?: string; engine?: string } | null | undefined,
|
||||
): ContainerSourceId | null;
|
||||
export function isAutomaticRuntimeFallback(
|
||||
preference: { source?: string; engine?: string } | null | undefined,
|
||||
source: ContainerSourceId | null | undefined,
|
||||
detected: readonly ContainerSourceId[] | null | undefined,
|
||||
): boolean;
|
||||
export function firstContainerRuntime(
|
||||
detected: readonly ContainerSourceId[] | null | undefined,
|
||||
): ContainerRuntimeId | null;
|
||||
export function isRuntimeSocketPermissionError(
|
||||
runtime: unknown,
|
||||
result: { stderr?: string; stdout?: string; error?: string } | null | undefined,
|
||||
): boolean;
|
||||
export function sanitizeContainerId(id: string): string;
|
||||
export function buildContainerExecShellCommand(runtime: unknown, containerId: string, shell?: string): string;
|
||||
export function buildContainerLogsCommand(runtime: unknown, containerId: string, shell?: string): string;
|
||||
export function buildKubectlContextsCommand(): string;
|
||||
export function buildKubectlNamespacesCommand(context: string, shell?: string): string | null;
|
||||
export function buildKubectlPodsCommand(context: string, namespace: string, shell?: string): string | null;
|
||||
export function buildKubectlDeletePodCommand(context: string, namespace: string, pod: string, shell?: string): string | null;
|
||||
export type PopupShell = "posix" | "powershell" | "cmd";
|
||||
export function isKubectlForbiddenError(message: string | null | undefined): boolean;
|
||||
export function isKubectlUnreachableError(message: string | null | undefined): boolean;
|
||||
export function isNerdctlUnreachableError(message: string | null | undefined): boolean;
|
||||
export function podStatusMessageKey(status: string | null | undefined): string | null;
|
||||
export function runtimeFailureMessage(
|
||||
errorCode: string | null | undefined,
|
||||
context?: string | null,
|
||||
): { key: string; values?: Record<string, string> } | null;
|
||||
export function buildKubectlExecShellCommand(
|
||||
context: string,
|
||||
namespace: string,
|
||||
pod: string,
|
||||
container?: string,
|
||||
shell?: string,
|
||||
): string;
|
||||
export function buildKubectlLogsCommand(
|
||||
context: string,
|
||||
namespace: string,
|
||||
pod: string,
|
||||
container?: string,
|
||||
shell?: string,
|
||||
): string;
|
||||
export function buildKubectlDescribeCommand(
|
||||
context: string,
|
||||
namespace: string,
|
||||
pod: string,
|
||||
shell?: string,
|
||||
): string;
|
||||
export function parseKubeconfigContexts(jsonText: string): { contexts: KubeContextInfo[]; current: string };
|
||||
export function parseNamespaceList(jsonText: string): string[];
|
||||
export function parseKubectlPodList(jsonText: string, now?: number): KubectlPodInfo[];
|
||||
export function formatPodAge(iso: string, now?: number): string;
|
||||
@@ -1,486 +0,0 @@
|
||||
"use strict";
|
||||
|
||||
const test = require("node:test");
|
||||
const assert = require("node:assert/strict");
|
||||
const {
|
||||
buildContainerExecShellCommand,
|
||||
buildContainerLogsCommand,
|
||||
buildKubectlDeletePodCommand,
|
||||
buildKubectlDescribeCommand,
|
||||
buildKubectlExecShellCommand,
|
||||
buildKubectlLogsCommand,
|
||||
buildKubectlNamespacesCommand,
|
||||
buildKubectlPodsCommand,
|
||||
buildRuntimeCommand,
|
||||
firstContainerRuntime,
|
||||
formatPodAge,
|
||||
isRuntimeSocketPermissionError,
|
||||
parseKubeconfigContexts,
|
||||
parseKubectlPodList,
|
||||
parseNamespaceList,
|
||||
parseRuntimeProbe,
|
||||
isAutomaticRuntimeFallback,
|
||||
pickContainerSource,
|
||||
reconcileContainerSource,
|
||||
rememberedContainerSource,
|
||||
sourcesFromCapabilities,
|
||||
isKubectlForbiddenError,
|
||||
isKubectlUnreachableError,
|
||||
isNerdctlUnreachableError,
|
||||
podStatusMessageKey,
|
||||
runtimeFailureMessage,
|
||||
} = require("./containerRuntime.shared.cjs");
|
||||
|
||||
test("parseRuntimeProbe reads line-anchored command -v markers", () => {
|
||||
const probe = parseRuntimeProbe([
|
||||
"command -v docker >/dev/null && printf __NC_DOCKER__=1",
|
||||
"__NC_OS__=Linux",
|
||||
"__NC_DOCKER__=1",
|
||||
"__NC_PODMAN__=1",
|
||||
"echo __NC_NERDCTL__=1 trailing",
|
||||
"__NC_KUBECTL__=1",
|
||||
].join("\n"));
|
||||
|
||||
assert.deepEqual(probe, {
|
||||
docker: true,
|
||||
podman: true,
|
||||
nerdctl: false,
|
||||
kubernetes: true,
|
||||
});
|
||||
assert.deepEqual(sourcesFromCapabilities(probe), ["docker", "podman", "kubernetes"]);
|
||||
});
|
||||
|
||||
test("sourcesFromCapabilities keeps a stable runtime order and ignores a docker-only host's missing peers", () => {
|
||||
assert.deepEqual(sourcesFromCapabilities({ hasDocker: true }), ["docker"]);
|
||||
assert.deepEqual(
|
||||
sourcesFromCapabilities({ hasNerdctl: true, hasKubectl: true, hasPodman: true }),
|
||||
["podman", "nerdctl", "kubernetes"],
|
||||
);
|
||||
assert.deepEqual(sourcesFromCapabilities(undefined), []);
|
||||
});
|
||||
|
||||
test("pickContainerSource remembers the last choice per host and falls back when it disappears", () => {
|
||||
assert.equal(
|
||||
pickContainerSource(["docker", "podman", "kubernetes"], null, "podman"),
|
||||
"podman",
|
||||
);
|
||||
assert.equal(
|
||||
pickContainerSource(["docker", "podman"], "kubernetes", "podman"),
|
||||
"podman",
|
||||
);
|
||||
assert.equal(
|
||||
pickContainerSource(["docker", "podman", "kubernetes"], "kubernetes", "docker"),
|
||||
"kubernetes",
|
||||
);
|
||||
assert.equal(pickContainerSource(["nerdctl"], null, "docker"), "nerdctl");
|
||||
assert.equal(
|
||||
rememberedContainerSource({ source: "kubernetes", engine: "podman" }, ["docker", "podman"]),
|
||||
"podman",
|
||||
);
|
||||
assert.equal(
|
||||
rememberedContainerSource({ source: "kubernetes", engine: "podman" }, ["docker", "podman", "kubernetes"]),
|
||||
"kubernetes",
|
||||
);
|
||||
assert.equal(
|
||||
pickContainerSource(
|
||||
["docker", "podman"],
|
||||
"kubernetes",
|
||||
rememberedContainerSource({ source: "kubernetes", engine: "podman" }, ["docker", "podman"]),
|
||||
),
|
||||
"podman",
|
||||
);
|
||||
assert.equal(firstContainerRuntime(["kubernetes", "podman"]), "podman");
|
||||
assert.equal(firstContainerRuntime(["kubernetes"]), null);
|
||||
});
|
||||
|
||||
test("reconcileContainerSource restores a runtime after a probe gap and does not treat the fallback as the choice", () => {
|
||||
const nerdctl = { source: "nerdctl", engine: "nerdctl" };
|
||||
assert.equal(reconcileContainerSource(["docker", "podman"], "nerdctl", nerdctl), "docker");
|
||||
assert.equal(isAutomaticRuntimeFallback(nerdctl, "docker", ["docker", "podman"]), true);
|
||||
assert.equal(
|
||||
reconcileContainerSource(["docker", "podman", "nerdctl"], "docker", nerdctl),
|
||||
"nerdctl",
|
||||
);
|
||||
assert.equal(isAutomaticRuntimeFallback(nerdctl, "podman", ["docker", "podman"]), false);
|
||||
|
||||
const kubernetes = { source: "kubernetes", engine: "podman" };
|
||||
assert.equal(reconcileContainerSource(["docker", "podman"], "kubernetes", kubernetes), "podman");
|
||||
assert.equal(isAutomaticRuntimeFallback(kubernetes, "podman", ["docker", "podman"]), true);
|
||||
assert.equal(
|
||||
reconcileContainerSource(["docker", "podman", "kubernetes"], "podman", kubernetes),
|
||||
"kubernetes",
|
||||
);
|
||||
});
|
||||
|
||||
test("buildRuntimeCommand uses the detected CLI and defaults unknown runtimes to docker", () => {
|
||||
assert.equal(buildRuntimeCommand("docker", "ps -a --format '{{json .}}'"), "docker ps -a --format '{{json .}}'");
|
||||
assert.equal(buildRuntimeCommand("podman", "start abc123"), "podman start abc123");
|
||||
assert.equal(buildRuntimeCommand("nerdctl", "logs -f --tail 200 abc123"), "nerdctl logs -f --tail 200 abc123");
|
||||
assert.equal(buildRuntimeCommand("kubernetes", "ps"), "docker ps");
|
||||
assert.equal(buildRuntimeCommand(undefined, "inspect abc"), "docker inspect abc");
|
||||
});
|
||||
|
||||
test("interactive container commands keep the docker sudo probe and swap the binary", () => {
|
||||
const dockerShell = buildContainerExecShellCommand("docker", "587abcdef123");
|
||||
assert.match(dockerShell, /^sh -c /);
|
||||
assert.match(dockerShell, /docker inspect 587abcdef123/);
|
||||
assert.match(dockerShell, /exec docker exec -it 587abcdef123/);
|
||||
assert.match(dockerShell, /exec sudo docker exec -it 587abcdef123/);
|
||||
assert.match(dockerShell, /permission\\ denied.*docker\.sock.*docker\.sock.*permission\\ denied/);
|
||||
assert.equal(dockerShell.includes("\n"), false);
|
||||
assert.doesNotMatch(dockerShell, /sudo -S/);
|
||||
|
||||
const podmanLogs = buildContainerLogsCommand("podman", "587abcdef123");
|
||||
assert.match(podmanLogs, /podman inspect 587abcdef123/);
|
||||
assert.match(podmanLogs, /exec podman logs -f --tail 200 587abcdef123/);
|
||||
assert.match(podmanLogs, /exec sudo podman logs -f --tail 200 587abcdef123/);
|
||||
assert.match(podmanLogs, /podman\.sock/);
|
||||
assert.doesNotMatch(podmanLogs, /docker/);
|
||||
|
||||
const nerdctlShell = buildContainerExecShellCommand("nerdctl", "abc");
|
||||
assert.match(nerdctlShell, /exec nerdctl exec -it abc/);
|
||||
assert.match(nerdctlShell, /exec sudo nerdctl exec -it abc/);
|
||||
assert.match(nerdctlShell, /containerd\.sock/);
|
||||
});
|
||||
|
||||
test("socket permission fallback is specific to each runtime", () => {
|
||||
assert.equal(
|
||||
isRuntimeSocketPermissionError("docker", { stderr: "dial unix /var/run/docker.sock: connect: permission denied" }),
|
||||
true,
|
||||
);
|
||||
assert.equal(
|
||||
isRuntimeSocketPermissionError("docker", { stderr: "permission denied" }),
|
||||
false,
|
||||
);
|
||||
assert.equal(
|
||||
isRuntimeSocketPermissionError("podman", { stderr: "dial unix /run/podman/podman.sock: connect: permission denied" }),
|
||||
true,
|
||||
);
|
||||
assert.equal(
|
||||
isRuntimeSocketPermissionError("nerdctl", { stderr: "dial unix /run/containerd/containerd.sock: connect: permission denied" }),
|
||||
true,
|
||||
);
|
||||
assert.equal(
|
||||
isRuntimeSocketPermissionError("podman", { stderr: "dial unix /var/run/docker.sock: connect: permission denied" }),
|
||||
false,
|
||||
);
|
||||
});
|
||||
|
||||
test("parseKubeconfigContexts returns names and namespaces without user material", () => {
|
||||
const parsed = parseKubeconfigContexts(JSON.stringify({
|
||||
"current-context": "kind-kind",
|
||||
contexts: [
|
||||
{
|
||||
name: "kind-kind",
|
||||
context: { cluster: "kind-kind", user: "kind-kind", namespace: "app" },
|
||||
},
|
||||
{
|
||||
name: "arn:aws:eks:us-west-2:123:cluster/prod",
|
||||
context: { cluster: "prod", user: "admin" },
|
||||
},
|
||||
],
|
||||
users: [{ name: "admin", user: { token: "secret-token" } }],
|
||||
}));
|
||||
|
||||
assert.equal(parsed.current, "kind-kind");
|
||||
assert.deepEqual(parsed.contexts, [
|
||||
{ name: "kind-kind", namespace: "app", current: true },
|
||||
{ name: "arn:aws:eks:us-west-2:123:cluster/prod", namespace: "default", current: false },
|
||||
]);
|
||||
assert.equal(JSON.stringify(parsed).includes("secret-token"), false);
|
||||
});
|
||||
|
||||
test("parseNamespaceList skips a bracketed banner before the JSON object", () => {
|
||||
const names = parseNamespaceList([
|
||||
"[WARN] connection is using an insecure certificate",
|
||||
'{"items":[{"metadata":{"name":"default"}}]}',
|
||||
].join("\n"));
|
||||
assert.deepEqual(names, ["default"]);
|
||||
});
|
||||
|
||||
test("parseNamespaceList reads metadata names and ignores kubectl warnings before the JSON", () => {
|
||||
const stdout = "Warning: old client\n" + JSON.stringify({
|
||||
items: [
|
||||
{ metadata: { name: "default" } },
|
||||
{ metadata: { name: "kube-system" } },
|
||||
{ metadata: {} },
|
||||
],
|
||||
});
|
||||
assert.deepEqual(parseNamespaceList(stdout), ["default", "kube-system"]);
|
||||
});
|
||||
|
||||
test("parseKubectlPodList reports status, restarts, age, and containers", () => {
|
||||
const now = Date.parse("2026-10-20T00:10:00Z");
|
||||
const pods = parseKubectlPodList(JSON.stringify({
|
||||
items: [
|
||||
{
|
||||
metadata: {
|
||||
name: "api",
|
||||
namespace: "default",
|
||||
creationTimestamp: "2026-10-20T00:08:30Z",
|
||||
},
|
||||
spec: {
|
||||
containers: [{ name: "app" }, { name: "sidecar" }],
|
||||
initContainers: [{ name: "init" }],
|
||||
},
|
||||
status: {
|
||||
phase: "Running",
|
||||
initContainerStatuses: [
|
||||
{ name: "init", restartCount: 0, state: { terminated: { reason: "Completed" } } },
|
||||
],
|
||||
containerStatuses: [
|
||||
{ name: "app", restartCount: 4, state: { waiting: { reason: "CrashLoopBackOff" } } },
|
||||
{ name: "sidecar", restartCount: 1, state: { running: {} } },
|
||||
],
|
||||
},
|
||||
},
|
||||
{
|
||||
metadata: {
|
||||
name: "batch",
|
||||
namespace: "jobs",
|
||||
creationTimestamp: "2026-10-18T00:10:00Z",
|
||||
},
|
||||
spec: { containers: [{ name: "worker" }] },
|
||||
status: {
|
||||
phase: "Succeeded",
|
||||
containerStatuses: [
|
||||
{ name: "worker", restartCount: 0, state: { terminated: { reason: "Completed" } } },
|
||||
],
|
||||
},
|
||||
},
|
||||
],
|
||||
}), now);
|
||||
|
||||
assert.deepEqual(pods, [
|
||||
{
|
||||
name: "api",
|
||||
namespace: "default",
|
||||
status: "CrashLoopBackOff",
|
||||
restarts: 5,
|
||||
age: "1m",
|
||||
containers: ["app", "sidecar", "init"],
|
||||
shellContainers: ["sidecar"],
|
||||
},
|
||||
{
|
||||
name: "batch",
|
||||
namespace: "jobs",
|
||||
status: "Completed",
|
||||
restarts: 0,
|
||||
age: "2d",
|
||||
containers: ["worker"],
|
||||
shellContainers: [],
|
||||
},
|
||||
]);
|
||||
const terminating = parseKubectlPodList(JSON.stringify({
|
||||
items: [{
|
||||
metadata: {
|
||||
name: "api",
|
||||
namespace: "default",
|
||||
creationTimestamp: "2026-10-20T00:08:30Z",
|
||||
deletionTimestamp: "2026-10-20T00:10:00Z",
|
||||
},
|
||||
spec: {
|
||||
containers: [{ name: "app" }],
|
||||
ephemeralContainers: [{ name: "debugger" }, { name: "old-debug" }],
|
||||
},
|
||||
status: {
|
||||
phase: "Running",
|
||||
containerStatuses: [
|
||||
{ name: "app", restartCount: 0, state: { running: {} } },
|
||||
],
|
||||
ephemeralContainerStatuses: [
|
||||
{ name: "debugger", restartCount: 0, state: { running: {} } },
|
||||
{ name: "old-debug", restartCount: 0, state: { terminated: { reason: "Completed" } } },
|
||||
],
|
||||
},
|
||||
}],
|
||||
}), now);
|
||||
const evicted = parseKubectlPodList(JSON.stringify({
|
||||
items: [{
|
||||
metadata: { name: "api", namespace: "default", creationTimestamp: "2026-10-20T00:08:30Z" },
|
||||
spec: { containers: [{ name: "app" }] },
|
||||
status: {
|
||||
phase: "Failed",
|
||||
reason: "Evicted",
|
||||
message: "The node was low on resource: memory.",
|
||||
containerStatuses: [
|
||||
{ name: "app", restartCount: 0, state: { terminated: { reason: "Error", exitCode: 137 } } },
|
||||
],
|
||||
},
|
||||
}],
|
||||
}), now);
|
||||
assert.equal(evicted[0].status, "Evicted");
|
||||
|
||||
assert.equal(terminating[0].status, "Terminating");
|
||||
assert.deepEqual(terminating[0].containers, ["app", "debugger", "old-debug"]);
|
||||
assert.deepEqual(terminating[0].shellContainers, ["app", "debugger"]);
|
||||
|
||||
assert.equal(formatPodAge("2026-10-20T00:09:10Z", now), "50s");
|
||||
assert.equal(formatPodAge("2026-10-20T00:07:00Z", now), "3m");
|
||||
assert.equal(formatPodAge("2026-10-19T22:10:00Z", now), "2h");
|
||||
});
|
||||
|
||||
test("kubectl commands quote remote arguments and reject control characters", () => {
|
||||
assert.equal(
|
||||
buildKubectlPodsCommand("kind-kind", "default"),
|
||||
"kubectl --context 'kind-kind' -n 'default' get pods -o json",
|
||||
);
|
||||
assert.equal(
|
||||
buildKubectlNamespacesCommand("arn:aws:eks:us-west-2:123:cluster/prod"),
|
||||
"kubectl --context 'arn:aws:eks:us-west-2:123:cluster/prod' get namespaces -o json",
|
||||
);
|
||||
assert.equal(
|
||||
buildKubectlDeletePodCommand("ctx", "kube-system", "coredns-a"),
|
||||
"kubectl --context 'ctx' -n 'kube-system' delete pod 'coredns-a' --wait=false",
|
||||
);
|
||||
assert.equal(buildKubectlDeletePodCommand("ctx", "default", "bad\nname"), null);
|
||||
assert.equal(buildKubectlPodsCommand("ok", "bad\nns"), null);
|
||||
assert.equal(
|
||||
buildKubectlNamespacesCommand("o'brien", "powershell"),
|
||||
"kubectl --context 'o''brien' get namespaces -o json",
|
||||
);
|
||||
assert.equal(
|
||||
buildKubectlPodsCommand("o'brien", "default", "powershell"),
|
||||
"kubectl --context 'o''brien' -n 'default' get pods -o json",
|
||||
);
|
||||
assert.match(
|
||||
buildKubectlDeletePodCommand("o'brien", "default", "web", "cmd"),
|
||||
/--context "o'brien"/,
|
||||
);
|
||||
|
||||
const quoted = buildKubectlDeletePodCommand("o'brien", "default", "web");
|
||||
assert.match(quoted, /--context 'o'"'"'brien'/);
|
||||
assert.doesNotMatch(quoted, /--context 'o'brien'/);
|
||||
|
||||
const shell = buildKubectlExecShellCommand("kind-kind", "default", "api", "sidecar");
|
||||
assert.match(shell, /^sh -c /);
|
||||
assert.match(shell, /kubectl/);
|
||||
assert.match(shell, /--context/);
|
||||
assert.match(shell, /kind-kind/);
|
||||
assert.match(shell, /-n/);
|
||||
assert.match(shell, /default/);
|
||||
assert.match(shell, /exec -it/);
|
||||
assert.match(shell, /api/);
|
||||
assert.match(shell, /-c/);
|
||||
assert.match(shell, /sidecar/);
|
||||
assert.match(shell, /command -v bash/);
|
||||
assert.equal(shell.includes("\n"), false);
|
||||
|
||||
const logs = buildKubectlLogsCommand("kind-kind", "default", "api", "app");
|
||||
assert.match(logs, /kubectl/);
|
||||
assert.match(logs, /logs -f --tail 200/);
|
||||
assert.match(logs, /kind-kind/);
|
||||
assert.match(logs, /app/);
|
||||
|
||||
const described = buildKubectlDescribeCommand("kind-kind", "default", "api");
|
||||
assert.match(described, /kubectl/);
|
||||
assert.match(described, /describe pod/);
|
||||
assert.match(described, /kind-kind/);
|
||||
assert.match(described, /api/);
|
||||
assert.equal(buildKubectlExecShellCommand("kind-kind", "default", "bad\npod"), 'echo "Invalid pod name"');
|
||||
});
|
||||
|
||||
test("kubectl popup commands skip the POSIX wrapper on Windows shells", () => {
|
||||
for (const shell of ["powershell", "cmd"]) {
|
||||
const exec = buildKubectlExecShellCommand("kind-kind", "default", "api", "sidecar", shell);
|
||||
const logs = buildKubectlLogsCommand("kind-kind", "default", "api", "app", shell);
|
||||
const described = buildKubectlDescribeCommand("kind-kind", "default", "api", shell);
|
||||
assert.match(exec, /kubectl --context/, shell);
|
||||
assert.match(logs, /kubectl --context/, shell);
|
||||
assert.match(described, /kubectl --context/, shell);
|
||||
assert.match(exec, /__NC_POPUP__/, shell);
|
||||
assert.equal(exec.startsWith("sh -c "), false, shell);
|
||||
assert.match(exec, /exec -it/);
|
||||
assert.match(exec, /sidecar/);
|
||||
assert.match(logs, /logs -f --tail 200/);
|
||||
assert.match(described, /describe pod/);
|
||||
}
|
||||
assert.match(
|
||||
buildKubectlExecShellCommand("kind-kind", "default", "api", "sidecar", "powershell"),
|
||||
/--context 'kind-kind'/,
|
||||
);
|
||||
assert.match(
|
||||
buildKubectlDescribeCommand("kind-kind", "default", "api", "cmd"),
|
||||
/--context "kind-kind"/,
|
||||
);
|
||||
assert.equal(
|
||||
buildKubectlDescribeCommand("prod-%USERNAME%", "default", "api", "cmd"),
|
||||
'echo "Invalid context"',
|
||||
);
|
||||
assert.equal(
|
||||
buildKubectlDescribeCommand("prod!USER!", "default", "api", "cmd"),
|
||||
'echo "Invalid context"',
|
||||
);
|
||||
assert.match(
|
||||
buildKubectlDescribeCommand("prod-%USERNAME%", "default", "api", "powershell"),
|
||||
/--context 'prod-%USERNAME%'/,
|
||||
);
|
||||
assert.equal(
|
||||
buildKubectlNamespacesCommand("prod-%USERNAME%", "powershell"),
|
||||
"kubectl --context 'prod-%USERNAME%' get namespaces -o json",
|
||||
);
|
||||
});
|
||||
|
||||
test("container popup commands inspect in the host shell on Windows", () => {
|
||||
const powershellLogs = buildContainerLogsCommand("podman", "abc123", "powershell");
|
||||
assert.equal(powershellLogs.startsWith("sh -c "), false);
|
||||
assert.match(powershellLogs, /\$__nc_popup='__NC_POPUP__'; podman inspect abc123 >\$null;/);
|
||||
assert.match(powershellLogs, /podman logs -f --tail 200 abc123/);
|
||||
|
||||
const cmdShell = buildContainerExecShellCommand("nerdctl", "abc123", "cmd");
|
||||
assert.equal(cmdShell.startsWith("sh -c "), false);
|
||||
assert.match(cmdShell, /^set "__NC_POPUP__=1"& nerdctl inspect abc123 >nul && nerdctl exec -it abc123/);
|
||||
assert.match(cmdShell, /sh -c "command -v bash/);
|
||||
});
|
||||
|
||||
test("isKubectlForbiddenError matches namespace list denial", () => {
|
||||
assert.equal(
|
||||
isKubectlForbiddenError('Error from server (Forbidden): namespaces is forbidden: User "view" cannot list resource "namespaces"'),
|
||||
true,
|
||||
);
|
||||
assert.equal(isKubectlForbiddenError("Unable to connect to the server"), false);
|
||||
});
|
||||
|
||||
test("unreachable kubectl API text is classified without treating Forbidden as unreachable", () => {
|
||||
const spam = [
|
||||
'memcache.go:381] "Couldn\'t get current server API group list"',
|
||||
'dial tcp 127.0.0.1:6443: connect: connection refused',
|
||||
].join("\n");
|
||||
assert.equal(isKubectlUnreachableError(spam), true);
|
||||
assert.equal(isKubectlUnreachableError("Unable to connect to the server: dial tcp 10.0.0.1:6443: i/o timeout"), true);
|
||||
assert.equal(
|
||||
isKubectlUnreachableError('Error from server (Forbidden): pods is forbidden'),
|
||||
false,
|
||||
);
|
||||
assert.equal(isKubectlUnreachableError(""), false);
|
||||
});
|
||||
|
||||
test("nerdctl containerd failures are classified separately from socket permission errors", () => {
|
||||
assert.equal(
|
||||
isNerdctlUnreachableError('time="2026-10-20T18:31:27+08:00" level=fatal msg="rootless containerd not running? (hint: use containerd-rootless-setuptool.sh install to start rootless containerd): environment variable XDG_RUNTIME_DIR is not set"'),
|
||||
true,
|
||||
);
|
||||
assert.equal(
|
||||
isNerdctlUnreachableError("permission denied while trying to connect to the containerd socket"),
|
||||
false,
|
||||
);
|
||||
assert.equal(isNerdctlUnreachableError("Error: no such container: abc"), false);
|
||||
});
|
||||
|
||||
test("runtime failures map to localized message keys and pod badges use known status keys", () => {
|
||||
assert.deepEqual(runtimeFailureMessage("nerdctlUnreachable"), {
|
||||
key: "systemManager.errors.nerdctlUnreachable",
|
||||
});
|
||||
assert.deepEqual(runtimeFailureMessage("clusterUnreachable", "broken-cluster"), {
|
||||
key: "systemManager.errors.clusterUnreachable",
|
||||
values: { context: "broken-cluster" },
|
||||
});
|
||||
assert.deepEqual(runtimeFailureMessage("clusterUnreachable", " "), {
|
||||
key: "systemManager.errors.clusterUnreachableGeneric",
|
||||
});
|
||||
assert.equal(runtimeFailureMessage("other"), null);
|
||||
assert.equal(podStatusMessageKey("Running"), "systemManager.k8s.status.Running");
|
||||
assert.equal(podStatusMessageKey("CrashLoopBackOff"), "systemManager.k8s.status.CrashLoopBackOff");
|
||||
assert.equal(podStatusMessageKey("Evicted"), "systemManager.k8s.status.Evicted");
|
||||
assert.equal(podStatusMessageKey("Init:0/1"), null);
|
||||
});
|
||||
@@ -1,38 +0,0 @@
|
||||
import { runtimeFailureMessage } from "@/domain/systemManager/containerRuntime.shared.cjs";
|
||||
|
||||
export type {
|
||||
ContainerRuntimeId,
|
||||
ContainerSourceId,
|
||||
KubeContextInfo,
|
||||
KubectlPodInfo,
|
||||
RuntimeProbeFlags,
|
||||
} from "@/domain/systemManager/containerRuntime.shared.cjs";
|
||||
|
||||
export {
|
||||
NETCATTY_POPUP_HISTORY_MARKER,
|
||||
buildContainerExecShellCommand,
|
||||
buildContainerLogsCommand,
|
||||
buildKubectlDescribeCommand,
|
||||
buildKubectlExecShellCommand,
|
||||
buildKubectlLogsCommand,
|
||||
firstContainerRuntime,
|
||||
isContainerSource,
|
||||
isKubectlForbiddenError,
|
||||
isAutomaticRuntimeFallback,
|
||||
pickContainerSource,
|
||||
podStatusMessageKey,
|
||||
reconcileContainerSource,
|
||||
rememberedContainerSource,
|
||||
sourcesFromCapabilities,
|
||||
} from "@/domain/systemManager/containerRuntime.shared.cjs";
|
||||
|
||||
export function describeRuntimeFailure(
|
||||
result: { error?: string; errorCode?: string } | null | undefined,
|
||||
translate: (key: string, values?: Record<string, string>) => string,
|
||||
fallback: string,
|
||||
context?: string | null,
|
||||
): string {
|
||||
const mapped = runtimeFailureMessage(result?.errorCode, context);
|
||||
if (mapped) return translate(mapped.key, mapped.values);
|
||||
return result?.error || fallback;
|
||||
}
|
||||
@@ -1,18 +1,52 @@
|
||||
import {
|
||||
buildContainerExecShellCommand,
|
||||
buildContainerLogsCommand,
|
||||
} from "./containerRuntime.ts";
|
||||
|
||||
/** Sanitize Docker container/image IDs — must match electron/bridges/systemManager/dockerOps.cjs */
|
||||
export function sanitizeDockerContainerId(id: string): string {
|
||||
return String(id || "").replace(/[^a-zA-Z0-9]/g, "").slice(0, 64);
|
||||
return String(id || '').replace(/[^a-zA-Z0-9]/g, '').slice(0, 64);
|
||||
}
|
||||
|
||||
const CLEAR_STARTUP_OUTPUT = "printf '\\033[H\\033[2J\\033[3J';";
|
||||
|
||||
function shQuote(value: string): string {
|
||||
return `'${String(value).replace(/'/g, `'"'"'`)}'`;
|
||||
}
|
||||
|
||||
function buildDockerCommandWithSudoFallback(containerId: string, dockerArgs: string): string {
|
||||
const plainCommand = `docker ${dockerArgs}`;
|
||||
const sudoCommand = `sudo ${plainCommand}`;
|
||||
const script = [
|
||||
CLEAR_STARTUP_OUTPUT,
|
||||
`_nc_docker_err=$(docker inspect ${containerId} 2>&1 >/dev/null);`,
|
||||
'_nc_docker_status=$?;',
|
||||
`if [ "$_nc_docker_status" -eq 0 ]; then exec ${plainCommand}; fi;`,
|
||||
'_nc_docker_lc=$(printf \'%s\' "$_nc_docker_err" | tr \'[:upper:]\' \'[:lower:]\');',
|
||||
'case "$_nc_docker_lc" in',
|
||||
[
|
||||
'*permission\\ denied*docker\\ daemon*',
|
||||
'*docker\\ daemon*permission\\ denied*',
|
||||
'*permission\\ denied*docker.sock*',
|
||||
'*docker.sock*permission\\ denied*',
|
||||
'*permission\\ denied*/var/run/docker.sock*',
|
||||
'*/var/run/docker.sock*permission\\ denied*',
|
||||
'*permission\\ denied*connect\\ to\\ the\\ docker\\ daemon*',
|
||||
'*connect\\ to\\ the\\ docker\\ daemon*permission\\ denied*',
|
||||
].join('|') + `) exec ${sudoCommand} ;;`,
|
||||
'*) printf \'%s\\n\' "$_nc_docker_err" >&2; exit "$_nc_docker_status" ;;',
|
||||
'esac',
|
||||
].join(' ');
|
||||
return `sh -c ${shQuote(script)}`;
|
||||
}
|
||||
|
||||
/** Interactive shell into a container — prefer bash, fall back to sh. */
|
||||
export function buildDockerExecShellCommand(containerId: string): string {
|
||||
return buildContainerExecShellCommand("docker", containerId);
|
||||
const safeId = sanitizeDockerContainerId(containerId);
|
||||
if (!safeId) return 'echo "Invalid container id"';
|
||||
return buildDockerCommandWithSudoFallback(
|
||||
safeId,
|
||||
`exec -it ${safeId} sh -c 'command -v bash >/dev/null 2>&1 && exec bash || exec sh'`,
|
||||
);
|
||||
}
|
||||
|
||||
export function buildDockerLogsCommand(containerId: string): string {
|
||||
return buildContainerLogsCommand("docker", containerId);
|
||||
const safeId = sanitizeDockerContainerId(containerId);
|
||||
if (!safeId) return 'echo "Invalid container id"';
|
||||
return buildDockerCommandWithSudoFallback(safeId, `logs -f --tail 200 ${safeId}`);
|
||||
}
|
||||
@@ -4,7 +4,6 @@ import test from "node:test";
|
||||
import {
|
||||
allowSystemManagerMutations,
|
||||
buildSystemManagerTabs,
|
||||
hasContainerSource,
|
||||
shouldCollectServerStats,
|
||||
shouldShowGpuTab,
|
||||
shouldShowPortsTab,
|
||||
@@ -58,23 +57,6 @@ test("gpu tab appears only after nvidia-smi or npu-smi is detected", () => {
|
||||
);
|
||||
});
|
||||
|
||||
test("the single container tab appears for podman, nerdctl, or kubectl without a docker binary", () => {
|
||||
const networkHost = {
|
||||
id: "host-podman",
|
||||
label: "Podman",
|
||||
hostname: "podman.local",
|
||||
username: "root",
|
||||
tags: [],
|
||||
os: "linux" as const,
|
||||
deviceType: "network" as const,
|
||||
};
|
||||
assert.equal(hasContainerSource(caps()), false);
|
||||
assert.equal(hasContainerSource(caps({ hasPodman: true })), true);
|
||||
assert.equal(buildSystemManagerTabs(networkHost, caps({ hasPodman: true }), null).includes("docker"), true);
|
||||
assert.equal(buildSystemManagerTabs(networkHost, caps({ hasKubectl: true }), null).includes("docker"), true);
|
||||
assert.equal(buildSystemManagerTabs(networkHost, caps(), null).includes("docker"), false);
|
||||
});
|
||||
|
||||
test("ports and services tabs are detect-first like GPU", () => {
|
||||
assert.equal(shouldShowPortsTab(undefined), false);
|
||||
assert.equal(shouldShowPortsTab(caps({ hasSs: true })), true);
|
||||
|
||||
@@ -44,19 +44,12 @@ export function shouldShowTmuxTab(
|
||||
return false;
|
||||
}
|
||||
|
||||
export function hasContainerSource(capabilities: SessionCapabilities | undefined): boolean {
|
||||
return capabilities?.hasDocker === true
|
||||
|| capabilities?.hasPodman === true
|
||||
|| capabilities?.hasNerdctl === true
|
||||
|| capabilities?.hasKubectl === true;
|
||||
}
|
||||
|
||||
export function shouldShowDockerTab(
|
||||
host: Host | null | undefined,
|
||||
capabilities: SessionCapabilities | undefined,
|
||||
session: TerminalSession | null | undefined,
|
||||
): boolean {
|
||||
if (hasContainerSource(capabilities)) return true;
|
||||
if (capabilities?.hasDocker === true) return true;
|
||||
// Network appliances: never show Docker from an OS guess alone.
|
||||
if (isNetworkDeviceTarget(host)) return false;
|
||||
return isDefiniteLinuxTarget(host, capabilities, session);
|
||||
|
||||
@@ -4,12 +4,6 @@ export interface SessionCapabilities {
|
||||
targetOs: TargetOs;
|
||||
hasTmux: boolean;
|
||||
hasDocker: boolean;
|
||||
/** `podman` binary present. A podman-docker shim may also set hasDocker. */
|
||||
hasPodman?: boolean;
|
||||
/** `nerdctl` binary present. */
|
||||
hasNerdctl?: boolean;
|
||||
/** `kubectl` binary present on the remote host. */
|
||||
hasKubectl?: boolean;
|
||||
hasNvidiaSmi: boolean;
|
||||
hasNpuSmi: boolean;
|
||||
/** `ss` binary present (preferred listening-port collector). */
|
||||
@@ -200,7 +194,7 @@ export type DockerContainerAction =
|
||||
|
||||
export type DockerImageManageAction =
|
||||
| { action: 'pull'; imageRef: string }
|
||||
| { action: 'rm'; imageId: string; force?: boolean; repository?: string; tag?: string }
|
||||
| { action: 'rm'; imageId: string; force?: boolean }
|
||||
| { action: 'prune'; all?: boolean }
|
||||
| { action: 'tag'; imageId: string; repository: string; tag?: string };
|
||||
|
||||
@@ -227,11 +221,6 @@ export interface TerminalPopupPayload {
|
||||
parentSessionId: string;
|
||||
sourceSession: import('../../types').TerminalSession;
|
||||
startupCommand: string;
|
||||
/**
|
||||
* System-manager panel rectangle in renderer viewport pixels. The popup is
|
||||
* placed in the remaining source-window area so it does not cover this panel.
|
||||
*/
|
||||
avoidClientRect?: { x: number; y: number; width: number; height: number };
|
||||
localShellType?: import('../../types').TerminalSession['shellType'];
|
||||
/**
|
||||
* When set, the popup attaches to this already-running backend session
|
||||
|
||||
@@ -77,8 +77,6 @@ function isConfirmedTerminalShellPrompt(promptText, options = {}) {
|
||||
if (code >= 0xE000 && code <= 0xF8FF) return true;
|
||||
}
|
||||
if (/[$#%]$/u.test(prompt)) return true;
|
||||
// POSIX sh/bash/ksh default PS2. Trusted only with heredoc context (#3796).
|
||||
if (prompt === ">") return isHeredocPs2Continuation(promptText, options);
|
||||
if (!prompt.endsWith(">")) return false;
|
||||
if (/^(?:PS\s+)?[A-Za-z]:[\\/].*>$/u.test(prompt)) return true;
|
||||
if (/[@\\/~:]\S*>$/u.test(prompt)) return true;
|
||||
@@ -86,53 +84,6 @@ function isConfirmedTerminalShellPrompt(promptText, options = {}) {
|
||||
&& /^[A-Za-z0-9_.-]+(?:\([^)]{1,128}\))?>$/u.test(prompt);
|
||||
}
|
||||
|
||||
function splitTerminalLogicalLines(value) {
|
||||
const lines = [];
|
||||
let start = 0;
|
||||
for (let i = 0; i < value.length; i += 1) {
|
||||
const ch = value[i];
|
||||
if (ch !== "\n" && ch !== "\r") continue;
|
||||
lines.push(value.slice(start, i));
|
||||
if (ch === "\r" && value[i + 1] === "\n") i += 1;
|
||||
start = i + 1;
|
||||
}
|
||||
lines.push(value.slice(start));
|
||||
return lines;
|
||||
}
|
||||
|
||||
function isBashPs2ContinuationLine(line) {
|
||||
const trimmed = line.trim();
|
||||
return trimmed === ">" || trimmed.startsWith("> ");
|
||||
}
|
||||
|
||||
function commandAfterConfirmedShellPrompt(line, options) {
|
||||
const text = line.trim();
|
||||
for (let i = 0; i < text.length - 1; i += 1) {
|
||||
const ch = text[i];
|
||||
if (ch !== "$" && ch !== "#" && ch !== "%" && ch !== ">") continue;
|
||||
const rest = text.slice(i + 1);
|
||||
if (!/^\s+\S/u.test(rest)) continue;
|
||||
const candidate = text.slice(0, i + 1);
|
||||
if (!isConfirmedTerminalShellPrompt(candidate, options)) continue;
|
||||
if ((ch === "$" || ch === "#" || ch === "%") && !isPlausibleShellPromptBody(candidate.slice(0, -1))) {
|
||||
continue;
|
||||
}
|
||||
return rest;
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
||||
// Bare ">" is bash PS2 only inside a heredoc opened by a confirmed shell
|
||||
// command (#3796). read -s -p '> ' and other origins stay fail-closed.
|
||||
function isHeredocPs2Continuation(value, options) {
|
||||
const lines = splitTerminalLogicalLines(stripTerminalControlSequences(value));
|
||||
let index = lines.length - 2;
|
||||
while (index >= 0 && isBashPs2ContinuationLine(lines[index])) index -= 1;
|
||||
if (index < 0) return false;
|
||||
const command = commandAfterConfirmedShellPrompt(lines[index], options);
|
||||
return command != null && command.includes("<<");
|
||||
}
|
||||
|
||||
function isPlausibleShellPromptBody(body) {
|
||||
const trimmed = body.trim();
|
||||
if (!trimmed) return true;
|
||||
@@ -166,8 +117,7 @@ function isUntrustedTerminalInputPrompt(value, options = {}) {
|
||||
if (options.alternateScreen && /^:\s*$/.test(prompt)) return false;
|
||||
if (!/[::>›»]\s*$/u.test(prompt)) return false;
|
||||
if (hasTypedInputAfterConfirmedPrompt(prompt, options)) return false;
|
||||
// Pass the original text so a bare ">" can see heredoc lines above it.
|
||||
return !isConfirmedTerminalShellPrompt(value, options);
|
||||
return !isConfirmedTerminalShellPrompt(prompt, options);
|
||||
}
|
||||
|
||||
module.exports = { isUntrustedTerminalInputPrompt };
|
||||
@@ -4,7 +4,6 @@ import test from 'node:test';
|
||||
|
||||
import {
|
||||
appendTerminalPromptSecurityTail,
|
||||
foldTerminalPromptSecurityOutput,
|
||||
isConfirmedTerminalShellPrompt,
|
||||
isSensitiveTerminalChallenge,
|
||||
isUntrustedTerminalInputPrompt,
|
||||
@@ -123,69 +122,6 @@ test('ordinary shell commands ending with a colon are not untrusted input prompt
|
||||
assert.equal(isUntrustedTerminalInputPrompt('Password: '), true);
|
||||
});
|
||||
|
||||
test('bash PS2 is trusted only while a heredoc continuation is open (#3796)', () => {
|
||||
// CentOS-7-style bash has no bracketed paste, so a heredoc paste echoes the
|
||||
// default PS2 "> " after each line. That must keep broadcast on.
|
||||
const heredocEcho = '[root@h ~]# cat >> f << EOF\r\n> ';
|
||||
assert.equal(isConfirmedTerminalShellPrompt(heredocEcho), true);
|
||||
assert.equal(isUntrustedTerminalInputPrompt(heredocEcho), false);
|
||||
const heredocBody = '[root@h ~]# cat >> f << EOF\r\n> echo madvise > /sys/kernel/mm/transparent_hugepage/enabled\r\n> ';
|
||||
assert.equal(isConfirmedTerminalShellPrompt(heredocBody), true);
|
||||
assert.equal(isUntrustedTerminalInputPrompt(heredocBody), false);
|
||||
// No preceding command, or an echo-disabled prompt labelled ">", fails closed.
|
||||
assert.equal(isConfirmedTerminalShellPrompt('>'), false);
|
||||
assert.equal(isConfirmedTerminalShellPrompt('> '), false);
|
||||
assert.equal(isUntrustedTerminalInputPrompt('>'), true);
|
||||
assert.equal(isUntrustedTerminalInputPrompt('> '), true);
|
||||
const secretPrompt = "[root@h ~]# read -s -p '> ' secret\r\n> ";
|
||||
assert.equal(isConfirmedTerminalShellPrompt(secretPrompt), false);
|
||||
assert.equal(isUntrustedTerminalInputPrompt(secretPrompt), true);
|
||||
// The echoed terminator line is ordinary output, not a prompt latch.
|
||||
assert.equal(isConfirmedTerminalShellPrompt('> EOF'), false);
|
||||
assert.equal(isUntrustedTerminalInputPrompt('> EOF'), false);
|
||||
// Labelled challenges stay fail-closed. zsh "heredoc>" is out of scope.
|
||||
assert.equal(isUntrustedTerminalInputPrompt('Custom>'), true);
|
||||
assert.equal(isUntrustedTerminalInputPrompt('Token>'), true);
|
||||
assert.equal(isUntrustedTerminalInputPrompt('Password:'), true);
|
||||
assert.equal(isConfirmedTerminalShellPrompt('Custom>'), false);
|
||||
assert.equal(isConfirmedTerminalShellPrompt('Token>'), false);
|
||||
assert.equal(isConfirmedTerminalShellPrompt('Password:'), false);
|
||||
assert.equal(isUntrustedTerminalInputPrompt('heredoc>'), true);
|
||||
assert.equal(isConfirmedTerminalShellPrompt('heredoc>'), false);
|
||||
});
|
||||
|
||||
test('prompt observation keeps the heredoc opener and drops it after the shell returns (#3796)', () => {
|
||||
let state = { origin: '', current: '' };
|
||||
state = foldTerminalPromptSecurityOutput(state, '[root@h ~]# cat >> f << EOF\r\n');
|
||||
state = foldTerminalPromptSecurityOutput(state, '> echo madvise > /sys/kernel/mm/transparent_hugepage/enabled\r\n');
|
||||
state = foldTerminalPromptSecurityOutput(state, '> ');
|
||||
assert.equal(isConfirmedTerminalShellPrompt(state.promptText), true);
|
||||
assert.equal(isUntrustedTerminalInputPrompt(state.promptText), false);
|
||||
|
||||
state = foldTerminalPromptSecurityOutput(state, "\r\n[root@h ~]# ");
|
||||
state = foldTerminalPromptSecurityOutput(state, '\r\n> ');
|
||||
assert.equal(isConfirmedTerminalShellPrompt(state.promptText), false);
|
||||
assert.equal(isUntrustedTerminalInputPrompt(state.promptText), true);
|
||||
|
||||
state = { origin: '', current: '' };
|
||||
state = foldTerminalPromptSecurityOutput(state, "[root@h ~]# read -s -p '> ' secret\r\n");
|
||||
state = foldTerminalPromptSecurityOutput(state, '> ');
|
||||
assert.equal(isConfirmedTerminalShellPrompt(state.promptText), false);
|
||||
assert.equal(isUntrustedTerminalInputPrompt(state.promptText), true);
|
||||
|
||||
state = foldTerminalPromptSecurityOutput({ origin: '', current: '' }, '> ');
|
||||
assert.equal(isUntrustedTerminalInputPrompt(state.promptText), true);
|
||||
assert.equal(isConfirmedTerminalShellPrompt(state.promptText), false);
|
||||
});
|
||||
|
||||
test('prompt observation coalesces a CRLF split across chunks (#3796)', () => {
|
||||
let state = { origin: '', current: '' };
|
||||
state = foldTerminalPromptSecurityOutput(state, "[root@h ~]# cat >> f << EOF\r");
|
||||
state = foldTerminalPromptSecurityOutput(state, '\n> ');
|
||||
assert.equal(isConfirmedTerminalShellPrompt(state.promptText), true);
|
||||
assert.equal(isUntrustedTerminalInputPrompt(state.promptText), false);
|
||||
});
|
||||
|
||||
test('vim ex command-line is not an untrusted input prompt (#3422)', () => {
|
||||
assert.equal(isUntrustedTerminalInputPrompt(':', { alternateScreen: true }), false);
|
||||
assert.equal(isUntrustedTerminalInputPrompt(': ', { alternateScreen: true }), false);
|
||||
@@ -211,14 +147,6 @@ test('renderer and main-process prompt classifiers stay aligned', () => {
|
||||
['Challenge # 1:', undefined, true],
|
||||
['router> show ip:', { allowHostStyleGreaterThan: true }, false],
|
||||
['router> show ip:', undefined, true],
|
||||
['>', undefined, true],
|
||||
['> ', undefined, true],
|
||||
['[root@h ~]# cat >> f << EOF\r\n> ', undefined, false],
|
||||
['[root@h ~]# cat >> f << EOF\r\n> echo madvise > /sys/kernel/mm/transparent_hugepage/enabled\r\n> ', undefined, false],
|
||||
["[root@h ~]# read -s -p '> ' secret\r\n> ", undefined, true],
|
||||
['Custom>', undefined, true],
|
||||
['Token>', undefined, true],
|
||||
['Password:', undefined, true],
|
||||
];
|
||||
|
||||
for (const [prompt, options, expected] of cases) {
|
||||
|
||||
Loaded 100 of 248 files, more files were not shown because too many files have changed in this diff.
Show more
Reference in new issue
Block a user