Compare commits

...
Author SHA1 Message Date
bincxz be44a44f52 fix: protect compose history during sensitive fallback writes 2026-09-30 00:15:34 +08:00
bincxz 19eae1aac4 fix: retain compose history when sensitive peer receives nothing 2026-09-30 00:12:03 +08:00
bincxz 6d9499902e fix: preserve sensitive peer input across compose wake 2026-09-29 23:56:33 +08:00
bincxz f7d6f70f09 Merge main into PR #3491 and complete broadcast acceptance 2026-09-29 23:42:24 +08:00
bincxz fd1dab65ea test: run desktop SSH check only in Windows job 2026-09-27 19:18:31 +08:00
bincxz 641cb1814f test: verify broadcast password prompts on Windows desktop 2026-09-27 19:09:03 +08:00
netcatty-bot f98f44ccf5 fix: address Codex review on PR #3491 2026-09-22 22:10:39 +00:00
netcatty-bot ed86efa528 fix: address Codex review on PR #3491 2026-09-22 21:38:49 +00:00
netcatty-bot e95f3244df fix: address Codex review on PR #3491 2026-09-22 21:10:37 +00:00
netcatty-bot 82bf1fb037 fix: address Codex review on PR #3491 2026-09-22 20:40:28 +00:00
netcatty-bot d28e701d74 fix: address Codex review on PR #3491 2026-09-22 20:06:16 +00:00
netcatty-bot 9d1caa1578 fix: address Codex review on PR #3491 2026-09-22 19:23:06 +00:00
netcatty-bot bbaba9085b fix: address Codex review on PR #3491 2026-09-22 18:45:48 +00:00
netcatty-bot bcfa7d3f41 fix: address Codex review on PR #3491 2026-09-22 18:07:13 +00:00
netcatty-bot c243b2bb29 fix: address Codex review on PR #3491 2026-09-22 17:40:06 +00:00
netcatty-bot 630b206e76 fix: address Codex review on PR #3491 2026-09-22 16:48:38 +00:00
netcatty-bot 522fe5e2f3 fix: address Codex review on PR #3491 2026-09-22 16:06:39 +00:00
netcatty-bot 598ea265dc fix: address Codex review on PR #3491 2026-09-22 15:33:14 +00:00
netcatty-bot 16ae591243 fix(#3488): automated AI fix 2026-09-22 14:32:52 +00:00
33 changed files with 1436 additions and 75 deletions

No files matched your search

+23
View File
@@ -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
+2
View File
@@ -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',
+2
View File
@@ -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',
+2
View File
@@ -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': 'Загрузить изображение из буфера',
+2
View File
@@ -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': '添加到对话',
+2
View File
@@ -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': '新增到對話',
+7 -1
View File
@@ -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
View File
@@ -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 (
<>
+63 -11
View File
@@ -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(
+135
View File
@@ -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>
);
};
+22 -2
View File
@@ -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\)/,
);
});
+110 -20
View File
@@ -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();
}
});
+24 -5
View File
@@ -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,
+20
View File
@@ -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 } },
]);
});
+2 -1
View File
@@ -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);
+28 -1
View File
@@ -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,
);
});
+13
View File
@@ -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,
+2
View File
@@ -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';
+350
View File
@@ -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; });