mirror of
https://github.com/binaricat/Netcatty.git
synced 2026-11-10 16:21:22 +00:00
Compare commits
19
Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
be44a44f52 | ||
|
|
19eae1aac4 | ||
|
|
6d9499902e | ||
|
|
f7d6f70f09 | ||
|
|
fd1dab65ea | ||
|
|
641cb1814f | ||
|
|
f98f44ccf5 | ||
|
|
ed86efa528 | ||
|
|
e95f3244df | ||
|
|
82bf1fb037 | ||
|
|
d28e701d74 | ||
|
|
9d1caa1578 | ||
|
|
bbaba9085b | ||
|
|
bcfa7d3f41 | ||
|
|
c243b2bb29 | ||
|
|
630b206e76 | ||
|
|
522fe5e2f3 | ||
|
|
598ea265dc | ||
|
|
16ae591243 |
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
|
||||
@@ -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',
|
||||
|
||||
@@ -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',
|
||||
|
||||
@@ -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': 'Загрузить изображение из буфера',
|
||||
|
||||
@@ -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': '添加到对话',
|
||||
|
||||
@@ -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': '新增到對話',
|
||||
|
||||
@@ -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 };
|
||||
};
|
||||
+66
-9
@@ -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";
|
||||
@@ -836,6 +838,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 +1077,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.
|
||||
@@ -3277,15 +3297,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 +3330,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 +3388,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);
|
||||
@@ -3428,6 +3482,7 @@ const TerminalComponent: React.FC<TerminalProps> = ({
|
||||
isBroadcastEnabledRef,
|
||||
onBroadcastInputRef,
|
||||
passwordPromptActiveRef,
|
||||
broadcastPasswordBypassRef,
|
||||
isLocalConnection,
|
||||
supportsRemoteImagePaste,
|
||||
autoUploadClipboardImageOnPasteRef,
|
||||
@@ -4018,6 +4073,7 @@ const TerminalComponent: React.FC<TerminalProps> = ({
|
||||
terminalBackend,
|
||||
isSensitiveInput: () => passwordPromptActiveRef.current,
|
||||
scrollOnPasteRef,
|
||||
broadcastPasswordBypassRef,
|
||||
onPasteData: broadcastUserPasteData,
|
||||
scrollToBottomAfterProgrammaticInput,
|
||||
containerRef,
|
||||
@@ -4260,6 +4316,7 @@ const TerminalComponent: React.FC<TerminalProps> = ({
|
||||
onOpenExternalError,
|
||||
isBroadcastEnabledRef,
|
||||
onBroadcastInputRef,
|
||||
broadcastPasswordBypassRef,
|
||||
snippetsRef,
|
||||
onSnippetShortkeyRef,
|
||||
sessionId,
|
||||
@@ -4566,7 +4623,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);
|
||||
@@ -2363,29 +2383,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 +2459,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(
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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\)/,
|
||||
);
|
||||
});
|
||||
@@ -383,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>;
|
||||
@@ -984,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;
|
||||
@@ -1263,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;
|
||||
/**
|
||||
@@ -1330,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 &&
|
||||
@@ -1548,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 (
|
||||
@@ -1558,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) {
|
||||
@@ -1609,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>();
|
||||
@@ -1656,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,
|
||||
});
|
||||
|
||||
@@ -2404,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,
|
||||
@@ -2499,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, {
|
||||
@@ -2509,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,
|
||||
@@ -2533,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",
|
||||
@@ -2541,7 +2595,7 @@ export const createXTermRuntime = (ctx: CreateXTermRuntimeContext): XTermRuntime
|
||||
urgentInterrupt: shouldUseUrgentTerminalInterrupt(e, {
|
||||
hasSelection: hasCopyableSelection,
|
||||
}),
|
||||
});
|
||||
}, false, undefined, sourceSensitivePrompt ? { sourceSensitive: true } : undefined);
|
||||
if (forwarded) {
|
||||
upsertKittyKeyboardForwardedPress(
|
||||
broadcastForwardedKeys,
|
||||
@@ -2737,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) {
|
||||
@@ -2884,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, {
|
||||
@@ -2902,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();
|
||||
@@ -2974,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;
|
||||
}
|
||||
@@ -3045,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;
|
||||
}
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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);
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -287,6 +287,8 @@ export const STORAGE_KEY_WORKSPACE_FOCUS_SIDEBAR_WIDTH = 'netcatty_workspace_foc
|
||||
export const STORAGE_KEY_TERMINAL_HOST_TREE_WIDTH = 'netcatty_terminal_host_tree_width_v1';
|
||||
export const STORAGE_KEY_TERMINAL_HOST_TREE_COLLAPSED = 'netcatty_terminal_host_tree_collapsed_v1';
|
||||
export const STORAGE_KEY_TERMINAL_COMPOSE_BAR_OPEN = 'netcatty_terminal_compose_bar_open_v1';
|
||||
/** Opt-in "broadcast without password protection" switch (terminal compose bar, issue #3488). */
|
||||
export const STORAGE_KEY_TERMINAL_BROADCAST_PASSWORD_BYPASS = 'netcatty_terminal_broadcast_password_bypass_v1';
|
||||
export const STORAGE_KEY_TERMINAL_SEARCH_OPEN = 'netcatty_terminal_search_open_v1';
|
||||
export const STORAGE_KEY_TERMINAL_ENCODING_BY_HOST_PREFIX = 'netcatty_terminal_encoding_by_host_v1:';
|
||||
export const STORAGE_KEY_TERMINAL_YMODEM_SEND_DIR = 'netcatty_terminal_ymodem_send_dir_v1';
|
||||
|
||||
@@ -0,0 +1,350 @@
|
||||
/* Real desktop/SSH exercise for the Windows GitHub runner. Run after `npm run build`. */
|
||||
const assert = require('node:assert/strict');
|
||||
const crypto = require('node:crypto');
|
||||
const fs = require('node:fs');
|
||||
const net = require('node:net');
|
||||
const path = require('node:path');
|
||||
const { spawn, spawnSync } = require('node:child_process');
|
||||
const { Server } = require('ssh2');
|
||||
const electron = require('electron');
|
||||
const { applyElectronLaunchEnv } = require('../electron/launchEnv.cjs');
|
||||
const { getTempFilePath } = require('../electron/bridges/tempDirBridge.cjs');
|
||||
|
||||
const pause = (ms) => new Promise((resolve) => setTimeout(resolve, ms));
|
||||
const deadline = (ms) => Date.now() + ms;
|
||||
function waitForExit(child, ms) {
|
||||
if (child.exitCode !== null || child.signalCode !== null) return Promise.resolve(true);
|
||||
return new Promise((resolve) => {
|
||||
const onExit = () => { clearTimeout(timer); resolve(true); };
|
||||
const timer = setTimeout(() => { child.off('exit', onExit); resolve(false); }, ms);
|
||||
child.once('exit', onExit);
|
||||
});
|
||||
}
|
||||
async function until(label, check, ms = 15000) {
|
||||
const end = deadline(ms);
|
||||
let last;
|
||||
while (Date.now() < end) {
|
||||
try {
|
||||
const value = await check();
|
||||
if (value) return value;
|
||||
} catch (error) { last = error; }
|
||||
await pause(100);
|
||||
}
|
||||
throw new Error(`Timed out waiting for ${label}${last ? `: ${last.message}` : ''}`);
|
||||
}
|
||||
async function freePort() {
|
||||
const server = net.createServer();
|
||||
await new Promise((resolve) => server.listen(0, '127.0.0.1', resolve));
|
||||
const port = server.address().port;
|
||||
await new Promise((resolve) => server.close(resolve));
|
||||
return port;
|
||||
}
|
||||
|
||||
class Devtools {
|
||||
constructor(url) {
|
||||
this.ws = new WebSocket(url);
|
||||
this.nextId = 0;
|
||||
this.pending = new Map();
|
||||
this.ws.addEventListener('message', (event) => {
|
||||
const message = JSON.parse(event.data);
|
||||
const item = this.pending.get(message.id);
|
||||
if (!item) return;
|
||||
this.pending.delete(message.id);
|
||||
if (message.error) item.reject(new Error(message.error.message));
|
||||
else item.resolve(message.result);
|
||||
});
|
||||
}
|
||||
async connect() {
|
||||
await new Promise((resolve, reject) => {
|
||||
this.ws.addEventListener('open', resolve, { once: true });
|
||||
this.ws.addEventListener('error', reject, { once: true });
|
||||
});
|
||||
}
|
||||
send(method, params = {}) {
|
||||
const id = ++this.nextId;
|
||||
return new Promise((resolve, reject) => {
|
||||
this.pending.set(id, { resolve, reject });
|
||||
this.ws.send(JSON.stringify({ id, method, params }));
|
||||
});
|
||||
}
|
||||
async eval(expression) {
|
||||
const result = await this.send('Runtime.evaluate', { expression, returnByValue: true, awaitPromise: true });
|
||||
if (result.exceptionDetails) throw new Error(JSON.stringify(result.exceptionDetails));
|
||||
return result.result.value;
|
||||
}
|
||||
close() { this.ws.close(); }
|
||||
}
|
||||
|
||||
async function clickPoint(cdp, { x, y }, button = 'left') {
|
||||
await cdp.send('Input.dispatchMouseEvent', { type: 'mousePressed', x, y, button, clickCount: 1 });
|
||||
await cdp.send('Input.dispatchMouseEvent', { type: 'mouseReleased', x, y, button, clickCount: 1 });
|
||||
}
|
||||
async function clickElement(cdp, selector, index = 0, button = 'left') {
|
||||
const point = await cdp.eval(`(() => {const items=[...document.querySelectorAll(${JSON.stringify(selector)})].filter(e=>{const r=e.getBoundingClientRect();return r.width>0&&r.height>0&&r.x>=0&&r.y>=0});const e=items[${index}];if(!e)throw new Error('Missing visible element: '+${JSON.stringify(selector)});const r=e.getBoundingClientRect();return {x:r.x+r.width/2,y:r.y+r.height/2}})()`);
|
||||
await clickPoint(cdp, point, button);
|
||||
}
|
||||
async function clickText(cdp, label, selector = 'button') {
|
||||
const point = await cdp.eval(`(() => {const e=[...document.querySelectorAll(${JSON.stringify(selector)})].find(x=>x.textContent.trim()===${JSON.stringify(label)}&&x.getBoundingClientRect().x>=0);if(!e)throw new Error('Missing button: '+${JSON.stringify(label)});const r=e.getBoundingClientRect();return {x:r.x+r.width/2,y:r.y+r.height/2}})()`);
|
||||
await clickPoint(cdp, point);
|
||||
}
|
||||
async function insertText(mainCdp, value) {
|
||||
await mainCdp.eval(`process.mainModule.require('electron').BrowserWindow.getAllWindows()[0].webContents.insertText(${JSON.stringify(value)})`);
|
||||
}
|
||||
async function key(mainCdp, code, type = 'keyDown') {
|
||||
await mainCdp.eval(`process.mainModule.require('electron').BrowserWindow.getAllWindows()[0].webContents.sendInputEvent({type:${JSON.stringify(type)},keyCode:${JSON.stringify(code)}})`);
|
||||
}
|
||||
async function typeLine(mainCdp, value) {
|
||||
for (const letter of value) await key(mainCdp, letter, 'char');
|
||||
await key(mainCdp, 'Enter');
|
||||
await key(mainCdp, 'Enter', 'keyUp');
|
||||
}
|
||||
async function terminalLine(cdp, mainCdp, value) {
|
||||
const point = await cdp.eval(`(() => {const e=[...document.querySelectorAll('.xterm')].find(x=>x.getBoundingClientRect().x>=0);if(!e)throw new Error('No visible terminal');const r=e.getBoundingClientRect();return {x:r.x+100,y:r.y+100}})()`);
|
||||
await clickPoint(cdp, point);
|
||||
await typeLine(mainCdp, value);
|
||||
}
|
||||
async function composeLine(cdp, mainCdp, value) {
|
||||
await clickElement(cdp, 'textarea[placeholder^="Type command here"]');
|
||||
await typeLine(mainCdp, value);
|
||||
}
|
||||
const visibleComposeTextarea = `[...document.querySelectorAll('textarea[placeholder^="Type command here"]')].find(e=>{const r=e.getBoundingClientRect();return r.width>0&&r.height>0&&r.x>=0})`;
|
||||
async function activeTab(cdp, index) {
|
||||
await clickElement(cdp, '[data-tab-type="session"]', index);
|
||||
await pause(250);
|
||||
}
|
||||
async function broadcast(cdp, enabled) {
|
||||
await clickElement(cdp, `button[aria-label="${enabled ? 'Enable' : 'Disable'} Broadcast Mode"]`);
|
||||
await pause(150);
|
||||
}
|
||||
|
||||
function startSshFixture() {
|
||||
const { privateKey } = crypto.generateKeyPairSync('rsa', {
|
||||
modulusLength: 2048,
|
||||
privateKeyEncoding: { type: 'pkcs1', format: 'pem' },
|
||||
publicKeyEncoding: { type: 'spki', format: 'pem' },
|
||||
});
|
||||
const sessions = [];
|
||||
const clients = new Set();
|
||||
const server = new Server({ hostKeys: [privateKey] }, (client) => {
|
||||
clients.add(client);
|
||||
client.on('close', () => clients.delete(client));
|
||||
const session = { commands: [], prompts: 0, answers: [], pending: false, ready: false };
|
||||
sessions.push(session);
|
||||
client.on('authentication', (ctx) => {
|
||||
if (ctx.method === 'password' && ctx.password === 'fixture-login') ctx.accept();
|
||||
else ctx.reject(['password']);
|
||||
});
|
||||
client.on('ready', () => {
|
||||
client.on('session', (accept) => {
|
||||
const channel = accept();
|
||||
channel.on('pty', (acceptPty) => acceptPty && acceptPty());
|
||||
channel.on('shell', (acceptShell) => {
|
||||
const stream = acceptShell();
|
||||
session.ready = true;
|
||||
let line = '';
|
||||
stream.write('fixture$ ');
|
||||
stream.on('data', (buffer) => {
|
||||
for (const char of buffer.toString('utf8')) {
|
||||
if (char === '\r' || char === '\n') {
|
||||
if (!line && char === '\n') continue;
|
||||
const submitted = line;
|
||||
line = '';
|
||||
if (session.pending) {
|
||||
session.answers.push(submitted);
|
||||
session.pending = false;
|
||||
stream.write('\r\naccepted\r\nfixture$ ');
|
||||
} else if (submitted === 'ask') {
|
||||
session.commands.push(submitted);
|
||||
session.prompts += 1;
|
||||
session.pending = true;
|
||||
stream.write('\r\nPassword:');
|
||||
} else if (submitted) {
|
||||
session.commands.push(submitted);
|
||||
stream.write(`\r\nran: ${submitted}\r\nfixture$ `);
|
||||
}
|
||||
} else if (char === '\x7f') line = line.slice(0, -1);
|
||||
else line += char;
|
||||
}
|
||||
});
|
||||
});
|
||||
});
|
||||
});
|
||||
});
|
||||
return { server, sessions, clients };
|
||||
}
|
||||
|
||||
async function main() {
|
||||
assert(fs.existsSync(path.join(__dirname, '..', 'dist', 'index.html')), 'Build the app first');
|
||||
const fixture = startSshFixture();
|
||||
await new Promise((resolve) => fixture.server.listen(0, '127.0.0.1', resolve));
|
||||
const sshPort = fixture.server.address().port;
|
||||
const debugPort = await freePort();
|
||||
const inspectPort = await freePort();
|
||||
const testDir = fs.mkdtempSync(`${getTempFilePath('broadcast-ssh-test')}-`);
|
||||
const output = [];
|
||||
const args = [
|
||||
'.', `--user-data-dir=${testDir}`, `--remote-debugging-port=${debugPort}`, `--inspect=${inspectPort}`,
|
||||
];
|
||||
const app = spawn(electron, args, {
|
||||
cwd: path.join(__dirname, '..'), env: applyElectronLaunchEnv({ ...process.env, VITE_DEV_SERVER_URL: '' }),
|
||||
stdio: ['ignore', 'pipe', 'pipe'],
|
||||
});
|
||||
for (const stream of [app.stdout, app.stderr]) {
|
||||
stream.on('data', (data) => { output.push(data.toString()); if (output.length > 200) output.shift(); });
|
||||
}
|
||||
let cdp;
|
||||
let mainCdp;
|
||||
const guard = setTimeout(async () => {
|
||||
console.error('Desktop SSH test exceeded its 120-second limit');
|
||||
app.kill();
|
||||
for (const client of fixture.clients) client.destroy();
|
||||
try { fixture.server.close(); } catch {}
|
||||
if (!await waitForExit(app, 2000)) {
|
||||
if (process.platform === 'win32') spawnSync('taskkill', ['/PID', String(app.pid), '/T', '/F']);
|
||||
else app.kill('SIGKILL');
|
||||
await waitForExit(app, 2000);
|
||||
}
|
||||
try { fs.rmSync(testDir, { recursive: true, force: true, maxRetries: 5, retryDelay: 200 }); } catch {}
|
||||
process.exit(1);
|
||||
}, 120000);
|
||||
try {
|
||||
const target = await until('Electron renderer', async () => {
|
||||
const response = await fetch(`http://127.0.0.1:${debugPort}/json/list`);
|
||||
const targets = await response.json();
|
||||
return targets.find((item) => item.type === 'page' && /index\.html/.test(item.url));
|
||||
}, 30000);
|
||||
cdp = new Devtools(target.webSocketDebuggerUrl);
|
||||
await cdp.connect();
|
||||
await cdp.send('Page.bringToFront');
|
||||
const inspectTarget = await until('Electron main inspector', async () => {
|
||||
const response = await fetch(`http://127.0.0.1:${inspectPort}/json/list`);
|
||||
return (await response.json())[0];
|
||||
}, 15000);
|
||||
mainCdp = new Devtools(inspectTarget.webSocketDebuggerUrl);
|
||||
await mainCdp.connect();
|
||||
await mainCdp.eval(`process.mainModule.require('electron').BrowserWindow.getAllWindows()[0].focus()`);
|
||||
await until('host search field', () => cdp.eval(`Boolean(document.querySelector('input[placeholder^="Find a host"]'))`), 15000);
|
||||
await clickElement(cdp, 'input[placeholder^="Find a host"]');
|
||||
await insertText(mainCdp, `ssh -p ${sshPort} fixture@127.0.0.1`);
|
||||
await until('quick-connect address', () => cdp.eval(`document.querySelector('input[placeholder^="Find a host"]')?.value.includes('fixture@127.0.0.1')`));
|
||||
await clickText(cdp, 'Connect');
|
||||
await until('protocol step', () => cdp.eval(`document.body.innerText.includes('Choose protocol')`));
|
||||
await clickText(cdp, 'Continue');
|
||||
await until('username step', () => cdp.eval(`Boolean(document.querySelector('input[placeholder="root"]'))`));
|
||||
await clickText(cdp, 'Continue');
|
||||
await until('password step', () => cdp.eval(`Boolean(document.querySelector('input[placeholder="Enter password"]'))`));
|
||||
await clickElement(cdp, 'input[placeholder="Enter password"]');
|
||||
await insertText(mainCdp, 'fixture-login');
|
||||
await clickText(cdp, 'Continue');
|
||||
await until('host-key confirmation', () => cdp.eval(`document.body.innerText.includes('Confirm this host key')`));
|
||||
await clickText(cdp, 'Add and continue');
|
||||
await until('first connected terminal', async () => fixture.sessions.length === 1 && cdp.eval(`document.querySelectorAll('.xterm').length === 1`), 20000);
|
||||
await clickElement(cdp, '[data-tab-type="session"]', 0, 'right');
|
||||
await until('duplicate menu', () => cdp.eval(`Boolean([...document.querySelectorAll('[role="menuitem"]')].find(x=>x.textContent.trim()==='Duplicate Session'))`));
|
||||
await clickText(cdp, 'Duplicate Session', '[role="menuitem"]');
|
||||
await until('second connected terminal', async () => fixture.sessions.length === 2 && fixture.sessions.every(x => x.ready) && cdp.eval(`document.querySelectorAll('.xterm').length === 2`), 20000);
|
||||
await pause(300);
|
||||
await terminalLine(cdp, mainCdp, 'ask');
|
||||
await until('second SSH prompt', () => fixture.sessions[1].pending, 5000);
|
||||
await activeTab(cdp, 0);
|
||||
await terminalLine(cdp, mainCdp, 'ask');
|
||||
await until('both SSH prompts', () => fixture.sessions.every(x => x.pending), 5000);
|
||||
await pause(350);
|
||||
await activeTab(cdp, 1);
|
||||
await broadcast(cdp, true);
|
||||
assert.notEqual(await cdp.eval(`localStorage.getItem('netcatty_terminal_broadcast_password_bypass_v1')`), 'true');
|
||||
await terminalLine(cdp, mainCdp, 'OFFKEY');
|
||||
await until('protected keyboard answer', () => fixture.sessions[1].answers.length === 1, 5000);
|
||||
assert.deepEqual(fixture.sessions.map(x => x.answers), [[], ['OFFKEY']]);
|
||||
assert(fixture.sessions[0].pending);
|
||||
await broadcast(cdp, false);
|
||||
await terminalLine(cdp, mainCdp, 'ask');
|
||||
await until('second prompt again', () => fixture.sessions[1].pending, 5000);
|
||||
await pause(350);
|
||||
await broadcast(cdp, true);
|
||||
await clickElement(cdp, 'button[aria-label="Compose Bar"]');
|
||||
await until('compose bar', () => cdp.eval(`Boolean([...document.querySelectorAll('textarea[placeholder^="Type command here"]')].find(x=>x.getBoundingClientRect().x>=0))`));
|
||||
await composeLine(cdp, mainCdp, 'OFFCOMP');
|
||||
await until('protected compose answer', () => fixture.sessions[1].answers.length === 2, 5000);
|
||||
assert.deepEqual(fixture.sessions.map(x => x.answers), [[], ['OFFKEY', 'OFFCOMP']]);
|
||||
assert(fixture.sessions[0].pending);
|
||||
await broadcast(cdp, false);
|
||||
await terminalLine(cdp, mainCdp, 'ask');
|
||||
await until('second prompt for opt-in', () => fixture.sessions[1].pending, 5000);
|
||||
await pause(350);
|
||||
await broadcast(cdp, true);
|
||||
await clickElement(cdp, 'textarea[placeholder^="Type command here"]', 0, 'right');
|
||||
await until('password bypass menu', () => cdp.eval(`document.querySelector('[data-compose-broadcast-menu]')?.innerText.includes('Broadcast without password protection')`));
|
||||
assert(await cdp.eval(`document.querySelector('[data-compose-broadcast-menu]')?.innerText.includes('Paste')`));
|
||||
await clickElement(cdp, '[data-compose-broadcast-toggle]');
|
||||
await until('password bypass enabled', () => cdp.eval(`localStorage.getItem('netcatty_terminal_broadcast_password_bypass_v1') === 'true'`));
|
||||
await terminalLine(cdp, mainCdp, 'ONKEY');
|
||||
await until('both opt-in keyboard answers', () => fixture.sessions.every(x => x.answers.includes('ONKEY')), 5000);
|
||||
assert.deepEqual(fixture.sessions.map(x => x.answers), [['ONKEY'], ['OFFKEY', 'OFFCOMP', 'ONKEY']]);
|
||||
await broadcast(cdp, false);
|
||||
await activeTab(cdp, 0);
|
||||
await terminalLine(cdp, mainCdp, 'ask');
|
||||
await activeTab(cdp, 1);
|
||||
await composeLine(cdp, mainCdp, 'ask');
|
||||
await until('both prompts for compose opt-in', () => fixture.sessions.every(x => x.pending), 5000);
|
||||
await pause(350);
|
||||
await broadcast(cdp, true);
|
||||
await composeLine(cdp, mainCdp, 'ONCOMP');
|
||||
await until('both opt-in compose answers', () => fixture.sessions.every(x => x.answers.includes('ONCOMP')), 5000);
|
||||
assert.deepEqual(fixture.sessions.map(x => x.answers), [['ONKEY', 'ONCOMP'], ['OFFKEY', 'OFFCOMP', 'ONKEY', 'ONCOMP']]);
|
||||
assert(fixture.sessions.every(x => !x.pending && x.commands.every(command => command === 'ask')));
|
||||
await key(mainCdp, 'Up');
|
||||
await key(mainCdp, 'Up', 'keyUp');
|
||||
await until('safe compose history', () => cdp.eval(`([...document.querySelectorAll('textarea[placeholder^="Type command here"]')].find(x=>x.getBoundingClientRect().x>=0)||{}).value === 'ask'`), 5000);
|
||||
await clickElement(cdp, 'textarea[placeholder^="Type command here"]');
|
||||
await cdp.eval(`(() => {const e=${visibleComposeTextarea};e.value='';e.dispatchEvent(new Event('input',{bubbles:true}));return e.value})()`);
|
||||
await typeLine(mainCdp, 'AFTERCOMP');
|
||||
await until('ordinary compose command reaches both sessions', () => fixture.sessions.every(x => x.commands.includes('AFTERCOMP')), 5000);
|
||||
await terminalLine(cdp, mainCdp, 'AFTERKEY');
|
||||
await until('ordinary keyboard command reaches both sessions', () => fixture.sessions.every(x => x.commands.includes('AFTERKEY')), 5000);
|
||||
assert(fixture.sessions.every(x => x.commands.filter(command => command === 'AFTERCOMP').length === 1));
|
||||
assert(fixture.sessions.every(x => x.commands.filter(command => command === 'AFTERKEY').length === 1));
|
||||
await mainCdp.eval(`process.mainModule.require('electron').clipboard.writeText('CLIPCHECK')`);
|
||||
await clickElement(cdp, 'textarea[placeholder^="Type command here"]', 0, 'right');
|
||||
await until('compose text menu', () => cdp.eval(`Boolean(document.querySelector('[data-compose-broadcast-menu]'))`));
|
||||
await clickText(cdp, 'Paste', '[data-compose-broadcast-menu] button');
|
||||
await until('compose clipboard paste', () => cdp.eval(`(${visibleComposeTextarea})?.value === 'CLIPCHECK'`));
|
||||
await cdp.eval(`(${visibleComposeTextarea}).select()`);
|
||||
await clickElement(cdp, 'textarea[placeholder^="Type command here"]', 0, 'right');
|
||||
await until('compose cut menu', () => cdp.eval(`Boolean(document.querySelector('[data-compose-broadcast-menu]'))`));
|
||||
await clickText(cdp, 'Cut', '[data-compose-broadcast-menu] button');
|
||||
await until('compose clipboard cut', () => cdp.eval(`(${visibleComposeTextarea})?.value === ''`));
|
||||
assert.equal(await mainCdp.eval(`process.mainModule.require('electron').clipboard.readText()`), 'CLIPCHECK');
|
||||
console.log('PASS: two real SSH sessions, password prompts, ordinary commands, safe history, and compose clipboard menu');
|
||||
} catch (error) {
|
||||
console.error(`SSH fixture state before failure: ${JSON.stringify(fixture.sessions)}`);
|
||||
if (cdp) {
|
||||
console.error(`Compose input before failure: ${JSON.stringify(await cdp.eval(`(${visibleComposeTextarea})?.value`).catch(() => null))}`);
|
||||
}
|
||||
console.error(`Electron output before failure:\n${output.join('').slice(-4000)}`);
|
||||
throw error;
|
||||
} finally {
|
||||
clearTimeout(guard);
|
||||
cdp?.close();
|
||||
mainCdp?.close();
|
||||
if (app.exitCode === null && app.signalCode === null) {
|
||||
app.kill();
|
||||
if (!await waitForExit(app, 4000)) {
|
||||
if (process.platform === 'win32') spawnSync('taskkill', ['/PID', String(app.pid), '/T', '/F']);
|
||||
else app.kill('SIGKILL');
|
||||
await waitForExit(app, 4000);
|
||||
}
|
||||
}
|
||||
const appExited = app.exitCode !== null || app.signalCode !== null;
|
||||
for (const client of fixture.clients) client.destroy();
|
||||
const fixtureClosed = await new Promise((resolve) => {
|
||||
const timer = setTimeout(() => resolve(false), 4000);
|
||||
fixture.server.close(() => { clearTimeout(timer); resolve(true); });
|
||||
});
|
||||
fs.rmSync(testDir, { recursive: true, force: true, maxRetries: 5, retryDelay: 200 });
|
||||
assert(appExited, 'Electron did not exit');
|
||||
assert(fixtureClosed, 'SSH fixture did not exit');
|
||||
}
|
||||
}
|
||||
|
||||
main().catch((error) => { console.error(error); process.exitCode = 1; });
|
||||
Reference in new issue
Block a user