mirror of
https://github.com/binaricat/Netcatty.git
synced 2026-11-02 03:42:34 +00:00
Compare 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',
|
||||
|
||||
@@ -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',
|
||||
|
||||
@@ -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': 'Загрузить изображение из буфера',
|
||||
|
||||
@@ -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': '全局快捷键在系统范围内工作,可快速显示或隐藏窗口(下拉式终端风格)。',
|
||||
|
||||
@@ -466,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': '搜索代码片段...',
|
||||
@@ -537,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': '全域快速鍵在系統範圍內工作,可快速顯示或隱藏視窗(下拉式終端風格)。',
|
||||
|
||||
@@ -466,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': '搜尋程式碼片段...',
|
||||
@@ -537,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.');
|
||||
}
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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";
|
||||
|
||||
+69
-10
@@ -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.
|
||||
@@ -2046,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(
|
||||
@@ -3277,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;
|
||||
@@ -3299,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
|
||||
@@ -3353,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);
|
||||
@@ -3389,6 +3444,7 @@ const TerminalComponent: React.FC<TerminalProps> = ({
|
||||
await runAutomationScript({
|
||||
snippet,
|
||||
sessionId,
|
||||
initiatedBy: 'user',
|
||||
sessionMeta: {
|
||||
connected: true,
|
||||
name: scriptSessionName,
|
||||
@@ -3428,6 +3484,7 @@ const TerminalComponent: React.FC<TerminalProps> = ({
|
||||
isBroadcastEnabledRef,
|
||||
onBroadcastInputRef,
|
||||
passwordPromptActiveRef,
|
||||
broadcastPasswordBypassRef,
|
||||
isLocalConnection,
|
||||
supportsRemoteImagePaste,
|
||||
autoUploadClipboardImageOnPasteRef,
|
||||
@@ -4018,6 +4075,7 @@ const TerminalComponent: React.FC<TerminalProps> = ({
|
||||
terminalBackend,
|
||||
isSensitiveInput: () => passwordPromptActiveRef.current,
|
||||
scrollOnPasteRef,
|
||||
broadcastPasswordBypassRef,
|
||||
onPasteData: broadcastUserPasteData,
|
||||
scrollToBottomAfterProgrammaticInput,
|
||||
containerRef,
|
||||
@@ -4260,6 +4318,7 @@ const TerminalComponent: React.FC<TerminalProps> = ({
|
||||
onOpenExternalError,
|
||||
isBroadcastEnabledRef,
|
||||
onBroadcastInputRef,
|
||||
broadcastPasswordBypassRef,
|
||||
snippetsRef,
|
||||
onSnippetShortkeyRef,
|
||||
sessionId,
|
||||
@@ -4566,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 ? (
|
||||
|
||||
@@ -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';
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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 {
|
||||
@@ -820,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}`, () => {
|
||||
|
||||
@@ -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 };
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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
|
||||
|
||||
+1
-1
@@ -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';
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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");
|
||||
});
|
||||
@@ -135,11 +135,11 @@ function registerAgentProcessHandlers(ctx) {
|
||||
});
|
||||
|
||||
// ── MCP Approval response (renderer → main) ──
|
||||
ipcMain.handle("netcatty:ai:mcp:approval-response", async (event, { approvalId, approved }) => {
|
||||
ipcMain.handle("netcatty:ai:mcp:approval-response", async (event, { approvalId, approved, scope }) => {
|
||||
// Settings window also hosts External MCP approval cards.
|
||||
if (!validateSenderOrSettings(event)) return { ok: false, error: "Unauthorized IPC sender" };
|
||||
mcpServerBridge.resolveApprovalFromRenderer(approvalId, approved);
|
||||
return { ok: true };
|
||||
const accepted = mcpServerBridge.resolveApprovalFromRenderer(approvalId, approved, scope);
|
||||
return { ok: true, accepted };
|
||||
});
|
||||
|
||||
// Cancel MCP approval auto-deny after the user starts reviewing the card.
|
||||
|
||||
@@ -19,6 +19,11 @@ let sendWhenRendererReady = null;
|
||||
let getSystemMenuMainWindow = null;
|
||||
let tray = null;
|
||||
let closeToTray = false;
|
||||
// User preference for whether the tray/menu bar icon is shown at all.
|
||||
// Independent of close-to-tray: the app keeps running in the background with
|
||||
// the icon hidden, and window close still hides (not quits) when close-to-tray
|
||||
// was explicitly enabled.
|
||||
let showTrayIcon = true;
|
||||
let windowsTrayScaleListener = null;
|
||||
let currentHotkey = null;
|
||||
let hotkeyEnabled = false;
|
||||
@@ -1292,18 +1297,12 @@ function setCloseToTray(enabled) {
|
||||
closeToTray = !!enabled;
|
||||
|
||||
if (closeToTray) {
|
||||
// Create tray if it doesn't exist
|
||||
if (!tray) {
|
||||
// Tray visibility is controlled separately by showTrayIcon.
|
||||
if (!tray && showTrayIcon) {
|
||||
createTray();
|
||||
}
|
||||
} else {
|
||||
clearPendingFullscreenHide(getMainWindow());
|
||||
// A hidden auto-launch cold start pins the tray regardless of this
|
||||
// preference until its window is actually shown once — otherwise a user
|
||||
// with close-to-tray off would get a windowless, trayless zombie process.
|
||||
if (!hiddenLaunchTrayPinned) {
|
||||
destroyTray();
|
||||
}
|
||||
}
|
||||
|
||||
return { success: true, enabled: closeToTray };
|
||||
@@ -1321,18 +1320,45 @@ function pinTrayForHiddenLaunch() {
|
||||
}
|
||||
|
||||
/**
|
||||
* Release the hidden-launch tray pin once its window has been shown. If the
|
||||
* user's close-to-tray preference is off, the tray is destroyed now instead
|
||||
* of lingering until the next close-to-tray toggle.
|
||||
* Release the hidden-launch tray pin once its window has been shown. The
|
||||
* hidden icon preference can now take effect safely.
|
||||
*/
|
||||
function releaseHiddenLaunchTrayPin() {
|
||||
if (!hiddenLaunchTrayPinned) return;
|
||||
hiddenLaunchTrayPinned = false;
|
||||
if (!closeToTray) {
|
||||
if (!showTrayIcon) {
|
||||
destroyTray();
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Show or hide the tray icon without changing close-to-tray behavior. The
|
||||
* app keeps running in the background while the icon is hidden; close-to-tray
|
||||
* still hides the window on close.
|
||||
*/
|
||||
function setShowTrayIcon(enabled) {
|
||||
showTrayIcon = !!enabled;
|
||||
|
||||
if (showTrayIcon) {
|
||||
if (!tray) {
|
||||
createTray();
|
||||
}
|
||||
} else if (!hiddenLaunchTrayPinned) {
|
||||
// A hidden auto-launch cold start keeps its safety pin until its window
|
||||
// is shown once, so a trayless zombie never appears without consent.
|
||||
destroyTray();
|
||||
}
|
||||
|
||||
return { success: true, enabled: showTrayIcon };
|
||||
}
|
||||
|
||||
/**
|
||||
* Check if the tray icon is currently meant to be shown
|
||||
*/
|
||||
function isShowTrayIconEnabled() {
|
||||
return showTrayIcon;
|
||||
}
|
||||
|
||||
/**
|
||||
* Check if close-to-tray is enabled
|
||||
*/
|
||||
@@ -1354,7 +1380,10 @@ function getHotkeyStatus() {
|
||||
* Handle window close event - hide to tray instead of closing
|
||||
*/
|
||||
function handleWindowClose(event, win) {
|
||||
if (closeToTray && tray) {
|
||||
// With the tray icon hidden by preference there is no `tray` object, but a
|
||||
// user who enabled close-to-tray still expects the window to hide (app
|
||||
// stays in the background), not to quit.
|
||||
if (closeToTray && (tray || !showTrayIcon)) {
|
||||
event.preventDefault();
|
||||
hideWindowRespectingMacFullscreen(win);
|
||||
return true; // Prevented close
|
||||
@@ -1392,6 +1421,16 @@ function registerHandlers(ipcMain) {
|
||||
return { enabled: closeToTray };
|
||||
});
|
||||
|
||||
// Show/hide the tray icon itself (independent of close-to-tray)
|
||||
ipcMain.handle("netcatty:tray:setShowTrayIcon", async (_event, { enabled }) => {
|
||||
return setShowTrayIcon(enabled);
|
||||
});
|
||||
|
||||
// Get show-tray-icon status
|
||||
ipcMain.handle("netcatty:tray:isShowTrayIcon", async () => {
|
||||
return { enabled: showTrayIcon };
|
||||
});
|
||||
|
||||
// Update tray menu data
|
||||
ipcMain.handle("netcatty:tray:updateMenuData", async (_event, data) => {
|
||||
setTrayMenuData(data);
|
||||
|
||||
@@ -1705,7 +1705,7 @@ test("a hidden-launch tray pin survives close-to-tray being turned off", async (
|
||||
}
|
||||
});
|
||||
|
||||
test("releasing the hidden-launch tray pin destroys the tray if close-to-tray is off", async () => {
|
||||
test("releasing the hidden-launch tray pin keeps the visible tray when close-to-tray is off", async () => {
|
||||
const bridge = loadBridge();
|
||||
const electronModule = createElectronStub();
|
||||
bridge.init({ electronModule, getMainWindow: () => null });
|
||||
@@ -1719,7 +1719,27 @@ test("releasing the hidden-launch tray pin destroys the tray if close-to-tray is
|
||||
|
||||
bridge.releaseHiddenLaunchTrayPin();
|
||||
|
||||
assert.equal(bridge.getTray(), null, "once the window is shown, close-to-tray=off should win");
|
||||
assert.notEqual(bridge.getTray(), null, "show-tray-icon=on keeps the tray visible");
|
||||
} finally {
|
||||
bridge.cleanup();
|
||||
}
|
||||
});
|
||||
|
||||
test("releasing the hidden-launch tray pin hides the icon when requested", async () => {
|
||||
const bridge = loadBridge();
|
||||
const electronModule = createElectronStub();
|
||||
bridge.init({ electronModule, getMainWindow: () => null });
|
||||
const ipcMain = createIpcMainStub();
|
||||
bridge.registerHandlers(ipcMain);
|
||||
|
||||
try {
|
||||
bridge.pinTrayForHiddenLaunch();
|
||||
await ipcMain.handlers.get("netcatty:tray:setShowTrayIcon")(null, { enabled: false });
|
||||
assert.notEqual(bridge.getTray(), null, "the hidden launch keeps its recovery icon");
|
||||
|
||||
bridge.releaseHiddenLaunchTrayPin();
|
||||
|
||||
assert.equal(bridge.getTray(), null);
|
||||
} finally {
|
||||
bridge.cleanup();
|
||||
}
|
||||
@@ -1757,7 +1777,7 @@ test("releaseHiddenLaunchTrayPin is a no-op when the pin was never set", () => {
|
||||
}
|
||||
});
|
||||
|
||||
test("setCloseToTray(false) still destroys an unpinned tray as before", async () => {
|
||||
test("setCloseToTray(false) keeps a visible tray without hiding the window on close", async () => {
|
||||
const bridge = loadBridge();
|
||||
const electronModule = createElectronStub();
|
||||
const { ipcMain } = await enableCloseToTray(bridge, electronModule);
|
||||
@@ -1765,6 +1785,92 @@ test("setCloseToTray(false) still destroys an unpinned tray as before", async ()
|
||||
try {
|
||||
assert.notEqual(bridge.getTray(), null);
|
||||
await ipcMain.handlers.get("netcatty:tray:setCloseToTray")(null, { enabled: false });
|
||||
assert.notEqual(bridge.getTray(), null);
|
||||
assert.equal(bridge.handleWindowClose({ preventDefault() { assert.fail("close was prevented"); } }, new FakeWindow()), false);
|
||||
} finally {
|
||||
bridge.cleanup();
|
||||
}
|
||||
});
|
||||
|
||||
test("setShowTrayIcon(false) destroys the tray even when close-to-tray stays on", async () => {
|
||||
const bridge = loadBridge();
|
||||
const electronModule = createElectronStub();
|
||||
const { ipcMain } = await enableCloseToTray(bridge, electronModule);
|
||||
|
||||
try {
|
||||
assert.notEqual(bridge.getTray(), null);
|
||||
const result = await ipcMain.handlers.get("netcatty:tray:setShowTrayIcon")(null, { enabled: false });
|
||||
assert.deepEqual(result, { success: true, enabled: false });
|
||||
assert.equal(bridge.getTray(), null);
|
||||
} finally {
|
||||
bridge.cleanup();
|
||||
}
|
||||
});
|
||||
|
||||
test("handleWindowClose still hides to tray after the icon was hidden by preference", async () => {
|
||||
await withPlatform("darwin", async () => {
|
||||
const bridge = loadBridge();
|
||||
const electronModule = createElectronStub();
|
||||
const { ipcMain } = await enableCloseToTray(bridge, electronModule);
|
||||
|
||||
try {
|
||||
await ipcMain.handlers.get("netcatty:tray:setShowTrayIcon")(null, { enabled: false });
|
||||
assert.equal(bridge.getTray(), null);
|
||||
|
||||
const win = new FakeWindow({ fullscreen: false });
|
||||
let prevented = false;
|
||||
const result = bridge.handleWindowClose({ preventDefault() { prevented = true; } }, win);
|
||||
|
||||
assert.equal(result, true);
|
||||
assert.equal(prevented, true);
|
||||
assert.equal(win.hideCalls, 1);
|
||||
} finally {
|
||||
bridge.cleanup();
|
||||
}
|
||||
});
|
||||
});
|
||||
|
||||
test("setShowTrayIcon(true) restores the tray when close-to-tray is on", async () => {
|
||||
const bridge = loadBridge();
|
||||
const electronModule = createElectronStub();
|
||||
const { ipcMain } = await enableCloseToTray(bridge, electronModule);
|
||||
|
||||
try {
|
||||
await ipcMain.handlers.get("netcatty:tray:setShowTrayIcon")(null, { enabled: false });
|
||||
assert.equal(bridge.getTray(), null);
|
||||
await ipcMain.handlers.get("netcatty:tray:setShowTrayIcon")(null, { enabled: true });
|
||||
assert.notEqual(bridge.getTray(), null);
|
||||
} finally {
|
||||
bridge.cleanup();
|
||||
}
|
||||
});
|
||||
|
||||
test("setShowTrayIcon(true) creates a tray when close-to-tray is off", async () => {
|
||||
const bridge = loadBridge();
|
||||
const electronModule = createElectronStub();
|
||||
bridge.init({ electronModule, getMainWindow: () => null });
|
||||
const ipcMain = createIpcMainStub();
|
||||
bridge.registerHandlers(ipcMain);
|
||||
|
||||
try {
|
||||
await ipcMain.handlers.get("netcatty:tray:setCloseToTray")(null, { enabled: false });
|
||||
await ipcMain.handlers.get("netcatty:tray:setShowTrayIcon")(null, { enabled: true });
|
||||
assert.notEqual(bridge.getTray(), null);
|
||||
} finally {
|
||||
bridge.cleanup();
|
||||
}
|
||||
});
|
||||
|
||||
test("setCloseToTray(true) does not recreate the tray while the icon is hidden by preference", async () => {
|
||||
const bridge = loadBridge();
|
||||
const electronModule = createElectronStub();
|
||||
bridge.init({ electronModule, getMainWindow: () => null });
|
||||
const ipcMain = createIpcMainStub();
|
||||
bridge.registerHandlers(ipcMain);
|
||||
|
||||
try {
|
||||
await ipcMain.handlers.get("netcatty:tray:setShowTrayIcon")(null, { enabled: false });
|
||||
await ipcMain.handlers.get("netcatty:tray:setCloseToTray")(null, { enabled: true });
|
||||
assert.equal(bridge.getTray(), null);
|
||||
} finally {
|
||||
bridge.cleanup();
|
||||
|
||||
@@ -63,6 +63,7 @@ const DISCOVERY_SELF_HEAL_INTERVAL_MS = 15000;
|
||||
const authenticatedSockets = new WeakSet();
|
||||
// Sockets authenticated with the External MCP token (or that used the reserved scope).
|
||||
const externalMcpSockets = new Set();
|
||||
const sessionApprovedExternalSockets = new WeakSet();
|
||||
|
||||
function markExternalMcpSocket(socket) {
|
||||
if (!socket || socket.destroyed) return;
|
||||
@@ -71,6 +72,8 @@ function markExternalMcpSocket(socket) {
|
||||
socket.__netcattyExternalMcpCleanupBound = true;
|
||||
const cleanup = () => {
|
||||
externalMcpSockets.delete(socket);
|
||||
sessionApprovedExternalSockets.delete(socket);
|
||||
clearPendingApprovalsForSocket(socket);
|
||||
};
|
||||
socket.once("close", cleanup);
|
||||
socket.once("end", cleanup);
|
||||
@@ -85,6 +88,9 @@ function issueExternalMcpAuthToken() {
|
||||
|
||||
function revokeExternalMcpAuthToken() {
|
||||
externalAuthToken = null;
|
||||
for (const socket of externalMcpSockets) {
|
||||
sessionApprovedExternalSockets.delete(socket);
|
||||
}
|
||||
}
|
||||
|
||||
function getExternalMcpAuthToken() {
|
||||
@@ -97,6 +103,7 @@ function disconnectExternalMcpClients() {
|
||||
// is reserved for process shutdown paths that call this intentionally.
|
||||
for (const socket of Array.from(externalMcpSockets)) {
|
||||
externalMcpSockets.delete(socket);
|
||||
sessionApprovedExternalSockets.delete(socket);
|
||||
try {
|
||||
if (!socket.destroyed) socket.destroy();
|
||||
} catch {
|
||||
@@ -207,7 +214,7 @@ function broadcastApprovalEvent(channel, payload) {
|
||||
}
|
||||
}
|
||||
|
||||
function requestApprovalFromRenderer(toolName, args, chatSessionId) {
|
||||
function requestApprovalFromRenderer(toolName, args, chatSessionId, approvalContext = {}) {
|
||||
return new Promise((resolve) => {
|
||||
debugLog("requestApprovalFromRenderer", { toolName, args, chatSessionId });
|
||||
const targets = listApprovalTargetWindows();
|
||||
@@ -261,25 +268,59 @@ function requestApprovalFromRenderer(toolName, args, chatSessionId) {
|
||||
absoluteExpiresAt,
|
||||
idleCancelled: false,
|
||||
chatSessionId: chatSessionId || null,
|
||||
externalSocket: approvalContext.externalSocket || null,
|
||||
});
|
||||
broadcastApprovalEvent('netcatty:ai:mcp:approval-request', {
|
||||
approvalId,
|
||||
toolName,
|
||||
args,
|
||||
chatSessionId: chatSessionId || undefined,
|
||||
target: approvalContext.target || undefined,
|
||||
allowSession: chatSessionId === EXTERNAL_MCP_CHAT_SESSION_ID
|
||||
&& externalMcpSockets.has(approvalContext.externalSocket),
|
||||
});
|
||||
});
|
||||
}
|
||||
|
||||
function resolveApprovalFromRenderer(approvalId, approved) {
|
||||
function resolveApprovalFromRenderer(approvalId, approved, scope = 'once') {
|
||||
debugLog("resolveApprovalFromRenderer", { approvalId, approved });
|
||||
const entry = pendingApprovals.get(approvalId);
|
||||
if (entry) {
|
||||
pendingApprovals.delete(approvalId);
|
||||
entry.resolve(approved);
|
||||
// Main + settings both receive approval requests; clear the sibling card.
|
||||
notifyRendererApprovalCleared([approvalId]);
|
||||
if (!entry) return false;
|
||||
pendingApprovals.delete(approvalId);
|
||||
const externalSocket = entry.chatSessionId === EXTERNAL_MCP_CHAT_SESSION_ID
|
||||
? entry.externalSocket
|
||||
: null;
|
||||
const socketValid = entry.chatSessionId !== EXTERNAL_MCP_CHAT_SESSION_ID
|
||||
|| Boolean(externalSocket
|
||||
&& !externalSocket.destroyed
|
||||
&& externalMcpSockets.has(externalSocket)
|
||||
&& externalMcpActivityHook?.isEnabled?.());
|
||||
const accepted = approved === true && socketValid;
|
||||
const clearedIds = [approvalId];
|
||||
if (accepted && scope === 'session' && externalSocket) {
|
||||
sessionApprovedExternalSockets.add(externalSocket);
|
||||
for (const [id, pending] of pendingApprovals) {
|
||||
if (pending.externalSocket !== externalSocket) continue;
|
||||
pendingApprovals.delete(id);
|
||||
pending.resolve(true);
|
||||
clearedIds.push(id);
|
||||
}
|
||||
}
|
||||
entry.resolve(accepted);
|
||||
// Main + settings both receive approval requests; clear sibling cards.
|
||||
notifyRendererApprovalCleared(clearedIds);
|
||||
return socketValid;
|
||||
}
|
||||
|
||||
function clearPendingApprovalsForSocket(socket) {
|
||||
const clearedIds = [];
|
||||
for (const [id, entry] of pendingApprovals) {
|
||||
if (entry.externalSocket !== socket) continue;
|
||||
pendingApprovals.delete(id);
|
||||
entry.resolve(false);
|
||||
clearedIds.push(id);
|
||||
}
|
||||
notifyRendererApprovalCleared(clearedIds);
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -642,6 +683,7 @@ function syncLiveSessionsToExternalScope(chatSessionId = EXTERNAL_MCP_CHAT_SESSI
|
||||
sessionList.push({
|
||||
sessionId,
|
||||
hostId: session.hostId || previous.hostId || "",
|
||||
savedHostId: session.savedHostId || previous.savedHostId || "",
|
||||
hostname: session.hostname || session.host || previous.hostname || "",
|
||||
label: session.label || session.hostname || previous.label || sessionId,
|
||||
os: session.os || previous.os || "",
|
||||
@@ -726,6 +768,7 @@ function updateLiveSessionMetadata(sessionList) {
|
||||
deviceType: entry.deviceType || "",
|
||||
connected: entry.connected !== false,
|
||||
hostId: entry.hostId || "",
|
||||
savedHostId: entry.savedHostId || "",
|
||||
hostChain: Array.isArray(entry.hostChain) ? entry.hostChain : [],
|
||||
activePortForwards: Array.isArray(entry.activePortForwards) ? entry.activePortForwards : [],
|
||||
_revision: updateRevision,
|
||||
@@ -850,6 +893,7 @@ function updateSessionMetadata(sessionList, chatSessionId) {
|
||||
deviceType: s.deviceType || "",
|
||||
connected: s.connected !== false,
|
||||
hostId: s.hostId || "",
|
||||
savedHostId: s.savedHostId || "",
|
||||
hostChain: Array.isArray(s.hostChain) ? s.hostChain : [],
|
||||
activePortForwards: Array.isArray(s.activePortForwards) ? s.activePortForwards : [],
|
||||
_revision: Number.isSafeInteger(s._revision) ? s._revision : updateRevision,
|
||||
@@ -890,6 +934,9 @@ function mergeSessionMetadata(sessionList, chatSessionId) {
|
||||
deviceType: entry.deviceType || previous.deviceType || "",
|
||||
connected: entry.connected !== undefined ? entry.connected !== false : previous.connected !== false,
|
||||
hostId: entry.hostId || previous.hostId || "",
|
||||
savedHostId: Object.prototype.hasOwnProperty.call(entry, "savedHostId")
|
||||
? entry.savedHostId || ""
|
||||
: previous.savedHostId || "",
|
||||
hostChain: Array.isArray(entry.hostChain)
|
||||
? entry.hostChain
|
||||
: (Array.isArray(previous.hostChain) ? previous.hostChain : []),
|
||||
@@ -1130,6 +1177,18 @@ function toPublicSessionMeta(meta) {
|
||||
return publicMeta;
|
||||
}
|
||||
|
||||
function getApprovalTarget(params) {
|
||||
if (typeof params?.sessionId !== 'string' || !params.sessionId) return null;
|
||||
const meta = getSessionMeta(params.sessionId, params.chatSessionId);
|
||||
if (!meta) return null;
|
||||
return {
|
||||
sessionId: params.sessionId,
|
||||
hostId: meta.savedHostId || '',
|
||||
label: meta.label || meta.hostname || params.sessionId,
|
||||
hostname: meta.hostname || '',
|
||||
};
|
||||
}
|
||||
|
||||
function buildOpenedSessionMeta(result, sessionId) {
|
||||
const host = result?.host && typeof result.host === "object" ? result.host : {};
|
||||
return {
|
||||
@@ -1143,6 +1202,9 @@ function buildOpenedSessionMeta(result, sessionId) {
|
||||
deviceType: host.deviceType || "",
|
||||
connected: result?.status === "connected",
|
||||
hostId: result?.hostId || host.id || "",
|
||||
savedHostId: result?.savedHostId && result.savedHostId === (result?.hostId || host.id)
|
||||
? result.savedHostId
|
||||
: "",
|
||||
hostChain: [],
|
||||
activePortForwards: [],
|
||||
};
|
||||
@@ -1410,7 +1472,9 @@ async function handleMessage(socket, line) {
|
||||
);
|
||||
}
|
||||
notifyExternalMcpActivity(method, callParams);
|
||||
const result = await dispatch(method, callParams);
|
||||
const result = await dispatch(method, callParams, {
|
||||
externalSocket: externalMcpSockets.has(socket) ? socket : null,
|
||||
});
|
||||
const response = JSON.stringify({ jsonrpc: "2.0", id, result }) + "\n";
|
||||
if (!socket.destroyed) socket.write(response);
|
||||
} catch (err) {
|
||||
@@ -1537,6 +1601,8 @@ const dispatchCapabilityRpc = createCapabilityRpcDispatcher({
|
||||
},
|
||||
isChatSessionCancelled,
|
||||
requestApprovalFromRenderer,
|
||||
getApprovalTarget,
|
||||
hasSessionApproval: (socket) => Boolean(socket && sessionApprovedExternalSockets.has(socket)),
|
||||
USER_DENIED_MESSAGE,
|
||||
listPortForwards: () => listActivePortForwards(),
|
||||
sessionService,
|
||||
@@ -1973,11 +2039,11 @@ function getBuiltinRpcHandlerRegistry() {
|
||||
return builtinRpcHandlerRegistry;
|
||||
}
|
||||
|
||||
async function dispatch(method, params) {
|
||||
async function dispatch(method, params, approvalContext = {}) {
|
||||
debugLog("dispatch", { method, params, permissionMode });
|
||||
|
||||
if (!method.startsWith("netcatty/")) {
|
||||
const capabilityResult = await dispatchCapabilityRpc(method, params || {});
|
||||
const capabilityResult = await dispatchCapabilityRpc(method, params || {}, approvalContext);
|
||||
if (capabilityResult !== UNROUTED) {
|
||||
return capabilityResult;
|
||||
}
|
||||
@@ -1989,6 +2055,7 @@ async function dispatch(method, params) {
|
||||
: null;
|
||||
pruneCompletedBackgroundJobs();
|
||||
|
||||
const approvalTarget = getApprovalTarget(params);
|
||||
const permission = evaluatePermissionWithGrants({
|
||||
rpcMethod: method,
|
||||
surface: CAPABILITY_SURFACES.BUILTIN,
|
||||
@@ -1996,6 +2063,7 @@ async function dispatch(method, params) {
|
||||
params,
|
||||
context: {
|
||||
chatSessionCancelled: isChatSessionCancelled(params?.chatSessionId),
|
||||
hostId: approvalTarget?.hostId,
|
||||
},
|
||||
}, permissionGrantsSnapshot);
|
||||
if (!permission.allowed) {
|
||||
@@ -2051,9 +2119,13 @@ async function dispatch(method, params) {
|
||||
// netcatty/jobStop bypasses approval — it's a stop/cancel action that
|
||||
// must remain available even if the renderer is unavailable; otherwise
|
||||
// a runaway terminal_start job could not be interrupted at all.
|
||||
if (permission.requiresApproval) {
|
||||
if (permission.requiresApproval
|
||||
&& !sessionApprovedExternalSockets.has(approvalContext.externalSocket)) {
|
||||
const { chatSessionId, ...toolArgs } = params || {};
|
||||
const approved = await requestApprovalFromRenderer(method, toolArgs, chatSessionId);
|
||||
const approved = await requestApprovalFromRenderer(method, toolArgs, chatSessionId, {
|
||||
...approvalContext,
|
||||
target: approvalTarget,
|
||||
});
|
||||
if (!approved) {
|
||||
return { ok: false, error: USER_DENIED_MESSAGE };
|
||||
}
|
||||
|
||||
@@ -0,0 +1,150 @@
|
||||
"use strict";
|
||||
|
||||
const test = require("node:test");
|
||||
const assert = require("node:assert/strict");
|
||||
const fs = require("node:fs");
|
||||
const path = require("node:path");
|
||||
const net = require("node:net");
|
||||
const { createNdjsonRpcClient } = require("../capabilities/rpcTransport.cjs");
|
||||
const tempDirBridge = require("./tempDirBridge.cjs");
|
||||
const bridge = require("./mcpServerBridge.cjs");
|
||||
|
||||
test("external MCP approval names the host and isolates connection and host grants", async (t) => {
|
||||
const dir = fs.mkdtempSync(`${tempDirBridge.getTempFilePath("host-approval")}-`);
|
||||
const clients = [];
|
||||
t.after(() => {
|
||||
for (const client of clients) client.close();
|
||||
bridge.disconnectExternalMcpClients();
|
||||
bridge.setMainWindowGetter(() => null);
|
||||
bridge.setVaultAgentInvoker(null);
|
||||
bridge.setExternalMcpHooks(null);
|
||||
bridge.setPermissionGrants([]);
|
||||
bridge.cleanup();
|
||||
fs.rmSync(dir, { recursive: true, force: true });
|
||||
});
|
||||
|
||||
const approvals = [];
|
||||
const waiters = [];
|
||||
const nextApproval = () => new Promise((resolve) => waiters.push(resolve));
|
||||
bridge.init({
|
||||
sessions: new Map(),
|
||||
cliDiscoveryFilePath: path.join(dir, "discovery.json"),
|
||||
terminalWorkerManager: {
|
||||
request: async () => ({ ok: true, stdout: "done", stderr: "", exitCode: 0 }),
|
||||
},
|
||||
});
|
||||
bridge.setMainWindowGetter(() => ({
|
||||
isDestroyed: () => false,
|
||||
webContents: {
|
||||
id: 42,
|
||||
send(channel, payload) {
|
||||
if (channel !== "netcatty:ai:mcp:approval-request") return;
|
||||
approvals.push(payload);
|
||||
waiters.shift()?.(payload);
|
||||
},
|
||||
},
|
||||
}));
|
||||
let externalEnabled = true;
|
||||
bridge.setExternalMcpHooks({ isEnabled: () => externalEnabled, recordActivity: () => {} });
|
||||
bridge.setPermissionMode("confirm");
|
||||
bridge.setCommandBlocklist([]);
|
||||
bridge.setVaultAgentInvoker(async () => ({ ok: true }));
|
||||
const token = bridge.issueExternalMcpAuthToken();
|
||||
const port = await bridge.getOrCreateHost();
|
||||
bridge.updateSessionMetadata([
|
||||
{ sessionId: "ssh-test", hostId: "host-test", savedHostId: "host-test", label: "Test server", hostname: "test.local", protocol: "ssh", connected: true },
|
||||
{ sessionId: "ssh-prod", hostId: "host-prod", savedHostId: "host-prod", label: "Production", hostname: "prod.local", protocol: "ssh", connected: true },
|
||||
], bridge.EXTERNAL_MCP_CHAT_SESSION_ID);
|
||||
|
||||
async function connect() {
|
||||
const socket = net.createConnection({ port, host: "127.0.0.1" });
|
||||
await new Promise((resolve, reject) => {
|
||||
socket.once("connect", resolve);
|
||||
socket.once("error", reject);
|
||||
});
|
||||
const client = createNdjsonRpcClient({ socket });
|
||||
clients.push(client);
|
||||
assert.equal((await client.call("auth/verify", { token })).ok, true);
|
||||
return client;
|
||||
}
|
||||
|
||||
const clientA = await connect();
|
||||
const clientB = await connect();
|
||||
const exec = (client, sessionId) => client.call("netcatty/exec", {
|
||||
sessionId,
|
||||
chatSessionId: bridge.EXTERNAL_MCP_CHAT_SESSION_ID,
|
||||
command: "date",
|
||||
});
|
||||
|
||||
const firstApproval = nextApproval();
|
||||
const firstCall = exec(clientA, "ssh-test");
|
||||
const request = await firstApproval;
|
||||
assert.deepEqual(request.target, {
|
||||
sessionId: "ssh-test", hostId: "host-test", label: "Test server", hostname: "test.local",
|
||||
});
|
||||
assert.equal(request.allowSession, true);
|
||||
bridge.resolveApprovalFromRenderer(request.approvalId, true, "session");
|
||||
assert.equal((await firstCall).ok, true);
|
||||
|
||||
assert.equal((await exec(clientA, "ssh-prod")).ok, true);
|
||||
assert.equal(approvals.length, 1, "session approval stays on the first connection");
|
||||
|
||||
const secondApproval = nextApproval();
|
||||
const secondCall = exec(clientB, "ssh-prod");
|
||||
const prodRequest = await secondApproval;
|
||||
assert.equal(prodRequest.target.label, "Production");
|
||||
bridge.resolveApprovalFromRenderer(prodRequest.approvalId, false);
|
||||
assert.equal((await secondCall).ok, false);
|
||||
|
||||
bridge.setPermissionGrants([{
|
||||
id: "allow-test-host", capabilityId: "*", sessionPattern: "host:host-test", createdAt: Date.now(),
|
||||
}]);
|
||||
assert.equal((await exec(clientB, "ssh-test")).ok, true);
|
||||
assert.equal(approvals.length, 2, "host grant applies to the second connection without approving production");
|
||||
|
||||
bridge.setPermissionGrants([]);
|
||||
clientA.close();
|
||||
const clientC = await connect();
|
||||
const thirdApproval = nextApproval();
|
||||
const thirdCall = exec(clientC, "ssh-test");
|
||||
const reconnectRequest = await thirdApproval;
|
||||
assert.equal(reconnectRequest.target.label, "Test server");
|
||||
bridge.resolveApprovalFromRenderer(reconnectRequest.approvalId, false);
|
||||
assert.equal((await thirdCall).ok, false, "a new connection does not inherit temporary approval");
|
||||
|
||||
const pendingA = nextApproval();
|
||||
const pendingB = nextApproval();
|
||||
const callA = clientB.call("public/vault/hosts/delete", { hostId: "missing-a" });
|
||||
const callB = clientB.call("public/vault/hosts/update", { hostId: "missing-b", label: "test" });
|
||||
const [approvalA, approvalB] = await Promise.all([pendingA, pendingB]);
|
||||
assert.equal(bridge.resolveApprovalFromRenderer(approvalA.approvalId, true, "session"), true);
|
||||
assert.equal((await callA).ok, true);
|
||||
assert.equal((await callB).ok, true, "connection approval clears other pending requests on that socket");
|
||||
assert.equal(bridge.resolveApprovalFromRenderer(approvalB.approvalId, true), false);
|
||||
|
||||
bridge.revokeExternalMcpAuthToken();
|
||||
const afterRevoke = nextApproval();
|
||||
const afterRevokeCall = exec(clientB, "ssh-test");
|
||||
const revokeRequest = await afterRevoke;
|
||||
bridge.resolveApprovalFromRenderer(revokeRequest.approvalId, false);
|
||||
assert.equal((await afterRevokeCall).ok, false, "re-enabling requires fresh approval");
|
||||
|
||||
bridge.updateSessionMetadata([
|
||||
{ sessionId: "local-test", hostId: "local-1", savedHostId: "", label: "Local Terminal", hostname: "localhost", protocol: "local", connected: true },
|
||||
], bridge.EXTERNAL_MCP_CHAT_SESSION_ID);
|
||||
const localApproval = nextApproval();
|
||||
const localCall = exec(clientB, "local-test");
|
||||
const localRequest = await localApproval;
|
||||
assert.equal(localRequest.target.label, "Local Terminal");
|
||||
assert.equal(localRequest.target.hostname, "localhost");
|
||||
assert.equal(localRequest.target.hostId, "", "local sessions cannot get a persistent host grant");
|
||||
bridge.resolveApprovalFromRenderer(localRequest.approvalId, false);
|
||||
assert.equal((await localCall).ok, false);
|
||||
|
||||
const staleApproval = nextApproval();
|
||||
const staleCall = exec(clientC, "local-test");
|
||||
const staleRequest = await staleApproval;
|
||||
externalEnabled = false;
|
||||
assert.equal(bridge.resolveApprovalFromRenderer(staleRequest.approvalId, true), false);
|
||||
assert.equal((await staleCall).ok, false);
|
||||
});
|
||||
@@ -120,6 +120,7 @@ test("MCP/Catty terminal_execute proxies to worker when terminal sessions live i
|
||||
deviceType: "",
|
||||
connected: true,
|
||||
hostId: "",
|
||||
savedHostId: "",
|
||||
hostChain: [],
|
||||
activePortForwards: [],
|
||||
},
|
||||
@@ -431,6 +432,7 @@ test("MCP/Catty terminal_start, poll, and stop proxy worker background jobs", as
|
||||
deviceType: "",
|
||||
connected: true,
|
||||
hostId: "",
|
||||
savedHostId: "",
|
||||
hostChain: [],
|
||||
activePortForwards: [],
|
||||
},
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
"use strict";
|
||||
|
||||
const { CAPABILITY_STATUS, CAPABILITY_SURFACES } = require("../../capabilities/constants.cjs");
|
||||
const { CAPABILITY_STATUS, CAPABILITY_SURFACES, PERMISSION_MODES } = require("../../capabilities/constants.cjs");
|
||||
const { getCapabilityByRpcMethod } = require("../../capabilities/registry.cjs");
|
||||
const { getMcpToolNameForRpcMethod } = require("../../capabilities/adapters/mcpAdapter.cjs");
|
||||
const { createVaultService } = require("../../capabilities/services/vaultService.cjs");
|
||||
@@ -106,7 +106,7 @@ function createCapabilityRpcDispatcher(deps) {
|
||||
session: sessionService,
|
||||
};
|
||||
|
||||
return async function dispatchCapabilityRpc(rpcMethod, params = {}) {
|
||||
return async function dispatchCapabilityRpc(rpcMethod, params = {}, approvalContext = {}) {
|
||||
if (typeof rpcMethod !== "string" || rpcMethod.startsWith("netcatty/")) {
|
||||
return UNROUTED;
|
||||
}
|
||||
@@ -130,6 +130,33 @@ function createCapabilityRpcDispatcher(deps) {
|
||||
return UNROUTED;
|
||||
}
|
||||
|
||||
// Only session.close executes against params.sessionId. host.open instead
|
||||
// targets its requested vault host; other vault/script operations must not
|
||||
// borrow an unrelated session's host grant.
|
||||
let approvalTarget = capability.id === "session.close"
|
||||
? (deps.getApprovalTarget?.(params) || null)
|
||||
: null;
|
||||
if (capability.id === "vault.host.open" && deps.permissionMode === PERMISSION_MODES.CONFIRM) {
|
||||
const requestedHostId = typeof params?.hostId === "string" ? params.hostId.trim() : "";
|
||||
let lookup = null;
|
||||
if (requestedHostId) {
|
||||
try {
|
||||
lookup = await vaultService.getHost({ hostId: requestedHostId });
|
||||
} catch {
|
||||
// The metadata lookup is best-effort; without it, approval cannot
|
||||
// offer a persistent host grant.
|
||||
}
|
||||
}
|
||||
const host = lookup?.host?.id === requestedHostId ? lookup.host : null;
|
||||
approvalTarget = {
|
||||
sessionId: "",
|
||||
hostId: host && host.ephemeral !== true && host.protocol !== "serial" && host.protocol !== "local"
|
||||
? requestedHostId
|
||||
: "",
|
||||
label: host?.label || host?.hostname || requestedHostId,
|
||||
hostname: host?.hostname || "",
|
||||
};
|
||||
}
|
||||
const permission = evaluatePermissionWithGrants({
|
||||
rpcMethod,
|
||||
surface,
|
||||
@@ -137,6 +164,7 @@ function createCapabilityRpcDispatcher(deps) {
|
||||
params,
|
||||
context: {
|
||||
chatSessionCancelled: isChatSessionCancelled(params?.chatSessionId),
|
||||
hostId: approvalTarget?.hostId,
|
||||
},
|
||||
}, deps.permissionGrantsSnapshot);
|
||||
|
||||
@@ -144,12 +172,15 @@ function createCapabilityRpcDispatcher(deps) {
|
||||
return { ok: false, error: permission.error };
|
||||
}
|
||||
|
||||
if (permission.requiresApproval) {
|
||||
if (permission.requiresApproval && !deps.hasSessionApproval?.(approvalContext.externalSocket)) {
|
||||
const { chatSessionId, ...toolArgs } = params || {};
|
||||
const toolName = getMcpToolNameForRpcMethod(rpcMethod, surface)
|
||||
|| capability.surfaces?.[CAPABILITY_SURFACES.PUBLIC]?.mcpTool
|
||||
|| capability.id;
|
||||
const approved = await requestApprovalFromRenderer(toolName, toolArgs, chatSessionId);
|
||||
const approved = await requestApprovalFromRenderer(toolName, toolArgs, chatSessionId, {
|
||||
...approvalContext,
|
||||
target: approvalTarget,
|
||||
});
|
||||
if (!approved) {
|
||||
return { ok: false, error: USER_DENIED_MESSAGE };
|
||||
}
|
||||
|
||||
@@ -5,6 +5,7 @@ const assert = require("node:assert/strict");
|
||||
|
||||
const { createCapabilityRpcDispatcher, UNROUTED } = require("./capabilityRpcDispatch.cjs");
|
||||
const { CAPABILITY_SURFACES, PERMISSION_MODES } = require("../../capabilities/constants.cjs");
|
||||
const { evaluatePermissionWithGrants } = require("../../capabilities/policy.cjs");
|
||||
|
||||
function createTestDispatcher(overrides = {}) {
|
||||
const invokeVaultAgent = overrides.invokeVaultAgent || (async (op, params) => ({
|
||||
@@ -84,6 +85,75 @@ test("dispatchCapabilityRpc routes public vault host notes set through approval"
|
||||
assert.equal(result.notes, "updated");
|
||||
});
|
||||
|
||||
test("vault host deletion cannot borrow a different session's host grant", async () => {
|
||||
let approvalTarget;
|
||||
let deleted = false;
|
||||
const dispatch = createTestDispatcher({
|
||||
evaluatePermissionWithGrants,
|
||||
permissionGrantsSnapshot: [{
|
||||
id: "trusted-host", capabilityId: "*", sessionPattern: "host:trusted", createdAt: 1,
|
||||
}],
|
||||
getApprovalTarget: () => ({ sessionId: "trusted-session", hostId: "trusted", label: "Trusted" }),
|
||||
requestApprovalFromRenderer: async (_name, _args, _chatSessionId, context) => {
|
||||
approvalTarget = context.target;
|
||||
return false;
|
||||
},
|
||||
invokeVaultAgent: async () => {
|
||||
deleted = true;
|
||||
return { ok: true };
|
||||
},
|
||||
});
|
||||
|
||||
const result = await dispatch("public/vault/hosts/delete", {
|
||||
chatSessionId: "__external_mcp__",
|
||||
sessionId: "trusted-session",
|
||||
hostId: "other-host",
|
||||
});
|
||||
assert.equal(result.ok, false);
|
||||
assert.equal(deleted, false);
|
||||
assert.equal(approvalTarget, null);
|
||||
});
|
||||
|
||||
test("host_open names its vault host and cannot borrow another host's grant", async () => {
|
||||
const approvals = [];
|
||||
const opened = [];
|
||||
const hosts = {
|
||||
test: { id: "test", label: "Test server", hostname: "test.example" },
|
||||
prod: { id: "prod", label: "Production", hostname: "prod.example" },
|
||||
temporary: { id: "temporary", label: "Temporary", hostname: "temp.example", ephemeral: true },
|
||||
serial: { id: "serial", label: "Serial device", hostname: "ttyUSB0", protocol: "serial" },
|
||||
};
|
||||
const dispatch = createTestDispatcher({
|
||||
evaluatePermissionWithGrants,
|
||||
permissionGrantsSnapshot: [{
|
||||
id: "test-only", capabilityId: "vault.host.open", sessionPattern: "host:test", createdAt: 1,
|
||||
}],
|
||||
requestApprovalFromRenderer: async (_name, _args, _chat, context) => {
|
||||
approvals.push(context.target);
|
||||
return false;
|
||||
},
|
||||
invokeVaultAgent: async (op, params) => {
|
||||
if (op === "host.get") return { ok: true, host: hosts[params.hostId] };
|
||||
if (op === "host.open") {
|
||||
opened.push(params.hostId);
|
||||
return { ok: true, sessionId: `session-${params.hostId}` };
|
||||
}
|
||||
return { ok: false };
|
||||
},
|
||||
});
|
||||
|
||||
assert.equal((await dispatch("public/vault/hosts/open", { hostId: "test" })).ok, true);
|
||||
assert.equal((await dispatch("public/vault/hosts/open", { hostId: "prod" })).ok, false);
|
||||
assert.equal((await dispatch("public/vault/hosts/open", { hostId: "temporary" })).ok, false);
|
||||
assert.equal((await dispatch("public/vault/hosts/open", { hostId: "serial" })).ok, false);
|
||||
assert.deepEqual(opened, ["test"]);
|
||||
assert.deepEqual(approvals, [
|
||||
{ sessionId: "", hostId: "prod", label: "Production", hostname: "prod.example" },
|
||||
{ sessionId: "", hostId: "", label: "Temporary", hostname: "temp.example" },
|
||||
{ sessionId: "", hostId: "", label: "Serial device", hostname: "ttyUSB0" },
|
||||
]);
|
||||
});
|
||||
|
||||
test("dispatchCapabilityRpc asks for approval before importing vault notes from the CLI rpc", async () => {
|
||||
const approvalCalls = [];
|
||||
let invokedOp = null;
|
||||
|
||||
@@ -35,6 +35,9 @@ function mergeRetentionMeta(previous, fallback) {
|
||||
shellType: newer.shellType || older.shellType,
|
||||
deviceType: newer.deviceType || older.deviceType,
|
||||
hostId: newer.hostId || older.hostId,
|
||||
savedHostId: Object.prototype.hasOwnProperty.call(newer, "savedHostId")
|
||||
? newer.savedHostId || ""
|
||||
: older.savedHostId,
|
||||
hostChain: Array.isArray(newer.hostChain) ? newer.hostChain : older.hostChain,
|
||||
// Explicit empty arrays in the newer snapshot clear stopped forwards.
|
||||
activePortForwards: Array.isArray(newer.activePortForwards)
|
||||
@@ -105,6 +108,7 @@ function retainOwnedSessions({
|
||||
deviceType: meta.deviceType || "",
|
||||
connected: meta.connected !== false,
|
||||
hostId: meta.hostId || "",
|
||||
savedHostId: meta.savedHostId || "",
|
||||
hostChain: Array.isArray(meta.hostChain) ? meta.hostChain : [],
|
||||
activePortForwards: Array.isArray(meta.activePortForwards) ? meta.activePortForwards : [],
|
||||
...(Number.isSafeInteger(meta._revision) ? { _revision: meta._revision } : {}),
|
||||
|
||||
@@ -2318,3 +2318,104 @@ test("single-channel Copy Tab dials separately and keeps the original shell", as
|
||||
assert.notEqual(sessions.get("copy").conn, sourceConn);
|
||||
assert.equal(sessions.get("source").conn, sourceConn);
|
||||
});
|
||||
|
||||
function waitForExitDeliveries() {
|
||||
// finalizeClose runs after the paced output buffer flushes on the next
|
||||
// event-loop turns; give both queue turns and timers a chance to run.
|
||||
return new Promise((resolve) => setTimeout(resolve, 50));
|
||||
}
|
||||
|
||||
function getExitPayloads(sender) {
|
||||
return sender.sent
|
||||
.filter((m) => m.channel === "netcatty:exit")
|
||||
.map((m) => m.payload);
|
||||
}
|
||||
|
||||
test("a channel closed without exit-status or exit-signal reports no exit code (#3591)", async (t) => {
|
||||
const { bridge } = loadBridgeWithMockedSsh2(t, { connectReady: true });
|
||||
const sessions = new Map();
|
||||
const start = registerStartHandler(bridge, sessions);
|
||||
const sender = makeSender();
|
||||
const options = {
|
||||
hostname: "bastion.example",
|
||||
username: "alice",
|
||||
port: 22,
|
||||
authMethod: "password",
|
||||
password: "secret",
|
||||
useSshAgent: false,
|
||||
verifyHostKeys: false,
|
||||
};
|
||||
|
||||
await start({ sender }, { ...options, sessionId: "no-status-close" });
|
||||
const stream = sessions.get("no-status-close").stream;
|
||||
|
||||
// Bastions/gateways built on stacks like golang.org/x/crypto/ssh close the
|
||||
// session channel without ever sending exit-status or exit-signal, so ssh2
|
||||
// never emits stream "exit".
|
||||
stream.emit("close");
|
||||
await waitForExitDeliveries();
|
||||
|
||||
const exits = getExitPayloads(sender);
|
||||
assert.equal(exits.length, 1);
|
||||
assert.equal(exits[0].reason, "closed");
|
||||
assert.equal(exits[0].exitCode, undefined, "the sentinel 0 must not be reported as a real exit code");
|
||||
assert.equal(exits[0].remoteClosedWithoutExitStatus, true);
|
||||
});
|
||||
|
||||
test("a real exit-status still reports its numeric exit code", async (t) => {
|
||||
const { bridge } = loadBridgeWithMockedSsh2(t, { connectReady: true });
|
||||
const sessions = new Map();
|
||||
const start = registerStartHandler(bridge, sessions);
|
||||
const sender = makeSender();
|
||||
const options = {
|
||||
hostname: "10.0.0.50",
|
||||
username: "alice",
|
||||
port: 22,
|
||||
authMethod: "password",
|
||||
password: "secret",
|
||||
useSshAgent: false,
|
||||
verifyHostKeys: false,
|
||||
};
|
||||
|
||||
await start({ sender }, { ...options, sessionId: "clean-exit" });
|
||||
const stream = sessions.get("clean-exit").stream;
|
||||
|
||||
stream.emit("exit", 0);
|
||||
stream.emit("close");
|
||||
await waitForExitDeliveries();
|
||||
|
||||
const exits = getExitPayloads(sender);
|
||||
assert.equal(exits.length, 1);
|
||||
assert.equal(exits[0].reason, "exited");
|
||||
assert.equal(exits[0].exitCode, 0);
|
||||
assert.equal(exits[0].remoteClosedWithoutExitStatus, undefined);
|
||||
});
|
||||
|
||||
test("an exit-signal with no code does not fabricate an exit code 0", async (t) => {
|
||||
const { bridge } = loadBridgeWithMockedSsh2(t, { connectReady: true });
|
||||
const sessions = new Map();
|
||||
const start = registerStartHandler(bridge, sessions);
|
||||
const sender = makeSender();
|
||||
const options = {
|
||||
hostname: "10.0.0.50",
|
||||
username: "alice",
|
||||
port: 22,
|
||||
authMethod: "password",
|
||||
password: "secret",
|
||||
useSshAgent: false,
|
||||
verifyHostKeys: false,
|
||||
};
|
||||
|
||||
await start({ sender }, { ...options, sessionId: "signal-exit" });
|
||||
const stream = sessions.get("signal-exit").stream;
|
||||
|
||||
stream.emit("exit", undefined, "SIGUSR2");
|
||||
stream.emit("close");
|
||||
await waitForExitDeliveries();
|
||||
|
||||
const exits = getExitPayloads(sender);
|
||||
assert.equal(exits.length, 1);
|
||||
assert.equal(exits[0].reason, "closed");
|
||||
assert.equal(exits[0].exitCode, undefined);
|
||||
assert.equal(exits[0].remoteClosedWithoutExitStatus, undefined, "the remote did report a signal");
|
||||
});
|
||||
@@ -611,12 +611,18 @@ function createStartSessionApi(ctx) {
|
||||
// Only treat it as user-initiated exit if "exit" fired with a numeric
|
||||
// code and no signal. Signal terminations (e.g. server kill, idle
|
||||
// timeout) have code=null and signal set — those are not user exits.
|
||||
let streamExitCode = 0;
|
||||
// A third case exists: a remote that closes the channel without sending
|
||||
// exit-status or exit-signal never fires "exit" at all (#3591). The
|
||||
// initial values below stay null in that case and must not be reported
|
||||
// as a real "code 0" — that reads like a clean, user-typed `exit`.
|
||||
let streamExitCode = null;
|
||||
let streamExitSignal = null;
|
||||
let streamExited = false;
|
||||
stream.on("exit", (code, signal) => {
|
||||
log("shell exit", { sessionId, hostname: options.hostname, code, signal, reused: !!isReused });
|
||||
streamExitCode = typeof code === "number" ? code : 0;
|
||||
streamExited = typeof code === "number" && !signal;
|
||||
streamExitCode = typeof code === "number" ? code : null;
|
||||
streamExitSignal = typeof signal === "string" && signal ? signal : null;
|
||||
streamExited = typeof code === "number" && !streamExitSignal;
|
||||
});
|
||||
|
||||
let closeFinalized = false;
|
||||
@@ -663,12 +669,21 @@ function createStartSessionApi(ctx) {
|
||||
// for reconnect instead of auto-closing it (#1062 / #977).
|
||||
const idleTimedOut = streamExited && looksLikeIdleAutoLogout(liveSession?._promptTrackTail);
|
||||
const reason = idleTimedOut ? "timeout" : (streamExited ? "exited" : "closed");
|
||||
safeSendSessionExit({ safeSend, electronModule, sessions }, contents, sessionId, {
|
||||
const exitPayload = {
|
||||
sessionId,
|
||||
exitCode: streamExitCode,
|
||||
reason,
|
||||
_terminalSessionGeneration: liveSession?._terminalSessionGeneration,
|
||||
});
|
||||
};
|
||||
// Report only exit facts the remote actually sent (#3591). When
|
||||
// the peer closed the channel without exit-status/exit-signal,
|
||||
// there is no code to show — flag it instead so the renderer can
|
||||
// distinguish an abnormal teardown from a user-typed `exit 0`.
|
||||
if (typeof streamExitCode === "number") {
|
||||
exitPayload.exitCode = streamExitCode;
|
||||
} else if (streamExitSignal === null) {
|
||||
exitPayload.remoteClosedWithoutExitStatus = true;
|
||||
}
|
||||
safeSendSessionExit({ safeSend, electronModule, sessions }, contents, sessionId, exitPayload);
|
||||
}
|
||||
liveSession?.zmodemSentry?.cancel();
|
||||
// Release this channel's hold on the shared connection. The transport
|
||||
|
||||
@@ -936,7 +936,7 @@ const applyLocaleDefaults = (env) => {
|
||||
* Host-level `environmentVariables` overrides are applied on top, and an
|
||||
* explicit PATH among them always wins — widening it would defeat the override.
|
||||
*/
|
||||
function buildLocalSessionEnv(
|
||||
async function buildLocalSessionEnv(
|
||||
payload,
|
||||
{ baseEnv = process.env, platform = process.platform, refreshWindowsPath } = {},
|
||||
) {
|
||||
@@ -953,7 +953,7 @@ function buildLocalSessionEnv(
|
||||
const pathKey = overridePathKey || getPathEnvKey(env, platform);
|
||||
|
||||
if (!overridePathKey) {
|
||||
const livePath = (refreshWindowsPath || resolveWindowsLivePath)({
|
||||
const livePath = await (refreshWindowsPath || resolveWindowsLivePath)({
|
||||
basePath: env[pathKey],
|
||||
env,
|
||||
platform,
|
||||
@@ -972,7 +972,7 @@ function buildLocalSessionEnv(
|
||||
/**
|
||||
* Start a local terminal session
|
||||
*/
|
||||
function startLocalSession(event, payload) {
|
||||
async function startLocalSession(event, payload) {
|
||||
const sessionId = payload?.sessionId || randomUUID();
|
||||
const defaultShell = getDefaultLocalShell();
|
||||
// payload.shell may be a discovered shell ID (e.g., "wsl-ubuntu") — resolve it
|
||||
@@ -995,12 +995,29 @@ function startLocalSession(event, payload) {
|
||||
requestedCwd,
|
||||
);
|
||||
const shellKind = detectShellKind(shell);
|
||||
const { registerPendingBootAbort, clearPendingBootAbort } = require("./sessionBootEpoch.cjs");
|
||||
const pendingBootAbort = registerPendingBootAbort(sessionId, payload?.bootEpoch);
|
||||
let sessionEnv;
|
||||
try {
|
||||
sessionEnv = await buildLocalSessionEnv(payload);
|
||||
} finally {
|
||||
clearPendingBootAbort(sessionId, pendingBootAbort);
|
||||
}
|
||||
if (pendingBootAbort.signal.aborted) {
|
||||
const supersededError = new Error("Local session closed or superseded before startup");
|
||||
supersededError.code = "NETCATTY_BOOT_SUPERSEDED";
|
||||
throw supersededError;
|
||||
}
|
||||
const env = applyLocaleDefaults({
|
||||
...buildLocalSessionEnv(payload),
|
||||
...sessionEnv,
|
||||
TERM: "xterm-256color",
|
||||
COLORTERM: "truecolor",
|
||||
});
|
||||
|
||||
if (event.sender.isDestroyed?.()) {
|
||||
throw new Error("Local terminal window closed before startup");
|
||||
}
|
||||
|
||||
// Determine the starting directory
|
||||
// Default to home directory if not specified or if specified path is invalid
|
||||
const defaultCwd = os.homedir();
|
||||
|
||||
@@ -37,8 +37,8 @@ const STALE_PATH =
|
||||
const LIVE_PATH =
|
||||
"C:\\Users\\me\\.herdr\\packages\\standalone\\releases\\0.9.1-x86_64-pc-windows-msvc;C:\\Windows\\System32";
|
||||
|
||||
test("buildLocalSessionEnv keeps the captured PATH untouched off Windows", () => {
|
||||
const env = bridge.buildLocalSessionEnv(
|
||||
test("buildLocalSessionEnv keeps the captured PATH untouched off Windows", async () => {
|
||||
const env = await bridge.buildLocalSessionEnv(
|
||||
{ env: { NETCATTY_MARKER: "1" } },
|
||||
{
|
||||
baseEnv: { PATH: "/usr/bin:/bin", HOME: "/home/me" },
|
||||
@@ -51,14 +51,14 @@ test("buildLocalSessionEnv keeps the captured PATH untouched off Windows", () =>
|
||||
assert.equal(env.NETCATTY_MARKER, "1");
|
||||
});
|
||||
|
||||
test("buildLocalSessionEnv refreshes PATH from the registry on Windows", () => {
|
||||
test("buildLocalSessionEnv refreshes PATH from the registry on Windows", async () => {
|
||||
const calls = [];
|
||||
const env = bridge.buildLocalSessionEnv(
|
||||
const env = await bridge.buildLocalSessionEnv(
|
||||
{},
|
||||
{
|
||||
baseEnv: { Path: STALE_PATH, SystemRoot: "C:\\Windows" },
|
||||
platform: "win32",
|
||||
refreshWindowsPath: (options) => {
|
||||
refreshWindowsPath: async (options) => {
|
||||
calls.push(options);
|
||||
return LIVE_PATH;
|
||||
},
|
||||
@@ -73,8 +73,8 @@ test("buildLocalSessionEnv refreshes PATH from the registry on Windows", () => {
|
||||
assert.equal(env.SystemRoot, "C:\\Windows");
|
||||
});
|
||||
|
||||
test("buildLocalSessionEnv collapses a duplicate PATH sitting next to Path", () => {
|
||||
const env = bridge.buildLocalSessionEnv(
|
||||
test("buildLocalSessionEnv collapses a duplicate PATH sitting next to Path", async () => {
|
||||
const env = await bridge.buildLocalSessionEnv(
|
||||
{},
|
||||
{
|
||||
baseEnv: { Path: STALE_PATH, PATH: STALE_PATH },
|
||||
@@ -91,9 +91,9 @@ test("buildLocalSessionEnv collapses a duplicate PATH sitting next to Path", ()
|
||||
);
|
||||
});
|
||||
|
||||
test("buildLocalSessionEnv never widens an explicit PATH override", () => {
|
||||
test("buildLocalSessionEnv never widens an explicit PATH override", async () => {
|
||||
let refreshed = false;
|
||||
const env = bridge.buildLocalSessionEnv(
|
||||
const env = await bridge.buildLocalSessionEnv(
|
||||
{ env: { PATH: "C:\\pinned\\bin" } },
|
||||
{
|
||||
baseEnv: { Path: STALE_PATH },
|
||||
@@ -109,3 +109,18 @@ test("buildLocalSessionEnv never widens an explicit PATH override", () => {
|
||||
assert.equal(env.PATH, "C:\\pinned\\bin");
|
||||
assert.equal(env.Path, undefined);
|
||||
});
|
||||
|
||||
for (const pathKey of ["Path", "PATH", "path"]) {
|
||||
test(`an explicit empty ${pathKey} override stays empty without a registry query`, async () => {
|
||||
const env = await bridge.buildLocalSessionEnv(
|
||||
{ env: { [pathKey]: "" } },
|
||||
{
|
||||
baseEnv: { Path: STALE_PATH, PATH: STALE_PATH },
|
||||
platform: "win32",
|
||||
refreshWindowsPath: () => { throw new Error("must not refresh an explicit override"); },
|
||||
},
|
||||
);
|
||||
assert.equal(env[pathKey], "");
|
||||
assert.deepEqual(Object.keys(env).filter((key) => key.toLowerCase() === "path"), [pathKey]);
|
||||
});
|
||||
}
|
||||
@@ -160,7 +160,7 @@ test("WSL default directory stays outside the Linux command and option values",
|
||||
}
|
||||
});
|
||||
|
||||
test("WSL local sessions pass the home directory option to the spawned process", () => {
|
||||
test("WSL local sessions pass the home directory option to the spawned process", async () => {
|
||||
const spawns = [];
|
||||
const bridge = loadBridgeWithFakes(spawns, []);
|
||||
const platformDescriptor = Object.getOwnPropertyDescriptor(process, "platform");
|
||||
@@ -172,10 +172,10 @@ test("WSL local sessions pass the home directory option to the spawned process",
|
||||
});
|
||||
const shellArgs = ["-d", "Ubuntu", "--", "zsh", "-l"];
|
||||
const payload = { shell: "C:\\Windows\\System32\\wsl.exe", shellArgs };
|
||||
bridge.startLocalSession({ sender: { id: 7 } }, { ...payload, sessionId: "wsl-home" });
|
||||
await bridge.startLocalSession({ sender: { id: 7 } }, { ...payload, sessionId: "wsl-home" });
|
||||
assert.deepEqual(spawns[0].spawnArgs[1], ["-d", "Ubuntu", "--cd", "~", "--", "zsh", "-l"]);
|
||||
assert.deepEqual(shellArgs, ["-d", "Ubuntu", "--", "zsh", "-l"]);
|
||||
bridge.startLocalSession(
|
||||
await bridge.startLocalSession(
|
||||
{ sender: { id: 7 } },
|
||||
{ ...payload, sessionId: "wsl-explicit-cwd", cwd: process.cwd() },
|
||||
);
|
||||
@@ -186,7 +186,7 @@ test("WSL local sessions pass the home directory option to the spawned process",
|
||||
}
|
||||
});
|
||||
|
||||
test("Windows local terminals enable the bundled ConPTY implementation required for clear", () => {
|
||||
test("Windows local terminals enable the bundled ConPTY implementation required for clear", async () => {
|
||||
const spawns = [];
|
||||
const sentries = [];
|
||||
const sessions = new Map();
|
||||
@@ -203,7 +203,7 @@ test("Windows local terminals enable the bundled ConPTY implementation required
|
||||
},
|
||||
},
|
||||
});
|
||||
bridge.startLocalSession(
|
||||
await bridge.startLocalSession(
|
||||
{ sender: { id: 7 } },
|
||||
{ sessionId: "windows-clear", shell: "C:\\Windows\\System32\\WindowsPowerShell\\v1.0\\powershell.exe" },
|
||||
);
|
||||
@@ -215,7 +215,7 @@ test("Windows local terminals enable the bundled ConPTY implementation required
|
||||
}
|
||||
});
|
||||
|
||||
test("local terminal buffers incoming flood while renderer flow is paused", () => {
|
||||
test("local terminal buffers incoming flood while renderer flow is paused", async () => {
|
||||
const spawns = [];
|
||||
const sentries = [];
|
||||
const sent = [];
|
||||
@@ -233,7 +233,7 @@ test("local terminal buffers incoming flood while renderer flow is paused", () =
|
||||
},
|
||||
});
|
||||
|
||||
bridge.startLocalSession(
|
||||
await bridge.startLocalSession(
|
||||
{ sender: { id: 7 } },
|
||||
{ sessionId: "local-flood", shell: "/bin/sh", cols: 80, rows: 24 },
|
||||
);
|
||||
@@ -258,7 +258,7 @@ test("local terminal buffers incoming flood while renderer flow is paused", () =
|
||||
assert.equal(sentries[0].consumeCalls.length, 2);
|
||||
});
|
||||
|
||||
test("local terminal keeps source paused while paced backlog absorbs fresh flood", () => {
|
||||
test("local terminal keeps source paused while paced backlog absorbs fresh flood", async () => {
|
||||
const spawns = [];
|
||||
const sentries = [];
|
||||
const sent = [];
|
||||
@@ -276,7 +276,7 @@ test("local terminal keeps source paused while paced backlog absorbs fresh flood
|
||||
},
|
||||
});
|
||||
|
||||
bridge.startLocalSession(
|
||||
await bridge.startLocalSession(
|
||||
{ sender: { id: 7 } },
|
||||
{ sessionId: "local-flood-paced-fresh", shell: "/bin/sh", cols: 80, rows: 24 },
|
||||
);
|
||||
@@ -307,7 +307,7 @@ test("local terminal keeps source paused while paced backlog absorbs fresh flood
|
||||
assert.ok(session.flowState.bufferedBytes >= FLOW_HIGH_WATER_MARK);
|
||||
});
|
||||
|
||||
test("closing a local terminal discards buffered output instead of flushing it", () => {
|
||||
test("closing a local terminal discards buffered output instead of flushing it", async () => {
|
||||
const spawns = [];
|
||||
const sentries = [];
|
||||
const sent = [];
|
||||
@@ -325,7 +325,7 @@ test("closing a local terminal discards buffered output instead of flushing it",
|
||||
},
|
||||
});
|
||||
|
||||
bridge.startLocalSession(
|
||||
await bridge.startLocalSession(
|
||||
{ sender: { id: 7 } },
|
||||
{ sessionId: "local-flood-close", shell: "/bin/sh", cols: 80, rows: 24 },
|
||||
);
|
||||
@@ -339,7 +339,7 @@ test("closing a local terminal discards buffered output instead of flushing it",
|
||||
}]);
|
||||
});
|
||||
|
||||
test("app cleanup discards buffered output instead of flushing it", () => {
|
||||
test("app cleanup discards buffered output instead of flushing it", async () => {
|
||||
const spawns = [];
|
||||
const sentries = [];
|
||||
const sent = [];
|
||||
@@ -357,7 +357,7 @@ test("app cleanup discards buffered output instead of flushing it", () => {
|
||||
},
|
||||
});
|
||||
|
||||
bridge.startLocalSession(
|
||||
await bridge.startLocalSession(
|
||||
{ sender: { id: 7 } },
|
||||
{ sessionId: "local-flood-cleanup", shell: "/bin/sh", cols: 80, rows: 24 },
|
||||
);
|
||||
@@ -387,7 +387,7 @@ test("local terminal exit waits for paced buffered output drain", async () => {
|
||||
},
|
||||
});
|
||||
|
||||
bridge.startLocalSession(
|
||||
await bridge.startLocalSession(
|
||||
{ sender: { id: 7 } },
|
||||
{ sessionId: "local-flood-exit", shell: "/bin/sh", cols: 80, rows: 24 },
|
||||
);
|
||||
@@ -414,7 +414,7 @@ test("local terminal exit waits for paced buffered output drain", async () => {
|
||||
assert.equal(sessions.has("local-flood-exit"), false);
|
||||
});
|
||||
|
||||
test("local terminal exit completes while renderer flow is paused", () => {
|
||||
test("local terminal exit completes while renderer flow is paused", async () => {
|
||||
const spawns = [];
|
||||
const sentries = [];
|
||||
const sent = [];
|
||||
@@ -432,7 +432,7 @@ test("local terminal exit completes while renderer flow is paused", () => {
|
||||
},
|
||||
});
|
||||
|
||||
bridge.startLocalSession(
|
||||
await bridge.startLocalSession(
|
||||
{ sender: { id: 7 } },
|
||||
{ sessionId: "local-paused-exit", shell: "/bin/sh", cols: 80, rows: 24 },
|
||||
);
|
||||
@@ -448,3 +448,181 @@ test("local terminal exit completes while renderer flow is paused", () => {
|
||||
assert.equal(sent.some((item) => item.channel === "netcatty:exit"), true);
|
||||
assert.equal(sessions.has("local-paused-exit"), false);
|
||||
});
|
||||
|
||||
test("a window closed while preparing the local environment does not spawn a PTY", async () => {
|
||||
const spawns = [];
|
||||
const bridge = loadBridgeWithFakes(spawns, []);
|
||||
let destroyed = false;
|
||||
const pending = bridge.startLocalSession(
|
||||
{ sender: { id: 7, isDestroyed: () => destroyed } },
|
||||
{ sessionId: "closed-before-spawn", shell: "/bin/sh" },
|
||||
);
|
||||
destroyed = true;
|
||||
await assert.rejects(pending, /window closed before startup/);
|
||||
assert.equal(spawns.length, 0);
|
||||
});
|
||||
|
||||
async function withPendingLocalPath(run) {
|
||||
const shellUtils = require("./ai/shellUtils.cjs");
|
||||
const originalResolve = shellUtils.resolveWindowsLivePath;
|
||||
const platformDescriptor = Object.getOwnPropertyDescriptor(process, "platform");
|
||||
const resolvers = [];
|
||||
const spawns = [];
|
||||
const sessions = new Map();
|
||||
const bridge = loadBridgeWithFakes(spawns, []);
|
||||
bridge.init({ sessions, electronModule: { webContents: { fromId: () => null } } });
|
||||
try {
|
||||
Object.defineProperty(process, "platform", { configurable: true, value: "win32" });
|
||||
shellUtils.resolveWindowsLivePath = () => new Promise((resolve) => resolvers.push(resolve));
|
||||
await run({ bridge, resolvers, spawns, sessions });
|
||||
} finally {
|
||||
shellUtils.resolveWindowsLivePath = originalResolve;
|
||||
Object.defineProperty(process, "platform", platformDescriptor);
|
||||
}
|
||||
}
|
||||
|
||||
test("closing a local tab during PATH refresh prevents the pending PTY spawn", async () => {
|
||||
await withPendingLocalPath(async ({ bridge, resolvers, spawns, sessions }) => {
|
||||
const event = { sender: { id: 7, isDestroyed: () => false } };
|
||||
const payload = { sessionId: "closed-pending-path", bootEpoch: 1, shell: "C:\\Windows\\cmd.exe" };
|
||||
const pending = bridge.startLocalSession(event, payload);
|
||||
const rejected = assert.rejects(pending, { code: "NETCATTY_BOOT_SUPERSEDED" });
|
||||
assert.equal(resolvers.length, 1);
|
||||
bridge.closeSession(event, payload);
|
||||
resolvers[0]("C:\\Windows\\System32");
|
||||
await rejected;
|
||||
assert.equal(spawns.length, 0);
|
||||
assert.equal(sessions.has(payload.sessionId), false);
|
||||
});
|
||||
});
|
||||
|
||||
test("a stale close and old PATH completion cannot cancel a newer local boot", async () => {
|
||||
await withPendingLocalPath(async ({ bridge, resolvers, spawns, sessions }) => {
|
||||
const event = { sender: { id: 7, isDestroyed: () => false } };
|
||||
const payload = { sessionId: "restarted-pending-path", shell: "C:\\Windows\\cmd.exe" };
|
||||
const older = bridge.startLocalSession(event, { ...payload, bootEpoch: 1 });
|
||||
const rejected = assert.rejects(older, { code: "NETCATTY_BOOT_SUPERSEDED" });
|
||||
const newer = bridge.startLocalSession(event, { ...payload, bootEpoch: 2 });
|
||||
assert.equal(resolvers.length, 2);
|
||||
assert.deepEqual(bridge.closeSession(event, { ...payload, bootEpoch: 1 }), {
|
||||
skipped: true, reason: "boot-epoch-mismatch",
|
||||
});
|
||||
resolvers[0]("C:\\old");
|
||||
await rejected;
|
||||
const { hasPendingBootAfter } = require("./sessionBootEpoch.cjs");
|
||||
assert.equal(hasPendingBootAfter(payload.sessionId, 1), true);
|
||||
resolvers[1]("C:\\new");
|
||||
await newer;
|
||||
assert.equal(spawns.length, 1);
|
||||
assert.equal(sessions.get(payload.sessionId)?.bootEpoch, 2);
|
||||
bridge.closeSession(event, { ...payload, bootEpoch: 1 });
|
||||
assert.equal(sessions.get(payload.sessionId)?.bootEpoch, 2);
|
||||
bridge.closeSession(event, { ...payload, bootEpoch: 2 });
|
||||
});
|
||||
});
|
||||
|
||||
for (const closeChannel of ["netcatty:close", "netcatty:close:await"]) {
|
||||
test(`worker ${closeChannel} cancels a local start before PATH refresh can spawn it`, async () => {
|
||||
await withPendingLocalPath(async ({ bridge, resolvers, spawns, sessions }) => {
|
||||
const { createTerminalWorkerRuntime } = require("../terminalWorker/runtime.cjs");
|
||||
const messages = [];
|
||||
let receiveMessage;
|
||||
const parentPort = {
|
||||
on: (_channel, listener) => { receiveMessage = listener; },
|
||||
postMessage: (message) => messages.push(message),
|
||||
};
|
||||
createTerminalWorkerRuntime({
|
||||
parentPort,
|
||||
registerBridges: (ipcMain) => bridge.registerHandlers(ipcMain),
|
||||
}).start();
|
||||
const payload = { sessionId: `worker-pending-${closeChannel}`, shell: "C:\\Windows\\cmd.exe" };
|
||||
const start = (bootEpoch) => receiveMessage({
|
||||
kind: "request", requestId: `start-${bootEpoch}`, channel: "netcatty:local:start",
|
||||
webContentsId: 7, payload: { ...payload, bootEpoch },
|
||||
});
|
||||
const close = (bootEpoch) => receiveMessage({
|
||||
kind: closeChannel.endsWith(":await") ? "request" : "send",
|
||||
requestId: `close-${bootEpoch}`, channel: closeChannel,
|
||||
webContentsId: 7, payload: { ...payload, bootEpoch },
|
||||
});
|
||||
const drainMessages = () => new Promise((resolve) => setImmediate(resolve));
|
||||
start(1);
|
||||
await drainMessages();
|
||||
assert.equal(resolvers.length, 1, "the real bridge is waiting for PATH inside the worker queue");
|
||||
close(1);
|
||||
resolvers[0]("C:\\Windows\\System32");
|
||||
await drainMessages();
|
||||
await drainMessages();
|
||||
assert.equal(spawns.length, 0, "a closed tab must not run shell startup scripts");
|
||||
assert.equal(sessions.has(payload.sessionId), false);
|
||||
assert.match(messages.find((message) => message.requestId === "start-1")?.error ?? "", /closed or superseded/);
|
||||
if (closeChannel.endsWith(":await")) {
|
||||
assert.equal(messages.find((message) => message.requestId === "close-1")?.result?.closed, false);
|
||||
}
|
||||
|
||||
start(2);
|
||||
await drainMessages();
|
||||
assert.equal(resolvers.length, 2);
|
||||
close(1); // A delayed old close must not abort the replacement's PATH wait.
|
||||
resolvers[1]("C:\\Windows\\System32");
|
||||
await drainMessages();
|
||||
await drainMessages();
|
||||
assert.equal(spawns.length, 1);
|
||||
assert.equal(sessions.get(payload.sessionId)?.bootEpoch, 2);
|
||||
close(2);
|
||||
await drainMessages();
|
||||
await drainMessages();
|
||||
assert.equal(sessions.has(payload.sessionId), false);
|
||||
});
|
||||
});
|
||||
}
|
||||
|
||||
test("worker replacements abort pending and skip superseded queued local starts", async () => {
|
||||
await withPendingLocalPath(async ({ bridge, resolvers, spawns, sessions }) => {
|
||||
const { createTerminalWorkerRuntime } = require("../terminalWorker/runtime.cjs");
|
||||
const messages = [];
|
||||
let receiveMessage;
|
||||
createTerminalWorkerRuntime({
|
||||
parentPort: {
|
||||
on: (_channel, listener) => { receiveMessage = listener; },
|
||||
postMessage: (message) => messages.push(message),
|
||||
},
|
||||
registerBridges: (ipcMain) => bridge.registerHandlers(ipcMain),
|
||||
}).start();
|
||||
const payload = { sessionId: "worker-replaced-path", shell: "C:\\Windows\\cmd.exe" };
|
||||
const start = (bootEpoch, requestId) => receiveMessage({
|
||||
kind: "request", requestId, channel: "netcatty:local:start",
|
||||
webContentsId: 7, payload: { ...payload, bootEpoch },
|
||||
});
|
||||
const drainMessages = () => new Promise((resolve) => setImmediate(resolve));
|
||||
start(1, "old-start");
|
||||
await drainMessages();
|
||||
start(2, "queued-replacement");
|
||||
start(3, "replacement");
|
||||
resolvers[0]("C:\\Windows\\System32");
|
||||
await drainMessages();
|
||||
await drainMessages();
|
||||
assert.equal(spawns.length, 0, "a superseded PATH wait must not run shell startup scripts");
|
||||
assert.equal(resolvers.length, 2, "the replacement now owns the startup queue");
|
||||
assert.match(messages.find((message) => message.requestId === "old-start")?.error ?? "", /closed or superseded/);
|
||||
assert.match(messages.find((message) => message.requestId === "queued-replacement")?.error ?? "", /superseded/);
|
||||
start(2, "stale-start");
|
||||
receiveMessage({
|
||||
kind: "send", channel: "netcatty:close", webContentsId: 7,
|
||||
payload: { ...payload, bootEpoch: 2 },
|
||||
});
|
||||
resolvers[1]("C:\\Windows\\System32");
|
||||
await drainMessages();
|
||||
await drainMessages();
|
||||
assert.equal(spawns.length, 1);
|
||||
assert.equal(resolvers.length, 2, "a stale start must not even prepare a new environment");
|
||||
assert.equal(sessions.get(payload.sessionId)?.bootEpoch, 3);
|
||||
assert.match(messages.find((message) => message.requestId === "stale-start")?.error ?? "", /superseded/);
|
||||
receiveMessage({
|
||||
kind: "send", channel: "netcatty:close", webContentsId: 7,
|
||||
payload: { ...payload, bootEpoch: 3 },
|
||||
});
|
||||
await drainMessages();
|
||||
await drainMessages();
|
||||
});
|
||||
});
|
||||
@@ -96,6 +96,7 @@ function evaluatePermissionWithGrants(ctx, grants = []) {
|
||||
capabilityId: base.capability.id,
|
||||
chatSessionId: params.chatSessionId,
|
||||
sessionId: params.sessionId,
|
||||
hostId: ctx?.context?.hostId,
|
||||
args: params,
|
||||
});
|
||||
|
||||
|
||||
@@ -236,6 +236,27 @@ test("evaluatePermissionWithGrants skips approval when a grant matches", () => {
|
||||
assert.equal(decision.requiresApproval, false);
|
||||
});
|
||||
|
||||
test("a server grant approves only that server's terminal and SFTP writes", () => {
|
||||
const grants = [{
|
||||
id: "test-server",
|
||||
capabilityId: "*",
|
||||
sessionPattern: "host:host-test",
|
||||
createdAt: Date.now(),
|
||||
}];
|
||||
const decide = (rpcMethod, hostId, sessionId = "ssh-1") => evaluatePermissionWithGrants({
|
||||
rpcMethod,
|
||||
permissionMode: PERMISSION_MODES.CONFIRM,
|
||||
params: { chatSessionId: "chat-1", sessionId, command: "rm -rf /tmp/build", path: "/tmp/build" },
|
||||
context: { hostId },
|
||||
}, grants);
|
||||
|
||||
assert.equal(decide("netcatty/exec", "host-test").requiresApproval, false);
|
||||
assert.equal(decide("netcatty/sftp/delete", "host-test").requiresApproval, false);
|
||||
assert.equal(decide("netcatty/exec", "host-prod").requiresApproval, true);
|
||||
assert.equal(decide("netcatty/exec", undefined).requiresApproval, true);
|
||||
assert.equal(decide("netcatty/exec", "host-test", "").requiresApproval, true);
|
||||
});
|
||||
|
||||
test("evaluatePermissionWithGrants does not let a comment grant approve a multiline command", () => {
|
||||
const decision = evaluatePermissionWithGrants({
|
||||
rpcMethod: "netcatty/exec",
|
||||
|
||||
+25
-8
@@ -68,7 +68,7 @@ function collectJmsDeepLinkUrls(argv) {
|
||||
return collectDeepLinkUrls(argv, JMS_PROTOCOL);
|
||||
}
|
||||
|
||||
function collectPuttyStyleDeepLinkUrls(argv) {
|
||||
function collectPuttyStyleDeepLinkIntents(argv) {
|
||||
if (
|
||||
collectSshDeepLinkUrls(argv).length > 0
|
||||
|| collectTelnetDeepLinkUrls(argv).length > 0
|
||||
@@ -84,10 +84,19 @@ function collectPuttyStyleDeepLinkUrls(argv) {
|
||||
const secureCrt = parseSecureCrtCommandLineTokens(argv);
|
||||
const parsed = secureCrt ? secureCrt.result : parsePuttyCommandLine(argv);
|
||||
if (!parsed?.url) return { ssh: [], telnet: [] };
|
||||
const intent = { rawUrl: parsed.url, tabName: parsed.tabName };
|
||||
if (parsed.protocol === TELNET_PROTOCOL) {
|
||||
return { ssh: [], telnet: [parsed.url] };
|
||||
return { ssh: [], telnet: [intent] };
|
||||
}
|
||||
return { ssh: [parsed.url], telnet: [] };
|
||||
return { ssh: [intent], telnet: [] };
|
||||
}
|
||||
|
||||
function collectPuttyStyleDeepLinkUrls(argv) {
|
||||
const intents = collectPuttyStyleDeepLinkIntents(argv);
|
||||
return {
|
||||
ssh: intents.ssh.map((intent) => intent.rawUrl),
|
||||
telnet: intents.telnet.map((intent) => intent.rawUrl),
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -98,7 +107,7 @@ function collectPuttyStyleDeepLinkUrls(argv) {
|
||||
* gated by includeSchemeUrls through the scheme-URL part of the queue.
|
||||
*/
|
||||
function collectSshDeepLinkQueueItems(argv, { includeSchemeUrls = true } = {}) {
|
||||
const puttyStyleDeepLinks = collectPuttyStyleDeepLinkUrls(argv);
|
||||
const puttyStyleDeepLinks = collectPuttyStyleDeepLinkIntents(argv);
|
||||
const queueItems = {
|
||||
ssh: [],
|
||||
telnet: [],
|
||||
@@ -111,11 +120,19 @@ function collectSshDeepLinkQueueItems(argv, { includeSchemeUrls = true } = {}) {
|
||||
queueItems.telnet.push({ rawUrl, viaCommandLine: false });
|
||||
});
|
||||
}
|
||||
puttyStyleDeepLinks.ssh.forEach((rawUrl) => {
|
||||
queueItems.ssh.push({ rawUrl, viaCommandLine: true });
|
||||
puttyStyleDeepLinks.ssh.forEach((intent) => {
|
||||
queueItems.ssh.push({
|
||||
rawUrl: intent.rawUrl,
|
||||
viaCommandLine: true,
|
||||
...(intent.tabName ? { tabName: intent.tabName } : {}),
|
||||
});
|
||||
});
|
||||
puttyStyleDeepLinks.telnet.forEach((rawUrl) => {
|
||||
queueItems.telnet.push({ rawUrl, viaCommandLine: true });
|
||||
puttyStyleDeepLinks.telnet.forEach((intent) => {
|
||||
queueItems.telnet.push({
|
||||
rawUrl: intent.rawUrl,
|
||||
viaCommandLine: true,
|
||||
...(intent.tabName ? { tabName: intent.tabName } : {}),
|
||||
});
|
||||
});
|
||||
return queueItems;
|
||||
}
|
||||
|
||||
@@ -75,6 +75,52 @@ test("collectPuttyStyleDeepLinkUrls leaves ssh:// tokens to the existing collect
|
||||
);
|
||||
});
|
||||
|
||||
test("collectSshDeepLinkQueueItems preserves tolerated PuTTY-style -newtab names", () => {
|
||||
assert.deepEqual(
|
||||
collectSshDeepLinkQueueItems([
|
||||
"Netcatty.exe",
|
||||
"-newtab",
|
||||
"Production",
|
||||
"-ssh",
|
||||
"root@192.168.1.122",
|
||||
"-P",
|
||||
"22",
|
||||
], { includeSchemeUrls: false }),
|
||||
{
|
||||
ssh: [{
|
||||
rawUrl: "ssh://root@192.168.1.122:22",
|
||||
viaCommandLine: true,
|
||||
tabName: "Production",
|
||||
}],
|
||||
telnet: [],
|
||||
},
|
||||
);
|
||||
});
|
||||
|
||||
test("collectSshDeepLinkQueueItems preserves SecureCRT /N names", () => {
|
||||
assert.deepEqual(
|
||||
collectSshDeepLinkQueueItems([
|
||||
"Netcatty.exe",
|
||||
"/SSH2",
|
||||
"/L",
|
||||
"root",
|
||||
"/P",
|
||||
"22",
|
||||
"/N",
|
||||
"Production",
|
||||
"192.168.1.122",
|
||||
], { includeSchemeUrls: false }),
|
||||
{
|
||||
ssh: [{
|
||||
rawUrl: "ssh://root@192.168.1.122:22",
|
||||
viaCommandLine: true,
|
||||
tabName: "Production",
|
||||
}],
|
||||
telnet: [],
|
||||
},
|
||||
);
|
||||
});
|
||||
|
||||
test("collectSshDeepLinkQueueItems keeps PuTTY CLI launches when scheme URLs are disabled", () => {
|
||||
// Warm second-instance case: the running app forwarded another launch's
|
||||
// argv while the ssh:// protocol-client preference is disabled.
|
||||
|
||||
+12
-10
@@ -723,19 +723,19 @@ let jmsDeepLinkDeliveryGeneration = 0;
|
||||
|
||||
let explorerContextMenuEnabled = resolveExplorerContextMenuEnabled({ app }).enabled === true;
|
||||
|
||||
function queueSshDeepLink(rawUrl, { viaCommandLine = false } = {}) {
|
||||
function queueSshDeepLink(rawUrl, { viaCommandLine = false, tabName } = {}) {
|
||||
if (!viaCommandLine && !sshDeepLinkEnabled) return;
|
||||
if (!isSshDeepLinkUrl(rawUrl)) return;
|
||||
pendingSshDeepLinkUrls.push({ rawUrl, viaCommandLine });
|
||||
pendingSshDeepLinkUrls.push({ rawUrl, viaCommandLine, ...(tabName ? { tabName } : {}) });
|
||||
if (app.isReady?.()) {
|
||||
void flushPendingSshDeepLinks();
|
||||
}
|
||||
}
|
||||
|
||||
function queueTelnetDeepLink(rawUrl, { viaCommandLine = false } = {}) {
|
||||
function queueTelnetDeepLink(rawUrl, { viaCommandLine = false, tabName } = {}) {
|
||||
if (!viaCommandLine && !sshDeepLinkEnabled) return;
|
||||
if (!isTelnetDeepLinkUrl(rawUrl)) return;
|
||||
pendingTelnetDeepLinkUrls.push({ rawUrl, viaCommandLine });
|
||||
pendingTelnetDeepLinkUrls.push({ rawUrl, viaCommandLine, ...(tabName ? { tabName } : {}) });
|
||||
if (app.isReady?.()) {
|
||||
void flushPendingTelnetDeepLinks();
|
||||
}
|
||||
@@ -935,7 +935,7 @@ async function flushPendingJmsDeepLinks() {
|
||||
}
|
||||
}
|
||||
|
||||
async function deliverSshDeepLink(rawUrl, expectedGeneration = sshSchemeDeliveryGeneration, { viaCommandLine = false } = {}) {
|
||||
async function deliverSshDeepLink(rawUrl, expectedGeneration = sshSchemeDeliveryGeneration, { viaCommandLine = false, tabName } = {}) {
|
||||
// The ssh:// preference can flip while a delivery is waiting for the
|
||||
// renderer, so re-check it at every gate instead of reusing the queue-time
|
||||
// snapshot. Command-line (PuTTY-style) launches bypass the preference.
|
||||
@@ -958,7 +958,7 @@ async function deliverSshDeepLink(rawUrl, expectedGeneration = sshSchemeDelivery
|
||||
const result = await windowManager.sendWhenRendererReady?.(
|
||||
win,
|
||||
SSH_DEEP_LINK_CHANNEL,
|
||||
{ url: rawUrl },
|
||||
{ url: rawUrl, ...(tabName ? { tabName } : {}) },
|
||||
{
|
||||
timeoutMs: getSshDeepLinkRendererReadyTimeoutMs({ isDev }),
|
||||
shouldSend: shouldDeliver,
|
||||
@@ -971,7 +971,7 @@ async function deliverSshDeepLink(rawUrl, expectedGeneration = sshSchemeDelivery
|
||||
return result || { success: true };
|
||||
}
|
||||
|
||||
async function deliverTelnetDeepLink(rawUrl, expectedGeneration = sshSchemeDeliveryGeneration, { viaCommandLine = false } = {}) {
|
||||
async function deliverTelnetDeepLink(rawUrl, expectedGeneration = sshSchemeDeliveryGeneration, { viaCommandLine = false, tabName } = {}) {
|
||||
// Mirror deliverSshDeepLink: gate on the live preference so disabling
|
||||
// protocol handling cancels in-flight scheme deliveries, while
|
||||
// command-line launches stay deliverable.
|
||||
@@ -988,7 +988,7 @@ async function deliverTelnetDeepLink(rawUrl, expectedGeneration = sshSchemeDeliv
|
||||
const result = await windowManager.sendWhenRendererReady?.(
|
||||
win,
|
||||
TELNET_DEEP_LINK_CHANNEL,
|
||||
{ url: rawUrl },
|
||||
{ url: rawUrl, ...(tabName ? { tabName } : {}) },
|
||||
{
|
||||
timeoutMs: 0,
|
||||
shouldSend: shouldDeliver,
|
||||
@@ -1015,6 +1015,7 @@ async function flushPendingSshDeepLinks() {
|
||||
const expectedGeneration = sshSchemeDeliveryGeneration;
|
||||
const result = await deliverSshDeepLink(item.rawUrl, expectedGeneration, {
|
||||
viaCommandLine: item.viaCommandLine === true,
|
||||
tabName: item.tabName,
|
||||
});
|
||||
if (shouldRequeueFailedSshDeepLinkDelivery({
|
||||
enabled: item.viaCommandLine === true || sshDeepLinkEnabled,
|
||||
@@ -1060,6 +1061,7 @@ async function flushPendingTelnetDeepLinks() {
|
||||
const expectedGeneration = sshSchemeDeliveryGeneration;
|
||||
const result = await deliverTelnetDeepLink(item.rawUrl, expectedGeneration, {
|
||||
viaCommandLine: item.viaCommandLine === true,
|
||||
tabName: item.tabName,
|
||||
});
|
||||
if (shouldRequeueFailedSshDeepLinkDelivery({
|
||||
enabled: item.viaCommandLine === true || sshDeepLinkEnabled,
|
||||
@@ -1263,13 +1265,13 @@ if (!gotLock) {
|
||||
}
|
||||
if (deepLinkQueueItems.telnet.length > 0) {
|
||||
deepLinkQueueItems.telnet.forEach((item) => {
|
||||
queueTelnetDeepLink(item.rawUrl, { viaCommandLine: item.viaCommandLine });
|
||||
queueTelnetDeepLink(item.rawUrl, { viaCommandLine: item.viaCommandLine, tabName: item.tabName });
|
||||
});
|
||||
return;
|
||||
}
|
||||
if (deepLinkQueueItems.ssh.length > 0) {
|
||||
deepLinkQueueItems.ssh.forEach((item) => {
|
||||
queueSshDeepLink(item.rawUrl, { viaCommandLine: item.viaCommandLine });
|
||||
queueSshDeepLink(item.rawUrl, { viaCommandLine: item.viaCommandLine, tabName: item.tabName });
|
||||
});
|
||||
return;
|
||||
}
|
||||
|
||||
@@ -1557,6 +1557,10 @@ function createPreloadApi(ctx) {
|
||||
ipcRenderer.invoke("netcatty:tray:setCloseToTray", { enabled }),
|
||||
isCloseToTray: () =>
|
||||
ipcRenderer.invoke("netcatty:tray:isCloseToTray"),
|
||||
setShowTrayIcon: (enabled) =>
|
||||
ipcRenderer.invoke("netcatty:tray:setShowTrayIcon", { enabled }),
|
||||
isShowTrayIcon: () =>
|
||||
ipcRenderer.invoke("netcatty:tray:isShowTrayIcon"),
|
||||
|
||||
// Auto Launch at system login (hidden to tray)
|
||||
getAutoLaunch: () =>
|
||||
@@ -1887,8 +1891,8 @@ function createPreloadApi(ctx) {
|
||||
ipcRenderer.on("netcatty:ai:mcp:approval-request", handler);
|
||||
return () => ipcRenderer.removeListener("netcatty:ai:mcp:approval-request", handler);
|
||||
},
|
||||
respondMcpApproval: async (approvalId, approved) => {
|
||||
return ipcRenderer.invoke("netcatty:ai:mcp:approval-response", { approvalId, approved });
|
||||
respondMcpApproval: async (approvalId, approved, scope) => {
|
||||
return ipcRenderer.invoke("netcatty:ai:mcp:approval-response", { approvalId, approved, scope });
|
||||
},
|
||||
cancelMcpApprovalTimeout: async (approvalId) => {
|
||||
return ipcRenderer.invoke("netcatty:ai:mcp:approval-cancel-timeout", { approvalId });
|
||||
|
||||
Loaded 100 of 116 files, more files were not shown because too many files have changed in this diff.
Show more
Reference in new issue
Block a user