mirror of
https://github.com/binaricat/Netcatty.git
synced 2026-11-05 22:12:31 +00:00
Compare commits
14
Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
51d68b51ce | ||
|
|
6b1cf802d0 | ||
|
|
76957e3597 | ||
|
|
64f6214690 | ||
|
|
6fcd6a3ec3 | ||
|
|
de6d1a28ba | ||
|
|
b2ed83ae51 | ||
|
|
f91b2cd04e | ||
|
|
1d10d1679e | ||
|
|
8d2058267a | ||
|
|
8020eaa427 | ||
|
|
3298a70f32 | ||
|
|
e7202f392a | ||
|
|
9423a1d1cd |
No files matched your search
@@ -70,3 +70,26 @@ jobs:
|
||||
|
||||
- name: Build
|
||||
run: npm run build
|
||||
|
||||
windows-broadcast-ssh:
|
||||
name: windows-broadcast-ssh
|
||||
runs-on: windows-latest
|
||||
timeout-minutes: 20
|
||||
steps:
|
||||
- name: Checkout
|
||||
uses: actions/checkout@v7
|
||||
|
||||
- name: Setup Node
|
||||
uses: actions/setup-node@v7
|
||||
with:
|
||||
node-version: 22
|
||||
cache: npm
|
||||
|
||||
- name: Install deps
|
||||
run: npm ci
|
||||
|
||||
- name: Build
|
||||
run: npm run build
|
||||
|
||||
- name: Exercise two SSH password prompts in the desktop app
|
||||
run: node scripts/terminal-broadcast-ssh.live.cjs
|
||||
@@ -0,0 +1,21 @@
|
||||
import assert from "node:assert/strict";
|
||||
import { readFileSync } from "node:fs";
|
||||
import test from "node:test";
|
||||
|
||||
const source = readFileSync(new URL("./AppSideEffects.tsx", import.meta.url), "utf8");
|
||||
|
||||
test("deep-link tab names are applied to terminal sessions", () => {
|
||||
assert.match(source, /type DeepLinkPayload = \{[\s\S]*tabName\?: string/);
|
||||
assert.match(source, /const handleConnectToHostWithTabName = useCallback/);
|
||||
assert.match(source, /renameSessionInline\(sessionId, tabName\)/);
|
||||
assert.match(source, /handleConnectToHostWithTabName\(ephemeralHost, tabName\)/);
|
||||
assert.match(
|
||||
source,
|
||||
/handleConnectToHostWithTabName\(buildSshDeepLinkConnectionHost\(originalHost\), tabName\)/,
|
||||
);
|
||||
assert.match(
|
||||
source,
|
||||
/handleConnectToHostWithTabName\(buildTelnetDeepLinkConnectionHost\(matchedEffectiveHost\), tabName\)/,
|
||||
);
|
||||
assert.match(source, /label: tabName/);
|
||||
});
|
||||
@@ -98,6 +98,11 @@ type OpenSessionInNewWindowPayload = {
|
||||
localShellType?: TerminalSession['shellType'];
|
||||
};
|
||||
|
||||
type DeepLinkPayload = {
|
||||
url?: string;
|
||||
tabName?: string;
|
||||
};
|
||||
|
||||
const IS_DEV = import.meta.env.DEV;
|
||||
const HOTKEY_DEBUG =
|
||||
IS_DEV && localStorageAdapter.readString(STORAGE_KEY_DEBUG_HOTKEYS) === '1';
|
||||
@@ -260,6 +265,7 @@ export function AppSideEffects() {
|
||||
copyWorkspace,
|
||||
createSessionFromCloneSource,
|
||||
getSessionRestoreCwd,
|
||||
renameSessionInline,
|
||||
} = sessionState;
|
||||
|
||||
// Presentation-field thrash stays off Host bags (Hosts use retainStable);
|
||||
@@ -1398,6 +1404,14 @@ export function AppSideEffects() {
|
||||
}), host, hidden);
|
||||
}, [addConnectionLog, connectToHost, resolveEffectiveHost, identities, keys]);
|
||||
|
||||
const handleConnectToHostWithTabName = useCallback((host: Host, tabName?: string) => {
|
||||
const sessionId = handleConnectToHost(host);
|
||||
if (sessionId && tabName) {
|
||||
renameSessionInline(sessionId, tabName);
|
||||
}
|
||||
return sessionId;
|
||||
}, [handleConnectToHost, renameSessionInline]);
|
||||
|
||||
const openHostForVaultAgent = useCallback((host: Host, isExternalMcpCall: boolean) => {
|
||||
// Silent sessions only apply to actual external MCP clients (chatSessionId
|
||||
// equals the reserved external-MCP scope). The in-app Catty AI chat's
|
||||
@@ -1465,15 +1479,16 @@ export function AppSideEffects() {
|
||||
// deep links until unlock so saved-credential connects cannot start behind
|
||||
// the lock screen.
|
||||
const pendingDeepLinksWhileLockedRef = useRef<Array<
|
||||
| { kind: 'ssh'; payload: { url?: string } }
|
||||
| { kind: 'telnet'; payload: { url?: string } }
|
||||
| { kind: 'ssh'; payload: DeepLinkPayload }
|
||||
| { kind: 'telnet'; payload: DeepLinkPayload }
|
||||
| { kind: 'jms'; payload: { url?: string } }
|
||||
| { kind: 'open-terminal-path'; payload: { path?: string } }
|
||||
>>([]);
|
||||
|
||||
const _processSshDeepLink = useEffectEvent((payload: { url?: string }) => {
|
||||
const _processSshDeepLink = useEffectEvent((payload: DeepLinkPayload) => {
|
||||
startupLaunchIntentReceivedRef.current = true;
|
||||
const rawUrl = payload?.url || '';
|
||||
const tabName = payload?.tabName?.trim();
|
||||
const target = parseSshDeepLink(rawUrl);
|
||||
if (!target) {
|
||||
toast.warning(t('deepLink.ssh.invalid'));
|
||||
@@ -1502,25 +1517,26 @@ export function AppSideEffects() {
|
||||
? buildSshDeepLinkEphemeralHostFromSaved(matchedEffectiveHost, target, draftOptions)
|
||||
: buildSshDeepLinkEphemeralHost(target, draftOptions);
|
||||
setEphemeralHosts((prev) => [...prev, ephemeralHost]);
|
||||
handleConnectToHost(ephemeralHost);
|
||||
handleConnectToHostWithTabName(ephemeralHost, tabName);
|
||||
return;
|
||||
}
|
||||
|
||||
if (matchedEffectiveHost) {
|
||||
const originalHost = hosts.find((host) => host.id === matchedEffectiveHost.id) ?? matchedEffectiveHost;
|
||||
handleConnectToHost(buildSshDeepLinkConnectionHost(originalHost));
|
||||
handleConnectToHostWithTabName(buildSshDeepLinkConnectionHost(originalHost), tabName);
|
||||
return;
|
||||
}
|
||||
|
||||
setDeepLinkHostDraft(buildSshDeepLinkHostDraft(target, {
|
||||
const hostDraft = buildSshDeepLinkHostDraft(target, {
|
||||
id: crypto.randomUUID(),
|
||||
now: Date.now(),
|
||||
}));
|
||||
});
|
||||
setDeepLinkHostDraft(tabName ? { ...hostDraft, label: tabName } : hostDraft);
|
||||
setNavigateToSection('hosts');
|
||||
setActiveTabId('vault');
|
||||
});
|
||||
|
||||
const _handleSshDeepLink = useEffectEvent((payload: { url?: string }) => {
|
||||
const _handleSshDeepLink = useEffectEvent((payload: DeepLinkPayload) => {
|
||||
if (shouldDeferExternalActionWhileAppLocked({ locked: appLockLocked })) {
|
||||
pendingDeepLinksWhileLockedRef.current.push({ kind: 'ssh', payload: payload || {} });
|
||||
return;
|
||||
@@ -1537,9 +1553,10 @@ export function AppSideEffects() {
|
||||
});
|
||||
}, [isPeerSessionWindow]);
|
||||
|
||||
const _processTelnetDeepLink = useEffectEvent((payload: { url?: string }) => {
|
||||
const _processTelnetDeepLink = useEffectEvent((payload: DeepLinkPayload) => {
|
||||
startupLaunchIntentReceivedRef.current = true;
|
||||
const rawUrl = payload?.url || '';
|
||||
const tabName = payload?.tabName?.trim();
|
||||
const target = parseTelnetDeepLink(rawUrl);
|
||||
if (!target) {
|
||||
toast.warning(t('deepLink.telnet.invalid'));
|
||||
@@ -1560,10 +1577,10 @@ export function AppSideEffects() {
|
||||
now: Date.now(),
|
||||
});
|
||||
setEphemeralHosts((prev) => [...prev, ephemeralHost]);
|
||||
handleConnectToHost(ephemeralHost);
|
||||
handleConnectToHostWithTabName(ephemeralHost, tabName);
|
||||
return;
|
||||
}
|
||||
handleConnectToHost(buildTelnetDeepLinkConnectionHost(matchedEffectiveHost));
|
||||
handleConnectToHostWithTabName(buildTelnetDeepLinkConnectionHost(matchedEffectiveHost), tabName);
|
||||
return;
|
||||
}
|
||||
|
||||
@@ -1572,10 +1589,10 @@ export function AppSideEffects() {
|
||||
now: Date.now(),
|
||||
});
|
||||
setEphemeralHosts((prev) => [...prev, ephemeralHost]);
|
||||
handleConnectToHost(ephemeralHost);
|
||||
handleConnectToHostWithTabName(ephemeralHost, tabName);
|
||||
});
|
||||
|
||||
const _handleTelnetDeepLink = useEffectEvent((payload: { url?: string }) => {
|
||||
const _handleTelnetDeepLink = useEffectEvent((payload: DeepLinkPayload) => {
|
||||
if (shouldDeferExternalActionWhileAppLocked({ locked: appLockLocked })) {
|
||||
pendingDeepLinksWhileLockedRef.current.push({ kind: 'telnet', payload: payload || {} });
|
||||
return;
|
||||
|
||||
@@ -336,6 +336,13 @@ export const enAiMessages: Messages = {
|
||||
|
||||
// External MCP (productized catalog MCP for Codex / Claude Code / Cursor)
|
||||
'ai.externalMcp.title': 'External MCP',
|
||||
'ai.externalMcp.allowCommandOnHost': 'Always',
|
||||
'ai.externalMcp.allowCommandOnHostHint': 'Always allow this action on this server.',
|
||||
'ai.externalMcp.allowAll': 'Allow all actions',
|
||||
'ai.externalMcp.allowSession': 'This connection',
|
||||
'ai.externalMcp.allowSessionHint': 'Allow all actions from this client until it disconnects.',
|
||||
'ai.externalMcp.allowHost': 'This server',
|
||||
'ai.externalMcp.allowHostHint': 'Always allow actions on this server.',
|
||||
'ai.externalMcp.description': 'Expose Netcatty as an MCP server for external clients such as Codex, Claude Code, Cursor, and Grok. Uses the same catalog tools as in-app agents (terminal, SFTP, Vault, port forwarding). Keep Netcatty running while clients are connected.',
|
||||
'ai.externalMcp.sessionsExposed': 'Sessions in scope: {count}',
|
||||
'ai.externalMcp.mode': 'Availability mode',
|
||||
|
||||
@@ -312,8 +312,11 @@ export const enCoreMessages: Messages = {
|
||||
'settings.globalHotkey.toggleWindowDesc': 'Press a key combination to set a global shortcut for showing/hiding the window.',
|
||||
'settings.globalHotkey.notSet': 'Not set',
|
||||
'settings.globalHotkey.reset': 'Reset to default',
|
||||
'settings.globalHotkey.closeToTray': 'Close to System Tray',
|
||||
'settings.globalHotkey.closeToTrayDesc': 'When enabled, closing the window will minimize to the system tray instead of quitting.',
|
||||
'settings.globalHotkey.closeToTray': 'Keep Running When Window Closes',
|
||||
'settings.globalHotkey.closeToTrayDesc': 'When enabled, closing the window hides it while the app keeps running. Tray icon visibility is set separately.',
|
||||
'settings.globalHotkey.showTrayIcon': 'Show System Tray Icon',
|
||||
'settings.globalHotkey.showTrayIconDesc': 'When disabled, the tray icon is hidden but the app keeps running in the background.',
|
||||
'settings.globalHotkey.showTrayIconHiddenHint': 'Tray icon hidden. Launch the app again or use the global toggle hotkey to bring the window back, then re-enable this setting to restore the icon.',
|
||||
'settings.globalHotkey.enabled': 'Enable Global Hotkey',
|
||||
'settings.globalHotkey.enabledDesc': 'Register system-wide keyboard shortcuts. When disabled, all global hotkeys are unregistered.',
|
||||
'settings.globalHotkey.hint': 'Global hotkey works system-wide to quickly show or hide the window (Quake-style terminal).',
|
||||
|
||||
@@ -61,6 +61,7 @@ export const enTerminalMessages: Messages = {
|
||||
'terminal.composeBar.send': 'Send',
|
||||
'terminal.composeBar.close': 'Close compose bar',
|
||||
'terminal.composeBar.broadcasting': 'Broadcasting to all sessions',
|
||||
'terminal.composeBar.broadcastAllowPassword': 'Broadcast without password protection',
|
||||
'terminal.composeBar.resize': 'Resize compose bar height',
|
||||
'terminal.composeBar.manageSnippets': 'Manage quick snippets',
|
||||
'terminal.composeBar.searchSnippets': 'Search snippets...',
|
||||
@@ -134,6 +135,7 @@ export const enTerminalMessages: Messages = {
|
||||
'terminal.search.noResults': 'No results',
|
||||
'terminal.search.prevMatch': 'Previous match (Shift+Enter)',
|
||||
'terminal.search.nextMatch': 'Next match (Enter)',
|
||||
'terminal.menu.cut': 'Cut',
|
||||
'terminal.menu.copy': 'Copy',
|
||||
'terminal.menu.paste': 'Paste',
|
||||
'terminal.menu.uploadClipboardImage': 'Upload clipboard image',
|
||||
|
||||
@@ -900,9 +900,6 @@ export const enVaultMessages: Messages = {
|
||||
'hostDetails.deviceType': 'Network Device Mode',
|
||||
'hostDetails.deviceType.desc': 'Enable for network equipment (switches, routers, firewalls) connected via SSH. Commands are sent as-is without shell wrapping, compatible with vendor CLIs like Huawei VRP and Cisco IOS.',
|
||||
'hostDetails.deviceType.warning': 'AI agent commands will be sent directly without exit code tracking. Only enable for devices that do not run a standard shell.',
|
||||
'hostDetails.singleChannelSsh': 'Single-channel SSH (bastion)',
|
||||
'hostDetails.singleChannelSsh.desc': 'Do not open extra SFTP or exec channels on the terminal SSH connection. Recognized one-channel bastion banners do this automatically. Turn this on when the bastion still advertises OpenSSH. File transfer uses a separate login.',
|
||||
'hostDetails.singleChannelSsh.warning': 'System Manager is unavailable while this is on. File transfer uses a second login and may be limited by concurrent-session policy.',
|
||||
'hostDetails.section.sshAlgorithms': 'SSH Algorithms',
|
||||
'hostDetails.section.terminalBehavior': 'Terminal Behavior',
|
||||
'hostDetails.lineTimestamps': 'Show output timestamps',
|
||||
|
||||
@@ -327,6 +327,13 @@ export const esAiMessages: Messages = {
|
||||
|
||||
// External MCP (productized catalog MCP for Codex / Claude Code / Cursor)
|
||||
'ai.externalMcp.title': 'MCP externo',
|
||||
'ai.externalMcp.allowCommandOnHost': 'Siempre',
|
||||
'ai.externalMcp.allowCommandOnHostHint': 'Permitir siempre esta acción en este servidor.',
|
||||
'ai.externalMcp.allowAll': 'Permitir todas las acciones',
|
||||
'ai.externalMcp.allowSession': 'Esta conexión',
|
||||
'ai.externalMcp.allowSessionHint': 'Permitir todas las acciones de este cliente hasta que se desconecte.',
|
||||
'ai.externalMcp.allowHost': 'Este servidor',
|
||||
'ai.externalMcp.allowHostHint': 'Permitir siempre acciones en este servidor.',
|
||||
'ai.externalMcp.description': 'Expón Netcatty como un servidor MCP para clientes externos como Codex, Claude Code, Cursor y Grok. Usa las mismas herramientas del catálogo que los agentes de la aplicación (terminal, SFTP, Vault, reenvío de puertos). Mantén Netcatty en ejecución mientras los clientes estén conectados.',
|
||||
'ai.externalMcp.sessionsExposed': 'Sesiones en alcance: {count}',
|
||||
'ai.externalMcp.mode': 'Modo de disponibilidad',
|
||||
|
||||
@@ -227,8 +227,11 @@ export const esCoreMessages: Messages = {
|
||||
'settings.globalHotkey.toggleWindowDesc': 'Presiona una combinación de teclas para definir un atajo global que muestre u oculte la ventana.',
|
||||
'settings.globalHotkey.notSet': 'No configurada',
|
||||
'settings.globalHotkey.reset': 'Restablecer al valor predeterminado',
|
||||
'settings.globalHotkey.closeToTray': 'Cerrar a la bandeja del sistema',
|
||||
'settings.globalHotkey.closeToTrayDesc': 'Cuando está activado, cerrar la ventana la minimiza a la bandeja del sistema en lugar de salir.',
|
||||
'settings.globalHotkey.closeToTray': 'Seguir en segundo plano al cerrar la ventana',
|
||||
'settings.globalHotkey.closeToTrayDesc': 'Al cerrar la ventana, se oculta y la aplicación sigue activa. La visibilidad del icono de la bandeja se configura aparte.',
|
||||
'settings.globalHotkey.showTrayIcon': 'Mostrar icono de la bandeja del sistema',
|
||||
'settings.globalHotkey.showTrayIconDesc': 'Cuando está desactivado, el icono de la bandeja se oculta, pero la aplicación sigue ejecutándose en segundo plano.',
|
||||
'settings.globalHotkey.showTrayIconHiddenHint': 'Icono de la bandeja oculto. Vuelve a iniciar la aplicación o usa el atajo global para recuperar la ventana y vuelve a activar este ajuste para restaurar el icono.',
|
||||
'settings.globalHotkey.enabled': 'Activar tecla de acceso global',
|
||||
'settings.globalHotkey.enabledDesc': 'Registra atajos de teclado a nivel de todo el sistema. Cuando está desactivado, todas las teclas de acceso global se desregistran.',
|
||||
'settings.globalHotkey.hint': 'La tecla de acceso global funciona en todo el sistema para mostrar u ocultar la ventana rápidamente (terminal estilo Quake).',
|
||||
|
||||
@@ -61,6 +61,7 @@ export const esTerminalMessages: Messages = {
|
||||
'terminal.composeBar.send': 'Enviar',
|
||||
'terminal.composeBar.close': 'Cerrar barra de redacción',
|
||||
'terminal.composeBar.broadcasting': 'Difundiendo a todas las sesiones',
|
||||
'terminal.composeBar.broadcastAllowPassword': 'Difundir sin protección de contraseña',
|
||||
'terminal.composeBar.resize': 'Cambiar la altura de la barra de redacción',
|
||||
'terminal.composeBar.manageSnippets': 'Administrar snippets rápidos',
|
||||
'terminal.composeBar.searchSnippets': 'Buscar snippets...',
|
||||
@@ -134,6 +135,7 @@ export const esTerminalMessages: Messages = {
|
||||
'terminal.search.noResults': 'Sin resultados',
|
||||
'terminal.search.prevMatch': 'Coincidencia anterior (Shift+Enter)',
|
||||
'terminal.search.nextMatch': 'Coincidencia siguiente (Enter)',
|
||||
'terminal.menu.cut': 'Cortar',
|
||||
'terminal.menu.copy': 'Copiar',
|
||||
'terminal.menu.paste': 'Pegar',
|
||||
'terminal.menu.uploadClipboardImage': 'Subir imagen del portapapeles',
|
||||
|
||||
@@ -900,9 +900,6 @@ export const esVaultMessages: Messages = {
|
||||
'hostDetails.deviceType': 'Modo de dispositivo de red',
|
||||
'hostDetails.deviceType.desc': 'Habilítalo para equipos de red (switches, routers, firewalls) conectados mediante SSH. Los comandos se envían tal cual, sin envoltura de shell, compatible con las CLI de proveedores como Huawei VRP y Cisco IOS.',
|
||||
'hostDetails.deviceType.warning': 'Los comandos del agente de IA se enviarán directamente sin seguimiento del código de salida. Habilítalo solo para dispositivos que no ejecutan un shell estándar.',
|
||||
'hostDetails.singleChannelSsh': 'SSH de un solo canal (bastión)',
|
||||
'hostDetails.singleChannelSsh.desc': 'No abrir canales extra de SFTP o exec en la conexión SSH del terminal. Los banners de bastión de un solo canal ya reconocidos lo hacen solos. Actívalo si el bastión sigue anunciando OpenSSH. La transferencia de archivos usa un inicio de sesión aparte.',
|
||||
'hostDetails.singleChannelSsh.warning': 'El administrador del sistema no estará disponible. La transferencia de archivos usa un segundo inicio de sesión y puede estar limitada por la política de sesiones concurrentes.',
|
||||
'hostDetails.section.sshAlgorithms': 'Algoritmos SSH',
|
||||
'hostDetails.section.terminalBehavior': 'Comportamiento de la terminal',
|
||||
'hostDetails.lineTimestamps': 'Mostrar marcas de tiempo de salida',
|
||||
|
||||
@@ -283,6 +283,13 @@ export const ruAiMessages: Messages = {
|
||||
'ai.toolAccess.skills.unavailable': 'Путь к skill-файлу недоступен',
|
||||
|
||||
'ai.externalMcp.title': 'Внешний MCP',
|
||||
'ai.externalMcp.allowCommandOnHost': 'Всегда',
|
||||
'ai.externalMcp.allowCommandOnHostHint': 'Всегда разрешать это действие на этом сервере.',
|
||||
'ai.externalMcp.allowAll': 'Разрешить все действия',
|
||||
'ai.externalMcp.allowSession': 'Это подключение',
|
||||
'ai.externalMcp.allowSessionHint': 'Разрешать все действия клиента до отключения.',
|
||||
'ai.externalMcp.allowHost': 'Этот сервер',
|
||||
'ai.externalMcp.allowHostHint': 'Всегда разрешать действия на этом сервере.',
|
||||
'ai.externalMcp.description': 'Откройте Netcatty как MCP-сервер для Codex, Claude Code, Cursor и Grok. Набор инструментов тот же, что у встроенных агентов. Держите Netcatty запущенным, пока клиенты подключены.',
|
||||
'ai.externalMcp.sessionsExposed': 'Сессий в области: {count}',
|
||||
'ai.externalMcp.mode': 'Режим доступности',
|
||||
|
||||
@@ -296,8 +296,11 @@ export const ruCoreMessages: Messages = {
|
||||
'settings.globalHotkey.toggleWindowDesc': 'Нажмите сочетание клавиш, чтобы задать глобальную горячую клавишу для показа или скрытия окна.',
|
||||
'settings.globalHotkey.notSet': 'Не задано',
|
||||
'settings.globalHotkey.reset': 'Сбросить по умолчанию',
|
||||
'settings.globalHotkey.closeToTray': 'Сворачивать в системный трей',
|
||||
'settings.globalHotkey.closeToTrayDesc': 'Если включено, при закрытии окно будет сворачиваться в системный трей вместо выхода из приложения.',
|
||||
'settings.globalHotkey.closeToTray': 'Оставлять приложение в фоне при закрытии окна',
|
||||
'settings.globalHotkey.closeToTrayDesc': 'При закрытии окно скрывается, а приложение продолжает работать. Отображение значка в трее настраивается отдельно.',
|
||||
'settings.globalHotkey.showTrayIcon': 'Показывать значок в системном трее',
|
||||
'settings.globalHotkey.showTrayIconDesc': 'Если отключено, значок в трее скрыт, но приложение продолжает работать в фоне.',
|
||||
'settings.globalHotkey.showTrayIconHiddenHint': 'Значок в трее скрыт. Запустите приложение снова или используйте глобальную горячую клавишу, чтобы вернуть окно, затем снова включите этот параметр, чтобы восстановить значок.',
|
||||
'settings.globalHotkey.enabled': 'Включить глобальную горячую клавишу',
|
||||
'settings.globalHotkey.enabledDesc': 'Регистрировать системные сочетания клавиш. Когда отключено, все глобальные горячие клавиши снимаются с регистрации.',
|
||||
'settings.globalHotkey.hint': 'Глобальная горячая клавиша работает на уровне всей системы и позволяет быстро показывать или скрывать окно (терминал в стиле Quake).',
|
||||
|
||||
@@ -82,6 +82,7 @@ export const ruTerminalMessages: Messages = {
|
||||
'terminal.composeBar.send': 'Отправить',
|
||||
'terminal.composeBar.close': 'Закрыть строку ввода',
|
||||
'terminal.composeBar.broadcasting': 'Трансляция во все сессии',
|
||||
'terminal.composeBar.broadcastAllowPassword': 'Транслировать без защиты пароля',
|
||||
'terminal.composeBar.resize': 'Изменить высоту строки ввода',
|
||||
'terminal.composeBar.manageSnippets': 'Управление быстрыми сниппетами',
|
||||
'terminal.composeBar.searchSnippets': 'Поиск сниппетов...',
|
||||
@@ -155,6 +156,7 @@ export const ruTerminalMessages: Messages = {
|
||||
'terminal.search.noResults': 'Ничего не найдено',
|
||||
'terminal.search.prevMatch': 'Предыдущее совпадение (Shift+Enter)',
|
||||
'terminal.search.nextMatch': 'Следующее совпадение (Enter)',
|
||||
'terminal.menu.cut': 'Вырезать',
|
||||
'terminal.menu.copy': 'Копировать',
|
||||
'terminal.menu.paste': 'Вставить',
|
||||
'terminal.menu.uploadClipboardImage': 'Загрузить изображение из буфера',
|
||||
|
||||
@@ -935,9 +935,6 @@ export const ruVaultMessages: Messages = {
|
||||
'hostDetails.deviceType': 'Режим сетевого устройства',
|
||||
'hostDetails.deviceType.desc': 'Включайте для сетевого оборудования (коммутаторов, маршрутизаторов, межсетевых экранов), подключённого по SSH. Команды отправляются как есть, без обёртки оболочки, что совместимо с CLI вендоров вроде Huawei VRP и Cisco IOS.',
|
||||
'hostDetails.deviceType.warning': 'Команды AI-агента будут отправляться напрямую без отслеживания кода выхода. Включайте только для устройств, на которых нет стандартной оболочки.',
|
||||
'hostDetails.singleChannelSsh': 'Одноканальный SSH (бастион)',
|
||||
'hostDetails.singleChannelSsh.desc': 'Не открывать дополнительные SFTP/exec-каналы на SSH-соединении терминала. Уже распознанные баннеры одноканальных бастионов обрабатываются сами. Включите, если бастион всё ещё представляется как OpenSSH. Передача файлов идёт отдельным входом.',
|
||||
'hostDetails.singleChannelSsh.warning': 'Системная панель будет недоступна. Передача файлов использует отдельный вход и может быть ограничена политикой одновременных сессий.',
|
||||
'hostDetails.section.sshAlgorithms': 'SSH-алгоритмы',
|
||||
'hostDetails.section.terminalBehavior': 'Поведение терминала',
|
||||
'hostDetails.lineTimestamps': 'Показывать время вывода',
|
||||
|
||||
@@ -336,6 +336,13 @@ export const zhCNAiMessages: Messages = {
|
||||
|
||||
// External MCP
|
||||
'ai.externalMcp.title': '对外 MCP',
|
||||
'ai.externalMcp.allowCommandOnHost': '始终',
|
||||
'ai.externalMcp.allowCommandOnHostHint': '始终允许在此服务器执行同类操作。',
|
||||
'ai.externalMcp.allowAll': '允许所有操作',
|
||||
'ai.externalMcp.allowSession': '此次连接',
|
||||
'ai.externalMcp.allowSessionHint': '在客户端断开前,允许此次连接的所有操作。',
|
||||
'ai.externalMcp.allowHost': '此服务器',
|
||||
'ai.externalMcp.allowHostHint': '始终允许操作此服务器。',
|
||||
'ai.externalMcp.description': '把 Netcatty 作为 MCP 服务器暴露给 Codex、Claude Code、Cursor、Grok 等外部客户端。工具面与应用内 Agent 相同(终端、SFTP、Vault、端口转发)。客户端连接期间请保持 Netcatty 运行。',
|
||||
'ai.externalMcp.sessionsExposed': '作用域内会话:{count}',
|
||||
'ai.externalMcp.mode': '可用模式',
|
||||
|
||||
@@ -316,8 +316,11 @@ export const zhCNCoreMessages: Messages = {
|
||||
'settings.globalHotkey.toggleWindowDesc': '按下组合键以设置显示/隐藏窗口的全局快捷键。',
|
||||
'settings.globalHotkey.notSet': '未设置',
|
||||
'settings.globalHotkey.reset': '恢复默认',
|
||||
'settings.globalHotkey.closeToTray': '关闭时最小化到托盘',
|
||||
'settings.globalHotkey.closeToTrayDesc': '启用后,关闭窗口将最小化到系统托盘而不是退出程序。',
|
||||
'settings.globalHotkey.closeToTray': '关闭窗口后在后台运行',
|
||||
'settings.globalHotkey.closeToTrayDesc': '启用后,关闭窗口会隐藏窗口,应用继续在后台运行。托盘图标可单独开关。',
|
||||
'settings.globalHotkey.showTrayIcon': '显示系统托盘图标',
|
||||
'settings.globalHotkey.showTrayIconDesc': '关闭后,托盘图标将被隐藏,但应用仍会在后台正常运行。',
|
||||
'settings.globalHotkey.showTrayIconHiddenHint': '托盘图标已隐藏。再次启动应用或使用全局显示/隐藏快捷键即可找回窗口,然后重新打开此设置即可恢复图标。',
|
||||
'settings.globalHotkey.enabled': '启用全局快捷键',
|
||||
'settings.globalHotkey.enabledDesc': '注册系统级键盘快捷键。禁用后将取消所有全局快捷键注册。',
|
||||
'settings.globalHotkey.hint': '全局快捷键在系统范围内工作,可快速显示或隐藏窗口(下拉式终端风格)。',
|
||||
|
||||
@@ -316,9 +316,6 @@ export const zhCNVaultMessages: Messages = {
|
||||
'hostDetails.deviceType': '网络设备模式',
|
||||
'hostDetails.deviceType.desc': '适用于通过 SSH 连接的网络设备(交换机、路由器、防火墙)。命令将原样发送,不进行 Shell 包装,兼容华为 VRP、Cisco IOS 等厂商 CLI。',
|
||||
'hostDetails.deviceType.warning': 'AI 代理命令将直接发送,无法获取退出码。仅建议在设备不运行标准 Shell 时启用。',
|
||||
'hostDetails.singleChannelSsh': '单通道 SSH(堡垒机)',
|
||||
'hostDetails.singleChannelSsh.desc': '禁止在当前终端 SSH 上再开 SFTP/系统探测等通道。已识别的单通道堡垒机软件标识会自动分开登录。仍显示 OpenSSH 标识的堡垒机需要手动开启。文件传输会改走独立连接。',
|
||||
'hostDetails.singleChannelSsh.warning': '开启后系统面板不可用。文件传输使用单独登录,可能受堡垒机并发会话限制。',
|
||||
'hostDetails.section.sshAlgorithms': 'SSH 算法',
|
||||
'hostDetails.section.terminalBehavior': '终端行为',
|
||||
'hostDetails.lineTimestamps': '显示输出时间',
|
||||
@@ -469,6 +466,7 @@ export const zhCNVaultMessages: Messages = {
|
||||
'terminal.composeBar.send': '发送',
|
||||
'terminal.composeBar.close': '关闭撰写栏',
|
||||
'terminal.composeBar.broadcasting': '正在广播到所有会话',
|
||||
'terminal.composeBar.broadcastAllowPassword': '广播时不对密码进行保护',
|
||||
'terminal.composeBar.resize': '拖拽调整撰写栏高度',
|
||||
'terminal.composeBar.manageSnippets': '管理快捷代码片段',
|
||||
'terminal.composeBar.searchSnippets': '搜索代码片段...',
|
||||
@@ -540,6 +538,7 @@ export const zhCNVaultMessages: Messages = {
|
||||
'terminal.search.noResults': '无结果',
|
||||
'terminal.search.prevMatch': '上一个匹配 (Shift+Enter)',
|
||||
'terminal.search.nextMatch': '下一个匹配 (Enter)',
|
||||
'terminal.menu.cut': '剪切',
|
||||
'terminal.menu.copy': '复制',
|
||||
'terminal.menu.paste': '粘贴',
|
||||
'terminal.menu.addSelectionToAI': '添加到对话',
|
||||
|
||||
@@ -326,6 +326,13 @@ export const zhTWAiMessages: Messages = {
|
||||
'ai.toolAccess.skills.unavailable': '暫時無法取得 Skill 檔案路徑',
|
||||
|
||||
'ai.externalMcp.title': '對外 MCP',
|
||||
'ai.externalMcp.allowCommandOnHost': '始終',
|
||||
'ai.externalMcp.allowCommandOnHostHint': '始終允許在此伺服器執行同類操作。',
|
||||
'ai.externalMcp.allowAll': '允許所有操作',
|
||||
'ai.externalMcp.allowSession': '此次連線',
|
||||
'ai.externalMcp.allowSessionHint': '在用戶端中斷前,允許此次連線的所有操作。',
|
||||
'ai.externalMcp.allowHost': '此伺服器',
|
||||
'ai.externalMcp.allowHostHint': '始終允許操作此伺服器。',
|
||||
'ai.externalMcp.description': '將 Netcatty 作為 MCP 伺服器暴露給 Codex、Claude Code、Cursor、Grok 等外部用戶端。工具面與應用內 Agent 相同。用戶端連線期間請保持 Netcatty 執行。',
|
||||
'ai.externalMcp.sessionsExposed': '作用域內工作階段:{count}',
|
||||
'ai.externalMcp.mode': '可用模式',
|
||||
|
||||
@@ -318,8 +318,11 @@ export const zhTWCoreMessages: Messages = {
|
||||
'settings.globalHotkey.toggleWindowDesc': '按下組合鍵以設定顯示/隱藏視窗的全域快速鍵。',
|
||||
'settings.globalHotkey.notSet': '未設定',
|
||||
'settings.globalHotkey.reset': '恢復預設',
|
||||
'settings.globalHotkey.closeToTray': '關閉時最小化到系統匣',
|
||||
'settings.globalHotkey.closeToTrayDesc': '啟用後,關閉視窗將最小化到系統匣而不是結束程式。',
|
||||
'settings.globalHotkey.closeToTray': '關閉視窗後在背景執行',
|
||||
'settings.globalHotkey.closeToTrayDesc': '啟用後,關閉視窗會隱藏視窗,應用程式繼續在背景執行。系統匣圖示可另外開關。',
|
||||
'settings.globalHotkey.showTrayIcon': '顯示系統匣圖示',
|
||||
'settings.globalHotkey.showTrayIconDesc': '關閉後,系統匣圖示將被隱藏,但應用程式仍會在背景正常執行。',
|
||||
'settings.globalHotkey.showTrayIconHiddenHint': '系統匣圖示已隱藏。再次啟動應用程式或使用全域顯示/隱藏快捷鍵即可找回視窗,然後重新開啟此設定即可恢復圖示。',
|
||||
'settings.globalHotkey.enabled': '啟用全域快速鍵',
|
||||
'settings.globalHotkey.enabledDesc': '註冊系統級鍵盤快速鍵。停用後將取消所有全域快速鍵註冊。',
|
||||
'settings.globalHotkey.hint': '全域快速鍵在系統範圍內工作,可快速顯示或隱藏視窗(下拉式終端風格)。',
|
||||
|
||||
@@ -316,9 +316,6 @@ export const zhTWVaultMessages: Messages = {
|
||||
'hostDetails.deviceType': '網路裝置模式',
|
||||
'hostDetails.deviceType.desc': '適用於透過 SSH 連線的網路裝置(交換機、路由器、防火牆)。指令將原樣傳送,不進行 Shell 包裝,相容華為 VRP、Cisco IOS 等廠商 CLI。',
|
||||
'hostDetails.deviceType.warning': 'AI 代理指令將直接傳送,無法取得結束代碼。僅建議在裝置不執行標準 Shell 時啟用。',
|
||||
'hostDetails.singleChannelSsh': '單通道 SSH(堡壘機)',
|
||||
'hostDetails.singleChannelSsh.desc': '禁止在目前終端 SSH 上再開 SFTP/系統探測等通道。已識別的單通道堡壘機軟體識別字串會自動分開登入。仍顯示 OpenSSH 識別字串的堡壘機需要手動開啟。檔案傳輸會改走獨立連線。',
|
||||
'hostDetails.singleChannelSsh.warning': '開啟後系統面板無法使用。檔案傳輸使用單獨登入,可能受堡壘機並行會話限制。',
|
||||
'hostDetails.section.sshAlgorithms': 'SSH 演算法',
|
||||
'hostDetails.section.terminalBehavior': '終端行為',
|
||||
'hostDetails.lineTimestamps': '顯示輸出時間',
|
||||
@@ -469,6 +466,7 @@ export const zhTWVaultMessages: Messages = {
|
||||
'terminal.composeBar.send': '傳送',
|
||||
'terminal.composeBar.close': '關閉撰寫欄',
|
||||
'terminal.composeBar.broadcasting': '正在廣播到所有工作階段',
|
||||
'terminal.composeBar.broadcastAllowPassword': '廣播時不對密碼進行保護',
|
||||
'terminal.composeBar.resize': '拖曳調整撰寫欄高度',
|
||||
'terminal.composeBar.manageSnippets': '管理快捷程式碼片段',
|
||||
'terminal.composeBar.searchSnippets': '搜尋程式碼片段...',
|
||||
@@ -540,6 +538,7 @@ export const zhTWVaultMessages: Messages = {
|
||||
'terminal.search.noResults': '無結果',
|
||||
'terminal.search.prevMatch': '上一個相符 (Shift+Enter)',
|
||||
'terminal.search.nextMatch': '下一個相符 (Enter)',
|
||||
'terminal.menu.cut': '剪下',
|
||||
'terminal.menu.copy': '複製',
|
||||
'terminal.menu.paste': '貼上',
|
||||
'terminal.menu.addSelectionToAI': '新增到對話',
|
||||
|
||||
@@ -2,6 +2,7 @@ import test from "node:test";
|
||||
import assert from "node:assert/strict";
|
||||
|
||||
import {
|
||||
formatSessionClosedExitMessage,
|
||||
resolveTerminalSessionExitIntent,
|
||||
shouldCloseTerminalPopupOnExit,
|
||||
shouldRevealTerminalPopupOnExit,
|
||||
@@ -120,3 +121,32 @@ test("attached terminal popups keep their existing auto-close behavior by defaul
|
||||
true,
|
||||
);
|
||||
});
|
||||
|
||||
test("exit messages show the reported code for a real remote exit", () => {
|
||||
assert.equal(
|
||||
formatSessionClosedExitMessage({ reason: "exited", exitCode: 0 }),
|
||||
"\r\n[session closed (code 0)]",
|
||||
);
|
||||
assert.equal(
|
||||
formatSessionClosedExitMessage({ reason: "exited", exitCode: 127 }),
|
||||
"\r\n[session closed (code 127)]",
|
||||
);
|
||||
});
|
||||
|
||||
test("a channel closed without exit-status or exit-signal must not render as code 0", () => {
|
||||
assert.equal(
|
||||
formatSessionClosedExitMessage({
|
||||
reason: "closed",
|
||||
remoteClosedWithoutExitStatus: true,
|
||||
}),
|
||||
"\r\n[session closed by remote (no exit status)]",
|
||||
);
|
||||
});
|
||||
|
||||
test("exit messages without a reported code stay plain instead of showing a sentinel", () => {
|
||||
assert.equal(
|
||||
formatSessionClosedExitMessage({ reason: "closed" }),
|
||||
"\r\n[session closed]",
|
||||
);
|
||||
assert.equal(formatSessionClosedExitMessage({}), "\r\n[session closed]");
|
||||
});
|
||||
@@ -5,9 +5,20 @@ export type TerminalSessionExitEvent = {
|
||||
signal?: number;
|
||||
error?: string;
|
||||
reason?: "exited" | "error" | "timeout" | "closed";
|
||||
// True when the remote closed the channel without sending exit-status or
|
||||
// exit-signal (#3591): no numeric exit code exists, and the renderer shows
|
||||
// a distinct message instead of a misleading "code 0".
|
||||
remoteClosedWithoutExitStatus?: boolean;
|
||||
diagnostics?: ReadonlyArray<ProviderValidationIssue>;
|
||||
};
|
||||
|
||||
export function formatSessionClosedExitMessage(evt: TerminalSessionExitEvent): string {
|
||||
if (evt?.remoteClosedWithoutExitStatus) {
|
||||
return "\r\n[session closed by remote (no exit status)]";
|
||||
}
|
||||
return `\r\n[session closed${evt?.exitCode !== undefined ? ` (code ${evt.exitCode})` : ""}]`;
|
||||
}
|
||||
|
||||
export type TerminalSessionExitIntent =
|
||||
| { kind: "closeSession" }
|
||||
| { kind: "markDisconnected" };
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
import assert from 'node:assert/strict';
|
||||
import test from 'node:test';
|
||||
import {
|
||||
runAutomationScript,
|
||||
runConnectScriptsSequential,
|
||||
selectScriptOverlayRun,
|
||||
setScriptRuns,
|
||||
@@ -8,6 +9,7 @@ import {
|
||||
} from './scriptAutomationCoordinator.ts';
|
||||
import type { ScriptRun } from '@/types/global/netcatty-bridge-script.d.ts';
|
||||
import type { Snippet } from '@/domain/models';
|
||||
import { STORAGE_KEY_AI_PERMISSION_MODE } from '@/infrastructure/config/storageKeys.ts';
|
||||
import { netcattyBridge } from '@/infrastructure/services/netcattyBridge.ts';
|
||||
|
||||
test('waitForScriptRun resolves when run is already completed on subscribe', async () => {
|
||||
@@ -246,3 +248,127 @@ test('runConnectScriptsSequential does not treat a script error named Aborted as
|
||||
Reflect.deleteProperty(globalThis, 'localStorage');
|
||||
}
|
||||
});
|
||||
|
||||
function installPermissionMode(mode: string) {
|
||||
const storage = new Map<string, string>([[STORAGE_KEY_AI_PERMISSION_MODE, mode]]);
|
||||
Object.defineProperty(globalThis, 'localStorage', {
|
||||
configurable: true,
|
||||
value: {
|
||||
getItem: (key: string) => storage.get(key) ?? null,
|
||||
setItem: (key: string, value: string) => { storage.set(key, String(value)); },
|
||||
removeItem: (key: string) => { storage.delete(key); },
|
||||
clear: () => { storage.clear(); },
|
||||
},
|
||||
});
|
||||
}
|
||||
|
||||
const writeScript: Snippet = {
|
||||
id: 'write-script',
|
||||
label: 'Write',
|
||||
command: "await nct.screen.sendLine('echo hi');",
|
||||
kind: 'script',
|
||||
};
|
||||
|
||||
test('runAutomationScript executes a user write script while AI permission mode is observer', async () => {
|
||||
const originalGet = netcattyBridge.get;
|
||||
installPermissionMode('observer');
|
||||
let seenMode: string | undefined;
|
||||
netcattyBridge.get = () => ({
|
||||
scriptRun: async (params) => {
|
||||
seenMode = params.permissionMode;
|
||||
return { runId: 'user-run', runIds: ['user-run'] };
|
||||
},
|
||||
}) as ReturnType<typeof netcattyBridge.get>;
|
||||
|
||||
try {
|
||||
const result = await runAutomationScript({
|
||||
snippet: writeScript,
|
||||
sessionId: 'sess-user',
|
||||
initiatedBy: 'user',
|
||||
});
|
||||
assert.equal(result.runId, 'user-run');
|
||||
assert.equal(seenMode, 'auto');
|
||||
} finally {
|
||||
netcattyBridge.get = originalGet;
|
||||
Reflect.deleteProperty(globalThis, 'localStorage');
|
||||
}
|
||||
});
|
||||
|
||||
test('runAutomationScript blocks automatic write scripts in observer mode', async () => {
|
||||
const originalGet = netcattyBridge.get;
|
||||
installPermissionMode('observer');
|
||||
let called = false;
|
||||
netcattyBridge.get = () => ({
|
||||
scriptRun: async () => {
|
||||
called = true;
|
||||
return { runId: 'automatic-run', runIds: ['automatic-run'] };
|
||||
},
|
||||
}) as ReturnType<typeof netcattyBridge.get>;
|
||||
|
||||
try {
|
||||
await assert.rejects(
|
||||
() => runAutomationScript({ snippet: writeScript, sessionId: 'sess-automatic' }),
|
||||
/Observer mode blocks scripts that write to the terminal/,
|
||||
);
|
||||
assert.equal(called, false);
|
||||
} finally {
|
||||
netcattyBridge.get = originalGet;
|
||||
Reflect.deleteProperty(globalThis, 'localStorage');
|
||||
}
|
||||
});
|
||||
|
||||
test('runAutomationScript blocks an AI write script in observer mode', async () => {
|
||||
const originalGet = netcattyBridge.get;
|
||||
installPermissionMode('observer');
|
||||
let called = false;
|
||||
netcattyBridge.get = () => ({
|
||||
scriptRun: async () => {
|
||||
called = true;
|
||||
return { runId: 'ai-run', runIds: ['ai-run'] };
|
||||
},
|
||||
}) as ReturnType<typeof netcattyBridge.get>;
|
||||
|
||||
try {
|
||||
await assert.rejects(
|
||||
() => runAutomationScript({
|
||||
snippet: writeScript,
|
||||
sessionId: 'sess-ai',
|
||||
initiatedBy: 'ai',
|
||||
}),
|
||||
/Observer mode blocks scripts that write to the terminal/,
|
||||
);
|
||||
assert.equal(called, false);
|
||||
} finally {
|
||||
netcattyBridge.get = originalGet;
|
||||
Reflect.deleteProperty(globalThis, 'localStorage');
|
||||
}
|
||||
});
|
||||
|
||||
test('runAutomationScript keeps observer on an AI read-only script so later writes stay blocked', async () => {
|
||||
const originalGet = netcattyBridge.get;
|
||||
installPermissionMode('observer');
|
||||
let seenMode: string | undefined;
|
||||
netcattyBridge.get = () => ({
|
||||
scriptRun: async (params) => {
|
||||
seenMode = params.permissionMode;
|
||||
return { runId: 'ai-read', runIds: ['ai-read'] };
|
||||
},
|
||||
}) as ReturnType<typeof netcattyBridge.get>;
|
||||
|
||||
try {
|
||||
await runAutomationScript({
|
||||
snippet: {
|
||||
id: 'read-script',
|
||||
label: 'Read',
|
||||
command: "await nct.screen.waitForPrompt(1000);",
|
||||
kind: 'script',
|
||||
},
|
||||
sessionId: 'sess-ai-read',
|
||||
initiatedBy: 'ai',
|
||||
});
|
||||
assert.equal(seenMode, 'observer');
|
||||
} finally {
|
||||
netcattyBridge.get = originalGet;
|
||||
Reflect.deleteProperty(globalThis, 'localStorage');
|
||||
}
|
||||
});
|
||||
@@ -95,8 +95,10 @@ export async function runAutomationScript(params: {
|
||||
hostname?: string;
|
||||
username?: string;
|
||||
};
|
||||
/** Only a direct user action bypasses Observer; AI and automatic runs honor it. */
|
||||
initiatedBy?: 'user' | 'ai';
|
||||
}): Promise<{ runId: string; runIds: string[] }> {
|
||||
const permissionMode = readPermissionMode();
|
||||
const permissionMode = params.initiatedBy === 'user' ? 'auto' : readPermissionMode();
|
||||
if (permissionMode === 'observer' && scriptContainsWriteOperations(params.snippet.command)) {
|
||||
throw new Error('Observer mode blocks scripts that write to the terminal.');
|
||||
}
|
||||
|
||||
@@ -205,7 +205,6 @@ export const buildSftpHostCredentials = ({
|
||||
// the original negotiation failure when opening their SFTP pane,
|
||||
// even though the terminal session works.
|
||||
legacyAlgorithms: host.legacyAlgorithms,
|
||||
singleChannelSsh: host.singleChannelSsh === true,
|
||||
skipEcdsaHostKey: host.skipEcdsaHostKey,
|
||||
algorithmOverrides: host.algorithms,
|
||||
};
|
||||
|
||||
@@ -0,0 +1,563 @@
|
||||
/** @test issue #3559 — dual-pane transfers must re-stat remote sources before planning */
|
||||
import assert from "node:assert/strict";
|
||||
import test from "node:test";
|
||||
import React from "react";
|
||||
import { act, create, type ReactTestRenderer } from "react-test-renderer";
|
||||
import { PREFLIGHT_STAT_CONCURRENCY, PREFLIGHT_STAT_MAX_FILES, useSftpTransfers } from "./useSftpTransfers";
|
||||
import { sftpTransferCenterStore } from "../sftpTransferCenterStore";
|
||||
import type { SftpPane } from "./types";
|
||||
|
||||
type StartOptions = {
|
||||
transferId: string;
|
||||
sourcePath: string;
|
||||
sourceType: string;
|
||||
targetType: string;
|
||||
totalBytes?: number;
|
||||
sourceSftpId?: string;
|
||||
};
|
||||
|
||||
/** Remote pane whose listing was captured before the file grew to 3626 bytes. */
|
||||
function makePane(side: "left" | "right"): SftpPane {
|
||||
const isLocal = side === "right";
|
||||
return {
|
||||
id: side,
|
||||
connection: {
|
||||
id: isLocal ? "local-conn" : "remote-conn",
|
||||
hostId: isLocal ? undefined : "host-1",
|
||||
hostLabel: isLocal ? "Local" : "SSH host",
|
||||
isLocal,
|
||||
status: "connected",
|
||||
currentPath: isLocal ? "/Users/reporter/Downloads" : "/opt",
|
||||
},
|
||||
files: isLocal
|
||||
? []
|
||||
: [
|
||||
{
|
||||
name: "aops_dns_view_demo.sh",
|
||||
type: "file" as const,
|
||||
size: 3622,
|
||||
sizeFormatted: "3.5 KB",
|
||||
lastModified: 1758000000000,
|
||||
lastModifiedFormatted: "",
|
||||
},
|
||||
],
|
||||
loading: false,
|
||||
reconnecting: false,
|
||||
error: null,
|
||||
connectionLogs: [],
|
||||
selectedFiles: new Set<string>(),
|
||||
filter: "",
|
||||
filenameEncoding: "auto",
|
||||
showHiddenFiles: true,
|
||||
transferMutationToken: 0,
|
||||
};
|
||||
}
|
||||
|
||||
function installGlobals(netty: Record<string, unknown>) {
|
||||
const previousWindow = (globalThis as { window?: unknown }).window;
|
||||
const previousActFlag = (globalThis as { IS_REACT_ACT_ENVIRONMENT?: boolean }).IS_REACT_ACT_ENVIRONMENT;
|
||||
(globalThis as { IS_REACT_ACT_ENVIRONMENT?: boolean }).IS_REACT_ACT_ENVIRONMENT = true;
|
||||
const previousStorage = (globalThis as { localStorage?: unknown }).localStorage;
|
||||
const previousRaf = (globalThis as { requestAnimationFrame?: unknown }).requestAnimationFrame;
|
||||
const previousCancelRaf = (globalThis as { cancelAnimationFrame?: unknown }).cancelAnimationFrame;
|
||||
// Progress patches are rAF-coalesced; provide a microtask-ish shim.
|
||||
const raf = (cb: (time: number) => void) => setTimeout(() => cb(Date.now()), 0);
|
||||
(globalThis as { requestAnimationFrame?: unknown }).requestAnimationFrame = raf;
|
||||
(globalThis as { cancelAnimationFrame?: unknown }).cancelAnimationFrame = (id: unknown) => clearTimeout(id as number);
|
||||
(globalThis as { window?: unknown }).window = {
|
||||
netcatty: netty,
|
||||
requestAnimationFrame: raf,
|
||||
cancelAnimationFrame: (id: unknown) => clearTimeout(id as number),
|
||||
};
|
||||
(globalThis as { localStorage?: unknown }).localStorage = {
|
||||
getItem: () => null,
|
||||
setItem: () => undefined,
|
||||
removeItem: () => undefined,
|
||||
};
|
||||
return () => {
|
||||
(globalThis as { window?: unknown }).window = previousWindow;
|
||||
(globalThis as { localStorage?: unknown }).localStorage = previousStorage;
|
||||
(globalThis as { IS_REACT_ACT_ENVIRONMENT?: boolean }).IS_REACT_ACT_ENVIRONMENT = previousActFlag;
|
||||
(globalThis as { requestAnimationFrame?: unknown }).requestAnimationFrame = previousRaf;
|
||||
(globalThis as { cancelAnimationFrame?: unknown }).cancelAnimationFrame = previousCancelRaf;
|
||||
};
|
||||
}
|
||||
|
||||
for (const liveStatAvailable of [true, false]) {
|
||||
test(
|
||||
liveStatAvailable
|
||||
? "dual-pane download plan uses the live source size, not the stale listing (#3559)"
|
||||
: "dual-pane download plan falls back to live size measurement when stat is unavailable (#3559)",
|
||||
async () => {
|
||||
const statCalls: Array<{ sftpId: string; path: string }> = [];
|
||||
const startedOptions: StartOptions[] = [];
|
||||
let resolveTransfer!: (options: StartOptions) => void;
|
||||
const transferStarted = new Promise<StartOptions>((resolve) => { resolveTransfer = resolve; });
|
||||
|
||||
const restore = installGlobals({
|
||||
statSftp: async (sftpId: string, target: string) => {
|
||||
if (!liveStatAvailable) return undefined;
|
||||
statCalls.push({ sftpId, path: target });
|
||||
return {
|
||||
name: "aops_dns_view_demo.sh",
|
||||
type: "file" as const,
|
||||
size: 3626,
|
||||
sizeKnown: true,
|
||||
lastModified: 1758000001000,
|
||||
};
|
||||
},
|
||||
statLocal: async () => null,
|
||||
startStreamTransfer: async (options: StartOptions) => {
|
||||
startedOptions.push(options);
|
||||
resolveTransfer(options);
|
||||
sftpTransferCenterStore.ingestBackgroundEvent({
|
||||
type: "completed",
|
||||
transferId: options.transferId,
|
||||
transferred: options.totalBytes ?? 0,
|
||||
totalBytes: options.totalBytes ?? 0,
|
||||
lifecycleEpoch: 0,
|
||||
});
|
||||
return {};
|
||||
},
|
||||
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;
|
||||
function Probe() {
|
||||
ops = useSftpTransfers({
|
||||
ownerId: "plan-owner",
|
||||
getActivePane: (side) => (side === "left" ? makePane("left") : makePane("right")),
|
||||
getPaneByConnectionId: () => null,
|
||||
getTabByConnectionId: () => null,
|
||||
updateTab: () => undefined,
|
||||
refresh: async () => undefined,
|
||||
clearCacheForConnection: () => undefined,
|
||||
handleSessionError: () => undefined,
|
||||
sftpSessionsRef: { current: new Map([["remote-conn", "sftp-remote"]]) },
|
||||
connectionCacheKeyMapRef: { current: new Map() },
|
||||
listLocalFiles: async () => [],
|
||||
listRemoteFiles: async () => [],
|
||||
});
|
||||
return null;
|
||||
}
|
||||
|
||||
try {
|
||||
await act(async () => { renderer = create(React.createElement(Probe)); });
|
||||
await act(async () => {
|
||||
const running = ops!.startTransfer(
|
||||
[{ name: "aops_dns_view_demo.sh", isDirectory: false }],
|
||||
"left",
|
||||
"right",
|
||||
);
|
||||
const options = await transferStarted;
|
||||
startedOptions.length = 0;
|
||||
startedOptions.push(options);
|
||||
await running;
|
||||
});
|
||||
|
||||
// Every fresh remote plan must come from a live re-stat of the source.
|
||||
if (liveStatAvailable) {
|
||||
assert.equal(statCalls.length, 1);
|
||||
assert.equal(statCalls[0].sftpId, "sftp-remote");
|
||||
assert.equal(statCalls[0].path, "/opt/aops_dns_view_demo.sh");
|
||||
}
|
||||
const planBytes = startedOptions[0]?.totalBytes;
|
||||
if (liveStatAvailable) {
|
||||
assert.equal(planBytes, 3626, "plan must use the live remote size, not the stale listing 3622");
|
||||
} else {
|
||||
// No live stat possible: plan 0 must be omitted so the transfer
|
||||
// bridge measures the live size instead of truncating.
|
||||
assert.equal(planBytes, undefined);
|
||||
}
|
||||
} finally {
|
||||
renderer?.unmount();
|
||||
restore();
|
||||
}
|
||||
},
|
||||
);
|
||||
}
|
||||
|
||||
test("stat-less remote source keeps the listing size in the overwrite dialog and leaves the plan unknown", async () => {
|
||||
const started: StartOptions[] = [];
|
||||
const restore = installGlobals({
|
||||
statSftp: async () => ({
|
||||
name: "aops_dns_view_demo.sh",
|
||||
type: "file" as const,
|
||||
size: 0,
|
||||
sizeKnown: false,
|
||||
lastModified: 0,
|
||||
}),
|
||||
statLocal: async () => ({
|
||||
name: "aops_dns_view_demo.sh",
|
||||
type: "file" as const,
|
||||
size: 100,
|
||||
lastModified: 1,
|
||||
}),
|
||||
startStreamTransfer: async (options: StartOptions) => {
|
||||
started.push(options);
|
||||
return {};
|
||||
},
|
||||
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;
|
||||
function Probe() {
|
||||
ops = useSftpTransfers({
|
||||
ownerId: "plan-owner-statless",
|
||||
getActivePane: (side) => (side === "left" ? makePane("left") : makePane("right")),
|
||||
getPaneByConnectionId: () => null,
|
||||
getTabByConnectionId: () => null,
|
||||
updateTab: () => undefined,
|
||||
refresh: async () => undefined,
|
||||
clearCacheForConnection: () => undefined,
|
||||
handleSessionError: () => undefined,
|
||||
sftpSessionsRef: { current: new Map([["remote-conn", "sftp-remote"]]) },
|
||||
connectionCacheKeyMapRef: { current: new Map() },
|
||||
listLocalFiles: async () => [],
|
||||
listRemoteFiles: async () => [],
|
||||
});
|
||||
return null;
|
||||
}
|
||||
|
||||
try {
|
||||
await act(async () => { renderer = create(React.createElement(Probe)); });
|
||||
await act(async () => {
|
||||
await ops!.startTransfer(
|
||||
[{ name: "aops_dns_view_demo.sh", isDirectory: false }],
|
||||
"left",
|
||||
"right",
|
||||
);
|
||||
});
|
||||
|
||||
assert.equal(started.length, 0, "an unresolved conflict must not start the transfer");
|
||||
assert.equal(ops!.conflicts.length, 1);
|
||||
assert.equal(ops!.conflicts[0].newSize, 3622);
|
||||
assert.equal(ops!.conflicts[0].newModified, 1758000000000);
|
||||
const task = ops!.transfers.find((row) => row.fileName === "aops_dns_view_demo.sh");
|
||||
assert.equal(task?.totalBytes, 0, "listing size must not become the transfer plan");
|
||||
assert.equal(task?.status, "attention");
|
||||
} finally {
|
||||
renderer?.unmount();
|
||||
restore();
|
||||
}
|
||||
});
|
||||
|
||||
test("remote transfer preflight stats stay within the concurrency cap and file bound", async () => {
|
||||
const limit = PREFLIGHT_STAT_CONCURRENCY;
|
||||
const fileCount = limit + 4;
|
||||
let inflight = 0;
|
||||
let maxInflight = 0;
|
||||
let releaseAll = () => {};
|
||||
const gate = new Promise<void>((resolve) => { releaseAll = resolve; });
|
||||
let reachedCap = () => {};
|
||||
const hitCap = new Promise<void>((resolve) => { reachedCap = resolve; });
|
||||
const names = Array.from({ length: fileCount }, (_, index) => `file-${index}.bin`);
|
||||
|
||||
const restore = installGlobals({
|
||||
statSftp: async () => {
|
||||
inflight += 1;
|
||||
maxInflight = Math.max(maxInflight, inflight);
|
||||
if (inflight >= limit) reachedCap();
|
||||
try {
|
||||
await gate;
|
||||
} finally {
|
||||
inflight -= 1;
|
||||
}
|
||||
return {
|
||||
name: "file",
|
||||
type: "file" as const,
|
||||
size: 10,
|
||||
sizeKnown: true,
|
||||
lastModified: 1,
|
||||
};
|
||||
},
|
||||
statLocal: async () => null,
|
||||
startStreamTransfer: async (options: StartOptions) => {
|
||||
sftpTransferCenterStore.ingestBackgroundEvent({
|
||||
type: "completed",
|
||||
transferId: options.transferId,
|
||||
transferred: options.totalBytes ?? 0,
|
||||
totalBytes: options.totalBytes ?? 0,
|
||||
lifecycleEpoch: 0,
|
||||
});
|
||||
return {};
|
||||
},
|
||||
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;
|
||||
function Probe() {
|
||||
ops = useSftpTransfers({
|
||||
ownerId: "plan-owner-concurrency",
|
||||
getActivePane: (side) => (side === "left" ? makePane("left") : makePane("right")),
|
||||
getPaneByConnectionId: () => null,
|
||||
getTabByConnectionId: () => null,
|
||||
updateTab: () => undefined,
|
||||
refresh: async () => undefined,
|
||||
clearCacheForConnection: () => undefined,
|
||||
handleSessionError: () => undefined,
|
||||
sftpSessionsRef: { current: new Map([["remote-conn", "sftp-remote"]]) },
|
||||
connectionCacheKeyMapRef: { current: new Map() },
|
||||
listLocalFiles: async () => [],
|
||||
listRemoteFiles: async () => [],
|
||||
});
|
||||
return null;
|
||||
}
|
||||
|
||||
try {
|
||||
await act(async () => { renderer = create(React.createElement(Probe)); });
|
||||
let running: Promise<unknown> = Promise.resolve();
|
||||
await act(async () => {
|
||||
running = ops!.startTransfer(
|
||||
names.map((name) => ({ name, isDirectory: false })),
|
||||
"left",
|
||||
"right",
|
||||
);
|
||||
const capTimeout = new Promise<never>((_, reject) => {
|
||||
setTimeout(() => reject(new Error("preflight did not reach the concurrency cap")), 2000);
|
||||
});
|
||||
await Promise.race([hitCap, capTimeout]);
|
||||
});
|
||||
assert.equal(maxInflight, limit);
|
||||
assert.ok(inflight <= limit);
|
||||
releaseAll();
|
||||
await act(async () => { await running; });
|
||||
assert.equal(maxInflight, limit);
|
||||
} finally {
|
||||
releaseAll();
|
||||
renderer?.unmount();
|
||||
restore();
|
||||
}
|
||||
});
|
||||
|
||||
test("a preflight timeout stops the rest of the batch", async () => {
|
||||
const extra = 4;
|
||||
const names = Array.from(
|
||||
{ length: PREFLIGHT_STAT_CONCURRENCY + extra },
|
||||
(_, index) => `hung-${index}.bin`,
|
||||
);
|
||||
let statCalls = 0;
|
||||
const restore = installGlobals({
|
||||
statSftp: () => {
|
||||
statCalls += 1;
|
||||
return new Promise(() => {});
|
||||
},
|
||||
statLocal: async () => null,
|
||||
startStreamTransfer: async (options: StartOptions) => {
|
||||
sftpTransferCenterStore.ingestBackgroundEvent({
|
||||
type: "completed",
|
||||
transferId: options.transferId,
|
||||
transferred: options.totalBytes ?? 0,
|
||||
totalBytes: options.totalBytes ?? 0,
|
||||
lifecycleEpoch: 0,
|
||||
});
|
||||
return {};
|
||||
},
|
||||
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;
|
||||
function Probe() {
|
||||
ops = useSftpTransfers({
|
||||
ownerId: "plan-owner-timeout",
|
||||
getActivePane: (side) => (side === "left" ? makePane("left") : makePane("right")),
|
||||
getPaneByConnectionId: () => null,
|
||||
getTabByConnectionId: () => null,
|
||||
updateTab: () => undefined,
|
||||
refresh: async () => undefined,
|
||||
clearCacheForConnection: () => undefined,
|
||||
handleSessionError: () => undefined,
|
||||
sftpSessionsRef: { current: new Map([["remote-conn", "sftp-remote"]]) },
|
||||
connectionCacheKeyMapRef: { current: new Map() },
|
||||
listLocalFiles: async () => [],
|
||||
listRemoteFiles: async () => [],
|
||||
});
|
||||
return null;
|
||||
}
|
||||
|
||||
try {
|
||||
await act(async () => { renderer = create(React.createElement(Probe)); });
|
||||
await act(async () => {
|
||||
await ops!.startTransfer(
|
||||
names.map((name) => ({ name, isDirectory: false })),
|
||||
"left",
|
||||
"right",
|
||||
);
|
||||
});
|
||||
// One preflight wave, then stop. Timed-out files are not statted again.
|
||||
assert.equal(statCalls, PREFLIGHT_STAT_CONCURRENCY);
|
||||
} finally {
|
||||
renderer?.unmount();
|
||||
restore();
|
||||
}
|
||||
});
|
||||
|
||||
test("stat-less preflight results are not statted again before transfer", async () => {
|
||||
const names = Array.from(
|
||||
{ length: PREFLIGHT_STAT_CONCURRENCY + 2 },
|
||||
(_, index) => `legacy-${index}.bin`,
|
||||
);
|
||||
let statCalls = 0;
|
||||
let inflight = 0;
|
||||
let maxInflight = 0;
|
||||
const started: StartOptions[] = [];
|
||||
const restore = installGlobals({
|
||||
statSftp: async () => {
|
||||
statCalls += 1;
|
||||
inflight += 1;
|
||||
maxInflight = Math.max(maxInflight, inflight);
|
||||
await Promise.resolve();
|
||||
inflight -= 1;
|
||||
return {
|
||||
name: "legacy",
|
||||
type: "file" as const,
|
||||
size: 0,
|
||||
sizeKnown: false,
|
||||
lastModified: 0,
|
||||
};
|
||||
},
|
||||
statLocal: async () => null,
|
||||
startStreamTransfer: async (options: StartOptions) => {
|
||||
started.push(options);
|
||||
sftpTransferCenterStore.ingestBackgroundEvent({
|
||||
type: "completed",
|
||||
transferId: options.transferId,
|
||||
transferred: options.totalBytes ?? 0,
|
||||
totalBytes: options.totalBytes ?? 0,
|
||||
lifecycleEpoch: 0,
|
||||
});
|
||||
return {};
|
||||
},
|
||||
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;
|
||||
function Probe() {
|
||||
ops = useSftpTransfers({
|
||||
ownerId: "plan-owner-statless-batch",
|
||||
getActivePane: (side) => (side === "left" ? makePane("left") : makePane("right")),
|
||||
getPaneByConnectionId: () => null,
|
||||
getTabByConnectionId: () => null,
|
||||
updateTab: () => undefined,
|
||||
refresh: async () => undefined,
|
||||
clearCacheForConnection: () => undefined,
|
||||
handleSessionError: () => undefined,
|
||||
sftpSessionsRef: { current: new Map([["remote-conn", "sftp-remote"]]) },
|
||||
connectionCacheKeyMapRef: { current: new Map() },
|
||||
listLocalFiles: async () => [],
|
||||
listRemoteFiles: async () => [],
|
||||
});
|
||||
return null;
|
||||
}
|
||||
|
||||
try {
|
||||
await act(async () => { renderer = create(React.createElement(Probe)); });
|
||||
await act(async () => {
|
||||
await ops!.startTransfer(
|
||||
names.map((name) => ({ name, isDirectory: false })),
|
||||
"left",
|
||||
"right",
|
||||
);
|
||||
});
|
||||
assert.equal(statCalls, names.length);
|
||||
assert.ok(maxInflight <= PREFLIGHT_STAT_CONCURRENCY);
|
||||
assert.equal(started.length, names.length);
|
||||
assert.equal(started.filter((options) => options.totalBytes === undefined).length, names.length);
|
||||
} finally {
|
||||
renderer?.unmount();
|
||||
restore();
|
||||
}
|
||||
});
|
||||
|
||||
test("files past the preflight bound stay un-statted after conflict resolution", async () => {
|
||||
const extra = 2;
|
||||
const names = Array.from({ length: PREFLIGHT_STAT_MAX_FILES + extra }, (_, index) => `bulk-${index}.bin`);
|
||||
let statCalls = 0;
|
||||
const started: StartOptions[] = [];
|
||||
const restore = installGlobals({
|
||||
statSftp: async () => {
|
||||
statCalls += 1;
|
||||
return {
|
||||
name: "bulk",
|
||||
type: "file" as const,
|
||||
size: 20,
|
||||
sizeKnown: true,
|
||||
lastModified: 5,
|
||||
};
|
||||
},
|
||||
statLocal: async (target: string) => names.slice(-extra).some((name) => target.endsWith(`/${name}`))
|
||||
? { name: target.split("/").pop() || "", type: "file" as const, size: 1, lastModified: 1 }
|
||||
: null,
|
||||
startStreamTransfer: async (options: StartOptions) => {
|
||||
started.push(options);
|
||||
sftpTransferCenterStore.ingestBackgroundEvent({
|
||||
type: "completed",
|
||||
transferId: options.transferId,
|
||||
transferred: options.totalBytes ?? 0,
|
||||
totalBytes: options.totalBytes ?? 0,
|
||||
lifecycleEpoch: 0,
|
||||
});
|
||||
return {};
|
||||
},
|
||||
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;
|
||||
function Probe() {
|
||||
ops = useSftpTransfers({
|
||||
ownerId: "plan-owner-bound",
|
||||
getActivePane: (side) => (side === "left" ? makePane("left") : makePane("right")),
|
||||
getPaneByConnectionId: () => null,
|
||||
getTabByConnectionId: () => null,
|
||||
updateTab: () => undefined,
|
||||
refresh: async () => undefined,
|
||||
clearCacheForConnection: () => undefined,
|
||||
handleSessionError: () => undefined,
|
||||
sftpSessionsRef: { current: new Map([["remote-conn", "sftp-remote"]]) },
|
||||
connectionCacheKeyMapRef: { current: new Map() },
|
||||
listLocalFiles: async () => [],
|
||||
listRemoteFiles: async () => [],
|
||||
});
|
||||
return null;
|
||||
}
|
||||
|
||||
try {
|
||||
await act(async () => { renderer = create(React.createElement(Probe)); });
|
||||
await act(async () => {
|
||||
await ops!.startTransfer(
|
||||
names.map((name) => ({ name, isDirectory: false })),
|
||||
"left",
|
||||
"right",
|
||||
);
|
||||
});
|
||||
assert.equal(statCalls, PREFLIGHT_STAT_MAX_FILES);
|
||||
assert.equal(started.length, names.length - extra);
|
||||
assert.equal(ops!.conflicts.length, extra);
|
||||
await act(async () => { renderer?.unmount(); });
|
||||
await act(async () => { renderer = create(React.createElement(Probe)); });
|
||||
assert.equal(ops!.conflicts.length, extra, "conflicts must survive panel remount");
|
||||
await act(async () => {
|
||||
await ops!.resolveConflict(ops!.conflicts[0].transferId, "replace", true);
|
||||
await new Promise((resolve) => setTimeout(resolve, 300));
|
||||
});
|
||||
assert.equal(started.length, names.length);
|
||||
assert.equal(statCalls, PREFLIGHT_STAT_MAX_FILES, "conflict resolution must keep the preflight cap");
|
||||
const omitted = started.filter((options) => options.totalBytes === undefined);
|
||||
assert.equal(omitted.length, extra);
|
||||
} finally {
|
||||
renderer?.unmount();
|
||||
restore();
|
||||
}
|
||||
});
|
||||
@@ -63,6 +63,21 @@ import {
|
||||
retryExternalDragDropFileUpload,
|
||||
} from "./externalDragDropRetry";
|
||||
|
||||
// The bridge's statSftp awaits an unbounded SFTP callback, so a half-open
|
||||
// session can leave a stat pending forever. Every stat issued outside the
|
||||
// bridge is bounded with this timeout; on timeout the caller falls back to
|
||||
// stale metadata / a 0 plan instead of one hung stat blocking a batch.
|
||||
const STAT_SFTP_TIMEOUT_MS = 10_000;
|
||||
|
||||
// Preflight re-stats share the browse SFTP session and the renderer IPC
|
||||
// queue. Launching one stat per file for a huge multi-file selection would
|
||||
// flood both before any transfer task reaches the UI, so cap the number of
|
||||
// in-flight preflight stats and skip the re-stat entirely once the selection
|
||||
// grows past a bound (files beyond the cap stay on the safe 0 plan, which
|
||||
// makes the bridge measure the live size anyway).
|
||||
export const PREFLIGHT_STAT_CONCURRENCY = 8;
|
||||
export const PREFLIGHT_STAT_MAX_FILES = 128;
|
||||
|
||||
/** Keep the MutableRefObject mirror in sync with the process-global latch set. */
|
||||
function syncPausedTasksRef(ref: { current: Set<string> }, taskId: string, latched: boolean) {
|
||||
if (latched) ref.current.add(taskId);
|
||||
@@ -207,7 +222,6 @@ export const useSftpTransfers = ({
|
||||
const transfersRef = useRef(transfers);
|
||||
const conflictsRef = useRef(conflicts);
|
||||
conflictsRef.current = conflicts;
|
||||
|
||||
// When the retained panel is re-opened, catch up React state from the ref that
|
||||
// kept receiving progress while the surface was hidden.
|
||||
useEffect(() => {
|
||||
@@ -613,9 +627,30 @@ export const useSftpTransfers = ({
|
||||
|
||||
const sameHost = sameHostEndpoints && !!sourceSftpId && !!targetSftpId;
|
||||
|
||||
const discoverTransferSize = async () => {
|
||||
// Returns the discovered stat so callers that need the size (e.g. the
|
||||
// overwrite conflict dialog) can await this instead of reading stale
|
||||
// metadata from the local task copy.
|
||||
const discoverTransferSize = async (): Promise<{
|
||||
size: number;
|
||||
lastModified?: number;
|
||||
/** False for pane-listing display data. That size must not become the plan. */
|
||||
plan: boolean;
|
||||
} | null> => {
|
||||
// When the endpoint cannot stat the source (legacy SCP returns the
|
||||
// sizeKnown: false placeholder) or the stat fails, the transfer plan
|
||||
// stays unknown (0 bytes, so the bridge measures the live size). The
|
||||
// overwrite dialog still needs display metadata, so fall back to the
|
||||
// retained cached listing entry instead of reporting 0 bytes — but only
|
||||
// for the dialog; the plan itself must never take the listing's
|
||||
// possibly stale size back.
|
||||
const cachedListingStatForDialog = (): { size: number; lastModified?: number; plan: false } | null => {
|
||||
if (getParentPath(task.sourcePath) !== sourcePane.connection?.currentPath) return null;
|
||||
const entry = sourcePane.files.find((candidate) => candidate.name === task.fileName);
|
||||
if (!entry || !(entry.size > 0)) return null;
|
||||
return { size: entry.size, lastModified: entry.lastModified, plan: false };
|
||||
};
|
||||
try {
|
||||
if (task.totalBytes > 0 || !!task.sourceLastModified) return;
|
||||
if (task.totalBytes > 0 || !!task.sourceLastModified) return null;
|
||||
|
||||
if (sourcePane.connection?.isLocal) {
|
||||
const stat = await netcattyBridge.get()?.statLocal?.(task.sourcePath);
|
||||
@@ -628,17 +663,41 @@ export const useSftpTransfers = ({
|
||||
totalBytes: stat.size,
|
||||
});
|
||||
}
|
||||
return { size: stat.size, lastModified: stat.lastModified, plan: true };
|
||||
}
|
||||
return;
|
||||
return null;
|
||||
}
|
||||
|
||||
if (sourceSftpId) {
|
||||
const stat = await netcattyBridge.get()?.statSftp?.(
|
||||
sourceSftpId,
|
||||
task.sourcePath,
|
||||
sourceEncoding,
|
||||
);
|
||||
if (stat) {
|
||||
if (task.preflightStatSkipped) {
|
||||
return cachedListingStatForDialog();
|
||||
}
|
||||
// This stat runs deferred (started before the conflict check) and
|
||||
// can be awaited by the conflict path, so it must not hang on a
|
||||
// half-open session. Bound it like the preflight stat and settle
|
||||
// early once the task is cancelled, since a marked task cannot
|
||||
// settle an in-flight SFTP callback itself.
|
||||
let timeoutTimer: ReturnType<typeof setTimeout> | undefined;
|
||||
let cancelTimer: ReturnType<typeof setInterval> | undefined;
|
||||
const stat = await Promise.race([
|
||||
Promise.resolve(netcattyBridge.get()?.statSftp?.(
|
||||
sourceSftpId,
|
||||
task.sourcePath,
|
||||
sourceEncoding,
|
||||
)),
|
||||
new Promise<undefined>((resolve) => {
|
||||
timeoutTimer = setTimeout(() => resolve(undefined), STAT_SFTP_TIMEOUT_MS);
|
||||
}),
|
||||
new Promise<undefined>((resolve) => {
|
||||
cancelTimer = setInterval(() => {
|
||||
if (cancelledTasksRef.current.has(task.id)) resolve(undefined);
|
||||
}, 250);
|
||||
}),
|
||||
]).finally(() => {
|
||||
if (timeoutTimer) clearTimeout(timeoutTimer);
|
||||
if (cancelTimer) clearInterval(cancelTimer);
|
||||
});
|
||||
if (stat && stat.sizeKnown !== false) {
|
||||
if (!task.sourceLastModified && stat.lastModified) {
|
||||
task.sourceLastModified = stat.lastModified;
|
||||
}
|
||||
@@ -647,12 +706,18 @@ export const useSftpTransfers = ({
|
||||
totalBytes: stat.size,
|
||||
});
|
||||
}
|
||||
return { size: stat.size, lastModified: stat.lastModified, plan: true };
|
||||
}
|
||||
// Unknown size (e.g. legacy SCP stat): keep the transfer plan
|
||||
// unknown so a 0-byte placeholder never replaces trusted metadata,
|
||||
// but give the conflict dialog the retained cached listing entry.
|
||||
return cachedListingStatForDialog();
|
||||
}
|
||||
} catch (err) {
|
||||
if (!isTransferCancelledError(err)) {
|
||||
logger.debug?.("[SFTP] Deferred transfer size discovery failed", err);
|
||||
}
|
||||
return cachedListingStatForDialog();
|
||||
}
|
||||
};
|
||||
|
||||
@@ -746,16 +811,35 @@ export const useSftpTransfers = ({
|
||||
return null;
|
||||
})();
|
||||
|
||||
// For single files: fire-and-forget size discovery
|
||||
if (!task.isDirectory) {
|
||||
void discoverTransferSize();
|
||||
}
|
||||
// For single files: start size discovery without blocking the conflict
|
||||
// check, but keep the promise so an actual conflict can await fresh
|
||||
// size metadata before the dialog is shown.
|
||||
const sizeDiscoveryPromise = task.isDirectory ? null : discoverTransferSize();
|
||||
|
||||
// Only await conflict check (fast single stat call)
|
||||
const conflict = await conflictCheckPromise;
|
||||
// Cancel/Stop may have won while conflict stats were in flight.
|
||||
if (cancelledTasksRef.current.has(task.id)) return "cancelled";
|
||||
|
||||
// null means the plan size is still unknown. A verified 0-byte stat is 0,
|
||||
// not null, so an empty file does not pick up a stale listing size.
|
||||
let verifiedPlanBytes: number | null = task.totalBytes > 0 ? task.totalBytes : null;
|
||||
if (conflict && sizeDiscoveryPromise) {
|
||||
// The task's totalBytes may still be 0 here because discovery ran
|
||||
// deferred and updateTask never mutates the local task copy. Await
|
||||
// discovery (bounded per-task; the task is already visible/cancellable)
|
||||
// so the overwrite dialog shows the real source size, not 0 (#3559).
|
||||
const discovered = await sizeDiscoveryPromise;
|
||||
// Cancel during this stat must win before an apply-to-all Replace
|
||||
// deletes an existing symlink.
|
||||
if (cancelledTasksRef.current.has(task.id)) return "cancelled";
|
||||
if (discovered && discovered.size >= 0) {
|
||||
conflict.newSize = discovered.size;
|
||||
if (discovered.lastModified) conflict.newModified = discovered.lastModified;
|
||||
if (discovered.plan) verifiedPlanBytes = discovered.size;
|
||||
}
|
||||
}
|
||||
|
||||
if (conflict) {
|
||||
const defaultAction = conflictDefaultsRef.current
|
||||
.get(task.batchId ?? "global")
|
||||
@@ -819,7 +903,9 @@ export const useSftpTransfers = ({
|
||||
setConflicts((prev) => [...prev, conflict]);
|
||||
updateTask({
|
||||
status: "attention",
|
||||
totalBytes: conflict.newSize || task.totalBytes || 0,
|
||||
// Dialog newSize may be the listing fallback (plan: false). Only a
|
||||
// verified stat may become the plan; 0 stays omitted at startStreamTransfer.
|
||||
totalBytes: verifiedPlanBytes ?? 0,
|
||||
conflict,
|
||||
});
|
||||
return "attention";
|
||||
@@ -1094,6 +1180,84 @@ export const useSftpTransfers = ({
|
||||
? new Map(sourcePane.files.map(f => [f.name, f]))
|
||||
: null;
|
||||
|
||||
// Pane listings can be stale when the file changed in a terminal or
|
||||
// another session after the pane was listed (#3559). A stale undersized
|
||||
// size becomes the transfer plan and silently truncates the download, so
|
||||
// remote single files are always re-statted before the plan is built.
|
||||
// When no live stat is possible the plan stays 0, which makes the bridge
|
||||
// measure the live size instead of trusting the stale listing.
|
||||
const freshRemoteMetadata = new Map<string, { size: number; lastModified: number }>();
|
||||
const sourceSftpIdForPlan = sourcePane.connection.isLocal
|
||||
? null
|
||||
: (sftpSessionsRef.current.get(sourceConnectionId) ?? null);
|
||||
const remoteSingleFiles = sourcePane.connection.isLocal
|
||||
? []
|
||||
: sourceFiles.filter((file) => !file.isDirectory);
|
||||
const preflightFiles = remoteSingleFiles.slice(0, PREFLIGHT_STAT_MAX_FILES);
|
||||
// Remote files without a verified live stat are not statted again in
|
||||
// discoverTransferSize. That covers the tail past the cap, a missing
|
||||
// browse session, a timeout that stops the pool, and a stat that
|
||||
// returns no usable size. The plan stays unknown so the bridge measures
|
||||
// the live size; the overwrite dialog still uses the pane listing.
|
||||
const preflightSkippedNames = new Set<string>();
|
||||
if (remoteSingleFiles.length > 0 && sourceSftpIdForPlan) {
|
||||
const sourceEncoding = sourcePane.filenameEncoding || "auto";
|
||||
// The bridge's statSftp awaits an unbounded SFTP callback, so a
|
||||
// half-open session can leave a stat pending forever. Bound each
|
||||
// preflight stat: on timeout the file keeps its 0 plan and the bridge
|
||||
// measures the live size during the transfer instead of one hung stat
|
||||
// blocking the entire selected batch.
|
||||
const PREFLIGHT_STAT_TIMEOUT_MS = STAT_SFTP_TIMEOUT_MS;
|
||||
// These stats share the browse session. Keep a few in flight, and
|
||||
// after the first timeout stop claiming more files so a half-open
|
||||
// session cannot run the whole selection out to 10s each.
|
||||
let preflightStopped = false;
|
||||
const statOneRemoteFile = async (file: { name: string; isDirectory: boolean }) => {
|
||||
let timeoutTimer: ReturnType<typeof setTimeout> | undefined;
|
||||
let timedOut = false;
|
||||
try {
|
||||
const stat = await Promise.race([
|
||||
Promise.resolve(netcattyBridge.get()?.statSftp?.(
|
||||
sourceSftpIdForPlan,
|
||||
joinPath(sourcePath, file.name),
|
||||
sourceEncoding,
|
||||
)),
|
||||
new Promise<undefined>((resolve) => {
|
||||
timeoutTimer = setTimeout(() => {
|
||||
timedOut = true;
|
||||
resolve(undefined);
|
||||
}, PREFLIGHT_STAT_TIMEOUT_MS);
|
||||
}),
|
||||
]);
|
||||
if (timedOut) {
|
||||
preflightStopped = true;
|
||||
return;
|
||||
}
|
||||
if (stat && stat.type !== "symlink" && stat.sizeKnown !== false && stat.size >= 0) {
|
||||
freshRemoteMetadata.set(file.name, { size: stat.size, lastModified: stat.lastModified });
|
||||
}
|
||||
} catch (err) {
|
||||
// Fall through to a 0 plan so the bridge measures the live size.
|
||||
logger.debug?.("[SFTP] Transfer plan re-stat failed; falling back to live size", err);
|
||||
} finally {
|
||||
if (timeoutTimer) clearTimeout(timeoutTimer);
|
||||
}
|
||||
};
|
||||
const workers = Math.min(PREFLIGHT_STAT_CONCURRENCY, preflightFiles.length);
|
||||
let preflightCursor = 0;
|
||||
await Promise.allSettled(
|
||||
Array.from({ length: workers }, async () => {
|
||||
while (!preflightStopped && preflightCursor < preflightFiles.length) {
|
||||
const file = preflightFiles[preflightCursor++];
|
||||
await statOneRemoteFile(file);
|
||||
}
|
||||
}),
|
||||
);
|
||||
}
|
||||
for (const file of remoteSingleFiles) {
|
||||
if (!freshRemoteMetadata.has(file.name)) preflightSkippedNames.add(file.name);
|
||||
}
|
||||
|
||||
for (const file of sourceFiles) {
|
||||
const direction: TransferDirection =
|
||||
sourcePane.connection!.isLocal && !targetPane.connection!.isLocal
|
||||
@@ -1102,12 +1266,30 @@ export const useSftpTransfers = ({
|
||||
? "download"
|
||||
: "remote-to-remote";
|
||||
|
||||
// Use cached metadata from the source pane's file list to avoid
|
||||
// redundant stat calls over the network, but only when the transfer
|
||||
// source matches the pane's currently listed directory.
|
||||
// Prefer the fresh re-stat for remote files: a cached listing size can
|
||||
// be stale and undersized, which would silently truncate downloads
|
||||
// (#3559). Remote files without a live stat use plan 0 so the bridge
|
||||
// measures the live size; local sources keep the pane metadata.
|
||||
const fileEntry = fileEntryMap?.get(file.name);
|
||||
const fileSize = file.isDirectory ? 0 : (fileEntry?.size ?? 0);
|
||||
const sourceLastModified = fileEntry?.lastModified ?? 0;
|
||||
const freshMetadata = freshRemoteMetadata.get(file.name);
|
||||
const fileSize = file.isDirectory
|
||||
? 0
|
||||
: freshMetadata
|
||||
? freshMetadata.size
|
||||
: sourcePane.connection!.isLocal
|
||||
? (fileEntry?.size ?? 0)
|
||||
: 0;
|
||||
// Only keep an mtime that is paired with trusted metadata (live re-stat,
|
||||
// local source size, or directory totals). A remote file whose re-stat
|
||||
// failed must stay fully unknown (size 0 AND mtime 0 in the plan):
|
||||
// discoverTransferSize and the conflict check both treat a nonzero
|
||||
// sourceLastModified as proof the size is already known, so a stale
|
||||
// mtime would suppress the deferred source re-stat. The overwrite
|
||||
// dialog still gets the pane listing's cached entry via
|
||||
// discoverTransferSize's cachedListingStatForDialog fallback.
|
||||
const sourceLastModified = !file.isDirectory && !sourcePane.connection!.isLocal && !freshMetadata
|
||||
? 0
|
||||
: (freshMetadata?.lastModified ?? fileEntry?.lastModified ?? 0);
|
||||
|
||||
const nextSourcePath = joinPath(sourcePath, file.name);
|
||||
const nextTargetPath = joinPath(targetPath, file.name);
|
||||
@@ -1167,8 +1349,9 @@ export const useSftpTransfers = ({
|
||||
}
|
||||
continue;
|
||||
}
|
||||
const taskId = crypto.randomUUID();
|
||||
newTasks.push({
|
||||
id: crypto.randomUUID(),
|
||||
id: taskId,
|
||||
batchId,
|
||||
fileName: file.name,
|
||||
originalFileName: file.name,
|
||||
@@ -1189,6 +1372,7 @@ export const useSftpTransfers = ({
|
||||
isDirectory: file.isDirectory,
|
||||
progressMode: file.isDirectory ? "files" : "bytes",
|
||||
sourceLastModified,
|
||||
preflightStatSkipped: preflightSkippedNames.has(file.name),
|
||||
origin: "manual",
|
||||
resumable: !usesLegacyScp,
|
||||
pauseUnavailableReason: usesLegacyScp ? "This server uses legacy SCP; cancel and retry from the beginning instead" : undefined,
|
||||
|
||||
@@ -2,6 +2,7 @@ import { useEffect, useRef, type MutableRefObject } from 'react';
|
||||
import {
|
||||
STORAGE_KEY_AUTO_UPDATE_ENABLED,
|
||||
STORAGE_KEY_CLOSE_TO_TRAY,
|
||||
STORAGE_KEY_SHOW_TRAY_ICON,
|
||||
STORAGE_KEY_AUTO_LAUNCH_ENABLED,
|
||||
STORAGE_KEY_GLOBAL_HOTKEY_ENABLED,
|
||||
STORAGE_KEY_TOGGLE_WINDOW_HOTKEY,
|
||||
@@ -42,6 +43,7 @@ interface UseSystemSettingsEffectsParams {
|
||||
toggleWindowHotkey: string;
|
||||
globalHotkeyEnabled: boolean;
|
||||
closeToTray: boolean;
|
||||
showTrayIcon: boolean;
|
||||
autoLaunchEnabled: boolean;
|
||||
windowOpacityRecord: WindowOpacityRecord;
|
||||
windowOpacityMutationSourceRef: MutableRefObject<WindowOpacityMutationSource>;
|
||||
@@ -62,6 +64,7 @@ export function useSystemSettingsEffects({
|
||||
toggleWindowHotkey,
|
||||
globalHotkeyEnabled,
|
||||
closeToTray,
|
||||
showTrayIcon,
|
||||
autoLaunchEnabled,
|
||||
windowOpacityRecord,
|
||||
windowOpacityMutationSourceRef,
|
||||
@@ -173,6 +176,24 @@ export function useSystemSettingsEffects({
|
||||
notifySettingsChanged(STORAGE_KEY_CLOSE_TO_TRAY, closeToTray);
|
||||
}, [enabled, closeToTray, notifySettingsChanged, persistMountedRef]);
|
||||
|
||||
// Persist and sync the show-tray-icon setting. Runs after the close-to-tray
|
||||
// effect above (declaration order) so a hidden icon destroys the tray that
|
||||
// close-to-tray's mount-time push may have just created.
|
||||
useEffect(() => {
|
||||
if (!enabled) return;
|
||||
// Update main process tray visibility (needed on mount)
|
||||
const bridge = netcattyBridge.get();
|
||||
if (bridge?.setShowTrayIcon) {
|
||||
bridge.setShowTrayIcon(showTrayIcon).catch((err) => {
|
||||
console.warn('[SystemTray] Failed to set show-tray-icon:', err);
|
||||
});
|
||||
}
|
||||
localStorageAdapter.writeString(STORAGE_KEY_SHOW_TRAY_ICON, showTrayIcon ? 'true' : 'false');
|
||||
// Skip IPC on initial mount
|
||||
if (!persistMountedRef.current) return;
|
||||
notifySettingsChanged(STORAGE_KEY_SHOW_TRAY_ICON, showTrayIcon);
|
||||
}, [enabled, showTrayIcon, notifySettingsChanged, persistMountedRef]);
|
||||
|
||||
// Hydrate auto-launch from the main process on mount — the OS login item
|
||||
// is the real source of truth (the user may have toggled it outside the
|
||||
// app), localStorage is only an optimistic cache for first paint.
|
||||
|
||||
@@ -10,5 +10,11 @@ export const useClipboardBackend = () => {
|
||||
return typeof text === "string" ? text : "";
|
||||
}, []);
|
||||
|
||||
return { readClipboardText };
|
||||
const writeClipboardText = useCallback(async (text: string): Promise<boolean> => {
|
||||
const bridge = netcattyBridge.get();
|
||||
if (!bridge?.writeClipboardText) return false;
|
||||
return await bridge.writeClipboardText(text) === true;
|
||||
}, []);
|
||||
|
||||
return { readClipboardText, writeClipboardText };
|
||||
};
|
||||
@@ -67,6 +67,7 @@ import {
|
||||
STORAGE_KEY_EXPLORER_CONTEXT_MENU_ENABLED,
|
||||
STORAGE_KEY_TOGGLE_WINDOW_HOTKEY,
|
||||
STORAGE_KEY_CLOSE_TO_TRAY,
|
||||
STORAGE_KEY_SHOW_TRAY_ICON,
|
||||
STORAGE_KEY_AUTO_LAUNCH_ENABLED,
|
||||
STORAGE_KEY_HTTP_NETWORK_PROXY,
|
||||
STORAGE_KEY_GLOBAL_HOTKEY_ENABLED,
|
||||
@@ -550,6 +551,13 @@ export const useSettingsState = (options: { enableSettingsSync?: boolean; enable
|
||||
if (stored === null) return true;
|
||||
return stored === 'true';
|
||||
});
|
||||
// Preserve the old tray visibility for existing users until they set this
|
||||
// independently. Fresh installs still default to showing the icon.
|
||||
const [showTrayIcon, setShowTrayIcon] = useState<boolean>(() => {
|
||||
const stored = readStoredString(STORAGE_KEY_SHOW_TRAY_ICON);
|
||||
if (stored === null) return closeToTray;
|
||||
return stored === 'true';
|
||||
});
|
||||
// Optimistic cache for first paint; useSystemSettingsEffects hydrates this
|
||||
// from app.getLoginItemSettings() (the real source of truth) on mount.
|
||||
const [autoLaunchEnabled, setAutoLaunchEnabled] = useState<boolean>(() => {
|
||||
@@ -1791,6 +1799,7 @@ export const useSettingsState = (options: { enableSettingsSync?: boolean; enable
|
||||
toggleWindowHotkey,
|
||||
globalHotkeyEnabled,
|
||||
closeToTray,
|
||||
showTrayIcon,
|
||||
autoLaunchEnabled,
|
||||
windowOpacityRecord,
|
||||
windowOpacityMutationSourceRef,
|
||||
@@ -2156,6 +2165,8 @@ export const useSettingsState = (options: { enableSettingsSync?: boolean; enable
|
||||
setToggleWindowHotkey,
|
||||
closeToTray,
|
||||
setCloseToTray,
|
||||
showTrayIcon,
|
||||
setShowTrayIcon,
|
||||
autoLaunchEnabled,
|
||||
setAutoLaunchEnabled,
|
||||
autoLaunchSupported,
|
||||
|
||||
@@ -100,7 +100,7 @@ export const hasGroupTelnetFields = (c: Partial<GroupConfig>): boolean =>
|
||||
export const hasGroupSshFields = (c: Partial<GroupConfig>): boolean =>
|
||||
c.protocol === 'ssh' ||
|
||||
c.port !== undefined || !!c.username || !!c.password || !!c.identityFileId ||
|
||||
c.deviceType !== undefined || c.singleChannelSsh !== undefined ||
|
||||
c.deviceType !== undefined ||
|
||||
c.agentForwarding !== undefined || c.authMethod !== undefined || c.identityId !== undefined ||
|
||||
!!c.proxyProfileId || !!c.proxyConfig || !!c.hostChain || !!c.startupCommand || c.startupCommandRunMode !== undefined || c.legacyAlgorithms !== undefined || c.skipEcdsaHostKey !== undefined || c.algorithms !== undefined || c.backspaceBehavior !== undefined ||
|
||||
Boolean(c.environmentVariables && c.environmentVariables.length > 0) ||
|
||||
@@ -260,7 +260,6 @@ const GroupDetailsPanel: React.FC<GroupDetailsPanelPropsWithResize> = ({
|
||||
delete next.identityFileId;
|
||||
delete next.identityFilePaths;
|
||||
delete next.deviceType;
|
||||
delete next.singleChannelSsh;
|
||||
delete next.agentForwarding;
|
||||
delete next.startupCommand;
|
||||
delete next.startupCommandRunMode;
|
||||
@@ -503,11 +502,6 @@ const GroupDetailsPanel: React.FC<GroupDetailsPanelPropsWithResize> = ({
|
||||
return resolveGroupDefaults(parentGroup, groupConfigs).deviceType;
|
||||
}, [groupConfigs, parentGroup]);
|
||||
const effectiveDeviceType = form.deviceType ?? inheritedDeviceType;
|
||||
const inheritedSingleChannelSsh = useMemo(() => {
|
||||
if (!parentGroup || groupConfigs.length === 0) return false;
|
||||
return !!resolveGroupDefaults(parentGroup, groupConfigs).singleChannelSsh;
|
||||
}, [groupConfigs, parentGroup]);
|
||||
const effectiveSingleChannelSsh = form.singleChannelSsh ?? inheritedSingleChannelSsh;
|
||||
const effectiveThemeId = form.themeOverride === false
|
||||
? inheritedThemeId
|
||||
: (form.theme || inheritedThemeId);
|
||||
@@ -567,7 +561,6 @@ const GroupDetailsPanel: React.FC<GroupDetailsPanelPropsWithResize> = ({
|
||||
...(form.identityFileId !== undefined && { identityFileId: form.identityFileId }),
|
||||
...(form.identityFilePaths !== undefined && { identityFilePaths: form.identityFilePaths }),
|
||||
...(form.deviceType !== undefined && { deviceType: form.deviceType }),
|
||||
...(form.singleChannelSsh !== undefined && { singleChannelSsh: form.singleChannelSsh }),
|
||||
...(form.agentForwarding !== undefined && { agentForwarding: form.agentForwarding }),
|
||||
...(form.startupCommand !== undefined && { startupCommand: form.startupCommand }),
|
||||
...(form.startupCommandRunMode !== undefined && { startupCommandRunMode: form.startupCommandRunMode }),
|
||||
@@ -807,20 +800,6 @@ const GroupDetailsPanel: React.FC<GroupDetailsPanelPropsWithResize> = ({
|
||||
</p>
|
||||
</div>
|
||||
)}
|
||||
<ToggleRow
|
||||
label={t("hostDetails.singleChannelSsh")}
|
||||
hint={t("hostDetails.singleChannelSsh.desc")}
|
||||
enabled={!!effectiveSingleChannelSsh}
|
||||
onToggle={() => update("singleChannelSsh", !effectiveSingleChannelSsh)}
|
||||
/>
|
||||
{!!effectiveSingleChannelSsh && (
|
||||
<div className="flex items-start gap-2 p-2 rounded-md bg-yellow-500/10 border border-yellow-500/20">
|
||||
<AlertTriangle size={14} className="text-yellow-500 mt-0.5 flex-shrink-0" />
|
||||
<p className="text-xs text-yellow-600 dark:text-yellow-400 break-words">
|
||||
{t("hostDetails.singleChannelSsh.warning")}
|
||||
</p>
|
||||
</div>
|
||||
)}
|
||||
</HostDetailsSection>
|
||||
)}
|
||||
|
||||
|
||||
@@ -57,8 +57,6 @@ export const HostDetailsAdvancedSections: React.FC<HostDetailsAdvancedSectionsPr
|
||||
}) => {
|
||||
const inheritedDeviceType = effectiveGroupDefaults?.deviceType;
|
||||
const effectiveDeviceType = form.deviceType ?? inheritedDeviceType;
|
||||
const inheritedSingleChannelSsh = effectiveGroupDefaults?.singleChannelSsh;
|
||||
const effectiveSingleChannelSsh = form.singleChannelSsh ?? inheritedSingleChannelSsh;
|
||||
const inheritedStartupCommandRunMode = effectiveGroupDefaults?.startupCommandRunMode ?? "paste";
|
||||
const effectiveStartupCommandRunMode = form.startupCommandRunMode ?? inheritedStartupCommandRunMode;
|
||||
const systemSshAgentSupported = effectiveAuthMethod === "auto" || effectiveAuthMethod === "key";
|
||||
@@ -373,20 +371,6 @@ export const HostDetailsAdvancedSections: React.FC<HostDetailsAdvancedSectionsPr
|
||||
</p>
|
||||
</div>
|
||||
)}
|
||||
<ToggleRow
|
||||
label={t("hostDetails.singleChannelSsh")}
|
||||
hint={t("hostDetails.singleChannelSsh.desc")}
|
||||
enabled={!!effectiveSingleChannelSsh}
|
||||
onToggle={() => update("singleChannelSsh", !effectiveSingleChannelSsh)}
|
||||
/>
|
||||
{!!effectiveSingleChannelSsh && (
|
||||
<div className="flex items-start gap-2 p-2 rounded-md bg-yellow-500/10 border border-yellow-500/20">
|
||||
<AlertTriangle size={14} className="text-yellow-500 mt-0.5 flex-shrink-0" />
|
||||
<p className="text-xs text-yellow-600 dark:text-yellow-400 break-words">
|
||||
{t("hostDetails.singleChannelSsh.warning")}
|
||||
</p>
|
||||
</div>
|
||||
)}
|
||||
</>)}
|
||||
<Collapsible>
|
||||
<CollapsibleTrigger asChild>
|
||||
|
||||
@@ -677,6 +677,8 @@ const SettingsPageContent: React.FC<{ settings: SettingsState; appLock?: AppLock
|
||||
setToggleWindowHotkey={settings.setToggleWindowHotkey}
|
||||
closeToTray={settings.closeToTray}
|
||||
setCloseToTray={settings.setCloseToTray}
|
||||
showTrayIcon={settings.showTrayIcon}
|
||||
setShowTrayIcon={settings.setShowTrayIcon}
|
||||
autoLaunchEnabled={settings.autoLaunchEnabled}
|
||||
setAutoLaunchEnabled={settings.setAutoLaunchEnabled}
|
||||
autoLaunchSupported={settings.autoLaunchSupported}
|
||||
|
||||
@@ -76,7 +76,7 @@ import {
|
||||
resolveLocateSftpPathSessionId,
|
||||
} from "../domain/sftpLocatePathInTerminal";
|
||||
import { classifyDistroId } from "../domain/host";
|
||||
import { remoteSoftwareRequiresSingleChannel } from "../domain/singleChannelSshBanner.shared.cjs";
|
||||
import { remoteSoftwareRequiresSingleChannel } from "../domain/singleChannelSshBanner.shared.mjs";
|
||||
import { useTerminalBackend } from "../application/state/useTerminalBackend";
|
||||
import { isTerminalSensitiveInputActive } from "./terminal/runtime/terminalSensitiveInputRegistry";
|
||||
import { isTerminalReadyForCommandInjection } from "./terminal/runtime/terminalCommandInjectionReadyRegistry";
|
||||
@@ -934,11 +934,8 @@ const SftpSidePanelInner: React.FC<SftpSidePanelProps> = ({
|
||||
|
||||
connectedKeyRef.current = connectionKey;
|
||||
connectedHostObjRef.current = activeHost;
|
||||
const dedicatedSftpLogin = activeHost.singleChannelSsh === true
|
||||
|| (
|
||||
sftpBannerDecision?.sessionId === activeSessionId
|
||||
&& sftpBannerDecision.singleChannel
|
||||
);
|
||||
const dedicatedSftpLogin = sftpBannerDecision?.sessionId === activeSessionId
|
||||
&& sftpBannerDecision.singleChannel;
|
||||
const reuseTerminalTransport = !dedicatedSftpLogin && (
|
||||
Boolean(pendingStrictSourceSessionId) || activeSessionStatus === "connected"
|
||||
);
|
||||
@@ -1695,7 +1692,7 @@ const SftpSidePanelInteractiveBody: React.FC<SftpSidePanelInteractiveBodyProps>
|
||||
|
||||
const canFollowTerminalCwd = useMemo(() => {
|
||||
if (!onGetTerminalCwd || !followTerminalCwdHost) return false;
|
||||
if (followTerminalCwdHost.singleChannelSsh === true || bannerRequiresSingleChannel) return false;
|
||||
if (bannerRequiresSingleChannel) return false;
|
||||
const proto = followTerminalCwdHost.protocol;
|
||||
if (proto === "local" || proto === "serial") return false;
|
||||
if (followTerminalCwdHost.id?.startsWith("local-") || followTerminalCwdHost.id?.startsWith("serial-")) return false;
|
||||
|
||||
+69
-11
@@ -1,4 +1,6 @@
|
||||
import { clearTerminalBroadcastUserInput, markTerminalBroadcastUserInput } from "./terminal/runtime/terminalPacedBroadcast";
|
||||
import { shouldBroadcastDuringSensitivePrompt } from "../domain/terminalBroadcast";
|
||||
import { STORAGE_KEY_TERMINAL_BROADCAST_PASSWORD_BYPASS } from "../infrastructure/config/storageKeys";
|
||||
import { createTerminalReflowReadingPosition } from "./terminal/terminalReflowReadingPosition";
|
||||
import { resolveHostOs } from '../domain/host';
|
||||
import { Terminal as XTerm } from "@xterm/xterm";
|
||||
@@ -38,6 +40,7 @@ import {
|
||||
type TerminalEncodingAttachConnection,
|
||||
} from "../domain/terminalEncodingPreference";
|
||||
import { resolveRestoreCwdIntent, resolveInheritedCwdIntent } from "../domain/sessionRestore";
|
||||
import { formatSessionClosedExitMessage } from "../application/state/resolveTerminalSessionExitIntent";
|
||||
import {
|
||||
buildTerminalContextReadResult,
|
||||
buildTerminalContextSnapshotText,
|
||||
@@ -836,6 +839,15 @@ const TerminalComponent: React.FC<TerminalProps> = ({
|
||||
isBroadcastEnabledRef.current = isBroadcastEnabled;
|
||||
onBroadcastInputRef.current = onBroadcastInput;
|
||||
|
||||
// Opt-in "broadcast without password protection" (#3488): when enabled, the
|
||||
// fail-closed password-prompt heuristic no longer pauses broadcast fan-out.
|
||||
const [broadcastPasswordBypass] = useStoredBoolean(
|
||||
STORAGE_KEY_TERMINAL_BROADCAST_PASSWORD_BYPASS,
|
||||
false,
|
||||
);
|
||||
const broadcastPasswordBypassRef = useRef(broadcastPasswordBypass);
|
||||
broadcastPasswordBypassRef.current = broadcastPasswordBypass;
|
||||
|
||||
// Snippets ref for shortkey support in terminal
|
||||
const snippetsRef = useRef(snippets);
|
||||
snippetsRef.current = snippets;
|
||||
@@ -1066,8 +1078,17 @@ const TerminalComponent: React.FC<TerminalProps> = ({
|
||||
}
|
||||
|
||||
// Broadcast to other sessions if broadcast mode is enabled
|
||||
if (!sensitive && isBroadcastEnabledRef.current && onBroadcastInputRef.current) {
|
||||
onBroadcastInputRef.current(text, sessionId);
|
||||
if (
|
||||
shouldBroadcastDuringSensitivePrompt({
|
||||
sensitivePromptActive: sensitive,
|
||||
broadcastPasswordBypass: broadcastPasswordBypassRef.current,
|
||||
})
|
||||
&& isBroadcastEnabledRef.current
|
||||
&& onBroadcastInputRef.current
|
||||
) {
|
||||
// Bypassed password fan-out (#3488): retain the sensitive marker so
|
||||
// peer writes keep skipping input interceptors.
|
||||
onBroadcastInputRef.current(text, sessionId, sensitive ? { sourceSensitive: true } : undefined);
|
||||
}
|
||||
|
||||
// ESC-prefixed writes (Esc+. yank-last-arg) are shell editor commands.
|
||||
@@ -1133,7 +1154,6 @@ const TerminalComponent: React.FC<TerminalProps> = ({
|
||||
systemUnknown: resolvedAutocompleteOs === 'unknown',
|
||||
isNetworkDevice: host.deviceType === 'network'
|
||||
|| classifyDistroId(host.distro) === 'network-device',
|
||||
restrictPtyRewrites: host.singleChannelSsh === true,
|
||||
});
|
||||
|
||||
const resolveSftpInitialPath = useCallback(async (options?: {
|
||||
@@ -2047,7 +2067,7 @@ const TerminalComponent: React.FC<TerminalProps> = ({
|
||||
if (evt.error) {
|
||||
setError(evt.error);
|
||||
}
|
||||
const exitMessage = `\r\n[session closed${evt?.exitCode !== undefined ? ` (code ${evt.exitCode})` : ""}]`;
|
||||
const exitMessage = formatSessionClosedExitMessage(evt);
|
||||
hibernatePendingBufferRef.current = hibernatePendingCapDisabledRef.current
|
||||
? hibernatePendingBufferRef.current + exitMessage
|
||||
: appendHibernatePendingBuffer(
|
||||
@@ -3278,15 +3298,26 @@ const TerminalComponent: React.FC<TerminalProps> = ({
|
||||
|
||||
const broadcastUserPasteData = useCallback((
|
||||
data: string,
|
||||
options?: { lineDelayMs?: number },
|
||||
options?: { lineDelayMs?: number; sensitive?: boolean },
|
||||
) => {
|
||||
if (
|
||||
!passwordPromptActiveRef.current
|
||||
shouldBroadcastDuringSensitivePrompt({
|
||||
sensitivePromptActive: passwordPromptActiveRef.current,
|
||||
broadcastPasswordBypass: broadcastPasswordBypassRef.current,
|
||||
})
|
||||
&& sessionRef.current
|
||||
&& isBroadcastEnabledRef.current
|
||||
&& onBroadcastInputRef.current
|
||||
) {
|
||||
onBroadcastInputRef.current(data, sessionId, options);
|
||||
// Bypassed password fan-out (#3488): retain the sensitive marker so
|
||||
// peer writes keep skipping input interceptors. A paste confirmed at
|
||||
// a sensitive prompt keeps its pre-dialog snapshot (#3491), so its
|
||||
// carried sensitivity tags the fan-out even when the dialog await
|
||||
// cleared the live prompt ref.
|
||||
const sourceSensitive = passwordPromptActiveRef.current || options?.sensitive === true;
|
||||
onBroadcastInputRef.current(data, sessionId, sourceSensitive
|
||||
? { ...options, sourceSensitive: true }
|
||||
: options);
|
||||
return true;
|
||||
}
|
||||
return false;
|
||||
@@ -3300,6 +3331,10 @@ const TerminalComponent: React.FC<TerminalProps> = ({
|
||||
multiLineRunMode?: Snippet["multiLineRunMode"];
|
||||
/** When false, skip term.focus() so multi-tab fan-out does not steal focus. */
|
||||
focus?: boolean;
|
||||
/** Force sensitive classification on the write (bypassed password fan-out, #3488). */
|
||||
sensitive?: boolean;
|
||||
/** Observer for the broadcast recipients that received the payload (#3491). */
|
||||
onBroadcastDelivered?: (sessionIds: readonly string[]) => void;
|
||||
},
|
||||
): Promise<boolean> => {
|
||||
// Hidden-tab hibernation clears termRef. Wake via the *connected* path so
|
||||
@@ -3354,13 +3389,32 @@ const TerminalComponent: React.FC<TerminalProps> = ({
|
||||
// without re-wrapping. Without broadcasting at all, accepting a snippet in
|
||||
// broadcast mode would clear peer input (the clear keystrokes already go
|
||||
// through the broadcast-aware path) but never send the command.
|
||||
const sensitive = passwordPromptActiveRef.current;
|
||||
if (!sensitive && options?.broadcast !== false && isBroadcastEnabledRef.current && onBroadcastInputRef.current) {
|
||||
onBroadcastInputRef.current(data, sessionId, {
|
||||
// A bypassed password fan-out (#3488) forces the sensitive marker even
|
||||
// when this target has not itself classified its prompt as sensitive.
|
||||
const sensitive = passwordPromptActiveRef.current || options?.sensitive === true;
|
||||
if (
|
||||
shouldBroadcastDuringSensitivePrompt({
|
||||
sensitivePromptActive: sensitive,
|
||||
broadcastPasswordBypass: broadcastPasswordBypassRef.current,
|
||||
})
|
||||
&& options?.broadcast !== false
|
||||
&& isBroadcastEnabledRef.current
|
||||
&& onBroadcastInputRef.current
|
||||
) {
|
||||
const broadcastDeliveredSessionIds = onBroadcastInputRef.current(data, sessionId, {
|
||||
automated: true,
|
||||
noAutoRun,
|
||||
// Bypassed password fan-out (#3488): retain the sensitive marker so
|
||||
// peer writes keep skipping input interceptors.
|
||||
...(sensitive ? { sourceSensitive: true } : {}),
|
||||
...(lineDelayMs ? { lineDelayMs } : {}),
|
||||
});
|
||||
// #3491: report the sessions that received the fan-out so callers (the
|
||||
// solo compose bar) can detect a payload delivered into a sensitive
|
||||
// peer prompt and keep it out of send history.
|
||||
if (broadcastDeliveredSessionIds) {
|
||||
options?.onBroadcastDelivered?.(broadcastDeliveredSessionIds);
|
||||
}
|
||||
}
|
||||
|
||||
data = prepareProgrammaticSudoInput(data);
|
||||
@@ -3390,6 +3444,7 @@ const TerminalComponent: React.FC<TerminalProps> = ({
|
||||
await runAutomationScript({
|
||||
snippet,
|
||||
sessionId,
|
||||
initiatedBy: 'user',
|
||||
sessionMeta: {
|
||||
connected: true,
|
||||
name: scriptSessionName,
|
||||
@@ -3429,6 +3484,7 @@ const TerminalComponent: React.FC<TerminalProps> = ({
|
||||
isBroadcastEnabledRef,
|
||||
onBroadcastInputRef,
|
||||
passwordPromptActiveRef,
|
||||
broadcastPasswordBypassRef,
|
||||
isLocalConnection,
|
||||
supportsRemoteImagePaste,
|
||||
autoUploadClipboardImageOnPasteRef,
|
||||
@@ -4019,6 +4075,7 @@ const TerminalComponent: React.FC<TerminalProps> = ({
|
||||
terminalBackend,
|
||||
isSensitiveInput: () => passwordPromptActiveRef.current,
|
||||
scrollOnPasteRef,
|
||||
broadcastPasswordBypassRef,
|
||||
onPasteData: broadcastUserPasteData,
|
||||
scrollToBottomAfterProgrammaticInput,
|
||||
containerRef,
|
||||
@@ -4261,6 +4318,7 @@ const TerminalComponent: React.FC<TerminalProps> = ({
|
||||
onOpenExternalError,
|
||||
isBroadcastEnabledRef,
|
||||
onBroadcastInputRef,
|
||||
broadcastPasswordBypassRef,
|
||||
snippetsRef,
|
||||
onSnippetShortkeyRef,
|
||||
sessionId,
|
||||
@@ -4567,7 +4625,7 @@ const TerminalComponent: React.FC<TerminalProps> = ({
|
||||
onWake: wakeFromHibernateRuntime,
|
||||
});
|
||||
|
||||
useTerminalEffects({ CONNECTION_TIMEOUT, Error, XTERM_PERFORMANCE_CONFIG, applyUserCursorPreference, applyUserCursorWidthPreference, shouldApplyUserCursorPreference, snapshotUserCursorPreference, auth, autocompleteCloseRef, autocompleteInputRef, autocompleteKeyEventRef, autocompleteRepositionRef, captureTerminalLogData, chainHosts: resolvedChainHosts, chainProgress, clearTerminalCwd, commandBufferRef, connectionLogBufferRef, containerRef, createPromptLineBreakState, createReplaySafeTerminalLogSanitizer, createXTermRuntime, deferTerminalResizeRef, disableTerminalFontZoomRef, effectiveFontSize, effectiveFontWeight, effectiveTheme, error, executeSnippetCommand, finalizeTerminalLogData, fitAddonRef, fontFamilyId, fontSize, fontWeightFixupDoneRef, forceCloseHibernatedSession, forceSyncRenderAfterResize, handleOsc52ReadRequest, handleTerminalDataCaptureOnce, hasConnectedRef, hasRuntimeRef, host, hotkeySchemeRef, hibernatedRef, identities, inWorkspace, isBootActiveRef, bootEpochRef, isBroadcastEnabledRef, isComposeBarOpen: effectiveComposeBarOpen, isConnectionAwaitingUserInput, isConnectionPastTcpDial, isFocusMode, isFocused, isLocalConnection, isNetworkDevice, isResizing: deferTerminalResize, isRestoringSelectionRef, isSearchOpen, isSerialConnection, isVisible, isVisibleRef, keyBindingsRef, keys, kittyKeyboardProtocolEnabledForSession, knownCwdRef, lastFittedSizeRef, lastToastedErrorRef, logger, mouseTrackingRef, needsHostKeyVerification, onBroadcastInputRef, onBroadcastInterruptPriorityChange, onCommandExecuted, onCommandSubmitted: cwdAwareOnCommandSubmitted, onHotkeyActionRef, onOpenExternalError, onOutputTriggerUserInputRef: noteOutputTriggerUserInputRef, onPluginRuntimeCwdChange: pluginAwareOnRuntimeCwdChange, onSnippetShortkeyRef, onSnippetExecutorChange, onTerminalCwdChange, onTerminalTitleChange, onTerminalBell, onTerminalFontSizeChange, paneLayoutKey, passwordPromptActiveRef, pendingAuthRef, pendingOutputScrollRef, pluginDecorationRefreshRef, pluginDecorationRules, pluginDecorationRulesRef, pluginTerminalLifecycle, pluginTerminalProviderRevision, isPluginTerminalProviderAvailable, requestPluginTerminalProviders, prepareRestoredReconnect, prepareInitialCwdIntent, prevIsResizingRef, promptLineBreakStateRef, resizeSession, resolveHostAuth, resolvedFontFamily, safeFit, scriptRecorderRef: recorderRef, searchAddonRef, serialConfig, serialLineBufferRef, serializeAddonRef, sessionId, sessionRef, sessionStarters, setError, setHasMouseTracking, setIsCancelling, setIsDisconnectedDialogDismissed, requestSearchFocus, setNeedsHostKeyVerification, setPendingHostKeyInfo, setPendingHostKeyRequestId, setProgressLogs, setProgressValue, setShowLogs, setStatus, setTimeLeft, shellType, shouldEnableNativeUserInputAutoScroll, shouldProbeSessionCwd, shouldStartTerminalBackend, vaultInitialized, attachExistingSession, attachAuthorization, attachHomeWebContentsIdRef, snippetsRef, splitResizeActive: isResizing, status, statusRef, sudoAutofillRef, t, teardown, telnetLocalEchoRef, termRef, terminalAltKeyOptions, terminalBackend, terminalContextActionsRef, terminalCwdTracker, terminalDataCapturedRef, terminalLogSanitizerRef, terminalOutputHistory: terminalOutputHistoryRef.current, terminalSettings, terminalSettingsRef, terminalTitleRef, toHostKeyInfo, toast, updateStatus, useEffect, useLayoutEffect, workspaceId, xtermRuntimeRef, zmodem, zmodemToastedRef, restoreState });
|
||||
useTerminalEffects({ CONNECTION_TIMEOUT, Error, XTERM_PERFORMANCE_CONFIG, applyUserCursorPreference, applyUserCursorWidthPreference, shouldApplyUserCursorPreference, snapshotUserCursorPreference, auth, autocompleteCloseRef, autocompleteInputRef, autocompleteKeyEventRef, autocompleteRepositionRef, captureTerminalLogData, chainHosts: resolvedChainHosts, chainProgress, clearTerminalCwd, commandBufferRef, connectionLogBufferRef, containerRef, createPromptLineBreakState, createReplaySafeTerminalLogSanitizer, createXTermRuntime, deferTerminalResizeRef, disableTerminalFontZoomRef, effectiveFontSize, effectiveFontWeight, effectiveTheme, error, executeSnippetCommand, finalizeTerminalLogData, fitAddonRef, fontFamilyId, fontSize, fontWeightFixupDoneRef, forceCloseHibernatedSession, forceSyncRenderAfterResize, handleOsc52ReadRequest, handleTerminalDataCaptureOnce, hasConnectedRef, hasRuntimeRef, host, hotkeySchemeRef, hibernatedRef, identities, inWorkspace, isBootActiveRef, bootEpochRef, isBroadcastEnabledRef, broadcastPasswordBypassRef, isComposeBarOpen: effectiveComposeBarOpen, isConnectionAwaitingUserInput, isConnectionPastTcpDial, isFocusMode, isFocused, isLocalConnection, isNetworkDevice, isResizing: deferTerminalResize, isRestoringSelectionRef, isSearchOpen, isSerialConnection, isVisible, isVisibleRef, keyBindingsRef, keys, kittyKeyboardProtocolEnabledForSession, knownCwdRef, lastFittedSizeRef, lastToastedErrorRef, logger, mouseTrackingRef, needsHostKeyVerification, onBroadcastInputRef, onBroadcastInterruptPriorityChange, onCommandExecuted, onCommandSubmitted: cwdAwareOnCommandSubmitted, onHotkeyActionRef, onOpenExternalError, onOutputTriggerUserInputRef: noteOutputTriggerUserInputRef, onPluginRuntimeCwdChange: pluginAwareOnRuntimeCwdChange, onSnippetShortkeyRef, onSnippetExecutorChange, onTerminalCwdChange, onTerminalTitleChange, onTerminalBell, onTerminalFontSizeChange, paneLayoutKey, passwordPromptActiveRef, pendingAuthRef, pendingOutputScrollRef, pluginDecorationRefreshRef, pluginDecorationRules, pluginDecorationRulesRef, pluginTerminalLifecycle, pluginTerminalProviderRevision, isPluginTerminalProviderAvailable, requestPluginTerminalProviders, prepareRestoredReconnect, prepareInitialCwdIntent, prevIsResizingRef, promptLineBreakStateRef, resizeSession, resolveHostAuth, resolvedFontFamily, safeFit, scriptRecorderRef: recorderRef, searchAddonRef, serialConfig, serialLineBufferRef, serializeAddonRef, sessionId, sessionRef, sessionStarters, setError, setHasMouseTracking, setIsCancelling, setIsDisconnectedDialogDismissed, requestSearchFocus, setNeedsHostKeyVerification, setPendingHostKeyInfo, setPendingHostKeyRequestId, setProgressLogs, setProgressValue, setShowLogs, setStatus, setTimeLeft, shellType, shouldEnableNativeUserInputAutoScroll, shouldProbeSessionCwd, shouldStartTerminalBackend, vaultInitialized, attachExistingSession, attachAuthorization, attachHomeWebContentsIdRef, snippetsRef, splitResizeActive: isResizing, status, statusRef, sudoAutofillRef, t, teardown, telnetLocalEchoRef, termRef, terminalAltKeyOptions, terminalBackend, terminalContextActionsRef, terminalCwdTracker, terminalDataCapturedRef, terminalLogSanitizerRef, terminalOutputHistory: terminalOutputHistoryRef.current, terminalSettings, terminalSettingsRef, terminalTitleRef, toHostKeyInfo, toast, updateStatus, useEffect, useLayoutEffect, workspaceId, xtermRuntimeRef, zmodem, zmodemToastedRef, restoreState });
|
||||
|
||||
return (
|
||||
<>
|
||||
|
||||
@@ -27,6 +27,7 @@ import { detectLocalOs } from '../lib/localShell';
|
||||
import { useStoredString } from '../application/state/useStoredString';
|
||||
import { useStoredNumber } from '../application/state/useStoredNumber';
|
||||
import { useStoredBoolean } from '../application/state/useStoredBoolean';
|
||||
import { STORAGE_KEY_TERMINAL_BROADCAST_PASSWORD_BYPASS } from '../infrastructure/config/storageKeys';
|
||||
import {
|
||||
STORAGE_KEY_SIDE_PANEL_HEIGHT,
|
||||
STORAGE_KEY_SIDE_PANEL_WIDTH,
|
||||
@@ -741,6 +742,14 @@ const TerminalLayerInner: React.FC<TerminalLayerProps> = ({
|
||||
STORAGE_KEY_TERMINAL_COMPOSE_BAR_OPEN,
|
||||
false,
|
||||
);
|
||||
// Opt-in "broadcast without password protection" (#3488): when enabled, the
|
||||
// fail-closed password-prompt heuristic no longer pauses workspace broadcast.
|
||||
const [broadcastPasswordBypass] = useStoredBoolean(
|
||||
STORAGE_KEY_TERMINAL_BROADCAST_PASSWORD_BYPASS,
|
||||
false,
|
||||
);
|
||||
const broadcastPasswordBypassRef = useRef(broadcastPasswordBypass);
|
||||
broadcastPasswordBypassRef.current = broadcastPasswordBypass;
|
||||
const sessionsRef = useRef(sessions);
|
||||
sessionsRef.current = sessions;
|
||||
const workspacesRef = useRef(workspaces);
|
||||
@@ -1241,10 +1250,13 @@ const TerminalLayerInner: React.FC<TerminalLayerProps> = ({
|
||||
options?: TerminalBroadcastInputOptions,
|
||||
) => {
|
||||
const paced = options?.pacedBroadcast;
|
||||
// Opt-in #3488 bypass: when enabled, sensitive-prompt targets stay in the
|
||||
// fan-out instead of being silently dropped here.
|
||||
const passwordBypass = broadcastPasswordBypassRef.current === true;
|
||||
if (paced && !options?.preparePacedBroadcast) {
|
||||
if (!paced.targets || isTerminalSensitiveInputActive(sourceSessionId)) return [];
|
||||
if (!paced.targets || (!passwordBypass && isTerminalSensitiveInputActive(sourceSessionId))) return [];
|
||||
paced.targets = paced.targets.filter(target => isTerminalBroadcastInputCurrent(target)
|
||||
&& !isTerminalSensitiveInputActive(target.sessionId));
|
||||
&& (passwordBypass || !isTerminalSensitiveInputActive(target.sessionId)));
|
||||
}
|
||||
const targetSessionIds = resolveTerminalBroadcastTargetIds({
|
||||
sessions: sessionsRef.current,
|
||||
@@ -1253,7 +1265,7 @@ const TerminalLayerInner: React.FC<TerminalLayerProps> = ({
|
||||
directTargetSessionIds: options?.kittyKeyboardTargetSessionIds,
|
||||
}).filter(id => !paced?.targets || paced.targets.some(target => target.sessionId === id));
|
||||
if (options?.preparePacedBroadcast && paced) {
|
||||
paced.targets = targetSessionIds.filter(id => !isTerminalSensitiveInputActive(id)).map(id => {
|
||||
paced.targets = targetSessionIds.filter(id => passwordBypass || !isTerminalSensitiveInputActive(id)).map(id => {
|
||||
markTerminalBroadcastUserInput(id);
|
||||
terminalBackend.interruptSession(id, undefined, { cancelPendingWritesOnly: true });
|
||||
return captureTerminalBroadcastInput(id);
|
||||
@@ -1275,6 +1287,9 @@ const TerminalLayerInner: React.FC<TerminalLayerProps> = ({
|
||||
beforeUrgentInterrupt: () => {
|
||||
broadcastInterruptPrioritizersRef.current.get(session.id)?.();
|
||||
},
|
||||
// Bypassed password fan-out (#3488): the receiver forces sensitive
|
||||
// writes so its input interceptors stay skipped for the secret.
|
||||
...(options?.sourceSensitive === true ? { sourceSensitive: true } : {}),
|
||||
});
|
||||
deliveredSessionIds.push(session.id);
|
||||
continue;
|
||||
@@ -1297,10 +1312,15 @@ const TerminalLayerInner: React.FC<TerminalLayerProps> = ({
|
||||
terminalBackend.interruptSession(session.id);
|
||||
continue;
|
||||
}
|
||||
if (isTerminalSensitiveInputActive(session.id)) continue;
|
||||
if (!passwordBypass && isTerminalSensitiveInputActive(session.id)) continue;
|
||||
terminalBackend.writeToSession(session.id, data, {
|
||||
automated: options?.automated === true,
|
||||
sensitive: false,
|
||||
// Retain the source-sensitive marker (bypassed password fan-out, #3488)
|
||||
// so peer input interceptors stay skipped for the secret payload. A
|
||||
// target that is itself awaiting a secret keeps its classification:
|
||||
// bypassing the fan-out pause must not downgrade its own sensitive
|
||||
// state (#3491).
|
||||
sensitive: options?.sourceSensitive === true || isTerminalSensitiveInputActive(session.id),
|
||||
...(lineDelayMs ? { lineDelayMs } : {}),
|
||||
});
|
||||
deliveredSessionIds.push(session.id);
|
||||
@@ -2135,6 +2155,7 @@ const TerminalLayerInner: React.FC<TerminalLayerProps> = ({
|
||||
await runAutomationScript({
|
||||
snippet,
|
||||
sessionId,
|
||||
initiatedBy: 'user',
|
||||
sessionMeta: buildScriptSessionMeta(sessionId, sessionsRef.current, hosts),
|
||||
});
|
||||
} catch (err) {
|
||||
@@ -2160,6 +2181,7 @@ const TerminalLayerInner: React.FC<TerminalLayerProps> = ({
|
||||
await runAutomationScript({
|
||||
snippet,
|
||||
sessionId,
|
||||
initiatedBy: 'user',
|
||||
sessionMeta: buildScriptSessionMeta(sessionId, sessionsRef.current, hosts),
|
||||
});
|
||||
} catch (err) {
|
||||
@@ -2245,6 +2267,7 @@ const TerminalLayerInner: React.FC<TerminalLayerProps> = ({
|
||||
await runAutomationScript({
|
||||
snippet,
|
||||
sessionId,
|
||||
initiatedBy: 'user',
|
||||
sessionMeta: buildScriptSessionMeta(sessionId, sessionsRef.current, hosts),
|
||||
});
|
||||
} catch (err) {
|
||||
@@ -2314,6 +2337,7 @@ const TerminalLayerInner: React.FC<TerminalLayerProps> = ({
|
||||
const runOnSession = (sid: string) => runAutomationScript({
|
||||
snippet,
|
||||
sessionId: sid,
|
||||
initiatedBy: 'user',
|
||||
sessionMeta: buildScriptSessionMeta(sid, sessionsRef.current, hosts),
|
||||
});
|
||||
if (mode === 'sequential') {
|
||||
@@ -2363,29 +2387,55 @@ const TerminalLayerInner: React.FC<TerminalLayerProps> = ({
|
||||
const activeWorkspace = activeWorkspaceRef.current;
|
||||
if (!activeWorkspace) return false;
|
||||
let recordHistory = false;
|
||||
// Any recipient at a sensitive prompt turns the fan-out into a secret
|
||||
// delivery: the payload is that peer's password/MFA input and must not be
|
||||
// recallable from compose history, even if the focused session itself is
|
||||
// non-sensitive.
|
||||
let anyRecipientSensitive = false;
|
||||
const pendingSends: Promise<boolean>[] = [];
|
||||
const payload = text + '\r';
|
||||
const broadcastEnabled = isBroadcastEnabled?.(activeWorkspace.id);
|
||||
const focusedSessionId = activeWorkspace.focusedSessionId;
|
||||
const broadcastPasswordBypass = broadcastPasswordBypassRef.current;
|
||||
const focusedSensitive = focusedSessionId
|
||||
? isTerminalSensitiveInputActive(focusedSessionId)
|
||||
: false;
|
||||
|
||||
if (broadcastEnabled && !focusedSensitive) {
|
||||
if (broadcastEnabled && (broadcastPasswordBypass || !focusedSensitive)) {
|
||||
const allSessionIds = sessionsRef.current
|
||||
.filter((session) => session.workspaceId === activeWorkspace.id)
|
||||
.map((session) => session.id);
|
||||
for (const sid of allSessionIds) {
|
||||
if (isTerminalSensitiveInputActive(sid)) continue;
|
||||
const recipientSensitive = isTerminalSensitiveInputActive(sid);
|
||||
if (!broadcastPasswordBypass && recipientSensitive) continue;
|
||||
const executor = snippetExecutorsRef.current.get(sid);
|
||||
if (executor) {
|
||||
pendingSends.push(Promise.resolve(executor(text, false, { broadcast: false })).then(
|
||||
(sent) => sent && !isTerminalSensitiveInputActive(sid),
|
||||
pendingSends.push(Promise.resolve(executor(text, false, {
|
||||
broadcast: false,
|
||||
// Preserve both prompt snapshots across an asynchronous wake: the
|
||||
// peer may clear its prompt classification before the actual write.
|
||||
...(focusedSensitive || recipientSensitive ? { sensitive: true } : {}),
|
||||
})).then(
|
||||
(sent) => {
|
||||
// A lagging peer can reach its password prompt by delivery time;
|
||||
// anything it receives there is its secret input, so mark the
|
||||
// whole fan-out as history-ineligible.
|
||||
if (sent && (recipientSensitive || isTerminalSensitiveInputActive(sid))) anyRecipientSensitive = true;
|
||||
return sent && (broadcastPasswordBypass || !isTerminalSensitiveInputActive(sid));
|
||||
},
|
||||
));
|
||||
} else {
|
||||
const session = sessionsRef.current.find((candidate) => candidate.id === sid);
|
||||
if (!session || !canUseDirectSessionWriteFallback(session)) continue;
|
||||
terminalBackend.writeToSession(sid, payload, { sensitive: false });
|
||||
// Keep the source-sensitive marker when the #3488 bypass let a
|
||||
// password-prompt payload through, so interceptors stay skipped.
|
||||
// A non-sensitive focused pane can still fan into a sensitive peer
|
||||
// (its password/MFA input): mark the peer's write sensitive too,
|
||||
// like the broadcast dispatcher does for its executor writes.
|
||||
terminalBackend.writeToSession(sid, payload, {
|
||||
sensitive: focusedSensitive || recipientSensitive,
|
||||
});
|
||||
if (recipientSensitive) anyRecipientSensitive = true;
|
||||
recordHistory = recordHistory || session.status === 'connected';
|
||||
}
|
||||
}
|
||||
@@ -2413,7 +2463,13 @@ const TerminalLayerInner: React.FC<TerminalLayerProps> = ({
|
||||
}
|
||||
}
|
||||
const results = await Promise.allSettled(pendingSends);
|
||||
return recordHistory || results.some((result) => result.status === 'fulfilled' && result.value);
|
||||
const delivered = recordHistory || results.some((result) => result.status === 'fulfilled' && result.value);
|
||||
// A bypassed send typed at a sensitive prompt (#3488), or delivered into
|
||||
// any recipient's sensitive prompt, was delivered but must not be
|
||||
// recallable from compose history via ArrowUp, so report no history
|
||||
// eligibility for it.
|
||||
if (focusedSensitive || anyRecipientSensitive) return false;
|
||||
return delivered;
|
||||
}, [isBroadcastEnabled, terminalBackend]);
|
||||
|
||||
const sessionLogConfig = useMemo(
|
||||
|
||||
@@ -249,7 +249,7 @@ function formatToolResult(result: unknown): string {
|
||||
const obj = JSON.parse(parsed);
|
||||
if (obj && typeof obj === 'object') parsed = obj;
|
||||
} catch {
|
||||
return parsed;
|
||||
return result as string;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -272,6 +272,7 @@ function formatToolResult(result: unknown): string {
|
||||
|
||||
export interface ToolCallProps extends HTMLAttributes<HTMLDivElement> {
|
||||
name: string;
|
||||
approvalTarget?: { label: string; hostname: string };
|
||||
className?: string;
|
||||
args?: Record<string, unknown>;
|
||||
result?: unknown;
|
||||
@@ -292,6 +293,7 @@ export interface ToolCallProps extends HTMLAttributes<HTMLDivElement> {
|
||||
onAlwaysAllow?: () => void;
|
||||
/** Optional source-specific label for the persistent/session approval action. */
|
||||
alwaysAllowLabel?: string;
|
||||
alwaysAllowTitle?: string;
|
||||
}
|
||||
|
||||
async function copyTextToClipboard(text: string): Promise<boolean> {
|
||||
@@ -321,8 +323,8 @@ async function copyTextToClipboard(text: string): Promise<boolean> {
|
||||
}
|
||||
|
||||
export const ToolCall = ({
|
||||
name, args, result, isError, isLoading, isInterrupted,
|
||||
approvalStatus, approvalId, onApprove, onReject, onApproveOnce, onAlwaysAllow, alwaysAllowLabel,
|
||||
name, args, result, isError, isLoading, isInterrupted, approvalTarget,
|
||||
approvalStatus, approvalId, onApprove, onReject, onApproveOnce, onAlwaysAllow, alwaysAllowLabel, alwaysAllowTitle,
|
||||
className, ...props
|
||||
}: ToolCallProps) => {
|
||||
const { t } = useI18n();
|
||||
@@ -520,31 +522,47 @@ export const ToolCall = ({
|
||||
{statusIcon}
|
||||
</button>
|
||||
|
||||
{isPendingApproval && approvalTarget && !showApprovalCommand && (
|
||||
<div className="min-w-0 break-words border-t border-border/20 px-3 py-1.5 text-xs font-medium">
|
||||
{t('ai.chat.targetLabel')}: <span className="break-all">{approvalTarget.label}</span>
|
||||
{approvalTarget.hostname && approvalTarget.hostname !== approvalTarget.label && (
|
||||
<span className="ml-1 break-all text-muted-foreground">({approvalTarget.hostname})</span>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
|
||||
{expanded && (
|
||||
<div className="border-t border-border/20">
|
||||
{showApprovalCommand && reviewCommand && (
|
||||
<div className="px-3 py-2 space-y-1.5">
|
||||
{executionContext && (
|
||||
<div className="flex flex-wrap gap-x-3 gap-y-0.5 text-[10px] text-muted-foreground/45">
|
||||
<span className="font-medium uppercase tracking-wider text-muted-foreground/30">
|
||||
{(approvalTarget || executionContext) && (
|
||||
<div className="flex flex-wrap items-baseline gap-x-3 gap-y-0.5 text-[11px] text-muted-foreground">
|
||||
<span className="font-medium uppercase tracking-wider text-muted-foreground/60">
|
||||
{t('ai.chat.targetLabel')}
|
||||
</span>
|
||||
{executionContext.sessionId && (
|
||||
{approvalTarget ? (
|
||||
<span className="min-w-0 break-all font-medium text-foreground/90">
|
||||
{approvalTarget.label}
|
||||
{approvalTarget.hostname && approvalTarget.hostname !== approvalTarget.label && (
|
||||
<span className="ml-1 font-normal text-muted-foreground">({approvalTarget.hostname})</span>
|
||||
)}
|
||||
</span>
|
||||
) : executionContext?.sessionId && (
|
||||
<span className="font-mono truncate" title={executionContext.sessionId}>
|
||||
{t('ai.chat.approvalSession')}: {executionContext.sessionId}
|
||||
</span>
|
||||
)}
|
||||
{executionContext.shell && (
|
||||
{executionContext?.shell && (
|
||||
<span className="font-mono">
|
||||
{t('ai.chat.approvalShell')}: {executionContext.shell}
|
||||
</span>
|
||||
)}
|
||||
{executionContext.cwd && (
|
||||
{executionContext?.cwd && (
|
||||
<span className="font-mono truncate" title={executionContext.cwd}>
|
||||
{t('ai.chat.approvalCwd')}: {executionContext.cwd}
|
||||
</span>
|
||||
)}
|
||||
{executionContext.reason && (
|
||||
{executionContext?.reason && (
|
||||
<span className="truncate" title={executionContext.reason}>
|
||||
{t('ai.chat.approvalReason')}: {executionContext.reason}
|
||||
</span>
|
||||
@@ -656,6 +674,7 @@ export const ToolCall = ({
|
||||
size="sm"
|
||||
className="h-7 min-w-0 flex-1 gap-1 px-1.5 text-[11px] font-normal border-green-500/35 text-green-300/95 hover:bg-green-500/10 hover:text-green-300 hover:border-green-500/50"
|
||||
onClick={handleAlwaysAllow}
|
||||
title={alwaysAllowTitle}
|
||||
>
|
||||
<Check size={12} className="shrink-0" />
|
||||
<span className="truncate">{alwaysAllowLabel || t('ai.chat.alwaysAllow')}</span>
|
||||
|
||||
@@ -843,6 +843,7 @@ const ChatMessageList: React.FC<ChatMessageListProps> = ({
|
||||
<ToolCall
|
||||
name={req.toolName}
|
||||
args={req.args}
|
||||
approvalTarget={req.target}
|
||||
isLoading={false}
|
||||
isInterrupted={false}
|
||||
approvalStatus={'pending'}
|
||||
|
||||
@@ -6,6 +6,7 @@
|
||||
|
||||
import React, { useCallback, useEffect, useState } from 'react';
|
||||
import { ToolCall } from '../ai-elements/tool-call';
|
||||
import { Button } from '../ui/button';
|
||||
import {
|
||||
onApprovalCleared,
|
||||
onApprovalRequest,
|
||||
@@ -15,6 +16,7 @@ import {
|
||||
} from '../../infrastructure/ai/shared/approvalGate';
|
||||
import {
|
||||
buildGrantsFromApproval,
|
||||
createPermissionGrantId,
|
||||
resolveCapabilityId,
|
||||
} from '../../infrastructure/ai/harness/permissionGrants';
|
||||
import { useI18n } from '../../application/i18n/I18nProvider';
|
||||
@@ -61,7 +63,10 @@ export const ExternalMcpApprovalsHost: React.FC = () => {
|
||||
|
||||
const handleAlwaysAllow = useCallback((toolCallId: string, request: ApprovalRequest) => {
|
||||
const capabilityId = request.capabilityId ?? resolveCapabilityId(request.toolName);
|
||||
const persistGrants = buildGrantsFromApproval(capabilityId, request.args, request.chatSessionId);
|
||||
const persistGrants = buildGrantsFromApproval(capabilityId, request.args, request.chatSessionId)
|
||||
.map((grant) => request.target?.hostId
|
||||
? { ...grant, sessionPattern: `host:${request.target.hostId}` }
|
||||
: grant);
|
||||
resolveApproval(toolCallId, { approved: true, persistGrants });
|
||||
setPendingApprovals((prev) => {
|
||||
const next = new Map(prev);
|
||||
@@ -70,6 +75,35 @@ export const ExternalMcpApprovalsHost: React.FC = () => {
|
||||
});
|
||||
}, []);
|
||||
|
||||
const handleAllowSession = useCallback((toolCallId: string) => {
|
||||
resolveApproval(toolCallId, { approved: true, scope: 'session' });
|
||||
setPendingApprovals((prev) => {
|
||||
const next = new Map(prev);
|
||||
next.delete(toolCallId);
|
||||
return next;
|
||||
});
|
||||
}, []);
|
||||
|
||||
const handleAllowHost = useCallback((toolCallId: string, request: ApprovalRequest) => {
|
||||
const hostId = request.target?.hostId;
|
||||
if (!hostId) return;
|
||||
resolveApproval(toolCallId, {
|
||||
approved: true,
|
||||
persistGrant: {
|
||||
id: createPermissionGrantId(),
|
||||
capabilityId: '*',
|
||||
sessionPattern: `host:${hostId}`,
|
||||
createdAt: Date.now(),
|
||||
note: request.target?.label,
|
||||
},
|
||||
});
|
||||
setPendingApprovals((prev) => {
|
||||
const next = new Map(prev);
|
||||
next.delete(toolCallId);
|
||||
return next;
|
||||
});
|
||||
}, []);
|
||||
|
||||
const handleReject = useCallback((toolCallId: string) => {
|
||||
resolveApproval(toolCallId, false);
|
||||
setPendingApprovals((prev) => {
|
||||
@@ -84,7 +118,7 @@ export const ExternalMcpApprovalsHost: React.FC = () => {
|
||||
|
||||
return (
|
||||
<div
|
||||
className="pointer-events-auto fixed bottom-4 right-4 z-[80] flex w-[min(420px,calc(100vw-2rem))] flex-col gap-2"
|
||||
className="pointer-events-auto fixed bottom-4 right-4 z-[80] flex max-h-[calc(100vh-2rem)] w-[min(420px,calc(100vw-2rem))] flex-col gap-2 overflow-y-auto overscroll-contain"
|
||||
data-testid="external-mcp-approvals-host"
|
||||
>
|
||||
<div className="rounded-lg border border-border/60 bg-background/95 p-3 shadow-lg backdrop-blur-sm">
|
||||
@@ -93,18 +127,65 @@ export const ExternalMcpApprovalsHost: React.FC = () => {
|
||||
</div>
|
||||
<div className="space-y-2">
|
||||
{entries.map(([id, req]) => (
|
||||
<ToolCall
|
||||
key={id}
|
||||
name={req.toolName}
|
||||
args={req.args}
|
||||
isLoading={false}
|
||||
isInterrupted={false}
|
||||
approvalStatus="pending"
|
||||
approvalId={id}
|
||||
onApproveOnce={() => handleApproveOnce(id)}
|
||||
onAlwaysAllow={() => handleAlwaysAllow(id, req)}
|
||||
onReject={() => handleReject(id)}
|
||||
/>
|
||||
<div key={id} className="space-y-1.5">
|
||||
<ToolCall
|
||||
name={req.toolName}
|
||||
args={req.args}
|
||||
approvalTarget={req.target}
|
||||
isLoading={false}
|
||||
isInterrupted={false}
|
||||
approvalStatus="pending"
|
||||
approvalId={id}
|
||||
onApproveOnce={() => handleApproveOnce(id)}
|
||||
onAlwaysAllow={req.target && !req.target.hostId ? undefined : () => handleAlwaysAllow(id, req)}
|
||||
onReject={() => handleReject(id)}
|
||||
alwaysAllowLabel={req.target?.hostId ? t('ai.externalMcp.allowCommandOnHost') : undefined}
|
||||
alwaysAllowTitle={req.target?.hostId ? t('ai.externalMcp.allowCommandOnHostHint') : undefined}
|
||||
/>
|
||||
{(req.allowSession || req.target?.hostId) && (
|
||||
<div className="space-y-1.5">
|
||||
<div className="text-[10px] font-medium uppercase tracking-wide text-muted-foreground/60">
|
||||
{t('ai.externalMcp.allowAll')}
|
||||
</div>
|
||||
<div className="grid grid-cols-2 gap-1.5">
|
||||
{req.allowSession && (
|
||||
<Button
|
||||
variant="outline"
|
||||
size="sm"
|
||||
className={`h-7 min-w-0 px-2 text-[11px] ${req.target?.hostId ? '' : 'col-span-2'}`}
|
||||
title={t('ai.externalMcp.allowSessionHint')}
|
||||
onClick={() => handleAllowSession(id)}
|
||||
onKeyDown={(event) => {
|
||||
if (event.key !== 'Escape') return;
|
||||
event.preventDefault();
|
||||
event.stopPropagation();
|
||||
handleReject(id);
|
||||
}}
|
||||
>
|
||||
{t('ai.externalMcp.allowSession')}
|
||||
</Button>
|
||||
)}
|
||||
{req.target?.hostId && (
|
||||
<Button
|
||||
variant="outline"
|
||||
size="sm"
|
||||
className={`h-7 min-w-0 px-2 text-[11px] ${req.allowSession ? '' : 'col-span-2'}`}
|
||||
title={t('ai.externalMcp.allowHostHint')}
|
||||
onClick={() => handleAllowHost(id, req)}
|
||||
onKeyDown={(event) => {
|
||||
if (event.key !== 'Escape') return;
|
||||
event.preventDefault();
|
||||
event.stopPropagation();
|
||||
handleReject(id);
|
||||
}}
|
||||
>
|
||||
{t('ai.externalMcp.allowHost')}
|
||||
</Button>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
@@ -121,6 +121,8 @@ interface SettingsSystemTabProps {
|
||||
setToggleWindowHotkey: (hotkey: string) => void;
|
||||
closeToTray: boolean;
|
||||
setCloseToTray: (enabled: boolean) => void;
|
||||
showTrayIcon: boolean;
|
||||
setShowTrayIcon: (enabled: boolean) => void;
|
||||
autoLaunchEnabled: boolean;
|
||||
setAutoLaunchEnabled: (enabled: boolean) => void;
|
||||
autoLaunchSupported: boolean;
|
||||
@@ -173,6 +175,8 @@ const SettingsSystemTab: React.FC<SettingsSystemTabProps> = ({
|
||||
setToggleWindowHotkey,
|
||||
closeToTray,
|
||||
setCloseToTray,
|
||||
showTrayIcon,
|
||||
setShowTrayIcon,
|
||||
autoLaunchEnabled,
|
||||
setAutoLaunchEnabled,
|
||||
autoLaunchSupported,
|
||||
@@ -1313,6 +1317,23 @@ const SettingsSystemTab: React.FC<SettingsSystemTabProps> = ({
|
||||
onChange={setCloseToTray}
|
||||
/>
|
||||
</SettingRow>
|
||||
|
||||
{/* Show System Tray Icon */}
|
||||
<SettingRow
|
||||
anchorId="system-show-tray-icon"
|
||||
label={t("settings.globalHotkey.showTrayIcon")}
|
||||
description={t("settings.globalHotkey.showTrayIconDesc")}
|
||||
>
|
||||
<Toggle
|
||||
checked={showTrayIcon}
|
||||
onChange={setShowTrayIcon}
|
||||
/>
|
||||
</SettingRow>
|
||||
{!showTrayIcon && (
|
||||
<p className="text-sm text-muted-foreground">
|
||||
{t("settings.globalHotkey.showTrayIconHiddenHint")}
|
||||
</p>
|
||||
)}
|
||||
</SettingCard>
|
||||
|
||||
<SettingHint>
|
||||
|
||||
@@ -38,6 +38,7 @@ const GrantCapabilitySelect: React.FC<{
|
||||
options: readonly string[];
|
||||
onChange: (value: string) => void;
|
||||
}> = ({ value, options, onChange }) => {
|
||||
const { t } = useI18n();
|
||||
const selectOptions = useMemo(() => {
|
||||
if (options.includes(value)) return options;
|
||||
return [value, ...options];
|
||||
@@ -52,7 +53,7 @@ const GrantCapabilitySelect: React.FC<{
|
||||
>
|
||||
{selectOptions.map((capabilityId) => (
|
||||
<option key={capabilityId} value={capabilityId}>
|
||||
{capabilityId}
|
||||
{capabilityId === '*' ? t('ai.externalMcp.allowHost') : capabilityId}
|
||||
</option>
|
||||
))}
|
||||
</select>
|
||||
@@ -204,6 +205,11 @@ export const PermissionGrantsSettings: React.FC<{
|
||||
updateGrant(grant.id, updates);
|
||||
}}
|
||||
/>
|
||||
{grant.sessionPattern.startsWith('host:') && (
|
||||
<div className="mt-1 truncate text-[10px] text-muted-foreground" title={grant.sessionPattern}>
|
||||
{t('ai.chat.targetLabel')}: {grant.note || grant.sessionPattern.slice(5)}
|
||||
</div>
|
||||
)}
|
||||
</td>
|
||||
<td className="px-2 py-2 align-middle max-w-0">
|
||||
{supportsCommandPattern ? (
|
||||
|
||||
@@ -47,7 +47,11 @@ test("locate-path uses focused session fallback when SFTP cannot reuse the termi
|
||||
});
|
||||
|
||||
test("single-channel SFTP opens a dedicated connection instead of the terminal channel", () => {
|
||||
assert.match(sidePanelSource, /activeHost\.singleChannelSsh === true/);
|
||||
assert.match(
|
||||
sidePanelSource,
|
||||
/const dedicatedSftpLogin = sftpBannerDecision\?\.sessionId === activeSessionId\s*&& sftpBannerDecision\.singleChannel;/,
|
||||
);
|
||||
assert.doesNotMatch(sidePanelSource, /activeHost\.singleChannelSsh/);
|
||||
assert.match(sidePanelSource, /reuseTransport: false/);
|
||||
assert.match(
|
||||
sidePanelSource,
|
||||
|
||||
@@ -16,8 +16,9 @@ test("SFTP side panel rebinds after same-tab SSH start-over", () => {
|
||||
sidePanelSource,
|
||||
/previousStatus:\s*lastSourceSessionStatusRef\.current/,
|
||||
);
|
||||
// Reuse the terminal transport only after SSH is connected. A linked id can
|
||||
// arrive while reconnecting. Single-channel bastions dial a separate login.
|
||||
// Reuse the terminal transport only when SSH is connected, or when a strict
|
||||
// pending source id is already pinned. A dedicated single-channel login never
|
||||
// reuses that transport. A linked id may arrive while reconnecting.
|
||||
assert.match(
|
||||
sidePanelSource,
|
||||
/const reuseTerminalTransport = !dedicatedSftpLogin && \(\s*Boolean\(pendingStrictSourceSessionId\) \|\| activeSessionStatus === "connected"\s*\);/,
|
||||
|
||||
@@ -5,7 +5,7 @@ import { useI18n } from '../../application/i18n/I18nProvider';
|
||||
import { SYSTEM_MANAGER_TAB_LAYOUT_DEFAULTS } from '../../application/state/systemManagerTabLayout';
|
||||
import { useSystemManagerBackend } from '../../application/state/useSystemManagerBackend';
|
||||
import { useTerminalBackend } from '../../application/state/useTerminalBackend';
|
||||
import { remoteSoftwareRequiresSingleChannel } from '../../domain/singleChannelSshBanner.shared.cjs';
|
||||
import { remoteSoftwareRequiresSingleChannel } from '../../domain/singleChannelSshBanner.shared.mjs';
|
||||
import { useToolbarItemLayout } from '../../application/state/useToolbarItemLayout';
|
||||
import type { TerminalSettings } from '../../domain/models';
|
||||
import type { Host } from '../../domain/models/connection';
|
||||
@@ -87,8 +87,8 @@ export const SystemManagerSidePanel = memo(function SystemManagerSidePanel({
|
||||
const [peerSingleChannel, setPeerSingleChannel] = useState<boolean | null>(null);
|
||||
|
||||
useEffect(() => {
|
||||
if (!sessionId || !isConnected || sessionHost?.singleChannelSsh === true) {
|
||||
setPeerSingleChannel(sessionHost?.singleChannelSsh === true);
|
||||
if (!sessionId || !isConnected) {
|
||||
setPeerSingleChannel(false);
|
||||
return;
|
||||
}
|
||||
let cancelled = false;
|
||||
@@ -102,9 +102,9 @@ export const SystemManagerSidePanel = memo(function SystemManagerSidePanel({
|
||||
return () => {
|
||||
cancelled = true;
|
||||
};
|
||||
}, [isConnected, sessionHost?.singleChannelSsh, sessionId, terminalBackend]);
|
||||
}, [isConnected, sessionId, terminalBackend]);
|
||||
|
||||
const systemManagerSupported = sessionHost?.singleChannelSsh !== true && peerSingleChannel !== true;
|
||||
const systemManagerSupported = peerSingleChannel !== true;
|
||||
const capabilitiesEnabled = systemManagerSupported && peerSingleChannel !== null;
|
||||
|
||||
const capabilitiesTtlMs = terminalSettings.systemManagerProcessRefreshInterval * 1000;
|
||||
@@ -536,7 +536,7 @@ export const SystemManagerSidePanel = memo(function SystemManagerSidePanel({
|
||||
<SystemOverviewTab
|
||||
sessionId={sessionId}
|
||||
isVisible={isVisible && resolvedTab === 'overview'}
|
||||
isSupportedOs={isStatsSupportedOs}
|
||||
isSupportedOs={isStatsSupportedOs && peerSingleChannel !== true}
|
||||
refreshIntervalSec={terminalSettings.serverStatsRefreshInterval}
|
||||
/>
|
||||
</div>
|
||||
|
||||
@@ -7,9 +7,12 @@ import { GripHorizontal, Pin, Plus, Radio, Search, X } from 'lucide-react';
|
||||
import React, { memo, useCallback, useEffect, useMemo, useRef, useState } from 'react';
|
||||
import { useComposeBarHistory } from '../../application/state/useComposeBarHistory';
|
||||
import { canNavigateComposeBarHistory } from '../../domain/composeBarHistory';
|
||||
import { STORAGE_KEY_TERMINAL_BROADCAST_PASSWORD_BYPASS } from '../../infrastructure/config/storageKeys';
|
||||
import { useStoredBoolean } from '../../application/state/useStoredBoolean';
|
||||
import { useComposeBarHeight } from '../../application/state/useComposeBarHeight';
|
||||
import { useComposeBarPinnedSnippets } from '../../application/state/useComposeBarPinnedSnippets';
|
||||
import { useI18n } from '../../application/i18n/I18nProvider';
|
||||
import { useClipboardBackend } from '../../application/state/useClipboardBackend';
|
||||
import { resolveSnippetCommand } from '../SnippetExecutionProvider';
|
||||
import { Snippet } from '../../types';
|
||||
import { cn } from '../../lib/utils';
|
||||
@@ -292,6 +295,7 @@ export const TerminalComposeBar: React.FC<TerminalComposeBarProps> = ({
|
||||
themeColors,
|
||||
}) => {
|
||||
const { t } = useI18n();
|
||||
const { readClipboardText, writeClipboardText } = useClipboardBackend();
|
||||
const textareaRef = useRef<HTMLTextAreaElement>(null);
|
||||
const restoreDraft = useCallback((draft: string) => {
|
||||
if (textareaRef.current) textareaRef.current.value = draft;
|
||||
@@ -319,6 +323,88 @@ export const TerminalComposeBar: React.FC<TerminalComposeBarProps> = ({
|
||||
|
||||
const theme = useMemo(() => buildTheme(themeColors), [themeColors]);
|
||||
|
||||
// Opt-in "broadcast without password protection" (#3488). Shared storage key
|
||||
// with the terminal runtime, which reads it to keep broadcasting during
|
||||
// password / interactive prompts while this switch is on.
|
||||
const [allowPasswordBroadcast, setAllowPasswordBroadcast] = useStoredBoolean(
|
||||
STORAGE_KEY_TERMINAL_BROADCAST_PASSWORD_BYPASS,
|
||||
false,
|
||||
);
|
||||
const [broadcastMenuAnchor, setBroadcastMenuAnchor] = useState<{ x: number; y: number; inTextarea: boolean } | null>(null);
|
||||
|
||||
useEffect(() => {
|
||||
if (!broadcastMenuAnchor) return;
|
||||
const handlePointerDown = (event: PointerEvent) => {
|
||||
if (event.target instanceof Element && event.target.closest('[data-compose-broadcast-menu]')) return;
|
||||
setBroadcastMenuAnchor(null);
|
||||
};
|
||||
const handleKeyDown = (event: KeyboardEvent) => {
|
||||
if (event.key === 'Escape') setBroadcastMenuAnchor(null);
|
||||
};
|
||||
window.addEventListener('pointerdown', handlePointerDown);
|
||||
window.addEventListener('keydown', handleKeyDown);
|
||||
return () => {
|
||||
window.removeEventListener('pointerdown', handlePointerDown);
|
||||
window.removeEventListener('keydown', handleKeyDown);
|
||||
};
|
||||
}, [broadcastMenuAnchor]);
|
||||
|
||||
const handleBroadcastContextMenu = useCallback((event: React.MouseEvent) => {
|
||||
if (!isBroadcastEnabled) return;
|
||||
event.preventDefault();
|
||||
event.stopPropagation();
|
||||
setBroadcastMenuAnchor({
|
||||
x: event.clientX,
|
||||
y: event.clientY,
|
||||
inTextarea: event.target instanceof Element && Boolean(event.target.closest('textarea')),
|
||||
});
|
||||
}, [isBroadcastEnabled]);
|
||||
|
||||
const handleComposeMenuEdit = useCallback(async (action: 'cut' | 'copy' | 'paste' | 'selectAll') => {
|
||||
setBroadcastMenuAnchor(null);
|
||||
const textarea = textareaRef.current;
|
||||
if (!textarea) return;
|
||||
textarea.focus();
|
||||
if (action === 'selectAll') {
|
||||
textarea.select();
|
||||
return;
|
||||
}
|
||||
const start = textarea.selectionStart;
|
||||
const end = textarea.selectionEnd;
|
||||
try {
|
||||
if (action === 'paste') {
|
||||
let text: string | undefined;
|
||||
try { text = await readClipboardText(); } catch { /* Use browser clipboard. */ }
|
||||
if (typeof text !== 'string') text = await navigator.clipboard.readText();
|
||||
if (!text) return;
|
||||
textarea.setSelectionRange(start, end);
|
||||
if (!document.execCommand('insertText', false, text)) {
|
||||
textarea.setRangeText(text, start, end, 'end');
|
||||
textarea.dispatchEvent(new Event('input', { bubbles: true }));
|
||||
}
|
||||
reset();
|
||||
return;
|
||||
}
|
||||
if (start === end) return;
|
||||
const selectedText = textarea.value.slice(start, end);
|
||||
let wroteToBridge = false;
|
||||
try { wroteToBridge = await writeClipboardText(selectedText); } catch { /* Use browser clipboard. */ }
|
||||
if (!wroteToBridge) {
|
||||
await navigator.clipboard.writeText(selectedText);
|
||||
}
|
||||
if (action === 'cut') {
|
||||
textarea.setSelectionRange(start, end);
|
||||
if (!document.execCommand('delete')) {
|
||||
textarea.setRangeText('', start, end, 'end');
|
||||
textarea.dispatchEvent(new Event('input', { bubbles: true }));
|
||||
}
|
||||
reset();
|
||||
}
|
||||
} catch (error) {
|
||||
console.error('Compose bar clipboard action failed', error);
|
||||
}
|
||||
}, [readClipboardText, reset, writeClipboardText]);
|
||||
|
||||
const snippetsById = useMemo(
|
||||
() => mergeComposeBarSnippetMap(snippets),
|
||||
[snippets],
|
||||
@@ -459,6 +545,7 @@ export const TerminalComposeBar: React.FC<TerminalComposeBarProps> = ({
|
||||
backgroundColor: theme.resolvedBg,
|
||||
borderTop: `1px solid ${theme.borderColor}`,
|
||||
}}
|
||||
onContextMenu={handleBroadcastContextMenu}
|
||||
>
|
||||
<div
|
||||
role="separator"
|
||||
@@ -561,6 +648,54 @@ export const TerminalComposeBar: React.FC<TerminalComposeBarProps> = ({
|
||||
</Tooltip>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{broadcastMenuAnchor && (
|
||||
<div
|
||||
data-compose-broadcast-menu
|
||||
className="fixed z-[120] min-w-[240px] rounded-md border py-1 shadow-lg"
|
||||
style={{
|
||||
// Clamp horizontally so the 240px-min menu stays on-screen even
|
||||
// when the compose bar is right-clicked near the right edge.
|
||||
left: Math.max(4, Math.min(broadcastMenuAnchor.x, window.innerWidth - 244)),
|
||||
top: Math.max(4, Math.min(broadcastMenuAnchor.y, window.innerHeight - (broadcastMenuAnchor.inTextarea ? 160 : 48))),
|
||||
backgroundColor: theme.resolvedBg,
|
||||
borderColor: theme.borderColor,
|
||||
color: theme.resolvedFg,
|
||||
}}
|
||||
onMouseDown={(event) => event.preventDefault()}
|
||||
onContextMenu={(e) => e.preventDefault()}
|
||||
>
|
||||
{broadcastMenuAnchor.inTextarea && (
|
||||
<>
|
||||
{(['cut', 'copy', 'paste', 'selectAll'] as const).map((action) => (
|
||||
<button
|
||||
key={action}
|
||||
type="button"
|
||||
className="flex w-full items-center px-2.5 py-1.5 text-left text-[11px] transition-colors duration-150 hover:bg-black/20"
|
||||
onClick={() => { void handleComposeMenuEdit(action); }}
|
||||
>
|
||||
{t(`terminal.menu.${action}`)}
|
||||
</button>
|
||||
))}
|
||||
<div className="my-1 border-t" style={{ borderColor: theme.borderColor }} />
|
||||
</>
|
||||
)}
|
||||
<button
|
||||
data-compose-broadcast-toggle
|
||||
type="button"
|
||||
className="flex w-full items-center gap-2 px-2.5 py-1.5 text-left text-[11px] transition-colors duration-150 hover:bg-black/20"
|
||||
onClick={() => {
|
||||
setAllowPasswordBroadcast((prev) => !prev);
|
||||
setBroadcastMenuAnchor(null);
|
||||
}}
|
||||
>
|
||||
<span className="w-3 shrink-0 text-center" aria-hidden="true">
|
||||
{allowPasswordBroadcast ? '✓' : ''}
|
||||
</span>
|
||||
{t('terminal.composeBar.broadcastAllowPassword')}
|
||||
</button>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
@@ -1315,8 +1315,28 @@ function TerminalViewInner({ ctx, isPaneMagnified = false }: { ctx: TerminalView
|
||||
onSend={async (text) => {
|
||||
if (sessionRef.current) {
|
||||
const sensitive = isTerminalSensitiveInputActive(sessionId);
|
||||
const sent = await executeSnippetCommand(text, false);
|
||||
return sent && !sensitive && !isTerminalSensitiveInputActive(sessionId);
|
||||
// A bypassed fan-out (#3488) can deliver the payload into a
|
||||
// peer's sensitive prompt even when this source session is
|
||||
// non-sensitive (#3491): that payload is the peer's
|
||||
// password/MFA input, so the send must stay history-
|
||||
// ineligible, like the workspace compose path does.
|
||||
let anyRecipientSensitive = false;
|
||||
let broadcastRecipientIds: readonly string[] = [];
|
||||
const sent = await executeSnippetCommand(text, false, {
|
||||
onBroadcastDelivered: (sessionIds) => {
|
||||
broadcastRecipientIds = sessionIds;
|
||||
if (sessionIds.some((id) => isTerminalSensitiveInputActive(id))) {
|
||||
anyRecipientSensitive = true;
|
||||
}
|
||||
},
|
||||
});
|
||||
// A lagging peer can reach its password prompt by delivery
|
||||
// time; re-check the recorded recipients after the send.
|
||||
if (broadcastRecipientIds.some((id) => isTerminalSensitiveInputActive(id))) {
|
||||
anyRecipientSensitive = true;
|
||||
}
|
||||
return sent && !sensitive && !anyRecipientSensitive
|
||||
&& !isTerminalSensitiveInputActive(sessionId);
|
||||
}
|
||||
return false;
|
||||
}}
|
||||
|
||||
@@ -22,6 +22,7 @@ import {
|
||||
} from "../runtime/terminalHistoryScrollOverride";
|
||||
|
||||
import { readTerminalScreenText } from "../terminalContextBuffer";
|
||||
import { shouldBroadcastDuringSensitivePrompt } from "../../../domain/terminalBroadcast";
|
||||
import { useI18n } from "../../../application/i18n/I18nProvider";
|
||||
import { toast } from "../../ui/toast";
|
||||
|
||||
@@ -33,6 +34,7 @@ type BroadcastPasteRefs = {
|
||||
((data: string, sourceSessionId: string, options?: TerminalBroadcastInputOptions) => void) | undefined
|
||||
>;
|
||||
passwordPromptActiveRef?: RefObject<boolean | undefined>;
|
||||
broadcastPasswordBypassRef?: RefObject<boolean | undefined>;
|
||||
};
|
||||
|
||||
export const broadcastTerminalPasteData = (
|
||||
@@ -43,16 +45,31 @@ export const broadcastTerminalPasteData = (
|
||||
isBroadcastEnabledRef,
|
||||
onBroadcastInputRef,
|
||||
passwordPromptActiveRef,
|
||||
broadcastPasswordBypassRef,
|
||||
}: BroadcastPasteRefs,
|
||||
options?: TerminalBroadcastInputOptions,
|
||||
): boolean => {
|
||||
if (
|
||||
passwordPromptActiveRef?.current !== true
|
||||
shouldBroadcastDuringSensitivePrompt({
|
||||
sensitivePromptActive: passwordPromptActiveRef?.current === true,
|
||||
broadcastPasswordBypass: broadcastPasswordBypassRef?.current === true,
|
||||
})
|
||||
&& sessionRef.current
|
||||
&& isBroadcastEnabledRef?.current
|
||||
&& onBroadcastInputRef?.current
|
||||
) {
|
||||
onBroadcastInputRef.current(data, sourceSessionId, options);
|
||||
// Bypassed password fan-out (#3488): when the prompt itself is sensitive,
|
||||
// tag the payload so peer writes keep input interceptors skipped. A paste
|
||||
// confirmed at a sensitive prompt keeps its pre-dialog snapshot (#3491),
|
||||
// so its carried sensitivity tags the fan-out even when the dialog await
|
||||
// cleared the live prompt ref.
|
||||
const dispatchingFromPasswordPrompt = passwordPromptActiveRef?.current === true;
|
||||
const sourceSensitive = dispatchingFromPasswordPrompt || options?.sensitive === true;
|
||||
onBroadcastInputRef.current(
|
||||
data,
|
||||
sourceSessionId,
|
||||
sourceSensitive ? { ...options, sourceSensitive: true } : options,
|
||||
);
|
||||
return true;
|
||||
}
|
||||
return false;
|
||||
@@ -68,6 +85,7 @@ export const useTerminalContextActions = ({
|
||||
isBroadcastEnabledRef,
|
||||
onBroadcastInputRef,
|
||||
passwordPromptActiveRef,
|
||||
broadcastPasswordBypassRef,
|
||||
isLocalConnection,
|
||||
supportsRemoteImagePaste,
|
||||
autoUploadClipboardImageOnPasteRef,
|
||||
@@ -90,6 +108,7 @@ export const useTerminalContextActions = ({
|
||||
((data: string, sourceSessionId: string, options?: TerminalBroadcastInputOptions) => void) | undefined
|
||||
>;
|
||||
passwordPromptActiveRef?: RefObject<boolean | undefined>;
|
||||
broadcastPasswordBypassRef?: RefObject<boolean | undefined>;
|
||||
isLocalConnection: boolean;
|
||||
supportsRemoteImagePaste: boolean;
|
||||
/** When true, paste auto-uploads a clipboard image (remote sessions only). */
|
||||
@@ -143,8 +162,9 @@ export const useTerminalContextActions = ({
|
||||
isBroadcastEnabledRef,
|
||||
onBroadcastInputRef,
|
||||
passwordPromptActiveRef,
|
||||
broadcastPasswordBypassRef,
|
||||
}, options);
|
||||
}, [isBroadcastEnabledRef, onBroadcastInputRef, passwordPromptActiveRef, sessionRef, sourceSessionId]);
|
||||
}, [isBroadcastEnabledRef, onBroadcastInputRef, passwordPromptActiveRef, broadcastPasswordBypassRef, sessionRef, sourceSessionId]);
|
||||
|
||||
const onCopy = useCallback(() => {
|
||||
const term = termRef.current;
|
||||
@@ -178,6 +198,7 @@ export const useTerminalContextActions = ({
|
||||
getRemoteCwd,
|
||||
isLocalConnection,
|
||||
isSensitiveInput: () => passwordPromptActiveRef?.current === true,
|
||||
broadcastPasswordBypass: () => broadcastPasswordBypassRef?.current === true,
|
||||
onClipboardImageUploadResult,
|
||||
readClipboardText: () => navigator.clipboard.readText(),
|
||||
scrollOnPaste: scrollOnPasteRef?.current ?? false,
|
||||
@@ -192,6 +213,7 @@ export const useTerminalContextActions = ({
|
||||
}
|
||||
}, [
|
||||
autoUploadClipboardImageOnPasteRef,
|
||||
broadcastPasswordBypassRef,
|
||||
broadcastUserPasteData,
|
||||
multilinePasteConfirmRef,
|
||||
getRemoteCwd,
|
||||
@@ -256,6 +278,7 @@ export const useTerminalContextActions = ({
|
||||
: undefined,
|
||||
getCurrentSessionId: () => sessionRef.current,
|
||||
isSensitiveInput: () => passwordPromptActiveRef?.current === true,
|
||||
broadcastPasswordBypass: () => broadcastPasswordBypassRef?.current === true,
|
||||
onPasteData: broadcastUserPasteData,
|
||||
scrollOnPaste: scrollOnPasteRef?.current ?? false,
|
||||
scrollToBottomAfterProgrammaticInput,
|
||||
@@ -264,6 +287,7 @@ export const useTerminalContextActions = ({
|
||||
term,
|
||||
});
|
||||
}, [
|
||||
broadcastPasswordBypassRef,
|
||||
broadcastUserPasteData,
|
||||
multilinePasteConfirmRef,
|
||||
normalizeTextOnCopyRef,
|
||||
|
||||
@@ -20,7 +20,9 @@ interface UseTerminalFilePasteOptions {
|
||||
};
|
||||
isSensitiveInput?: () => boolean;
|
||||
scrollOnPasteRef?: React.RefObject<boolean>;
|
||||
onPasteData?: (data: string, options?: { lineDelayMs?: number }) => boolean | void;
|
||||
/** Live #3488 bypass probe; forwarded to the multiline-confirm gate. */
|
||||
broadcastPasswordBypassRef?: React.RefObject<boolean | undefined>;
|
||||
onPasteData?: (data: string, options?: { lineDelayMs?: number; sensitive?: boolean }) => boolean | void;
|
||||
scrollToBottomAfterProgrammaticInput: (data: string) => void;
|
||||
containerRef: React.RefObject<HTMLDivElement | null>;
|
||||
/** Remote sessions only: auto-upload a clipboard image on paste. */
|
||||
@@ -39,6 +41,7 @@ export function useTerminalFilePaste({
|
||||
terminalBackend,
|
||||
isSensitiveInput,
|
||||
scrollOnPasteRef,
|
||||
broadcastPasswordBypassRef,
|
||||
onPasteData,
|
||||
scrollToBottomAfterProgrammaticInput,
|
||||
containerRef,
|
||||
@@ -100,6 +103,10 @@ export function useTerminalFilePaste({
|
||||
getRemoteCwd,
|
||||
isLocalConnection,
|
||||
isSensitiveInput,
|
||||
// Forward the #3488 bypass probe so a paste confirmed at a
|
||||
// sensitive prompt still fans out to broadcast peers, matching
|
||||
// the context-menu and shortcut paste paths.
|
||||
broadcastPasswordBypass: () => broadcastPasswordBypassRef?.current === true,
|
||||
onClipboardImageUploadResult,
|
||||
readClipboardText: async () => {
|
||||
if (eventText) return eventText;
|
||||
@@ -124,6 +131,7 @@ export function useTerminalFilePaste({
|
||||
};
|
||||
}, [
|
||||
autoUploadClipboardImage,
|
||||
broadcastPasswordBypassRef,
|
||||
containerRef,
|
||||
multilinePasteConfirmRef,
|
||||
getRemoteCwd,
|
||||
|
||||
@@ -441,10 +441,10 @@ test("startSSH tells the bridge to skip shell discovery for network devices", as
|
||||
|
||||
assert.equal(capturedOptions?.sourceSessionId, "source-session");
|
||||
assert.equal(capturedOptions?.skipShellPidDiscovery, true);
|
||||
assert.equal(capturedOptions?.singleChannelSsh, false);
|
||||
assert.equal(capturedOptions?.singleChannelSsh, undefined);
|
||||
});
|
||||
|
||||
test("startSSH opens a fresh connection when copying a single-channel host", async () => {
|
||||
test("startSSH ignores a legacy single-channel host field when copying a tab", async () => {
|
||||
let capturedOptions: Record<string, unknown> | null = null;
|
||||
const terminalBackend = {
|
||||
backendAvailable: () => true,
|
||||
@@ -472,9 +472,8 @@ test("startSSH opens a fresh connection when copying a single-channel host", asy
|
||||
|
||||
await createTerminalSessionStarters(ctx as never).startSSH(createTermStub() as never);
|
||||
|
||||
assert.equal(capturedOptions?.sourceSessionId, undefined);
|
||||
assert.equal(capturedOptions?.reuseTransport, false);
|
||||
assert.equal(capturedOptions?.singleChannelSsh, true);
|
||||
assert.equal(capturedOptions?.sourceSessionId, "source-session");
|
||||
assert.equal(capturedOptions?.singleChannelSsh, undefined);
|
||||
});
|
||||
|
||||
test("startSSH requests a fresh transport for ordinary opens with connection automation", async () => {
|
||||
|
||||
@@ -2,7 +2,7 @@ import type { Terminal as XTerm } from "@xterm/xterm";
|
||||
import type { ProviderValidationIssue } from "@netcatty/plugin-contract";
|
||||
import { logger } from "../../../lib/logger";
|
||||
import type { Host, Identity, SSHKey } from "../../../types";
|
||||
import type { TerminalSessionExitEvent } from "../../../application/state/resolveTerminalSessionExitIntent";
|
||||
import { type TerminalSessionExitEvent, formatSessionClosedExitMessage } from "../../../application/state/resolveTerminalSessionExitIntent";
|
||||
import { setTerminalBootEpoch } from "../../../domain/terminalBootEpoch";
|
||||
import type { TerminalSessionStartersContext } from "./createTerminalSessionStarters.types";
|
||||
export type {
|
||||
@@ -625,10 +625,7 @@ export const createTerminalSessionStarters = (ctx: TerminalSessionStartersContex
|
||||
useSshAgent?: boolean;
|
||||
}): Promise<string> => {
|
||||
// Reconnect supersedes a Copy/Split intent that was still waiting for credentials.
|
||||
const sourceSessionId = (
|
||||
ctx.requireFreshConnectionOnReconnectRef?.current
|
||||
|| ctx.host.singleChannelSsh === true
|
||||
)
|
||||
const sourceSessionId = ctx.requireFreshConnectionOnReconnectRef?.current
|
||||
? undefined
|
||||
: ctx.reuseConnectionFromSessionIdRef?.current;
|
||||
if (ctx.reuseConnectionFromSessionIdRef) {
|
||||
@@ -701,7 +698,6 @@ export const createTerminalSessionStarters = (ctx: TerminalSessionStartersContex
|
||||
charset: ctx.host.charset,
|
||||
// Persist for session-backed SFTP opens (AI tools / clipboard paste).
|
||||
sftpFileProtocol: ctx.host.sftpFileProtocol || "auto",
|
||||
singleChannelSsh: ctx.host.singleChannelSsh === true,
|
||||
env: termEnv,
|
||||
proxy: proxyConfig,
|
||||
jumpHosts: jumpHosts.length > 0 ? jumpHosts : undefined,
|
||||
@@ -824,8 +820,7 @@ export const createTerminalSessionStarters = (ctx: TerminalSessionStartersContex
|
||||
isCurrentAttempt,
|
||||
bootEpoch,
|
||||
onConnected: () => ctx.setChainProgress(null),
|
||||
onExitMessage: (evt) =>
|
||||
`\r\n[session closed${evt?.exitCode !== undefined ? ` (code ${evt.exitCode})` : ""}]`,
|
||||
onExitMessage: formatSessionClosedExitMessage,
|
||||
sudoAutofillPassword: resolveSavedSudoAutofillPassword(),
|
||||
sudoAutofillCandidates: resolveSudoAutofillCandidates(),
|
||||
})) {
|
||||
|
||||
@@ -1027,7 +1027,7 @@ test("multi-character plain text goes out as per-character writes (#3077)", asyn
|
||||
// broadcast keep using the unsplit payload.
|
||||
const writeSite = source.slice(writeLoopIdx - 600, writeLoopIdx + 320);
|
||||
assert.match(writeSite, /ctx\.onOutputTriggerUserInputRef\?\.current\?\.\(outData\)/);
|
||||
assert.match(source, /onBroadcastInput\?\.\(broadcastData, ctx\.sessionId\)/);
|
||||
assert.match(source, /onBroadcastInput\?\.\(broadcastData, ctx\.sessionId, sensitive \? \{ sourceSensitive: true \} : undefined\)/);
|
||||
});
|
||||
|
||||
test("Command+Period interrupt press is keyed apart from an outstanding physical KeyC press (#3409)", async () => {
|
||||
@@ -1088,3 +1088,28 @@ test("Command+Period interrupt yields to a user-assigned snippet or shortcut cho
|
||||
/const macCommandPeriodInterrupt =\s*isMacPlatform\(\)\s*&& isMacCommandPeriodInterruptChord\(e\)\s*&& !\(ctx\.snippetsRef\?\.current \?\? \[\]\)\.some\(\(snippet\) => \(\s*snippet\.shortkey && matchesKeyBinding\(e, snippet\.shortkey, isMac\)\s*\)\)\s*&& !\(currentScheme !== "disabled"\s*&& checkAppShortcut\(e, ctx\.keyBindingsRef\.current, isMac\) !== null\);/,
|
||||
);
|
||||
});
|
||||
|
||||
test("Shift+Enter send-text fallback snapshots prompt sensitivity before the local write (#3491)", async () => {
|
||||
const { readFileSync } = await import("node:fs");
|
||||
const source = readFileSync(new URL("./createXTermRuntime.ts", import.meta.url), "utf8");
|
||||
|
||||
// The Shift+Enter fallback reaches the PTY as a bare newline via
|
||||
// handleTerminalInputData, which submits synchronously and clears
|
||||
// passwordPromptActiveRef. The subsequent key-chord broadcast must carry a
|
||||
// pre-write sensitivity snapshot, otherwise the live prompt-source check
|
||||
// reports the dispatch as nonsensitive and a peer commits its buffered
|
||||
// password characters as ordinary input.
|
||||
const fallbackIdx = source.indexOf(
|
||||
'handleTerminalInputData(shiftEnterText, {',
|
||||
);
|
||||
assert.ok(fallbackIdx >= 0);
|
||||
const fallback = source.slice(fallbackIdx, fallbackIdx + 400);
|
||||
const snapshotIdx = source.slice(0, fallbackIdx).lastIndexOf(
|
||||
"const sourceSensitivePrompt = ctx.passwordPromptActiveRef?.current === true;",
|
||||
);
|
||||
assert.ok(snapshotIdx >= 0, "snapshot must precede the local Shift+Enter write");
|
||||
assert.match(
|
||||
fallback,
|
||||
/broadcastKittyInput\(\s*\{\s*kind: "key",\s*event: kittyEvent,\s*fallbackToLegacy: true,\s*\},\s*false,\s*undefined,\s*sourceSensitivePrompt \? \{ sourceSensitive: true \} : undefined\)/,
|
||||
);
|
||||
});
|
||||
@@ -129,6 +129,7 @@ import {
|
||||
shouldFlushStaleDeferredImeTextInput,
|
||||
} from "./terminalImeTextInput";
|
||||
import { keepImeCommittedTextThroughModifierKeyDowns } from "./imeModifierKeyDownSeenGuard";
|
||||
import { keepLiveImeTranscriptionSingle } from "./imeCompositionRewrite";
|
||||
import { formatSerialLocalEcho } from "./serialLocalEcho";
|
||||
import { getLastChar, removeLastChar, isPrintableInput } from "../../../domain/serialCharMetrics";
|
||||
import { mapTerminalBackspaceInput } from "./terminalBackspaceInput";
|
||||
@@ -382,6 +383,11 @@ export type CreateXTermRuntimeContext = {
|
||||
captureSubmittedLineRecorder?: () => ((line: string, options?: { sensitive?: boolean; includePendingInput?: boolean; consumePendingInput?: boolean }) => Promise<void>) | undefined;
|
||||
} | undefined>;
|
||||
passwordPromptActiveRef?: RefObject<boolean>;
|
||||
/**
|
||||
* Opt-in "broadcast without password protection" (#3488). When true, an
|
||||
* active password prompt no longer pauses raw-string broadcast fan-out.
|
||||
*/
|
||||
broadcastPasswordBypassRef?: RefObject<boolean>;
|
||||
allowHostStyleGreaterThanPrompt?: boolean;
|
||||
onOutputTriggerUserInputRef?: RefObject<((data: string) => void) | undefined>;
|
||||
sudoAutofillRef?: RefObject<SudoPasswordAutofill | null>;
|
||||
@@ -705,6 +711,9 @@ export const createXTermRuntime = (ctx: CreateXTermRuntimeContext): XTermRuntime
|
||||
}
|
||||
|
||||
term.open(ctx.container);
|
||||
// CompositionHelper is created in open(). Live transcription rewrites the
|
||||
// hypothesis instead of appending; commit that utterance once (#3421).
|
||||
keepLiveImeTranscriptionSingle(term);
|
||||
|
||||
// Inline raster images (Kitty graphics / SIXEL / iTerm IIP). Loaded right after
|
||||
// term.open so the addon can patch IRenderService.setRenderer before the WebGL
|
||||
@@ -980,12 +989,23 @@ export const createXTermRuntime = (ctx: CreateXTermRuntimeContext): XTermRuntime
|
||||
data: string,
|
||||
options?: TerminalBroadcastInputOptions,
|
||||
) => {
|
||||
// Password bypass (#3488): the opt-in lifts the password-prompt pause.
|
||||
// Payloads dispatched from an active prompt stay marked sourceSensitive so
|
||||
// peer writes keep skipping input interceptors.
|
||||
const dispatchingFromPasswordPrompt = ctx.passwordPromptActiveRef?.current === true;
|
||||
// A paste confirmed at a sensitive prompt keeps its pre-dialog snapshot
|
||||
// (#3491): the dialog await can clear the live prompt ref, so the saved
|
||||
// classification carried in the paste options must still tag the fan-out.
|
||||
const sourceSensitive = dispatchingFromPasswordPrompt || options?.sensitive === true;
|
||||
if (
|
||||
ctx.passwordPromptActiveRef?.current !== true
|
||||
(!dispatchingFromPasswordPrompt
|
||||
|| ctx.broadcastPasswordBypassRef?.current === true)
|
||||
&& ctx.isBroadcastEnabledRef.current
|
||||
&& ctx.onBroadcastInputRef.current
|
||||
) {
|
||||
ctx.onBroadcastInputRef.current(data, ctx.sessionId, options);
|
||||
ctx.onBroadcastInputRef.current(data, ctx.sessionId, sourceSensitive
|
||||
? { ...options, sourceSensitive: true }
|
||||
: options);
|
||||
return true;
|
||||
}
|
||||
return false;
|
||||
@@ -1259,6 +1279,13 @@ export const createXTermRuntime = (ctx: CreateXTermRuntimeContext): XTermRuntime
|
||||
skipBroadcast?: boolean;
|
||||
/** Confirmed paste: preserve classification and backend pacing. */
|
||||
sensitive?: boolean;
|
||||
/**
|
||||
* The sensitive marker came from a source-sensitive broadcast peer
|
||||
* (#3488), not from this session's own prompt detector. Semantic input
|
||||
* consumers such as autocomplete must not see the secret payload: their
|
||||
* gate only checks this peer's own prompt state (#3491).
|
||||
*/
|
||||
sourceSensitiveBroadcast?: boolean;
|
||||
lineDelayMs?: number;
|
||||
pasteRequestId?: string;
|
||||
/**
|
||||
@@ -1326,7 +1353,11 @@ export const createXTermRuntime = (ctx: CreateXTermRuntimeContext): XTermRuntime
|
||||
if (suppressTerminalBroadcast) suppressNextTerminalDataBroadcast = false;
|
||||
// skipBroadcast only suppresses the raw-string fan-out. Peers still receive
|
||||
// the Shift+Enter chord, so sudo autofill must treat this as a broadcast.
|
||||
const canBroadcastInput = !sensitive &&
|
||||
// The password bypass (#3488) only lifts the live password-prompt pause.
|
||||
// Explicitly classified sensitive payloads (confirmed pastes, preloaded
|
||||
// sudo credentials) stay non-broadcastable regardless of the opt-in.
|
||||
const canBroadcastInput = (!sensitive
|
||||
|| (ctx.broadcastPasswordBypassRef?.current === true && options?.sensitive !== true)) &&
|
||||
inputSource !== "kitty" &&
|
||||
!handlingKittyBroadcast &&
|
||||
!suppressTerminalBroadcast &&
|
||||
@@ -1544,7 +1575,9 @@ export const createXTermRuntime = (ctx: CreateXTermRuntimeContext): XTermRuntime
|
||||
ctx.host.backspaceBehavior,
|
||||
);
|
||||
if (willBroadcastInput) {
|
||||
onBroadcastInput?.(broadcastData, ctx.sessionId);
|
||||
// If the bypass (#3488) let a password-prompt payload through, keep the
|
||||
// sensitive marker on peer writes so input interceptors stay skipped.
|
||||
onBroadcastInput?.(broadcastData, ctx.sessionId, sensitive ? { sourceSensitive: true } : undefined);
|
||||
}
|
||||
|
||||
if (
|
||||
@@ -1554,8 +1587,11 @@ export const createXTermRuntime = (ctx: CreateXTermRuntimeContext): XTermRuntime
|
||||
scrollToBottomAfterInput(logicalData);
|
||||
}
|
||||
|
||||
// Notify autocomplete of input
|
||||
if (logicalData !== null) ctx.onAutocompleteInput?.(logicalData);
|
||||
// Notify autocomplete of input. Payloads whose sensitivity arrived from
|
||||
// a source-sensitive broadcast peer (#3488) stay out of autocomplete:
|
||||
// this peer's own prompt gate has not latched, so the secret could reach
|
||||
// enabled plugin completion providers (#3491).
|
||||
if (logicalData !== null && !options?.sourceSensitiveBroadcast) ctx.onAutocompleteInput?.(logicalData);
|
||||
|
||||
if (ctx.statusRef.current === "connected" && logicalData !== null) {
|
||||
if (handledSubmittedInput || submittedInput) {
|
||||
@@ -1605,6 +1641,8 @@ export const createXTermRuntime = (ctx: CreateXTermRuntimeContext): XTermRuntime
|
||||
let win32InputModePendingEvent: {
|
||||
event: KittyKeyboardEvent;
|
||||
logicalData: string | null;
|
||||
/** Forced sensitive marker from a bypassed password-prompt source (#3488). */
|
||||
sensitive?: boolean;
|
||||
} | null = null;
|
||||
const win32InputModeForwardedKeys = new Map<string, KittyKeyboardForwardedPress>();
|
||||
const kittyForwardedKeys = new Map<string, KittyKeyboardForwardedPress>();
|
||||
@@ -1652,7 +1690,12 @@ export const createXTermRuntime = (ctx: CreateXTermRuntimeContext): XTermRuntime
|
||||
sourceSessionId: ctx.sessionId,
|
||||
isHandlingBroadcast: () => handlingKittyBroadcast,
|
||||
isBroadcastEnabled: () => ctx.isBroadcastEnabledRef.current,
|
||||
isSensitiveInput: () => ctx.passwordPromptActiveRef?.current === true,
|
||||
// The #3488 bypass lifts the password-prompt pause for Kitty key fan-out too.
|
||||
isSensitiveInput: () => ctx.passwordPromptActiveRef?.current === true
|
||||
&& ctx.broadcastPasswordBypassRef?.current !== true,
|
||||
// Dispatching from an active prompt under the bypass must tag the payload
|
||||
// so peer writes keep input interceptors skipped.
|
||||
isSensitivePromptSource: () => ctx.passwordPromptActiveRef?.current === true,
|
||||
getDispatcher: () => ctx.onBroadcastInputRef.current,
|
||||
});
|
||||
|
||||
@@ -2400,6 +2443,7 @@ export const createXTermRuntime = (ctx: CreateXTermRuntimeContext): XTermRuntime
|
||||
},
|
||||
getCurrentSessionId: () => ctx.sessionRef.current,
|
||||
isSensitiveInput: () => ctx.passwordPromptActiveRef?.current === true,
|
||||
broadcastPasswordBypass: () => ctx.broadcastPasswordBypassRef?.current === true,
|
||||
onPasteData: broadcastUserPasteData,
|
||||
scrollOnPaste: shouldScrollOnTerminalPaste(ctx.terminalSettingsRef.current),
|
||||
scrollToBottomAfterProgrammaticInput: scrollToBottomAfterInput,
|
||||
@@ -2495,6 +2539,13 @@ export const createXTermRuntime = (ctx: CreateXTermRuntimeContext): XTermRuntime
|
||||
if (shiftEnterText) {
|
||||
e.preventDefault();
|
||||
e.stopPropagation();
|
||||
// Snapshot source-prompt sensitivity before the local write (#3491):
|
||||
// the default bare-newline send-text reaches the PTY as a
|
||||
// submission that clears passwordPromptActiveRef, so the live
|
||||
// prompt-source check in broadcastKittyInput would report this
|
||||
// Shift+Enter as nonsensitive and a peer would commit its buffered
|
||||
// source-sensitive password characters as ordinary input.
|
||||
const sourceSensitivePrompt = ctx.passwordPromptActiveRef?.current === true;
|
||||
// Skip string broadcast: peers resolve Shift+Enter from their own
|
||||
// negotiated keyboard mode via the key chord below.
|
||||
handleTerminalInputData(shiftEnterText, {
|
||||
@@ -2505,7 +2556,7 @@ export const createXTermRuntime = (ctx: CreateXTermRuntimeContext): XTermRuntime
|
||||
kind: "key",
|
||||
event: kittyEvent,
|
||||
fallbackToLegacy: true,
|
||||
});
|
||||
}, false, undefined, sourceSensitivePrompt ? { sourceSensitive: true } : undefined);
|
||||
if (forwarded) {
|
||||
upsertKittyKeyboardForwardedPress(
|
||||
broadcastForwardedKeys,
|
||||
@@ -2529,6 +2580,13 @@ export const createXTermRuntime = (ctx: CreateXTermRuntimeContext): XTermRuntime
|
||||
kittyEvent,
|
||||
[],
|
||||
);
|
||||
// Snapshot source-prompt sensitivity before the local write (#3491):
|
||||
// a Kitty-encoded Enter that reaches the PTY as bare CR submits here
|
||||
// and clears passwordPromptActiveRef, so the live prompt-source check
|
||||
// in broadcastKittyInput would report this Enter as nonsensitive and
|
||||
// a peer would commit its buffered source-sensitive password
|
||||
// characters as ordinary command/script bookkeeping.
|
||||
const sourceSensitivePrompt = ctx.passwordPromptActiveRef?.current === true;
|
||||
handleTerminalInputData(kittySequenceForKeyDown, { source: "kitty" });
|
||||
const forwarded = broadcastKittyInput({
|
||||
kind: "key",
|
||||
@@ -2537,7 +2595,7 @@ export const createXTermRuntime = (ctx: CreateXTermRuntimeContext): XTermRuntime
|
||||
urgentInterrupt: shouldUseUrgentTerminalInterrupt(e, {
|
||||
hasSelection: hasCopyableSelection,
|
||||
}),
|
||||
});
|
||||
}, false, undefined, sourceSensitivePrompt ? { sourceSensitive: true } : undefined);
|
||||
if (forwarded) {
|
||||
upsertKittyKeyboardForwardedPress(
|
||||
broadcastForwardedKeys,
|
||||
@@ -2733,8 +2791,15 @@ export const createXTermRuntime = (ctx: CreateXTermRuntimeContext): XTermRuntime
|
||||
const writeWin32InputModeEvent = (
|
||||
event: KittyKeyboardEvent,
|
||||
logicalData: string | null,
|
||||
writeOptions?: { sensitive?: boolean },
|
||||
) => {
|
||||
const pending = { event, logicalData };
|
||||
const pending = {
|
||||
event,
|
||||
logicalData,
|
||||
// A bypassed source-prompt fan-out (#3488) forces the sensitive marker so
|
||||
// this peer's Win32 write keeps input interceptors skipped.
|
||||
...(writeOptions?.sensitive === true ? { sensitive: true } : {}),
|
||||
};
|
||||
win32InputModePendingEvent = pending;
|
||||
dispatchWin32InputModeEvent(term, event);
|
||||
if (win32InputModePendingEvent === pending) {
|
||||
@@ -2880,9 +2945,15 @@ export const createXTermRuntime = (ctx: CreateXTermRuntimeContext): XTermRuntime
|
||||
acknowledgedWrite: true,
|
||||
});
|
||||
if (pending.broadcast && isTerminalBroadcastInputCurrent(pending.sourceInput)
|
||||
&& ctx.isBroadcastEnabledRef.current && !sensitive) {
|
||||
&& ctx.isBroadcastEnabledRef.current
|
||||
// The #3488 bypass keeps the paced fan-out alive when this line was
|
||||
// confirmed at (or lands on) a sensitive prompt.
|
||||
&& (!sensitive || ctx.broadcastPasswordBypassRef?.current === true)) {
|
||||
ctx.onBroadcastInputRef.current?.(`${index === 0 ? pending.firstPastedLine : command}\r`, ctx.sessionId, {
|
||||
pacedBroadcast: pending.broadcast,
|
||||
// Bypassed sensitive fan-out (#3488): tag the line so peer writes
|
||||
// keep input interceptors skipped, like raw-string broadcast does.
|
||||
...(sensitive ? { sourceSensitive: true } : {}),
|
||||
});
|
||||
}
|
||||
void pending.recordLine?.(index === 0 ? pending.firstPastedLine : command, {
|
||||
@@ -2898,7 +2969,11 @@ export const createXTermRuntime = (ctx: CreateXTermRuntimeContext): XTermRuntime
|
||||
if (!sessionId) return;
|
||||
markTerminalBroadcastUserInput(ctx.sessionId);
|
||||
const sourceInput = captureTerminalBroadcastInput(ctx.sessionId);
|
||||
const broadcast: TerminalPacedBroadcast | undefined = options.broadcast && !options.sensitive
|
||||
// The #3488 bypass lets a paste confirmed at a sensitive prompt keep the
|
||||
// paced fan-out: each acknowledged line is re-guarded on its receipt and
|
||||
// the dispatcher keeps peer writes sensitive via the source marker.
|
||||
const broadcast: TerminalPacedBroadcast | undefined = options.broadcast
|
||||
&& (!options.sensitive || ctx.broadcastPasswordBypassRef?.current === true)
|
||||
&& ctx.isBroadcastEnabledRef.current ? {} : undefined;
|
||||
if (broadcast) ctx.onBroadcastInputRef.current?.("", ctx.sessionId, { pacedBroadcast: broadcast, preparePacedBroadcast: true });
|
||||
const requestId = crypto.randomUUID();
|
||||
@@ -2970,6 +3045,11 @@ export const createXTermRuntime = (ctx: CreateXTermRuntimeContext): XTermRuntime
|
||||
handleTerminalInputData(data, {
|
||||
logicalData: win32Input.logicalData,
|
||||
skipBroadcast: true,
|
||||
// A bypassed source-prompt fan-out (#3488) forces the sensitive marker
|
||||
// so this peer's write keeps input interceptors skipped.
|
||||
...(win32Input.sensitive === true
|
||||
? { sensitive: true, sourceSensitiveBroadcast: true }
|
||||
: {}),
|
||||
});
|
||||
return;
|
||||
}
|
||||
@@ -2977,7 +3057,9 @@ export const createXTermRuntime = (ctx: CreateXTermRuntimeContext): XTermRuntime
|
||||
// must not classify the next physical key as a second composition commit.
|
||||
// Actual composition/input handlers clear the physical-key pairing first.
|
||||
if (broadcastLegacyDataPending) kittyCompositionPending = false;
|
||||
if (kittyCompositionPending && !data.startsWith("\u001b")) {
|
||||
// A rewritten IME hypothesis first emits standalone deletions. Route those
|
||||
// as backspaces and keep the composition marker for the replacement text.
|
||||
if (kittyCompositionPending && data !== "\x7f" && !data.startsWith("\u001b")) {
|
||||
kittyCompositionPending = false;
|
||||
if (kittyCompositionClearTimer !== undefined) {
|
||||
window.clearTimeout(kittyCompositionClearTimer);
|
||||
@@ -3039,31 +3121,45 @@ export const createXTermRuntime = (ctx: CreateXTermRuntimeContext): XTermRuntime
|
||||
win32ShiftEnterTextKeys: broadcastWin32ShiftEnterTextKeys,
|
||||
}),
|
||||
getSessionId: () => ctx.sessionRef.current,
|
||||
isSensitiveInput: () => ctx.passwordPromptActiveRef?.current === true,
|
||||
// Receiving side: the #3488 bypass lets Kitty broadcast land at password prompts too.
|
||||
isSensitiveInput: () => ctx.passwordPromptActiveRef?.current === true
|
||||
&& ctx.broadcastPasswordBypassRef?.current !== true,
|
||||
isConnected: () => ctx.statusRef.current === "connected",
|
||||
isRuntimeDisposed: () => runtimeDisposed,
|
||||
interruptSession: ctx.terminalBackend.interruptSession
|
||||
? (id) => ctx.terminalBackend.interruptSession?.(id)
|
||||
: undefined,
|
||||
writeDisposed: (id, data) => ctx.terminalBackend.writeToSession(
|
||||
writeDisposed: (id, data, writeOptions) => ctx.terminalBackend.writeToSession(
|
||||
id,
|
||||
mapTerminalBackspaceInput(data, ctx.host.backspaceBehavior),
|
||||
{ sensitive: ctx.passwordPromptActiveRef?.current === true },
|
||||
{
|
||||
sensitive: ctx.passwordPromptActiveRef?.current === true
|
||||
|| writeOptions?.sensitive === true,
|
||||
},
|
||||
),
|
||||
writeActive: (data, logicalData) => handleTerminalInputData(data, {
|
||||
writeActive: (data, logicalData, writeOptions) => handleTerminalInputData(data, {
|
||||
source: "kitty",
|
||||
logicalData,
|
||||
// A bypassed source-prompt fan-out (#3488) forces the sensitive marker so
|
||||
// this peer's write keeps input interceptors skipped.
|
||||
...(writeOptions?.sensitive === true
|
||||
? { sensitive: true, sourceSensitiveBroadcast: true }
|
||||
: {}),
|
||||
}),
|
||||
writeWin32Event: (event, logicalData) => {
|
||||
writeWin32InputModeEvent(event, logicalData);
|
||||
writeWin32Event: (event, logicalData, writeOptions) => {
|
||||
writeWin32InputModeEvent(event, logicalData, writeOptions);
|
||||
},
|
||||
});
|
||||
registerKittyKeyboardBroadcastHandler(
|
||||
ctx.sessionId,
|
||||
(input) => {
|
||||
// Forward the dispatch options (#3488): source-prompt fan-outs tag their
|
||||
// payload with sourceSensitive / beforeUrgentInterrupt, and dropping the
|
||||
// second argument here would downgrade bypassed password keystrokes to
|
||||
// ordinary peer input (leaving input interceptors / autocomplete exposed).
|
||||
(input, dispatchOptions) => {
|
||||
handlingKittyBroadcast = true;
|
||||
try {
|
||||
handleKittyKeyboardBroadcast(input);
|
||||
handleKittyKeyboardBroadcast(input, dispatchOptions);
|
||||
} finally {
|
||||
handlingKittyBroadcast = false;
|
||||
}
|
||||
|
||||
@@ -0,0 +1,516 @@
|
||||
import assert from "node:assert/strict";
|
||||
import test from "node:test";
|
||||
import { readFileSync } from "node:fs";
|
||||
import { dirname, join } from "node:path";
|
||||
import { fileURLToPath } from "node:url";
|
||||
import {
|
||||
commitTextareaChange,
|
||||
continuedCompositionData,
|
||||
continuedCompositionPrefix,
|
||||
keepLiveImeTranscriptionSingle,
|
||||
rememberTextareaCommit,
|
||||
type ImeCompositionCommitTarget,
|
||||
} from "./imeCompositionRewrite";
|
||||
|
||||
const BACKSPACE = "\x7f";
|
||||
|
||||
test("commitTextareaChange appends the new suffix", () => {
|
||||
assert.equal(commitTextareaChange("\u6211\u662f\u771f\u7684", "\u6211\u662f\u771f\u7684\u725b\u903c"), "\u725b\u903c");
|
||||
assert.equal(commitTextareaChange("", "\u6211\u662f\u771f\u7684"), "\u6211\u662f\u771f\u7684");
|
||||
assert.equal(commitTextareaChange("ls ", "ls \uff0c"), "\uff0c");
|
||||
});
|
||||
|
||||
test("commitTextareaChange counts a supplementary-plane character as one grapheme", () => {
|
||||
const emoji = "\u{1F600}";
|
||||
const other = "\u{1F601}";
|
||||
assert.equal(commitTextareaChange(`a${emoji}`, "aX"), `${BACKSPACE}X`);
|
||||
assert.equal(commitTextareaChange(emoji, other), `${BACKSPACE}${other}`);
|
||||
assert.equal(commitTextareaChange(`a${emoji}`, "a"), BACKSPACE);
|
||||
});
|
||||
|
||||
test("commitTextareaChange counts only characters forwarded to the terminal", () => {
|
||||
assert.equal(commitTextareaChange("ab\u200bcd", "abXcd"), `${BACKSPACE}${BACKSPACE}Xcd`);
|
||||
});
|
||||
|
||||
test("commitTextareaChange deletes the whole tail and retypes the new one", () => {
|
||||
assert.equal(commitTextareaChange("abcd", "abXY"), `${BACKSPACE}${BACKSPACE}XY`);
|
||||
assert.equal(commitTextareaChange("abcde", "abXde"), `${BACKSPACE}${BACKSPACE}${BACKSPACE}Xde`);
|
||||
assert.equal(commitTextareaChange("abcd", "ab"), `${BACKSPACE}${BACKSPACE}`);
|
||||
assert.equal(commitTextareaChange("hello", ""), BACKSPACE);
|
||||
assert.equal(commitTextareaChange("same", "same"), "");
|
||||
});
|
||||
|
||||
test("rememberTextareaCommit grows a hypothesis and replaces a rewrite", () => {
|
||||
assert.equal(rememberTextareaCommit("", "", "\u6211\u662f\u771f\u7684"), "\u6211\u662f\u771f\u7684");
|
||||
assert.equal(rememberTextareaCommit("\u6211\u662f\u771f\u7684", "\u6211\u662f\u771f\u7684", "\u725b\u903c"), "\u6211\u662f\u771f\u7684\u725b\u903c");
|
||||
assert.equal(
|
||||
rememberTextareaCommit("abcd", "abcd", `${BACKSPACE}${BACKSPACE}XY`),
|
||||
"abXY",
|
||||
);
|
||||
assert.equal(
|
||||
rememberTextareaCommit("abcde", "abcde", `${BACKSPACE}${BACKSPACE}${BACKSPACE}Xde`, "abXde"),
|
||||
"abXde",
|
||||
);
|
||||
assert.equal(
|
||||
rememberTextareaCommit("a\u{1F600}", "a\u{1F600}", `${BACKSPACE}X`),
|
||||
"aX",
|
||||
);
|
||||
assert.equal(rememberTextareaCommit("hello", "hello", BACKSPACE, ""), "");
|
||||
});
|
||||
|
||||
test("continuedCompositionPrefix keeps a new composition after a finished line", () => {
|
||||
assert.equal(continuedCompositionPrefix(undefined, "\u4f60\u597d"), "");
|
||||
assert.equal(
|
||||
continuedCompositionPrefix({
|
||||
continued: true,
|
||||
pendingPrevious: "hello\uff0c",
|
||||
alreadySent: "\uff0c",
|
||||
textareaAtStart: "hello\uff0c",
|
||||
}, "\u4f60\u597d"),
|
||||
"",
|
||||
);
|
||||
assert.equal(
|
||||
continuedCompositionPrefix({
|
||||
continued: false,
|
||||
pendingPrevious: "a",
|
||||
alreadySent: "a",
|
||||
textareaAtStart: "a",
|
||||
}, "apple"),
|
||||
"",
|
||||
);
|
||||
assert.equal(
|
||||
continuedCompositionPrefix({
|
||||
continued: true,
|
||||
pendingPrevious: "\u6211",
|
||||
alreadySent: "\u6211",
|
||||
textareaAtStart: "\u6211\u771f",
|
||||
}, "\u6211\u771f"),
|
||||
"\u6211",
|
||||
);
|
||||
});
|
||||
|
||||
test("continuedCompositionData rewrites a revised hypothesis instead of appending it", () => {
|
||||
const marker = {
|
||||
continued: true,
|
||||
pendingPrevious: "abcde",
|
||||
alreadySent: "abcde",
|
||||
textareaAtStart: "abXde",
|
||||
};
|
||||
assert.equal(
|
||||
continuedCompositionData(marker, "abXde", "abXde", 0),
|
||||
`${BACKSPACE}${BACKSPACE}${BACKSPACE}Xde`,
|
||||
);
|
||||
// The new character starts at the caret after "hello", so this is a new
|
||||
// composition. The caller sends the slice unchanged.
|
||||
assert.equal(
|
||||
continuedCompositionData({
|
||||
continued: true,
|
||||
pendingPrevious: "hello",
|
||||
alreadySent: "",
|
||||
textareaAtStart: "hello",
|
||||
}, "\u4f60", "hello\u4f60", 5),
|
||||
null,
|
||||
);
|
||||
// In-place extension of a one-character hypothesis still strips the prefix.
|
||||
assert.equal(
|
||||
continuedCompositionData({
|
||||
continued: true,
|
||||
pendingPrevious: "\u6211",
|
||||
alreadySent: "\u6211",
|
||||
textareaAtStart: "\u6211\u771f",
|
||||
}, "\u6211\u771f", "\u6211\u771f", 0),
|
||||
"\u771f",
|
||||
);
|
||||
// A new word that merely starts with the finished character is not a prefix.
|
||||
assert.equal(
|
||||
continuedCompositionData({
|
||||
continued: true,
|
||||
pendingPrevious: "\u6211",
|
||||
alreadySent: "\u6211",
|
||||
textareaAtStart: "\u6211\u6211\u4eec",
|
||||
}, "\u6211\u4eec", "\u6211\u6211\u4eec", 1),
|
||||
null,
|
||||
);
|
||||
});
|
||||
|
||||
test("continuedCompositionPrefix returns the hypothesis already on the PTY", () => {
|
||||
assert.equal(
|
||||
continuedCompositionPrefix({
|
||||
continued: true,
|
||||
pendingPrevious: "\u6211\u662f\u771f\u7684",
|
||||
alreadySent: "\u6211\u662f\u771f\u7684",
|
||||
textareaAtStart: "\u6211\u662f\u771f\u7684\u725b\u903c",
|
||||
}, "\u6211\u662f\u771f\u7684\u725b\u903c"),
|
||||
"\u6211\u662f\u771f\u7684",
|
||||
);
|
||||
assert.equal(
|
||||
continuedCompositionPrefix({
|
||||
continued: true,
|
||||
pendingPrevious: "ls \u6211\u662f\u771f\u7684",
|
||||
alreadySent: "\u6211\u662f\u771f\u7684",
|
||||
textareaAtStart: "ls \u6211\u662f\u771f\u7684\u725b\u903c",
|
||||
}, "ls \u6211\u662f\u771f\u7684\u725b\u903c"),
|
||||
"ls \u6211\u662f\u771f\u7684",
|
||||
);
|
||||
});
|
||||
|
||||
type Harness = ImeCompositionCommitTarget & {
|
||||
compositionstart: () => void;
|
||||
compositionend: () => void;
|
||||
keydown: (event: { keyCode: number }) => boolean;
|
||||
sent: string[];
|
||||
};
|
||||
|
||||
function createHarness(): Harness {
|
||||
const sent: string[] = [];
|
||||
const helper: Harness = {
|
||||
_textarea: { value: "" },
|
||||
_compositionView: { classList: { remove() {} } },
|
||||
_isComposing: false,
|
||||
_isSendingComposition: false,
|
||||
_compositionPosition: { start: 0, end: 0 },
|
||||
_compositionSuffix: "",
|
||||
_dataAlreadySent: "",
|
||||
_coreService: {
|
||||
triggerDataEvent(data: string) {
|
||||
sent.push(data);
|
||||
},
|
||||
},
|
||||
sent,
|
||||
compositionstart() {
|
||||
this._isComposing = true;
|
||||
this._compositionPosition = { start: 0, end: this._textarea.value.length };
|
||||
this._compositionSuffix = "";
|
||||
this._dataAlreadySent = "";
|
||||
this._compositionView.classList.remove("active");
|
||||
},
|
||||
compositionend() {
|
||||
this._finalizeComposition?.(true);
|
||||
},
|
||||
keydown(event) {
|
||||
if (this._isComposing || this._isSendingComposition) {
|
||||
if (event.keyCode === 229 || event.keyCode === 16) return false;
|
||||
this._finalizeComposition?.(false);
|
||||
}
|
||||
if (event.keyCode === 229) {
|
||||
this._handleAnyTextareaChanges?.();
|
||||
return false;
|
||||
}
|
||||
return true;
|
||||
},
|
||||
};
|
||||
return helper;
|
||||
}
|
||||
|
||||
function install(helper: Harness): void {
|
||||
keepLiveImeTranscriptionSingle({ _core: { _compositionHelper: helper } });
|
||||
}
|
||||
|
||||
async function flushTimers(): Promise<void> {
|
||||
await new Promise((resolve) => setTimeout(resolve, 0));
|
||||
}
|
||||
|
||||
test("live transcription of one sentence is written once", async () => {
|
||||
const helper = createHarness();
|
||||
install(helper);
|
||||
|
||||
helper.keydown({ keyCode: 229 });
|
||||
helper._textarea.value = "\u6211\u662f\u771f\u7684";
|
||||
await flushTimers();
|
||||
|
||||
helper.keydown({ keyCode: 229 });
|
||||
helper._textarea.value = "\u6211\u662f\u771f\u7684\u725b\u903c";
|
||||
helper.compositionstart();
|
||||
helper.compositionend();
|
||||
await flushTimers();
|
||||
await flushTimers();
|
||||
|
||||
assert.equal(helper.sent.join(""), "\u6211\u662f\u771f\u7684\u725b\u903c");
|
||||
});
|
||||
|
||||
test("a composition range that contains only the new tail is not prefixed again", async () => {
|
||||
const helper = createHarness();
|
||||
install(helper);
|
||||
|
||||
helper.keydown({ keyCode: 229 });
|
||||
helper._textarea.value = "ls \u6211\u662f\u771f\u7684";
|
||||
await flushTimers();
|
||||
|
||||
helper.keydown({ keyCode: 229 });
|
||||
helper._textarea.value = "ls \u6211\u662f\u771f\u7684\u725b\u903c";
|
||||
helper.compositionstart();
|
||||
helper._compositionPosition = { start: "ls \u6211\u662f\u771f\u7684".length, end: "ls \u6211\u662f\u771f\u7684\u725b\u903c".length };
|
||||
helper.compositionend();
|
||||
await flushTimers();
|
||||
await flushTimers();
|
||||
|
||||
assert.equal(helper.sent.join(""), "ls \u6211\u662f\u771f\u7684\u725b\u903c");
|
||||
});
|
||||
|
||||
test("a composition that only confirms the hypothesis does not append it again", async () => {
|
||||
const helper = createHarness();
|
||||
install(helper);
|
||||
|
||||
helper.keydown({ keyCode: 229 });
|
||||
helper._textarea.value = "\u6211\u662f\u771f\u7684\u725b\u903c";
|
||||
await flushTimers();
|
||||
|
||||
helper.keydown({ keyCode: 229 });
|
||||
helper.compositionstart();
|
||||
helper.compositionend();
|
||||
await flushTimers();
|
||||
await flushTimers();
|
||||
|
||||
assert.equal(helper.sent.join(""), "\u6211\u662f\u771f\u7684\u725b\u903c");
|
||||
});
|
||||
|
||||
test("normal compositionend still sends the committed word once", async () => {
|
||||
const helper = createHarness();
|
||||
install(helper);
|
||||
|
||||
helper._textarea.value = "hello";
|
||||
helper.keydown({ keyCode: 229 });
|
||||
helper._textarea.value = "hello\u4f60";
|
||||
helper.compositionstart();
|
||||
helper._compositionPosition = { start: 5, end: 6 };
|
||||
helper.compositionend();
|
||||
await flushTimers();
|
||||
|
||||
assert.deepEqual(helper.sent, ["\u4f60"]);
|
||||
});
|
||||
|
||||
test("punctuation entered through keyCode 229 sends only the new character", async () => {
|
||||
const helper = createHarness();
|
||||
install(helper);
|
||||
|
||||
helper._textarea.value = "ls ";
|
||||
helper.keydown({ keyCode: 229 });
|
||||
helper._textarea.value = "ls \uff0c";
|
||||
await flushTimers();
|
||||
|
||||
assert.deepEqual(helper.sent, ["\uff0c"]);
|
||||
});
|
||||
|
||||
test("keydown text already sent is not repeated on compositionend", async () => {
|
||||
const helper = createHarness();
|
||||
install(helper);
|
||||
|
||||
helper.keydown({ keyCode: 229 });
|
||||
helper._textarea.value = "abc";
|
||||
await flushTimers();
|
||||
helper._compositionPosition = { start: 0, end: 4 };
|
||||
helper._textarea.value = "abcd";
|
||||
helper.compositionend();
|
||||
await flushTimers();
|
||||
|
||||
assert.equal(helper.sent.join(""), "abcd");
|
||||
});
|
||||
|
||||
test("a rewritten hypothesis is not sent again on markerless compositionend", async () => {
|
||||
const helper = createHarness();
|
||||
install(helper);
|
||||
|
||||
helper.keydown({ keyCode: 229 });
|
||||
helper._textarea.value = "abcde";
|
||||
await flushTimers();
|
||||
|
||||
helper.keydown({ keyCode: 229 });
|
||||
helper._textarea.value = "abXde";
|
||||
await flushTimers();
|
||||
|
||||
helper._compositionPosition = { start: 0, end: "abXde".length };
|
||||
helper.compositionend();
|
||||
await flushTimers();
|
||||
|
||||
assert.deepEqual(helper.sent, ["abcde", BACKSPACE, BACKSPACE, BACKSPACE, "Xde"]);
|
||||
});
|
||||
|
||||
test("an equal-length rewrite does not resend the shared prefix", async () => {
|
||||
const helper = createHarness();
|
||||
install(helper);
|
||||
|
||||
helper.keydown({ keyCode: 229 });
|
||||
helper._textarea.value = "abcd";
|
||||
await flushTimers();
|
||||
helper.sent.length = 0;
|
||||
|
||||
helper.keydown({ keyCode: 229 });
|
||||
helper._textarea.value = "abXY";
|
||||
await flushTimers();
|
||||
|
||||
assert.deepEqual(helper.sent, [BACKSPACE, BACKSPACE, "XY"]);
|
||||
});
|
||||
|
||||
test("a revised composition replaces the hypothesis already on the PTY", async () => {
|
||||
const helper = createHarness();
|
||||
install(helper);
|
||||
|
||||
helper.keydown({ keyCode: 229 });
|
||||
helper._textarea.value = "abcde";
|
||||
await flushTimers();
|
||||
|
||||
helper.keydown({ keyCode: 229 });
|
||||
helper._textarea.value = "abXde";
|
||||
helper.compositionstart();
|
||||
helper.compositionend();
|
||||
await flushTimers();
|
||||
await flushTimers();
|
||||
|
||||
assert.deepEqual(helper.sent, ["abcde", BACKSPACE, BACKSPACE, BACKSPACE, "Xde"]);
|
||||
});
|
||||
|
||||
test("a one-character hypothesis is not repeated when composition extends it", async () => {
|
||||
const helper = createHarness();
|
||||
install(helper);
|
||||
|
||||
helper.keydown({ keyCode: 229 });
|
||||
helper._textarea.value = "\u6211";
|
||||
await flushTimers();
|
||||
|
||||
helper.keydown({ keyCode: 229 });
|
||||
helper._textarea.value = "\u6211\u771f";
|
||||
helper.compositionstart();
|
||||
helper.compositionend();
|
||||
await flushTimers();
|
||||
await flushTimers();
|
||||
|
||||
assert.equal(helper.sent.join(""), "\u6211\u771f");
|
||||
});
|
||||
|
||||
test("a new word that shares a prefix with a finished character is sent whole", async () => {
|
||||
const helper = createHarness();
|
||||
install(helper);
|
||||
|
||||
helper.keydown({ keyCode: 229 });
|
||||
helper._textarea.value = "\u6211";
|
||||
await flushTimers();
|
||||
|
||||
helper.keydown({ keyCode: 229 });
|
||||
helper._textarea.value = "\u6211\u6211\u4eec";
|
||||
helper.compositionstart();
|
||||
helper._compositionPosition = { start: 1, end: "\u6211\u6211\u4eec".length };
|
||||
helper.compositionend();
|
||||
await flushTimers();
|
||||
await flushTimers();
|
||||
|
||||
assert.equal(helper.sent.join(""), "\u6211\u6211\u4eec");
|
||||
});
|
||||
|
||||
test("a paused live transcription still extends its previously sent hypothesis", async () => {
|
||||
const helper = createHarness();
|
||||
install(helper);
|
||||
helper.keydown({ keyCode: 229 });
|
||||
helper._textarea.value = "\u6211\u662f\u771f\u7684";
|
||||
await flushTimers();
|
||||
helper.compositionstart();
|
||||
helper._textarea.value = "\u6211\u662f\u771f\u7684\u725b\u903c";
|
||||
helper._compositionPosition = { start: 0, end: helper._textarea.value.length };
|
||||
helper.compositionend();
|
||||
await flushTimers();
|
||||
assert.deepEqual(helper.sent, ["\u6211\u662f\u771f\u7684", "\u725b\u903c"]);
|
||||
});
|
||||
|
||||
test("a later new word with the same prefix is sent whole", async () => {
|
||||
const helper = createHarness();
|
||||
install(helper);
|
||||
helper.keydown({ keyCode: 229 });
|
||||
helper._textarea.value = "\u6211";
|
||||
await flushTimers();
|
||||
helper.compositionstart();
|
||||
helper._textarea.value = "\u6211\u6211\u4eec";
|
||||
helper._compositionPosition = { start: 1, end: helper._textarea.value.length };
|
||||
helper.compositionend();
|
||||
await flushTimers();
|
||||
assert.deepEqual(helper.sent, ["\u6211", "\u6211\u4eec"]);
|
||||
});
|
||||
|
||||
test("a sent rewrite is not repeated when its composition later confirms", async () => {
|
||||
const helper = createHarness();
|
||||
install(helper);
|
||||
helper.keydown({ keyCode: 229 });
|
||||
helper._textarea.value = "ls abcde";
|
||||
await flushTimers();
|
||||
helper.keydown({ keyCode: 229 });
|
||||
helper._textarea.value = "ls abXde";
|
||||
await flushTimers();
|
||||
helper.compositionstart();
|
||||
helper._compositionPosition = { start: 3, end: helper._textarea.value.length };
|
||||
helper.compositionend();
|
||||
await flushTimers();
|
||||
assert.deepEqual(helper.sent, ["ls abcde", BACKSPACE, BACKSPACE, BACKSPACE, "Xde"]);
|
||||
});
|
||||
|
||||
test("an earlier rewrite is delivered when the next composition starts first", async () => {
|
||||
const helper = createHarness();
|
||||
install(helper);
|
||||
helper.keydown({ keyCode: 229 });
|
||||
helper._textarea.value = "abcde";
|
||||
await flushTimers();
|
||||
|
||||
helper.compositionstart();
|
||||
helper._compositionPosition = { start: 0, end: 5 };
|
||||
helper._textarea.value = "abXde";
|
||||
helper.compositionend();
|
||||
|
||||
helper.compositionstart();
|
||||
helper._compositionPosition = { start: 5, end: 5 };
|
||||
helper._textarea.value = "abXdeY";
|
||||
helper._compositionPosition = { start: 5, end: 6 };
|
||||
|
||||
await flushTimers();
|
||||
helper.compositionend();
|
||||
await flushTimers();
|
||||
assert.deepEqual(helper.sent, ["abcde", BACKSPACE, BACKSPACE, BACKSPACE, "Xde", "Y"]);
|
||||
});
|
||||
|
||||
test("a punctuation commit does not make the next word repeat", async () => {
|
||||
const helper = createHarness();
|
||||
install(helper);
|
||||
helper.keydown({ keyCode: 229 });
|
||||
helper._textarea.value = "\uff0c";
|
||||
await flushTimers();
|
||||
helper.keydown({ keyCode: 229 });
|
||||
helper._textarea.value = "\uff0c\u4f60";
|
||||
await flushTimers();
|
||||
helper._compositionPosition = { start: 1, end: 2 };
|
||||
helper.compositionend();
|
||||
await flushTimers();
|
||||
assert.deepEqual(helper.sent, ["\uff0c", "\u4f60"]);
|
||||
});
|
||||
|
||||
test("filtered formatting characters do not break final composition deduplication", async () => {
|
||||
const helper = createHarness();
|
||||
install(helper);
|
||||
helper.keydown({ keyCode: 229 });
|
||||
helper._textarea.value = "ab";
|
||||
await flushTimers();
|
||||
helper.keydown({ keyCode: 229 });
|
||||
helper._textarea.value = "ab\u200b";
|
||||
await flushTimers();
|
||||
helper.keydown({ keyCode: 229 });
|
||||
helper._textarea.value = "ab\u200bX";
|
||||
await flushTimers();
|
||||
helper._compositionPosition = { start: 0, end: helper._textarea.value.length };
|
||||
helper.compositionend();
|
||||
await flushTimers();
|
||||
assert.deepEqual(helper.sent, ["ab", "X"]);
|
||||
});
|
||||
|
||||
test("keepLiveImeTranscriptionSingle is a no-op without a composition helper", () => {
|
||||
assert.doesNotThrow(() => keepLiveImeTranscriptionSingle({}));
|
||||
const helper = createHarness();
|
||||
install(helper);
|
||||
install(helper);
|
||||
assert.equal(helper.__ncImeCommitInstalled, true);
|
||||
});
|
||||
|
||||
test("createXTermRuntime installs the guard after the composition helper exists", () => {
|
||||
const source = readFileSync(
|
||||
join(dirname(fileURLToPath(import.meta.url)), "createXTermRuntime.ts"),
|
||||
"utf8",
|
||||
);
|
||||
const openAt = source.indexOf("term.open(ctx.container);");
|
||||
const installAt = source.indexOf("keepLiveImeTranscriptionSingle(term);");
|
||||
assert.ok(openAt >= 0);
|
||||
assert.ok(installAt > openAt);
|
||||
});
|
||||
@@ -0,0 +1,342 @@
|
||||
/**
|
||||
* xterm.js commits an IME textarea change with `newValue.replace(oldValue, "")`
|
||||
* and, on compositionend, skips `_dataAlreadySent.length` characters. Both
|
||||
* steps assume the IME only appends. Live transcription rewrites the whole
|
||||
* hypothesis, so the utterance is written as the
|
||||
* interim, again as the full final string, and again as the tail (#3421).
|
||||
*
|
||||
* Append-only commits (punctuation, a normal compositionend) stay as they are.
|
||||
* A composition that continues a multi-character hypothesis already sent to
|
||||
* the PTY writes only the unsent suffix, and the in-flight textarea timer is
|
||||
* dropped so it cannot append that suffix a second time.
|
||||
*/
|
||||
|
||||
import { sanitizeTerminalInput } from "./terminalInputSanitize";
|
||||
|
||||
const BACKSPACE = "\x7f";
|
||||
const graphemeSegmenter = new Intl.Segmenter(undefined, { granularity: "grapheme" });
|
||||
|
||||
function graphemes(value: string): string[] {
|
||||
return Array.from(graphemeSegmenter.segment(value), (part) => part.segment);
|
||||
}
|
||||
|
||||
export type ImeCompositionCommitTarget = {
|
||||
_textarea: { value: string };
|
||||
_compositionView: { classList: { remove(token: string): void } };
|
||||
_isComposing: boolean;
|
||||
_isSendingComposition: boolean;
|
||||
_compositionPosition: { start: number; end: number };
|
||||
_compositionSuffix: string;
|
||||
_dataAlreadySent: string;
|
||||
_textareaChangeTimer?: ReturnType<typeof setTimeout>;
|
||||
__ncPendingPrevious?: string;
|
||||
_coreService: { triggerDataEvent(data: string, wasUserInput?: boolean): void };
|
||||
compositionstart: () => void;
|
||||
_handleAnyTextareaChanges?: () => void;
|
||||
_finalizeComposition?: (waitForPropagation: boolean) => void;
|
||||
__ncImeCommitInstalled?: boolean;
|
||||
__ncImeCommit?: ImeCompositionMarker;
|
||||
};
|
||||
|
||||
export type ImeCompositionMarker = {
|
||||
/** A keydown-229 textarea timer was still pending when composition started. */
|
||||
continued: boolean;
|
||||
/** Textarea value that timer snapshotted, already reflected on the PTY. */
|
||||
pendingPrevious: string;
|
||||
alreadySent: string;
|
||||
textareaAtStart: string;
|
||||
};
|
||||
|
||||
/**
|
||||
* PTY edit for one textarea rewrite.
|
||||
* A pure append sends the new suffix. A pure shrink sends one backspace per
|
||||
* deleted character, except a cleared textarea, which stays a single
|
||||
* backspace. xterm empties the textarea when a line is submitted, and that
|
||||
* reset is not a dictation rewrite. Any other rewrite deletes only the
|
||||
* diverging span and inserts its replacement, instead of `String.replace`
|
||||
* or the whole new value.
|
||||
*/
|
||||
export function commitTextareaChange(previous: string, next: string): string {
|
||||
previous = sanitizeTerminalInput(previous);
|
||||
next = sanitizeTerminalInput(next);
|
||||
if (previous === next) return "";
|
||||
if (next.startsWith(previous)) return next.slice(previous.length);
|
||||
if (previous.startsWith(next)) {
|
||||
if (next.length === 0) return BACKSPACE;
|
||||
return BACKSPACE.repeat(graphemes(previous.slice(next.length)).length);
|
||||
}
|
||||
return rewriteDivergingSpan(previous, next);
|
||||
}
|
||||
|
||||
export function rememberTextareaCommit(
|
||||
alreadySent: string,
|
||||
previous: string,
|
||||
sent: string,
|
||||
next?: string,
|
||||
): string {
|
||||
if (!sent) return alreadySent;
|
||||
let index = 0;
|
||||
while (sent.charCodeAt(index) === 0x7f) index += 1;
|
||||
const inserted = sent.slice(index);
|
||||
if (index > 0) {
|
||||
// Backspaces edit the PTY in place. A later markerless compositionend
|
||||
// only skips `_dataAlreadySent` when it is a prefix of the final text,
|
||||
// so the whole revised value has to be remembered, not just the tail.
|
||||
// A cleared textarea is xterm's line-submit reset, not that value.
|
||||
if (typeof next === "string") return sanitizeTerminalInput(next);
|
||||
const parts = graphemes(previous);
|
||||
const kept = parts.slice(0, Math.max(0, parts.length - index)).join("");
|
||||
return kept + inserted;
|
||||
}
|
||||
if (sanitizeTerminalInput(previous).endsWith(alreadySent)) return alreadySent + inserted;
|
||||
return sent;
|
||||
}
|
||||
|
||||
/**
|
||||
* Prefix of `compositionText` already on the PTY. Set only when composition
|
||||
* started while a 229 textarea timer was still pending, so this composition
|
||||
* finishes the update that timer snapshotted, not the next word.
|
||||
*
|
||||
* The timer's snapshot is the text already written, including a one-character
|
||||
* hypothesis. A later composition that does not begin with that snapshot
|
||||
* (normal pinyin after a finished line) keeps every character.
|
||||
*/
|
||||
export function continuedCompositionPrefix(
|
||||
marker: ImeCompositionMarker | undefined,
|
||||
compositionText: string,
|
||||
): string {
|
||||
if (!marker?.continued) return "";
|
||||
if (
|
||||
marker.pendingPrevious.length > 0
|
||||
&& compositionText.startsWith(marker.pendingPrevious)
|
||||
) {
|
||||
return marker.pendingPrevious;
|
||||
}
|
||||
if (
|
||||
marker.alreadySent.length > 0
|
||||
&& compositionText.startsWith(marker.alreadySent)
|
||||
&& marker.textareaAtStart.endsWith(marker.alreadySent)
|
||||
) {
|
||||
return marker.alreadySent;
|
||||
}
|
||||
return "";
|
||||
}
|
||||
|
||||
function rewriteDivergingSpan(previous: string, next: string): string {
|
||||
const previousParts = graphemes(previous);
|
||||
const nextParts = graphemes(next);
|
||||
let start = 0;
|
||||
const shared = Math.min(previousParts.length, nextParts.length);
|
||||
while (start < shared && previousParts[start] === nextParts[start]) start += 1;
|
||||
// The cursor stays after the last cell. A shared suffix is still part of
|
||||
// the tail that must be deleted and typed again. Count graphemes so an
|
||||
// emoji is one deletion, not the two UTF-16 units of a surrogate pair.
|
||||
return BACKSPACE.repeat(previousParts.length - start) + nextParts.slice(start).join("");
|
||||
}
|
||||
|
||||
/**
|
||||
* What a continued composition should write. Null means this composition is a
|
||||
* new word and the caller sends the slice unchanged.
|
||||
*
|
||||
* An extension of the snapshot sends only the new suffix. A revision sends
|
||||
* the same edit the cancelled textarea timer would have sent, so `abcde`
|
||||
* replaced by `abXde` does not get appended after the old hypothesis.
|
||||
* A composition that starts at or after the snapshot is a new word. A
|
||||
* finished character followed by a new word that begins with that same
|
||||
* character must be sent whole.
|
||||
*/
|
||||
export function continuedCompositionData(
|
||||
marker: ImeCompositionMarker | undefined,
|
||||
compositionText: string,
|
||||
textareaNow: string,
|
||||
compositionStart: number,
|
||||
followingComposition = false,
|
||||
): string | null {
|
||||
if (!marker || !compositionText) return null;
|
||||
// A live hypothesis can settle before the next composition starts. In that
|
||||
// case the timer has fired, but its sent text is still the textarea suffix.
|
||||
const previous = marker.continued ? marker.pendingPrevious : marker.textareaAtStart;
|
||||
if (!marker.continued && (
|
||||
!marker.alreadySent || !sanitizeTerminalInput(previous).endsWith(marker.alreadySent)
|
||||
)) {
|
||||
return null;
|
||||
}
|
||||
if (!previous) return null;
|
||||
if (compositionStart >= previous.length) return null;
|
||||
if (!marker.continued && compositionText === previous.slice(compositionStart)) return "";
|
||||
if (compositionText.startsWith(previous)) return compositionText.slice(previous.length);
|
||||
if (
|
||||
marker.alreadySent.length > 0
|
||||
&& sanitizeTerminalInput(compositionText).startsWith(marker.alreadySent)
|
||||
&& (
|
||||
sanitizeTerminalInput(textareaNow).endsWith(marker.alreadySent)
|
||||
|| sanitizeTerminalInput(marker.textareaAtStart).endsWith(marker.alreadySent)
|
||||
)
|
||||
) {
|
||||
return sanitizeTerminalInput(compositionText).slice(marker.alreadySent.length);
|
||||
}
|
||||
if (textareaNow === compositionText || marker.textareaAtStart === compositionText) {
|
||||
return commitTextareaChange(previous, compositionText);
|
||||
}
|
||||
if (
|
||||
followingComposition
|
||||
&& textareaNow.startsWith(compositionText, compositionStart)
|
||||
&& textareaNow.length > compositionStart + compositionText.length
|
||||
) {
|
||||
return commitTextareaChange(previous.slice(compositionStart), compositionText);
|
||||
}
|
||||
if (textareaNow.endsWith(compositionText) && textareaNow.length > compositionText.length) {
|
||||
const headLength = textareaNow.length - compositionText.length;
|
||||
if (
|
||||
previous.length >= headLength
|
||||
&& textareaNow.startsWith(previous.slice(0, headLength))
|
||||
) {
|
||||
return commitTextareaChange(previous.slice(headLength), compositionText);
|
||||
}
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
||||
function emitPtyData(helper: ImeCompositionCommitTarget, data: string): void {
|
||||
// One event per backspace so mapTerminalBackspaceInput and the command
|
||||
// buffer see a standalone DEL or Ctrl-H, then the replacement text.
|
||||
let index = 0;
|
||||
while (data.charCodeAt(index) === 0x7f) {
|
||||
helper._coreService.triggerDataEvent(BACKSPACE, true);
|
||||
index += 1;
|
||||
}
|
||||
const inserted = data.slice(index);
|
||||
if (inserted.length > 0) helper._coreService.triggerDataEvent(inserted, true);
|
||||
}
|
||||
|
||||
function compositionSlice(
|
||||
helper: ImeCompositionCommitTarget,
|
||||
rangeStart: number,
|
||||
suffix: string,
|
||||
composingNow: boolean,
|
||||
newCompositionStart: number,
|
||||
): string {
|
||||
const value = helper._textarea.value;
|
||||
if (composingNow) return value.substring(rangeStart, newCompositionStart);
|
||||
const valueEnd = suffix.length > 0 && value.endsWith(suffix)
|
||||
? value.length - suffix.length
|
||||
: value.length;
|
||||
return value.substring(rangeStart, Math.max(rangeStart, valueEnd));
|
||||
}
|
||||
|
||||
function deliverComposition(
|
||||
helper: ImeCompositionCommitTarget,
|
||||
compositionText: string,
|
||||
marker: ImeCompositionMarker | undefined,
|
||||
compositionStart: number,
|
||||
followingComposition = false,
|
||||
): void {
|
||||
const continued = continuedCompositionData(
|
||||
marker,
|
||||
compositionText,
|
||||
helper._textarea.value,
|
||||
compositionStart,
|
||||
followingComposition,
|
||||
);
|
||||
if (continued !== null) {
|
||||
if (continued.length > 0) emitPtyData(helper, continued);
|
||||
if (!helper._isComposing) helper._dataAlreadySent = "";
|
||||
return;
|
||||
}
|
||||
const cleanText = sanitizeTerminalInput(compositionText);
|
||||
const live = helper._dataAlreadySent && cleanText.startsWith(helper._dataAlreadySent)
|
||||
? helper._dataAlreadySent
|
||||
: "";
|
||||
const data = live
|
||||
? cleanText.slice(live.length)
|
||||
: helper._dataAlreadySent.endsWith(cleanText) ? "" : cleanText;
|
||||
if (data.length > 0) emitPtyData(helper, data);
|
||||
if (!helper._isComposing) helper._dataAlreadySent = "";
|
||||
}
|
||||
|
||||
/**
|
||||
* Installs the commit guard on one terminal. CompositionHelper is created in
|
||||
* `terminal.open()`, so this runs after open. Missing helpers (tests, log
|
||||
* view) are left untouched.
|
||||
*/
|
||||
export function keepLiveImeTranscriptionSingle(term: object): void {
|
||||
const helper = (term as { _core?: { _compositionHelper?: ImeCompositionCommitTarget } })
|
||||
._core?._compositionHelper;
|
||||
if (!helper || helper.__ncImeCommitInstalled || typeof helper.compositionstart !== "function") {
|
||||
return;
|
||||
}
|
||||
helper.__ncImeCommitInstalled = true;
|
||||
const originalStart = helper.compositionstart.bind(helper);
|
||||
|
||||
helper.compositionstart = () => {
|
||||
const alreadySent = helper._dataAlreadySent;
|
||||
const pendingPrevious = helper.__ncPendingPrevious ?? "";
|
||||
const continued = helper._textareaChangeTimer !== undefined;
|
||||
if (helper._textareaChangeTimer !== undefined) {
|
||||
clearTimeout(helper._textareaChangeTimer);
|
||||
helper._textareaChangeTimer = undefined;
|
||||
}
|
||||
helper.__ncPendingPrevious = undefined;
|
||||
originalStart();
|
||||
helper.__ncImeCommit = {
|
||||
continued,
|
||||
pendingPrevious,
|
||||
alreadySent,
|
||||
textareaAtStart: helper._textarea.value,
|
||||
};
|
||||
};
|
||||
|
||||
helper._handleAnyTextareaChanges = () => {
|
||||
if (helper._textareaChangeTimer !== undefined) return;
|
||||
const previous = helper._textarea.value;
|
||||
helper.__ncPendingPrevious = previous;
|
||||
helper._textareaChangeTimer = setTimeout(() => {
|
||||
helper._textareaChangeTimer = undefined;
|
||||
helper.__ncPendingPrevious = undefined;
|
||||
helper.__ncImeCommit = undefined;
|
||||
if (helper._isComposing || helper._isSendingComposition) return;
|
||||
const next = helper._textarea.value;
|
||||
const data = commitTextareaChange(previous, next);
|
||||
if (!data) return;
|
||||
helper._dataAlreadySent = rememberTextareaCommit(helper._dataAlreadySent, previous, data, next);
|
||||
emitPtyData(helper, data);
|
||||
}, 0);
|
||||
};
|
||||
|
||||
helper._finalizeComposition = (waitForPropagation: boolean) => {
|
||||
const marker = helper.__ncImeCommit;
|
||||
helper.__ncImeCommit = undefined;
|
||||
helper._compositionView.classList.remove("active");
|
||||
helper._isComposing = false;
|
||||
|
||||
if (!waitForPropagation) {
|
||||
helper._isSendingComposition = false;
|
||||
const compositionText = helper._textarea.value.substring(
|
||||
helper._compositionPosition.start,
|
||||
helper._compositionPosition.end,
|
||||
);
|
||||
deliverComposition(helper, compositionText, marker, helper._compositionPosition.start);
|
||||
return;
|
||||
}
|
||||
|
||||
const rangeStart = helper._compositionPosition.start;
|
||||
const suffix = helper._compositionSuffix;
|
||||
const alreadySent = helper._dataAlreadySent;
|
||||
helper._isSendingComposition = true;
|
||||
setTimeout(() => {
|
||||
if (!helper._isSendingComposition) return;
|
||||
helper._isSendingComposition = false;
|
||||
const compositionText = compositionSlice(
|
||||
helper,
|
||||
rangeStart,
|
||||
suffix,
|
||||
helper._isComposing,
|
||||
helper._compositionPosition.start,
|
||||
);
|
||||
// compositionstart may have cleared the live field. The no-composition
|
||||
// path (#3191) still has the keydown commit in this snapshot.
|
||||
if (!marker && alreadySent) helper._dataAlreadySent = alreadySent;
|
||||
deliverComposition(helper, compositionText, marker, rangeStart, helper._isComposing && !suffix);
|
||||
}, 0);
|
||||
};
|
||||
}
|
||||
@@ -1163,3 +1163,139 @@ test("a dedicated keyIdentity pairs the interrupt press with its release (#3409)
|
||||
assert.equal(defaultRelease?.data, "\x1b[97;1:3u");
|
||||
assert.equal(defaultOptions.encodedKeys.has("KeyA"), false);
|
||||
});
|
||||
|
||||
test("a bypassed password-prompt source tags Kitty broadcasts as sourceSensitive", () => {
|
||||
const dispatched: Array<{
|
||||
kittyKeyboardInput?: KittyKeyboardBroadcastInput;
|
||||
sourceSensitive?: boolean;
|
||||
}> = [];
|
||||
const forward = createKittyKeyboardBroadcastForwarder({
|
||||
sourceSessionId: "source",
|
||||
isHandlingBroadcast: () => false,
|
||||
isBroadcastEnabled: () => true,
|
||||
// #3488 bypass: the sensitive-prompt pause is lifted, but the source still
|
||||
// tags the dispatch so peers keep input interceptors skipped.
|
||||
isSensitiveInput: () => false,
|
||||
isSensitivePromptSource: () => true,
|
||||
getDispatcher: () => (_data, _sourceSessionId, dispatchOptions) => {
|
||||
dispatched.push(dispatchOptions);
|
||||
return ["target-a"];
|
||||
},
|
||||
});
|
||||
const press: KittyKeyboardBroadcastInput = {
|
||||
kind: "key",
|
||||
event: { type: "keydown", key: "x", code: "KeyX" },
|
||||
};
|
||||
|
||||
assert.deepEqual(forward(press), { targetSessionIds: ["target-a"] });
|
||||
assert.deepEqual(dispatched, [
|
||||
{ kittyKeyboardInput: press, sourceSensitive: true },
|
||||
]);
|
||||
});
|
||||
|
||||
test("a pre-write sensitivity snapshot tags the Kitty dispatch as sourceSensitive", () => {
|
||||
const dispatched: Array<{
|
||||
kittyKeyboardInput?: KittyKeyboardBroadcastInput;
|
||||
sourceSensitive?: boolean;
|
||||
}> = [];
|
||||
const forward = createKittyKeyboardBroadcastForwarder({
|
||||
sourceSessionId: "source",
|
||||
isHandlingBroadcast: () => false,
|
||||
isBroadcastEnabled: () => true,
|
||||
isSensitiveInput: () => false,
|
||||
// #3491: the source's local Enter submission already cleared the live
|
||||
// prompt flag before this dispatch runs, so the live check reports
|
||||
// nonsensitive and only the pre-write snapshot can restore the tag.
|
||||
isSensitivePromptSource: () => false,
|
||||
getDispatcher: () => (_data, _sourceSessionId, dispatchOptions) => {
|
||||
dispatched.push(dispatchOptions);
|
||||
return ["target-a"];
|
||||
},
|
||||
});
|
||||
const enter: KittyKeyboardBroadcastInput = {
|
||||
kind: "key",
|
||||
event: { type: "keydown", key: "Enter", code: "Enter" },
|
||||
};
|
||||
|
||||
// Snapshot taken before the local write says the source sat at a prompt.
|
||||
assert.deepEqual(
|
||||
forward(enter, false, undefined, { sourceSensitive: true }),
|
||||
{ targetSessionIds: ["target-a"] },
|
||||
);
|
||||
// Without a snapshot the dispatch stays nonsensitive (live flag is false).
|
||||
forward(enter);
|
||||
assert.deepEqual(dispatched, [
|
||||
{ kittyKeyboardInput: enter, sourceSensitive: true },
|
||||
{ kittyKeyboardInput: enter },
|
||||
]);
|
||||
});
|
||||
|
||||
test("a source-sensitive Kitty dispatch forces sensitive peer writes", () => {
|
||||
const mode = createKittyKeyboardModeState();
|
||||
const activeWrites: Array<{ data: string; sensitive?: boolean }> = [];
|
||||
const handler = createKittyKeyboardBroadcastHandler({
|
||||
resolveOptions: () => ({
|
||||
kittyProtocolEnabled: false,
|
||||
kittyMode: mode,
|
||||
applicationCursorMode: false,
|
||||
encodedKeys: new Set<string>(),
|
||||
}),
|
||||
getSessionId: () => "peer-session",
|
||||
// The peer has not classified its own prompt as sensitive.
|
||||
isSensitiveInput: () => false,
|
||||
isConnected: () => true,
|
||||
isRuntimeDisposed: () => false,
|
||||
writeDisposed: () => {},
|
||||
writeActive: (data, _logicalData, writeOptions) => {
|
||||
activeWrites.push({ data, sensitive: writeOptions?.sensitive === true });
|
||||
},
|
||||
});
|
||||
|
||||
handler({ kind: "text", text: "secret" });
|
||||
assert.deepEqual(activeWrites, [{ data: "secret", sensitive: false }]);
|
||||
|
||||
handler({ kind: "text", text: "secret" }, { sourceSensitive: true });
|
||||
assert.deepEqual(activeWrites, [
|
||||
{ data: "secret", sensitive: false },
|
||||
{ data: "secret", sensitive: true },
|
||||
]);
|
||||
});
|
||||
|
||||
test("a source-sensitive dispatch forces sensitive Win32 peer writes", () => {
|
||||
const encodedKeys = new Set<string>();
|
||||
const win32Writes: Array<{ type?: string; sensitive?: boolean }> = [];
|
||||
const handler = createKittyKeyboardBroadcastHandler({
|
||||
resolveOptions: () => ({
|
||||
kittyProtocolEnabled: false,
|
||||
kittyMode: createKittyKeyboardModeState(),
|
||||
applicationCursorMode: false,
|
||||
encodedKeys,
|
||||
win32InputMode: true,
|
||||
}),
|
||||
getSessionId: () => "win32-peer",
|
||||
// The peer has not classified its own prompt as sensitive.
|
||||
isSensitiveInput: () => false,
|
||||
isConnected: () => true,
|
||||
isRuntimeDisposed: () => false,
|
||||
writeDisposed: () => {},
|
||||
writeActive: () => {},
|
||||
writeWin32Event: (event, _logicalData, writeOptions) => {
|
||||
win32Writes.push({
|
||||
type: event.type,
|
||||
sensitive: writeOptions?.sensitive === true,
|
||||
});
|
||||
},
|
||||
});
|
||||
|
||||
handler({ kind: "key", event: { type: "keydown", key: "Enter", code: "Enter", shiftKey: true } });
|
||||
assert.deepEqual(win32Writes, [{ type: "keydown", sensitive: false }]);
|
||||
|
||||
handler(
|
||||
{ kind: "key", event: { type: "keydown", key: "Enter", code: "Enter", shiftKey: true } },
|
||||
{ sourceSensitive: true },
|
||||
);
|
||||
assert.deepEqual(win32Writes, [
|
||||
{ type: "keydown", sensitive: false },
|
||||
{ type: "keydown", sensitive: true },
|
||||
]);
|
||||
});
|
||||
@@ -39,6 +39,12 @@ export type KittyKeyboardBroadcastInput =
|
||||
|
||||
type KittyKeyboardBroadcastDispatchOptions = {
|
||||
beforeUrgentInterrupt?: () => void;
|
||||
/**
|
||||
* The source session dispatched this input from an active password /
|
||||
* sensitive prompt under the #3488 bypass. The receiving peer must keep the
|
||||
* sensitive marker on its own writes so input interceptors stay skipped.
|
||||
*/
|
||||
sourceSensitive?: boolean;
|
||||
};
|
||||
|
||||
type KittyKeyboardBroadcastHandler = (
|
||||
@@ -60,12 +66,15 @@ export const createKittyKeyboardBroadcastForwarder = (options: {
|
||||
isHandlingBroadcast: () => boolean;
|
||||
isBroadcastEnabled: () => boolean;
|
||||
isSensitiveInput?: () => boolean;
|
||||
/** True while the source session sits at an active password / sensitive prompt. */
|
||||
isSensitivePromptSource?: () => boolean;
|
||||
getDispatcher: () => ((
|
||||
data: string,
|
||||
sourceSessionId: string,
|
||||
options: {
|
||||
kittyKeyboardInput: KittyKeyboardBroadcastInput;
|
||||
kittyKeyboardTargetSessionIds?: string[];
|
||||
sourceSensitive?: boolean;
|
||||
},
|
||||
) => string[] | void) | null | undefined;
|
||||
}) => {
|
||||
@@ -74,6 +83,13 @@ export const createKittyKeyboardBroadcastForwarder = (options: {
|
||||
input: KittyKeyboardBroadcastInput,
|
||||
forcePairedRelease = false,
|
||||
targetSessionIds?: string[],
|
||||
/**
|
||||
* Source-prompt sensitivity snapshotted before the source's own local
|
||||
* write (#3491): a submitting key such as Enter clears the live prompt
|
||||
* flag before this dispatch runs, so the live check alone would report
|
||||
* the payload as nonsensitive.
|
||||
*/
|
||||
dispatchOptions?: { sourceSensitive?: boolean },
|
||||
): { targetSessionIds: string[] } | null => {
|
||||
const currentDispatcher = options.getDispatcher();
|
||||
if (currentDispatcher) lastDispatcher = currentDispatcher;
|
||||
@@ -84,8 +100,15 @@ export const createKittyKeyboardBroadcastForwarder = (options: {
|
||||
(!forcePairedRelease && !options.isBroadcastEnabled()) ||
|
||||
!dispatcher
|
||||
) return null;
|
||||
// Bypassed password fan-out (#3488): tag the dispatch so peer writes keep
|
||||
// input interceptors skipped for the secret keystrokes. A pre-write
|
||||
// snapshot wins (#3491): the local submission may have already cleared
|
||||
// the live prompt flag by the time this dispatch runs.
|
||||
const sourceSensitive = dispatchOptions?.sourceSensitive === true
|
||||
|| options.isSensitivePromptSource?.() === true;
|
||||
const deliveredSessionIds = dispatcher("", options.sourceSessionId, {
|
||||
kittyKeyboardInput: input,
|
||||
...(sourceSensitive ? { sourceSensitive: true } : {}),
|
||||
...(targetSessionIds ? { kittyKeyboardTargetSessionIds: targetSessionIds } : {}),
|
||||
});
|
||||
return { targetSessionIds: deliveredSessionIds ?? targetSessionIds ?? [] };
|
||||
@@ -416,9 +439,17 @@ export const createKittyKeyboardBroadcastHandler = (options: {
|
||||
isConnected: () => boolean;
|
||||
isRuntimeDisposed: () => boolean;
|
||||
interruptSession?: (sessionId: string) => void;
|
||||
writeDisposed: (sessionId: string, data: string) => void;
|
||||
writeActive: (data: string, logicalData?: string | null) => void;
|
||||
writeWin32Event?: (event: KittyKeyboardEvent, logicalData: string | null) => void;
|
||||
writeDisposed: (sessionId: string, data: string, writeOptions?: { sensitive?: boolean }) => void;
|
||||
writeActive: (
|
||||
data: string,
|
||||
logicalData?: string | null,
|
||||
writeOptions?: { sensitive?: boolean },
|
||||
) => void;
|
||||
writeWin32Event?: (
|
||||
event: KittyKeyboardEvent,
|
||||
logicalData: string | null,
|
||||
writeOptions?: { sensitive?: boolean },
|
||||
) => void;
|
||||
}): KittyKeyboardBroadcastHandler => (input, dispatchOptions) => {
|
||||
const sessionId = options.getSessionId();
|
||||
if (!sessionId || !options.isConnected()) return;
|
||||
@@ -433,20 +464,34 @@ export const createKittyKeyboardBroadcastHandler = (options: {
|
||||
options.interruptSession(sessionId);
|
||||
return;
|
||||
}
|
||||
// The source dispatched from a bypassed password prompt: keep the sensitive
|
||||
// marker on the peer write even when this peer's own prompt is not (yet)
|
||||
// classified sensitive, so input interceptors stay skipped for the secret.
|
||||
// Computed before the Win32 branch so its write stays sensitive too.
|
||||
const forceSensitiveWrite = dispatchOptions?.sourceSensitive === true;
|
||||
if (resolved.win32Event) {
|
||||
if (!options.isRuntimeDisposed()) {
|
||||
options.writeWin32Event?.(
|
||||
resolved.win32Event,
|
||||
resolved.logicalData ?? null,
|
||||
forceSensitiveWrite ? { sensitive: true } : undefined,
|
||||
);
|
||||
}
|
||||
return;
|
||||
}
|
||||
if (options.isRuntimeDisposed()) {
|
||||
options.writeDisposed(sessionId, resolved.data);
|
||||
options.writeDisposed(
|
||||
sessionId,
|
||||
resolved.data,
|
||||
forceSensitiveWrite ? { sensitive: true } : undefined,
|
||||
);
|
||||
return;
|
||||
}
|
||||
options.writeActive(resolved.data, resolved.logicalData);
|
||||
options.writeActive(
|
||||
resolved.data,
|
||||
resolved.logicalData,
|
||||
forceSensitiveWrite ? { sensitive: true } : undefined,
|
||||
);
|
||||
};
|
||||
|
||||
const handlers = new Map<string, KittyKeyboardBroadcastHandler>();
|
||||
|
||||
@@ -215,11 +215,11 @@ test("runtime routes Shift+Enter text through the shared input handler", () => {
|
||||
);
|
||||
assert.match(
|
||||
source,
|
||||
/const shiftEnterText = resolveShiftEnterText\([\s\S]*?if \(shiftEnterText\) \{[\s\S]*?handleTerminalInputData\(shiftEnterText, \{\s*source: "shift-enter",\s*skipBroadcast: true,\s*\}\);\s*const forwarded = broadcastKittyInput\(\{\s*kind: "key",\s*event: kittyEvent,\s*fallbackToLegacy: true,\s*\}\);/s,
|
||||
/const shiftEnterText = resolveShiftEnterText\([\s\S]*?if \(shiftEnterText\) \{[\s\S]*?const sourceSensitivePrompt = ctx\.passwordPromptActiveRef\?\.current === true;[\s\S]*?handleTerminalInputData\(shiftEnterText, \{\s*source: "shift-enter",\s*skipBroadcast: true,\s*\}\);\s*const forwarded = broadcastKittyInput\(\{\s*kind: "key",\s*event: kittyEvent,\s*fallbackToLegacy: true,\s*\}, false, undefined, sourceSensitivePrompt \? \{ sourceSensitive: true \} : undefined\);/s,
|
||||
);
|
||||
assert.match(
|
||||
source,
|
||||
/const canBroadcastInput = !sensitive &&[\s\S]*?const willBroadcastInput = canBroadcastInput && options\?\.skipBroadcast !== true;[\s\S]*?if \(!canBroadcastInput && !handlingKittyBroadcast\) \{\s*prepareSudoAutofillInput/s,
|
||||
/const canBroadcastInput = \(!sensitive\s*\|\|\s*\(ctx\.broadcastPasswordBypassRef\?\.current === true && options\?\.sensitive !== true\)\) &&[\s\S]*?const willBroadcastInput = canBroadcastInput && options\?\.skipBroadcast !== true;[\s\S]*?if \(!canBroadcastInput && !handlingKittyBroadcast\) \{\s*prepareSudoAutofillInput/s,
|
||||
);
|
||||
assert.match(
|
||||
source,
|
||||
|
||||
@@ -39,10 +39,10 @@ const callbackCode = ts.transpileModule(`
|
||||
${textInputMarker}
|
||||
${keyboardCallback}
|
||||
${dataCallback}
|
||||
globalThis.controls = { mark: markBroadcastLegacyDataPending, clear: clearBroadcastLegacyDataPending, input: markKittyTextInput };
|
||||
globalThis.controls = { mark: markBroadcastLegacyDataPending, clear: clearBroadcastLegacyDataPending, input: markKittyTextInput, composition: markKittyCompositionPending };
|
||||
`, { compilerOptions: { target: ts.ScriptTarget.ES2022 } }).outputText;
|
||||
|
||||
function setup(flags = 0) {
|
||||
function setup(flags = 0, sourceFlags = 0) {
|
||||
const writes: string[] = [];
|
||||
const timers = new Map<number, () => void>();
|
||||
let timerId = 0;
|
||||
@@ -51,6 +51,8 @@ function setup(flags = 0) {
|
||||
const forwarded = new Map<string, { targetSessionIds: string[] }>();
|
||||
const mode = createKittyKeyboardModeState();
|
||||
setKittyKeyboardModeFlags(mode, flags);
|
||||
const sourceMode = createKittyKeyboardModeState();
|
||||
setKittyKeyboardModeFlags(sourceMode, sourceFlags);
|
||||
const options = {
|
||||
kittyProtocolEnabled: flags !== 0, kittyMode: mode, applicationCursorMode: false,
|
||||
encodedKeys: new Set<string>(), legacySuppressedKeys: new Set<string>(),
|
||||
@@ -60,7 +62,7 @@ function setup(flags = 0) {
|
||||
if (result) writes.push(result.data);
|
||||
};
|
||||
const context = {
|
||||
controls: undefined as unknown as { mark: (identity: string) => void; clear: () => void; input: (event: { data: string; inputType: string }) => void },
|
||||
controls: undefined as unknown as { mark: (identity: string) => void; clear: () => void; input: (event: { data: string; inputType: string }) => void; composition: () => void },
|
||||
window: {
|
||||
setTimeout(fn: () => void) { const id = ++timerId; timers.set(id, fn); return id; },
|
||||
clearTimeout(id: number) { timers.delete(id); },
|
||||
@@ -73,7 +75,7 @@ function setup(flags = 0) {
|
||||
ctx: { terminalSettingsRef: { current: {} }, isBroadcastEnabledRef: { current: true }, onBroadcastInputRef: { current: () => undefined } },
|
||||
imeTextInputDeferredKey: null, imeTextInputDeferredKittyEvent: null,
|
||||
shouldBlockKeyPressForImeTextInput, shouldCommitDeferredImeTextInput, shouldMarkKittyTextInputEvent, shouldEncodeKittyCompositionText, encodeKittyCompositionText,
|
||||
kittyKeyboardMode: createKittyKeyboardModeState(), shouldSplitImeTextInputForWire: () => false,
|
||||
kittyKeyboardMode: sourceMode, shouldSplitImeTextInputForWire: () => false,
|
||||
kittyKeyIdentity: (event: Partial<KeyboardEvent>) => event.code || event.key,
|
||||
broadcastForwardedKeys: forwarded,
|
||||
broadcastKittyInput: normalized,
|
||||
@@ -99,6 +101,16 @@ function setup(flags = 0) {
|
||||
};
|
||||
}
|
||||
|
||||
test("IME rewrite backspaces leave Kitty composition routing for replacement text", () => {
|
||||
const runtime = setup(0, 24);
|
||||
runtime.controls.composition();
|
||||
runtime.receive("\x7f");
|
||||
runtime.receive("Xde");
|
||||
assert.equal(runtime.writes[0], "\x7f");
|
||||
assert.ok(runtime.writes.includes("\x1b[0;;88:100:101u"));
|
||||
assert.ok(!runtime.writes.includes("\x1b[0;;127u"));
|
||||
});
|
||||
|
||||
for (const flags of [0, 8]) {
|
||||
for (const [key, code] of [["A", "KeyA"], [" ", "Space"]]) {
|
||||
test(`broadcast pairs delayed ${code} keypress once with target flags ${flags}`, () => {
|
||||
|
||||
@@ -6,10 +6,9 @@ import {
|
||||
runDistroDetection,
|
||||
} from "./terminalDistroDetection.ts";
|
||||
|
||||
test("runDistroDetection skips POSIX probes when singleChannelSsh is enabled", async () => {
|
||||
test("runDistroDetection still probes when only a legacy host flag is set", async () => {
|
||||
let distroProbeCalls = 0;
|
||||
const detected: string[] = [];
|
||||
const token = registerConnectionToken("bastion-session");
|
||||
const token = registerConnectionToken("legacy-flag-session");
|
||||
|
||||
await runDistroDetection({
|
||||
host: {
|
||||
@@ -22,20 +21,17 @@ test("runDistroDetection skips POSIX probes when singleChannelSsh is enabled", a
|
||||
terminalBackend: {
|
||||
getSessionRemoteInfo: async () => ({
|
||||
success: true,
|
||||
remoteSshVersion: "CLOUDBILITY-4.14",
|
||||
remoteSshVersion: "OpenSSH_9.6",
|
||||
}),
|
||||
getSessionDistroInfo: async () => {
|
||||
distroProbeCalls += 1;
|
||||
return { success: false, error: "must not probe single-channel SSH" };
|
||||
return { success: true, stdout: 'ID="ubuntu"\n' };
|
||||
},
|
||||
},
|
||||
onOsDetected: (_hostId: string, distro: string) => {
|
||||
detected.push(distro);
|
||||
},
|
||||
} as never, "bastion-session", token);
|
||||
onOsDetected: () => undefined,
|
||||
} as never, "legacy-flag-session", token);
|
||||
|
||||
assert.equal(distroProbeCalls, 0);
|
||||
assert.deepEqual(detected, []);
|
||||
assert.equal(distroProbeCalls, 1);
|
||||
});
|
||||
|
||||
test("runDistroDetection skips POSIX probes when the SSH banner is a one-channel bastion", async () => {
|
||||
|
||||
@@ -4,7 +4,7 @@ import {
|
||||
hostRestrictsExtraSshChannels,
|
||||
normalizeDistroId,
|
||||
} from "../../../domain/host";
|
||||
import { remoteSoftwareRequiresSingleChannel } from "../../../domain/singleChannelSshBanner.shared.cjs";
|
||||
import { remoteSoftwareRequiresSingleChannel } from "../../../domain/singleChannelSshBanner.shared.mjs";
|
||||
import { logger } from "../../../lib/logger";
|
||||
import type { TerminalSessionStartersContext } from "./createTerminalSessionStarters.types";
|
||||
|
||||
|
||||
@@ -34,7 +34,7 @@ test("password-prompt input is classified before prompt state reset and cannot b
|
||||
);
|
||||
assert.match(
|
||||
runtimeSource,
|
||||
/const sensitive = ctx\.passwordPromptActiveRef\?\.current === true[\s\S]*?const canBroadcastInput = !sensitive &&[\s\S]*?const willBroadcastInput = canBroadcastInput && options\?\.skipBroadcast !== true;/u,
|
||||
/const sensitive = ctx\.passwordPromptActiveRef\?\.current === true[\s\S]*?const canBroadcastInput = \(!sensitive\s*\|\|\s*\(ctx\.broadcastPasswordBypassRef\?\.current === true && options\?\.sensitive !== true\)\) &&[\s\S]*?const willBroadcastInput = canBroadcastInput && options\?\.skipBroadcast !== true;/u,
|
||||
);
|
||||
assert.match(
|
||||
runtimeSource,
|
||||
@@ -46,11 +46,11 @@ test("password-prompt input is classified before prompt state reset and cannot b
|
||||
);
|
||||
assert.match(
|
||||
runtimeSource,
|
||||
/const broadcastUserPasteData = \(\s*data: string,\s*options\?: TerminalBroadcastInputOptions,\s*\) => \{[\s\S]*?passwordPromptActiveRef\?\.current !== true[\s\S]*?onBroadcastInputRef\.current\(\s*data,\s*ctx\.sessionId,\s*options\)/u,
|
||||
/const broadcastUserPasteData = \(\s*data: string,\s*options\?: TerminalBroadcastInputOptions,\s*\) => \{[\s\S]*?const dispatchingFromPasswordPrompt = ctx\.passwordPromptActiveRef\?\.current === true;[\s\S]*?const sourceSensitive = dispatchingFromPasswordPrompt \|\| options\?\.sensitive === true;[\s\S]*?\|\| ctx\.broadcastPasswordBypassRef\?\.current === true\)[\s\S]*?onBroadcastInputRef\.current\(\s*data,\s*ctx\.sessionId,\s*sourceSensitive[\s\S]*?sourceSensitive: true/u,
|
||||
);
|
||||
assert.match(
|
||||
terminalSource,
|
||||
/const sensitive = passwordPromptActiveRef\.current;[\s\S]*?!sensitive && isBroadcastEnabledRef\.current[\s\S]*?writeToSession\(id, data, \{[\s\S]*?sensitive,/u,
|
||||
/const sensitive = passwordPromptActiveRef\.current;[\s\S]*?shouldBroadcastDuringSensitivePrompt\(\{[\s\S]*?isBroadcastEnabledRef\.current[\s\S]*?writeToSession\(id, data, \{[\s\S]*?sensitive,/u,
|
||||
);
|
||||
});
|
||||
|
||||
@@ -128,6 +128,6 @@ test("active and hibernated output share host-owned sensitive prompt classificat
|
||||
test("ordinary broadcast skips targets that are waiting for sensitive input", () => {
|
||||
assert.match(
|
||||
terminalLayerSource,
|
||||
/if \(isTerminalSensitiveInputActive\(session\.id\)\) continue;[\s\S]*?writeToSession\(session\.id, data/u,
|
||||
/if \(!passwordBypass && isTerminalSensitiveInputActive\(session\.id\)\) continue;[\s\S]*?writeToSession\(session\.id, data, \{[\s\S]*?sensitive: options\?\.sourceSensitive === true \|\| isTerminalSensitiveInputActive\(session\.id\),/u,
|
||||
);
|
||||
});
|
||||
@@ -295,3 +295,80 @@ for (const [protocol, lineMode, sensitive] of [
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
test("bypassed sensitive line paste keeps the paced fan-out with sourceSensitive tagging (#3488)", async (t) => {
|
||||
t.mock.timers.enable({ apis: ["setTimeout"] });
|
||||
const wire: string[] = [];
|
||||
const broadcast: Array<{ data: string; options?: { sourceSensitive?: boolean } }> = [];
|
||||
const writes: Array<{ data: string; sensitive?: boolean; lineDelayMs?: number; automated?: boolean }> = [];
|
||||
let receiptListener: ((event: unknown) => void) | undefined;
|
||||
const session = { socket: { write: (data: string) => wire.push(String(data)) } };
|
||||
bridge.init({
|
||||
sessions: new Map([["serial-1", session]]),
|
||||
electronModule: { webContents: { fromId: () => ({ send(channel: string, event: unknown) {
|
||||
if (channel === "netcatty:paste-write") receiptListener?.(event);
|
||||
} }) } },
|
||||
});
|
||||
const ctx = {
|
||||
host: { protocol: "telnet", id: "h", label: "h" }, sessionId: "tab-1",
|
||||
sessionRef: { current: "serial-1" }, statusRef: { current: "connected" },
|
||||
commandBufferRef: { current: "" }, serialLineBufferRef: { current: "" },
|
||||
serialLineMode: false, serialLocalEcho: false, telnetLocalEchoRef: { current: false },
|
||||
passwordPromptActiveRef: { current: true },
|
||||
broadcastPasswordBypassRef: { current: true },
|
||||
onCommandSubmitted: () => {}, onCommandExecuted: () => {},
|
||||
onAutocompleteInput: () => {},
|
||||
onOutputTriggerUserInputRef: { current: () => {} },
|
||||
scriptRecorderRef: { current: { isRecording: false } },
|
||||
isBroadcastEnabledRef: { current: true },
|
||||
onBroadcastInputRef: { current: (data: string, _id: string, options?: { sourceSensitive?: boolean; preparePacedBroadcast?: boolean }) => {
|
||||
if (!options?.preparePacedBroadcast) broadcast.push({ data, options });
|
||||
} },
|
||||
terminalBackend: {
|
||||
notifyUserInput() {},
|
||||
interruptSession(_id: string, _trace?: unknown, options?: { cancelPendingWritesOnly?: boolean }) {
|
||||
bridge.interruptSession({}, { sessionId: "serial-1", ...options });
|
||||
},
|
||||
writeToSession(sessionId: string, data: string, options?: { sensitive?: boolean; lineDelayMs?: number; automated?: boolean }) {
|
||||
writes.push({ data, ...options });
|
||||
bridge.writeToSession({}, { sessionId, data, ...options });
|
||||
},
|
||||
},
|
||||
};
|
||||
const term = {
|
||||
paste: () => assert.fail("paced paste must not gain bracketed-paste markers"),
|
||||
scrollToBottom() {}, cols: 80,
|
||||
buffer: { active: { cursorX: 0, cursorY: 0, baseY: 0, getLine: () => undefined } },
|
||||
};
|
||||
const env = {
|
||||
...Object.assign({}, ...helpers), ...userPaste, ctx, term, crypto, isPluginHostProtocol: () => false, logger: { warn() {} },
|
||||
netcattyBridge: { get: () => ({ onTerminalPasteWrite: (listener: typeof receiptListener) => { receiptListener = listener; return () => { receiptListener = undefined; }; } }) },
|
||||
suppressNextTerminalDataBroadcast: false, handlingKittyBroadcast: false,
|
||||
prioritizeTerminalInput() {}, getFlowControllerForTerm: () => null,
|
||||
scrollToBottomAfterInput() {}, writeLocalTerminalData: () => {},
|
||||
api: undefined as unknown as { urgent: () => void; input: (data: string) => void; dispose: () => void },
|
||||
};
|
||||
vm.runInNewContext(code, env);
|
||||
t.after(() => env.api.dispose());
|
||||
await pasteTextWithMultilineConfirm("version\nshow clock", {
|
||||
term, sessionId: "serial-1", terminalBackend: ctx.terminalBackend,
|
||||
getCurrentSessionId: () => ctx.sessionRef.current,
|
||||
isSensitiveInput: () => ctx.passwordPromptActiveRef.current,
|
||||
broadcastPasswordBypass: () => ctx.broadcastPasswordBypassRef?.current === true,
|
||||
confirmMultilinePaste: {
|
||||
enabled: true, minLines: 2,
|
||||
requestConfirm: async () => ({ action: "line-by-line" }),
|
||||
},
|
||||
onPasteData: () => assert.fail("paced broadcast must use actual write receipts"),
|
||||
});
|
||||
assert.equal(writes[0]?.sensitive, true);
|
||||
assert.equal(writes[0]?.automated, false);
|
||||
assert.equal(writes[0]?.lineDelayMs, 250);
|
||||
t.mock.timers.tick(1000);
|
||||
await new Promise(resolve => setImmediate(resolve));
|
||||
// The bypassed fan-out stays alive at the sensitive prompt, and each line
|
||||
// keeps the source-sensitive marker so peer writes skip interceptors.
|
||||
assert.deepEqual(broadcast.map((entry) => entry.data), ["version\r", "show clock\r"]);
|
||||
for (const entry of broadcast) assert.equal(entry.options?.sourceSensitive, true);
|
||||
env.api.dispose();
|
||||
});
|
||||
@@ -44,7 +44,7 @@ for (const path of ["source", "broadcast"] as const) {
|
||||
terminalBackend, isPluginHostProtocol: () => true,
|
||||
useCallback: (fn: unknown) => fn, resolveTerminalBroadcastTargetIds: () => ["s"],
|
||||
sessionsRef: { current: [{ id: "s", protocol: "plugin:test" }] }, isGlobalBroadcastEnabled: true,
|
||||
canUseDirectSessionWriteFallback: () => true, broadcastInterruptPrioritizersRef: { current: new Map() },
|
||||
canUseDirectSessionWriteFallback: () => true, broadcastPasswordBypassRef: { current: false }, broadcastInterruptPrioritizersRef: { current: new Map() },
|
||||
isTerminalSensitiveInputActive: () => false,
|
||||
invoke: undefined as unknown as (data: string, source: string) => void,
|
||||
};
|
||||
@@ -69,7 +69,7 @@ test("ordinary broadcast typing supersedes the recipient's paced paste", t => {
|
||||
terminalBackend: { writeToSession: (sessionId: string, data: string, options: object) => bridge.writeToSession(null, { sessionId, data, ...options }) },
|
||||
useCallback: (fn: unknown) => fn, resolveTerminalBroadcastTargetIds: () => ["s"],
|
||||
sessionsRef: { current: [{ id: "s", protocol: "ssh" }] }, isGlobalBroadcastEnabled: true,
|
||||
canUseDirectSessionWriteFallback: () => true, isTerminalSensitiveInputActive: () => false,
|
||||
canUseDirectSessionWriteFallback: () => true, broadcastPasswordBypassRef: { current: false }, isTerminalSensitiveInputActive: () => false,
|
||||
invoke: undefined as unknown as (data: string, source: string, options?: object) => void,
|
||||
};
|
||||
vm.runInNewContext(compile(layer.slice(broadcastStart, broadcastEnd) + "\nglobalThis.invoke = handleBroadcastInput;"), env);
|
||||
@@ -103,7 +103,7 @@ for (const change of ["source-sensitive", "peer-sensitive", "peer-typing", "peer
|
||||
const env = {
|
||||
...pacedHelpers, useCallback: (fn: unknown) => fn, resolveTerminalBroadcastTargetIds,
|
||||
sessionsRef: { current: [{ id: "source", protocol: "ssh" }, { id: "peer", protocol: "ssh" }] as Array<{ id: string; protocol: string; workspaceId?: string }> },
|
||||
isGlobalBroadcastEnabled: true, canUseDirectSessionWriteFallback: () => true,
|
||||
isGlobalBroadcastEnabled: true, canUseDirectSessionWriteFallback: () => true, broadcastPasswordBypassRef: { current: false },
|
||||
isTerminalSensitiveInputActive: (id: string) => sensitive.has(id),
|
||||
isPluginHostProtocol: () => false, broadcastInterruptPrioritizersRef: { current: new Map() },
|
||||
terminalBackend: {
|
||||
|
||||
@@ -11,7 +11,7 @@ type PasteTarget = Pick<XTerm, "paste" | "scrollToBottom"> &
|
||||
type PasteOptions = {
|
||||
scrollOnPaste?: boolean;
|
||||
requestAnimationFrame?: (callback: () => void) => unknown;
|
||||
onPasteData?: (data: string) => boolean | void;
|
||||
onPasteData?: (data: string, options?: { sensitive?: boolean }) => boolean | void;
|
||||
/**
|
||||
* Pre-computed sensitivity for this paste. When true, the terminal input
|
||||
* handler must treat the pasted bytes as sensitive even if its live
|
||||
@@ -342,7 +342,13 @@ export function pasteTextIntoTerminal(
|
||||
|
||||
if (options.onPasteData) {
|
||||
const pasteData = getPasteInputData(term, text);
|
||||
const didBroadcast = options.onPasteData(pasteData) === true;
|
||||
// Forward the pre-dialog sensitivity snapshot (#3491): the broadcast
|
||||
// callback re-reads the live password-prompt ref, which the confirm
|
||||
// dialog await may have cleared, so the saved classification must ride
|
||||
// along to keep the peer fan-out tagged sourceSensitive.
|
||||
const didBroadcast = options.onPasteData(pasteData, {
|
||||
sensitive: options.sensitive === true,
|
||||
}) === true;
|
||||
if (didBroadcast) {
|
||||
pasteBroadcastStates.set(term, {
|
||||
expiresAt: getNow() + PASTE_INPUT_SCROLL_WINDOW_MS,
|
||||
|
||||
@@ -831,3 +831,119 @@ for (const [preview, expected] of [
|
||||
assert.deepEqual(broadcasts, [], "without a runtime receipt owner, never broadcast an unchecked batch");
|
||||
});
|
||||
}
|
||||
|
||||
test("bypassed paste confirmed at a sensitive prompt still fans out but keeps its sensitive marker (#3488)", async () => {
|
||||
const { shouldOverrideTerminalUserPasteSensitivity } = await import("./runtime/terminalUserPaste");
|
||||
const pasted: string[] = [];
|
||||
const broadcasted: string[] = [];
|
||||
const term = {
|
||||
paste(data: string) {
|
||||
// xterm normalizes pasted line endings before delivering onData.
|
||||
assert.equal(shouldOverrideTerminalUserPasteSensitivity(term, data.replace(/\n/g, "\r")), true);
|
||||
pasted.push(data);
|
||||
},
|
||||
scrollToBottom() {},
|
||||
};
|
||||
await handleTerminalClipboardPaste({
|
||||
isLocalConnection: false,
|
||||
sessionId: "session-1",
|
||||
term,
|
||||
isSensitiveInput: () => true,
|
||||
broadcastPasswordBypass: () => true,
|
||||
confirmMultilinePaste: {
|
||||
enabled: true,
|
||||
minLines: 2,
|
||||
requestConfirm: async () => ({ action: "send" }),
|
||||
},
|
||||
readClipboardText: async () => "secret\nsecond secret",
|
||||
onPasteData: (data) => {
|
||||
broadcasted.push(data);
|
||||
return true;
|
||||
},
|
||||
terminalBackend: {
|
||||
writeToSession: () => assert.fail("normal send should use xterm paste handling"),
|
||||
},
|
||||
});
|
||||
assert.deepEqual(pasted, ["secret\nsecond secret"]);
|
||||
assert.deepEqual(broadcasted, ["secret\rsecond secret"]);
|
||||
});
|
||||
|
||||
test("bypassed line-by-line paste keeps the paced broadcast flag with the sensitive marker (#3488)", async () => {
|
||||
const { registerTerminalLinePasteHandler } = await import("./runtime/terminalUserPaste");
|
||||
const lineOptions: Array<{ lineDelayMs: number; sensitive: boolean; broadcast?: boolean }> = [];
|
||||
const writes: Array<{ data: string; options?: { sensitive?: boolean; lineDelayMs?: number } }> = [];
|
||||
const term = {
|
||||
paste: () => assert.fail("line-by-line must use delayed writes"),
|
||||
scrollToBottom() {},
|
||||
};
|
||||
const dispose = registerTerminalLinePasteHandler(term, (_data, options) => lineOptions.push(options));
|
||||
try {
|
||||
await handleTerminalClipboardPaste({
|
||||
isLocalConnection: false,
|
||||
sessionId: "session-1",
|
||||
term,
|
||||
isSensitiveInput: () => true,
|
||||
broadcastPasswordBypass: () => true,
|
||||
confirmMultilinePaste: {
|
||||
enabled: true,
|
||||
minLines: 2,
|
||||
requestConfirm: async () => ({ action: "line-by-line", text: "conf t\nint gi0/0" }),
|
||||
},
|
||||
readClipboardText: async () => "conf t\nint gi0/0",
|
||||
onPasteData: () => assert.fail("paced broadcast must use actual write receipts"),
|
||||
terminalBackend: {
|
||||
writeToSession: (_id, data, options) => writes.push({ data, options }),
|
||||
},
|
||||
});
|
||||
assert.deepEqual(lineOptions, [{
|
||||
lineDelayMs: 250,
|
||||
sensitive: true,
|
||||
broadcast: true,
|
||||
}]);
|
||||
assert.deepEqual(writes, []);
|
||||
} finally {
|
||||
dispose();
|
||||
}
|
||||
});
|
||||
|
||||
test("without the bypass, a paste confirmed at a sensitive prompt still broadcasts only to the source (#3488)", async () => {
|
||||
const { registerTerminalLinePasteHandler } = await import("./runtime/terminalUserPaste");
|
||||
const lineOptions: Array<{ lineDelayMs: number; sensitive: boolean; broadcast?: boolean }> = [];
|
||||
const pasted: string[] = [];
|
||||
const term = {
|
||||
paste: (text: string) => pasted.push(text),
|
||||
scrollToBottom() {},
|
||||
};
|
||||
const dispose = registerTerminalLinePasteHandler(term, (_data, options) => lineOptions.push(options));
|
||||
try {
|
||||
await handleTerminalClipboardPaste({
|
||||
isLocalConnection: false,
|
||||
sessionId: "session-1",
|
||||
term,
|
||||
isSensitiveInput: () => true,
|
||||
confirmMultilinePaste: {
|
||||
enabled: true,
|
||||
minLines: 2,
|
||||
requestConfirm: async () => ({ action: "line-by-line", text: "conf t\nint gi0/0" }),
|
||||
},
|
||||
readClipboardText: async () => "conf t\nint gi0/0",
|
||||
onPasteData: () => assert.fail("unbypassed sensitive paste must not fan out"),
|
||||
terminalBackend: {
|
||||
writeToSession: (_id, data, options) => {
|
||||
// The registered line-paste handler owns the paced path in this
|
||||
// test, so the backend fallback must not be used.
|
||||
void data;
|
||||
void options;
|
||||
},
|
||||
},
|
||||
});
|
||||
assert.deepEqual(lineOptions, [{
|
||||
lineDelayMs: 250,
|
||||
sensitive: true,
|
||||
broadcast: false,
|
||||
}]);
|
||||
assert.deepEqual(pasted, []);
|
||||
} finally {
|
||||
dispose();
|
||||
}
|
||||
});
|
||||
@@ -74,8 +74,10 @@ type TerminalClipboardPasteOptions = {
|
||||
getRemoteCwd?: () => Promise<string | null | undefined>;
|
||||
isLocalConnection: boolean;
|
||||
isSensitiveInput?: () => boolean;
|
||||
/** Live #3488 bypass probe; forwarded to the multiline-confirm gate. */
|
||||
broadcastPasswordBypass?: () => boolean;
|
||||
onClipboardImageUploadResult?: (result: RemoteClipboardImageUploadResult) => void;
|
||||
onPasteData?: (data: string, options?: { lineDelayMs?: number }) => boolean | void;
|
||||
onPasteData?: (data: string, options?: { lineDelayMs?: number; sensitive?: boolean }) => boolean | void;
|
||||
readClipboardText: () => Promise<string>;
|
||||
scrollOnPaste?: boolean;
|
||||
scrollToBottomAfterProgrammaticInput?: (data: string) => void;
|
||||
@@ -96,7 +98,14 @@ type MultilineGatedPasteOptions = {
|
||||
*/
|
||||
getCurrentSessionId?: () => string | null | undefined;
|
||||
isSensitiveInput?: () => boolean;
|
||||
onPasteData?: (data: string, options?: { lineDelayMs?: number }) => boolean | void;
|
||||
/**
|
||||
* Live probe for the opt-in #3488 "broadcast without password protection"
|
||||
* bypass. When true, a paste confirmed at a sensitive prompt still fans out
|
||||
* to broadcast peers through onPasteData / the paced line broadcast, while
|
||||
* the paste itself keeps its sensitive marker for the source write.
|
||||
*/
|
||||
broadcastPasswordBypass?: () => boolean;
|
||||
onPasteData?: (data: string, options?: { lineDelayMs?: number; sensitive?: boolean }) => boolean | void;
|
||||
scrollOnPaste?: boolean;
|
||||
scrollToBottomAfterProgrammaticInput?: (data: string) => void;
|
||||
sessionId: string | null | undefined;
|
||||
@@ -118,6 +127,7 @@ export async function pasteTextWithMultilineConfirm(
|
||||
confirmMultilinePaste,
|
||||
getCurrentSessionId,
|
||||
isSensitiveInput,
|
||||
broadcastPasswordBypass,
|
||||
onPasteData,
|
||||
scrollOnPaste = false,
|
||||
scrollToBottomAfterProgrammaticInput,
|
||||
@@ -150,6 +160,11 @@ export async function pasteTextWithMultilineConfirm(
|
||||
const currentSessionId = getCurrentSessionId ? getCurrentSessionId() : session;
|
||||
if (!currentSessionId) return;
|
||||
const confirmedSensitive = sensitive || isSensitiveInput?.() === true;
|
||||
// The #3488 bypass keeps the broadcast fan-out alive for a paste
|
||||
// confirmed at a sensitive prompt: onPasteData / the paced line
|
||||
// broadcast re-check broadcast state and re-guard targets at dispatch
|
||||
// time, and the sensitive marker below still governs the source write.
|
||||
const broadcastBypassActive = broadcastPasswordBypass?.() === true;
|
||||
if (decision.action === "line-by-line") {
|
||||
// An explicitly emptied preview means "send nothing"; only a missing
|
||||
// value falls back to the original clipboard text.
|
||||
@@ -158,7 +173,7 @@ export async function pasteTextWithMultilineConfirm(
|
||||
const lineOptions = {
|
||||
lineDelayMs: AUTO_RUN_SNIPPET_LINE_DELAY_MS,
|
||||
sensitive: confirmedSensitive,
|
||||
broadcast: !confirmedSensitive && !!onPasteData,
|
||||
broadcast: (!confirmedSensitive || broadcastBypassActive) && !!onPasteData,
|
||||
};
|
||||
if (!dispatchTerminalLinePaste(term, lineData, lineOptions)) {
|
||||
terminalBackend.writeToSession(currentSessionId, lineData, {
|
||||
@@ -176,8 +191,9 @@ export async function pasteTextWithMultilineConfirm(
|
||||
pasteTextIntoTerminal(term, decision.text ?? text, {
|
||||
scrollOnPaste,
|
||||
// Same post-await race as above: never fan a sensitive paste out to
|
||||
// broadcast peers via onPasteData.
|
||||
onPasteData: confirmedSensitive ? undefined : onPasteData,
|
||||
// broadcast peers via onPasteData — unless the #3488 bypass is active,
|
||||
// in which case the dispatcher tags the payload and re-guards targets.
|
||||
onPasteData: confirmedSensitive && !broadcastBypassActive ? undefined : onPasteData,
|
||||
// Carry the pre-dialog sensitivity snapshot through the normal Send
|
||||
// path too: term.paste's input handler recomputes `sensitive` from the
|
||||
// live password-prompt ref, which the dialog await may have cleared.
|
||||
@@ -200,6 +216,7 @@ export async function handleTerminalClipboardPaste({
|
||||
getRemoteCwd,
|
||||
isLocalConnection,
|
||||
isSensitiveInput,
|
||||
broadcastPasswordBypass,
|
||||
onClipboardImageUploadResult,
|
||||
onPasteData,
|
||||
readClipboardText,
|
||||
@@ -278,6 +295,7 @@ export async function handleTerminalClipboardPaste({
|
||||
confirmMultilinePaste,
|
||||
getCurrentSessionId,
|
||||
isSensitiveInput,
|
||||
broadcastPasswordBypass,
|
||||
onPasteData,
|
||||
scrollOnPaste,
|
||||
scrollToBottomAfterProgrammaticInput,
|
||||
@@ -315,6 +333,7 @@ export async function handleTerminalClipboardPaste({
|
||||
confirmMultilinePaste,
|
||||
getCurrentSessionId,
|
||||
isSensitiveInput,
|
||||
broadcastPasswordBypass,
|
||||
onPasteData,
|
||||
scrollOnPaste,
|
||||
scrollToBottomAfterProgrammaticInput,
|
||||
|
||||
@@ -35,6 +35,26 @@ export interface TerminalBroadcastInputOptions {
|
||||
lineDelayMs?: number;
|
||||
kittyKeyboardInput?: KittyKeyboardBroadcastInput;
|
||||
kittyKeyboardTargetSessionIds?: string[];
|
||||
/**
|
||||
* Source session captured the payload at a password / sensitive prompt (the
|
||||
* #3488 bypass allowed the fan-out). Peer writes must retain that marker so
|
||||
* input interceptors on the receiving side stay skipped.
|
||||
*/
|
||||
sourceSensitive?: boolean;
|
||||
/**
|
||||
* Paste payload classified sensitive before a confirm-dialog await (#3491):
|
||||
* the callback resolves the live prompt state itself, which the dialog can
|
||||
* have cleared, so the saved classification rides into the broadcast and
|
||||
* the dispatcher tags the payload sourceSensitive.
|
||||
*/
|
||||
sensitive?: boolean;
|
||||
/**
|
||||
* Observer for the sessions that actually received the payload (#3491):
|
||||
* the solo compose bar uses it to detect a fan-out delivered into a peer's
|
||||
* sensitive prompt (its password/MFA input) and keep that send
|
||||
* history-ineligible.
|
||||
*/
|
||||
onBroadcastDelivered?: (sessionIds: readonly string[]) => void;
|
||||
}
|
||||
|
||||
export { resolveSessionTabTitle };
|
||||
|
||||
@@ -190,8 +190,9 @@ test('terminal view derives the network-device prompt policy before autocomplete
|
||||
);
|
||||
assert.match(
|
||||
terminalSource,
|
||||
/resolveTerminalAutocompleteSettings\(\{[\s\S]*?isNetworkDevice: host\.deviceType === 'network'[\s\S]*?classifyDistroId\(host\.distro\) === 'network-device',[\s\S]*?restrictPtyRewrites: host\.singleChannelSsh === true,/,
|
||||
/resolveTerminalAutocompleteSettings\(\{[\s\S]*?isNetworkDevice: host\.deviceType === 'network'[\s\S]*?classifyDistroId\(host\.distro\) === 'network-device',\s*\}\);/,
|
||||
);
|
||||
assert.doesNotMatch(terminalSource, /singleChannelSsh/);
|
||||
const autocompleteSource = readFileSync(new URL('./TerminalAutocomplete.tsx', import.meta.url), 'utf8');
|
||||
assert.match(
|
||||
autocompleteSource,
|
||||
|
||||
@@ -90,3 +90,24 @@ test("terminal context paste never broadcasts password-prompt input", () => {
|
||||
|
||||
assert.equal(didBroadcast, false);
|
||||
});
|
||||
|
||||
test("bypassed context paste at a password prompt is marked sourceSensitive (#3488)", () => {
|
||||
const broadcasted: Array<{ options?: { sourceSensitive?: boolean } }> = [];
|
||||
const didBroadcast = broadcastTerminalPasteData("secret", {
|
||||
sourceSessionId: "workspace-session-1",
|
||||
sessionRef: { current: "session-1" },
|
||||
isBroadcastEnabledRef: { current: true },
|
||||
passwordPromptActiveRef: { current: true },
|
||||
broadcastPasswordBypassRef: { current: true },
|
||||
onBroadcastInputRef: {
|
||||
current: (_data, _sourceSessionId, options) => {
|
||||
broadcasted.push({ options });
|
||||
},
|
||||
},
|
||||
}, { lineDelayMs: 250 });
|
||||
|
||||
assert.equal(didBroadcast, true);
|
||||
assert.deepEqual(broadcasted, [
|
||||
{ options: { lineDelayMs: 250, sourceSensitive: true } },
|
||||
]);
|
||||
});
|
||||
@@ -213,7 +213,7 @@ export function resolveSelectionOverlayPosition(term: any, container: HTMLElemen
|
||||
}
|
||||
|
||||
export function useTerminalEffects(ctx: TerminalEffectsContext) {
|
||||
const { CONNECTION_TIMEOUT, Error, XTERM_PERFORMANCE_CONFIG, applyUserCursorPreference, applyUserCursorWidthPreference, shouldApplyUserCursorPreference, auth, autocompleteCloseRef, autocompleteInputRef, autocompleteKeyEventRef, autocompleteRepositionRef, captureTerminalLogData, chainHosts, chainProgress, clearTerminalCwd, commandBufferRef, connectionLogBufferRef, containerRef, createPromptLineBreakState, createReplaySafeTerminalLogSanitizer, createXTermRuntime, deferTerminalResizeRef, disableTerminalFontZoomRef, effectiveFontSize, effectiveFontWeight, effectiveTheme, error, executeSnippetCommand, finalizeTerminalLogData, fitAddonRef, fontFamilyId, fontSize, fontWeightFixupDoneRef, forceCloseHibernatedSession, forceSyncRenderAfterResize, handleOsc52ReadRequest, handleTerminalDataCaptureOnce, hasConnectedRef, hasRuntimeRef, host, hotkeySchemeRef, hibernatedRef, identities, inWorkspace, isBootActiveRef, bootEpochRef, isBroadcastEnabledRef, isComposeBarOpen, isConnectionAwaitingUserInput, isConnectionPastTcpDial, isFocusMode, isFocused, isLocalConnection, isNetworkDevice, isResizing, isRestoringSelectionRef, isSearchOpen, isSerialConnection, isVisible, isVisibleRef, keyBindingsRef, keys, kittyKeyboardProtocolEnabledForSession, knownCwdRef, lastFittedSizeRef, lastToastedErrorRef, logger, mouseTrackingRef, needsHostKeyVerification, onBroadcastInputRef, onBroadcastInterruptPriorityChange, onCommandExecuted, onCommandSubmitted, onHotkeyActionRef, onOpenExternalError, onOutputTriggerUserInputRef, onPluginRuntimeCwdChange, onSnippetExecutorChange, onTerminalCwdChange, onTerminalTitleChange, onTerminalBell, onTerminalFontSizeChange, paneLayoutKey, passwordPromptActiveRef, pendingAuthRef, pendingOutputScrollRef, pluginDecorationRules, pluginTerminalLifecycle, pluginTerminalProviderRevision, isPluginTerminalProviderAvailable, requestPluginTerminalProviders, prepareRestoredReconnect, prepareInitialCwdIntent, prevIsResizingRef, promptLineBreakStateRef, resizeSession, resolveHostAuth, resolvedFontFamily, safeFit, scriptRecorderRef, searchAddonRef, serialConfig, serialLineBufferRef, serializeAddonRef, sessionId, sessionRef, sessionStarters, setError, setHasMouseTracking, setIsCancelling, setIsDisconnectedDialogDismissed, requestSearchFocus, setNeedsHostKeyVerification, setPendingHostKeyInfo, setPendingHostKeyRequestId, setProgressLogs, setProgressValue, setShowLogs, setStatus, setTimeLeft, shouldEnableNativeUserInputAutoScroll, shouldProbeSessionCwd, shouldStartTerminalBackend, attachExistingSession, attachAuthorization, attachHomeWebContentsIdRef, onSnippetShortkeyRef, snippetsRef, splitResizeActive, status, statusRef, sudoAutofillRef, t, teardown, telnetLocalEchoRef, termRef, terminalAltKeyOptions, terminalBackend, terminalContextActionsRef, terminalCwdTracker, terminalDataCapturedRef, terminalLogSanitizerRef, terminalOutputHistory, terminalSettings, terminalSettingsRef, terminalTitleRef, toHostKeyInfo, toast, updateStatus, useEffect, useLayoutEffect, xtermRuntimeRef, zmodem, zmodemToastedRef, restoreState, vaultInitialized } = ctx;
|
||||
const { CONNECTION_TIMEOUT, Error, XTERM_PERFORMANCE_CONFIG, applyUserCursorPreference, applyUserCursorWidthPreference, shouldApplyUserCursorPreference, auth, autocompleteCloseRef, autocompleteInputRef, autocompleteKeyEventRef, autocompleteRepositionRef, captureTerminalLogData, chainHosts, chainProgress, clearTerminalCwd, commandBufferRef, connectionLogBufferRef, containerRef, createPromptLineBreakState, createReplaySafeTerminalLogSanitizer, createXTermRuntime, deferTerminalResizeRef, disableTerminalFontZoomRef, effectiveFontSize, effectiveFontWeight, effectiveTheme, error, executeSnippetCommand, finalizeTerminalLogData, fitAddonRef, fontFamilyId, fontSize, fontWeightFixupDoneRef, forceCloseHibernatedSession, forceSyncRenderAfterResize, handleOsc52ReadRequest, handleTerminalDataCaptureOnce, hasConnectedRef, hasRuntimeRef, host, hotkeySchemeRef, hibernatedRef, identities, inWorkspace, isBootActiveRef, bootEpochRef, isBroadcastEnabledRef, broadcastPasswordBypassRef, isComposeBarOpen, isConnectionAwaitingUserInput, isConnectionPastTcpDial, isFocusMode, isFocused, isLocalConnection, isNetworkDevice, isResizing, isRestoringSelectionRef, isSearchOpen, isSerialConnection, isVisible, isVisibleRef, keyBindingsRef, keys, kittyKeyboardProtocolEnabledForSession, knownCwdRef, lastFittedSizeRef, lastToastedErrorRef, logger, mouseTrackingRef, needsHostKeyVerification, onBroadcastInputRef, onBroadcastInterruptPriorityChange, onCommandExecuted, onCommandSubmitted, onHotkeyActionRef, onOpenExternalError, onOutputTriggerUserInputRef, onPluginRuntimeCwdChange, onSnippetExecutorChange, onTerminalCwdChange, onTerminalTitleChange, onTerminalBell, onTerminalFontSizeChange, paneLayoutKey, passwordPromptActiveRef, pendingAuthRef, pendingOutputScrollRef, pluginDecorationRules, pluginTerminalLifecycle, pluginTerminalProviderRevision, isPluginTerminalProviderAvailable, requestPluginTerminalProviders, prepareRestoredReconnect, prepareInitialCwdIntent, prevIsResizingRef, promptLineBreakStateRef, resizeSession, resolveHostAuth, resolvedFontFamily, safeFit, scriptRecorderRef, searchAddonRef, serialConfig, serialLineBufferRef, serializeAddonRef, sessionId, sessionRef, sessionStarters, setError, setHasMouseTracking, setIsCancelling, setIsDisconnectedDialogDismissed, requestSearchFocus, setNeedsHostKeyVerification, setPendingHostKeyInfo, setPendingHostKeyRequestId, setProgressLogs, setProgressValue, setShowLogs, setStatus, setTimeLeft, shouldEnableNativeUserInputAutoScroll, shouldProbeSessionCwd, shouldStartTerminalBackend, attachExistingSession, attachAuthorization, attachHomeWebContentsIdRef, onSnippetShortkeyRef, snippetsRef, splitResizeActive, status, statusRef, sudoAutofillRef, t, teardown, telnetLocalEchoRef, termRef, terminalAltKeyOptions, terminalBackend, terminalContextActionsRef, terminalCwdTracker, terminalDataCapturedRef, terminalLogSanitizerRef, terminalOutputHistory, terminalSettings, terminalSettingsRef, terminalTitleRef, toHostKeyInfo, toast, updateStatus, useEffect, useLayoutEffect, xtermRuntimeRef, zmodem, zmodemToastedRef, restoreState, vaultInitialized } = ctx;
|
||||
const effectiveTerminalProtocol = resolveEffectiveTerminalProtocol(host);
|
||||
const hibernateHiddenTabs = resolveTerminalHibernateEnabledForProtocol(
|
||||
terminalSettings,
|
||||
@@ -533,6 +533,7 @@ export function useTerminalEffects(ctx: TerminalEffectsContext) {
|
||||
onOpenExternalError,
|
||||
isBroadcastEnabledRef,
|
||||
onBroadcastInputRef,
|
||||
broadcastPasswordBypassRef,
|
||||
snippetsRef,
|
||||
onSnippetShortkeyRef,
|
||||
sessionId,
|
||||
|
||||
@@ -113,6 +113,12 @@ export type SnippetExecutor = (
|
||||
multiLineRunMode?: Snippet["multiLineRunMode"];
|
||||
/** When false, do not steal keyboard focus (multi-tab fan-out). Default true. */
|
||||
focus?: boolean;
|
||||
/**
|
||||
* Force sensitive classification on the target's own write (bypassed
|
||||
* password fan-out, #3488): the peer has not necessarily classified its
|
||||
* own prompt as sensitive, so it must keep input interceptors skipped.
|
||||
*/
|
||||
sensitive?: boolean;
|
||||
},
|
||||
/**
|
||||
* Returns true when the command was written to the session. False means the
|
||||
|
||||
@@ -219,9 +219,8 @@ export function TerminalLayerTabBridge({ stableRef }: { stableRef: StableRef })
|
||||
if (!activeTabId || !activeSidePanelTools.has('system')) return [];
|
||||
const session = activeTerminalSessionForSystem;
|
||||
if (!session || session.status !== 'connected') return [];
|
||||
if (activeSystemSessionHost?.singleChannelSsh === true) return [];
|
||||
return [session.id];
|
||||
}, [activeSidePanelTools, activeSystemSessionHost, activeTabId, activeTerminalSessionForSystem]);
|
||||
}, [activeSidePanelTools, activeTabId, activeTerminalSessionForSystem]);
|
||||
|
||||
useSystemCapabilitiesWarmup(
|
||||
systemWarmupSessionIds,
|
||||
|
||||
@@ -15,16 +15,27 @@ const code = ts.transpileModule(source.slice(start, end) + '\nglobalThis.send =
|
||||
}).outputText;
|
||||
|
||||
type Status = 'connected' | 'connecting' | 'disconnected';
|
||||
type Executor = () => boolean | Promise<boolean>;
|
||||
function setup(statuses: Status[], broadcast = false, executors = new Map<string, Executor>(), sensitive = new Set<string>()) {
|
||||
type Executor = (text: string, executeImmediately: boolean, options?: { broadcast?: boolean; sensitive?: boolean }) => boolean | Promise<boolean>;
|
||||
function setup(
|
||||
statuses: Status[],
|
||||
broadcast = false,
|
||||
executors = new Map<string, Executor>(),
|
||||
sensitive = new Set<string>(),
|
||||
broadcastPasswordBypass = false,
|
||||
restoredDisconnected = new Set<string>(),
|
||||
) {
|
||||
const writes: string[] = [];
|
||||
const context = {
|
||||
send: undefined as unknown as (text: string) => Promise<boolean>,
|
||||
useCallback: (callback: unknown) => callback,
|
||||
activeWorkspaceRef: { current: { id: 'workspace', focusedSessionId: '0' } },
|
||||
sessionsRef: { current: statuses.map((status, index) => ({ id: String(index), workspaceId: 'workspace', status })) },
|
||||
sessionsRef: { current: statuses.map((status, index) => ({
|
||||
id: String(index), workspaceId: 'workspace', status,
|
||||
restoreState: restoredDisconnected.has(String(index)) ? 'restored-disconnected' : undefined,
|
||||
})) },
|
||||
isBroadcastEnabled: () => broadcast,
|
||||
isTerminalSensitiveInputActive: (id: string) => sensitive.has(id),
|
||||
broadcastPasswordBypassRef: { current: broadcastPasswordBypass },
|
||||
snippetExecutorsRef: { current: executors },
|
||||
canUseDirectSessionWriteFallback,
|
||||
terminalBackend: { writeToSession: (id: string) => writes.push(id) },
|
||||
@@ -53,7 +64,68 @@ test('a disconnected broadcast peer cannot undo another successful fallback', as
|
||||
assert.equal(await setup(['connected', 'disconnected'], true).send('command'), true);
|
||||
});
|
||||
|
||||
test('sensitive input is excluded for executor and fallback paths', async () => {
|
||||
test('the password bypass delivers sensitive sends but keeps them out of compose history (#3488)', async () => {
|
||||
let executorCalls = 0;
|
||||
const executors = new Map<string, Executor>([['0', async () => {
|
||||
executorCalls += 1;
|
||||
return true;
|
||||
}]]);
|
||||
// The payload was delivered (the executor ran), but the send originates from
|
||||
// a sensitive prompt, so the compose bar must not record it for ArrowUp.
|
||||
assert.equal(
|
||||
await setup(['connected'], true, executors, new Set(['0']), true).send('secret'),
|
||||
false,
|
||||
);
|
||||
assert.equal(executorCalls, 1);
|
||||
});
|
||||
|
||||
test('bypassed fan-out into a lagging sensitive peer keeps the whole send out of history', async () => {
|
||||
// Focused session 0 is non-sensitive; lagging peer 1 sits at a password
|
||||
// prompt. The bypass delivers the payload there, but that text is peer 1's
|
||||
// password input, so the send must not be recallable via ArrowUp.
|
||||
for (const executorOwner of ['0', '1'] as const) {
|
||||
const executors = new Map<string, Executor>([[executorOwner, async () => true]]);
|
||||
assert.equal(
|
||||
await setup(['connected', 'connected'], true, executors, new Set(['1']), true).send('secret'),
|
||||
false,
|
||||
);
|
||||
}
|
||||
});
|
||||
|
||||
test('bypassed fan-out preserves a sensitive peer snapshot across executor wake', async () => {
|
||||
const sensitive = new Set(['1']);
|
||||
let sensitiveAtWrite: boolean | undefined;
|
||||
const executors = new Map<string, Executor>([['1', async (_text, _executeImmediately, options) => {
|
||||
await Promise.resolve();
|
||||
sensitive.delete('1');
|
||||
sensitiveAtWrite = options?.sensitive;
|
||||
return true;
|
||||
}]]);
|
||||
assert.equal(await setup(['connected', 'connected'], true, executors, sensitive, true).send('secret'), false);
|
||||
assert.equal(sensitiveAtWrite, true);
|
||||
});
|
||||
|
||||
test('a bypassed sensitive peer that rejects delivery does not hide another successful send', async () => {
|
||||
const executors = new Map<string, Executor>([
|
||||
['0', async () => true],
|
||||
['1', async () => false],
|
||||
]);
|
||||
assert.equal(await setup(['connected', 'connected'], true, executors, new Set(['1']), true).send('command'), true);
|
||||
});
|
||||
|
||||
test('a bypassed sensitive peer with no write route does not hide another successful send', async () => {
|
||||
const { send, writes } = setup(
|
||||
['connected', 'disconnected'], true, new Map(), new Set(['1']), true, new Set(['1']),
|
||||
);
|
||||
assert.equal(await send('command'), true);
|
||||
assert.deepEqual(writes, ['0']);
|
||||
});
|
||||
|
||||
test('a connecting sensitive fallback keeps attempted input out of history', async () => {
|
||||
assert.equal(await setup(['connected', 'connecting'], true, new Map(), new Set(['1']), true).send('secret'), false);
|
||||
});
|
||||
|
||||
test('without the password bypass sensitive input is excluded for executor and fallback paths', async () => {
|
||||
for (const broadcast of [false, true]) {
|
||||
for (const executors of [new Map<string, Executor>(), new Map<string, Executor>([['0', async () => true]])]) {
|
||||
assert.equal(await setup(['connected'], broadcast, executors, new Set(['0'])).send('secret'), false);
|
||||
|
||||
@@ -31,20 +31,20 @@
|
||||
<text x="85" y="85.0" fill="#e6edf3" font-size="16" font-family="Handlee, cursive" font-weight="bold" text-anchor="end">7K</text>
|
||||
<text x="43" y="271.5" fill="#e6edf3" font-size="17" font-family="Handlee, cursive" font-weight="bold" transform="rotate(-90, 43, 271.5)">GitHub Stars</text>
|
||||
<text x="90.0" y="488" fill="#e6edf3" font-size="16" font-family="Handlee, cursive" font-weight="bold" text-anchor="middle">Jan 2026</text>
|
||||
<text x="163.3" y="488" fill="#e6edf3" font-size="16" font-family="Handlee, cursive" font-weight="bold" text-anchor="middle">Feb 2026</text>
|
||||
<text x="234.1" y="488" fill="#e6edf3" font-size="16" font-family="Handlee, cursive" font-weight="bold" text-anchor="middle">Mar 2026</text>
|
||||
<text x="312.5" y="488" fill="#e6edf3" font-size="16" font-family="Handlee, cursive" font-weight="bold" text-anchor="middle">Apr 2026</text>
|
||||
<text x="388.3" y="488" fill="#e6edf3" font-size="16" font-family="Handlee, cursive" font-weight="bold" text-anchor="middle">May 2026</text>
|
||||
<text x="466.7" y="488" fill="#e6edf3" font-size="16" font-family="Handlee, cursive" font-weight="bold" text-anchor="middle">Jun 2026</text>
|
||||
<text x="542.5" y="488" fill="#e6edf3" font-size="16" font-family="Handlee, cursive" font-weight="bold" text-anchor="middle">Jul 2026</text>
|
||||
<text x="620.9" y="488" fill="#e6edf3" font-size="16" font-family="Handlee, cursive" font-weight="bold" text-anchor="middle">Aug 2026</text>
|
||||
<text x="699.3" y="488" fill="#e6edf3" font-size="16" font-family="Handlee, cursive" font-weight="bold" text-anchor="middle">Sep 2026</text>
|
||||
<text x="161.4" y="488" fill="#e6edf3" font-size="16" font-family="Handlee, cursive" font-weight="bold" text-anchor="middle">Feb 2026</text>
|
||||
<text x="306.8" y="488" fill="#e6edf3" font-size="16" font-family="Handlee, cursive" font-weight="bold" text-anchor="middle">Apr 2026</text>
|
||||
<text x="380.7" y="488" fill="#e6edf3" font-size="16" font-family="Handlee, cursive" font-weight="bold" text-anchor="middle">May 2026</text>
|
||||
<text x="457.1" y="488" fill="#e6edf3" font-size="16" font-family="Handlee, cursive" font-weight="bold" text-anchor="middle">Jun 2026</text>
|
||||
<text x="531.0" y="488" fill="#e6edf3" font-size="16" font-family="Handlee, cursive" font-weight="bold" text-anchor="middle">Jul 2026</text>
|
||||
<text x="607.3" y="488" fill="#e6edf3" font-size="16" font-family="Handlee, cursive" font-weight="bold" text-anchor="middle">Aug 2026</text>
|
||||
<text x="683.7" y="488" fill="#e6edf3" font-size="16" font-family="Handlee, cursive" font-weight="bold" text-anchor="middle">Sep 2026</text>
|
||||
<text x="757.6" y="488" fill="#e6edf3" font-size="16" font-family="Handlee, cursive" font-weight="bold" text-anchor="middle">Oct 2026</text>
|
||||
<text x="50%" y="525" fill="#e6edf3" font-size="17" font-family="Handlee, cursive" font-weight="bold" text-anchor="middle">Date</text>
|
||||
<line x1="90" y1="80" x2="90" y2="463" stroke="#30363d" stroke-width="2.5" filter="url(#xkcdify)"/>
|
||||
<line x1="90" y1="463" x2="765" y2="463" stroke="#30363d" stroke-width="2.5" filter="url(#xkcdify)"/>
|
||||
<path d="M 90.0,462.2 C 90.4,462.1 92.1,461.8 92.5,461.7 C 93.4,461.6 94.2,461.4 95.1,461.2 C 95.9,461.1 96.7,460.8 97.6,460.8 C 98.4,460.7 99.3,460.7 100.1,460.7 C 101.0,460.7 101.8,460.6 102.6,460.6 C 103.5,460.5 104.3,460.5 105.2,460.4 C 106.0,460.4 106.4,460.3 107.7,460.3 C 109.0,460.2 111.5,460.1 112.8,460.1 C 114.0,460.1 114.4,460.0 115.3,460.0 C 116.1,460.0 117.0,460.0 117.8,459.9 C 118.7,459.9 119.5,459.9 120.3,459.9 C 121.2,459.9 122.0,459.9 122.9,459.8 C 123.7,459.8 124.6,459.7 125.4,459.7 C 126.2,459.6 127.1,459.6 127.9,459.4 C 128.8,459.3 129.6,459.0 130.4,458.7 C 131.3,458.3 132.1,457.6 133.0,457.3 C 133.8,457.1 134.7,457.2 135.5,457.1 C 136.3,457.1 137.2,457.1 138.0,457.0 C 138.9,457.0 139.3,457.0 140.6,456.9 C 141.8,456.9 143.9,456.9 145.6,456.9 C 147.3,456.8 149.4,456.8 150.7,456.8 C 151.9,456.7 152.4,456.7 153.2,456.7 C 154.0,456.6 154.9,456.6 155.7,456.5 C 156.6,456.5 157.4,456.5 158.3,456.4 C 159.1,456.4 159.9,456.4 160.8,456.4 C 161.6,456.4 162.1,456.4 163.3,456.3 C 164.6,456.3 166.3,456.2 168.4,456.2 C 170.5,456.2 173.8,456.2 176.0,456.2 C 178.1,456.1 178.5,456.2 181.0,456.1 C 183.5,456.1 189.0,455.9 191.1,455.9 C 193.2,455.8 192.4,455.9 193.7,455.8 C 194.9,455.8 196.6,455.8 198.7,455.8 C 200.8,455.8 202.1,455.7 206.3,455.7 C 210.5,455.6 220.6,455.5 224.0,455.5 C 227.4,455.5 225.7,455.5 226.5,455.4 C 227.4,455.4 227.8,456.1 229.0,455.4 C 230.3,454.7 232.8,454.1 234.1,451.2 C 235.4,448.4 235.8,440.7 236.6,438.3 C 237.5,435.9 238.3,437.2 239.2,436.8 C 240.0,436.5 240.8,436.2 241.7,436.0 C 242.5,435.7 243.4,435.5 244.2,435.4 C 245.1,435.2 245.9,435.2 246.7,435.0 C 247.6,434.9 248.4,434.8 249.3,434.7 C 250.1,434.6 251.0,434.7 251.8,434.7 C 252.6,434.6 253.5,434.4 254.3,434.3 C 255.2,434.2 256.0,434.2 256.9,434.1 C 257.7,434.0 258.5,433.6 259.4,433.5 C 260.2,433.4 261.1,433.4 261.9,433.3 C 262.8,433.3 263.2,433.1 264.4,433.0 C 265.7,432.9 268.2,432.9 269.5,432.8 C 270.8,432.7 271.2,432.7 272.0,432.6 C 272.9,432.6 273.7,432.6 274.6,432.5 C 275.4,432.4 276.2,432.1 277.1,432.0 C 277.9,431.8 278.8,432.5 279.6,431.6 C 280.4,430.7 281.3,427.5 282.1,426.5 C 283.0,425.4 283.8,425.5 284.7,425.4 C 285.5,425.2 286.3,425.4 287.2,425.3 C 288.0,425.2 288.9,424.8 289.7,424.5 C 290.6,424.2 291.4,424.1 292.2,423.6 C 293.1,423.0 293.9,422.1 294.8,421.5 C 295.6,420.9 296.5,420.3 297.3,419.8 C 298.1,419.4 299.0,419.0 299.8,418.8 C 300.7,418.5 301.5,418.4 302.4,418.3 C 303.2,418.2 304.0,418.2 304.9,418.1 C 305.7,418.0 306.6,417.7 307.4,417.6 C 308.3,417.6 309.1,417.6 309.9,417.5 C 310.8,417.5 311.6,417.4 312.5,417.3 C 313.3,417.2 314.2,417.2 315.0,416.8 C 315.8,416.4 316.7,415.4 317.5,414.9 C 318.4,414.3 319.2,414.2 320.1,413.8 C 320.9,413.4 321.7,414.2 322.6,412.6 C 323.4,411.0 324.3,406.3 325.1,404.5 C 326.0,402.6 326.8,402.3 327.6,401.6 C 328.5,400.8 329.3,400.3 330.2,399.8 C 331.0,399.2 331.9,398.6 332.7,398.2 C 333.5,397.8 334.4,397.4 335.2,397.2 C 336.1,396.9 336.9,396.9 337.8,396.6 C 338.6,396.4 339.4,396.2 340.3,395.9 C 341.1,395.6 342.0,395.1 342.8,394.8 C 343.7,394.6 344.5,394.6 345.3,394.3 C 346.2,394.1 347.0,393.6 347.9,393.3 C 348.7,393.0 349.6,392.7 350.4,392.5 C 351.2,392.2 352.1,392.0 352.9,391.9 C 353.8,391.7 354.6,391.7 355.4,391.5 C 356.3,391.4 357.1,391.2 358.0,391.1 C 358.8,390.9 359.7,390.7 360.5,390.5 C 361.3,390.3 362.2,390.1 363.0,389.8 C 363.9,389.5 364.7,388.9 365.6,388.6 C 366.4,388.3 367.2,388.2 368.1,388.0 C 368.9,387.9 369.8,387.8 370.6,387.7 C 371.5,387.5 372.3,387.4 373.1,387.3 C 374.0,387.2 374.8,387.1 375.7,387.0 C 376.5,386.9 377.4,387.0 378.2,386.8 C 379.0,386.7 379.9,386.5 380.7,386.2 C 381.6,386.0 382.4,385.5 383.3,385.1 C 384.1,384.8 384.9,384.4 385.8,384.2 C 386.6,384.0 387.5,384.0 388.3,383.8 C 389.2,383.7 390.0,383.6 390.8,383.4 C 391.7,383.2 392.5,382.6 393.4,382.4 C 394.2,382.2 395.1,382.3 395.9,382.1 C 396.7,382.0 397.6,381.8 398.4,381.6 C 399.3,381.4 400.1,381.2 401.0,381.0 C 401.8,380.8 402.6,380.6 403.5,380.4 C 404.3,380.3 405.2,380.2 406.0,379.9 C 406.9,379.7 407.7,379.4 408.5,379.1 C 409.4,378.9 410.2,378.8 411.1,378.5 C 411.9,378.3 412.8,378.0 413.6,377.7 C 414.4,377.4 415.3,377.1 416.1,376.9 C 417.0,376.6 417.8,376.6 418.7,376.2 C 419.5,375.8 420.3,374.8 421.2,374.5 C 422.0,374.1 422.9,374.1 423.7,373.9 C 424.6,373.8 425.4,373.8 426.2,373.7 C 427.1,373.6 427.9,373.4 428.8,373.3 C 429.6,373.2 430.4,373.4 431.3,373.0 C 432.1,372.7 433.0,371.6 433.8,371.2 C 434.7,370.8 435.5,370.8 436.3,370.6 C 437.2,370.3 438.0,369.9 438.9,369.7 C 439.7,369.5 440.6,369.6 441.4,369.4 C 442.2,369.2 443.1,368.7 443.9,368.5 C 444.8,368.3 445.6,368.3 446.5,368.2 C 447.3,368.1 448.1,368.1 449.0,367.9 C 449.8,367.6 450.7,367.4 451.5,366.9 C 452.4,366.4 453.2,365.3 454.0,364.8 C 454.9,364.2 455.7,364.0 456.6,363.6 C 457.4,363.2 458.3,363.2 459.1,362.4 C 459.9,361.7 460.8,360.0 461.6,359.2 C 462.5,358.4 463.3,358.0 464.2,357.6 C 465.0,357.1 465.8,357.0 466.7,356.3 C 467.5,355.6 468.4,354.0 469.2,353.2 C 470.1,352.5 470.9,352.4 471.7,352.0 C 472.6,351.6 473.4,351.2 474.3,350.7 C 475.1,Line truncated
|
||||
<path d="M 90.0,462.2 C 90.4,462.1 92.1,461.8 92.5,461.7 C 93.4,461.6 94.2,461.4 95.1,461.2 C 95.9,461.1 96.7,460.8 97.6,460.8 C 98.4,460.7 99.3,460.7 100.1,460.7 C 101.0,460.7 101.8,460.6 102.6,460.6 C 103.5,460.5 104.3,460.5 105.2,460.4 C 106.0,460.4 106.4,460.3 107.7,460.3 C 109.0,460.2 111.5,460.1 112.8,460.1 C 114.0,460.1 114.4,460.0 115.3,460.0 C 116.1,460.0 117.0,460.0 117.8,459.9 C 118.7,459.9 119.5,459.9 120.3,459.9 C 121.2,459.9 122.0,459.9 122.9,459.8 C 123.7,459.8 124.6,459.7 125.4,459.7 C 126.2,459.6 127.1,459.6 127.9,459.4 C 128.8,459.3 129.6,459.0 130.4,458.7 C 131.3,458.3 132.1,457.6 133.0,457.3 C 133.8,457.1 134.7,457.2 135.5,457.1 C 136.3,457.1 137.2,457.1 138.0,457.0 C 138.9,457.0 139.3,457.0 140.6,456.9 C 141.8,456.9 143.9,456.9 145.6,456.9 C 147.3,456.8 149.4,456.8 150.7,456.8 C 151.9,456.7 152.4,456.7 153.2,456.7 C 154.0,456.6 154.9,456.6 155.7,456.5 C 156.6,456.5 157.4,456.5 158.3,456.4 C 159.1,456.4 159.9,456.4 160.8,456.4 C 161.6,456.4 162.1,456.4 163.3,456.3 C 164.6,456.3 166.3,456.2 168.4,456.2 C 170.5,456.2 173.8,456.2 176.0,456.2 C 178.1,456.1 178.5,456.2 181.0,456.1 C 183.5,456.1 189.0,455.9 191.1,455.9 C 193.2,455.8 192.4,455.9 193.7,455.8 C 194.9,455.8 196.6,455.8 198.7,455.8 C 200.8,455.8 202.1,455.7 206.3,455.7 C 210.5,455.6 220.6,455.5 224.0,455.5 C 227.4,455.5 225.7,455.5 226.5,455.4 C 227.4,455.4 227.8,456.1 229.0,455.4 C 230.3,454.7 232.8,454.1 234.1,451.2 C 235.4,448.4 235.8,440.7 236.6,438.3 C 237.5,435.9 238.3,437.2 239.2,436.8 C 240.0,436.5 240.8,436.2 241.7,436.0 C 242.5,435.7 243.4,435.5 244.2,435.4 C 245.1,435.2 245.9,435.2 246.7,435.0 C 247.6,434.9 248.4,434.8 249.3,434.7 C 250.1,434.6 251.0,434.7 251.8,434.7 C 252.6,434.6 253.5,434.4 254.3,434.3 C 255.2,434.2 256.0,434.2 256.9,434.1 C 257.7,434.0 258.5,433.6 259.4,433.5 C 260.2,433.4 261.1,433.4 261.9,433.3 C 262.8,433.3 263.2,433.1 264.4,433.0 C 265.7,432.9 268.2,432.9 269.5,432.8 C 270.8,432.7 271.2,432.7 272.0,432.6 C 272.9,432.6 273.7,432.6 274.6,432.5 C 275.4,432.4 276.2,432.1 277.1,432.0 C 277.9,431.8 278.8,432.5 279.6,431.6 C 280.4,430.7 281.3,427.5 282.1,426.5 C 283.0,425.4 283.8,425.5 284.7,425.4 C 285.5,425.2 286.3,425.4 287.2,425.3 C 288.0,425.2 288.9,424.8 289.7,424.5 C 290.6,424.2 291.4,424.1 292.2,423.6 C 293.1,423.0 293.9,422.1 294.8,421.5 C 295.6,420.9 296.5,420.3 297.3,419.8 C 298.1,419.4 299.0,419.0 299.8,418.8 C 300.7,418.5 301.5,418.4 302.4,418.3 C 303.2,418.2 304.0,418.2 304.9,418.1 C 305.7,418.0 306.6,417.7 307.4,417.6 C 308.3,417.6 309.1,417.6 309.9,417.5 C 310.8,417.5 311.6,417.4 312.5,417.3 C 313.3,417.2 314.2,417.2 315.0,416.8 C 315.8,416.4 316.7,415.4 317.5,414.9 C 318.4,414.3 319.2,414.2 320.1,413.8 C 320.9,413.4 321.7,414.2 322.6,412.6 C 323.4,411.0 324.3,406.3 325.1,404.5 C 326.0,402.6 326.8,402.3 327.6,401.6 C 328.5,400.8 329.3,400.3 330.2,399.8 C 331.0,399.2 331.9,398.6 332.7,398.2 C 333.5,397.8 334.4,397.4 335.2,397.2 C 336.1,396.9 336.9,396.9 337.8,396.6 C 338.6,396.4 339.4,396.2 340.3,395.9 C 341.1,395.6 342.0,395.1 342.8,394.8 C 343.7,394.6 344.5,394.6 345.3,394.3 C 346.2,394.1 347.0,393.6 347.9,393.3 C 348.7,393.0 349.6,392.7 350.4,392.5 C 351.2,392.2 352.1,392.0 352.9,391.9 C 353.8,391.7 354.6,391.7 355.4,391.5 C 356.3,391.4 357.1,391.2 358.0,391.1 C 358.8,390.9 359.7,390.7 360.5,390.5 C 361.3,390.3 362.2,390.1 363.0,389.8 C 363.9,389.5 364.7,388.9 365.6,388.6 C 366.4,388.3 367.2,388.2 368.1,388.0 C 368.9,387.9 369.8,387.8 370.6,387.7 C 371.5,387.5 372.3,387.4 373.1,387.3 C 374.0,387.2 374.8,387.1 375.7,387.0 C 376.5,386.9 377.4,387.0 378.2,386.8 C 379.0,386.7 379.9,386.5 380.7,386.2 C 381.6,386.0 382.4,385.5 383.3,385.1 C 384.1,384.8 384.9,384.4 385.8,384.2 C 386.6,384.0 387.5,384.0 388.3,383.8 C 389.2,383.7 390.0,383.6 390.8,383.4 C 391.7,383.2 392.5,382.6 393.4,382.4 C 394.2,382.2 395.1,382.3 395.9,382.1 C 396.7,382.0 397.6,381.8 398.4,381.6 C 399.3,381.4 400.1,381.2 401.0,381.0 C 401.8,380.8 402.6,380.6 403.5,380.4 C 404.3,380.3 405.2,380.2 406.0,379.9 C 406.9,379.7 407.7,379.4 408.5,379.1 C 409.4,378.9 410.2,378.8 411.1,378.5 C 411.9,378.3 412.8,378.0 413.6,377.7 C 414.4,377.4 415.3,377.1 416.1,376.9 C 417.0,376.6 417.8,376.6 418.7,376.2 C 419.5,375.8 420.3,374.8 421.2,374.5 C 422.0,374.1 422.9,374.1 423.7,373.9 C 424.6,373.8 425.4,373.8 426.2,373.7 C 427.1,373.6 427.9,373.4 428.8,373.3 C 429.6,373.2 430.4,373.4 431.3,373.0 C 432.1,372.7 433.0,371.6 433.8,371.2 C 434.7,370.8 435.5,370.8 436.3,370.6 C 437.2,370.3 438.0,369.9 438.9,369.7 C 439.7,369.5 440.6,369.6 441.4,369.4 C 442.2,369.2 443.1,368.7 443.9,368.5 C 444.8,368.3 445.6,368.3 446.5,368.2 C 447.3,368.1 448.1,368.1 449.0,367.9 C 449.8,367.6 450.7,367.4 451.5,366.9 C 452.4,366.4 453.2,365.3 454.0,364.8 C 454.9,364.2 455.7,364.0 456.6,363.6 C 457.4,363.2 458.3,363.2 459.1,362.4 C 459.9,361.7 460.8,360.0 461.6,359.2 C 462.5,358.4 463.3,358.0 464.2,357.6 C 465.0,357.1 465.8,357.0 466.7,356.3 C 467.5,355.6 468.4,354.0 469.2,353.2 C 470.1,352.5 470.9,352.4 471.7,352.0 C 472.6,351.6 473.4,351.2 474.3,350.7 C 475.1,Line truncated
|
||||
<circle cx="765.0" cy="119.7" r="5" fill="#dd4528" stroke="#0d1117" stroke-width="2" filter="url(#xkcdify)"/>
|
||||
<text x="753.0" y="77.0" fill="#dd4528" font-size="18" font-family="Handlee, cursive" font-weight="bold" text-anchor="end">6275</text>
|
||||
<path d="M 90.0,462.2 C 90.4,462.1 92.1,461.8 92.5,461.7 C 93.3,461.6 94.1,461.4 94.9,461.2 C 95.7,461.1 96.6,460.8 97.4,460.8 C 98.2,460.7 99.0,460.7 99.9,460.7 C 100.7,460.7 101.5,460.6 102.3,460.6 C 103.1,460.5 104.0,460.5 104.8,460.4 C 105.6,460.4 106.0,460.3 107.2,460.3 C 108.5,460.2 110.9,460.1 112.2,460.1 C 113.4,460.1 113.8,460.0 114.6,460.0 C 115.5,460.0 116.3,460.0 117.1,459.9 C 117.9,459.9 118.7,459.9 119.6,459.9 C 120.4,459.9 121.2,459.9 122.0,459.8 C 122.8,459.8 123.7,459.7 124.5,459.7 C 125.3,459.6 126.1,459.6 127.0,459.4 C 127.8,459.3 128.6,459.0 129.4,458.7 C 130.2,458.3 131.1,457.6 131.9,457.3 C 132.7,457.1 133.5,457.2 134.3,457.1 C 135.2,457.1 136.0,457.1 136.8,457.0 C 137.6,457.0 138.0,457.0 139.3,456.9 C 140.5,456.9 142.6,456.9 144.2,456.9 C 145.8,456.8 147.9,456.8 149.1,456.8 C 150.4,456.7 150.8,456.7 151.6,456.7 C 152.4,456.6 153.2,456.6 154.1,456.5 C 154.9,456.5 155.7,456.5 156.5,456.4 C 157.3,456.4 158.2,456.4 159.0,456.4 C 159.8,456.4 160.2,456.4 161.4,456.3 C 162.7,456.3 164.3,456.2 166.4,456.2 C 168.4,456.2 171.7,456.2 173.8,456.2 C 175.8,456.1 176.2,456.2 178.7,456.1 C 181.1,456.1 186.5,455.9 188.5,455.9 C 190.6,455.8 189.8,455.9 191.0,455.8 C 192.2,455.8 193.9,455.8 195.9,455.8 C 198.0,455.8 199.2,455.7 203.3,455.7 C 207.4,455.6 217.3,455.5 220.6,455.5 C 223.9,455.5 222.2,455.5 223.0,455.4 C 223.9,455.4 224.3,456.1 225.5,455.4 C 226.7,454.7 229.2,454.1 230.4,451.2 C 231.7,448.4 232.1,440.7 232.9,438.3 C 233.7,435.9 234.5,437.2 235.3,436.8 C 236.2,436.5 237.0,436.2 237.8,436.0 C 238.6,435.7 239.5,435.5 240.3,435.4 C 241.1,435.2 241.9,435.2 242.7,435.0 C 243.6,434.9 244.4,434.8 245.2,434.7 C 246.0,434.6 246.8,434.7 247.7,434.7 C 248.5,434.6 249.3,434.4 250.1,434.3 C 250.9,434.2 251.8,434.2 252.6,434.1 C 253.4,434.0 254.2,433.6 255.1,433.5 C 255.9,433.4 256.7,433.4 257.5,433.3 C 258.3,433.3 258.8,433.1 260.0,433.0 C 261.2,432.9 263.7,432.9 264.9,432.8 C 266.1,432.7 266.6,432.7 267.4,432.6 C 268.2,432.6 269.0,432.6 269.8,432.5 C 270.7,432.4 271.5,432.1 272.3,432.0 C 273.1,431.8 273.9,432.5 274.8,431.6 C 275.6,430.7 276.4,427.5 277.2,426.5 C 278.0,425.4 278.9,425.5 279.7,425.4 C 280.5,425.2 281.3,425.4 282.2,425.3 C 283.0,425.2 283.8,424.8 284.6,424.5 C 285.4,424.2 286.3,424.1 287.1,423.6 C 287.9,423.0 288.7,422.1 289.5,421.5 C 290.4,420.9 291.2,420.3 292.0,419.9 C 292.8,419.4 293.6,419.1 294.5,418.8 C 295.3,418.6 296.1,418.5 296.9,418.4 C 297.8,418.2 298.6,418.2 299.4,418.1 C 300.2,418.0 301.0,417.8 301.9,417.7 C 302.7,417.6 303.5,417.6 304.3,417.6 C 305.1,417.5 306.0,417.5 306.8,417.4 C 307.6,417.2 308.4,417.3 309.3,416.9 C 310.1,416.5 310.9,415.4 311.7,414.9 C 312.5,414.4 313.4,414.2 314.2,413.9 C 315.0,413.5 315.8,414.2 316.6,412.7 C 317.5,411.1 318.3,406.3 319.1,404.5 C 319.9,402.6 320.7,402.3 321.6,401.6 C 322.4,400.8 323.2,400.3 324.0,399.8 C 324.9,399.2 325.7,398.7 326.5,398.3 C 327.3,397.9 328.1,397.5 329.0,397.2 C 329.8,397.0 330.6,396.9 331.4,396.7 C 332.2,396.5 333.1,396.2 333.9,395.9 C 334.7,395.6 335.5,395.1 336.4,394.9 C 337.2,394.6 338.0,394.6 338.8,394.4 C 339.6,394.1 340.5,393.7 341.3,393.3 C 342.1,393.0 342.9,392.8 343.7,392.5 C 344.6,392.3 345.4,392.1 346.2,391.9 C 347.0,391.8 347.8,391.7 348.7,391.6 C 349.5,391.5 350.3,391.3 351.1,391.1 C 352.0,390.9 352.8,390.8 353.6,390.6 C 354.4,390.3 355.2,390.2 356.1,389.8 C 356.9,389.5 357.7,388.9 358.5,388.6 C 359.3,388.4 360.2,388.3 361.0,388.1 C 361.8,387.9 362.6,387.8 363.4,387.7 C 364.3,387.6 365.1,387.5 365.9,387.4 C 366.7,387.3 367.6,387.1 368.4,387.1 C 369.2,387.0 370.0,387.0 370.8,386.9 C 371.7,386.8 372.5,386.6 373.3,386.3 C 374.1,386.0 374.9,385.5 375.8,385.2 C 376.6,384.9 377.4,384.5 378.2,384.3 C 379.1,384.0 379.9,384.0 380.7,383.9 C 381.5,383.7 382.3,383.7 383.2,383.4 C 384.0,383.2 384.8,382.7 385.6,382.5 C 386.4,382.3 387.3,382.3 388.1,382.2 C 388.9,382.1 389.7,381.8 390.5,381.6 C 391.4,381.5 392.2,381.3 393.0,381.1 C 393.8,380.9 394.7,380.7 395.5,380.5 C 396.3,380.3 397.1,380.2 397.9,380.0 C 398.8,379.8 399.6,379.4 400.4,379.2 C 401.2,378.9 402.0,378.8 402.9,378.6 C 403.7,378.3 404.5,378.0 405.3,377.8 C 406.1,377.5 407.0,377.2 407.8,376.9 C 408.6,376.7 409.4,376.6 410.3,376.2 C 411.1,375.8 411.9,374.9 412.7,374.5 C 413.5,374.2 414.4,374.1 415.2,374.0 C 416.0,373.9 416.8,373.9 417.6,373.8 C 418.5,373.7 419.3,373.5 420.1,373.4 C 420.9,373.3 421.8,373.5 422.6,373.1 C 423.4,372.7 424.2,371.7 425.0,371.2 C 425.9,370.8 426.7,370.9 427.5,370.6 C 428.3,370.4 429.1,369.9 430.0,369.7 C 430.8,369.5 431.6,369.6 432.4,369.4 C 433.2,369.2 434.1,368.7 434.9,368.5 C 435.7,368.3 436.5,368.3 437.4,368.2 C 438.2,368.1 439.0,368.1 439.8,367.9 C 440.6,367.7 441.5,367.4 442.3,366.9 C 443.1,366.4 443.9,365.4 444.7,364.8 C 445.6,364.3 446.4,364.1 447.2,363.7 C 448.0,363.3 448.9,363.2 449.7,362.5 C 450.5,361.8 451.3,360.1 452.1,359.3 C 453.0,358.5 453.8,358.1 454.6,357.6 C 455.4,357.1 456.2,357.1 457.1,356.4 C 457.9,355.6 458.7,354.0 459.5,353.3 C 460.3,352.6 461.2,352.5 462.0,352.0 C 462.8,351.6 463.6,351.3 464.5,350.8 C 465.3,3Line truncated
|
||||
<path d="M 90.0,462.2 C 90.4,462.1 92.1,461.8 92.5,461.7 C 93.3,461.6 94.1,461.4 94.9,461.2 C 95.7,461.1 96.6,460.8 97.4,460.8 C 98.2,460.7 99.0,460.7 99.9,460.7 C 100.7,460.7 101.5,460.6 102.3,460.6 C 103.1,460.5 104.0,460.5 104.8,460.4 C 105.6,460.4 106.0,460.3 107.2,460.3 C 108.5,460.2 110.9,460.1 112.2,460.1 C 113.4,460.1 113.8,460.0 114.6,460.0 C 115.5,460.0 116.3,460.0 117.1,459.9 C 117.9,459.9 118.7,459.9 119.6,459.9 C 120.4,459.9 121.2,459.9 122.0,459.8 C 122.8,459.8 123.7,459.7 124.5,459.7 C 125.3,459.6 126.1,459.6 127.0,459.4 C 127.8,459.3 128.6,459.0 129.4,458.7 C 130.2,458.3 131.1,457.6 131.9,457.3 C 132.7,457.1 133.5,457.2 134.3,457.1 C 135.2,457.1 136.0,457.1 136.8,457.0 C 137.6,457.0 138.0,457.0 139.3,456.9 C 140.5,456.9 142.6,456.9 144.2,456.9 C 145.8,456.8 147.9,456.8 149.1,456.8 C 150.4,456.7 150.8,456.7 151.6,456.7 C 152.4,456.6 153.2,456.6 154.1,456.5 C 154.9,456.5 155.7,456.5 156.5,456.4 C 157.3,456.4 158.2,456.4 159.0,456.4 C 159.8,456.4 160.2,456.4 161.4,456.3 C 162.7,456.3 164.3,456.2 166.4,456.2 C 168.4,456.2 171.7,456.2 173.8,456.2 C 175.8,456.1 176.2,456.2 178.7,456.1 C 181.1,456.1 186.5,455.9 188.5,455.9 C 190.6,455.8 189.8,455.9 191.0,455.8 C 192.2,455.8 193.9,455.8 195.9,455.8 C 198.0,455.8 199.2,455.7 203.3,455.7 C 207.4,455.6 217.3,455.5 220.6,455.5 C 223.9,455.5 222.2,455.5 223.0,455.4 C 223.9,455.4 224.3,456.1 225.5,455.4 C 226.7,454.7 229.2,454.1 230.4,451.2 C 231.7,448.4 232.1,440.7 232.9,438.3 C 233.7,435.9 234.5,437.2 235.3,436.8 C 236.2,436.5 237.0,436.2 237.8,436.0 C 238.6,435.7 239.5,435.5 240.3,435.4 C 241.1,435.2 241.9,435.2 242.7,435.0 C 243.6,434.9 244.4,434.8 245.2,434.7 C 246.0,434.6 246.8,434.7 247.7,434.7 C 248.5,434.6 249.3,434.4 250.1,434.3 C 250.9,434.2 251.8,434.2 252.6,434.1 C 253.4,434.0 254.2,433.6 255.1,433.5 C 255.9,433.4 256.7,433.4 257.5,433.3 C 258.3,433.3 258.8,433.1 260.0,433.0 C 261.2,432.9 263.7,432.9 264.9,432.8 C 266.1,432.7 266.6,432.7 267.4,432.6 C 268.2,432.6 269.0,432.6 269.8,432.5 C 270.7,432.4 271.5,432.1 272.3,432.0 C 273.1,431.8 273.9,432.5 274.8,431.6 C 275.6,430.7 276.4,427.5 277.2,426.5 C 278.0,425.4 278.9,425.5 279.7,425.4 C 280.5,425.2 281.3,425.4 282.2,425.3 C 283.0,425.2 283.8,424.8 284.6,424.5 C 285.4,424.2 286.3,424.1 287.1,423.6 C 287.9,423.0 288.7,422.1 289.5,421.5 C 290.4,420.9 291.2,420.3 292.0,419.9 C 292.8,419.4 293.6,419.1 294.5,418.8 C 295.3,418.6 296.1,418.5 296.9,418.4 C 297.8,418.2 298.6,418.2 299.4,418.1 C 300.2,418.0 301.0,417.8 301.9,417.7 C 302.7,417.6 303.5,417.6 304.3,417.6 C 305.1,417.5 306.0,417.5 306.8,417.4 C 307.6,417.2 308.4,417.3 309.3,416.9 C 310.1,416.5 310.9,415.4 311.7,414.9 C 312.5,414.4 313.4,414.2 314.2,413.9 C 315.0,413.5 315.8,414.2 316.6,412.7 C 317.5,411.1 318.3,406.3 319.1,404.5 C 319.9,402.6 320.7,402.3 321.6,401.6 C 322.4,400.8 323.2,400.3 324.0,399.8 C 324.9,399.2 325.7,398.7 326.5,398.3 C 327.3,397.9 328.1,397.5 329.0,397.2 C 329.8,397.0 330.6,396.9 331.4,396.7 C 332.2,396.5 333.1,396.2 333.9,395.9 C 334.7,395.6 335.5,395.1 336.4,394.9 C 337.2,394.6 338.0,394.6 338.8,394.4 C 339.6,394.1 340.5,393.7 341.3,393.3 C 342.1,393.0 342.9,392.8 343.7,392.5 C 344.6,392.3 345.4,392.1 346.2,391.9 C 347.0,391.8 347.8,391.7 348.7,391.6 C 349.5,391.5 350.3,391.3 351.1,391.1 C 352.0,390.9 352.8,390.8 353.6,390.6 C 354.4,390.3 355.2,390.2 356.1,389.8 C 356.9,389.5 357.7,388.9 358.5,388.6 C 359.3,388.4 360.2,388.3 361.0,388.1 C 361.8,387.9 362.6,387.8 363.4,387.7 C 364.3,387.6 365.1,387.5 365.9,387.4 C 366.7,387.3 367.6,387.1 368.4,387.1 C 369.2,387.0 370.0,387.0 370.8,386.9 C 371.7,386.8 372.5,386.6 373.3,386.3 C 374.1,386.0 374.9,385.5 375.8,385.2 C 376.6,384.9 377.4,384.5 378.2,384.3 C 379.1,384.0 379.9,384.0 380.7,383.9 C 381.5,383.7 382.3,383.7 383.2,383.4 C 384.0,383.2 384.8,382.7 385.6,382.5 C 386.4,382.3 387.3,382.3 388.1,382.2 C 388.9,382.1 389.7,381.8 390.5,381.6 C 391.4,381.5 392.2,381.3 393.0,381.1 C 393.8,380.9 394.7,380.7 395.5,380.5 C 396.3,380.3 397.1,380.2 397.9,380.0 C 398.8,379.8 399.6,379.4 400.4,379.2 C 401.2,378.9 402.0,378.8 402.9,378.6 C 403.7,378.3 404.5,378.0 405.3,377.8 C 406.1,377.5 407.0,377.2 407.8,376.9 C 408.6,376.7 409.4,376.6 410.3,376.2 C 411.1,375.8 411.9,374.9 412.7,374.5 C 413.5,374.2 414.4,374.1 415.2,374.0 C 416.0,373.9 416.8,373.9 417.6,373.8 C 418.5,373.7 419.3,373.5 420.1,373.4 C 420.9,373.3 421.8,373.5 422.6,373.1 C 423.4,372.7 424.2,371.7 425.0,371.2 C 425.9,370.8 426.7,370.9 427.5,370.6 C 428.3,370.4 429.1,369.9 430.0,369.7 C 430.8,369.5 431.6,369.6 432.4,369.4 C 433.2,369.2 434.1,368.7 434.9,368.5 C 435.7,368.3 436.5,368.3 437.4,368.2 C 438.2,368.1 439.0,368.1 439.8,367.9 C 440.6,367.7 441.5,367.4 442.3,366.9 C 443.1,366.4 443.9,365.4 444.7,364.8 C 445.6,364.3 446.4,364.1 447.2,363.7 C 448.0,363.3 448.9,363.2 449.7,362.5 C 450.5,361.8 451.3,360.1 452.1,359.3 C 453.0,358.5 453.8,358.1 454.6,357.6 C 455.4,357.1 456.2,357.1 457.1,356.4 C 457.9,355.6 458.7,354.0 459.5,353.3 C 460.3,352.6 461.2,352.5 462.0,352.0 C 462.8,351.6 463.6,351.3 464.5,350.8 C 465.3,3Line truncated
|
||||
<circle cx="765.0" cy="113.6" r="5" fill="#dd4528" stroke="#0d1117" stroke-width="2" filter="url(#xkcdify)"/>
|
||||
<text x="753.0" y="77.0" fill="#dd4528" font-size="18" font-family="Handlee, cursive" font-weight="bold" text-anchor="end">6385</text>
|
||||
<a href="https://github.com/carsteneu/mystarhistory" target="_blank" rel="noopener"><text x="763" y="527" font-size="13" font-family="Handlee, cursive" fill="#fff" text-anchor="end">Made with carsteneu/mystarhistory</text></a>
|
||||
</svg>
|
||||
|
Before Width: | Height: | Size: 34 KiB After Width: | Height: | Size: 35 KiB |
@@ -31,20 +31,20 @@
|
||||
<text x="85" y="85.0" fill="#000" font-size="16" font-family="Handlee, cursive" font-weight="bold" text-anchor="end">7K</text>
|
||||
<text x="43" y="271.5" fill="#000" font-size="17" font-family="Handlee, cursive" font-weight="bold" transform="rotate(-90, 43, 271.5)">GitHub Stars</text>
|
||||
<text x="90.0" y="488" fill="#000" font-size="16" font-family="Handlee, cursive" font-weight="bold" text-anchor="middle">Jan 2026</text>
|
||||
<text x="163.3" y="488" fill="#000" font-size="16" font-family="Handlee, cursive" font-weight="bold" text-anchor="middle">Feb 2026</text>
|
||||
<text x="234.1" y="488" fill="#000" font-size="16" font-family="Handlee, cursive" font-weight="bold" text-anchor="middle">Mar 2026</text>
|
||||
<text x="312.5" y="488" fill="#000" font-size="16" font-family="Handlee, cursive" font-weight="bold" text-anchor="middle">Apr 2026</text>
|
||||
<text x="388.3" y="488" fill="#000" font-size="16" font-family="Handlee, cursive" font-weight="bold" text-anchor="middle">May 2026</text>
|
||||
<text x="466.7" y="488" fill="#000" font-size="16" font-family="Handlee, cursive" font-weight="bold" text-anchor="middle">Jun 2026</text>
|
||||
<text x="542.5" y="488" fill="#000" font-size="16" font-family="Handlee, cursive" font-weight="bold" text-anchor="middle">Jul 2026</text>
|
||||
<text x="620.9" y="488" fill="#000" font-size="16" font-family="Handlee, cursive" font-weight="bold" text-anchor="middle">Aug 2026</text>
|
||||
<text x="699.3" y="488" fill="#000" font-size="16" font-family="Handlee, cursive" font-weight="bold" text-anchor="middle">Sep 2026</text>
|
||||
<text x="161.4" y="488" fill="#000" font-size="16" font-family="Handlee, cursive" font-weight="bold" text-anchor="middle">Feb 2026</text>
|
||||
<text x="306.8" y="488" fill="#000" font-size="16" font-family="Handlee, cursive" font-weight="bold" text-anchor="middle">Apr 2026</text>
|
||||
<text x="380.7" y="488" fill="#000" font-size="16" font-family="Handlee, cursive" font-weight="bold" text-anchor="middle">May 2026</text>
|
||||
<text x="457.1" y="488" fill="#000" font-size="16" font-family="Handlee, cursive" font-weight="bold" text-anchor="middle">Jun 2026</text>
|
||||
<text x="531.0" y="488" fill="#000" font-size="16" font-family="Handlee, cursive" font-weight="bold" text-anchor="middle">Jul 2026</text>
|
||||
<text x="607.3" y="488" fill="#000" font-size="16" font-family="Handlee, cursive" font-weight="bold" text-anchor="middle">Aug 2026</text>
|
||||
<text x="683.7" y="488" fill="#000" font-size="16" font-family="Handlee, cursive" font-weight="bold" text-anchor="middle">Sep 2026</text>
|
||||
<text x="757.6" y="488" fill="#000" font-size="16" font-family="Handlee, cursive" font-weight="bold" text-anchor="middle">Oct 2026</text>
|
||||
<text x="50%" y="525" fill="#000" font-size="17" font-family="Handlee, cursive" font-weight="bold" text-anchor="middle">Date</text>
|
||||
<line x1="90" y1="80" x2="90" y2="463" stroke="#222" stroke-width="2.5" filter="url(#xkcdify)"/>
|
||||
<line x1="90" y1="463" x2="765" y2="463" stroke="#222" stroke-width="2.5" filter="url(#xkcdify)"/>
|
||||
<path d="M 90.0,462.2 C 90.4,462.1 92.1,461.8 92.5,461.7 C 93.4,461.6 94.2,461.4 95.1,461.2 C 95.9,461.1 96.7,460.8 97.6,460.8 C 98.4,460.7 99.3,460.7 100.1,460.7 C 101.0,460.7 101.8,460.6 102.6,460.6 C 103.5,460.5 104.3,460.5 105.2,460.4 C 106.0,460.4 106.4,460.3 107.7,460.3 C 109.0,460.2 111.5,460.1 112.8,460.1 C 114.0,460.1 114.4,460.0 115.3,460.0 C 116.1,460.0 117.0,460.0 117.8,459.9 C 118.7,459.9 119.5,459.9 120.3,459.9 C 121.2,459.9 122.0,459.9 122.9,459.8 C 123.7,459.8 124.6,459.7 125.4,459.7 C 126.2,459.6 127.1,459.6 127.9,459.4 C 128.8,459.3 129.6,459.0 130.4,458.7 C 131.3,458.3 132.1,457.6 133.0,457.3 C 133.8,457.1 134.7,457.2 135.5,457.1 C 136.3,457.1 137.2,457.1 138.0,457.0 C 138.9,457.0 139.3,457.0 140.6,456.9 C 141.8,456.9 143.9,456.9 145.6,456.9 C 147.3,456.8 149.4,456.8 150.7,456.8 C 151.9,456.7 152.4,456.7 153.2,456.7 C 154.0,456.6 154.9,456.6 155.7,456.5 C 156.6,456.5 157.4,456.5 158.3,456.4 C 159.1,456.4 159.9,456.4 160.8,456.4 C 161.6,456.4 162.1,456.4 163.3,456.3 C 164.6,456.3 166.3,456.2 168.4,456.2 C 170.5,456.2 173.8,456.2 176.0,456.2 C 178.1,456.1 178.5,456.2 181.0,456.1 C 183.5,456.1 189.0,455.9 191.1,455.9 C 193.2,455.8 192.4,455.9 193.7,455.8 C 194.9,455.8 196.6,455.8 198.7,455.8 C 200.8,455.8 202.1,455.7 206.3,455.7 C 210.5,455.6 220.6,455.5 224.0,455.5 C 227.4,455.5 225.7,455.5 226.5,455.4 C 227.4,455.4 227.8,456.1 229.0,455.4 C 230.3,454.7 232.8,454.1 234.1,451.2 C 235.4,448.4 235.8,440.7 236.6,438.3 C 237.5,435.9 238.3,437.2 239.2,436.8 C 240.0,436.5 240.8,436.2 241.7,436.0 C 242.5,435.7 243.4,435.5 244.2,435.4 C 245.1,435.2 245.9,435.2 246.7,435.0 C 247.6,434.9 248.4,434.8 249.3,434.7 C 250.1,434.6 251.0,434.7 251.8,434.7 C 252.6,434.6 253.5,434.4 254.3,434.3 C 255.2,434.2 256.0,434.2 256.9,434.1 C 257.7,434.0 258.5,433.6 259.4,433.5 C 260.2,433.4 261.1,433.4 261.9,433.3 C 262.8,433.3 263.2,433.1 264.4,433.0 C 265.7,432.9 268.2,432.9 269.5,432.8 C 270.8,432.7 271.2,432.7 272.0,432.6 C 272.9,432.6 273.7,432.6 274.6,432.5 C 275.4,432.4 276.2,432.1 277.1,432.0 C 277.9,431.8 278.8,432.5 279.6,431.6 C 280.4,430.7 281.3,427.5 282.1,426.5 C 283.0,425.4 283.8,425.5 284.7,425.4 C 285.5,425.2 286.3,425.4 287.2,425.3 C 288.0,425.2 288.9,424.8 289.7,424.5 C 290.6,424.2 291.4,424.1 292.2,423.6 C 293.1,423.0 293.9,422.1 294.8,421.5 C 295.6,420.9 296.5,420.3 297.3,419.8 C 298.1,419.4 299.0,419.0 299.8,418.8 C 300.7,418.5 301.5,418.4 302.4,418.3 C 303.2,418.2 304.0,418.2 304.9,418.1 C 305.7,418.0 306.6,417.7 307.4,417.6 C 308.3,417.6 309.1,417.6 309.9,417.5 C 310.8,417.5 311.6,417.4 312.5,417.3 C 313.3,417.2 314.2,417.2 315.0,416.8 C 315.8,416.4 316.7,415.4 317.5,414.9 C 318.4,414.3 319.2,414.2 320.1,413.8 C 320.9,413.4 321.7,414.2 322.6,412.6 C 323.4,411.0 324.3,406.3 325.1,404.5 C 326.0,402.6 326.8,402.3 327.6,401.6 C 328.5,400.8 329.3,400.3 330.2,399.8 C 331.0,399.2 331.9,398.6 332.7,398.2 C 333.5,397.8 334.4,397.4 335.2,397.2 C 336.1,396.9 336.9,396.9 337.8,396.6 C 338.6,396.4 339.4,396.2 340.3,395.9 C 341.1,395.6 342.0,395.1 342.8,394.8 C 343.7,394.6 344.5,394.6 345.3,394.3 C 346.2,394.1 347.0,393.6 347.9,393.3 C 348.7,393.0 349.6,392.7 350.4,392.5 C 351.2,392.2 352.1,392.0 352.9,391.9 C 353.8,391.7 354.6,391.7 355.4,391.5 C 356.3,391.4 357.1,391.2 358.0,391.1 C 358.8,390.9 359.7,390.7 360.5,390.5 C 361.3,390.3 362.2,390.1 363.0,389.8 C 363.9,389.5 364.7,388.9 365.6,388.6 C 366.4,388.3 367.2,388.2 368.1,388.0 C 368.9,387.9 369.8,387.8 370.6,387.7 C 371.5,387.5 372.3,387.4 373.1,387.3 C 374.0,387.2 374.8,387.1 375.7,387.0 C 376.5,386.9 377.4,387.0 378.2,386.8 C 379.0,386.7 379.9,386.5 380.7,386.2 C 381.6,386.0 382.4,385.5 383.3,385.1 C 384.1,384.8 384.9,384.4 385.8,384.2 C 386.6,384.0 387.5,384.0 388.3,383.8 C 389.2,383.7 390.0,383.6 390.8,383.4 C 391.7,383.2 392.5,382.6 393.4,382.4 C 394.2,382.2 395.1,382.3 395.9,382.1 C 396.7,382.0 397.6,381.8 398.4,381.6 C 399.3,381.4 400.1,381.2 401.0,381.0 C 401.8,380.8 402.6,380.6 403.5,380.4 C 404.3,380.3 405.2,380.2 406.0,379.9 C 406.9,379.7 407.7,379.4 408.5,379.1 C 409.4,378.9 410.2,378.8 411.1,378.5 C 411.9,378.3 412.8,378.0 413.6,377.7 C 414.4,377.4 415.3,377.1 416.1,376.9 C 417.0,376.6 417.8,376.6 418.7,376.2 C 419.5,375.8 420.3,374.8 421.2,374.5 C 422.0,374.1 422.9,374.1 423.7,373.9 C 424.6,373.8 425.4,373.8 426.2,373.7 C 427.1,373.6 427.9,373.4 428.8,373.3 C 429.6,373.2 430.4,373.4 431.3,373.0 C 432.1,372.7 433.0,371.6 433.8,371.2 C 434.7,370.8 435.5,370.8 436.3,370.6 C 437.2,370.3 438.0,369.9 438.9,369.7 C 439.7,369.5 440.6,369.6 441.4,369.4 C 442.2,369.2 443.1,368.7 443.9,368.5 C 444.8,368.3 445.6,368.3 446.5,368.2 C 447.3,368.1 448.1,368.1 449.0,367.9 C 449.8,367.6 450.7,367.4 451.5,366.9 C 452.4,366.4 453.2,365.3 454.0,364.8 C 454.9,364.2 455.7,364.0 456.6,363.6 C 457.4,363.2 458.3,363.2 459.1,362.4 C 459.9,361.7 460.8,360.0 461.6,359.2 C 462.5,358.4 463.3,358.0 464.2,357.6 C 465.0,357.1 465.8,357.0 466.7,356.3 C 467.5,355.6 468.4,354.0 469.2,353.2 C 470.1,352.5 470.9,352.4 471.7,352.0 C 472.6,351.6 473.4,351.2 474.3,350.7 C 475.1,Line truncated
|
||||
<path d="M 90.0,462.2 C 90.4,462.1 92.1,461.8 92.5,461.7 C 93.4,461.6 94.2,461.4 95.1,461.2 C 95.9,461.1 96.7,460.8 97.6,460.8 C 98.4,460.7 99.3,460.7 100.1,460.7 C 101.0,460.7 101.8,460.6 102.6,460.6 C 103.5,460.5 104.3,460.5 105.2,460.4 C 106.0,460.4 106.4,460.3 107.7,460.3 C 109.0,460.2 111.5,460.1 112.8,460.1 C 114.0,460.1 114.4,460.0 115.3,460.0 C 116.1,460.0 117.0,460.0 117.8,459.9 C 118.7,459.9 119.5,459.9 120.3,459.9 C 121.2,459.9 122.0,459.9 122.9,459.8 C 123.7,459.8 124.6,459.7 125.4,459.7 C 126.2,459.6 127.1,459.6 127.9,459.4 C 128.8,459.3 129.6,459.0 130.4,458.7 C 131.3,458.3 132.1,457.6 133.0,457.3 C 133.8,457.1 134.7,457.2 135.5,457.1 C 136.3,457.1 137.2,457.1 138.0,457.0 C 138.9,457.0 139.3,457.0 140.6,456.9 C 141.8,456.9 143.9,456.9 145.6,456.9 C 147.3,456.8 149.4,456.8 150.7,456.8 C 151.9,456.7 152.4,456.7 153.2,456.7 C 154.0,456.6 154.9,456.6 155.7,456.5 C 156.6,456.5 157.4,456.5 158.3,456.4 C 159.1,456.4 159.9,456.4 160.8,456.4 C 161.6,456.4 162.1,456.4 163.3,456.3 C 164.6,456.3 166.3,456.2 168.4,456.2 C 170.5,456.2 173.8,456.2 176.0,456.2 C 178.1,456.1 178.5,456.2 181.0,456.1 C 183.5,456.1 189.0,455.9 191.1,455.9 C 193.2,455.8 192.4,455.9 193.7,455.8 C 194.9,455.8 196.6,455.8 198.7,455.8 C 200.8,455.8 202.1,455.7 206.3,455.7 C 210.5,455.6 220.6,455.5 224.0,455.5 C 227.4,455.5 225.7,455.5 226.5,455.4 C 227.4,455.4 227.8,456.1 229.0,455.4 C 230.3,454.7 232.8,454.1 234.1,451.2 C 235.4,448.4 235.8,440.7 236.6,438.3 C 237.5,435.9 238.3,437.2 239.2,436.8 C 240.0,436.5 240.8,436.2 241.7,436.0 C 242.5,435.7 243.4,435.5 244.2,435.4 C 245.1,435.2 245.9,435.2 246.7,435.0 C 247.6,434.9 248.4,434.8 249.3,434.7 C 250.1,434.6 251.0,434.7 251.8,434.7 C 252.6,434.6 253.5,434.4 254.3,434.3 C 255.2,434.2 256.0,434.2 256.9,434.1 C 257.7,434.0 258.5,433.6 259.4,433.5 C 260.2,433.4 261.1,433.4 261.9,433.3 C 262.8,433.3 263.2,433.1 264.4,433.0 C 265.7,432.9 268.2,432.9 269.5,432.8 C 270.8,432.7 271.2,432.7 272.0,432.6 C 272.9,432.6 273.7,432.6 274.6,432.5 C 275.4,432.4 276.2,432.1 277.1,432.0 C 277.9,431.8 278.8,432.5 279.6,431.6 C 280.4,430.7 281.3,427.5 282.1,426.5 C 283.0,425.4 283.8,425.5 284.7,425.4 C 285.5,425.2 286.3,425.4 287.2,425.3 C 288.0,425.2 288.9,424.8 289.7,424.5 C 290.6,424.2 291.4,424.1 292.2,423.6 C 293.1,423.0 293.9,422.1 294.8,421.5 C 295.6,420.9 296.5,420.3 297.3,419.8 C 298.1,419.4 299.0,419.0 299.8,418.8 C 300.7,418.5 301.5,418.4 302.4,418.3 C 303.2,418.2 304.0,418.2 304.9,418.1 C 305.7,418.0 306.6,417.7 307.4,417.6 C 308.3,417.6 309.1,417.6 309.9,417.5 C 310.8,417.5 311.6,417.4 312.5,417.3 C 313.3,417.2 314.2,417.2 315.0,416.8 C 315.8,416.4 316.7,415.4 317.5,414.9 C 318.4,414.3 319.2,414.2 320.1,413.8 C 320.9,413.4 321.7,414.2 322.6,412.6 C 323.4,411.0 324.3,406.3 325.1,404.5 C 326.0,402.6 326.8,402.3 327.6,401.6 C 328.5,400.8 329.3,400.3 330.2,399.8 C 331.0,399.2 331.9,398.6 332.7,398.2 C 333.5,397.8 334.4,397.4 335.2,397.2 C 336.1,396.9 336.9,396.9 337.8,396.6 C 338.6,396.4 339.4,396.2 340.3,395.9 C 341.1,395.6 342.0,395.1 342.8,394.8 C 343.7,394.6 344.5,394.6 345.3,394.3 C 346.2,394.1 347.0,393.6 347.9,393.3 C 348.7,393.0 349.6,392.7 350.4,392.5 C 351.2,392.2 352.1,392.0 352.9,391.9 C 353.8,391.7 354.6,391.7 355.4,391.5 C 356.3,391.4 357.1,391.2 358.0,391.1 C 358.8,390.9 359.7,390.7 360.5,390.5 C 361.3,390.3 362.2,390.1 363.0,389.8 C 363.9,389.5 364.7,388.9 365.6,388.6 C 366.4,388.3 367.2,388.2 368.1,388.0 C 368.9,387.9 369.8,387.8 370.6,387.7 C 371.5,387.5 372.3,387.4 373.1,387.3 C 374.0,387.2 374.8,387.1 375.7,387.0 C 376.5,386.9 377.4,387.0 378.2,386.8 C 379.0,386.7 379.9,386.5 380.7,386.2 C 381.6,386.0 382.4,385.5 383.3,385.1 C 384.1,384.8 384.9,384.4 385.8,384.2 C 386.6,384.0 387.5,384.0 388.3,383.8 C 389.2,383.7 390.0,383.6 390.8,383.4 C 391.7,383.2 392.5,382.6 393.4,382.4 C 394.2,382.2 395.1,382.3 395.9,382.1 C 396.7,382.0 397.6,381.8 398.4,381.6 C 399.3,381.4 400.1,381.2 401.0,381.0 C 401.8,380.8 402.6,380.6 403.5,380.4 C 404.3,380.3 405.2,380.2 406.0,379.9 C 406.9,379.7 407.7,379.4 408.5,379.1 C 409.4,378.9 410.2,378.8 411.1,378.5 C 411.9,378.3 412.8,378.0 413.6,377.7 C 414.4,377.4 415.3,377.1 416.1,376.9 C 417.0,376.6 417.8,376.6 418.7,376.2 C 419.5,375.8 420.3,374.8 421.2,374.5 C 422.0,374.1 422.9,374.1 423.7,373.9 C 424.6,373.8 425.4,373.8 426.2,373.7 C 427.1,373.6 427.9,373.4 428.8,373.3 C 429.6,373.2 430.4,373.4 431.3,373.0 C 432.1,372.7 433.0,371.6 433.8,371.2 C 434.7,370.8 435.5,370.8 436.3,370.6 C 437.2,370.3 438.0,369.9 438.9,369.7 C 439.7,369.5 440.6,369.6 441.4,369.4 C 442.2,369.2 443.1,368.7 443.9,368.5 C 444.8,368.3 445.6,368.3 446.5,368.2 C 447.3,368.1 448.1,368.1 449.0,367.9 C 449.8,367.6 450.7,367.4 451.5,366.9 C 452.4,366.4 453.2,365.3 454.0,364.8 C 454.9,364.2 455.7,364.0 456.6,363.6 C 457.4,363.2 458.3,363.2 459.1,362.4 C 459.9,361.7 460.8,360.0 461.6,359.2 C 462.5,358.4 463.3,358.0 464.2,357.6 C 465.0,357.1 465.8,357.0 466.7,356.3 C 467.5,355.6 468.4,354.0 469.2,353.2 C 470.1,352.5 470.9,352.4 471.7,352.0 C 472.6,351.6 473.4,351.2 474.3,350.7 C 475.1,Line truncated
|
||||
<circle cx="765.0" cy="119.7" r="5" fill="#dd4528" stroke="#fff" stroke-width="2" filter="url(#xkcdify)"/>
|
||||
<text x="753.0" y="77.0" fill="#dd4528" font-size="18" font-family="Handlee, cursive" font-weight="bold" text-anchor="end">6275</text>
|
||||
<path d="M 90.0,462.2 C 90.4,462.1 92.1,461.8 92.5,461.7 C 93.3,461.6 94.1,461.4 94.9,461.2 C 95.7,461.1 96.6,460.8 97.4,460.8 C 98.2,460.7 99.0,460.7 99.9,460.7 C 100.7,460.7 101.5,460.6 102.3,460.6 C 103.1,460.5 104.0,460.5 104.8,460.4 C 105.6,460.4 106.0,460.3 107.2,460.3 C 108.5,460.2 110.9,460.1 112.2,460.1 C 113.4,460.1 113.8,460.0 114.6,460.0 C 115.5,460.0 116.3,460.0 117.1,459.9 C 117.9,459.9 118.7,459.9 119.6,459.9 C 120.4,459.9 121.2,459.9 122.0,459.8 C 122.8,459.8 123.7,459.7 124.5,459.7 C 125.3,459.6 126.1,459.6 127.0,459.4 C 127.8,459.3 128.6,459.0 129.4,458.7 C 130.2,458.3 131.1,457.6 131.9,457.3 C 132.7,457.1 133.5,457.2 134.3,457.1 C 135.2,457.1 136.0,457.1 136.8,457.0 C 137.6,457.0 138.0,457.0 139.3,456.9 C 140.5,456.9 142.6,456.9 144.2,456.9 C 145.8,456.8 147.9,456.8 149.1,456.8 C 150.4,456.7 150.8,456.7 151.6,456.7 C 152.4,456.6 153.2,456.6 154.1,456.5 C 154.9,456.5 155.7,456.5 156.5,456.4 C 157.3,456.4 158.2,456.4 159.0,456.4 C 159.8,456.4 160.2,456.4 161.4,456.3 C 162.7,456.3 164.3,456.2 166.4,456.2 C 168.4,456.2 171.7,456.2 173.8,456.2 C 175.8,456.1 176.2,456.2 178.7,456.1 C 181.1,456.1 186.5,455.9 188.5,455.9 C 190.6,455.8 189.8,455.9 191.0,455.8 C 192.2,455.8 193.9,455.8 195.9,455.8 C 198.0,455.8 199.2,455.7 203.3,455.7 C 207.4,455.6 217.3,455.5 220.6,455.5 C 223.9,455.5 222.2,455.5 223.0,455.4 C 223.9,455.4 224.3,456.1 225.5,455.4 C 226.7,454.7 229.2,454.1 230.4,451.2 C 231.7,448.4 232.1,440.7 232.9,438.3 C 233.7,435.9 234.5,437.2 235.3,436.8 C 236.2,436.5 237.0,436.2 237.8,436.0 C 238.6,435.7 239.5,435.5 240.3,435.4 C 241.1,435.2 241.9,435.2 242.7,435.0 C 243.6,434.9 244.4,434.8 245.2,434.7 C 246.0,434.6 246.8,434.7 247.7,434.7 C 248.5,434.6 249.3,434.4 250.1,434.3 C 250.9,434.2 251.8,434.2 252.6,434.1 C 253.4,434.0 254.2,433.6 255.1,433.5 C 255.9,433.4 256.7,433.4 257.5,433.3 C 258.3,433.3 258.8,433.1 260.0,433.0 C 261.2,432.9 263.7,432.9 264.9,432.8 C 266.1,432.7 266.6,432.7 267.4,432.6 C 268.2,432.6 269.0,432.6 269.8,432.5 C 270.7,432.4 271.5,432.1 272.3,432.0 C 273.1,431.8 273.9,432.5 274.8,431.6 C 275.6,430.7 276.4,427.5 277.2,426.5 C 278.0,425.4 278.9,425.5 279.7,425.4 C 280.5,425.2 281.3,425.4 282.2,425.3 C 283.0,425.2 283.8,424.8 284.6,424.5 C 285.4,424.2 286.3,424.1 287.1,423.6 C 287.9,423.0 288.7,422.1 289.5,421.5 C 290.4,420.9 291.2,420.3 292.0,419.9 C 292.8,419.4 293.6,419.1 294.5,418.8 C 295.3,418.6 296.1,418.5 296.9,418.4 C 297.8,418.2 298.6,418.2 299.4,418.1 C 300.2,418.0 301.0,417.8 301.9,417.7 C 302.7,417.6 303.5,417.6 304.3,417.6 C 305.1,417.5 306.0,417.5 306.8,417.4 C 307.6,417.2 308.4,417.3 309.3,416.9 C 310.1,416.5 310.9,415.4 311.7,414.9 C 312.5,414.4 313.4,414.2 314.2,413.9 C 315.0,413.5 315.8,414.2 316.6,412.7 C 317.5,411.1 318.3,406.3 319.1,404.5 C 319.9,402.6 320.7,402.3 321.6,401.6 C 322.4,400.8 323.2,400.3 324.0,399.8 C 324.9,399.2 325.7,398.7 326.5,398.3 C 327.3,397.9 328.1,397.5 329.0,397.2 C 329.8,397.0 330.6,396.9 331.4,396.7 C 332.2,396.5 333.1,396.2 333.9,395.9 C 334.7,395.6 335.5,395.1 336.4,394.9 C 337.2,394.6 338.0,394.6 338.8,394.4 C 339.6,394.1 340.5,393.7 341.3,393.3 C 342.1,393.0 342.9,392.8 343.7,392.5 C 344.6,392.3 345.4,392.1 346.2,391.9 C 347.0,391.8 347.8,391.7 348.7,391.6 C 349.5,391.5 350.3,391.3 351.1,391.1 C 352.0,390.9 352.8,390.8 353.6,390.6 C 354.4,390.3 355.2,390.2 356.1,389.8 C 356.9,389.5 357.7,388.9 358.5,388.6 C 359.3,388.4 360.2,388.3 361.0,388.1 C 361.8,387.9 362.6,387.8 363.4,387.7 C 364.3,387.6 365.1,387.5 365.9,387.4 C 366.7,387.3 367.6,387.1 368.4,387.1 C 369.2,387.0 370.0,387.0 370.8,386.9 C 371.7,386.8 372.5,386.6 373.3,386.3 C 374.1,386.0 374.9,385.5 375.8,385.2 C 376.6,384.9 377.4,384.5 378.2,384.3 C 379.1,384.0 379.9,384.0 380.7,383.9 C 381.5,383.7 382.3,383.7 383.2,383.4 C 384.0,383.2 384.8,382.7 385.6,382.5 C 386.4,382.3 387.3,382.3 388.1,382.2 C 388.9,382.1 389.7,381.8 390.5,381.6 C 391.4,381.5 392.2,381.3 393.0,381.1 C 393.8,380.9 394.7,380.7 395.5,380.5 C 396.3,380.3 397.1,380.2 397.9,380.0 C 398.8,379.8 399.6,379.4 400.4,379.2 C 401.2,378.9 402.0,378.8 402.9,378.6 C 403.7,378.3 404.5,378.0 405.3,377.8 C 406.1,377.5 407.0,377.2 407.8,376.9 C 408.6,376.7 409.4,376.6 410.3,376.2 C 411.1,375.8 411.9,374.9 412.7,374.5 C 413.5,374.2 414.4,374.1 415.2,374.0 C 416.0,373.9 416.8,373.9 417.6,373.8 C 418.5,373.7 419.3,373.5 420.1,373.4 C 420.9,373.3 421.8,373.5 422.6,373.1 C 423.4,372.7 424.2,371.7 425.0,371.2 C 425.9,370.8 426.7,370.9 427.5,370.6 C 428.3,370.4 429.1,369.9 430.0,369.7 C 430.8,369.5 431.6,369.6 432.4,369.4 C 433.2,369.2 434.1,368.7 434.9,368.5 C 435.7,368.3 436.5,368.3 437.4,368.2 C 438.2,368.1 439.0,368.1 439.8,367.9 C 440.6,367.7 441.5,367.4 442.3,366.9 C 443.1,366.4 443.9,365.4 444.7,364.8 C 445.6,364.3 446.4,364.1 447.2,363.7 C 448.0,363.3 448.9,363.2 449.7,362.5 C 450.5,361.8 451.3,360.1 452.1,359.3 C 453.0,358.5 453.8,358.1 454.6,357.6 C 455.4,357.1 456.2,357.1 457.1,356.4 C 457.9,355.6 458.7,354.0 459.5,353.3 C 460.3,352.6 461.2,352.5 462.0,352.0 C 462.8,351.6 463.6,351.3 464.5,350.8 C 465.3,3Line truncated
|
||||
<path d="M 90.0,462.2 C 90.4,462.1 92.1,461.8 92.5,461.7 C 93.3,461.6 94.1,461.4 94.9,461.2 C 95.7,461.1 96.6,460.8 97.4,460.8 C 98.2,460.7 99.0,460.7 99.9,460.7 C 100.7,460.7 101.5,460.6 102.3,460.6 C 103.1,460.5 104.0,460.5 104.8,460.4 C 105.6,460.4 106.0,460.3 107.2,460.3 C 108.5,460.2 110.9,460.1 112.2,460.1 C 113.4,460.1 113.8,460.0 114.6,460.0 C 115.5,460.0 116.3,460.0 117.1,459.9 C 117.9,459.9 118.7,459.9 119.6,459.9 C 120.4,459.9 121.2,459.9 122.0,459.8 C 122.8,459.8 123.7,459.7 124.5,459.7 C 125.3,459.6 126.1,459.6 127.0,459.4 C 127.8,459.3 128.6,459.0 129.4,458.7 C 130.2,458.3 131.1,457.6 131.9,457.3 C 132.7,457.1 133.5,457.2 134.3,457.1 C 135.2,457.1 136.0,457.1 136.8,457.0 C 137.6,457.0 138.0,457.0 139.3,456.9 C 140.5,456.9 142.6,456.9 144.2,456.9 C 145.8,456.8 147.9,456.8 149.1,456.8 C 150.4,456.7 150.8,456.7 151.6,456.7 C 152.4,456.6 153.2,456.6 154.1,456.5 C 154.9,456.5 155.7,456.5 156.5,456.4 C 157.3,456.4 158.2,456.4 159.0,456.4 C 159.8,456.4 160.2,456.4 161.4,456.3 C 162.7,456.3 164.3,456.2 166.4,456.2 C 168.4,456.2 171.7,456.2 173.8,456.2 C 175.8,456.1 176.2,456.2 178.7,456.1 C 181.1,456.1 186.5,455.9 188.5,455.9 C 190.6,455.8 189.8,455.9 191.0,455.8 C 192.2,455.8 193.9,455.8 195.9,455.8 C 198.0,455.8 199.2,455.7 203.3,455.7 C 207.4,455.6 217.3,455.5 220.6,455.5 C 223.9,455.5 222.2,455.5 223.0,455.4 C 223.9,455.4 224.3,456.1 225.5,455.4 C 226.7,454.7 229.2,454.1 230.4,451.2 C 231.7,448.4 232.1,440.7 232.9,438.3 C 233.7,435.9 234.5,437.2 235.3,436.8 C 236.2,436.5 237.0,436.2 237.8,436.0 C 238.6,435.7 239.5,435.5 240.3,435.4 C 241.1,435.2 241.9,435.2 242.7,435.0 C 243.6,434.9 244.4,434.8 245.2,434.7 C 246.0,434.6 246.8,434.7 247.7,434.7 C 248.5,434.6 249.3,434.4 250.1,434.3 C 250.9,434.2 251.8,434.2 252.6,434.1 C 253.4,434.0 254.2,433.6 255.1,433.5 C 255.9,433.4 256.7,433.4 257.5,433.3 C 258.3,433.3 258.8,433.1 260.0,433.0 C 261.2,432.9 263.7,432.9 264.9,432.8 C 266.1,432.7 266.6,432.7 267.4,432.6 C 268.2,432.6 269.0,432.6 269.8,432.5 C 270.7,432.4 271.5,432.1 272.3,432.0 C 273.1,431.8 273.9,432.5 274.8,431.6 C 275.6,430.7 276.4,427.5 277.2,426.5 C 278.0,425.4 278.9,425.5 279.7,425.4 C 280.5,425.2 281.3,425.4 282.2,425.3 C 283.0,425.2 283.8,424.8 284.6,424.5 C 285.4,424.2 286.3,424.1 287.1,423.6 C 287.9,423.0 288.7,422.1 289.5,421.5 C 290.4,420.9 291.2,420.3 292.0,419.9 C 292.8,419.4 293.6,419.1 294.5,418.8 C 295.3,418.6 296.1,418.5 296.9,418.4 C 297.8,418.2 298.6,418.2 299.4,418.1 C 300.2,418.0 301.0,417.8 301.9,417.7 C 302.7,417.6 303.5,417.6 304.3,417.6 C 305.1,417.5 306.0,417.5 306.8,417.4 C 307.6,417.2 308.4,417.3 309.3,416.9 C 310.1,416.5 310.9,415.4 311.7,414.9 C 312.5,414.4 313.4,414.2 314.2,413.9 C 315.0,413.5 315.8,414.2 316.6,412.7 C 317.5,411.1 318.3,406.3 319.1,404.5 C 319.9,402.6 320.7,402.3 321.6,401.6 C 322.4,400.8 323.2,400.3 324.0,399.8 C 324.9,399.2 325.7,398.7 326.5,398.3 C 327.3,397.9 328.1,397.5 329.0,397.2 C 329.8,397.0 330.6,396.9 331.4,396.7 C 332.2,396.5 333.1,396.2 333.9,395.9 C 334.7,395.6 335.5,395.1 336.4,394.9 C 337.2,394.6 338.0,394.6 338.8,394.4 C 339.6,394.1 340.5,393.7 341.3,393.3 C 342.1,393.0 342.9,392.8 343.7,392.5 C 344.6,392.3 345.4,392.1 346.2,391.9 C 347.0,391.8 347.8,391.7 348.7,391.6 C 349.5,391.5 350.3,391.3 351.1,391.1 C 352.0,390.9 352.8,390.8 353.6,390.6 C 354.4,390.3 355.2,390.2 356.1,389.8 C 356.9,389.5 357.7,388.9 358.5,388.6 C 359.3,388.4 360.2,388.3 361.0,388.1 C 361.8,387.9 362.6,387.8 363.4,387.7 C 364.3,387.6 365.1,387.5 365.9,387.4 C 366.7,387.3 367.6,387.1 368.4,387.1 C 369.2,387.0 370.0,387.0 370.8,386.9 C 371.7,386.8 372.5,386.6 373.3,386.3 C 374.1,386.0 374.9,385.5 375.8,385.2 C 376.6,384.9 377.4,384.5 378.2,384.3 C 379.1,384.0 379.9,384.0 380.7,383.9 C 381.5,383.7 382.3,383.7 383.2,383.4 C 384.0,383.2 384.8,382.7 385.6,382.5 C 386.4,382.3 387.3,382.3 388.1,382.2 C 388.9,382.1 389.7,381.8 390.5,381.6 C 391.4,381.5 392.2,381.3 393.0,381.1 C 393.8,380.9 394.7,380.7 395.5,380.5 C 396.3,380.3 397.1,380.2 397.9,380.0 C 398.8,379.8 399.6,379.4 400.4,379.2 C 401.2,378.9 402.0,378.8 402.9,378.6 C 403.7,378.3 404.5,378.0 405.3,377.8 C 406.1,377.5 407.0,377.2 407.8,376.9 C 408.6,376.7 409.4,376.6 410.3,376.2 C 411.1,375.8 411.9,374.9 412.7,374.5 C 413.5,374.2 414.4,374.1 415.2,374.0 C 416.0,373.9 416.8,373.9 417.6,373.8 C 418.5,373.7 419.3,373.5 420.1,373.4 C 420.9,373.3 421.8,373.5 422.6,373.1 C 423.4,372.7 424.2,371.7 425.0,371.2 C 425.9,370.8 426.7,370.9 427.5,370.6 C 428.3,370.4 429.1,369.9 430.0,369.7 C 430.8,369.5 431.6,369.6 432.4,369.4 C 433.2,369.2 434.1,368.7 434.9,368.5 C 435.7,368.3 436.5,368.3 437.4,368.2 C 438.2,368.1 439.0,368.1 439.8,367.9 C 440.6,367.7 441.5,367.4 442.3,366.9 C 443.1,366.4 443.9,365.4 444.7,364.8 C 445.6,364.3 446.4,364.1 447.2,363.7 C 448.0,363.3 448.9,363.2 449.7,362.5 C 450.5,361.8 451.3,360.1 452.1,359.3 C 453.0,358.5 453.8,358.1 454.6,357.6 C 455.4,357.1 456.2,357.1 457.1,356.4 C 457.9,355.6 458.7,354.0 459.5,353.3 C 460.3,352.6 461.2,352.5 462.0,352.0 C 462.8,351.6 463.6,351.3 464.5,350.8 C 465.3,3Line truncated
|
||||
<circle cx="765.0" cy="113.6" r="5" fill="#dd4528" stroke="#fff" stroke-width="2" filter="url(#xkcdify)"/>
|
||||
<text x="753.0" y="77.0" fill="#dd4528" font-size="18" font-family="Handlee, cursive" font-weight="bold" text-anchor="end">6385</text>
|
||||
<a href="https://github.com/carsteneu/mystarhistory" target="_blank" rel="noopener"><text x="763" y="527" font-size="13" font-family="Handlee, cursive" fill="#000" text-anchor="end">Made with carsteneu/mystarhistory</text></a>
|
||||
</svg>
|
||||
|
Before Width: | Height: | Size: 34 KiB After Width: | Height: | Size: 35 KiB |
@@ -2,7 +2,7 @@ import test from "node:test";
|
||||
import assert from "node:assert/strict";
|
||||
|
||||
import { buildAITerminalSessionInfo } from "./buildAITerminalSessionInfo.ts";
|
||||
import type { Host, TerminalSession } from "../../types";
|
||||
import type { Host, TerminalSession } from "../types";
|
||||
|
||||
const baseHost = (overrides: Partial<Host> = {}): Host =>
|
||||
({
|
||||
@@ -93,3 +93,23 @@ test("prefers a session custom name over the shared host label", () => {
|
||||
);
|
||||
assert.equal(info.label, "Bastion - production");
|
||||
});
|
||||
|
||||
test("only saved remote hosts can receive persistent server approvals", () => {
|
||||
const saved = baseHost();
|
||||
assert.equal(buildAITerminalSessionInfo(baseSession(), saved, "linux", {
|
||||
allHosts: [saved],
|
||||
}).savedHostId, "h1");
|
||||
assert.equal(buildAITerminalSessionInfo(baseSession(), saved, "linux", {
|
||||
allHosts: [],
|
||||
}).savedHostId, undefined);
|
||||
const temporary = baseHost({ ephemeral: true });
|
||||
assert.equal(buildAITerminalSessionInfo(baseSession(), temporary, "linux", {
|
||||
allHosts: [temporary],
|
||||
}).savedHostId, undefined);
|
||||
assert.equal(buildAITerminalSessionInfo(baseSession({ protocol: "local" }), saved, "linux", {
|
||||
allHosts: [saved],
|
||||
}).savedHostId, undefined);
|
||||
assert.equal(buildAITerminalSessionInfo(baseSession({ protocol: "serial" }), saved, "linux", {
|
||||
allHosts: [saved],
|
||||
}).savedHostId, undefined);
|
||||
});
|
||||
@@ -1,10 +1,12 @@
|
||||
import { classifyDistroId, resolveHostOs } from './host';
|
||||
import { isSavedVaultHost } from './ephemeralHosts';
|
||||
import type { PortForwardingRule } from './models';
|
||||
import type { Host, TerminalSession } from '../types';
|
||||
|
||||
export type AITerminalSessionInfo = {
|
||||
sessionId: string;
|
||||
hostId: string;
|
||||
savedHostId?: string;
|
||||
hostname: string;
|
||||
label: string;
|
||||
os?: string;
|
||||
@@ -50,6 +52,9 @@ export const buildAITerminalSessionInfo = (
|
||||
const protocol = session?.protocol || host?.protocol;
|
||||
const isLocalSession = protocol === 'local' || session?.hostId?.startsWith('local-');
|
||||
const allHosts = options?.allHosts ?? (host ? [host] : []);
|
||||
const savedHostId = !isLocalSession && protocol !== 'serial'
|
||||
? allHosts.find((entry) => entry.id === session?.hostId && isSavedVaultHost(entry))?.id
|
||||
: undefined;
|
||||
const hostChain = summarizeHostChain(host, allHosts);
|
||||
const activePortForwards = host?.id && options?.portForwardingRules
|
||||
? options.portForwardingRules
|
||||
@@ -81,6 +86,7 @@ export const buildAITerminalSessionInfo = (
|
||||
return {
|
||||
sessionId: session?.id || '',
|
||||
hostId: session?.hostId || '',
|
||||
...(savedHostId ? { savedHostId } : {}),
|
||||
hostname: host?.hostname || session?.hostname || '',
|
||||
// A session rename is scoped to the individual terminal tab. Prefer it
|
||||
// over the saved Host label so duplicate connections to the same host can
|
||||
|
||||
@@ -14,6 +14,16 @@ const host = (overrides: Partial<Host> = {}): Host => ({
|
||||
...overrides,
|
||||
});
|
||||
|
||||
test("applyGroupDefaults ignores a legacy single-channel group flag", () => {
|
||||
const result = applyGroupDefaults(
|
||||
host(),
|
||||
{ deviceType: "network", singleChannelSsh: true } as Partial<GroupConfig>,
|
||||
);
|
||||
|
||||
assert.equal(result.deviceType, "network");
|
||||
assert.equal("singleChannelSsh" in result, false);
|
||||
});
|
||||
|
||||
test("applyGroupDefaults lets a host proxy profile override a group custom proxy", () => {
|
||||
const groupDefaults: Partial<GroupConfig> = {
|
||||
proxyConfig: { type: "http", host: "group-proxy.example.com", port: 3128 },
|
||||
@@ -513,6 +523,19 @@ test("sanitizeGroupConfig keeps a still-valid fontFamily untouched", () => {
|
||||
assert.equal(after.fontFamilyOverride, true);
|
||||
});
|
||||
|
||||
test("sanitizeGroupConfig drops a legacy single-channel SSH flag", () => {
|
||||
const source = {
|
||||
path: "team",
|
||||
username: "alice",
|
||||
singleChannelSsh: true,
|
||||
} as Parameters<typeof sanitizeGroupConfig>[0];
|
||||
const after = sanitizeGroupConfig(source);
|
||||
|
||||
assert.equal("singleChannelSsh" in after, false);
|
||||
assert.equal(after.username, "alice");
|
||||
assert.equal("singleChannelSsh" in source, true);
|
||||
});
|
||||
|
||||
test("sanitizeGroupConfig preserves legacy group passwords as password-only", () => {
|
||||
const after = sanitizeGroupConfig({
|
||||
path: "team",
|
||||
|
||||
@@ -15,9 +15,13 @@ export function sanitizeGroupConfig(config: GroupConfig): GroupConfig {
|
||||
&& !migrated.identityFileId
|
||||
&& !migrated.identityFilePaths?.length;
|
||||
|
||||
return hasLegacyPasswordOnlyCredentials
|
||||
? { ...migrated, authMethod: 'password' }
|
||||
const next = hasLegacyPasswordOnlyCredentials
|
||||
? { ...migrated, authMethod: 'password' as const }
|
||||
: migrated;
|
||||
if (!Object.prototype.hasOwnProperty.call(next, 'singleChannelSsh')) return next;
|
||||
const cleaned = { ...next };
|
||||
delete (cleaned as { singleChannelSsh?: unknown }).singleChannelSsh;
|
||||
return cleaned;
|
||||
}
|
||||
|
||||
export interface ApplyGroupDefaultsOptions {
|
||||
@@ -145,7 +149,7 @@ export function resolveGroupDefaults(
|
||||
|
||||
const INHERITABLE_KEYS: (keyof GroupConfig)[] = [
|
||||
'username', 'password', 'savePassword', 'authMethod', 'identityId', 'identityFileId', 'identityFilePaths',
|
||||
'port', 'protocol', 'deviceType', 'singleChannelSsh', 'agentForwarding', 'proxyProfileId', 'proxyConfig', 'hostChain', 'startupCommand', 'startupCommandRunMode',
|
||||
'port', 'protocol', 'deviceType', 'agentForwarding', 'proxyProfileId', 'proxyConfig', 'hostChain', 'startupCommand', 'startupCommandRunMode',
|
||||
'legacyAlgorithms', 'skipEcdsaHostKey', 'algorithms',
|
||||
'environmentVariables', 'charset', 'moshEnabled', 'moshServerPath',
|
||||
'etEnabled', 'etPort',
|
||||
|
||||
+13
-3
@@ -305,6 +305,16 @@ test("sanitizeHost removes hidden built-in credentials and transport state from
|
||||
}
|
||||
});
|
||||
|
||||
test("sanitizeHost drops a legacy single-channel SSH flag", () => {
|
||||
const sanitized = sanitizeHost({
|
||||
...makeHost(),
|
||||
singleChannelSsh: true,
|
||||
} as Host);
|
||||
|
||||
assert.equal("singleChannelSsh" in sanitized, false);
|
||||
assert.equal(sanitized.hostname, "127.0.0.1");
|
||||
});
|
||||
|
||||
test("sanitizeHost keeps legacy empty-password hosts on automatic authentication", () => {
|
||||
const sanitized = sanitizeHost(makeHost({
|
||||
password: undefined,
|
||||
@@ -645,10 +655,10 @@ test("shouldProbeSessionCwd skips the probe when the SSH banner reveals a networ
|
||||
);
|
||||
});
|
||||
|
||||
test("hostRestrictsExtraSshChannels follows the host toggle and network-device mode", () => {
|
||||
assert.equal(hostRestrictsExtraSshChannels({ singleChannelSsh: true }), true);
|
||||
test("hostRestrictsExtraSshChannels follows network-device mode only", () => {
|
||||
assert.equal(hostRestrictsExtraSshChannels({ deviceType: "network" }), true);
|
||||
assert.equal(hostRestrictsExtraSshChannels({ singleChannelSsh: false, deviceType: "general" }), false);
|
||||
assert.equal(hostRestrictsExtraSshChannels({ deviceType: "general" }), false);
|
||||
assert.equal(hostRestrictsExtraSshChannels({ singleChannelSsh: true } as never), false);
|
||||
assert.equal(hostRestrictsExtraSshChannels(undefined), false);
|
||||
});
|
||||
|
||||
|
||||
+10
-10
@@ -1,4 +1,4 @@
|
||||
import { remoteSoftwareRequiresSingleChannel } from './singleChannelSshBanner.shared.cjs';
|
||||
import { remoteSoftwareRequiresSingleChannel } from './singleChannelSshBanner.shared.mjs';
|
||||
import { Host, Snippet, TerminalSettings } from './models';
|
||||
import type { HostOperatingSystem, HostOsSelection } from './models/connection';
|
||||
import { sanitizeHostIconFields } from './hostIcon';
|
||||
@@ -271,23 +271,22 @@ export const shouldSuggestNetworkDeviceMode = (opts: {
|
||||
};
|
||||
|
||||
/**
|
||||
* True when an extra exec or SFTP channel on the terminal transport is unsafe.
|
||||
* Use this to skip probes. Do not store it as session.singleChannelSsh:
|
||||
* network devices already have their own restrictions, and that flag means
|
||||
* the user opted into a one-channel bastion.
|
||||
* True when an extra exec channel on the terminal transport is unsafe because
|
||||
* the host is a network device. Use this to skip probes. Do not store it as
|
||||
* session.singleChannelSsh: that runtime flag is stamped only after a
|
||||
* recognized one-channel bastion banner.
|
||||
*/
|
||||
export const hostRestrictsExtraSshChannels = (
|
||||
host?: Pick<Host, 'singleChannelSsh' | 'deviceType'> | null,
|
||||
): boolean => host?.singleChannelSsh === true || host?.deviceType === 'network';
|
||||
host?: Pick<Host, 'deviceType'> | null,
|
||||
): boolean => host?.deviceType === 'network';
|
||||
|
||||
/**
|
||||
* Decide whether it is safe to run the post-connect `pwd` probe that
|
||||
* discovers the session's working directory. The probe opens an extra exec
|
||||
* channel running a POSIX-shell script; strict network-device CLIs such as
|
||||
* Huawei VRP respond by closing the whole SSH session (#1043), so it must be
|
||||
* skipped for them. Hosts with `singleChannelSsh` (bastion / PAM), and
|
||||
* software banners that allow only one session channel per TCP connection,
|
||||
* have the same constraint.
|
||||
* skipped for them. Software banners that allow only one session channel per
|
||||
* TCP connection have the same constraint.
|
||||
*
|
||||
* `isNetworkDevice` covers hosts we already classified (a reconnect, or an
|
||||
* explicit `deviceType: 'network'`). On a brand-new host that field is not
|
||||
@@ -532,5 +531,6 @@ export const sanitizeHost = (host: Host, snippets: Snippet[] = []): Host => {
|
||||
connectScriptIds: connectScriptIds && connectScriptIds.length > 0 ? connectScriptIds : undefined,
|
||||
pluginConnection,
|
||||
};
|
||||
delete (sanitized as { singleChannelSsh?: unknown }).singleChannelSsh;
|
||||
return stripBuiltInConnectionFieldsForPluginHost(sanitized);
|
||||
};
|
||||
@@ -177,9 +177,6 @@ export interface Host {
|
||||
// Device type: 'general' for standard servers, 'network' for switches/routers/firewalls.
|
||||
// Network devices use raw command execution (no shell wrapping) for AI agent compatibility.
|
||||
deviceType?: 'general' | 'network';
|
||||
// Bastion / PAM hosts that allow only one SSH session channel.
|
||||
// Extra exec/SFTP on the terminal transport disconnects the shell.
|
||||
singleChannelSsh?: boolean;
|
||||
identityFileId?: string; // Reference to SSHKey
|
||||
protocol?: HostProtocol; // Default/primary protocol, including namespaced plugin protocols
|
||||
pluginConnection?: PluginConnectionConfig;
|
||||
@@ -428,7 +425,6 @@ export interface GroupConfig {
|
||||
port?: number;
|
||||
protocol?: 'ssh' | 'telnet';
|
||||
deviceType?: 'general' | 'network';
|
||||
singleChannelSsh?: boolean;
|
||||
agentForwarding?: boolean;
|
||||
proxyProfileId?: string;
|
||||
proxyConfig?: ProxyConfig;
|
||||
|
||||
@@ -86,6 +86,8 @@ export interface TransferTask {
|
||||
childTasks?: string[]; // For directory transfers
|
||||
parentTaskId?: string;
|
||||
sourceLastModified?: number; // Cached from file list to avoid redundant stat
|
||||
/** Bounded remote preflight already skipped this file; keep the cap after panel remount. */
|
||||
preflightStatSkipped?: boolean;
|
||||
skipConflictCheck?: boolean; // Skip conflict check for replace operations
|
||||
replaceExistingTarget?: boolean; // Delete the existing target before transferring
|
||||
retryable?: boolean; // False for task types that cannot be safely replayed through generic retry
|
||||
|
||||
@@ -152,7 +152,6 @@ export function stripBuiltInConnectionFieldsForPluginHost(host: Host): Host {
|
||||
telnetPassword: _telnetPassword,
|
||||
serialConfig: _serialConfig,
|
||||
sftpSudo: _sftpSudo,
|
||||
singleChannelSsh: _singleChannelSsh,
|
||||
legacyAlgorithms: _legacyAlgorithms,
|
||||
skipEcdsaHostKey: _skipEcdsaHostKey,
|
||||
algorithms: _algorithms,
|
||||
@@ -163,5 +162,6 @@ export function stripBuiltInConnectionFieldsForPluginHost(host: Host): Host {
|
||||
sshAuthReadyTimeoutSeconds: _sshAuthReadyTimeoutSeconds,
|
||||
...pluginHost
|
||||
} = host;
|
||||
delete (pluginHost as { singleChannelSsh?: unknown }).singleChannelSsh;
|
||||
return pluginHost;
|
||||
}
|
||||
@@ -208,7 +208,6 @@ const normalizeHost = (value: JsonValue): Host | null => {
|
||||
etEnabled: optionalBooleanValue(object, 'etEnabled'),
|
||||
telnetEnabled: optionalBooleanValue(object, 'telnetEnabled'),
|
||||
sftpSudo: optionalBooleanValue(object, 'sftpSudo'),
|
||||
singleChannelSsh: optionalBooleanValue(object, 'singleChannelSsh'),
|
||||
requiresMfa: optionalBooleanValue(object, 'requiresMfa'),
|
||||
useSshAgent: optionalBooleanValue(object, 'useSshAgent'),
|
||||
identitiesOnly: optionalBooleanValue(object, 'identitiesOnly'),
|
||||
|
||||
@@ -961,6 +961,13 @@ export const SETTINGS_SEARCH_CATALOG: readonly SettingsSearchEntry[] = [
|
||||
descriptionKey: "settings.globalHotkey.closeToTrayDesc",
|
||||
sectionKey: "settings.globalHotkey.title",
|
||||
},
|
||||
{
|
||||
id: "system-show-tray-icon",
|
||||
tab: "system",
|
||||
labelKey: "settings.globalHotkey.showTrayIcon",
|
||||
descriptionKey: "settings.globalHotkey.showTrayIconDesc",
|
||||
sectionKey: "settings.globalHotkey.title",
|
||||
},
|
||||
|
||||
// Plugins
|
||||
{
|
||||
|
||||
@@ -156,6 +156,13 @@ test("restoring terminal tasks strips stale conflict payloads (skip-without-clea
|
||||
assert.equal(restored.tasks[1]?.conflict?.fileName, "css");
|
||||
});
|
||||
|
||||
test("a preflight-skipped conflict keeps its stat limit after restart", () => {
|
||||
const pending = { ...task("late-file", "attention", 1), preflightStatSkipped: true };
|
||||
const restored = deserializeSftpTransferCenter(serializeSftpTransferCenter([pending]));
|
||||
assert.equal(restored.tasks[0]?.status, "attention");
|
||||
assert.equal(restored.tasks[0]?.preflightStatSkipped, true);
|
||||
});
|
||||
|
||||
test("history keeps unfinished tasks and caps terminal tasks by age and count", () => {
|
||||
const now = Date.UTC(2026, 6, 23);
|
||||
const old = now - 31 * 24 * 60 * 60 * 1000;
|
||||
|
||||
@@ -34,7 +34,7 @@ const SAFE_TASK_KEYS: ReadonlySet<keyof TransferTask> = new Set([
|
||||
"sourceConnectionId", "targetConnectionId", "targetHostId", "targetConnectionKey",
|
||||
"direction", "status", "totalBytes", "transferredBytes", "speed", "error",
|
||||
"startTime", "endTime", "isDirectory", "progressMode", "childTasks", "parentTaskId",
|
||||
"sourceLastModified", "skipConflictCheck", "replaceExistingTarget", "retryable",
|
||||
"sourceLastModified", "preflightStatSkipped", "skipConflictCheck", "replaceExistingTarget", "retryable",
|
||||
"ownerId", "sourceHostId", "sourceHostLabel", "targetHostLabel", "origin", "background",
|
||||
"phase", "controlKind", "resumable", "checkpointBytes", "priority", "updatedAt", "pauseUnavailableReason",
|
||||
"resumeStage", "downloadCheckpointBytes", "uploadCheckpointBytes",
|
||||
@@ -58,6 +58,7 @@ export function sanitizeSftpTransferTask(value: unknown): TransferTask | null {
|
||||
if (source[key] !== undefined) sanitized[key] = source[key];
|
||||
}
|
||||
const task = sanitized as unknown as TransferTask;
|
||||
if (task.preflightStatSkipped !== true) task.preflightStatSkipped = undefined;
|
||||
if (!Number.isSafeInteger(task.directoryEntryIndex) || (task.directoryEntryIndex ?? -1) < 0) {
|
||||
task.directoryEntryIndex = undefined;
|
||||
}
|
||||
|
||||
@@ -0,0 +1,7 @@
|
||||
export const SINGLE_CHANNEL_SOFTWARE_TOKENS: readonly string[];
|
||||
export function remoteSoftwareRequiresSingleChannel(remoteSshVersion?: string): boolean;
|
||||
export function remoteDisallowsChunkedChannelWrite(remoteSshVersion?: string): boolean;
|
||||
export function optionsForPeerSingleChannel<T extends { singleChannelSsh?: boolean }>(
|
||||
options: T,
|
||||
conn?: { _remoteVer?: string } | null,
|
||||
): T;
|
||||
@@ -0,0 +1,70 @@
|
||||
"use strict";
|
||||
|
||||
// SSH software tokens that allow one session channel per TCP connection.
|
||||
// Match the identification software token only. Do not match product names,
|
||||
// OpenSSH, JumpServer/KoKo, or a "term-sshd" substring inside another token.
|
||||
//
|
||||
// BHostSSH_7.0 — issue #3146 log field remoteSshVersion. PR #3313: a second
|
||||
// exec channel tears down the first interactive session. Multi-character
|
||||
// channel writes are also dropped on this banner.
|
||||
// TERM-SSHD — issue #2923 comment 5314292157 logs remoteSshVersion
|
||||
// "TERM-SSHD". PR #3030: a later session channel on that TCP is accepted
|
||||
// and the shell exits 0.
|
||||
// CLOUDBILITY-4.14 — PR #3458 reproduction note: SSH-2.0-CLOUDBILITY-4.14
|
||||
// drops the interactive shell when another session channel opens. No other
|
||||
// public packet was found; do not add aliases.
|
||||
// JumpServer KoKo advertises Version "JumpServer" and multiplexes. Omit it.
|
||||
|
||||
const SINGLE_CHANNEL_SOFTWARE_TOKENS = Object.freeze([
|
||||
"BHostSSH",
|
||||
"TERM-SSHD",
|
||||
"CLOUDBILITY",
|
||||
]);
|
||||
|
||||
// #3146 also drops multi-character writes. That evidence is only for these
|
||||
// two tokens, not for CLOUDBILITY.
|
||||
const CHUNKED_WRITE_SOFTWARE_TOKENS = Object.freeze([
|
||||
"BHostSSH",
|
||||
"TERM-SSHD",
|
||||
]);
|
||||
|
||||
function escapeRegExp(value) {
|
||||
return String(value).replace(/[.*+?^${}()|[\]\\]/g, "\\$&");
|
||||
}
|
||||
|
||||
function softwareTokenMatches(remoteSshVersion, token) {
|
||||
const software = String(remoteSshVersion || "").trim();
|
||||
if (!software) return false;
|
||||
const pattern = new RegExp(
|
||||
`(?:^|[^A-Za-z0-9])${escapeRegExp(token)}(?:[^A-Za-z0-9]|$)`,
|
||||
"i",
|
||||
);
|
||||
return pattern.test(software);
|
||||
}
|
||||
|
||||
function remoteSoftwareMatchesAny(remoteSshVersion, tokens) {
|
||||
return tokens.some((token) => softwareTokenMatches(remoteSshVersion, token));
|
||||
}
|
||||
|
||||
function remoteSoftwareRequiresSingleChannel(remoteSshVersion) {
|
||||
return remoteSoftwareMatchesAny(remoteSshVersion, SINGLE_CHANNEL_SOFTWARE_TOKENS);
|
||||
}
|
||||
|
||||
function remoteDisallowsChunkedChannelWrite(remoteSshVersion) {
|
||||
return remoteSoftwareMatchesAny(remoteSshVersion, CHUNKED_WRITE_SOFTWARE_TOKENS);
|
||||
}
|
||||
|
||||
function optionsForPeerSingleChannel(options, conn) {
|
||||
const remoteSshVersion = conn && typeof conn._remoteVer === "string" ? conn._remoteVer : "";
|
||||
if (!options || options.singleChannelSsh === true || !remoteSoftwareRequiresSingleChannel(remoteSshVersion)) {
|
||||
return options;
|
||||
}
|
||||
return { ...options, singleChannelSsh: true };
|
||||
}
|
||||
|
||||
export {
|
||||
SINGLE_CHANNEL_SOFTWARE_TOKENS,
|
||||
remoteSoftwareRequiresSingleChannel,
|
||||
remoteDisallowsChunkedChannelWrite,
|
||||
optionsForPeerSingleChannel,
|
||||
};
|
||||
@@ -69,3 +69,18 @@ test("optionsForPeerSingleChannel stamps the runtime flag from the peer banner",
|
||||
true,
|
||||
);
|
||||
});
|
||||
|
||||
test("CommonJS and renderer modules agree on SSH banner behavior", async () => {
|
||||
const browserModule = await import("./singleChannelSshBanner.shared.mjs");
|
||||
const nodeModule = require("./singleChannelSshBanner.shared.cjs");
|
||||
for (const version of [
|
||||
"BHostSSH_7.0", "TERM-SSHD", "CLOUDBILITY-4.14", "OpenSSH_9.6", "JumpServer", "",
|
||||
]) {
|
||||
assert.equal(browserModule.remoteSoftwareRequiresSingleChannel(version), nodeModule.remoteSoftwareRequiresSingleChannel(version));
|
||||
assert.equal(browserModule.remoteDisallowsChunkedChannelWrite(version), nodeModule.remoteDisallowsChunkedChannelWrite(version));
|
||||
assert.deepEqual(
|
||||
browserModule.optionsForPeerSingleChannel({ singleChannelSsh: false }, { _remoteVer: version }),
|
||||
nodeModule.optionsForPeerSingleChannel({ singleChannelSsh: false }, { _remoteVer: version }),
|
||||
);
|
||||
}
|
||||
});
|
||||
@@ -30,7 +30,7 @@ await nct.dialog.alert('Netcatty script smoke test OK');
|
||||
|
||||
/** Full integration test for onConnect / manual run; dialog API enabled by default. */
|
||||
export const SCRIPT_INTEGRATION_TEST = `// Netcatty Integration Test — onConnect / manual full API exercise
|
||||
// Trigger: onConnect or Run now | Permission: Auto or Confirm (dialogs need non-Observer)
|
||||
// Trigger: onConnect or Run now
|
||||
|
||||
const CONFIG = {
|
||||
SAMPLE_COUNT: 8,
|
||||
|
||||
@@ -107,7 +107,7 @@ test("network devices hide processes until OS probe confirms a real target", ()
|
||||
});
|
||||
|
||||
|
||||
test("system overview stats skip single-channel SSH hosts", () => {
|
||||
test("system overview stats ignore a legacy single-channel host field", () => {
|
||||
assert.equal(
|
||||
shouldCollectServerStats(
|
||||
{
|
||||
@@ -117,12 +117,13 @@ test("system overview stats skip single-channel SSH hosts", () => {
|
||||
username: "user",
|
||||
tags: [],
|
||||
os: "linux",
|
||||
distro: "ubuntu",
|
||||
singleChannelSsh: true,
|
||||
},
|
||||
} as never,
|
||||
undefined,
|
||||
null,
|
||||
),
|
||||
false,
|
||||
true,
|
||||
);
|
||||
});
|
||||
|
||||
|
||||
@@ -93,7 +93,6 @@ export function shouldCollectServerStats(
|
||||
_session: TerminalSession | null | undefined,
|
||||
): boolean {
|
||||
const detectedDeviceClass = classifyDistroId(host?.distro);
|
||||
if (host?.singleChannelSsh) return false;
|
||||
if (isNetworkDeviceTarget(host) || detectedDeviceClass === 'network-device') return false;
|
||||
if (capabilities?.targetOs && capabilities.targetOs !== 'unknown') {
|
||||
return capabilities.targetOs === 'linux' || capabilities.targetOs === 'darwin';
|
||||
|
||||
@@ -1,7 +1,10 @@
|
||||
import assert from 'node:assert/strict';
|
||||
import test from 'node:test';
|
||||
|
||||
import { resolveTerminalBroadcastTargetIds } from './terminalBroadcast.ts';
|
||||
import {
|
||||
resolveTerminalBroadcastTargetIds,
|
||||
shouldBroadcastDuringSensitivePrompt,
|
||||
} from './terminalBroadcast.ts';
|
||||
import type { TerminalSession } from './models.ts';
|
||||
|
||||
const session = (
|
||||
@@ -89,3 +92,27 @@ test('missing or hidden global sources do not fan out', () => {
|
||||
[],
|
||||
);
|
||||
});
|
||||
|
||||
test('sensitive prompts pause broadcasting unless the password bypass is on', () => {
|
||||
assert.equal(
|
||||
shouldBroadcastDuringSensitivePrompt({
|
||||
sensitivePromptActive: false,
|
||||
broadcastPasswordBypass: false,
|
||||
}),
|
||||
true,
|
||||
);
|
||||
assert.equal(
|
||||
shouldBroadcastDuringSensitivePrompt({
|
||||
sensitivePromptActive: true,
|
||||
broadcastPasswordBypass: false,
|
||||
}),
|
||||
false,
|
||||
);
|
||||
assert.equal(
|
||||
shouldBroadcastDuringSensitivePrompt({
|
||||
sensitivePromptActive: true,
|
||||
broadcastPasswordBypass: true,
|
||||
}),
|
||||
true,
|
||||
);
|
||||
});
|
||||
@@ -2,6 +2,19 @@ import type { TerminalSession } from './models';
|
||||
|
||||
type BroadcastSession = Pick<TerminalSession, 'id' | 'workspaceId' | 'hiddenFromTabs'>;
|
||||
|
||||
/**
|
||||
* The fail-closed password-prompt heuristic pauses broadcasting while an
|
||||
* untrusted prompt is on screen. With the opt-in bypass enabled (SecureCRT
|
||||
* "Send commands to all sessions" style), input keeps flowing to peer tabs
|
||||
* even during interactive / password prompts.
|
||||
*/
|
||||
export function shouldBroadcastDuringSensitivePrompt(input: {
|
||||
sensitivePromptActive: boolean;
|
||||
broadcastPasswordBypass: boolean;
|
||||
}): boolean {
|
||||
return !input.sensitivePromptActive || input.broadcastPasswordBypass;
|
||||
}
|
||||
|
||||
export function resolveTerminalBroadcastTargetIds({
|
||||
sessions,
|
||||
sourceSessionId,
|
||||
|
||||
@@ -71,6 +71,7 @@ module.exports = {
|
||||
// Main-process SSH, SFTP, and port-forward bridges share this matcher.
|
||||
// TypeScript under domain/ is excluded below, so the .cjs must be listed.
|
||||
'domain/singleChannelSshBanner.shared.cjs',
|
||||
'domain/singleChannelSshBanner.shared.mjs',
|
||||
'lib/**/*.cjs',
|
||||
'lib/**/*.json',
|
||||
'skills/**/*',
|
||||
|
||||
@@ -832,93 +832,98 @@ function getWindowsKnownCliPathDirs(env = process.env) {
|
||||
return dirs.filter((dir) => existsSync(dir));
|
||||
}
|
||||
|
||||
// The hives a brand-new shell reads its PATH from, in Windows' own order:
|
||||
// user scope first, then machine scope.
|
||||
// Windows appends the user PATH to the machine PATH. Keep that ordering even
|
||||
// though both registry queries run concurrently.
|
||||
const WINDOWS_PATH_HIVES = [
|
||||
"HKCU\\Environment",
|
||||
"HKLM\\SYSTEM\\CurrentControlSet\\Control\\Session Manager\\Environment",
|
||||
"HKCU\\Environment",
|
||||
];
|
||||
|
||||
function collectWindowsRegistryPath(stdout, env, parts) {
|
||||
const raw = parseRegQueryPath(stdout);
|
||||
if (raw) parts.push(expandWindowsEnvRefs(raw, env));
|
||||
}
|
||||
|
||||
async function readWindowsRegistryPath({ exec = execFileAsync, env = process.env } = {}) {
|
||||
const parts = [];
|
||||
for (const hive of WINDOWS_PATH_HIVES) {
|
||||
try {
|
||||
const { stdout } = await exec("reg", ["query", hive, "/v", "Path"], {
|
||||
encoding: "utf8",
|
||||
timeout: 3000,
|
||||
});
|
||||
collectWindowsRegistryPath(stdout, env, parts);
|
||||
} catch {
|
||||
// Hive unreadable / value missing — skip and rely on other sources.
|
||||
}
|
||||
}
|
||||
return parts.join(";");
|
||||
}
|
||||
|
||||
/**
|
||||
* Synchronous twin of readWindowsRegistryPath() for callers that cannot await,
|
||||
* such as local terminal startup, which spawns the PTY inline.
|
||||
*/
|
||||
function readWindowsRegistryPathSync({ exec = execFileSync, env = process.env } = {}) {
|
||||
const parts = [];
|
||||
for (const hive of WINDOWS_PATH_HIVES) {
|
||||
try {
|
||||
const stdout = exec("reg", ["query", hive, "/v", "Path"], {
|
||||
encoding: "utf8",
|
||||
timeout: 3000,
|
||||
windowsHide: true,
|
||||
});
|
||||
collectWindowsRegistryPath(stdout, env, parts);
|
||||
} catch {
|
||||
// Hive unreadable / value missing — skip and rely on other sources.
|
||||
}
|
||||
}
|
||||
return parts.join(";");
|
||||
}
|
||||
|
||||
/**
|
||||
* Windows only: put the live registry PATH ahead of an already-captured PATH.
|
||||
*
|
||||
* Long-lived processes keep the PATH they were launched with, so anything the
|
||||
* user installs or upgrades afterwards is invisible to them — including tools
|
||||
* whose installer rewrites a versioned user-PATH entry, where the stale entry
|
||||
* points at a directory the upgrade removed. Merging the registry value (what
|
||||
* a fresh shell inherits) ahead of the captured one resolves those CLIs
|
||||
* without a restart. Returns `basePath` unchanged off Windows, or when the
|
||||
* registry cannot be read.
|
||||
*/
|
||||
function resolveWindowsLivePath({
|
||||
basePath,
|
||||
exec,
|
||||
async function readWindowsRegistryPaths({
|
||||
exec = execFileAsync,
|
||||
env = process.env,
|
||||
platform = process.platform,
|
||||
knownCliDirs = getWindowsKnownCliPathDirs,
|
||||
timeoutMs = 1000,
|
||||
} = {}) {
|
||||
if (platform !== "win32") return basePath;
|
||||
|
||||
let registryPath = "";
|
||||
try {
|
||||
registryPath = readWindowsRegistryPathSync({ exec, env });
|
||||
} catch {
|
||||
registryPath = "";
|
||||
}
|
||||
|
||||
let knownDirs = "";
|
||||
try {
|
||||
knownDirs = (knownCliDirs(env) || []).join(";");
|
||||
} catch {
|
||||
knownDirs = "";
|
||||
}
|
||||
|
||||
if (!registryPath && !knownDirs) return basePath;
|
||||
return mergeWindowsPath(registryPath, knownDirs, basePath || "");
|
||||
return Promise.all(WINDOWS_PATH_HIVES.map(async (hive) => {
|
||||
let timer;
|
||||
try {
|
||||
// The JS deadline also bounds an unresponsive process callback. The
|
||||
// child-process timeout remains responsible for terminating reg.exe.
|
||||
const result = await Promise.race([
|
||||
exec("reg", ["query", hive, "/v", "Path"], {
|
||||
encoding: "utf8",
|
||||
timeout: timeoutMs,
|
||||
windowsHide: true,
|
||||
}),
|
||||
new Promise((resolve) => {
|
||||
timer = setTimeout(() => resolve(null), timeoutMs);
|
||||
}),
|
||||
]);
|
||||
return result ? expandWindowsEnvRefs(parseRegQueryPath(result.stdout), env) : null;
|
||||
} catch {
|
||||
return null;
|
||||
} finally {
|
||||
clearTimeout(timer);
|
||||
}
|
||||
}));
|
||||
}
|
||||
|
||||
async function readWindowsRegistryPath(options) {
|
||||
return mergeWindowsPath(...await readWindowsRegistryPaths(options));
|
||||
}
|
||||
|
||||
// Share a short-lived registry snapshot across simultaneous local terminals.
|
||||
// Cache raw values so each terminal expands references using its own env.
|
||||
function createWindowsLivePathResolver({
|
||||
exec = execFileAsync,
|
||||
now = Date.now,
|
||||
ttlMs = 5000,
|
||||
timeoutMs = 1000,
|
||||
} = {}) {
|
||||
const cachedPaths = [null, null];
|
||||
let refreshedAt = -Infinity;
|
||||
let pending = null;
|
||||
|
||||
return async function resolveWindowsLivePath({
|
||||
basePath,
|
||||
env = process.env,
|
||||
platform = process.platform,
|
||||
knownCliDirs = getWindowsKnownCliPathDirs,
|
||||
} = {}) {
|
||||
if (platform !== "win32") return basePath;
|
||||
if (!pending && now() - refreshedAt >= ttlMs) {
|
||||
pending = readWindowsRegistryPaths({ exec, env: {}, timeoutMs })
|
||||
.then((paths) => {
|
||||
// A failed hive retains its last successful value; one unavailable
|
||||
// hive must not discard the other half of a cached PATH.
|
||||
paths.forEach((value, index) => {
|
||||
if (value !== null) cachedPaths[index] = value;
|
||||
});
|
||||
refreshedAt = now();
|
||||
})
|
||||
.finally(() => { pending = null; });
|
||||
}
|
||||
if (pending) await pending;
|
||||
|
||||
let knownDirs = "";
|
||||
try {
|
||||
knownDirs = (knownCliDirs(env) || []).join(";");
|
||||
} catch { /* Best-effort fallback only. */ }
|
||||
|
||||
const registryPath = expandWindowsEnvRefs(mergeWindowsPath(...cachedPaths), env);
|
||||
if (!registryPath && !knownDirs) return basePath;
|
||||
// Registry order reflects a fresh shell; inherited entries remain a
|
||||
// fallback, and guessed package-manager bins must never take precedence.
|
||||
// With no machine snapshot, retain inherited system precedence rather
|
||||
// than accidentally promoting user directories above system binaries.
|
||||
return cachedPaths[0] === null
|
||||
? mergeWindowsPath(basePath || "", registryPath, knownDirs)
|
||||
: mergeWindowsPath(registryPath, basePath || "", knownDirs);
|
||||
};
|
||||
}
|
||||
|
||||
const resolveWindowsLivePath = createWindowsLivePathResolver();
|
||||
|
||||
async function getShellEnv() {
|
||||
if (_cachedShellEnv) return _cachedShellEnv;
|
||||
if (_shellEnvPromise) return _shellEnvPromise;
|
||||
@@ -946,7 +951,7 @@ async function getShellEnv() {
|
||||
const knownDirs = getWindowsKnownCliPathDirs().join(path.delimiter);
|
||||
const nextEnv = {
|
||||
...process.env,
|
||||
PATH: mergeWindowsPath(registryPath, knownDirs, process.env.PATH || ""),
|
||||
PATH: mergeWindowsPath(registryPath, process.env[getPathEnvKey(process.env, "win32")] || "", knownDirs),
|
||||
};
|
||||
if (generation === _shellEnvGeneration) {
|
||||
_cachedShellEnv = nextEnv;
|
||||
@@ -1062,7 +1067,7 @@ module.exports = {
|
||||
expandWindowsEnvRefs,
|
||||
mergeWindowsPath,
|
||||
readWindowsRegistryPath,
|
||||
readWindowsRegistryPathSync,
|
||||
createWindowsLivePathResolver,
|
||||
resolveWindowsLivePath,
|
||||
getShellEnv,
|
||||
invalidateShellEnvCache,
|
||||
|
||||
@@ -21,8 +21,7 @@ const {
|
||||
expandWindowsEnvRefs,
|
||||
mergeWindowsPath,
|
||||
readWindowsRegistryPath,
|
||||
readWindowsRegistryPathSync,
|
||||
resolveWindowsLivePath,
|
||||
createWindowsLivePathResolver,
|
||||
trackSessionIdlePrompt,
|
||||
} = require("./shellUtils.cjs");
|
||||
const fs = require("node:fs");
|
||||
@@ -591,7 +590,7 @@ test("readWindowsRegistryPath merges HKCU and HKLM and expands refs", async () =
|
||||
return { stdout: " Path REG_EXPAND_SZ C:\\Windows\\System32\r\n" };
|
||||
};
|
||||
const out = await readWindowsRegistryPath({ exec, env: { APPDATA: "C:\\Roaming" } });
|
||||
assert.equal(out, "C:\\Roaming\\npm;C:\\Windows\\System32");
|
||||
assert.equal(out, "C:\\Windows\\System32;C:\\Roaming\\npm");
|
||||
});
|
||||
|
||||
test("readWindowsRegistryPath tolerates a failing hive query", async () => {
|
||||
@@ -603,79 +602,6 @@ test("readWindowsRegistryPath tolerates a failing hive query", async () => {
|
||||
assert.equal(out, "C:\\tools");
|
||||
});
|
||||
|
||||
test("readWindowsRegistryPathSync mirrors the async reader for non-awaiting callers", () => {
|
||||
const exec = (cmd, args) => {
|
||||
assert.equal(cmd, "reg");
|
||||
if (args[1] === "HKCU\\Environment") {
|
||||
return " Path REG_EXPAND_SZ %APPDATA%\\npm\r\n";
|
||||
}
|
||||
return " Path REG_EXPAND_SZ C:\\Windows\\System32\r\n";
|
||||
};
|
||||
const out = readWindowsRegistryPathSync({ exec, env: { APPDATA: "C:\\Roaming" } });
|
||||
assert.equal(out, "C:\\Roaming\\npm;C:\\Windows\\System32");
|
||||
});
|
||||
|
||||
test("readWindowsRegistryPathSync tolerates a failing hive query", () => {
|
||||
const exec = (cmd, args) => {
|
||||
if (args[1] === "HKCU\\Environment") throw new Error("ERROR: cannot read");
|
||||
return " Path REG_SZ C:\\tools\r\n";
|
||||
};
|
||||
assert.equal(readWindowsRegistryPathSync({ exec, env: {} }), "C:\\tools");
|
||||
});
|
||||
|
||||
test("resolveWindowsLivePath front-loads a freshly installed CLI over the stale PATH", () => {
|
||||
// The reported regression: a long-running app captured a PATH whose herdr
|
||||
// entry still points at the previous release directory, which the upgrade
|
||||
// removed, so the stale entry no longer resolves anything.
|
||||
const stalePath = "C:\\Users\\me\\.herdr\\packages\\standalone\\releases\\0.8.2-x86_64;C:\\Windows\\System32";
|
||||
const exec = (cmd, args) => {
|
||||
if (args[1] === "HKCU\\Environment") {
|
||||
return " Path REG_EXPAND_SZ C:\\Users\\me\\.herdr\\packages\\standalone\\releases\\0.9.1-x86_64\r\n";
|
||||
}
|
||||
return " Path REG_EXPAND_SZ C:\\Windows\\System32\r\n";
|
||||
};
|
||||
|
||||
const out = resolveWindowsLivePath({
|
||||
basePath: stalePath,
|
||||
exec,
|
||||
env: {},
|
||||
platform: "win32",
|
||||
knownCliDirs: () => [],
|
||||
});
|
||||
|
||||
assert.equal(
|
||||
out,
|
||||
"C:\\Users\\me\\.herdr\\packages\\standalone\\releases\\0.9.1-x86_64;C:\\Windows\\System32;"
|
||||
+ "C:\\Users\\me\\.herdr\\packages\\standalone\\releases\\0.8.2-x86_64",
|
||||
);
|
||||
});
|
||||
|
||||
test("resolveWindowsLivePath keeps the captured PATH off Windows", () => {
|
||||
const exec = () => {
|
||||
throw new Error("the registry must not be queried off Windows");
|
||||
};
|
||||
const out = resolveWindowsLivePath({
|
||||
basePath: "/usr/local/bin:/usr/bin",
|
||||
exec,
|
||||
platform: "darwin",
|
||||
});
|
||||
assert.equal(out, "/usr/local/bin:/usr/bin");
|
||||
});
|
||||
|
||||
test("resolveWindowsLivePath falls back to the captured PATH when the registry is unreadable", () => {
|
||||
const exec = () => {
|
||||
throw new Error("access denied");
|
||||
};
|
||||
const out = resolveWindowsLivePath({
|
||||
basePath: "C:\\Windows\\System32",
|
||||
exec,
|
||||
env: {},
|
||||
platform: "win32",
|
||||
knownCliDirs: () => [],
|
||||
});
|
||||
assert.equal(out, "C:\\Windows\\System32");
|
||||
});
|
||||
|
||||
test("tracks PowerShell idle prompt after SSH output", () => {
|
||||
const session = {};
|
||||
|
||||
@@ -820,3 +746,94 @@ function withExecPath(fakePath, fn) {
|
||||
Object.defineProperty(process, "execPath", { value: original, configurable: true, writable: true });
|
||||
}
|
||||
}
|
||||
|
||||
test("Windows live PATH refresh is shared, asynchronous, bounded, and cached", async () => {
|
||||
let calls = 0;
|
||||
let tick = 0;
|
||||
const resolve = createWindowsLivePathResolver({
|
||||
exec: () => { calls += 1; return new Promise(() => {}); },
|
||||
timeoutMs: 20,
|
||||
now: () => tick,
|
||||
ttlMs: 50,
|
||||
});
|
||||
const options = { basePath: "C:\\Windows\\System32", platform: "win32", env: {}, knownCliDirs: () => [] };
|
||||
let eventLoopRan = false;
|
||||
setTimeout(() => { eventLoopRan = true; }, 0);
|
||||
const first = resolve(options);
|
||||
const second = resolve(options);
|
||||
assert.equal(calls, 2, "concurrent terminals share one query per hive");
|
||||
assert.deepEqual(await Promise.all([first, second]), [options.basePath, options.basePath]);
|
||||
assert.equal(eventLoopRan, true, "registry reads must not block the main event loop");
|
||||
await resolve(options);
|
||||
assert.equal(calls, 2, "even failed queries are briefly cached");
|
||||
tick = 51;
|
||||
await resolve(options);
|
||||
assert.equal(calls, 4, "expired snapshots refresh");
|
||||
});
|
||||
|
||||
test("Windows live PATH keeps machine before user and guesses after inherited entries", async () => {
|
||||
const resolve = createWindowsLivePathResolver({
|
||||
exec: async (_cmd, args, options) => {
|
||||
assert.equal(options.windowsHide, true);
|
||||
assert.equal(options.timeout, 1000);
|
||||
return { stdout: args[1].startsWith("HKLM")
|
||||
? " Path REG_SZ C:\\Windows\\System32;C:\\shared\\\r\n"
|
||||
: " Path REG_EXPAND_SZ %APPDATA%\\npm;c:\\SHARED;C:\\new-release\r\n" };
|
||||
},
|
||||
});
|
||||
const result = await resolve({
|
||||
basePath: "C:\\old-release;C:\\Windows\\System32;C:\\launcher",
|
||||
env: { APPDATA: "C:\\Roaming" },
|
||||
platform: "win32",
|
||||
knownCliDirs: () => ["C:\\guess", "C:\\launcher"],
|
||||
});
|
||||
assert.equal(result, "C:\\Windows\\System32;C:\\shared\\;C:\\Roaming\\npm;C:\\new-release;C:\\old-release;C:\\launcher;C:\\guess");
|
||||
});
|
||||
|
||||
test("Windows live PATH picks up upgraded directories after cache expiry", async () => {
|
||||
let tick = 0;
|
||||
let release = "v1";
|
||||
let unavailable = false;
|
||||
const resolve = createWindowsLivePathResolver({
|
||||
now: () => tick,
|
||||
ttlMs: 10,
|
||||
exec: async () => {
|
||||
if (unavailable) throw new Error("registry unavailable");
|
||||
return { stdout: ` Path REG_SZ C:\\${release}\r\n` };
|
||||
},
|
||||
});
|
||||
const options = { platform: "win32", basePath: "C:\\old", env: {}, knownCliDirs: () => [] };
|
||||
assert.equal(await resolve(options), "C:\\v1;C:\\old");
|
||||
release = "v2";
|
||||
tick = 11;
|
||||
assert.equal(await resolve(options), "C:\\v2;C:\\old");
|
||||
unavailable = true;
|
||||
tick = 22;
|
||||
assert.equal(await resolve(options), "C:\\v2;C:\\old", "query failure retains the last successful snapshot");
|
||||
});
|
||||
|
||||
test("cached Windows registry references expand per terminal environment", async () => {
|
||||
const resolve = createWindowsLivePathResolver({
|
||||
exec: async () => ({ stdout: " Path REG_EXPAND_SZ %APPDATA%\\npm\r\n" }),
|
||||
});
|
||||
const options = { platform: "win32", basePath: "", knownCliDirs: () => [] };
|
||||
assert.equal(await resolve({ ...options, env: { APPDATA: "C:\\first" } }), "C:\\first\\npm");
|
||||
assert.equal(await resolve({ ...options, env: { APPDATA: "C:\\second" } }), "C:\\second\\npm");
|
||||
});
|
||||
|
||||
test("Windows live PATH resolver does not query the registry off Windows", async () => {
|
||||
const resolve = createWindowsLivePathResolver({ exec: () => { throw new Error("must not query"); } });
|
||||
assert.equal(await resolve({ basePath: "/usr/bin:/bin", platform: "linux" }), "/usr/bin:/bin");
|
||||
});
|
||||
|
||||
test("a failed machine hive does not promote user directories ahead of inherited system paths", async () => {
|
||||
const resolve = createWindowsLivePathResolver({
|
||||
exec: async (_cmd, args) => {
|
||||
if (args[1].startsWith("HKLM")) throw new Error("unavailable");
|
||||
return { stdout: " Path REG_SZ C:\\user-tools\r\n" };
|
||||
},
|
||||
});
|
||||
assert.equal(await resolve({
|
||||
platform: "win32", basePath: "C:\\Windows\\System32", env: {}, knownCliDirs: () => [],
|
||||
}), "C:\\Windows\\System32;C:\\user-tools");
|
||||
});
|
||||
Loaded 100 of 139 files, more files were not shown because too many files have changed in this diff.
Show more
Reference in new issue
Block a user