mirror of
https://github.com/binaricat/Netcatty.git
synced 2026-11-06 14:30:13 +00:00
Compare commits
32
Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
47cb640b18 | ||
|
|
4b5103cf6a | ||
|
|
47f5077054 | ||
|
|
66496a1b47 | ||
|
|
9e93016ac3 | ||
|
|
b13e504fbf | ||
|
|
eff8ac5627 | ||
|
|
b7ce7820d3 | ||
|
|
f833273d25 | ||
|
|
2199487458 | ||
|
|
097d17b16e | ||
|
|
410ecc4f61 | ||
|
|
8a884b75df | ||
|
|
61e1aa0f76 | ||
|
|
5af46e117d | ||
|
|
3d939c5de0 | ||
|
|
16f9026b35 | ||
|
|
def3efaa7f | ||
|
|
ce411bb852 | ||
|
|
8af00ff293 | ||
|
|
6f90b19b25 | ||
|
|
3380c5ab43 | ||
|
|
e2d61cad9c | ||
|
|
1f833590fc | ||
|
|
4b6e1b2b01 | ||
|
|
a899cd3137 | ||
|
|
1618ccefc5 | ||
|
|
b062c7ab54 | ||
|
|
4121399d5a | ||
|
|
c34927d689 | ||
|
|
b4045e697b | ||
|
|
0ee03b085e |
No files matched your search
@@ -11,6 +11,7 @@ import { TooltipProvider } from './components/ui/tooltip';
|
||||
import { ScriptAutomationRoot } from './components/scripts/ScriptAutomationRoot';
|
||||
import { ExternalMcpApprovalsHost } from './components/ai/ExternalMcpApprovalsHost';
|
||||
import { PluginAuthenticationHost } from './components/plugins/PluginAuthenticationHost';
|
||||
import { MultilinePasteConfirmHost } from './components/terminal/MultilinePasteConfirmDialog';
|
||||
import { useExternalMcpGrantPersister } from './components/ai/useExternalMcpGrantPersister';
|
||||
import { setupMcpApprovalBridge } from './infrastructure/ai/shared/approvalGate';
|
||||
import { setupCodexAppServerInteractionBridge } from './infrastructure/ai/shared/codexAppServerInteractions';
|
||||
@@ -81,6 +82,7 @@ function AppWithProviders({ settings, appLock }: { settings: SettingsState; appL
|
||||
<ScriptAutomationRoot />
|
||||
<ExternalMcpApprovalsHost />
|
||||
<PluginAuthenticationHost />
|
||||
<MultilinePasteConfirmHost />
|
||||
<VaultPublisher>
|
||||
<SessionPublisher persistSessionRestore={!isPeerSessionWindow}>
|
||||
<App />
|
||||
|
||||
@@ -697,6 +697,12 @@ Highlight the focused split pane:
|
||||
'settings.terminal.behavior.autoUploadClipboardImage': 'Auto-upload clipboard images on paste',
|
||||
'settings.terminal.behavior.autoUploadClipboardImage.desc':
|
||||
'When the clipboard holds an image, pasting in a remote session uploads it via SFTP to .netcatty-paste-images/ under the remote working directory and inserts the remote path instead of pasting text.',
|
||||
'settings.terminal.behavior.confirmMultilinePaste': 'Ask before pasting multiple lines',
|
||||
'settings.terminal.behavior.confirmMultilinePaste.desc':
|
||||
'Show a confirmation dialog before pasting multiple lines, with the line/character count, an editable preview, and Send / Send line by line / Cancel actions. Protects switch and firewall CLIs (Cisco IOS, Huawei VRP, H3C Comware) that do not support bracketed paste.',
|
||||
'settings.terminal.behavior.confirmMultilinePasteMinLines': 'Confirmation threshold (lines)',
|
||||
'settings.terminal.behavior.confirmMultilinePasteMinLines.desc':
|
||||
'Ask for confirmation when a paste contains at least this many lines.',
|
||||
'settings.terminal.behavior.shiftEnterNewline': 'Shift+Enter text',
|
||||
'settings.terminal.behavior.shiftEnterNewline.desc':
|
||||
'Send configured text instead of normal Enter when pressing Shift+Enter in the terminal.',
|
||||
|
||||
@@ -3,6 +3,13 @@ import type { Messages } from '../types';
|
||||
export const enTerminalMessages: Messages = {
|
||||
'terminal.menu.saveScreen': 'Save current screen',
|
||||
'terminal.saveScreen.failed': 'Failed to save terminal screen.',
|
||||
// Multi-line paste confirmation dialog (#3398)
|
||||
'terminal.pasteConfirm.title': 'Paste multiple lines?',
|
||||
'terminal.pasteConfirm.summary': '{lines} lines ({chars} characters) will be sent to the session.',
|
||||
'terminal.pasteConfirm.preview': 'Preview (editable)',
|
||||
'terminal.pasteConfirm.send': 'Send',
|
||||
'terminal.pasteConfirm.sendLineByLine': 'Send line by line',
|
||||
'terminal.pasteConfirm.cancel': 'Cancel',
|
||||
'terminal.sudoHint.pressEnter': 'Press Enter to paste saved password',
|
||||
'terminal.passwordPicker.title': 'Saved passwords',
|
||||
'terminal.passwordPicker.empty': 'No saved passwords',
|
||||
|
||||
@@ -612,6 +612,12 @@ Resalta el panel dividido enfocado:
|
||||
'settings.terminal.behavior.autoUploadClipboardImage': 'Subir automáticamente imágenes del portapapeles al pegar',
|
||||
'settings.terminal.behavior.autoUploadClipboardImage.desc':
|
||||
'Cuando el portapapeles contiene una imagen, pegarla en una sesión remota la sube por SFTP a .netcatty-paste-images/ dentro del directorio de trabajo remoto e inserta la ruta remota en lugar de pegar texto.',
|
||||
'settings.terminal.behavior.confirmMultilinePaste': 'Preguntar antes de pegar varias líneas',
|
||||
'settings.terminal.behavior.confirmMultilinePaste.desc':
|
||||
'Muestra un diálogo de confirmación antes de pegar varias líneas, con el número de líneas y caracteres, una vista previa editable y las acciones Enviar / Enviar línea por línea / Cancelar. Protege las CLI de dispositivos de red (Cisco IOS, Huawei VRP, H3C Comware) que no admiten bracketed paste.',
|
||||
'settings.terminal.behavior.confirmMultilinePasteMinLines': 'Umbral de confirmación (líneas)',
|
||||
'settings.terminal.behavior.confirmMultilinePasteMinLines.desc':
|
||||
'Pedir confirmación cuando el texto pegado contenga al menos esta cantidad de líneas.',
|
||||
'settings.terminal.behavior.shiftEnterNewline': 'Texto de Shift+Enter',
|
||||
'settings.terminal.behavior.shiftEnterNewline.desc':
|
||||
'Envía el texto configurado en lugar del Enter normal al presionar Shift+Enter en la terminal.',
|
||||
|
||||
@@ -3,6 +3,13 @@ import type { Messages } from '../types';
|
||||
export const esTerminalMessages: Messages = {
|
||||
'terminal.menu.saveScreen': 'Guardar pantalla actual',
|
||||
'terminal.saveScreen.failed': 'No se pudo guardar la pantalla del terminal.',
|
||||
// Diálogo de confirmación de pegado multilínea (#3398)
|
||||
'terminal.pasteConfirm.title': '¿Pegar varias líneas?',
|
||||
'terminal.pasteConfirm.summary': 'Se enviarán {lines} líneas ({chars} caracteres) a la sesión.',
|
||||
'terminal.pasteConfirm.preview': 'Vista previa (editable)',
|
||||
'terminal.pasteConfirm.send': 'Enviar',
|
||||
'terminal.pasteConfirm.sendLineByLine': 'Enviar línea por línea',
|
||||
'terminal.pasteConfirm.cancel': 'Cancelar',
|
||||
'terminal.sudoHint.pressEnter': 'Presiona Enter para pegar la contraseña guardada',
|
||||
'terminal.passwordPicker.title': 'Contraseñas guardadas',
|
||||
'terminal.passwordPicker.empty': 'No hay contraseñas guardadas',
|
||||
|
||||
@@ -648,6 +648,12 @@ export const ruCoreMessages: Messages = {
|
||||
'settings.terminal.behavior.autoUploadClipboardImage': 'Авто-загрузка изображений буфера обмена при вставке',
|
||||
'settings.terminal.behavior.autoUploadClipboardImage.desc':
|
||||
'Когда в буфере обмена есть изображение, вставка в удалённой сессии загружает его по SFTP в каталог .netcatty-paste-images/ в текущем удалённом каталоге и вводит удалённый путь вместо вставки текста.',
|
||||
'settings.terminal.behavior.confirmMultilinePaste': 'Спрашивать перед вставкой нескольких строк',
|
||||
'settings.terminal.behavior.confirmMultilinePaste.desc':
|
||||
'Перед вставкой нескольких строк показывать диалог подтверждения с количеством строк и символов, редактируемым предпросмотром и действиями «Отправить» / «Отправить построчно» / «Отмена». Защищает CLI сетевых устройств (Cisco IOS, Huawei VRP, H3C Comware), не поддерживающих bracketed paste.',
|
||||
'settings.terminal.behavior.confirmMultilinePasteMinLines': 'Порог подтверждения (строк)',
|
||||
'settings.terminal.behavior.confirmMultilinePasteMinLines.desc':
|
||||
'Запрашивать подтверждение, если вставка содержит не меньше этого числа строк.',
|
||||
'settings.terminal.behavior.shiftEnterNewline': 'Текст для Shift+Enter',
|
||||
'settings.terminal.behavior.shiftEnterNewline.desc':
|
||||
'Отправлять настроенный текст вместо обычного Enter при нажатии Shift+Enter в терминале.',
|
||||
|
||||
@@ -3,6 +3,13 @@ import type { Messages } from '../types';
|
||||
export const ruTerminalMessages: Messages = {
|
||||
'terminal.menu.saveScreen': 'Сохранить текущий экран',
|
||||
'terminal.saveScreen.failed': 'Не удалось сохранить экран терминала.',
|
||||
// Диалог подтверждения многострочной вставки (#3398)
|
||||
'terminal.pasteConfirm.title': 'Вставить несколько строк?',
|
||||
'terminal.pasteConfirm.summary': 'В сессию будет отправлено строк: {lines} (символов: {chars}).',
|
||||
'terminal.pasteConfirm.preview': 'Предпросмотр (можно изменить)',
|
||||
'terminal.pasteConfirm.send': 'Отправить',
|
||||
'terminal.pasteConfirm.sendLineByLine': 'Отправить построчно',
|
||||
'terminal.pasteConfirm.cancel': 'Отмена',
|
||||
'terminal.sudoHint.pressEnter': 'Нажмите Enter, чтобы вставить сохранённый пароль',
|
||||
'terminal.passwordPicker.title': 'Сохранённые пароли',
|
||||
'terminal.passwordPicker.empty': 'Нет сохранённых паролей',
|
||||
|
||||
@@ -3,6 +3,13 @@ import type { Messages } from '../types';
|
||||
export const zhCNTerminalMessages: Messages = {
|
||||
'terminal.menu.saveScreen': '保存当前屏幕',
|
||||
'terminal.saveScreen.failed': '保存终端屏幕失败。',
|
||||
// 多行粘贴确认对话框(#3398)
|
||||
'terminal.pasteConfirm.title': '要粘贴多行文本吗?',
|
||||
'terminal.pasteConfirm.summary': '将向会话发送 {lines} 行({chars} 个字符)。',
|
||||
'terminal.pasteConfirm.preview': '预览(可编辑)',
|
||||
'terminal.pasteConfirm.send': '发送',
|
||||
'terminal.pasteConfirm.sendLineByLine': '逐行发送',
|
||||
'terminal.pasteConfirm.cancel': '取消',
|
||||
'terminal.sudoHint.pressEnter': '按 Enter 粘贴已保存的密码',
|
||||
'terminal.passwordPicker.title': '已保存的密码',
|
||||
'terminal.passwordPicker.empty': '没有已保存的密码',
|
||||
@@ -326,6 +333,12 @@ export const zhCNTerminalMessages: Messages = {
|
||||
'settings.terminal.behavior.autoUploadClipboardImage': '粘贴时自动上传剪贴板图片',
|
||||
'settings.terminal.behavior.autoUploadClipboardImage.desc':
|
||||
'当剪贴板包含图片时,在远程会话中粘贴会通过 SFTP 将图片上传到远端当前目录的 .netcatty-paste-images/ 文件夹,并在终端输入远端路径,而不是粘贴文本。',
|
||||
'settings.terminal.behavior.confirmMultilinePaste': '粘贴多行前先询问',
|
||||
'settings.terminal.behavior.confirmMultilinePaste.desc':
|
||||
'粘贴多行文本时先弹出确认对话框,显示行数和字符数、可编辑的预览,并提供发送 / 逐行发送 / 取消操作。可保护不支持括号粘贴的网络设备 CLI(Cisco IOS、华为 VRP、H3C Comware)。',
|
||||
'settings.terminal.behavior.confirmMultilinePasteMinLines': '确认阈值(行数)',
|
||||
'settings.terminal.behavior.confirmMultilinePasteMinLines.desc':
|
||||
'粘贴内容达到该行数时才弹出确认对话框。',
|
||||
'settings.terminal.behavior.shiftEnterNewline': 'Shift+Enter 发送文本',
|
||||
'settings.terminal.behavior.shiftEnterNewline.desc':
|
||||
'在终端中按 Shift+Enter 时发送配置的文本,而不是普通回车。',
|
||||
|
||||
@@ -3,6 +3,13 @@ import type { Messages } from '../types';
|
||||
export const zhTWTerminalMessages: Messages = {
|
||||
'terminal.menu.saveScreen': '儲存目前畫面',
|
||||
'terminal.saveScreen.failed': '儲存終端畫面失敗。',
|
||||
// 多行貼上確認對話框(#3398)
|
||||
'terminal.pasteConfirm.title': '要貼上多行文字嗎?',
|
||||
'terminal.pasteConfirm.summary': '將向工作階段傳送 {lines} 行({chars} 個字元)。',
|
||||
'terminal.pasteConfirm.preview': '預覽(可編輯)',
|
||||
'terminal.pasteConfirm.send': '傳送',
|
||||
'terminal.pasteConfirm.sendLineByLine': '逐行傳送',
|
||||
'terminal.pasteConfirm.cancel': '取消',
|
||||
'terminal.sudoHint.pressEnter': '按 Enter 貼上已儲存的密碼',
|
||||
'terminal.passwordPicker.title': '已儲存的密碼',
|
||||
'terminal.passwordPicker.empty': '沒有已儲存的密碼',
|
||||
@@ -326,6 +333,12 @@ export const zhTWTerminalMessages: Messages = {
|
||||
'settings.terminal.behavior.autoUploadClipboardImage': '貼上時自動上傳剪貼簿圖片',
|
||||
'settings.terminal.behavior.autoUploadClipboardImage.desc':
|
||||
'當剪貼簿包含圖片時,在遠端工作階段中貼上會透過 SFTP 將圖片上傳到遠端目前目錄的 .netcatty-paste-images/ 資料夾,並在終端輸入遠端路徑,而不是貼上文字。',
|
||||
'settings.terminal.behavior.confirmMultilinePaste': '貼上多行前先詢問',
|
||||
'settings.terminal.behavior.confirmMultilinePaste.desc':
|
||||
'貼上多行文字時先彈出確認對話框,顯示行數和字元數、可編輯的預覽,並提供傳送 / 逐行傳送 / 取消操作。可保護不支援括號貼上的網路設備 CLI(Cisco IOS、華為 VRP、H3C Comware)。',
|
||||
'settings.terminal.behavior.confirmMultilinePasteMinLines': '確認門檻(行數)',
|
||||
'settings.terminal.behavior.confirmMultilinePasteMinLines.desc':
|
||||
'貼上內容達到該行數時才彈出確認對話框。',
|
||||
'settings.terminal.behavior.shiftEnterNewline': 'Shift+Enter 傳送文字',
|
||||
'settings.terminal.behavior.shiftEnterNewline.desc':
|
||||
'在終端中按 Shift+Enter 時傳送設定的文字,而不是一般 Enter。',
|
||||
|
||||
@@ -0,0 +1,62 @@
|
||||
import test from "node:test";
|
||||
import assert from "node:assert/strict";
|
||||
|
||||
import {
|
||||
getPendingMultilinePasteConfirm,
|
||||
requestMultilinePasteConfirm,
|
||||
respondMultilinePasteConfirm,
|
||||
subscribeMultilinePasteConfirm,
|
||||
} from "./multilinePasteConfirmStore";
|
||||
|
||||
test("request stores the pending paste and respond resolves it", async () => {
|
||||
const pending = requestMultilinePasteConfirm({
|
||||
text: "line1\nline2",
|
||||
lineCount: 2,
|
||||
charCount: 12,
|
||||
});
|
||||
assert.equal(getPendingMultilinePasteConfirm()?.lineCount, 2);
|
||||
|
||||
respondMultilinePasteConfirm("line-by-line", "line1\nline2\nline3");
|
||||
const response = await pending;
|
||||
assert.deepEqual(response, { action: "line-by-line", text: "line1\nline2\nline3" });
|
||||
assert.equal(getPendingMultilinePasteConfirm(), null);
|
||||
});
|
||||
|
||||
test("respond without text falls back to the original request text", async () => {
|
||||
const pending = requestMultilinePasteConfirm({
|
||||
text: "line1\nline2",
|
||||
lineCount: 2,
|
||||
charCount: 12,
|
||||
});
|
||||
respondMultilinePasteConfirm("send");
|
||||
const response = await pending;
|
||||
assert.deepEqual(response, { action: "send", text: "line1\nline2" });
|
||||
});
|
||||
|
||||
test("a newer paste request supersedes the pending one as cancelled", async () => {
|
||||
const first = requestMultilinePasteConfirm({ text: "a\nb", lineCount: 2, charCount: 3 });
|
||||
const second = requestMultilinePasteConfirm({ text: "c\nd", lineCount: 2, charCount: 3 });
|
||||
assert.equal(getPendingMultilinePasteConfirm()?.text, "c\nd");
|
||||
|
||||
respondMultilinePasteConfirm("send", "c\nd");
|
||||
assert.deepEqual(await first, { action: "cancel", text: "a\nb" });
|
||||
assert.deepEqual(await second, { action: "send", text: "c\nd" });
|
||||
});
|
||||
|
||||
test("respond without a pending request is a no-op and listeners are notified", async () => {
|
||||
respondMultilinePasteConfirm("send");
|
||||
assert.equal(getPendingMultilinePasteConfirm(), null);
|
||||
|
||||
const events: Array<string | null> = [];
|
||||
const unsubscribe = subscribeMultilinePasteConfirm(() => {
|
||||
events.push(getPendingMultilinePasteConfirm()?.id ?? "cleared");
|
||||
});
|
||||
const pending = requestMultilinePasteConfirm({ text: "x", lineCount: 1, charCount: 1 });
|
||||
const pendingId = getPendingMultilinePasteConfirm()?.id ?? null;
|
||||
respondMultilinePasteConfirm("cancel", "x");
|
||||
await pending;
|
||||
unsubscribe();
|
||||
|
||||
assert.ok(pendingId);
|
||||
assert.deepEqual(events, [pendingId, "cleared"]);
|
||||
});
|
||||
@@ -0,0 +1,81 @@
|
||||
/**
|
||||
* App-level store for the "paste multiple lines" confirmation dialog (#3398).
|
||||
*
|
||||
* Terminal paste handlers call `requestMultilinePasteConfirm` from anywhere in
|
||||
* the renderer and await the user's decision; `MultilinePasteConfirmHost`
|
||||
* (mounted once in App) renders the dialog and resolves the pending request.
|
||||
*/
|
||||
|
||||
export type MultilinePasteConfirmAction = "send" | "line-by-line" | "cancel";
|
||||
|
||||
export interface MultilinePasteConfirmRequest {
|
||||
id: string;
|
||||
/** Clipboard text the dialog previews (editable by the user). */
|
||||
text: string;
|
||||
lineCount: number;
|
||||
charCount: number;
|
||||
/** Restore the originating terminal's focus after the modal releases it. */
|
||||
onClose?: () => void;
|
||||
}
|
||||
|
||||
export interface MultilinePasteConfirmResponse {
|
||||
action: MultilinePasteConfirmAction;
|
||||
/** Text to send: the preview content, possibly edited by the user. */
|
||||
text: string;
|
||||
}
|
||||
|
||||
interface PendingRequest {
|
||||
request: MultilinePasteConfirmRequest;
|
||||
resolve: (response: MultilinePasteConfirmResponse) => void;
|
||||
}
|
||||
|
||||
let pending: PendingRequest | null = null;
|
||||
let nextRequestId = 0;
|
||||
const listeners = new Set<() => void>();
|
||||
|
||||
const emit = (): void => {
|
||||
for (const listener of listeners) listener();
|
||||
};
|
||||
|
||||
export function requestMultilinePasteConfirm(
|
||||
request: Omit<MultilinePasteConfirmRequest, "id">,
|
||||
): Promise<MultilinePasteConfirmResponse> {
|
||||
// Only one confirmation dialog can be pending; a newer paste supersedes the
|
||||
// older one (the stale promise resolves as "cancel" so its paste is dropped).
|
||||
if (pending) {
|
||||
const stale = pending;
|
||||
pending = null;
|
||||
stale.resolve({ action: "cancel", text: stale.request.text });
|
||||
}
|
||||
nextRequestId += 1;
|
||||
const requestWithId: MultilinePasteConfirmRequest = {
|
||||
...request,
|
||||
id: `multiline-paste-${nextRequestId}`,
|
||||
};
|
||||
return new Promise((resolve) => {
|
||||
pending = { request: requestWithId, resolve };
|
||||
emit();
|
||||
});
|
||||
}
|
||||
|
||||
export function respondMultilinePasteConfirm(
|
||||
action: MultilinePasteConfirmAction,
|
||||
text?: string,
|
||||
): void {
|
||||
const current = pending;
|
||||
if (!current) return;
|
||||
pending = null;
|
||||
current.resolve({ action, text: text ?? current.request.text });
|
||||
emit();
|
||||
}
|
||||
|
||||
/** Snapshot for `useSyncExternalStore`; stable reference while pending. */
|
||||
export const getPendingMultilinePasteConfirm =
|
||||
(): MultilinePasteConfirmRequest | null => pending?.request ?? null;
|
||||
|
||||
export function subscribeMultilinePasteConfirm(listener: () => void): () => void {
|
||||
listeners.add(listener);
|
||||
return () => {
|
||||
listeners.delete(listener);
|
||||
};
|
||||
}
|
||||
@@ -352,3 +352,158 @@ test("manual stop still returns its recording and permits a clean restart", asyn
|
||||
assert.equal(recorder!.isRecording, true);
|
||||
await act(async () => renderer!.unmount());
|
||||
});
|
||||
|
||||
test("confirmed line-by-line recording awaits each send and prompt step in order", async (t) => {
|
||||
const previousWindow = Object.getOwnPropertyDescriptor(globalThis, "window");
|
||||
let appendGate: Promise<void> | undefined;
|
||||
let stopCalls = 0;
|
||||
const steps: Array<{ type: string; value?: unknown; sensitive?: boolean }> = [];
|
||||
const eventTarget = new EventTarget();
|
||||
Object.assign(eventTarget, {
|
||||
netcatty: {
|
||||
scriptRecordingStart: async () => ({ ok: true }),
|
||||
scriptRecordingStop: async () => { stopCalls++; return { steps: [...steps], code: "" }; },
|
||||
scriptRecordingAppendStep: async (_id: string, step: typeof steps[number]) => {
|
||||
await appendGate;
|
||||
await new Promise(resolve => setImmediate(resolve));
|
||||
steps.push(step);
|
||||
return { stopped: false };
|
||||
},
|
||||
},
|
||||
setInterval, clearInterval,
|
||||
});
|
||||
Object.defineProperty(globalThis, "window", { configurable: true, value: eventTarget });
|
||||
t.after(() => {
|
||||
if (previousWindow) Object.defineProperty(globalThis, "window", previousWindow);
|
||||
else Reflect.deleteProperty(globalThis, "window");
|
||||
});
|
||||
let recorder!: Recorder;
|
||||
let renderer!: ReactTestRenderer;
|
||||
function Probe() { recorder = useScriptRecorder("session-1"); return null; }
|
||||
await act(async () => { renderer = create(React.createElement(Probe)); });
|
||||
try {
|
||||
await act(async () => { await recorder.startRecording(); });
|
||||
await act(async () => {
|
||||
recorder.recordInput("echo ");
|
||||
const recordLine = recorder.captureSubmittedLineRecorder()!;
|
||||
recorder.recordInput("new input");
|
||||
await Promise.all([recordLine("echo first"), recordLine("cd /tmp")]);
|
||||
});
|
||||
assert.deepEqual(steps.map(step => [step.type, step.value]), [
|
||||
["send", "echo first"], ["waitForPrompt", undefined],
|
||||
["send", "cd /tmp"], ["waitForPrompt", undefined],
|
||||
]);
|
||||
steps.length = 0;
|
||||
await act(async () => {
|
||||
await recorder.recordEnter();
|
||||
assert.equal(steps[0].value, "new input");
|
||||
steps.length = 0;
|
||||
const recordLine = recorder.captureSubmittedLineRecorder()!;
|
||||
await Promise.all([recordLine("secret", { sensitive: true }), recordLine("second secret", { sensitive: true })]);
|
||||
});
|
||||
assert.deepEqual(steps.filter(step => step.type === "send").map(step => step.sensitive), [true, true]);
|
||||
const staleRecorder = recorder.captureSubmittedLineRecorder()!;
|
||||
await act(async () => { await recorder.stopRecording(); await recorder.startRecording(); });
|
||||
steps.length = 0;
|
||||
await act(async () => { await staleRecorder("never sent in this recording"); });
|
||||
assert.deepEqual(steps, []);
|
||||
const pauseGate = deferred<void>();
|
||||
appendGate = pauseGate.promise;
|
||||
const beforePause = recorder.captureSubmittedLineRecorder()!;
|
||||
const pausedFirst = beforePause("sent before pause 1");
|
||||
const pausedSecond = beforePause("sent before pause 2");
|
||||
await flushReact();
|
||||
act(() => recorder.pauseRecording());
|
||||
await beforePause("ignored while paused");
|
||||
pauseGate.resolve();
|
||||
await act(async () => { await Promise.all([pausedFirst, pausedSecond]); });
|
||||
assert.deepEqual(steps.map(step => [step.type, step.value]), [
|
||||
["send", "sent before pause 1"], ["waitForPrompt", undefined],
|
||||
["send", "sent before pause 2"], ["waitForPrompt", undefined],
|
||||
]);
|
||||
act(() => recorder.resumeRecording());
|
||||
steps.length = 0;
|
||||
const gate = deferred<void>();
|
||||
appendGate = gate.promise;
|
||||
const recordLine = recorder.captureSubmittedLineRecorder()!;
|
||||
const first = recordLine("sent first");
|
||||
const second = recordLine("sent second");
|
||||
await flushReact();
|
||||
let stopped!: ReturnType<Recorder["stopRecording"]>;
|
||||
act(() => { stopped = recorder.stopRecording(); });
|
||||
assert.equal(stopCalls, 1);
|
||||
await recordLine("arrived after stop");
|
||||
gate.resolve();
|
||||
await act(async () => { await Promise.all([first, second, stopped]); });
|
||||
assert.equal(stopCalls, 2);
|
||||
assert.deepEqual(steps.map(step => [step.type, step.value]), [
|
||||
["send", "sent first"], ["waitForPrompt", undefined],
|
||||
["send", "sent second"], ["waitForPrompt", undefined],
|
||||
]);
|
||||
} finally {
|
||||
await act(async () => renderer.unmount());
|
||||
}
|
||||
});
|
||||
|
||||
test("paced recording uses its captured prefix and never imports text typed while paused", async (t) => {
|
||||
const previousWindow = Object.getOwnPropertyDescriptor(globalThis, "window");
|
||||
const steps: Array<{ type: string; value?: unknown }> = [];
|
||||
const target = new EventTarget();
|
||||
Object.assign(target, {
|
||||
netcatty: {
|
||||
scriptRecordingStart: async () => ({ ok: true }),
|
||||
scriptRecordingAppendStep: async (_id: string, step: typeof steps[number]) => {
|
||||
steps.push(step);
|
||||
return { stopped: false };
|
||||
},
|
||||
}, setInterval, clearInterval,
|
||||
});
|
||||
Object.defineProperty(globalThis, "window", { configurable: true, value: target });
|
||||
t.after(() => {
|
||||
if (previousWindow) Object.defineProperty(globalThis, "window", previousWindow);
|
||||
else Reflect.deleteProperty(globalThis, "window");
|
||||
});
|
||||
let recorder!: Recorder;
|
||||
let renderer!: ReactTestRenderer;
|
||||
function Probe() { recorder = useScriptRecorder("s"); return null; }
|
||||
await act(async () => { renderer = create(React.createElement(Probe)); });
|
||||
try {
|
||||
await act(async () => {
|
||||
await recorder.startRecording();
|
||||
recorder.recordInput("echo ");
|
||||
recorder.pauseRecording();
|
||||
recorder.recordInput("PAUSED_");
|
||||
recorder.resumeRecording();
|
||||
const lineRecorder = recorder.captureSubmittedLineRecorder()!;
|
||||
recorder.recordInput("next input");
|
||||
await lineRecorder("first", { includePendingInput: true });
|
||||
await lineRecorder("second");
|
||||
await recorder.recordEnter();
|
||||
});
|
||||
assert.deepEqual(steps.filter(step => step.type === "send").map(step => step.value), ["echo first", "second", "next input"]);
|
||||
steps.length = 0;
|
||||
await act(async () => {
|
||||
recorder.recordInput("preserved ");
|
||||
recorder.captureSubmittedLineRecorder(); // No write receipt: rejected batch.
|
||||
recorder.recordInput("draft");
|
||||
await recorder.recordEnter();
|
||||
assert.equal(steps.find(step => step.type === "send")?.value, "preserved draft");
|
||||
steps.length = 0;
|
||||
recorder.recordInput("old ");
|
||||
const lateReceipt = recorder.captureSubmittedLineRecorder()!;
|
||||
recorder.recordClearLine();
|
||||
recorder.recordInput("old new draft");
|
||||
await lateReceipt("sent", { includePendingInput: true });
|
||||
await recorder.recordEnter();
|
||||
assert.deepEqual(steps.filter(step => step.type === "send").map(step => step.value), ["old sent", "old new draft"]);
|
||||
steps.length = 0;
|
||||
recorder.recordInput("prefix for a dropped first line");
|
||||
const lineRecorder = recorder.captureSubmittedLineRecorder()!;
|
||||
await lineRecorder("second was sent", { consumePendingInput: false });
|
||||
await recorder.recordEnter();
|
||||
});
|
||||
assert.deepEqual(steps.filter(step => step.type === "send").map(step => step.value), ["second was sent", "prefix for a dropped first line"]);
|
||||
} finally {
|
||||
await act(async () => renderer.unmount());
|
||||
}
|
||||
});
|
||||
@@ -27,12 +27,16 @@ export function useScriptRecorder(sessionId: string | undefined) {
|
||||
const [elapsedMs, setElapsedMs] = useState(0);
|
||||
const startedAtRef = useRef<number | null>(null);
|
||||
const inputBufferRef = useRef('');
|
||||
const inputRevisionRef = useRef(0);
|
||||
const lastStepAtRef = useRef<number>(Date.now());
|
||||
const isRecordingRef = useRef(false);
|
||||
const isPausedRef = useRef(false);
|
||||
const isStoppingRef = useRef(false);
|
||||
const stopPromiseRef = useRef<Promise<ScriptRecordingResult> | null>(null);
|
||||
const sessionIdRef = useRef(sessionId);
|
||||
const recordingGenerationRef = useRef(0);
|
||||
const pendingRecordStepsRef = useRef(Promise.resolve());
|
||||
const isDrainingRecordingRef = useRef(false);
|
||||
|
||||
sessionIdRef.current = sessionId;
|
||||
|
||||
@@ -60,8 +64,10 @@ export function useScriptRecorder(sessionId: string | undefined) {
|
||||
const bridge = netcattyBridge.get();
|
||||
if (!sid || !bridge?.scriptRecordingStart) return;
|
||||
await bridge.scriptRecordingStart(sid);
|
||||
recordingGenerationRef.current += 1;
|
||||
startedAtRef.current = Date.now();
|
||||
lastStepAtRef.current = Date.now();
|
||||
inputRevisionRef.current += 1;
|
||||
inputBufferRef.current = '';
|
||||
setElapsedMs(0);
|
||||
setIsPaused(false);
|
||||
@@ -76,27 +82,26 @@ export function useScriptRecorder(sessionId: string | undefined) {
|
||||
const sid = sessionIdRef.current;
|
||||
const bridge = netcattyBridge.get();
|
||||
isStoppingRef.current = true;
|
||||
isDrainingRecordingRef.current = true;
|
||||
isRecordingRef.current = false;
|
||||
isPausedRef.current = false;
|
||||
setIsRecording(false);
|
||||
setIsPaused(false);
|
||||
inputRevisionRef.current += 1;
|
||||
inputBufferRef.current = '';
|
||||
startedAtRef.current = null;
|
||||
if (!sid || !bridge?.scriptRecordingStop) {
|
||||
isStoppingRef.current = false;
|
||||
isDrainingRecordingRef.current = false;
|
||||
return emptyScriptRecordingResult();
|
||||
}
|
||||
let stopRequest: Promise<ScriptRecordingResult>;
|
||||
try {
|
||||
stopRequest = Promise.resolve(bridge.scriptRecordingStop(sid));
|
||||
} catch (error) {
|
||||
stopRequest = Promise.reject(error);
|
||||
}
|
||||
const stopRequest = pendingRecordStepsRef.current.catch(() => {}).then(() => bridge.scriptRecordingStop!(sid));
|
||||
let stopPromise: Promise<ScriptRecordingResult>;
|
||||
stopPromise = stopRequest.finally(() => {
|
||||
if (stopPromiseRef.current === stopPromise) {
|
||||
stopPromiseRef.current = null;
|
||||
isStoppingRef.current = false;
|
||||
isDrainingRecordingRef.current = false;
|
||||
}
|
||||
});
|
||||
stopPromiseRef.current = stopPromise;
|
||||
@@ -104,8 +109,10 @@ export function useScriptRecorder(sessionId: string | undefined) {
|
||||
}, []);
|
||||
|
||||
const finishAutomaticStop = useCallback((detail: ScriptRecordingLimitDetail) => {
|
||||
isDrainingRecordingRef.current = false;
|
||||
isRecordingRef.current = false;
|
||||
isPausedRef.current = false;
|
||||
inputRevisionRef.current += 1;
|
||||
inputBufferRef.current = '';
|
||||
startedAtRef.current = null;
|
||||
setIsRecording(false);
|
||||
@@ -126,10 +133,13 @@ export function useScriptRecorder(sessionId: string | undefined) {
|
||||
setIsPaused(false);
|
||||
}, []);
|
||||
|
||||
const appendStep = useCallback(async (step: ScriptRecordingStep) => {
|
||||
const appendStep = useCallback(async (step: ScriptRecordingStep, accepted = false) => {
|
||||
const sid = sessionIdRef.current;
|
||||
if (!sid || !isRecordingRef.current || isPausedRef.current || isStoppingRef.current) return;
|
||||
if (!sid || (!(accepted && isDrainingRecordingRef.current)
|
||||
&& (!isRecordingRef.current || (!accepted && isPausedRef.current) || isStoppingRef.current))) return;
|
||||
const generation = recordingGenerationRef.current;
|
||||
const result = await netcattyBridge.get()?.scriptRecordingAppendStep?.(sid, step);
|
||||
if (generation !== recordingGenerationRef.current || sid !== sessionIdRef.current) return;
|
||||
if (result?.stopped) {
|
||||
finishAutomaticStop({
|
||||
sessionId: sid,
|
||||
@@ -154,6 +164,7 @@ export function useScriptRecorder(sessionId: string | undefined) {
|
||||
isStoppingRef.current = true;
|
||||
isRecordingRef.current = false;
|
||||
isPausedRef.current = false;
|
||||
inputRevisionRef.current += 1;
|
||||
inputBufferRef.current = '';
|
||||
startedAtRef.current = null;
|
||||
setIsRecording(false);
|
||||
@@ -188,33 +199,68 @@ export function useScriptRecorder(sessionId: string | undefined) {
|
||||
|
||||
const recordBackspace = useCallback(() => {
|
||||
if (!isRecordingRef.current || isPausedRef.current || isStoppingRef.current) return;
|
||||
inputRevisionRef.current += 1;
|
||||
inputBufferRef.current = inputBufferRef.current.slice(0, -1);
|
||||
}, []);
|
||||
|
||||
const recordClearLine = useCallback(() => {
|
||||
if (!isRecordingRef.current || isPausedRef.current || isStoppingRef.current) return;
|
||||
inputRevisionRef.current += 1;
|
||||
inputBufferRef.current = '';
|
||||
}, []);
|
||||
|
||||
const recordEnter = useCallback(async (options?: { sensitive?: boolean }) => {
|
||||
const recordEnter = useCallback(async (options?: { sensitive?: boolean; submittedLine?: string }) => {
|
||||
const sid = sessionIdRef.current;
|
||||
if (!isRecordingRef.current || isPausedRef.current || isStoppingRef.current || !sid) return;
|
||||
const line = inputBufferRef.current;
|
||||
inputBufferRef.current = '';
|
||||
const line = options?.submittedLine ?? inputBufferRef.current;
|
||||
if (options?.submittedLine === undefined) {
|
||||
inputRevisionRef.current += 1;
|
||||
inputBufferRef.current = '';
|
||||
}
|
||||
const now = Date.now();
|
||||
const gap = now - lastStepAtRef.current;
|
||||
if (gap > 1000) {
|
||||
await appendStep({ type: 'sleep', value: gap });
|
||||
}
|
||||
await appendStep({
|
||||
type: 'send',
|
||||
value: line,
|
||||
sensitive: options?.sensitive,
|
||||
});
|
||||
await appendStep({ type: 'waitForPrompt', timeoutMs: DEFAULT_RECORDING_PROMPT_TIMEOUT_MS });
|
||||
lastStepAtRef.current = now;
|
||||
const generation = recordingGenerationRef.current;
|
||||
const pending = pendingRecordStepsRef.current.catch(() => {}).then(async () => {
|
||||
const isCurrent = () => generation === recordingGenerationRef.current && sid === sessionIdRef.current
|
||||
&& (isRecordingRef.current || isDrainingRecordingRef.current);
|
||||
if (!isCurrent()) return;
|
||||
if (gap > 1000) await appendStep({ type: 'sleep', value: gap }, true);
|
||||
if (!isCurrent()) return;
|
||||
await appendStep({ type: 'send', value: line, sensitive: options?.sensitive }, true);
|
||||
if (!isCurrent()) return;
|
||||
await appendStep({ type: 'waitForPrompt', timeoutMs: DEFAULT_RECORDING_PROMPT_TIMEOUT_MS }, true);
|
||||
});
|
||||
pendingRecordStepsRef.current = pending;
|
||||
await pending;
|
||||
}, [appendStep]);
|
||||
|
||||
// Delayed paste receipts must not consume newly typed input or leak into a
|
||||
// recording started after the paste. Each receipt records one actual write.
|
||||
const captureSubmittedLineRecorder = useCallback(() => {
|
||||
if (!isRecordingRef.current || isPausedRef.current || isStoppingRef.current) return undefined;
|
||||
const generation = recordingGenerationRef.current;
|
||||
const sid = sessionIdRef.current;
|
||||
const pendingInput = inputBufferRef.current;
|
||||
const revision = inputRevisionRef.current;
|
||||
let consumed = false;
|
||||
const isCurrent = () => generation === recordingGenerationRef.current
|
||||
&& sid === sessionIdRef.current && isRecordingRef.current
|
||||
&& !isPausedRef.current && !isStoppingRef.current;
|
||||
return (line: string, options?: { sensitive?: boolean; includePendingInput?: boolean; consumePendingInput?: boolean }): Promise<void> => {
|
||||
// Capturing a batch is not a write. Retain its prefix if no receipt
|
||||
// arrives; only its prefixed first line can consume the still-owned draft.
|
||||
if (options?.consumePendingInput !== false && !consumed && generation === recordingGenerationRef.current && sid === sessionIdRef.current
|
||||
&& revision === inputRevisionRef.current && inputBufferRef.current.startsWith(pendingInput)) {
|
||||
inputBufferRef.current = inputBufferRef.current.slice(pendingInput.length);
|
||||
inputRevisionRef.current += 1;
|
||||
}
|
||||
if (options?.consumePendingInput !== false) consumed = true;
|
||||
if (!isCurrent()) return Promise.resolve();
|
||||
return recordEnter({ sensitive: options?.sensitive, submittedLine: options?.includePendingInput ? `${pendingInput}${line}` : line });
|
||||
};
|
||||
}, [recordEnter]);
|
||||
|
||||
return {
|
||||
isRecording,
|
||||
isPaused,
|
||||
@@ -227,5 +273,6 @@ export function useScriptRecorder(sessionId: string | undefined) {
|
||||
recordBackspace,
|
||||
recordClearLine,
|
||||
recordEnter,
|
||||
captureSubmittedLineRecorder,
|
||||
};
|
||||
}
|
||||
@@ -207,13 +207,14 @@ export const useTerminalBackend = () => {
|
||||
bridge?.notifySessionUserInput?.(sessionId);
|
||||
}, []);
|
||||
|
||||
const interruptSession = useCallback((sessionId: string, trace?: NetcattyTerminalInterruptTrace) => {
|
||||
const interruptSession = useCallback((...args: Parameters<NonNullable<NetcattyBridge["interruptSession"]>>) => {
|
||||
const [sessionId, , options] = args;
|
||||
const bridge = netcattyBridge.get();
|
||||
if (bridge?.interruptSession) {
|
||||
bridge.interruptSession(sessionId, trace);
|
||||
bridge.interruptSession(...args);
|
||||
return;
|
||||
}
|
||||
bridge?.writeToSession?.(sessionId, "\x03");
|
||||
if (!options?.cancelPendingWritesOnly) bridge?.writeToSession?.(sessionId, "\x03");
|
||||
}, []);
|
||||
|
||||
const resizeSession = useCallback((sessionId: string, cols: number, rows: number) => {
|
||||
|
||||
@@ -268,6 +268,7 @@ const SYNCABLE_TERMINAL_KEYS = [
|
||||
'linkModifier', 'keywordHighlightEnabled', 'keywordHighlightRules',
|
||||
'keepaliveInterval', 'keepaliveCountMax', 'disableBracketedPaste', 'clearWipesScrollback',
|
||||
'autoUploadClipboardImageOnPaste',
|
||||
'confirmBeforeMultilinePaste', 'multilinePasteConfirmMinLines',
|
||||
'preserveSelectionOnInput', 'forcePromptNewLine', 'osc52Clipboard', 'oscNotifications', 'dynamicTabTitleMode', 'tabDoubleClickBehavior',
|
||||
'autoCloseOnExit', 'disconnectedNoticeMode',
|
||||
'showHostInfoBar', 'hostInfoBarTitleMode', 'showServerStats',
|
||||
|
||||
+24
-2
@@ -1,3 +1,4 @@
|
||||
import { clearTerminalBroadcastUserInput, markTerminalBroadcastUserInput } from "./terminal/runtime/terminalPacedBroadcast";
|
||||
import { publishTerminalCommandCompletion } from "../application/state/terminalCommandCompletion";
|
||||
import { createTerminalReflowReadingPosition } from "./terminal/terminalReflowReadingPosition";
|
||||
import { resolveHostOs } from '../domain/host';
|
||||
@@ -52,6 +53,7 @@ import { CONNECTION_PROGRESS_START } from "./terminal/connectionProgress";
|
||||
import { supportsZmodemTerminalDragDrop } from "../lib/zmodemDragDrop";
|
||||
import { resolveHostAuth, resolveHostAutofillPassword } from "../domain/sshAuth";
|
||||
import { resolveEffectiveTerminalProtocol } from "../domain/terminalProtocol";
|
||||
import { MULTILINE_PASTE_CONFIRM_MIN_LINES_DEFAULT } from "../domain/terminalPasteConfirm";
|
||||
import { isPluginHostProtocol } from "../domain/pluginConnection";
|
||||
import { clearTerminalBootEpoch, setTerminalBootEpoch } from "../domain/terminalBootEpoch";
|
||||
import {
|
||||
@@ -434,6 +436,7 @@ const TerminalComponent: React.FC<TerminalProps> = ({
|
||||
sessionId,
|
||||
() => passwordPromptActiveRef.current,
|
||||
), [sessionId]);
|
||||
useEffect(() => () => clearTerminalBroadcastUserInput(sessionId), [sessionId]);
|
||||
const sensitivePromptOutputTailRef = useRef("");
|
||||
const [activeScriptRun, setActiveScriptRun] = useState<import('@/types/global/netcatty-bridge-script.d.ts').ScriptRun | undefined>(undefined);
|
||||
const dismissedScriptRunIdsRef = useRef(new Set<string>());
|
||||
@@ -998,6 +1001,10 @@ const TerminalComponent: React.FC<TerminalProps> = ({
|
||||
autocompleteAcceptTextRef.current = (text: string) => {
|
||||
const id = sessionRef.current;
|
||||
if (id && text) {
|
||||
markTerminalBroadcastUserInput(sessionId);
|
||||
if (["\r", "\n", "\b", "\x7f", "\x15"].some(control => text.includes(control))) {
|
||||
xtermRuntimeRef.current?.invalidatePendingPasteDraft();
|
||||
}
|
||||
const sensitive = passwordPromptActiveRef.current;
|
||||
let textToWrite = text;
|
||||
let handledSubmittedInput = false;
|
||||
@@ -3216,6 +3223,14 @@ const TerminalComponent: React.FC<TerminalProps> = ({
|
||||
normalizeTextOnCopyRef.current = terminalSettings?.normalizeTextOnCopy ?? true;
|
||||
const autoUploadClipboardImageOnPasteRef = useRef(terminalSettings?.autoUploadClipboardImageOnPaste ?? false);
|
||||
autoUploadClipboardImageOnPasteRef.current = terminalSettings?.autoUploadClipboardImageOnPaste ?? false;
|
||||
const multilinePasteConfirmRef = useRef({
|
||||
enabled: terminalSettings?.confirmBeforeMultilinePaste ?? false,
|
||||
minLines: terminalSettings?.multilinePasteConfirmMinLines ?? MULTILINE_PASTE_CONFIRM_MIN_LINES_DEFAULT,
|
||||
});
|
||||
multilinePasteConfirmRef.current = {
|
||||
enabled: terminalSettings?.confirmBeforeMultilinePaste ?? false,
|
||||
minLines: terminalSettings?.multilinePasteConfirmMinLines ?? MULTILINE_PASTE_CONFIRM_MIN_LINES_DEFAULT,
|
||||
};
|
||||
|
||||
const scrollToBottomAfterProgrammaticInput = useCallback((data: string) => {
|
||||
if (!termRef.current) return;
|
||||
@@ -3240,14 +3255,17 @@ const TerminalComponent: React.FC<TerminalProps> = ({
|
||||
termRef.current?.scrollToBottom();
|
||||
}, [activeScriptRun]);
|
||||
|
||||
const broadcastUserPasteData = useCallback((data: string) => {
|
||||
const broadcastUserPasteData = useCallback((
|
||||
data: string,
|
||||
options?: { lineDelayMs?: number },
|
||||
) => {
|
||||
if (
|
||||
!passwordPromptActiveRef.current
|
||||
&& sessionRef.current
|
||||
&& isBroadcastEnabledRef.current
|
||||
&& onBroadcastInputRef.current
|
||||
) {
|
||||
onBroadcastInputRef.current(data, sessionId);
|
||||
onBroadcastInputRef.current(data, sessionId, options);
|
||||
return true;
|
||||
}
|
||||
return false;
|
||||
@@ -3299,6 +3317,7 @@ const TerminalComponent: React.FC<TerminalProps> = ({
|
||||
? AUTO_RUN_SNIPPET_LINE_DELAY_MS
|
||||
: undefined;
|
||||
const isMultiLine = data.includes('\n');
|
||||
if (lineDelayMs) markTerminalBroadcastUserInput(sessionId);
|
||||
// Wrap in bracketed paste BEFORE appending \r so the Enter is sent
|
||||
// outside the paste markers — otherwise shells treat it as pasted text
|
||||
// instead of a submit action.
|
||||
@@ -3317,6 +3336,7 @@ const TerminalComponent: React.FC<TerminalProps> = ({
|
||||
const sensitive = passwordPromptActiveRef.current;
|
||||
if (!sensitive && options?.broadcast !== false && isBroadcastEnabledRef.current && onBroadcastInputRef.current) {
|
||||
onBroadcastInputRef.current(data, sessionId, {
|
||||
automated: true,
|
||||
noAutoRun,
|
||||
...(lineDelayMs ? { lineDelayMs } : {}),
|
||||
});
|
||||
@@ -3391,6 +3411,7 @@ const TerminalComponent: React.FC<TerminalProps> = ({
|
||||
isLocalConnection,
|
||||
supportsRemoteImagePaste,
|
||||
autoUploadClipboardImageOnPasteRef,
|
||||
multilinePasteConfirmRef,
|
||||
terminalBackend,
|
||||
getRemoteCwd: () => resolveSftpInitialPath({ preferFreshBackend: true }),
|
||||
scrollToBottomAfterProgrammaticInput,
|
||||
@@ -3972,6 +3993,7 @@ const TerminalComponent: React.FC<TerminalProps> = ({
|
||||
containerRef,
|
||||
autoUploadClipboardImage:
|
||||
supportsRemoteImagePaste && terminalSettings?.autoUploadClipboardImageOnPaste === true,
|
||||
multilinePasteConfirmRef,
|
||||
getRemoteCwd: () => resolveSftpInitialPath({ preferFreshBackend: true }),
|
||||
onClipboardImageUploadResult: handleClipboardImageUploadResult,
|
||||
});
|
||||
|
||||
@@ -1,3 +1,4 @@
|
||||
import { captureTerminalBroadcastInput, isTerminalBroadcastInputCurrent, markTerminalBroadcastUserInput } from "./terminal/runtime/terminalPacedBroadcast";
|
||||
import { pruneComposeBarHistory } from '../application/state/composeBarHistoryStore';
|
||||
import { FolderTree, History, MessageSquare, PanelLeft, PanelRight, Palette, X, Zap } from 'lucide-react';
|
||||
import React, { memo, startTransition, useCallback, useEffect, useMemo, useRef, useState } from 'react';
|
||||
@@ -1070,12 +1071,26 @@ const TerminalLayerInner: React.FC<TerminalLayerProps> = ({
|
||||
sourceSessionId: string,
|
||||
options?: TerminalBroadcastInputOptions,
|
||||
) => {
|
||||
const paced = options?.pacedBroadcast;
|
||||
if (paced && !options?.preparePacedBroadcast) {
|
||||
if (!paced.targets || isTerminalSensitiveInputActive(sourceSessionId)) return [];
|
||||
paced.targets = paced.targets.filter(target => isTerminalBroadcastInputCurrent(target)
|
||||
&& !isTerminalSensitiveInputActive(target.sessionId));
|
||||
}
|
||||
const targetSessionIds = resolveTerminalBroadcastTargetIds({
|
||||
sessions: sessionsRef.current,
|
||||
sourceSessionId,
|
||||
globalBroadcastEnabled: isGlobalBroadcastEnabled,
|
||||
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 => {
|
||||
markTerminalBroadcastUserInput(id);
|
||||
terminalBackend.interruptSession(id, undefined, { cancelPendingWritesOnly: true });
|
||||
return captureTerminalBroadcastInput(id);
|
||||
});
|
||||
return paced.targets.map(target => target.sessionId);
|
||||
}
|
||||
if (targetSessionIds.length === 0) return [];
|
||||
|
||||
const targetSessionIdSet = new Set(targetSessionIds);
|
||||
@@ -1084,6 +1099,7 @@ const TerminalLayerInner: React.FC<TerminalLayerProps> = ({
|
||||
for (const session of sessionsRef.current) {
|
||||
if (!targetSessionIdSet.has(session.id)) continue;
|
||||
if (!canUseDirectSessionWriteFallback(session)) continue;
|
||||
if (!paced && (options?.automated !== true || options.lineDelayMs)) markTerminalBroadcastUserInput(session.id);
|
||||
|
||||
if (options?.kittyKeyboardInput) {
|
||||
dispatchKittyKeyboardBroadcastInput(session.id, options.kittyKeyboardInput, {
|
||||
@@ -1100,6 +1116,7 @@ const TerminalLayerInner: React.FC<TerminalLayerProps> = ({
|
||||
&& isPluginHostProtocol(session.protocol)
|
||||
&& terminalBackend.signalPluginConnection) {
|
||||
broadcastInterruptPrioritizersRef.current.get(session.id)?.();
|
||||
terminalBackend.interruptSession(session.id, undefined, { cancelPendingWritesOnly: true });
|
||||
void terminalBackend.signalPluginConnection(session.id, "interrupt").catch(() => {
|
||||
terminalBackend.interruptSession(session.id);
|
||||
});
|
||||
@@ -1113,7 +1130,7 @@ const TerminalLayerInner: React.FC<TerminalLayerProps> = ({
|
||||
}
|
||||
if (isTerminalSensitiveInputActive(session.id)) continue;
|
||||
terminalBackend.writeToSession(session.id, data, {
|
||||
automated: true,
|
||||
automated: options?.automated === true,
|
||||
sensitive: false,
|
||||
...(lineDelayMs ? { lineDelayMs } : {}),
|
||||
});
|
||||
|
||||
@@ -145,11 +145,11 @@ test('popup terminals use their window-local vault readiness', () => {
|
||||
assert.match(source, /vaultInitializedOverride=\{vaultInitialized\}/);
|
||||
});
|
||||
|
||||
test('popup provider tree mounts the plugin authentication host', () => {
|
||||
test('popup provider tree mounts authentication and paste confirmation hosts', () => {
|
||||
assert.match(source, /import \{ PluginAuthenticationHost \} from '\.\/plugins\/PluginAuthenticationHost';/);
|
||||
assert.match(
|
||||
source,
|
||||
/<I18nProvider locale=\{settings\.uiLanguage\}>\s+<TerminalPopupPageInner\s+settings=\{settings\}\s+allowTerminalStart=\{allowTerminalStart\}\s+\/>\s+<PluginAuthenticationHost \/>\s+<\/I18nProvider>/,
|
||||
/<I18nProvider locale=\{settings\.uiLanguage\}>\s+<TerminalPopupPageInner\s+settings=\{settings\}\s+allowTerminalStart=\{allowTerminalStart\}\s+\/>\s+<PluginAuthenticationHost \/>\s+<MultilinePasteConfirmHost \/>\s+<\/I18nProvider>/,
|
||||
);
|
||||
});
|
||||
|
||||
|
||||
@@ -2,6 +2,7 @@ import { setupVaultAgentBridge } from "../infrastructure/ai/vaultAgentBridgeClie
|
||||
import { Copy, Minus, Square, Unplug, X } from 'lucide-react';
|
||||
import React, { lazy, Suspense, useCallback, useEffect, useMemo, useState } from 'react';
|
||||
import { I18nProvider, useI18n } from '../application/i18n/I18nProvider';
|
||||
import { MultilinePasteConfirmHost } from './terminal/MultilinePasteConfirmDialog';
|
||||
import { useTerminalPopupWindow } from '../application/state/useTerminalPopupWindow';
|
||||
import { useVaultState } from '../application/state/useVaultState';
|
||||
import { useWindowControls } from '../application/state/useWindowControls';
|
||||
@@ -547,6 +548,7 @@ export default function TerminalPopupPage({
|
||||
allowTerminalStart={allowTerminalStart}
|
||||
/>
|
||||
<PluginAuthenticationHost />
|
||||
<MultilinePasteConfirmHost />
|
||||
</I18nProvider>
|
||||
);
|
||||
}
|
||||
@@ -180,6 +180,37 @@ export const TerminalBehaviorSettings: React.FC<TerminalBehaviorSettingsProps> =
|
||||
/>
|
||||
</SettingRow>
|
||||
|
||||
<SettingRow
|
||||
anchorId="terminal-confirm-multiline-paste"
|
||||
label={t("settings.terminal.behavior.confirmMultilinePaste")}
|
||||
description={t("settings.terminal.behavior.confirmMultilinePaste.desc")}
|
||||
>
|
||||
<Toggle
|
||||
checked={terminalSettings.confirmBeforeMultilinePaste ?? false}
|
||||
onChange={(v) => updateTerminalSetting("confirmBeforeMultilinePaste", v)}
|
||||
/>
|
||||
</SettingRow>
|
||||
|
||||
<SettingRow
|
||||
anchorId="terminal-confirm-multiline-paste-min-lines"
|
||||
label={t("settings.terminal.behavior.confirmMultilinePasteMinLines")}
|
||||
description={t("settings.terminal.behavior.confirmMultilinePasteMinLines.desc")}
|
||||
>
|
||||
<Input
|
||||
type="number"
|
||||
min={1}
|
||||
max={1000}
|
||||
value={terminalSettings.multilinePasteConfirmMinLines ?? 2}
|
||||
onChange={(e) => {
|
||||
const val = parseInt(e.target.value);
|
||||
if (!isNaN(val) && val >= 1 && val <= 1000) {
|
||||
updateTerminalSetting("multilinePasteConfirmMinLines", val);
|
||||
}
|
||||
}}
|
||||
className="w-24"
|
||||
/>
|
||||
</SettingRow>
|
||||
|
||||
<SettingRow
|
||||
anchorId="terminal-shift-enter-newline"
|
||||
label={t("settings.terminal.behavior.shiftEnterNewline")}
|
||||
|
||||
@@ -0,0 +1,85 @@
|
||||
import assert from 'node:assert/strict';
|
||||
import test from 'node:test';
|
||||
import { JSDOM } from 'jsdom';
|
||||
|
||||
test('multiline paste dialog returns decisions and restores terminal focus after closing', async () => {
|
||||
const dom = new JSDOM('<!doctype html><html><body><div id="root"></div></body></html>', {
|
||||
pretendToBeVisual: true,
|
||||
url: 'http://localhost',
|
||||
});
|
||||
const window = dom.window;
|
||||
const previousGlobals = new Map<string, PropertyDescriptor | undefined>();
|
||||
const installGlobal = (key: string, value: unknown) => {
|
||||
previousGlobals.set(key, Object.getOwnPropertyDescriptor(globalThis, key));
|
||||
Object.defineProperty(globalThis, key, {
|
||||
configurable: true,
|
||||
writable: true,
|
||||
value,
|
||||
});
|
||||
};
|
||||
|
||||
class ResizeObserverStub {
|
||||
observe() {}
|
||||
unobserve() {}
|
||||
disconnect() {}
|
||||
}
|
||||
|
||||
installGlobal('window', window);
|
||||
installGlobal('document', window.document);
|
||||
installGlobal('navigator', window.navigator);
|
||||
installGlobal('HTMLElement', window.HTMLElement);
|
||||
installGlobal('HTMLInputElement', window.HTMLInputElement);
|
||||
installGlobal('HTMLTextAreaElement', window.HTMLTextAreaElement);
|
||||
installGlobal('Element', window.Element);
|
||||
installGlobal('SVGElement', window.SVGElement);
|
||||
installGlobal('Node', window.Node);
|
||||
installGlobal('NodeFilter', window.NodeFilter);
|
||||
installGlobal('MutationObserver', window.MutationObserver);
|
||||
installGlobal('CustomEvent', window.CustomEvent);
|
||||
installGlobal('DOMRect', window.DOMRect);
|
||||
installGlobal('Event', window.Event);
|
||||
installGlobal('KeyboardEvent', window.KeyboardEvent);
|
||||
installGlobal('MouseEvent', window.MouseEvent);
|
||||
installGlobal('getComputedStyle', window.getComputedStyle.bind(window));
|
||||
installGlobal('requestAnimationFrame', window.requestAnimationFrame.bind(window));
|
||||
installGlobal('cancelAnimationFrame', window.cancelAnimationFrame.bind(window));
|
||||
installGlobal('ResizeObserver', ResizeObserverStub);
|
||||
installGlobal('IS_REACT_ACT_ENVIRONMENT', true);
|
||||
|
||||
const { default: React, act } = await import('react');
|
||||
const { createRoot } = await import('react-dom/client');
|
||||
const { I18nProvider } = await import('../../application/i18n/I18nProvider.tsx');
|
||||
const { MultilinePasteConfirmHost } = await import('./MultilinePasteConfirmDialog.tsx');
|
||||
const { requestMultilinePasteConfirm } = await import('../../application/state/multilinePasteConfirmStore.ts');
|
||||
const terminalInput = window.document.createElement('textarea');
|
||||
window.document.body.appendChild(terminalInput);
|
||||
const root = createRoot(window.document.getElementById('root')!);
|
||||
try {
|
||||
await act(async () => root.render(<I18nProvider locale="en"><MultilinePasteConfirmHost /></I18nProvider>));
|
||||
for (const [label, action] of [['Send', 'send'], ['Send line by line', 'line-by-line'], ['Cancel', 'cancel']]) {
|
||||
terminalInput.focus();
|
||||
let decision!: ReturnType<typeof requestMultilinePasteConfirm>;
|
||||
await act(async () => {
|
||||
decision = requestMultilinePasteConfirm({
|
||||
text: 'show run\nshow version', lineCount: 2, charCount: 21,
|
||||
onClose: () => terminalInput.focus(),
|
||||
});
|
||||
});
|
||||
assert.ok(window.document.querySelector('[role="dialog"]'));
|
||||
const button = Array.from(window.document.querySelectorAll('button')).find(item => item.textContent === label);
|
||||
assert.ok(button);
|
||||
await act(async () => button.click());
|
||||
await act(async () => { await new Promise(resolve => setTimeout(resolve, 20)); });
|
||||
assert.deepEqual(await decision, { action, text: 'show run\nshow version' });
|
||||
assert.equal(window.document.querySelector('[role="dialog"]'), null);
|
||||
assert.equal(window.document.activeElement, terminalInput, `${label} should restore focus`);
|
||||
}
|
||||
} finally {
|
||||
await act(async () => root.unmount());
|
||||
window.close();
|
||||
for (const [key, descriptor] of previousGlobals) {
|
||||
if (descriptor) Object.defineProperty(globalThis, key, descriptor);
|
||||
else Reflect.deleteProperty(globalThis, key);
|
||||
}
|
||||
}
|
||||
});
|
||||
@@ -0,0 +1,88 @@
|
||||
import React, { useState } from "react";
|
||||
import { useSyncExternalStore } from "react";
|
||||
|
||||
import { useI18n } from "../../application/i18n/I18nProvider";
|
||||
import {
|
||||
getPendingMultilinePasteConfirm,
|
||||
respondMultilinePasteConfirm,
|
||||
subscribeMultilinePasteConfirm,
|
||||
} from "../../application/state/multilinePasteConfirmStore";
|
||||
import { Button } from "../ui/button";
|
||||
import { Dialog, DialogContent, DialogFooter, DialogHeader, DialogTitle } from "../ui/dialog";
|
||||
import { Textarea } from "../ui/textarea";
|
||||
import type { MultilinePasteConfirmRequest } from "../../application/state/multilinePasteConfirmStore";
|
||||
|
||||
/**
|
||||
* Always-mounted host: renders the multi-line paste confirmation dialog while
|
||||
* a paste request is pending (#3398). The preview is editable — the edited
|
||||
* content is what actually gets sent.
|
||||
*/
|
||||
export const MultilinePasteConfirmHost: React.FC = () => {
|
||||
const request = useSyncExternalStore(
|
||||
subscribeMultilinePasteConfirm,
|
||||
getPendingMultilinePasteConfirm,
|
||||
getPendingMultilinePasteConfirm,
|
||||
);
|
||||
if (!request) return null;
|
||||
return <MultilinePasteConfirmDialog key={request.id} request={request} />;
|
||||
};
|
||||
|
||||
const MultilinePasteConfirmDialog: React.FC<{ request: MultilinePasteConfirmRequest }> = ({
|
||||
request,
|
||||
}) => {
|
||||
const { t } = useI18n();
|
||||
const [previewText, setPreviewText] = useState(request.text);
|
||||
|
||||
const respond = (action: "send" | "line-by-line") =>
|
||||
respondMultilinePasteConfirm(action, previewText);
|
||||
|
||||
return (
|
||||
<Dialog
|
||||
open
|
||||
onOpenChange={(open) => {
|
||||
if (!open) respondMultilinePasteConfirm("cancel");
|
||||
}}
|
||||
>
|
||||
<DialogContent
|
||||
className="sm:max-w-[520px]"
|
||||
onCloseAutoFocus={(event) => {
|
||||
if (request.onClose) {
|
||||
event.preventDefault();
|
||||
request.onClose();
|
||||
}
|
||||
}}
|
||||
>
|
||||
<DialogHeader>
|
||||
<DialogTitle>{t("terminal.pasteConfirm.title")}</DialogTitle>
|
||||
</DialogHeader>
|
||||
<p className="text-sm text-muted-foreground">
|
||||
{t("terminal.pasteConfirm.summary", {
|
||||
lines: request.lineCount,
|
||||
chars: request.charCount,
|
||||
})}
|
||||
</p>
|
||||
<div className="space-y-1">
|
||||
<label className="text-xs text-muted-foreground">
|
||||
{t("terminal.pasteConfirm.preview")}
|
||||
</label>
|
||||
<Textarea
|
||||
value={previewText}
|
||||
onChange={(e) => setPreviewText(e.target.value)}
|
||||
className="max-h-64 min-h-32 font-mono text-xs"
|
||||
spellCheck={false}
|
||||
autoFocus
|
||||
/>
|
||||
</div>
|
||||
<DialogFooter>
|
||||
<Button variant="ghost" onClick={() => respondMultilinePasteConfirm("cancel")}>
|
||||
{t("terminal.pasteConfirm.cancel")}
|
||||
</Button>
|
||||
<Button variant="outline" onClick={() => respond("line-by-line")}>
|
||||
{t("terminal.pasteConfirm.sendLineByLine")}
|
||||
</Button>
|
||||
<Button onClick={() => respond("send")}>{t("terminal.pasteConfirm.send")}</Button>
|
||||
</DialogFooter>
|
||||
</DialogContent>
|
||||
</Dialog>
|
||||
);
|
||||
};
|
||||
@@ -1,15 +1,17 @@
|
||||
import type { Terminal as XTerm } from "@xterm/xterm";
|
||||
import { useCallback, useRef } from "react";
|
||||
import type { RefObject } from "react";
|
||||
import { requestMultilinePasteConfirm } from "../../../application/state/multilinePasteConfirmStore";
|
||||
import { netcattyBridge } from "../../../infrastructure/services/netcattyBridge";
|
||||
import { logger } from "../../../lib/logger";
|
||||
import { pasteTextIntoTerminal } from "../runtime/terminalUserPaste";
|
||||
import type { MultilinePasteConfirmGate } from "../terminalClipboardPaste";
|
||||
import type { TerminalBroadcastInputOptions } from "../terminalHelpers";
|
||||
import { clearTerminalViewportAndSyncPty } from "../clearTerminalViewport";
|
||||
import {
|
||||
handleRemoteClipboardImageUpload,
|
||||
type RemoteClipboardImageUploadResult,
|
||||
} from "../clipboardImagePaste";
|
||||
import { handleTerminalClipboardPaste } from "../terminalClipboardPaste";
|
||||
import { handleTerminalClipboardPaste, pasteTextWithMultilineConfirm } from "../terminalClipboardPaste";
|
||||
import { pulseCopyOnSelectUserCommand } from "../copyOnSelect";
|
||||
import { getTerminalSelectionForClipboard } from "../normalizeTerminalSelection";
|
||||
import {
|
||||
@@ -27,7 +29,9 @@ type BroadcastPasteRefs = {
|
||||
sourceSessionId: string;
|
||||
sessionRef: RefObject<string | null>;
|
||||
isBroadcastEnabledRef?: RefObject<boolean | undefined>;
|
||||
onBroadcastInputRef?: RefObject<((data: string, sourceSessionId: string) => void) | undefined>;
|
||||
onBroadcastInputRef?: RefObject<
|
||||
((data: string, sourceSessionId: string, options?: TerminalBroadcastInputOptions) => void) | undefined
|
||||
>;
|
||||
passwordPromptActiveRef?: RefObject<boolean | undefined>;
|
||||
};
|
||||
|
||||
@@ -40,6 +44,7 @@ export const broadcastTerminalPasteData = (
|
||||
onBroadcastInputRef,
|
||||
passwordPromptActiveRef,
|
||||
}: BroadcastPasteRefs,
|
||||
options?: TerminalBroadcastInputOptions,
|
||||
): boolean => {
|
||||
if (
|
||||
passwordPromptActiveRef?.current !== true
|
||||
@@ -47,7 +52,7 @@ export const broadcastTerminalPasteData = (
|
||||
&& isBroadcastEnabledRef?.current
|
||||
&& onBroadcastInputRef?.current
|
||||
) {
|
||||
onBroadcastInputRef.current(data, sourceSessionId);
|
||||
onBroadcastInputRef.current(data, sourceSessionId, options);
|
||||
return true;
|
||||
}
|
||||
return false;
|
||||
@@ -66,6 +71,7 @@ export const useTerminalContextActions = ({
|
||||
isLocalConnection,
|
||||
supportsRemoteImagePaste,
|
||||
autoUploadClipboardImageOnPasteRef,
|
||||
multilinePasteConfirmRef,
|
||||
clearWipesScrollbackRef,
|
||||
normalizeTextOnCopyRef,
|
||||
terminalBackend,
|
||||
@@ -80,12 +86,16 @@ export const useTerminalContextActions = ({
|
||||
onHasSelectionChange?: (hasSelection: boolean) => void;
|
||||
scrollOnPasteRef?: RefObject<boolean>;
|
||||
isBroadcastEnabledRef?: RefObject<boolean | undefined>;
|
||||
onBroadcastInputRef?: RefObject<((data: string, sourceSessionId: string) => void) | undefined>;
|
||||
onBroadcastInputRef?: RefObject<
|
||||
((data: string, sourceSessionId: string, options?: TerminalBroadcastInputOptions) => void) | undefined
|
||||
>;
|
||||
passwordPromptActiveRef?: RefObject<boolean | undefined>;
|
||||
isLocalConnection: boolean;
|
||||
supportsRemoteImagePaste: boolean;
|
||||
/** When true, paste auto-uploads a clipboard image (remote sessions only). */
|
||||
autoUploadClipboardImageOnPasteRef?: RefObject<boolean | undefined>;
|
||||
/** Multi-line paste confirmation gate (#3398); undefined keeps confirm off. */
|
||||
multilinePasteConfirmRef?: RefObject<Omit<MultilinePasteConfirmGate, "requestConfirm"> | undefined>;
|
||||
clearWipesScrollbackRef?: RefObject<boolean | undefined>;
|
||||
/** When false, copy uses raw getSelection(). Default true when unset. */
|
||||
normalizeTextOnCopyRef?: RefObject<boolean | undefined>;
|
||||
@@ -126,14 +136,14 @@ export const useTerminalContextActions = ({
|
||||
}
|
||||
}, [sessionName, t, termRef]);
|
||||
|
||||
const broadcastUserPasteData = useCallback((data: string) => {
|
||||
const broadcastUserPasteData = useCallback((data: string, options?: TerminalBroadcastInputOptions) => {
|
||||
return broadcastTerminalPasteData(data, {
|
||||
sourceSessionId,
|
||||
sessionRef,
|
||||
isBroadcastEnabledRef,
|
||||
onBroadcastInputRef,
|
||||
passwordPromptActiveRef,
|
||||
});
|
||||
}, options);
|
||||
}, [isBroadcastEnabledRef, onBroadcastInputRef, passwordPromptActiveRef, sessionRef, sourceSessionId]);
|
||||
|
||||
const onCopy = useCallback(() => {
|
||||
@@ -161,6 +171,10 @@ export const useTerminalContextActions = ({
|
||||
autoUploadClipboardImage:
|
||||
supportsRemoteImagePaste && autoUploadClipboardImageOnPasteRef?.current === true,
|
||||
clipboardImageBridge: bridge ?? undefined,
|
||||
confirmMultilinePaste: multilinePasteConfirmRef?.current
|
||||
? { ...multilinePasteConfirmRef.current, requestConfirm: requestMultilinePasteConfirm }
|
||||
: undefined,
|
||||
getCurrentSessionId: () => sessionRef.current,
|
||||
getRemoteCwd,
|
||||
isLocalConnection,
|
||||
isSensitiveInput: () => passwordPromptActiveRef?.current === true,
|
||||
@@ -179,6 +193,7 @@ export const useTerminalContextActions = ({
|
||||
}, [
|
||||
autoUploadClipboardImageOnPasteRef,
|
||||
broadcastUserPasteData,
|
||||
multilinePasteConfirmRef,
|
||||
getRemoteCwd,
|
||||
isLocalConnection,
|
||||
onClipboardImageUploadResult,
|
||||
@@ -221,7 +236,7 @@ export const useTerminalContextActions = ({
|
||||
terminalBackend,
|
||||
]);
|
||||
|
||||
const onPasteSelection = useCallback(() => {
|
||||
const onPasteSelection = useCallback(async () => {
|
||||
const term = termRef.current;
|
||||
if (!term) return;
|
||||
const selection = getHistoryPreviewSelectionFromRoot(term.element?.parentElement)
|
||||
@@ -232,11 +247,33 @@ export const useTerminalContextActions = ({
|
||||
if (!selection || !sessionRef.current) return;
|
||||
requestHistoryPreviewHide(term.element?.parentElement);
|
||||
term.focus();
|
||||
pasteTextIntoTerminal(term, selection, {
|
||||
scrollOnPaste: scrollOnPasteRef?.current ?? false,
|
||||
// Route through the multi-line paste confirmation gate (#3398) so a
|
||||
// selected multi-line region cannot be sent without review, just like
|
||||
// the clipboard paste path.
|
||||
await pasteTextWithMultilineConfirm(selection, {
|
||||
confirmMultilinePaste: multilinePasteConfirmRef?.current
|
||||
? { ...multilinePasteConfirmRef.current, requestConfirm: requestMultilinePasteConfirm }
|
||||
: undefined,
|
||||
getCurrentSessionId: () => sessionRef.current,
|
||||
isSensitiveInput: () => passwordPromptActiveRef?.current === true,
|
||||
onPasteData: broadcastUserPasteData,
|
||||
scrollOnPaste: scrollOnPasteRef?.current ?? false,
|
||||
scrollToBottomAfterProgrammaticInput,
|
||||
sessionId: sessionRef.current,
|
||||
terminalBackend,
|
||||
term,
|
||||
});
|
||||
}, [broadcastUserPasteData, normalizeTextOnCopyRef, sessionRef, termRef, scrollOnPasteRef]);
|
||||
}, [
|
||||
broadcastUserPasteData,
|
||||
multilinePasteConfirmRef,
|
||||
normalizeTextOnCopyRef,
|
||||
passwordPromptActiveRef,
|
||||
scrollToBottomAfterProgrammaticInput,
|
||||
sessionRef,
|
||||
termRef,
|
||||
scrollOnPasteRef,
|
||||
terminalBackend,
|
||||
]);
|
||||
|
||||
const onSelectAll = useCallback(() => {
|
||||
const term = termRef.current;
|
||||
|
||||
@@ -2,10 +2,12 @@ import type { Terminal as XTerm } from "@xterm/xterm";
|
||||
import type React from "react";
|
||||
import { useEffect } from "react";
|
||||
|
||||
import { requestMultilinePasteConfirm } from "../../../application/state/multilinePasteConfirmStore";
|
||||
import { netcattyBridge } from "../../../infrastructure/services/netcattyBridge";
|
||||
import { logger } from "../../../lib/logger";
|
||||
import type { TerminalSession } from "../../../types";
|
||||
import type { RemoteClipboardImageUploadResult } from "../clipboardImagePaste";
|
||||
import type { MultilinePasteConfirmGate } from "../terminalClipboardPaste";
|
||||
import { handleTerminalClipboardPaste } from "../terminalClipboardPaste";
|
||||
|
||||
interface UseTerminalFilePasteOptions {
|
||||
@@ -18,11 +20,13 @@ interface UseTerminalFilePasteOptions {
|
||||
};
|
||||
isSensitiveInput?: () => boolean;
|
||||
scrollOnPasteRef?: React.RefObject<boolean>;
|
||||
onPasteData?: (data: string) => boolean | void;
|
||||
onPasteData?: (data: string, options?: { lineDelayMs?: number }) => boolean | void;
|
||||
scrollToBottomAfterProgrammaticInput: (data: string) => void;
|
||||
containerRef: React.RefObject<HTMLDivElement | null>;
|
||||
/** Remote sessions only: auto-upload a clipboard image on paste. */
|
||||
autoUploadClipboardImage?: boolean;
|
||||
/** Multi-line paste confirmation gate (#3398); undefined keeps confirm off. */
|
||||
multilinePasteConfirmRef?: React.RefObject<Omit<MultilinePasteConfirmGate, "requestConfirm"> | undefined>;
|
||||
getRemoteCwd?: () => Promise<string | null | undefined>;
|
||||
onClipboardImageUploadResult?: (result: RemoteClipboardImageUploadResult) => void;
|
||||
}
|
||||
@@ -39,6 +43,7 @@ export function useTerminalFilePaste({
|
||||
scrollToBottomAfterProgrammaticInput,
|
||||
containerRef,
|
||||
autoUploadClipboardImage = false,
|
||||
multilinePasteConfirmRef,
|
||||
getRemoteCwd,
|
||||
onClipboardImageUploadResult,
|
||||
}: UseTerminalFilePasteOptions) {
|
||||
@@ -55,7 +60,15 @@ export function useTerminalFilePaste({
|
||||
autoUploadClipboardImage && !isLocalConnection && !!bridge?.readClipboardImage;
|
||||
const canHandleLocalPaste =
|
||||
isLocalConnection && !!(bridge?.readClipboardFiles || bridge?.hasClipboardImage);
|
||||
if (!wantsImageUpload && !canHandleLocalPaste) return;
|
||||
// The multi-line paste confirmation (#3398) must also intercept plain
|
||||
// remote keyboard pastes; otherwise xterm's default handler would send
|
||||
// the lines without the safety dialog when neither image upload nor
|
||||
// local file handling applies.
|
||||
const shouldInterceptPaste =
|
||||
wantsImageUpload
|
||||
|| canHandleLocalPaste
|
||||
|| !!multilinePasteConfirmRef?.current?.enabled;
|
||||
if (!shouldInterceptPaste) return;
|
||||
|
||||
// ⚡ Must call preventDefault SYNCHRONOUSLY — the event lifecycle
|
||||
// is synchronous; calling it after an await is too late and the
|
||||
@@ -63,6 +76,12 @@ export function useTerminalFilePaste({
|
||||
event.preventDefault();
|
||||
event.stopPropagation();
|
||||
|
||||
// Capture the pasted text synchronously: navigator.clipboard.readText()
|
||||
// can be rejected (permissions / platform quirks) even though the event
|
||||
// already carries the text, so prefer it and only fall back to the
|
||||
// async clipboard API when the event has no text data.
|
||||
const eventText = event.clipboardData?.getData("text/plain") ?? "";
|
||||
|
||||
void (async () => {
|
||||
try {
|
||||
const term = termRef.current;
|
||||
@@ -71,11 +90,21 @@ export function useTerminalFilePaste({
|
||||
bridge,
|
||||
autoUploadClipboardImage: wantsImageUpload,
|
||||
clipboardImageBridge: bridge ?? undefined,
|
||||
confirmMultilinePaste: multilinePasteConfirmRef?.current
|
||||
? { ...multilinePasteConfirmRef.current, requestConfirm: requestMultilinePasteConfirm }
|
||||
: undefined,
|
||||
// The confirm dialog can outlive the captured session (disconnect
|
||||
// / auto-reconnect), so revalidate against the live session ref
|
||||
// like the context-menu and shortcut callers do.
|
||||
getCurrentSessionId: () => sessionRef.current,
|
||||
getRemoteCwd,
|
||||
isLocalConnection,
|
||||
isSensitiveInput,
|
||||
onClipboardImageUploadResult,
|
||||
readClipboardText: () => navigator.clipboard.readText(),
|
||||
readClipboardText: async () => {
|
||||
if (eventText) return eventText;
|
||||
return navigator.clipboard.readText();
|
||||
},
|
||||
scrollOnPaste: scrollOnPasteRef?.current ?? false,
|
||||
onPasteData,
|
||||
sessionId: sessionRef.current,
|
||||
@@ -96,6 +125,7 @@ export function useTerminalFilePaste({
|
||||
}, [
|
||||
autoUploadClipboardImage,
|
||||
containerRef,
|
||||
multilinePasteConfirmRef,
|
||||
getRemoteCwd,
|
||||
isLocalConnection,
|
||||
isSensitiveInput,
|
||||
|
||||
@@ -101,9 +101,9 @@ export type TerminalBackendApi = {
|
||||
cb: (sessionId: string, sourceSessionId?: string) => void,
|
||||
) => (() => void) | undefined;
|
||||
writeToSession: (sessionId: string, data: string, options?: { automated?: boolean; sensitive?: boolean; lineDelayMs?: number; logRewrite?: ProgrammaticCommandLogRewrite }) => void;
|
||||
/** Signal user input for buffered-input modes (serial line mode) so main-process auto-login cancels. */
|
||||
/** Signal buffered user input so main-process auto-login cancels. */
|
||||
notifyUserInput?: (sessionId: string) => void;
|
||||
interruptSession?: (sessionId: string, trace?: NetcattyTerminalInterruptTrace) => void;
|
||||
interruptSession?: NetcattyBridge["interruptSession"];
|
||||
resizeSession: (sessionId: string, cols: number, rows: number) => void;
|
||||
closeSession: (sessionId: string, options?: { bootEpoch?: number }) => void | Promise<void>;
|
||||
/** Pause/resume the source stream for output back-pressure (optional). */
|
||||
|
||||
@@ -996,9 +996,9 @@ test("multi-character plain text goes out as per-character writes (#3077)", asyn
|
||||
// Every chunk carries the same write options as the original single write.
|
||||
const writeLoopIdx = source.indexOf("for (const chunk of getTextInputWireChunks(outData");
|
||||
assert.ok(writeLoopIdx >= 0);
|
||||
const writeLoop = source.slice(writeLoopIdx, writeLoopIdx + 320);
|
||||
const writeLoop = source.slice(writeLoopIdx, source.indexOf("});", writeLoopIdx) + 3);
|
||||
assert.match(writeLoop, /for \(const chunk of/);
|
||||
assert.match(writeLoop, /writeToSession\(id, chunk, \{ sensitive, serialEraseChar \}\)/);
|
||||
assert.match(writeLoop, /writeToSession\(id, chunk, \{\s*sensitive,\s*serialEraseChar,[\s\S]*?\}\)/);
|
||||
|
||||
// IME commits split the committed glyph, not the Kitty CSI-u encoding.
|
||||
assert.match(
|
||||
|
||||
@@ -1,4 +1,6 @@
|
||||
import { clearTerminalBroadcastUserInput, captureTerminalBroadcastInput, isTerminalBroadcastInputCurrent, markTerminalBroadcastUserInput, type TerminalPacedBroadcast } from "./terminalPacedBroadcast";
|
||||
import { stringCellWidth } from "../autocomplete/terminalStringCellWidth";
|
||||
import type { TerminalBroadcastInputOptions } from "../terminalHelpers";
|
||||
import { FitAddon } from "@xterm/addon-fit";
|
||||
import { ImageAddon } from "@xterm/addon-image";
|
||||
import { SearchAddon } from "@xterm/addon-search";
|
||||
@@ -181,15 +183,18 @@ import {
|
||||
} from "./terminalOutputPipeline";
|
||||
import {
|
||||
markExpectedTerminalCursorPositionReport,
|
||||
pasteTextIntoTerminal,
|
||||
registerTerminalLinePasteHandler,
|
||||
shouldBroadcastTerminalUserInput,
|
||||
shouldOverrideTerminalUserPasteSensitivity,
|
||||
shouldSuppressTerminalInputScrollForUserPaste,
|
||||
} from "./terminalUserPaste";
|
||||
import { pasteTextWithMultilineConfirm } from "../terminalClipboardPaste";
|
||||
import { requestMultilinePasteConfirm } from "../../../application/state/multilinePasteConfirmStore";
|
||||
import {
|
||||
consumeOsc133CommandCompletion,
|
||||
type PromptLineBreakState,
|
||||
} from "./promptLineBreak";
|
||||
import { recordTerminalCommandExecution } from "./terminalCommandExecution";
|
||||
import { isSensitiveTerminalCommandInput, recordTerminalCommandExecution } from "./terminalCommandExecution";
|
||||
import {
|
||||
getSingleBracketedPasteLine,
|
||||
getSinglePastedCommand,
|
||||
@@ -212,9 +217,9 @@ import {
|
||||
type TerminalBackendApi = {
|
||||
openExternalAvailable: () => boolean;
|
||||
openExternal: (url: string) => Promise<void>;
|
||||
writeToSession: (sessionId: string, data: string) => void;
|
||||
writeToSession: NetcattyBridge["writeToSession"];
|
||||
notifyUserInput?: (sessionId: string) => void;
|
||||
interruptSession?: (sessionId: string, trace?: NetcattyTerminalInterruptTrace) => void;
|
||||
interruptSession?: NetcattyBridge["interruptSession"];
|
||||
signalPluginConnection?: (
|
||||
sessionId: string,
|
||||
signal?: "interrupt" | "terminate" | "kill" | "eof" | "break",
|
||||
@@ -248,6 +253,7 @@ export type XTermRuntime = {
|
||||
dispose: () => void;
|
||||
/** Track the pending final line of a serial snippet left for editing. */
|
||||
recordSerialSnippetInput: (data: string) => void;
|
||||
invalidatePendingPasteDraft: () => void;
|
||||
/** Current working directory detected via OSC 7 */
|
||||
currentCwd: string | undefined;
|
||||
keywordHighlighter: KeywordHighlighter;
|
||||
@@ -319,7 +325,7 @@ export type CreateXTermRuntimeContext = {
|
||||
((
|
||||
data: string,
|
||||
sourceSessionId: string,
|
||||
options?: { kittyKeyboardInput?: KittyKeyboardBroadcastInput },
|
||||
options?: TerminalBroadcastInputOptions,
|
||||
) => void) | undefined
|
||||
>;
|
||||
|
||||
@@ -361,6 +367,7 @@ export type CreateXTermRuntimeContext = {
|
||||
recordBackspace: () => void;
|
||||
recordClearLine: () => void;
|
||||
recordEnter: (options?: { sensitive?: boolean }) => Promise<void>;
|
||||
captureSubmittedLineRecorder?: () => ((line: string, options?: { sensitive?: boolean; includePendingInput?: boolean; consumePendingInput?: boolean }) => Promise<void>) | undefined;
|
||||
} | undefined>;
|
||||
passwordPromptActiveRef?: RefObject<boolean>;
|
||||
allowHostStyleGreaterThanPrompt?: boolean;
|
||||
@@ -931,13 +938,16 @@ export const createXTermRuntime = (ctx: CreateXTermRuntimeContext): XTermRuntime
|
||||
|
||||
const appLevelActions = getAppLevelActions();
|
||||
const terminalActions = getTerminalPassthroughActions();
|
||||
const broadcastUserPasteData = (data: string) => {
|
||||
const broadcastUserPasteData = (
|
||||
data: string,
|
||||
options?: TerminalBroadcastInputOptions,
|
||||
) => {
|
||||
if (
|
||||
ctx.passwordPromptActiveRef?.current !== true
|
||||
&& ctx.isBroadcastEnabledRef.current
|
||||
&& ctx.onBroadcastInputRef.current
|
||||
) {
|
||||
ctx.onBroadcastInputRef.current(data, ctx.sessionId);
|
||||
ctx.onBroadcastInputRef.current(data, ctx.sessionId, options);
|
||||
return true;
|
||||
}
|
||||
return false;
|
||||
@@ -1161,6 +1171,11 @@ export const createXTermRuntime = (ctx: CreateXTermRuntimeContext): XTermRuntime
|
||||
// nonempty (hibernation wake: the pre-hibernation cursor may have been
|
||||
// moved away from the tail, so we conservatively assume it is not).
|
||||
let lastInputWasPrintable = !ctx.commandBufferRef?.current;
|
||||
let commandBufferRevision = 0;
|
||||
const invalidatePendingPasteDraft = () => {
|
||||
commandBufferRevision += 1;
|
||||
markTerminalBroadcastUserInput(ctx.sessionId);
|
||||
};
|
||||
|
||||
const restoreSerialTailForEmptyInput = (data: string) => {
|
||||
// Apply the same rule to typed text, pasted text, and editable snippets.
|
||||
@@ -1177,6 +1192,7 @@ export const createXTermRuntime = (ctx: CreateXTermRuntimeContext): XTermRuntime
|
||||
: data;
|
||||
const lastLineBreak = Math.max(text.lastIndexOf("\r"), text.lastIndexOf("\n"));
|
||||
if (lastLineBreak >= 0) {
|
||||
commandBufferRevision += 1;
|
||||
ctx.commandBufferRef.current = "";
|
||||
lastInputWasPrintable = true;
|
||||
}
|
||||
@@ -1203,6 +1219,10 @@ export const createXTermRuntime = (ctx: CreateXTermRuntimeContext): XTermRuntime
|
||||
logicalData?: string | null;
|
||||
/** Skip string broadcast when peers will re-resolve from a key chord. */
|
||||
skipBroadcast?: boolean;
|
||||
/** Confirmed paste: preserve classification and backend pacing. */
|
||||
sensitive?: boolean;
|
||||
lineDelayMs?: number;
|
||||
pasteRequestId?: string;
|
||||
/**
|
||||
* Send plain text as one write per character. Strict bastion prompts
|
||||
* (QAX) treat one channel write as a keystroke and drop multi-character
|
||||
@@ -1235,7 +1255,21 @@ export const createXTermRuntime = (ctx: CreateXTermRuntimeContext): XTermRuntime
|
||||
const inputSource = options?.source ?? "terminal";
|
||||
const id = ctx.sessionRef.current;
|
||||
const dataToWrite = data;
|
||||
const sensitive = ctx.passwordPromptActiveRef?.current === true;
|
||||
// A programmatic paste can carry a sensitivity snapshot taken before a
|
||||
// confirm-dialog await (terminalUserPaste.ts): remote output or a
|
||||
// reconnect may have cleared passwordPromptActiveRef while the dialog was
|
||||
// open, so the live ref alone would downgrade the secret to nonsensitive.
|
||||
// The override is consumed only for the flagged paste data.
|
||||
const sensitive = ctx.passwordPromptActiveRef?.current === true
|
||||
|| options?.sensitive === true
|
||||
|| shouldOverrideTerminalUserPasteSensitivity(term, logicalData ?? data);
|
||||
if (!options?.lineDelayMs && logicalData !== null
|
||||
&& !isPrintableInput(logicalData) && !isTerminalReportSequence(logicalData)) {
|
||||
commandBufferRevision += 1;
|
||||
}
|
||||
if (!options?.lineDelayMs && logicalData !== null && !isTerminalReportSequence(logicalData)) {
|
||||
markTerminalBroadcastUserInput(ctx.sessionId);
|
||||
}
|
||||
let handledSubmittedInput = false;
|
||||
const submittedInput: { text: string; lineEnding: "\r\n" | "\r" | "\n" } | null =
|
||||
logicalData === null
|
||||
@@ -1263,7 +1297,12 @@ export const createXTermRuntime = (ctx: CreateXTermRuntimeContext): XTermRuntime
|
||||
hasBroadcastInputHandler: !!onBroadcastInput,
|
||||
});
|
||||
const willBroadcastInput = canBroadcastInput && options?.skipBroadcast !== true;
|
||||
if (ctx.statusRef.current === "connected" && submittedInput) {
|
||||
if (ctx.statusRef.current === "connected" && options?.lineDelayMs && logicalData) {
|
||||
// Keep the draft until the backend confirms a write. A rejected or
|
||||
// canceled batch must not discard input already present at the prompt.
|
||||
if (ctx.passwordPromptActiveRef) ctx.passwordPromptActiveRef.current = false;
|
||||
handledSubmittedInput = true;
|
||||
} else if (ctx.statusRef.current === "connected" && submittedInput) {
|
||||
if (submittedInput.text) {
|
||||
ctx.commandBufferRef.current += submittedInput.text;
|
||||
ctx.scriptRecorderRef?.current?.recordInput(submittedInput.text);
|
||||
@@ -1334,6 +1373,14 @@ export const createXTermRuntime = (ctx: CreateXTermRuntimeContext): XTermRuntime
|
||||
ctx.serialLineMode &&
|
||||
ctx.serialLineBufferRef
|
||||
) {
|
||||
// Local line editing sends no transport write, so cancel queued paste
|
||||
// work explicitly. Only actual protocol replies are exempt; keyboard
|
||||
// escape sequences also edit the local buffer.
|
||||
if (!options?.lineDelayMs && !isTerminalReportSequence(dataToWrite)
|
||||
&& (dataToWrite === "\b" || dataToWrite === "\x15"
|
||||
|| dataToWrite.charCodeAt(0) >= 32 || dataToWrite.length > 1)) {
|
||||
ctx.terminalBackend.interruptSession?.(id, undefined, { cancelPendingWritesOnly: true });
|
||||
}
|
||||
// Line mode never reaches writeToSession until Enter, so buffered
|
||||
// keystrokes are invisible to the main-process auto-login detector.
|
||||
// The first buffered keystroke means the user is taking control:
|
||||
@@ -1348,16 +1395,28 @@ export const createXTermRuntime = (ctx: CreateXTermRuntimeContext): XTermRuntime
|
||||
if (!isSessionWrite && !isTerminalReportSequence(dataToWrite)) {
|
||||
ctx.terminalBackend.notifyUserInput?.(id);
|
||||
}
|
||||
const pacedWrites: string[] = [];
|
||||
handleSerialLineModeInput(dataToWrite, {
|
||||
bufferRef: ctx.serialLineBufferRef,
|
||||
localEcho: ctx.serialLocalEcho,
|
||||
writeToSession: (nextData) => {
|
||||
ctx.onOutputTriggerUserInputRef?.current?.(nextData);
|
||||
ctx.terminalBackend.writeToSession(id, nextData, { sensitive });
|
||||
if (options?.lineDelayMs) pacedWrites.push(nextData);
|
||||
else ctx.terminalBackend.writeToSession(id, nextData, { sensitive });
|
||||
},
|
||||
writeToTerminal: writeLocalTerminalData,
|
||||
term,
|
||||
});
|
||||
// Submit one batch so the backend spaces the lines apart. Separate
|
||||
// calls would each start at delay zero and lose the paste pacing.
|
||||
if (pacedWrites.length > 0) {
|
||||
ctx.terminalBackend.writeToSession(id, pacedWrites.join(""), {
|
||||
automated: false,
|
||||
sensitive,
|
||||
lineDelayMs: options?.lineDelayMs,
|
||||
pasteRequestId: options?.pasteRequestId,
|
||||
});
|
||||
}
|
||||
} else {
|
||||
// Character mode sends input immediately. Byte-oriented devices opt in
|
||||
// to expansion in the backend, using the session's actual wire encoder.
|
||||
@@ -1387,7 +1446,15 @@ export const createXTermRuntime = (ctx: CreateXTermRuntimeContext): XTermRuntime
|
||||
|
||||
ctx.onOutputTriggerUserInputRef?.current?.(outData);
|
||||
for (const chunk of getTextInputWireChunks(outData, options?.perCharacterWrites === true)) {
|
||||
ctx.terminalBackend.writeToSession(id, chunk, { sensitive, serialEraseChar });
|
||||
ctx.terminalBackend.writeToSession(id, chunk, {
|
||||
sensitive,
|
||||
serialEraseChar,
|
||||
...(options?.lineDelayMs ? {
|
||||
automated: false,
|
||||
lineDelayMs: options.lineDelayMs,
|
||||
pasteRequestId: options.pasteRequestId,
|
||||
} : {}),
|
||||
});
|
||||
}
|
||||
|
||||
// Local echo for serial connections only when explicitly enabled
|
||||
@@ -2103,10 +2170,13 @@ export const createXTermRuntime = (ctx: CreateXTermRuntimeContext): XTermRuntime
|
||||
onAutocompleteInput: ctx.onAutocompleteInput,
|
||||
});
|
||||
lastInputWasPrintable = true;
|
||||
invalidatePendingPasteDraft();
|
||||
ctx.scriptRecorderRef?.current?.recordClearLine();
|
||||
if (ctx.passwordPromptActiveRef) {
|
||||
ctx.passwordPromptActiveRef.current = false;
|
||||
}
|
||||
if (isPluginHostProtocol(ctx.host.protocol) && ctx.terminalBackend.signalPluginConnection) {
|
||||
ctx.terminalBackend.interruptSession?.(id, interruptTrace, { cancelPendingWritesOnly: true });
|
||||
void ctx.terminalBackend.signalPluginConnection(id, "interrupt").catch(() => {
|
||||
if (ctx.terminalBackend.interruptSession) {
|
||||
ctx.terminalBackend.interruptSession(id, interruptTrace);
|
||||
@@ -2279,9 +2349,24 @@ export const createXTermRuntime = (ctx: CreateXTermRuntimeContext): XTermRuntime
|
||||
const id = ctx.sessionRef.current;
|
||||
if (selection && id) {
|
||||
hideHistoryPreview();
|
||||
pasteTextIntoTerminal(term, selection, {
|
||||
scrollOnPaste: shouldScrollOnTerminalPaste(ctx.terminalSettingsRef.current),
|
||||
// Route through the multi-line paste confirmation gate
|
||||
// (#3398) so a selected multi-line region cannot be sent to
|
||||
// the session (and broadcast peers) without review, just
|
||||
// like the clipboard paste path.
|
||||
void pasteTextWithMultilineConfirm(selection, {
|
||||
confirmMultilinePaste: {
|
||||
enabled: ctx.terminalSettingsRef.current?.confirmBeforeMultilinePaste === true,
|
||||
minLines: ctx.terminalSettingsRef.current?.multilinePasteConfirmMinLines,
|
||||
requestConfirm: requestMultilinePasteConfirm,
|
||||
},
|
||||
getCurrentSessionId: () => ctx.sessionRef.current,
|
||||
isSensitiveInput: () => ctx.passwordPromptActiveRef?.current === true,
|
||||
onPasteData: broadcastUserPasteData,
|
||||
scrollOnPaste: shouldScrollOnTerminalPaste(ctx.terminalSettingsRef.current),
|
||||
scrollToBottomAfterProgrammaticInput: scrollToBottomAfterInput,
|
||||
sessionId: id,
|
||||
terminalBackend: ctx.terminalBackend,
|
||||
term,
|
||||
});
|
||||
}
|
||||
break;
|
||||
@@ -2697,6 +2782,93 @@ export const createXTermRuntime = (ctx: CreateXTermRuntimeContext): XTermRuntime
|
||||
ctx.container.addEventListener("input", markKittyTextInput, true);
|
||||
textarea?.addEventListener("blur", clearKittyTransientInputState);
|
||||
|
||||
const pendingLinePastes = new Map<string, {
|
||||
sessionId: string;
|
||||
commands: string[];
|
||||
firstPastedLine: string;
|
||||
pendingInput: string;
|
||||
serialPendingInput?: string;
|
||||
sourceInput: ReturnType<typeof captureTerminalBroadcastInput>;
|
||||
broadcast?: TerminalPacedBroadcast;
|
||||
inputRevision: number;
|
||||
sensitive: boolean;
|
||||
recorded: Set<number>;
|
||||
recordLine?: (line: string, options?: { sensitive?: boolean; includePendingInput?: boolean; consumePendingInput?: boolean }) => Promise<void>;
|
||||
}>();
|
||||
const disposePasteWriteReceipts = netcattyBridge.get()?.onTerminalPasteWrite?.((receipt) => {
|
||||
const pending = pendingLinePastes.get(receipt.requestId);
|
||||
if (!pending || pending.sessionId !== receipt.sessionId) return;
|
||||
if (ctx.sessionRef.current !== pending.sessionId) {
|
||||
pendingLinePastes.delete(receipt.requestId);
|
||||
return;
|
||||
}
|
||||
const index = receipt.index;
|
||||
if (index !== undefined && Number.isInteger(index) && index >= 0
|
||||
&& index < pending.commands.length && !pending.recorded.has(index)) {
|
||||
if (index === 0 && pending.inputRevision === commandBufferRevision
|
||||
&& ctx.commandBufferRef.current.startsWith(pending.pendingInput)) {
|
||||
ctx.commandBufferRef.current = ctx.commandBufferRef.current.slice(pending.pendingInput.length);
|
||||
if (pending.serialPendingInput !== undefined && ctx.serialLineBufferRef?.current.startsWith(pending.serialPendingInput)) {
|
||||
ctx.serialLineBufferRef.current = ctx.serialLineBufferRef.current.slice(pending.serialPendingInput.length);
|
||||
}
|
||||
commandBufferRevision += 1;
|
||||
}
|
||||
pending.recorded.add(index);
|
||||
const command = pending.commands[index];
|
||||
const sensitive = isSensitiveTerminalCommandInput(
|
||||
term, pending.sensitive || ctx.passwordPromptActiveRef?.current === true,
|
||||
);
|
||||
// Receipts can arrive after the user starts typing another command.
|
||||
// Never consume that live input buffer or reconcile with a newer screen.
|
||||
recordTerminalCommandExecution(command, { ...ctx, commandBufferRef: { current: "" } }, term, {
|
||||
sensitive,
|
||||
allowHostStyleGreaterThanPrompt: ctx.allowHostStyleGreaterThanPrompt,
|
||||
useProvidedCommand: true,
|
||||
acknowledgedWrite: true,
|
||||
});
|
||||
if (pending.broadcast && isTerminalBroadcastInputCurrent(pending.sourceInput)
|
||||
&& ctx.isBroadcastEnabledRef.current && !sensitive) {
|
||||
ctx.onBroadcastInputRef.current?.(`${index === 0 ? pending.firstPastedLine : command}\r`, ctx.sessionId, {
|
||||
pacedBroadcast: pending.broadcast,
|
||||
});
|
||||
}
|
||||
void pending.recordLine?.(index === 0 ? pending.firstPastedLine : command, {
|
||||
sensitive, includePendingInput: index === 0, consumePendingInput: index === 0,
|
||||
}).catch((error) => {
|
||||
logger.warn("Failed to record confirmed paste write", error);
|
||||
});
|
||||
}
|
||||
if (receipt.done) pendingLinePastes.delete(receipt.requestId);
|
||||
});
|
||||
const disposeLinePasteHandler = registerTerminalLinePasteHandler(term, (data, options) => {
|
||||
const sessionId = ctx.sessionRef.current;
|
||||
if (!sessionId) return;
|
||||
markTerminalBroadcastUserInput(ctx.sessionId);
|
||||
const sourceInput = captureTerminalBroadcastInput(ctx.sessionId);
|
||||
const broadcast: TerminalPacedBroadcast | undefined = options.broadcast && !options.sensitive
|
||||
&& ctx.isBroadcastEnabledRef.current ? {} : undefined;
|
||||
if (broadcast) ctx.onBroadcastInputRef.current?.("", ctx.sessionId, { pacedBroadcast: broadcast, preparePacedBroadcast: true });
|
||||
const requestId = crypto.randomUUID();
|
||||
const commands = data.replace(/\r\n/g, "\n").replace(/\r/g, "\n").split("\n");
|
||||
if (commands.at(-1) === "") commands.pop();
|
||||
const firstPastedLine = commands[0] ?? "";
|
||||
if (commands.length) {
|
||||
commands[0] = `${ctx.commandBufferRef.current}${commands[0]}`;
|
||||
}
|
||||
const serialPendingInput = ctx.host.protocol === "serial" && ctx.serialLineMode
|
||||
? ctx.serialLineBufferRef?.current : undefined;
|
||||
const recorded = new Set<number>();
|
||||
pendingLinePastes.set(requestId, {
|
||||
sessionId, sourceInput, broadcast, commands, firstPastedLine, sensitive: options.sensitive, recorded, serialPendingInput,
|
||||
pendingInput: ctx.commandBufferRef.current, inputRevision: commandBufferRevision,
|
||||
recordLine: ctx.scriptRecorderRef?.current?.captureSubmittedLineRecorder?.(),
|
||||
});
|
||||
handleTerminalInputData(data, { ...options, pasteRequestId: requestId, skipBroadcast: true });
|
||||
if (recorded.size === 0 && serialPendingInput !== undefined && ctx.serialLineBufferRef) {
|
||||
ctx.serialLineBufferRef.current = serialPendingInput;
|
||||
}
|
||||
});
|
||||
|
||||
term.onData((data) => {
|
||||
const win32Input = win32InputModePendingEvent;
|
||||
if (
|
||||
@@ -3140,8 +3312,13 @@ export const createXTermRuntime = (ctx: CreateXTermRuntimeContext): XTermRuntime
|
||||
getKittyKeyboardProtocolEnabled: () => kittyKeyboardProtocolEnabled,
|
||||
setKittyKeyboardProtocolEnabled,
|
||||
recordSerialSnippetInput,
|
||||
invalidatePendingPasteDraft,
|
||||
dispose: () => {
|
||||
runtimeDisposed = true;
|
||||
disposeLinePasteHandler?.();
|
||||
disposePasteWriteReceipts?.();
|
||||
pendingLinePastes.clear();
|
||||
clearTerminalBroadcastUserInput(ctx.sessionId);
|
||||
resizeScheduler.dispose();
|
||||
webglController.dispose();
|
||||
term.element?.removeEventListener("copy", handleNativeCopy, true);
|
||||
|
||||
@@ -10,7 +10,7 @@ const ts=require('typescript');
|
||||
const bridge=require(root+'/electron/bridges/terminalBridge.cjs');
|
||||
test('serial input combinations preserve the preceding device bytes after CJK deletion', async () => {
|
||||
const modules = await Promise.all([
|
||||
'domain/serialCharMetrics.ts', 'components/terminal/runtime/terminalInputSanitize.ts',
|
||||
'components/terminal/runtime/terminalPacedBroadcast.ts', 'components/terminal/runtime/terminalReportSequence.ts', 'domain/serialCharMetrics.ts', 'components/terminal/runtime/terminalInputSanitize.ts',
|
||||
'components/terminal/runtime/terminalBackspaceInput.ts', 'components/terminal/runtime/terminalPerCharacterInput.ts',
|
||||
'components/terminal/runtime/terminalSudoAutofill.ts', 'components/terminal/runtime/terminalCommandExecution.ts',
|
||||
'components/terminal/runtime/serialLocalEcho.ts', 'components/terminal/autocomplete/terminalStringCellWidth.ts',
|
||||
@@ -27,7 +27,7 @@ for (const prefix of ['fresh','empty-arrow','submit','paste-submit','interrupt',
|
||||
const session={encoding, serialPort:{write(data: string | Buffer){for(const byte of Buffer.from(data)) { if(byte===127||byte===8) wire=wire.subarray(0,Math.max(0,wire.length-1)); else wire=Buffer.concat([wire,Buffer.from([byte])]);}}}};
|
||||
bridge.init({sessions:new Map([['s',session]]),electronModule:{}});
|
||||
const ctx={host:{protocol:'serial',id:'h',label:'h'},sessionId:'s',sessionRef:{current:'s'},statusRef:{current:'connected'},commandBufferRef:{current:''},onBroadcastInputRef:{current:null},isBroadcastEnabledRef:{current:false},terminalBackend:{writeToSession(id:string,data:string,opts: {serialEraseChar?: string; sensitive?: boolean}){bridge.writeToSession({}, {sessionId:id,data,...opts})}},serialByteOrientedBackspace:true,serialLocalEcho:false};
|
||||
const env={...Object.assign({},...modules),api: undefined as unknown as {handleTerminalInputData: (data: string) => void; recordSerialSnippetInput: (data: string) => void},ctx,term:{buffer:{active:{cursorX:0,cursorY:0,baseY:0,getLine:()=>undefined}},cols:80},suppressNextTerminalDataBroadcast:false,handlingKittyBroadcast:false,shouldBroadcastTerminalUserInput:()=>false,prioritizeTerminalInput:()=>{},getFlowControllerForTerm:()=>null,shouldSuppressTerminalInputScrollForUserPaste:()=>true,scrollToBottomAfterInput:()=>{},writeLocalTerminalData:()=>{}};
|
||||
const env={...Object.assign({},...modules),api: undefined as unknown as {handleTerminalInputData: (data: string) => void; recordSerialSnippetInput: (data: string) => void},ctx,term:{buffer:{active:{cursorX:0,cursorY:0,baseY:0,getLine:()=>undefined}},cols:80},suppressNextTerminalDataBroadcast:false,handlingKittyBroadcast:false,shouldBroadcastTerminalUserInput:()=>false,prioritizeTerminalInput:()=>{},getFlowControllerForTerm:()=>null,shouldSuppressTerminalInputScrollForUserPaste:()=>true,shouldOverrideTerminalUserPasteSensitivity:()=>false,scrollToBottomAfterInput:()=>{},writeLocalTerminalData:()=>{}};
|
||||
vm.runInNewContext(code,env); const {handleTerminalInputData:input,recordSerialSnippetInput:snippet}=env.api;
|
||||
if(prefix==='empty-arrow') input('\x1b[D');
|
||||
if(prefix==='submit') {input('旧');input('\x1b[D');input('\r');}
|
||||
|
||||
@@ -41,7 +41,7 @@ test("formatSerialLocalEcho erases 2 cells for wide-character backspace", () =>
|
||||
test("serial byte deletion metadata is forwarded through the runtime input loop", async () => {
|
||||
const { readFile } = await import("node:fs/promises");
|
||||
const source = await readFile(new URL("./createXTermRuntime.ts", import.meta.url), "utf8");
|
||||
assert.match(source, /writeToSession\(id, chunk, \{ sensitive, serialEraseChar \}\)/);
|
||||
assert.match(source, /writeToSession\(id, chunk, \{\s*sensitive,\s*serialEraseChar,[\s\S]*?\}\)/);
|
||||
});
|
||||
|
||||
test("both Enter and submitted paste restore serial tail confidence", async () => {
|
||||
|
||||
@@ -98,3 +98,32 @@ test('plugin semantic callbacks fail closed when terminal prompt state is unavai
|
||||
assert.deepEqual(submitted, []);
|
||||
assert.deepEqual(executed, ['echo ok']);
|
||||
});
|
||||
|
||||
test('acknowledged pasted commands enter history over output without becoming trusted semantic input', () => {
|
||||
for (const acknowledgedWrite of [false, true]) {
|
||||
const history: string[] = [];
|
||||
const trusted: string[] = [];
|
||||
recordTerminalCommandExecution('cd /tmp', {
|
||||
host: { id: 'h', label: 'h' }, sessionId: 's', commandBufferRef: { current: '' },
|
||||
onCommandExecuted: command => history.push(command),
|
||||
onTrustedCommandSubmitted: command => trusted.push(command),
|
||||
}, createFakeTerm('working... still producing output') as never, { useProvidedCommand: true, acknowledgedWrite });
|
||||
assert.deepEqual(history, acknowledgedWrite ? ['cd /tmp'] : []);
|
||||
assert.deepEqual(trusted, []);
|
||||
}
|
||||
});
|
||||
|
||||
test('acknowledged writes retain both live challenge and explicit sensitive checks', () => {
|
||||
for (const [line, sensitive] of [['Password: ', false], ['OTP> ', false], ['output', true]] as const) {
|
||||
const history: string[] = [];
|
||||
const submitted: string[] = [];
|
||||
const result = recordTerminalCommandExecution('secret', {
|
||||
host: { id: 'h', label: 'h' }, sessionId: 's', commandBufferRef: { current: '' },
|
||||
onCommandExecuted: command => history.push(command),
|
||||
onCommandSubmitted: command => submitted.push(command),
|
||||
}, createFakeTerm(line) as never, { sensitive, useProvidedCommand: true, acknowledgedWrite: true });
|
||||
assert.equal(result, null);
|
||||
assert.deepEqual(history, []);
|
||||
assert.deepEqual(submitted, []);
|
||||
}
|
||||
});
|
||||
@@ -856,18 +856,23 @@ export const resolveSubmittedShellCommand = (
|
||||
return live;
|
||||
};
|
||||
|
||||
export const isSensitiveTerminalCommandInput = (
|
||||
term?: XTerm | null,
|
||||
sensitive = false,
|
||||
): boolean => sensitive || isSensitiveTerminalChallenge(readCurrentLogicalTerminalLine(term));
|
||||
|
||||
export const recordTerminalCommandExecution = (
|
||||
command: string,
|
||||
ctx: TerminalCommandExecutionContext,
|
||||
term?: XTerm | null,
|
||||
options?: { sensitive?: boolean; allowHostStyleGreaterThanPrompt?: boolean },
|
||||
options?: { sensitive?: boolean; allowHostStyleGreaterThanPrompt?: boolean; useProvidedCommand?: boolean; acknowledgedWrite?: boolean },
|
||||
): string | null => {
|
||||
if (options?.sensitive || isSensitiveTerminalChallenge(readCurrentLogicalTerminalLine(term))) {
|
||||
if (isSensitiveTerminalCommandInput(term, options?.sensitive)) {
|
||||
ctx.commandBufferRef.current = "";
|
||||
return null;
|
||||
}
|
||||
const lastPromptText = ctx.promptLineBreakStateRef?.current?.lastPromptText;
|
||||
const cmd = resolveSubmittedShellCommand(command, term, lastPromptText);
|
||||
const cmd = options?.useProvidedCommand ? command.trim() : resolveSubmittedShellCommand(command, term, lastPromptText);
|
||||
if (cmd) {
|
||||
ctx.onCommandSubmitted?.(cmd, ctx.host.id, ctx.host.label, ctx.sessionId);
|
||||
}
|
||||
@@ -878,7 +883,7 @@ export const recordTerminalCommandExecution = (
|
||||
allowHostStyleGreaterThan: options?.allowHostStyleGreaterThanPrompt,
|
||||
}),
|
||||
);
|
||||
if (cmd && shouldRecordShellHistory(cmd, term)) {
|
||||
if (cmd && (options?.acknowledgedWrite || shouldRecordShellHistory(cmd, term))) {
|
||||
if (trustedPrompt) {
|
||||
ctx.onTrustedCommandSubmitted?.(cmd, ctx.host.id, ctx.host.label, ctx.sessionId);
|
||||
}
|
||||
|
||||
@@ -34,11 +34,11 @@ 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]*?const willBroadcastInput = canBroadcastInput && options\?\.skipBroadcast !== true;/u,
|
||||
);
|
||||
assert.match(
|
||||
runtimeSource,
|
||||
/for \(const chunk of getTextInputWireChunks\(outData, options\?\.perCharacterWrites === true\)\) \{\s*ctx\.terminalBackend\.writeToSession\(id, chunk, \{ sensitive, serialEraseChar \}\);\s*\}/u,
|
||||
/for \(const chunk of getTextInputWireChunks\(outData, options\?\.perCharacterWrites === true\)\) \{\s*ctx\.terminalBackend\.writeToSession\(id, chunk, \{\s*sensitive,\s*serialEraseChar,[\s\S]*?\}\);\s*\}/u,
|
||||
);
|
||||
assert.match(
|
||||
runtimeSource,
|
||||
@@ -46,7 +46,7 @@ test("password-prompt input is classified before prompt state reset and cannot b
|
||||
);
|
||||
assert.match(
|
||||
runtimeSource,
|
||||
/const broadcastUserPasteData = \(data: string\) => \{[\s\S]*?passwordPromptActiveRef\?\.current !== true[\s\S]*?onBroadcastInputRef\.current/u,
|
||||
/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,
|
||||
);
|
||||
assert.match(
|
||||
terminalSource,
|
||||
|
||||
@@ -0,0 +1,297 @@
|
||||
import assert from "node:assert/strict";
|
||||
import { readFileSync } from "node:fs";
|
||||
import { createRequire } from "node:module";
|
||||
import test from "node:test";
|
||||
import vm from "node:vm";
|
||||
import ts from "typescript";
|
||||
import { pasteTextWithMultilineConfirm } from "../terminalClipboardPaste";
|
||||
import * as userPaste from "./terminalUserPaste";
|
||||
|
||||
const require = createRequire(import.meta.url);
|
||||
const bridge = require("../../../electron/bridges/terminalBridge.cjs");
|
||||
const source = readFileSync(new URL("./createXTermRuntime.ts", import.meta.url), "utf8");
|
||||
// Exercise the production input handler without starting an Electron window,
|
||||
// following serialInputSequences.test.ts's runtime extraction harness.
|
||||
const start = source.indexOf("let lastInputWasPrintable =");
|
||||
const end = source.indexOf(" let kittyCompositionPending", start);
|
||||
const registrationStart = source.indexOf(" const pendingLinePastes =");
|
||||
const registrationEnd = source.indexOf(" term.onData(", registrationStart);
|
||||
const urgentStart = source.indexOf(" clearTerminalInputStateForInterrupt({");
|
||||
const urgentEnd = source.indexOf(" const interruptEventForKitty", urgentStart);
|
||||
const urgentCode = "\nconst urgent = () => { const id = ctx.sessionRef.current; const interruptTrace = undefined; "
|
||||
+ source.slice(urgentStart, urgentEnd) + "\n};";
|
||||
const code = ts.transpileModule(
|
||||
source.slice(start, end) + source.slice(registrationStart, registrationEnd)
|
||||
+ urgentCode + "\nglobalThis.api = { urgent, input: handleTerminalInputData, dispose: () => { disposeLinePasteHandler(); disposePasteWriteReceipts?.(); pendingLinePastes.clear(); } };",
|
||||
{ compilerOptions: { target: ts.ScriptTarget.ES2022 } },
|
||||
).outputText;
|
||||
const helpers = await Promise.all([
|
||||
"../../../domain/serialCharMetrics.ts", "./terminalReportSequence.ts", "./terminalInputSanitize.ts",
|
||||
"./terminalBackspaceInput.ts", "./terminalPerCharacterInput.ts",
|
||||
"./terminalSudoAutofill.ts", "./terminalCommandExecution.ts",
|
||||
"./terminalPacedBroadcast.ts", "./terminalInterruptInputState.ts", "./serialLocalEcho.ts", "../autocomplete/terminalStringCellWidth.ts",
|
||||
"./shiftEnterText.ts", "./serialLineInput.ts", "./telnetLocalEcho.ts",
|
||||
].map(path => import(new URL(path, import.meta.url).href)));
|
||||
|
||||
for (const [protocol, lineMode, sensitive] of [
|
||||
["serial", false, false], ["serial", false, true], ["serial", true, false], ["serial", true, true],
|
||||
["telnet", false, false], ["telnet", false, true],
|
||||
["ssh", false, false], ["ssh", false, true], ["local", false, false],
|
||||
["mosh", false, false], ["et", false, false], ["plugin:example", false, false],
|
||||
] as const) {
|
||||
for (const completion of ["late-urgent", "filtered-first", "pending-manual", "pending-clear", "pending-ack", "blocked-first", "failed-first", "complete", "manual", "interrupt", "replacement", "password-ref", "password-screen", "output", "serial-text", "serial-backspace", "serial-clear", "serial-arrow", "serial-delete", "serial-report"] as const) {
|
||||
if (completion.startsWith("serial-") && !lineMode) continue;
|
||||
if ((completion.startsWith("pending-") || completion === "filtered-first") && sensitive) continue;
|
||||
test(`${completion}: ${protocol} confirmed line paste consumes pending text with pacing (lineMode=${lineMode}, sensitive=${sensitive})`, async (t) => {
|
||||
t.mock.timers.enable({ apis: ["setTimeout"] });
|
||||
const wire: string[] = [];
|
||||
const echo: string[] = [];
|
||||
const broadcast: string[] = [];
|
||||
const submitted: string[] = [];
|
||||
const history: string[] = [];
|
||||
const recordingSensitivity: boolean[] = [];
|
||||
const loginCancellationNotices: string[] = [];
|
||||
let liveLine = protocol === "ssh" ? "alice@host:~$ show " : "";
|
||||
const autocomplete: string[] = [];
|
||||
const outputTriggers: string[] = [];
|
||||
const recorded: string[] = [];
|
||||
let recorderInput = "";
|
||||
const queuedReceipts: unknown[] = [];
|
||||
let receiptListener: ((event: unknown) => void) | undefined;
|
||||
const writes: Array<{ data: string; sensitive?: boolean; lineDelayMs?: number; automated?: boolean }> = [];
|
||||
let rejectPaste = false;
|
||||
let releaseFirst: (() => void) | undefined;
|
||||
const session = { zmodemSentry: { isActive: () => rejectPaste && completion === "blocked-first" }, [protocol === "serial" ? "serialPort" : protocol === "telnet" ? "socket" : protocol === "local" ? "proc" : "stream"]: { write: (data: string) => {
|
||||
if (rejectPaste && completion === "failed-first") throw new Error("write failed");
|
||||
wire.push(String(data));
|
||||
} } };
|
||||
bridge.init({
|
||||
sessions: new Map([["serial-1", session]]),
|
||||
terminalDataPipeline: completion.startsWith("pending-") || completion === "filtered-first" ? {
|
||||
has: () => true,
|
||||
interceptInput(_id: string, data: string) {
|
||||
if (completion === "filtered-first") return data.includes("version") ? "" : data;
|
||||
if (rejectPaste && !releaseFirst) return new Promise<string>(resolve => { releaseFirst = () => resolve(data); });
|
||||
return data;
|
||||
},
|
||||
} : undefined,
|
||||
electronModule: { webContents: { fromId: () => ({ send(channel: string, event: unknown) { if (channel === "netcatty:paste-write") {
|
||||
if (completion === "late-urgent") queuedReceipts.push(event);
|
||||
else receiptListener?.(event);
|
||||
} } }) } },
|
||||
});
|
||||
const ctx = {
|
||||
host: { protocol, id: "h", label: "h" }, sessionId: "tab-1",
|
||||
sessionRef: { current: "serial-1" }, statusRef: { current: "connected" },
|
||||
commandBufferRef: { current: "" }, serialLineBufferRef: { current: "" },
|
||||
serialLineMode: lineMode, serialLocalEcho: true, telnetLocalEchoRef: { current: true },
|
||||
passwordPromptActiveRef: { current: sensitive },
|
||||
onCommandSubmitted: (command: string) => submitted.push(command),
|
||||
onCommandExecuted: (command: string) => history.push(command),
|
||||
onAutocompleteInput: (data: string) => autocomplete.push(data),
|
||||
onOutputTriggerUserInputRef: { current: (data: string) => outputTriggers.push(data) },
|
||||
scriptRecorderRef: { current: { isRecording: true,
|
||||
recordClearLine: () => { recorderInput = ""; },
|
||||
recordBackspace: () => { recorderInput = recorderInput.slice(0, -1); },
|
||||
recordInput: (data: string) => { recorderInput += data; },
|
||||
recordEnter: async ({ sensitive: secret }: { sensitive: boolean }) => {
|
||||
if (!secret) recorded.push(recorderInput);
|
||||
recorderInput = "";
|
||||
},
|
||||
captureSubmittedLineRecorder: () => {
|
||||
const pendingInput = recorderInput;
|
||||
recorderInput = "";
|
||||
return async (line: string, { sensitive: secret, includePendingInput }: { sensitive: boolean; includePendingInput?: boolean }) => {
|
||||
recordingSensitivity.push(secret);
|
||||
if (!secret) recorded.push(includePendingInput ? pendingInput + line : line);
|
||||
};
|
||||
},
|
||||
} },
|
||||
isBroadcastEnabledRef: { current: false },
|
||||
onBroadcastInputRef: { current: (data: string, _id: string, options?: { preparePacedBroadcast?: boolean }) => { if (!options?.preparePacedBroadcast) broadcast.push(data); } },
|
||||
terminalBackend: {
|
||||
notifyUserInput(sessionId: string) { loginCancellationNotices.push(sessionId); },
|
||||
interruptSession(sessionId: string, _trace: unknown, options?: { cancelPendingWritesOnly?: boolean }) {
|
||||
bridge.interruptSession({}, { sessionId, ...options });
|
||||
},
|
||||
writeToSession(sessionId: string, data: string, options?: { sensitive?: boolean; lineDelayMs?: number }) {
|
||||
writes.push({ data, ...options });
|
||||
bridge.writeToSession({}, { sessionId, data, ...options });
|
||||
},
|
||||
},
|
||||
};
|
||||
const term = {
|
||||
paste: () => assert.fail("paced serial input must not gain bracketed-paste markers"),
|
||||
scrollToBottom() {}, cols: 80,
|
||||
buffer: { active: { cursorX: 18, cursorY: 0, baseY: 0, getLine: (row: number) => liveLine && row === 0
|
||||
? { isWrapped: false, translateToString: () => liveLine } : 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: (data: string) => echo.push(data),
|
||||
api: undefined as unknown as { urgent: () => void; input: (data: string) => void; dispose: () => void },
|
||||
};
|
||||
vm.runInNewContext(code, env);
|
||||
t.after(() => env.api.dispose());
|
||||
env.api.input("show ");
|
||||
ctx.isBroadcastEnabledRef.current = true;
|
||||
rejectPaste = true;
|
||||
await pasteTextWithMultilineConfirm("version\nshow clock", {
|
||||
term, sessionId: "serial-1", terminalBackend: ctx.terminalBackend,
|
||||
getCurrentSessionId: () => ctx.sessionRef.current,
|
||||
isSensitiveInput: () => ctx.passwordPromptActiveRef.current,
|
||||
confirmMultilinePaste: {
|
||||
enabled: true, minLines: 2,
|
||||
requestConfirm: async () => {
|
||||
ctx.passwordPromptActiveRef.current = false;
|
||||
return { action: "line-by-line" };
|
||||
},
|
||||
},
|
||||
onPasteData: () => assert.fail("paced broadcast must use actual write receipts"),
|
||||
});
|
||||
if (completion === "late-urgent") {
|
||||
ctx.isBroadcastEnabledRef.current = false;
|
||||
env.api.urgent();
|
||||
env.api.input("show new draft");
|
||||
for (const receipt of queuedReceipts.splice(0)) receiptListener?.(receipt);
|
||||
assert.deepEqual(broadcast, [], "old successful receipts must not resume broadcast after urgent input");
|
||||
assert.equal(ctx.commandBufferRef.current, "show new draft");
|
||||
if (lineMode) assert.equal(ctx.serialLineBufferRef.current, "show new draft");
|
||||
env.api.input("\r");
|
||||
if (lineMode) assert.equal(wire.at(-1), "show new draft\r");
|
||||
return;
|
||||
}
|
||||
if (completion === "filtered-first") {
|
||||
t.mock.timers.tick(250);
|
||||
await new Promise(resolve => setImmediate(resolve));
|
||||
assert.deepEqual(wire, lineMode ? ["show clock\r"] : ["show ", "show clock\r"]);
|
||||
assert.equal(ctx.commandBufferRef.current, "show ");
|
||||
if (lineMode) assert.equal(ctx.serialLineBufferRef.current, "show ");
|
||||
assert.deepEqual(history, ["show clock"]);
|
||||
return;
|
||||
}
|
||||
if (completion.startsWith("pending-")) {
|
||||
assert.equal(ctx.commandBufferRef.current, "show ");
|
||||
assert.deepEqual(history, []);
|
||||
ctx.isBroadcastEnabledRef.current = false;
|
||||
if (completion === "pending-manual") env.api.input("x");
|
||||
if (completion === "pending-clear") {
|
||||
env.api.input("\x15");
|
||||
env.api.input("show new draft");
|
||||
}
|
||||
releaseFirst?.();
|
||||
await new Promise(resolve => setImmediate(resolve));
|
||||
if (completion === "pending-ack") {
|
||||
assert.equal(ctx.commandBufferRef.current, "");
|
||||
assert.deepEqual(history, sensitive ? [] : ["show version"]);
|
||||
if (lineMode) assert.equal(ctx.serialLineBufferRef.current, "");
|
||||
} else {
|
||||
assert.equal(ctx.commandBufferRef.current, completion === "pending-manual" ? "show x" : "show new draft");
|
||||
assert.deepEqual(history, []);
|
||||
}
|
||||
bridge.interruptSession({}, { sessionId: "serial-1", cancelPendingWritesOnly: true });
|
||||
t.mock.timers.tick(1000);
|
||||
return;
|
||||
}
|
||||
if (completion === "blocked-first" || completion === "failed-first") {
|
||||
t.mock.timers.tick(1000);
|
||||
assert.deepEqual(wire, lineMode ? [] : ["show "]);
|
||||
assert.equal(ctx.commandBufferRef.current, "show ");
|
||||
if (lineMode) assert.equal(ctx.serialLineBufferRef.current, "show ");
|
||||
assert.deepEqual(history, []);
|
||||
assert.deepEqual(recorded, []);
|
||||
rejectPaste = false;
|
||||
ctx.isBroadcastEnabledRef.current = false;
|
||||
env.api.input("next");
|
||||
assert.equal(ctx.commandBufferRef.current, "show next");
|
||||
env.api.input("\r");
|
||||
if (lineMode) assert.equal(wire.at(-1), "show next\r");
|
||||
return;
|
||||
}
|
||||
assert.deepEqual(wire, lineMode ? ["show version\r"] : ["show ", "version\r"]);
|
||||
assert.equal(ctx.serialLineBufferRef.current, "");
|
||||
assert.deepEqual(loginCancellationNotices, lineMode ? ["serial-1", "serial-1"] : []);
|
||||
assert.equal(ctx.commandBufferRef.current, "");
|
||||
assert.deepEqual(submitted, sensitive ? [] : ["show version"]);
|
||||
assert.deepEqual(recorded, sensitive ? [] : ["show version"]);
|
||||
assert.deepEqual(autocomplete, ["show ", "version\nshow clock\r"]);
|
||||
assert.ok(outputTriggers.join("").includes("show clock"));
|
||||
assert.equal(echo.join(""), protocol === "serial" || protocol === "telnet" ? "show version\r\nshow clock\r\n" : "");
|
||||
assert.equal(writes.length, lineMode ? 1 : 2);
|
||||
assert.equal(writes.at(-1)?.lineDelayMs, 250);
|
||||
assert.equal(writes.at(-1)?.automated, false, "confirmed paced paste is still user input");
|
||||
assert.equal(writes.at(-1)?.sensitive, sensitive);
|
||||
assert.deepEqual(broadcast, sensitive ? [] : ["version\r"]);
|
||||
t.mock.timers.tick(249);
|
||||
assert.deepEqual(wire, lineMode ? ["show version\r"] : ["show ", "version\r"]);
|
||||
if (completion === "replacement") {
|
||||
await pasteTextWithMultilineConfirm("replacement", {
|
||||
term, sessionId: "serial-1", terminalBackend: ctx.terminalBackend,
|
||||
getCurrentSessionId: () => ctx.sessionRef.current,
|
||||
isSensitiveInput: () => sensitive,
|
||||
confirmMultilinePaste: { enabled: true, minLines: 1, requestConfirm: async () => ({ action: "line-by-line" }) },
|
||||
});
|
||||
const afterReplacement = [...wire];
|
||||
assert.equal(wire.at(-1), "replacement\r");
|
||||
t.mock.timers.tick(1000);
|
||||
assert.deepEqual(wire, afterReplacement);
|
||||
assert.deepEqual(submitted, sensitive ? [] : ["show version", "replacement"]);
|
||||
assert.deepEqual(recorded, sensitive ? [] : ["show version", "replacement"]);
|
||||
return;
|
||||
}
|
||||
if (completion === "password-ref" || completion === "password-screen" || completion === "output") {
|
||||
liveLine = completion === "password-screen" ? "Password: " : "working... still producing output";
|
||||
ctx.passwordPromptActiveRef.current = completion === "password-ref";
|
||||
t.mock.timers.tick(1);
|
||||
assert.equal(wire.at(-1), "show clock\r");
|
||||
const secondSensitive = sensitive || completion !== "output";
|
||||
assert.deepEqual(recordingSensitivity, [sensitive, secondSensitive]);
|
||||
assert.deepEqual(broadcast, sensitive ? [] : secondSensitive ? ["version\r"] : ["version\r", "show clock\r"]);
|
||||
assert.deepEqual(history, sensitive ? [] : secondSensitive ? ["show version"] : ["show version", "show clock"]);
|
||||
assert.deepEqual(recorded, sensitive ? [] : secondSensitive ? ["show version"] : ["show version", "show clock"]);
|
||||
return;
|
||||
}
|
||||
if (completion.startsWith("serial-")) {
|
||||
ctx.isBroadcastEnabledRef.current = false;
|
||||
const beforeEdit = [...wire];
|
||||
const edit = completion === "serial-text" ? "x" : completion === "serial-backspace" ? "\x7f"
|
||||
: completion === "serial-arrow" ? "\x1b[D" : completion === "serial-delete" ? "\x1b[3~"
|
||||
: completion === "serial-report" ? "\x1b[1;2R" : "\x15";
|
||||
env.api.input(edit);
|
||||
t.mock.timers.tick(1000);
|
||||
if (completion === "serial-report") {
|
||||
assert.deepEqual(wire, [...beforeEdit, "show clock\r"]);
|
||||
return;
|
||||
}
|
||||
assert.deepEqual(wire, beforeEdit);
|
||||
assert.equal(ctx.serialLineBufferRef.current, completion === "serial-backspace" || completion === "serial-clear" ? "" : edit);
|
||||
assert.deepEqual(submitted, sensitive ? [] : ["show version"]);
|
||||
assert.deepEqual(recorded, sensitive ? [] : ["show version"]);
|
||||
return;
|
||||
}
|
||||
if (completion !== "complete") {
|
||||
ctx.isBroadcastEnabledRef.current = false;
|
||||
if (completion === "manual") env.api.input(lineMode ? "\x03" : "x");
|
||||
else bridge.interruptSession({}, { sessionId: "serial-1" });
|
||||
const afterCancellation = [...wire];
|
||||
t.mock.timers.tick(1000);
|
||||
assert.deepEqual(wire, afterCancellation);
|
||||
assert.deepEqual(submitted, sensitive ? [] : ["show version"]);
|
||||
assert.deepEqual(recorded, sensitive ? [] : ["show version"]);
|
||||
return;
|
||||
}
|
||||
t.mock.timers.tick(1);
|
||||
assert.deepEqual(wire, lineMode ? ["show version\r", "show clock\r"] : ["show ", "version\r", "show clock\r"]);
|
||||
assert.deepEqual(submitted, sensitive ? [] : ["show version", "show clock"]);
|
||||
assert.deepEqual(recorded, sensitive ? [] : ["show version", "show clock"]);
|
||||
ctx.isBroadcastEnabledRef.current = false;
|
||||
env.api.input("\r");
|
||||
assert.deepEqual(wire, lineMode ? ["show version\r", "show clock\r", "\r"] : ["show ", "version\r", "show clock\r", "\r"]);
|
||||
env.api.dispose();
|
||||
assert.equal(userPaste.dispatchTerminalLinePaste(term, "unused\r", { lineDelayMs: 250, sensitive: false }), false);
|
||||
});
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,24 @@
|
||||
import { getTerminalBootEpoch } from '../../../domain/terminalBootEpoch';
|
||||
|
||||
const inputVersions = new Map<string, number>();
|
||||
let nextVersion = 0;
|
||||
|
||||
export function markTerminalBroadcastUserInput(sessionId: string): void {
|
||||
inputVersions.set(sessionId, ++nextVersion);
|
||||
}
|
||||
|
||||
export function clearTerminalBroadcastUserInput(sessionId: string): void {
|
||||
inputVersions.delete(sessionId);
|
||||
}
|
||||
|
||||
export function captureTerminalBroadcastInput(sessionId: string) {
|
||||
return { sessionId, inputVersion: inputVersions.get(sessionId), bootEpoch: getTerminalBootEpoch(sessionId) };
|
||||
}
|
||||
|
||||
export type TerminalBroadcastInputSnapshot = ReturnType<typeof captureTerminalBroadcastInput>;
|
||||
export type TerminalPacedBroadcast = { targets?: TerminalBroadcastInputSnapshot[] };
|
||||
|
||||
export function isTerminalBroadcastInputCurrent(snapshot: TerminalBroadcastInputSnapshot): boolean {
|
||||
return snapshot.inputVersion === inputVersions.get(snapshot.sessionId)
|
||||
&& snapshot.bootEpoch === getTerminalBootEpoch(snapshot.sessionId);
|
||||
}
|
||||
@@ -0,0 +1,141 @@
|
||||
import assert from "node:assert/strict";
|
||||
import { readFileSync } from "node:fs";
|
||||
import { createRequire } from "node:module";
|
||||
import test from "node:test";
|
||||
import vm from "node:vm";
|
||||
import ts from "typescript";
|
||||
import { resolveTerminalBroadcastTargetIds } from "../../../domain/terminalBroadcast";
|
||||
import * as pacedHelpers from "./terminalPacedBroadcast";
|
||||
import { setTerminalBootEpoch } from "../../../domain/terminalBootEpoch";
|
||||
|
||||
const require = createRequire(import.meta.url);
|
||||
const bridge = require("../../../electron/bridges/terminalBridge.cjs");
|
||||
const runtime = readFileSync(new URL("./createXTermRuntime.ts", import.meta.url), "utf8");
|
||||
const layer = readFileSync(new URL("../../TerminalLayer.tsx", import.meta.url), "utf8");
|
||||
const backend = readFileSync(new URL("../../../application/state/useTerminalBackend.ts", import.meta.url), "utf8");
|
||||
const compile = (source: string) => ts.transpileModule(source, { compilerOptions: { target: ts.ScriptTarget.ES2022 } }).outputText;
|
||||
const broadcastStart = layer.indexOf(" const handleBroadcastInput = useCallback(");
|
||||
const broadcastEnd = layer.indexOf(" const handleCommandSubmitted", broadcastStart);
|
||||
const pluginStart = runtime.indexOf(' if (isPluginHostProtocol(ctx.host.protocol) && ctx.terminalBackend.signalPluginConnection)');
|
||||
const pluginEnd = runtime.indexOf(" const interruptEventForKitty", pluginStart);
|
||||
const hookStart = backend.indexOf(" const interruptSession = useCallback(");
|
||||
const hookEnd = backend.indexOf(" const resizeSession", hookStart);
|
||||
|
||||
for (const path of ["source", "broadcast"] as const) {
|
||||
for (const failedSignal of [false, true]) {
|
||||
test(`${path} plugin interrupt cancels pending paste without duplicate raw interrupt (failure=${failedSignal})`, async t => {
|
||||
t.mock.timers.enable({ apis: ["setTimeout"] });
|
||||
const writes: string[] = [];
|
||||
const calls: string[] = [];
|
||||
bridge.init({ sessions: new Map([["s", { stream: { write: (data: string) => writes.push(data) } }]]), electronModule: {} });
|
||||
bridge.writeToSession(null, { sessionId: "s", data: "one\ntwo\r", automated: true, lineDelayMs: 250 });
|
||||
const terminalBackend = {
|
||||
interruptSession(id: string, _trace?: unknown, options?: { cancelPendingWritesOnly?: boolean }) {
|
||||
calls.push(options?.cancelPendingWritesOnly ? "cancel" : "raw");
|
||||
bridge.interruptSession(null, { sessionId: id, ...options });
|
||||
},
|
||||
async signalPluginConnection() {
|
||||
calls.push("signal");
|
||||
if (failedSignal) throw new Error("unsupported");
|
||||
},
|
||||
};
|
||||
const env = { ...pacedHelpers,
|
||||
ctx: { host: { protocol: "plugin:test" }, terminalBackend }, id: "s", interruptTrace: {},
|
||||
terminalBackend, isPluginHostProtocol: () => true,
|
||||
useCallback: (fn: unknown) => fn, resolveTerminalBroadcastTargetIds: () => ["s"],
|
||||
sessionsRef: { current: [{ id: "s", protocol: "plugin:test" }] }, isGlobalBroadcastEnabled: true,
|
||||
canUseDirectSessionWriteFallback: () => true, broadcastInterruptPrioritizersRef: { current: new Map() },
|
||||
isTerminalSensitiveInputActive: () => false,
|
||||
invoke: undefined as unknown as (data: string, source: string) => void,
|
||||
};
|
||||
if (path === "source") vm.runInNewContext(compile(runtime.slice(pluginStart, pluginEnd)), env);
|
||||
else {
|
||||
vm.runInNewContext(compile(layer.slice(broadcastStart, broadcastEnd) + "\nglobalThis.invoke = handleBroadcastInput;"), env);
|
||||
env.invoke("\x03", "other");
|
||||
}
|
||||
await Promise.resolve();
|
||||
t.mock.timers.tick(1000);
|
||||
assert.deepEqual(calls, failedSignal ? ["cancel", "signal", "raw"] : ["cancel", "signal"]);
|
||||
assert.deepEqual(writes, failedSignal ? ["one\r", "\x03"] : ["one\r"]);
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
test("ordinary broadcast typing supersedes the recipient's paced paste", t => {
|
||||
t.mock.timers.enable({ apis: ["setTimeout"] });
|
||||
const writes: string[] = [];
|
||||
bridge.init({ sessions: new Map([["s", { stream: { write: (data: string) => writes.push(data) } }]]), electronModule: {} });
|
||||
const env = { ...pacedHelpers,
|
||||
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,
|
||||
invoke: undefined as unknown as (data: string, source: string, options?: object) => void,
|
||||
};
|
||||
vm.runInNewContext(compile(layer.slice(broadcastStart, broadcastEnd) + "\nglobalThis.invoke = handleBroadcastInput;"), env);
|
||||
env.invoke("one\ntwo\r", "other", { lineDelayMs: 250 });
|
||||
env.invoke("x", "other");
|
||||
t.mock.timers.tick(1000);
|
||||
assert.deepEqual(writes, ["one\r", "x"]);
|
||||
});
|
||||
|
||||
test("backend hook preserves cancel-only options and never falls back to raw Ctrl+C for cancellation", () => {
|
||||
const calls: unknown[][] = [];
|
||||
let current: object = { interruptSession: (...args: unknown[]) => calls.push(args) };
|
||||
const env = {
|
||||
netcattyBridge: { get: () => current }, useCallback: (fn: unknown) => fn,
|
||||
invoke: undefined as unknown as (id: string, trace: unknown, options: object) => void,
|
||||
};
|
||||
vm.runInNewContext(compile(backend.slice(hookStart, hookEnd) + "\nglobalThis.invoke = interruptSession;"), env);
|
||||
env.invoke("s", undefined, { cancelPendingWritesOnly: true });
|
||||
assert.deepEqual(calls, [["s", undefined, { cancelPendingWritesOnly: true }]]);
|
||||
current = { writeToSession: () => assert.fail("cancel-only must not become raw Ctrl+C") };
|
||||
env.invoke("s", undefined, { cancelPendingWritesOnly: true });
|
||||
});
|
||||
|
||||
for (const change of ["source-sensitive", "peer-sensitive", "peer-typing", "peer-interrupt", "peer-reconnect", "new-target", "removed-target", "paced-snippet"] as const) {
|
||||
test(`receipt-paced user broadcast rechecks ${change} without rescheduling peers`, t => {
|
||||
t.mock.timers.enable({ apis: ["setTimeout"] });
|
||||
const wire: Record<string, string[]> = { source: [], peer: [], newcomer: [] };
|
||||
const sensitive = new Set<string>();
|
||||
const batch: pacedHelpers.TerminalPacedBroadcast = {};
|
||||
setTerminalBootEpoch("peer", 1);
|
||||
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,
|
||||
isTerminalSensitiveInputActive: (id: string) => sensitive.has(id),
|
||||
isPluginHostProtocol: () => false, broadcastInterruptPrioritizersRef: { current: new Map() },
|
||||
terminalBackend: {
|
||||
writeToSession(sessionId: string, data: string, options: object) { bridge.writeToSession(null, { sessionId, data, ...options }); },
|
||||
interruptSession(sessionId: string, _trace?: unknown, options?: object) { bridge.interruptSession(null, { sessionId, ...options }); },
|
||||
},
|
||||
invoke: undefined as unknown as (data: string, source: string, options?: object) => void,
|
||||
};
|
||||
vm.runInNewContext(compile(layer.slice(broadcastStart, broadcastEnd) + "\nglobalThis.invoke = handleBroadcastInput;"), env);
|
||||
bridge.init({ sessions: new Map(Object.keys(wire).map(id => [id, { webContentsId: 1, stream: { write: (data: string) => wire[id].push(data) } }])), electronModule: {
|
||||
webContents: { fromId: () => ({ send(channel: string, receipt: { sessionId: string; index?: number }) {
|
||||
if (channel === "netcatty:paste-write" && receipt.sessionId === "source" && receipt.index !== undefined) {
|
||||
env.invoke(receipt.index === 0 ? "first\r" : "second\r", "source", { pacedBroadcast: batch });
|
||||
}
|
||||
} }) },
|
||||
} });
|
||||
env.invoke("", "source", { pacedBroadcast: batch, preparePacedBroadcast: true });
|
||||
bridge.writeToSession(null, { sessionId: "source", data: "first\nsecond\r", automated: false, lineDelayMs: 250, pasteRequestId: "broadcast-test" });
|
||||
assert.deepEqual(wire.peer, ["first\r"]);
|
||||
if (change === "source-sensitive") sensitive.add("source");
|
||||
if (change === "peer-sensitive") sensitive.add("peer");
|
||||
if (change === "peer-typing" || change === "peer-interrupt") {
|
||||
env.invoke(change === "peer-typing" ? "typed" : "\x03", "other", { kittyKeyboardTargetSessionIds: ["peer"] });
|
||||
}
|
||||
if (change === "peer-reconnect") setTerminalBootEpoch("peer", 2);
|
||||
if (change === "new-target") env.sessionsRef.current.push({ id: "newcomer", protocol: "ssh" });
|
||||
if (change === "removed-target") env.sessionsRef.current[1].workspaceId = "other-workspace";
|
||||
if (change === "paced-snippet") env.invoke("snippet\r", "other", { automated: true, lineDelayMs: 250, kittyKeyboardTargetSessionIds: ["peer"] });
|
||||
const before = [...wire.peer];
|
||||
t.mock.timers.tick(250);
|
||||
assert.deepEqual(wire.source, ["first\r", "second\r"]);
|
||||
assert.deepEqual(wire.peer, change === "new-target" ? [...before, "second\r"] : before);
|
||||
assert.deepEqual(wire.newcomer, []);
|
||||
});
|
||||
}
|
||||
@@ -1,5 +1,8 @@
|
||||
import assert from "node:assert/strict";
|
||||
import test from "node:test";
|
||||
import { createRequire } from "node:module";
|
||||
const require = createRequire(import.meta.url);
|
||||
const backend = require("../../../electron/bridges/terminalReportSequence.cjs");
|
||||
|
||||
import { isTerminalReportSequence } from "./terminalReportSequence";
|
||||
|
||||
@@ -29,3 +32,17 @@ test("does not classify typed text or submit keys as reports", () => {
|
||||
assert.equal(isTerminalReportSequence("\x1b[200~pasted\x1b[201~"), false);
|
||||
assert.equal(isTerminalReportSequence(""), false);
|
||||
});
|
||||
|
||||
test('renderer and backend agree on protocol replies and distinguish keyboard escape sequences', () => {
|
||||
const replies = ['\x1b[I', '\x1b[O', '\x1b[1;2R', '\x1b[?1;2c', '\x1b[>1;2c', '\x1b[?997;1n',
|
||||
'\x1b[?3u', '\x1b[?2004;1$y', '\x1b[8;24;80t', '\x1b[6;12;8t', '\x1b[4;1080;1920t',
|
||||
'\x1b]10;rgb:ffff/ffff/ffff\x1b\\', '\x1b]4;255;rgb:ffff/0000/ffff\x1b\\',
|
||||
'\x1b]11;rgb:0000/0000/0000\x07', '\x1bP1$r0m\x1b\\'];
|
||||
const keys = ['hello', '\r', '\x7f', '\x15', '\x1b[A', '\x1b[D', '\x1b[3~', '\x1bOD', '\x1bx', '\x1b[200~paste\x1b[201~'];
|
||||
for (const [values, expected] of [[replies, true], [keys, false]] as const) {
|
||||
for (const value of values) {
|
||||
assert.equal(isTerminalReportSequence(value), expected, JSON.stringify(value));
|
||||
assert.equal(isTerminalReportSequence(value), backend.isTerminalReportSequence(value), JSON.stringify(value));
|
||||
}
|
||||
}
|
||||
});
|
||||
@@ -0,0 +1,117 @@
|
||||
import assert from 'node:assert/strict';
|
||||
import net from 'node:net';
|
||||
import { readFileSync } from 'node:fs';
|
||||
import test from 'node:test';
|
||||
import { createRequire, Module } from 'node:module';
|
||||
import path from 'node:path';
|
||||
import { pathToFileURL, fileURLToPath, URL } from 'node:url';
|
||||
import { setTimeout } from 'node:timers';
|
||||
import vm from 'node:vm';
|
||||
|
||||
const root = fileURLToPath(new URL('../../../', import.meta.url));
|
||||
const require = createRequire(path.join(root, 'package.json'));
|
||||
const ts = require('typescript');
|
||||
const load = file => import(pathToFileURL(path.join(root, file)).href);
|
||||
const { normalizeLineEndings } = await load('lib/utils.ts');
|
||||
const { shouldDelayAutoRunSnippetInput, AUTO_RUN_SNIPPET_LINE_DELAY_MS } = await load('components/terminal/terminalHelpers.ts');
|
||||
const pacedHelpers = await load('components/terminal/runtime/terminalPacedBroadcast.ts');
|
||||
const { resolveTerminalBroadcastTargetIds } = await load('domain/terminalBroadcast.ts');
|
||||
const { canUseDirectSessionWriteFallback } = await load('components/terminalLayer/terminalLayerSessionRouting.ts');
|
||||
const sourceAt = file => readFileSync(path.join(root, file), 'utf8');
|
||||
const compile = source => ts.transpileModule(source, { compilerOptions: { target: ts.ScriptTarget.ES2022 } }).outputText;
|
||||
const extract = (source, from, to) => {
|
||||
const start = source.indexOf(from), end = source.indexOf(to, start);
|
||||
assert.ok(start >= 0 && end > start, 'Production extraction boundaries changed');
|
||||
return source.slice(start, end);
|
||||
};
|
||||
const waitFor = async predicate => {
|
||||
for (let i = 0; i < 200; i++) {
|
||||
if (predicate()) return;
|
||||
await new Promise(resolve => setTimeout(resolve, 10));
|
||||
}
|
||||
throw new Error('Timed out waiting for Telnet evidence');
|
||||
};
|
||||
|
||||
for (const mode of ['paste', 'lineDelay']) {
|
||||
test(`broadcast ${mode} snippet preserves Telnet auto-login on source and peer`, async () => {
|
||||
const backendFile = path.join(root, 'electron/bridges/terminalBridge.cjs');
|
||||
const backend = new Module(backendFile);
|
||||
backend.filename = backendFile;
|
||||
backend.paths = Module._nodeModulePaths(path.dirname(backendFile));
|
||||
backend._compile(sourceAt('electron/bridges/terminalBridge.cjs'), backendFile);
|
||||
const bridge = backend.exports;
|
||||
const received = [], sockets = [], events = [], calls = [];
|
||||
const server = net.createServer(socket => {
|
||||
const index = received.length;
|
||||
received.push('');
|
||||
sockets.push(socket);
|
||||
socket.setEncoding('utf8');
|
||||
socket.on('error', () => {});
|
||||
let askedUser = false, askedPassword = false;
|
||||
socket.on('data', chunk => {
|
||||
received[index] += chunk;
|
||||
if (!askedUser && received[index].includes('second\r\n')) {
|
||||
askedUser = true;
|
||||
socket.write('Username: ');
|
||||
}
|
||||
if (!askedPassword && received[index].includes('saved-user\r\n')) {
|
||||
askedPassword = true;
|
||||
socket.write('\r\nPassword: ');
|
||||
}
|
||||
});
|
||||
});
|
||||
await new Promise(resolve => server.listen(0, '127.0.0.1', resolve));
|
||||
bridge.init({ sessions: new Map(), electronModule: {
|
||||
webContents: { fromId: () => ({ send: (channel, payload) => events.push({ channel, payload }) }) },
|
||||
} });
|
||||
try {
|
||||
for (const id of ['source', 'peer']) {
|
||||
await bridge.startTelnetSession({ sender: { id: 1 } }, {
|
||||
sessionId: id, hostname: '127.0.0.1', port: server.address().port,
|
||||
username: 'saved-user', password: 'saved-secret',
|
||||
});
|
||||
}
|
||||
const terminalBackend = { writeToSession(sessionId, data, options) {
|
||||
calls.push({ sessionId, data, ...options });
|
||||
bridge.writeToSession(null, { sessionId, data, ...options });
|
||||
} };
|
||||
const layerSource = sourceAt('components/TerminalLayer.tsx');
|
||||
const layerEnv = { ...pacedHelpers,
|
||||
useCallback: callback => callback, terminalBackend,
|
||||
resolveTerminalBroadcastTargetIds, canUseDirectSessionWriteFallback,
|
||||
sessionsRef: { current: ['source', 'peer'].map(id => ({ id, protocol: 'telnet', status: 'connected' })) },
|
||||
isGlobalBroadcastEnabled: true, isTerminalSensitiveInputActive: () => false,
|
||||
};
|
||||
vm.runInNewContext(compile(extract(layerSource,
|
||||
' const handleBroadcastInput = useCallback(', ' const handleCommandSubmitted')
|
||||
+ '\nglobalThis.broadcast = handleBroadcastInput;'), layerEnv);
|
||||
const snippetSource = sourceAt('components/Terminal.tsx');
|
||||
const snippetEnv = { ...pacedHelpers,
|
||||
useCallback: callback => callback,
|
||||
termRef: { current: { modes: { bracketedPasteMode: false }, focus() {} } },
|
||||
sessionRef: { current: 'source' }, sessionId: 'source', hibernatedRef: { current: false },
|
||||
passwordPromptActiveRef: { current: false }, isBroadcastEnabledRef: { current: true },
|
||||
onBroadcastInputRef: { current: layerEnv.broadcast },
|
||||
normalizeLineEndings, shouldDelayAutoRunSnippetInput, AUTO_RUN_SNIPPET_LINE_DELAY_MS,
|
||||
disableBracketedPasteRef: { current: false }, prepareProgrammaticSudoInput: data => data,
|
||||
scrollToBottomAfterProgrammaticInput() {}, terminalBackend,
|
||||
host: { protocol: 'telnet' }, serialConfig: { lineMode: false },
|
||||
};
|
||||
vm.runInNewContext(compile(extract(snippetSource,
|
||||
' const executeSnippetCommand = useCallback(', ' const executeSnippet = useCallback(')
|
||||
+ '\nglobalThis.send = executeSnippetCommand;'), snippetEnv);
|
||||
assert.equal(await snippetEnv.send('first\nsecond', false, { multiLineRunMode: mode }), true);
|
||||
await waitFor(() => received[0]?.includes('saved-secret\r\n'));
|
||||
await waitFor(() => received[1]?.includes('saved-secret\r\n'));
|
||||
assert.deepEqual(events.filter(event => event.channel === 'netcatty:telnet:auto-login-cancelled'), []);
|
||||
assert.equal(calls.find(call => call.sessionId === 'source').automated, true);
|
||||
assert.equal(calls.find(call => call.sessionId === 'peer').automated, true);
|
||||
assert.equal(calls.find(call => call.sessionId === 'peer').lineDelayMs, mode === 'lineDelay' ? 250 : undefined);
|
||||
assert.equal(received[1].includes('saved-secret'), true);
|
||||
} finally {
|
||||
bridge.cleanupAllSessions();
|
||||
for (const socket of sockets) socket.destroy();
|
||||
await new Promise(resolve => server.close(resolve));
|
||||
}
|
||||
});
|
||||
}
|
||||
@@ -7,9 +7,11 @@ import {
|
||||
pasteTextIntoTerminal,
|
||||
prepareTerminalDataForUserPasteDisplay,
|
||||
shouldBroadcastTerminalUserInput,
|
||||
shouldOverrideTerminalUserPasteSensitivity,
|
||||
shouldSuppressTerminalBroadcastForUserPaste,
|
||||
shouldSuppressTerminalInputScrollForUserPaste,
|
||||
} from "./terminalUserPaste";
|
||||
import { sanitizeTerminalInput } from "./terminalInputSanitize";
|
||||
|
||||
test("user paste delegates raw clipboard text to xterm paste handling", () => {
|
||||
const pasted: string[] = [];
|
||||
@@ -445,3 +447,43 @@ test("long multi-line paste clears cursor-right residue after terminal echo", ()
|
||||
|
||||
assert.deepEqual(writes, ["\x1b[K"]);
|
||||
});
|
||||
|
||||
test("sensitive paste override matches sanitized data containing zero-width characters", () => {
|
||||
const term = {
|
||||
paste: () => {},
|
||||
scrollToBottom: () => {},
|
||||
};
|
||||
|
||||
const rawText = "pass\nword\n";
|
||||
pasteTextIntoTerminal(term, rawText, { sensitive: true });
|
||||
|
||||
// The input handler sanitizes chunk data before consulting the override,
|
||||
// so the lookup uses the sanitized, newline-prepared variants.
|
||||
const sanitizedPrepared = sanitizeTerminalInput(rawText).replace(/\r?\n/g, "\r");
|
||||
assert.equal(shouldOverrideTerminalUserPasteSensitivity(term, sanitizedPrepared), true);
|
||||
|
||||
const termBracketed = {
|
||||
modes: { bracketedPasteMode: true },
|
||||
paste: () => {},
|
||||
scrollToBottom: () => {},
|
||||
};
|
||||
pasteTextIntoTerminal(termBracketed, rawText, { sensitive: true });
|
||||
assert.equal(
|
||||
shouldOverrideTerminalUserPasteSensitivity(
|
||||
termBracketed,
|
||||
`\x1b[200~${sanitizedPrepared}\x1b[201~`,
|
||||
),
|
||||
true,
|
||||
);
|
||||
});
|
||||
|
||||
test("sensitive paste override does not leak to later unrelated input", () => {
|
||||
const term = {
|
||||
paste: () => {},
|
||||
scrollToBottom: () => {},
|
||||
};
|
||||
|
||||
pasteTextIntoTerminal(term, "secret", { sensitive: true });
|
||||
assert.equal(shouldOverrideTerminalUserPasteSensitivity(term, "secret"), true);
|
||||
assert.equal(shouldOverrideTerminalUserPasteSensitivity(term, "secret"), false);
|
||||
});
|
||||
@@ -1,5 +1,7 @@
|
||||
import type { Terminal as XTerm } from "@xterm/xterm";
|
||||
|
||||
import { sanitizeTerminalInput } from "./terminalInputSanitize";
|
||||
|
||||
type PasteTarget = Pick<XTerm, "paste" | "scrollToBottom"> &
|
||||
Partial<Pick<XTerm, "cols" | "rows" | "write">> & {
|
||||
modes?: { bracketedPasteMode?: boolean };
|
||||
@@ -10,6 +12,14 @@ type PasteOptions = {
|
||||
scrollOnPaste?: boolean;
|
||||
requestAnimationFrame?: (callback: () => void) => unknown;
|
||||
onPasteData?: (data: string) => 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
|
||||
* password-prompt ref was cleared while a confirmation dialog was open
|
||||
* (the dialog await can outlive the prompt flag). The override only
|
||||
* applies to the pasted data itself and is consumed as it arrives.
|
||||
*/
|
||||
sensitive?: boolean;
|
||||
};
|
||||
|
||||
type BroadcastUserInputOptions = {
|
||||
@@ -38,6 +48,26 @@ type TerminalProtocolReplyState = {
|
||||
const pasteDisplayStates = new WeakMap<object, PasteDisplayState>();
|
||||
const pasteInputScrollStates = new WeakMap<object, PasteInputScrollState>();
|
||||
const pasteBroadcastStates = new WeakMap<object, PasteInputScrollState>();
|
||||
const pasteSensitiveStates = new WeakMap<object, PasteInputScrollState>();
|
||||
type LinePasteOptions = { lineDelayMs: number; sensitive: boolean; broadcast?: boolean };
|
||||
type LinePasteHandler = (data: string, options: LinePasteOptions) => void;
|
||||
const linePasteHandlers = new WeakMap<object, LinePasteHandler>();
|
||||
|
||||
/** Preserve pending input and local echo before scheduling delayed writes. */
|
||||
export function registerTerminalLinePasteHandler(term: object, handler: LinePasteHandler): () => void {
|
||||
linePasteHandlers.set(term, handler);
|
||||
return () => {
|
||||
if (linePasteHandlers.get(term) === handler) linePasteHandlers.delete(term);
|
||||
};
|
||||
}
|
||||
|
||||
export function dispatchTerminalLinePaste(term: object, data: string, options: LinePasteOptions): boolean {
|
||||
const handler = linePasteHandlers.get(term);
|
||||
if (!handler) return false;
|
||||
handler(data, options);
|
||||
return true;
|
||||
}
|
||||
|
||||
const terminalProtocolReplyStates = new WeakMap<object, TerminalProtocolReplyState>();
|
||||
const LONG_PASTE_MIN_LENGTH = 200;
|
||||
const PASTE_DISPLAY_FIX_WINDOW_MS = 4000;
|
||||
@@ -325,6 +355,25 @@ export function pasteTextIntoTerminal(
|
||||
pasteBroadcastStates.delete(term);
|
||||
}
|
||||
|
||||
// Carry the pre-dialog sensitivity snapshot through term.paste so the
|
||||
// input handler writes / records this paste as sensitive even when the
|
||||
// live password-prompt ref has been cleared while the confirm dialog was
|
||||
// open. The state is consumed chunk-by-chunk like the scroll/broadcast
|
||||
// states so it never leaks to later, unrelated input.
|
||||
if (options.sensitive === true) {
|
||||
// The input handler sanitizes chunk data (stripping zero-width /
|
||||
// invisible characters such as U+200B or a BOM) before consulting this
|
||||
// override, so the stored variants must be sanitized equivalently or a
|
||||
// paste containing such characters would never match and could be
|
||||
// broadcast / recorded as nonsensitive.
|
||||
pasteSensitiveStates.set(term, {
|
||||
expiresAt: getNow() + PASTE_INPUT_SCROLL_WINDOW_MS,
|
||||
remainingDataVariants: getPasteInputDataVariants(sanitizeTerminalInput(text)),
|
||||
});
|
||||
} else {
|
||||
pasteSensitiveStates.delete(term);
|
||||
}
|
||||
|
||||
term.paste(text);
|
||||
|
||||
if (!options.scrollOnPaste) return;
|
||||
@@ -351,6 +400,18 @@ export function shouldSuppressTerminalBroadcastForUserPaste(term: object, data:
|
||||
return consumePasteInputState(pasteBroadcastStates, term, data);
|
||||
}
|
||||
|
||||
/**
|
||||
* True when the given input chunk belongs to a programmatic paste that was
|
||||
* flagged sensitive before a confirm-dialog await (see PasteOptions.sensitive).
|
||||
* Consumes the override state so it applies only to the flagged paste data.
|
||||
*/
|
||||
export function shouldOverrideTerminalUserPasteSensitivity(
|
||||
term: object,
|
||||
data: string,
|
||||
): boolean {
|
||||
return consumePasteInputState(pasteSensitiveStates, term, data);
|
||||
}
|
||||
|
||||
export function markExpectedTerminalCursorPositionReport(term: object): void {
|
||||
const currentState = terminalProtocolReplyStates.get(term);
|
||||
const activeState = isStateActive(currentState)
|
||||
|
||||
@@ -452,3 +452,382 @@ test("remote paste does not forward Ctrl+V for clipboard images", async () => {
|
||||
},
|
||||
});
|
||||
});
|
||||
|
||||
test("multi-line paste below the confirmation threshold pastes directly", async () => {
|
||||
const pasted: string[] = [];
|
||||
const confirmed: string[][] = [];
|
||||
|
||||
await handleTerminalClipboardPaste({
|
||||
isLocalConnection: false,
|
||||
confirmMultilinePaste: {
|
||||
enabled: true,
|
||||
minLines: 2,
|
||||
requestConfirm: async (info) => {
|
||||
confirmed.push([String(info.lineCount), String(info.charCount)]);
|
||||
return { action: "send", text: "line1\nline2" };
|
||||
},
|
||||
},
|
||||
readClipboardText: async () => "single line",
|
||||
sessionId: "session-1",
|
||||
terminalBackend: {
|
||||
writeToSession: () => assert.fail("single-line paste must not use writeToSession"),
|
||||
},
|
||||
term: {
|
||||
paste: (text) => pasted.push(text),
|
||||
scrollToBottom: () => {},
|
||||
},
|
||||
});
|
||||
|
||||
assert.deepEqual(confirmed, []);
|
||||
assert.deepEqual(pasted, ["single line"]);
|
||||
});
|
||||
|
||||
test("multi-line paste confirmation sends the (possibly edited) preview text", async () => {
|
||||
const pasted: string[] = [];
|
||||
const requests: Array<{ lineCount: number; charCount: number; text: string }> = [];
|
||||
|
||||
await handleTerminalClipboardPaste({
|
||||
isLocalConnection: false,
|
||||
confirmMultilinePaste: {
|
||||
enabled: true,
|
||||
minLines: 2,
|
||||
requestConfirm: async (info) => {
|
||||
requests.push({ ...info, text: info.text });
|
||||
return { action: "send", text: "line1\nedited" };
|
||||
},
|
||||
},
|
||||
readClipboardText: async () => "line1\r\nline2",
|
||||
sessionId: "session-1",
|
||||
terminalBackend: {
|
||||
writeToSession: () => assert.fail("send should use xterm paste handling"),
|
||||
},
|
||||
term: {
|
||||
paste: (text) => pasted.push(text),
|
||||
scrollToBottom: () => {},
|
||||
},
|
||||
});
|
||||
|
||||
assert.equal(requests.length, 1);
|
||||
assert.equal(requests[0].lineCount, 2);
|
||||
assert.deepEqual(pasted, ["line1\nedited"]);
|
||||
});
|
||||
|
||||
test("multi-line paste confirmation can send line by line with delay", async () => {
|
||||
const writes: Array<{ data: string; options?: { automated?: boolean; lineDelayMs?: number; sensitive?: boolean } }> = [];
|
||||
const scrolled: string[] = [];
|
||||
const pasted: string[] = [];
|
||||
const broadcast: Array<{ data: string; options?: { lineDelayMs?: number } }> = [];
|
||||
let focused = false;
|
||||
// Simulate the dialog staying open while remote output / a reconnect
|
||||
// clears the password prompt: the sensitive classification must be taken
|
||||
// before the dialog opens, not re-evaluated at send time.
|
||||
let passwordPromptActive = true;
|
||||
let onClose: (() => void) | undefined;
|
||||
|
||||
await handleTerminalClipboardPaste({
|
||||
isLocalConnection: false,
|
||||
isSensitiveInput: () => passwordPromptActive,
|
||||
confirmMultilinePaste: {
|
||||
enabled: true,
|
||||
minLines: 2,
|
||||
requestConfirm: async (info) => {
|
||||
onClose = info.onClose;
|
||||
passwordPromptActive = false;
|
||||
return { action: "line-by-line", text: "conf t\r\nint gi0/0" };
|
||||
},
|
||||
},
|
||||
readClipboardText: async () => "conf t\nint gi0/0",
|
||||
onPasteData: (data, options) => {
|
||||
broadcast.push({ data, options });
|
||||
return true;
|
||||
},
|
||||
scrollToBottomAfterProgrammaticInput: (data) => scrolled.push(data),
|
||||
sessionId: "session-1",
|
||||
terminalBackend: {
|
||||
writeToSession: (_sessionId, data, options) => writes.push({ data, options }),
|
||||
},
|
||||
term: {
|
||||
focus: () => {
|
||||
focused = true;
|
||||
},
|
||||
paste: (text) => pasted.push(text),
|
||||
scrollToBottom: () => {},
|
||||
},
|
||||
});
|
||||
|
||||
assert.deepEqual(writes, [{
|
||||
data: "conf t\nint gi0/0\r",
|
||||
options: { automated: false, lineDelayMs: 250, sensitive: true },
|
||||
}]);
|
||||
// A paste made at a sensitive prompt must not be fanned out to broadcast
|
||||
// peers, even though the dialog await cleared the live password-prompt ref.
|
||||
assert.deepEqual(broadcast, []);
|
||||
assert.deepEqual(scrolled, ["conf t\nint gi0/0\r"]);
|
||||
assert.deepEqual(pasted, []);
|
||||
assert.equal(focused, false);
|
||||
onClose?.();
|
||||
assert.equal(focused, true);
|
||||
});
|
||||
|
||||
for (const action of ["send", "line-by-line"] as const) {
|
||||
test(`${action} drops a confirmed paste when the source disconnects`, async () => {
|
||||
let sessionId: string | null = "session-1";
|
||||
await handleTerminalClipboardPaste({
|
||||
isLocalConnection: false,
|
||||
sessionId,
|
||||
getCurrentSessionId: () => sessionId,
|
||||
confirmMultilinePaste: {
|
||||
enabled: true, minLines: 2,
|
||||
requestConfirm: async () => {
|
||||
sessionId = null;
|
||||
return { action };
|
||||
},
|
||||
},
|
||||
readClipboardText: async () => "one\ntwo",
|
||||
onPasteData: () => assert.fail("disconnected source must not broadcast"),
|
||||
terminalBackend: { writeToSession: () => assert.fail("disconnected source must not write") },
|
||||
term: { paste: () => assert.fail("disconnected source must not paste"), scrollToBottom() {} },
|
||||
});
|
||||
});
|
||||
|
||||
test(`${action} preserves a password prompt that appears during confirmation`, async () => {
|
||||
const { shouldOverrideTerminalUserPasteSensitivity } = await import("./runtime/terminalUserPaste");
|
||||
let sensitive = false;
|
||||
let sent = false;
|
||||
const term = {
|
||||
paste(data: string) {
|
||||
// xterm normalizes pasted line endings before delivering onData.
|
||||
assert.equal(shouldOverrideTerminalUserPasteSensitivity(term, data.replace(/\n/g, "\r")), true);
|
||||
sent = true;
|
||||
},
|
||||
scrollToBottom() {},
|
||||
};
|
||||
await handleTerminalClipboardPaste({
|
||||
isLocalConnection: false, sessionId: "session-1", term,
|
||||
isSensitiveInput: () => sensitive,
|
||||
confirmMultilinePaste: {
|
||||
enabled: true, minLines: 2,
|
||||
requestConfirm: async () => {
|
||||
sensitive = true;
|
||||
return { action };
|
||||
},
|
||||
},
|
||||
readClipboardText: async () => "secret\nsecond secret",
|
||||
onPasteData: () => assert.fail("new password prompt must suppress broadcast"),
|
||||
terminalBackend: {
|
||||
writeToSession(_id, _data, options) {
|
||||
assert.equal(options?.sensitive, true);
|
||||
sent = true;
|
||||
},
|
||||
},
|
||||
});
|
||||
assert.equal(sent, true);
|
||||
});
|
||||
}
|
||||
|
||||
test("line-by-line send converts a single trailing LF to CR so the last line is submitted", async () => {
|
||||
const writes: Array<{ data: string; options?: { lineDelayMs?: number } }> = [];
|
||||
|
||||
await handleTerminalClipboardPaste({
|
||||
isLocalConnection: false,
|
||||
confirmMultilinePaste: {
|
||||
enabled: true,
|
||||
minLines: 2,
|
||||
requestConfirm: async () => ({ action: "line-by-line", text: "show run\n" }),
|
||||
},
|
||||
readClipboardText: async () => "show run\nconf t",
|
||||
sessionId: "session-1",
|
||||
terminalBackend: {
|
||||
writeToSession: (_sessionId, data, options) => writes.push({ data, options }),
|
||||
},
|
||||
term: {
|
||||
paste: () => {},
|
||||
scrollToBottom: () => {},
|
||||
},
|
||||
});
|
||||
|
||||
assert.deepEqual(writes, [{
|
||||
data: "show run\r",
|
||||
options: { automated: false, lineDelayMs: 250, sensitive: false },
|
||||
}]);
|
||||
});
|
||||
|
||||
test("line-by-line send revalidates the backend session after the dialog resolves", async () => {
|
||||
const writes: Array<{ sessionId: string; data: string }> = [];
|
||||
let liveSessionId: string | null = "session-1";
|
||||
|
||||
await handleTerminalClipboardPaste({
|
||||
isLocalConnection: false,
|
||||
confirmMultilinePaste: {
|
||||
enabled: true,
|
||||
minLines: 2,
|
||||
requestConfirm: async () => {
|
||||
// Simulate a reconnect replacing the backend session while the
|
||||
// confirmation dialog is open.
|
||||
liveSessionId = "session-2";
|
||||
return { action: "line-by-line", text: "conf t\nint gi0/0" };
|
||||
},
|
||||
},
|
||||
readClipboardText: async () => "conf t\nint gi0/0",
|
||||
sessionId: "session-1",
|
||||
getCurrentSessionId: () => liveSessionId,
|
||||
terminalBackend: {
|
||||
writeToSession: (sessionId, data) => writes.push({ sessionId, data }),
|
||||
},
|
||||
term: {
|
||||
paste: () => {},
|
||||
scrollToBottom: () => {},
|
||||
},
|
||||
});
|
||||
|
||||
assert.deepEqual(writes, [{ sessionId: "session-2", data: "conf t\nint gi0/0\r" }]);
|
||||
});
|
||||
|
||||
test("line-by-line send drops the paste when the session is gone after the dialog", async () => {
|
||||
let liveSessionId: string | null = "session-1";
|
||||
|
||||
await handleTerminalClipboardPaste({
|
||||
isLocalConnection: false,
|
||||
confirmMultilinePaste: {
|
||||
enabled: true,
|
||||
minLines: 2,
|
||||
requestConfirm: async () => {
|
||||
liveSessionId = null;
|
||||
return { action: "line-by-line", text: "conf t\nint gi0/0" };
|
||||
},
|
||||
},
|
||||
readClipboardText: async () => "conf t\nint gi0/0",
|
||||
sessionId: "session-1",
|
||||
getCurrentSessionId: () => liveSessionId,
|
||||
terminalBackend: {
|
||||
writeToSession: () => assert.fail("defunct session must not receive the paste"),
|
||||
},
|
||||
term: {
|
||||
paste: () => {},
|
||||
scrollToBottom: () => {},
|
||||
},
|
||||
});
|
||||
});
|
||||
|
||||
test("line-by-line send terminates the final line when the text lacks a trailing newline", async () => {
|
||||
const writes: Array<{ data: string; options?: { lineDelayMs?: number } }> = [];
|
||||
|
||||
await handleTerminalClipboardPaste({
|
||||
isLocalConnection: false,
|
||||
confirmMultilinePaste: {
|
||||
enabled: true,
|
||||
minLines: 2,
|
||||
requestConfirm: async () => ({ action: "line-by-line", text: "conf t\nint gi0/0" }),
|
||||
},
|
||||
readClipboardText: async () => "conf t\nint gi0/0",
|
||||
sessionId: "session-1",
|
||||
terminalBackend: {
|
||||
writeToSession: (_sessionId, data, options) => writes.push({ data, options }),
|
||||
},
|
||||
term: {
|
||||
paste: () => {},
|
||||
scrollToBottom: () => {},
|
||||
},
|
||||
});
|
||||
|
||||
assert.deepEqual(writes, [{
|
||||
data: "conf t\nint gi0/0\r",
|
||||
options: { automated: false, lineDelayMs: 250, sensitive: false },
|
||||
}]);
|
||||
});
|
||||
|
||||
test("an intentionally emptied preview sends nothing instead of the original text", async () => {
|
||||
const writes: string[] = [];
|
||||
const pasted: string[] = [];
|
||||
|
||||
await handleTerminalClipboardPaste({
|
||||
isLocalConnection: false,
|
||||
confirmMultilinePaste: {
|
||||
enabled: true,
|
||||
minLines: 2,
|
||||
requestConfirm: async () => ({ action: "send", text: "" }),
|
||||
},
|
||||
readClipboardText: async () => "line1\nline2",
|
||||
sessionId: "session-1",
|
||||
terminalBackend: {
|
||||
writeToSession: (_sessionId, data) => writes.push(data),
|
||||
},
|
||||
term: {
|
||||
paste: (text) => pasted.push(text),
|
||||
scrollToBottom: () => {},
|
||||
},
|
||||
});
|
||||
|
||||
assert.deepEqual(writes, []);
|
||||
assert.deepEqual(pasted, []);
|
||||
});
|
||||
|
||||
test("cancelling the multi-line paste confirmation drops the paste", async () => {
|
||||
await handleTerminalClipboardPaste({
|
||||
isLocalConnection: false,
|
||||
confirmMultilinePaste: {
|
||||
enabled: true,
|
||||
minLines: 2,
|
||||
requestConfirm: async () => ({ action: "cancel", text: "line1\nline2" }),
|
||||
},
|
||||
readClipboardText: async () => "line1\nline2",
|
||||
sessionId: "session-1",
|
||||
terminalBackend: {
|
||||
writeToSession: () => assert.fail("cancelled paste must not write to the session"),
|
||||
},
|
||||
term: {
|
||||
paste: () => assert.fail("cancelled paste must not call xterm paste"),
|
||||
scrollToBottom: () => {},
|
||||
},
|
||||
});
|
||||
});
|
||||
|
||||
test("multi-line paste confirmation skipped when the gate is disabled", async () => {
|
||||
const pasted: string[] = [];
|
||||
|
||||
await handleTerminalClipboardPaste({
|
||||
isLocalConnection: false,
|
||||
confirmMultilinePaste: {
|
||||
enabled: false,
|
||||
minLines: 2,
|
||||
requestConfirm: async () => assert.fail("disabled gate must not open the dialog"),
|
||||
},
|
||||
readClipboardText: async () => "line1\nline2\nline3",
|
||||
sessionId: "session-1",
|
||||
terminalBackend: {
|
||||
writeToSession: () => assert.fail("disabled gate should use xterm paste"),
|
||||
},
|
||||
term: {
|
||||
paste: (text) => pasted.push(text),
|
||||
scrollToBottom: () => {},
|
||||
},
|
||||
});
|
||||
|
||||
assert.deepEqual(pasted, ["line1\nline2\nline3"]);
|
||||
});
|
||||
|
||||
for (const [preview, expected] of [
|
||||
["\ufeffshow\u200b run\r\nshow version", "show run\nshow version\r"],
|
||||
["echo 👩\u200d💻\u200c\n", "echo 👩\u200d💻\u200c\r"],
|
||||
["\ufeff\u200b", ""],
|
||||
]) {
|
||||
test(`line-by-line paste sanitizes preview ${JSON.stringify(preview)}`, async () => {
|
||||
const writes: string[] = [];
|
||||
const broadcasts: string[] = [];
|
||||
await handleTerminalClipboardPaste({
|
||||
isLocalConnection: false,
|
||||
confirmMultilinePaste: {
|
||||
enabled: true,
|
||||
minLines: 2,
|
||||
requestConfirm: async () => ({ action: "line-by-line", text: preview }),
|
||||
},
|
||||
readClipboardText: async () => "first\nsecond",
|
||||
sessionId: "session-1",
|
||||
terminalBackend: { writeToSession: (_id, data) => { writes.push(data); } },
|
||||
onPasteData: (data) => { broadcasts.push(data); },
|
||||
term: { paste: () => assert.fail("must use delayed writes"), scrollToBottom: () => {} },
|
||||
});
|
||||
assert.deepEqual(writes, expected ? [expected] : []);
|
||||
assert.deepEqual(broadcasts, [], "without a runtime receipt owner, never broadcast an unchecked batch");
|
||||
});
|
||||
}
|
||||
@@ -5,8 +5,16 @@ import {
|
||||
type RemoteClipboardImageBridge,
|
||||
type RemoteClipboardImageUploadResult,
|
||||
} from "./clipboardImagePaste";
|
||||
import { extractRootPathsFromClipboardFiles } from "./terminalHelpers";
|
||||
import { pasteTextIntoTerminal } from "./runtime/terminalUserPaste";
|
||||
import { extractRootPathsFromClipboardFiles, AUTO_RUN_SNIPPET_LINE_DELAY_MS } from "./terminalHelpers";
|
||||
import { dispatchTerminalLinePaste, pasteTextIntoTerminal } from "./runtime/terminalUserPaste";
|
||||
import { sanitizeTerminalInput } from "./runtime/terminalInputSanitize";
|
||||
import {
|
||||
getMultilinePasteInfo,
|
||||
shouldConfirmMultilinePaste,
|
||||
type MultilinePasteConfirmAction,
|
||||
type MultilinePasteInfo,
|
||||
} from "../../domain/terminalPasteConfirm";
|
||||
import { normalizeLineEndings } from "../../lib/utils";
|
||||
import { logger } from "../../lib/logger";
|
||||
|
||||
/** ASCII Ctrl+V - forwarded so nested TUIs can run their own image-paste bindings. */
|
||||
@@ -17,6 +25,40 @@ type ClipboardFileBridge = Pick<
|
||||
"readClipboardFiles" | "hasClipboardImage"
|
||||
>;
|
||||
|
||||
export type MultilinePasteConfirmRequestFn = (
|
||||
info: MultilinePasteInfo & { text: string; onClose?: () => void },
|
||||
) => Promise<{ action: MultilinePasteConfirmAction; text?: string }>;
|
||||
|
||||
/**
|
||||
* The line-by-line backend writes chunks ending at a line separator with a
|
||||
* trailing `\r`, but leaves the final chunk unterminated when the source text
|
||||
* does not end with a newline — and only splits into CR-normalized chunks when
|
||||
* there is more than one line. Ensure the last line always ends with `\r` so
|
||||
* it is executed too, even for single-line input.
|
||||
*/
|
||||
const withFinalLineTerminator = (data: string): string => {
|
||||
if (data.length === 0) return data;
|
||||
// The backend's delayed-write path only emits CR-normalized chunks when
|
||||
// splitting produces more than one chunk, so a single trailing LF would
|
||||
// be written verbatim and leave the command unsubmitted on the
|
||||
// network-device CLIs this feature targets. Convert it to CR even when
|
||||
// only one line remains.
|
||||
if (data.endsWith("\n")) return `${data.slice(0, -1)}\r`;
|
||||
return `${data}\r`;
|
||||
};
|
||||
|
||||
/**
|
||||
* Gate for the multi-line paste confirmation dialog (#3398). When enabled and
|
||||
* the clipboard text reaches the configured line threshold, the paste waits
|
||||
* for the user to choose send / send-line-by-line / cancel.
|
||||
*/
|
||||
export type MultilinePasteConfirmGate = {
|
||||
enabled: boolean;
|
||||
minLines: number;
|
||||
/** Opens the confirm dialog; resolves with the chosen action + preview text. */
|
||||
requestConfirm?: MultilinePasteConfirmRequestFn;
|
||||
};
|
||||
|
||||
type TerminalClipboardPasteOptions = {
|
||||
bridge?: ClipboardFileBridge;
|
||||
/**
|
||||
@@ -27,25 +69,134 @@ type TerminalClipboardPasteOptions = {
|
||||
*/
|
||||
autoUploadClipboardImage?: boolean;
|
||||
clipboardImageBridge?: RemoteClipboardImageBridge;
|
||||
confirmMultilinePaste?: MultilinePasteConfirmGate;
|
||||
getCurrentSessionId?: () => string | null | undefined;
|
||||
getRemoteCwd?: () => Promise<string | null | undefined>;
|
||||
isLocalConnection: boolean;
|
||||
isSensitiveInput?: () => boolean;
|
||||
onClipboardImageUploadResult?: (result: RemoteClipboardImageUploadResult) => void;
|
||||
onPasteData?: (data: string) => boolean | void;
|
||||
onPasteData?: (data: string, options?: { lineDelayMs?: number }) => boolean | void;
|
||||
readClipboardText: () => Promise<string>;
|
||||
scrollOnPaste?: boolean;
|
||||
scrollToBottomAfterProgrammaticInput?: (data: string) => void;
|
||||
sessionId: string | null | undefined;
|
||||
terminalBackend: {
|
||||
writeToSession: (sessionId: string, data: string, options?: { automated?: boolean; sensitive?: boolean }) => void;
|
||||
writeToSession: (sessionId: string, data: string, options?: { automated?: boolean; sensitive?: boolean; lineDelayMs?: number }) => void;
|
||||
};
|
||||
term: Pick<XTerm, "paste" | "scrollToBottom"> & Partial<Pick<XTerm, "focus">>;
|
||||
};
|
||||
|
||||
type MultilineGatedPasteOptions = {
|
||||
confirmMultilinePaste?: MultilinePasteConfirmGate;
|
||||
/**
|
||||
* Returns the live backend session id. The confirmation dialog can stay
|
||||
* open across a disconnect / auto-reconnect, so callers backed by a
|
||||
* session ref should provide this so the line-by-line send is
|
||||
* revalidated (or dropped) after the dialog resolves.
|
||||
*/
|
||||
getCurrentSessionId?: () => string | null | undefined;
|
||||
isSensitiveInput?: () => boolean;
|
||||
onPasteData?: (data: string, options?: { lineDelayMs?: number }) => boolean | void;
|
||||
scrollOnPaste?: boolean;
|
||||
scrollToBottomAfterProgrammaticInput?: (data: string) => void;
|
||||
sessionId: string | null | undefined;
|
||||
terminalBackend: {
|
||||
writeToSession: (sessionId: string, data: string, options?: { automated?: boolean; sensitive?: boolean; lineDelayMs?: number }) => void;
|
||||
};
|
||||
term: Pick<XTerm, "paste" | "scrollToBottom"> & Partial<Pick<XTerm, "focus">>;
|
||||
};
|
||||
|
||||
/**
|
||||
* Paste `text` into the terminal, routing through the multi-line paste
|
||||
* confirmation dialog when enabled (#3398). Shared by clipboard paste,
|
||||
* the context-menu Paste Selection action and the pasteSelection shortcut
|
||||
* so every user-initiated paste path honors the same review gate.
|
||||
*/
|
||||
export async function pasteTextWithMultilineConfirm(
|
||||
text: string,
|
||||
{
|
||||
confirmMultilinePaste,
|
||||
getCurrentSessionId,
|
||||
isSensitiveInput,
|
||||
onPasteData,
|
||||
scrollOnPaste = false,
|
||||
scrollToBottomAfterProgrammaticInput,
|
||||
sessionId,
|
||||
terminalBackend,
|
||||
term,
|
||||
}: MultilineGatedPasteOptions,
|
||||
): Promise<void> {
|
||||
if (!sessionId) return;
|
||||
const session: string = sessionId;
|
||||
// Snapshot the sensitive classification before any await: the confirm
|
||||
// dialog can stay open while remote output or a reconnect clears
|
||||
// passwordPromptActiveRef, and re-evaluating after the await would
|
||||
// downgrade a paste made at a password prompt to nonsensitive (enabling
|
||||
// broadcast fan-out and input logging of the secret).
|
||||
const sensitive = isSensitiveInput?.() === true;
|
||||
// Multi-line paste confirmation (#3398): network-device CLIs (Cisco IOS,
|
||||
// Huawei VRP, H3C Comware) execute every pasted line immediately and have
|
||||
// no bracketed-paste protection, so let the user review before sending.
|
||||
// Applied to non-empty text and whitespace-only text alike, so a blank
|
||||
// multi-line clipboard cannot silently submit Enter presses at a prompt.
|
||||
if (
|
||||
confirmMultilinePaste?.enabled
|
||||
&& shouldConfirmMultilinePaste(text, { minLines: confirmMultilinePaste.minLines })
|
||||
) {
|
||||
const decision = confirmMultilinePaste.requestConfirm
|
||||
? await confirmMultilinePaste.requestConfirm({ ...getMultilinePasteInfo(text), text, onClose: () => term.focus?.() })
|
||||
: null;
|
||||
if (!decision || decision.action === "cancel") return;
|
||||
const currentSessionId = getCurrentSessionId ? getCurrentSessionId() : session;
|
||||
if (!currentSessionId) return;
|
||||
const confirmedSensitive = sensitive || isSensitiveInput?.() === 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.
|
||||
const lineData = withFinalLineTerminator(normalizeLineEndings(sanitizeTerminalInput(decision.text ?? text)));
|
||||
if (!lineData) return;
|
||||
const lineOptions = {
|
||||
lineDelayMs: AUTO_RUN_SNIPPET_LINE_DELAY_MS,
|
||||
sensitive: confirmedSensitive,
|
||||
broadcast: !confirmedSensitive && !!onPasteData,
|
||||
};
|
||||
if (!dispatchTerminalLinePaste(term, lineData, lineOptions)) {
|
||||
terminalBackend.writeToSession(currentSessionId, lineData, {
|
||||
automated: false,
|
||||
lineDelayMs: lineOptions.lineDelayMs,
|
||||
sensitive: lineOptions.sensitive,
|
||||
});
|
||||
}
|
||||
// The mounted runtime broadcasts each acknowledged line with fresh guards.
|
||||
// Without a runtime there is no receipt owner, so fallback sends only to
|
||||
// the source rather than enqueueing an unchecked batch on peers.
|
||||
scrollToBottomAfterProgrammaticInput?.(lineData);
|
||||
return;
|
||||
}
|
||||
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,
|
||||
// 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.
|
||||
sensitive: confirmedSensitive,
|
||||
});
|
||||
return;
|
||||
}
|
||||
pasteTextIntoTerminal(term, text, {
|
||||
scrollOnPaste,
|
||||
onPasteData,
|
||||
});
|
||||
}
|
||||
|
||||
export async function handleTerminalClipboardPaste({
|
||||
bridge,
|
||||
autoUploadClipboardImage = false,
|
||||
clipboardImageBridge,
|
||||
confirmMultilinePaste,
|
||||
getCurrentSessionId,
|
||||
getRemoteCwd,
|
||||
isLocalConnection,
|
||||
isSensitiveInput,
|
||||
@@ -123,9 +274,16 @@ export async function handleTerminalClipboardPaste({
|
||||
// image probe so screenshot clipboards that also carry blank text/plain can
|
||||
// still forward Ctrl+V for nested TUIs.
|
||||
if (text.trim() && sessionId) {
|
||||
pasteTextIntoTerminal(term, text, {
|
||||
scrollOnPaste,
|
||||
await pasteTextWithMultilineConfirm(text, {
|
||||
confirmMultilinePaste,
|
||||
getCurrentSessionId,
|
||||
isSensitiveInput,
|
||||
onPasteData,
|
||||
scrollOnPaste,
|
||||
scrollToBottomAfterProgrammaticInput,
|
||||
sessionId,
|
||||
terminalBackend,
|
||||
term,
|
||||
});
|
||||
return;
|
||||
}
|
||||
@@ -150,11 +308,19 @@ export async function handleTerminalClipboardPaste({
|
||||
}
|
||||
|
||||
// Preserve intentional whitespace-only pastes (indent / newline) when no
|
||||
// local clipboard image is present.
|
||||
// local clipboard image is present. Multi-line whitespace still goes through
|
||||
// the confirmation gate so it cannot bypass the review dialog (#3398).
|
||||
if (text && sessionId) {
|
||||
pasteTextIntoTerminal(term, text, {
|
||||
scrollOnPaste,
|
||||
await pasteTextWithMultilineConfirm(text, {
|
||||
confirmMultilinePaste,
|
||||
getCurrentSessionId,
|
||||
isSensitiveInput,
|
||||
onPasteData,
|
||||
scrollOnPaste,
|
||||
scrollToBottomAfterProgrammaticInput,
|
||||
sessionId,
|
||||
terminalBackend,
|
||||
term,
|
||||
});
|
||||
}
|
||||
}
|
||||
@@ -1,3 +1,4 @@
|
||||
import type { TerminalPacedBroadcast } from "./runtime/terminalPacedBroadcast";
|
||||
import type { DragEvent, PointerEvent } from "react";
|
||||
import { Terminal as XTerm } from "@xterm/xterm";
|
||||
|
||||
@@ -27,6 +28,9 @@ export const MAX_CONNECTION_LOG_DATA_CHARS = 1_000_000;
|
||||
export const AUTO_RUN_SNIPPET_LINE_DELAY_MS = 250;
|
||||
|
||||
export interface TerminalBroadcastInputOptions {
|
||||
automated?: boolean;
|
||||
pacedBroadcast?: TerminalPacedBroadcast;
|
||||
preparePacedBroadcast?: boolean;
|
||||
noAutoRun?: boolean;
|
||||
lineDelayMs?: number;
|
||||
kittyKeyboardInput?: KittyKeyboardBroadcastInput;
|
||||
|
||||
@@ -159,7 +159,7 @@ test('trusted command delivery reaches plugin providers without duplicating the
|
||||
test('password-prompt input is consumed before every semantic command callback', () => {
|
||||
assert.match(
|
||||
xtermRuntimeSource,
|
||||
/const sensitive = ctx\.passwordPromptActiveRef\?\.current === true;[\s\S]*?recordTerminalCommandExecution\([\s\S]*?\{ sensitive, allowHostStyleGreaterThanPrompt: ctx\.allowHostStyleGreaterThanPrompt \},\s*\);/,
|
||||
/const sensitive = ctx\.passwordPromptActiveRef\?\.current === true[\s\S]*?recordTerminalCommandExecution\([\s\S]*?\{ sensitive, allowHostStyleGreaterThanPrompt: ctx\.allowHostStyleGreaterThanPrompt \},\s*\);/,
|
||||
);
|
||||
assert.match(
|
||||
terminalSource,
|
||||
|
||||
@@ -21,6 +21,26 @@ test("terminal context paste reports whether it broadcast to peers", () => {
|
||||
assert.deepEqual(broadcasted, [{ data: "line one", sessionId: "workspace-session-1" }]);
|
||||
});
|
||||
|
||||
test("terminal context paste forwards paste options to the broadcast handler", () => {
|
||||
const broadcasted: Array<{ data: string; sessionId: string; options?: { lineDelayMs?: number } }> = [];
|
||||
|
||||
const didBroadcast = broadcastTerminalPasteData("line one", {
|
||||
sourceSessionId: "workspace-session-1",
|
||||
sessionRef: { current: "backend-session-1" },
|
||||
isBroadcastEnabledRef: { current: true },
|
||||
onBroadcastInputRef: {
|
||||
current: (data, sourceSessionId, options) => {
|
||||
broadcasted.push({ data, sessionId: sourceSessionId, options });
|
||||
},
|
||||
},
|
||||
}, { lineDelayMs: 250 });
|
||||
|
||||
assert.equal(didBroadcast, true);
|
||||
assert.deepEqual(broadcasted, [
|
||||
{ data: "line one", sessionId: "workspace-session-1", options: { lineDelayMs: 250 } },
|
||||
]);
|
||||
});
|
||||
|
||||
test("terminal context paste reports false when broadcast is disabled", () => {
|
||||
const didBroadcast = broadcastTerminalPasteData("line one", {
|
||||
sourceSessionId: "workspace-session-1",
|
||||
@@ -43,6 +63,18 @@ test("paste selection dismisses the history preview before sending text", async
|
||||
assert.equal(source.split("requestHistoryPreviewHide(term.element?.parentElement)").length - 1, 2);
|
||||
});
|
||||
|
||||
test("paste selection routes through the multi-line paste confirmation gate", async () => {
|
||||
const { readFileSync } = await import("node:fs");
|
||||
const source = readFileSync(new URL("./hooks/useTerminalContextActions.ts", import.meta.url), "utf8");
|
||||
const runtimeSource = readFileSync(new URL("./runtime/createXTermRuntime.ts", import.meta.url), "utf8");
|
||||
// Context-menu Paste Selection goes through the shared #3398 gate.
|
||||
assert.match(source, /const onPasteSelection = useCallback\(async \(\) => \{[\s\S]*?pasteTextWithMultilineConfirm\(selection, \{[\s\S]*?requestMultilinePasteConfirm/u);
|
||||
// The pasteSelection shortcut applies the same gate instead of pasting
|
||||
// the selected text directly into the session.
|
||||
assert.match(runtimeSource, /case "pasteSelection": \{[\s\S]*?pasteTextWithMultilineConfirm\(selection, \{/u);
|
||||
assert.doesNotMatch(runtimeSource, /case "pasteSelection":[\s\S]*?pasteTextIntoTerminal/u);
|
||||
});
|
||||
|
||||
test("terminal context paste never broadcasts password-prompt input", () => {
|
||||
const didBroadcast = broadcastTerminalPasteData("secret", {
|
||||
sourceSessionId: "workspace-session-1",
|
||||
|
||||
@@ -13,6 +13,7 @@ import {
|
||||
TERMINAL_INLINE_IMAGE_SEQUENCE_LIMIT_MB_DEFAULT,
|
||||
TERMINAL_INLINE_IMAGE_STORAGE_LIMIT_MB_DEFAULT,
|
||||
} from '../terminalInlineImages';
|
||||
import { normalizeMultilinePasteConfirmMinLines } from '../terminalPasteConfirm';
|
||||
|
||||
// Terminal appearance settings
|
||||
export type CursorShape = 'block' | 'bar' | 'underline';
|
||||
@@ -169,6 +170,14 @@ export interface TerminalSettings {
|
||||
// instead of falling back to a text paste. Remote SSH sessions only.
|
||||
autoUploadClipboardImageOnPaste: boolean;
|
||||
|
||||
// When true, pasting multiple lines first shows a confirmation dialog with
|
||||
// a line/character summary, an editable preview, and send / send-line-by-line
|
||||
// / cancel actions. Protects network-device CLIs (Cisco IOS, Huawei VRP,
|
||||
// H3C Comware) that do not implement bracketed paste (#3398).
|
||||
confirmBeforeMultilinePaste: boolean;
|
||||
/** Minimum line count that triggers the multi-line paste confirmation. */
|
||||
multilinePasteConfirmMinLines: number;
|
||||
|
||||
// Shell `clear` command behavior — controls whether CSI 3 J (erase scrollback)
|
||||
// from the shell is honored. Default true matches POSIX/ncurses since 2013:
|
||||
// `clear` clears both visible screen and scrollback. Disable to keep history
|
||||
@@ -449,6 +458,9 @@ export const normalizeTerminalSettings = (
|
||||
...mergedSettings,
|
||||
rendererType,
|
||||
autocompleteMaxSuggestions,
|
||||
multilinePasteConfirmMinLines: normalizeMultilinePasteConfirmMinLines(
|
||||
mergedSettings.multilinePasteConfirmMinLines,
|
||||
),
|
||||
hibernateHiddenTabsDelaySec: normalizeHibernateHiddenTabsDelaySec(
|
||||
mergedSettings.hibernateHiddenTabsDelaySec,
|
||||
),
|
||||
@@ -542,6 +554,8 @@ const DEFAULT_TERMINAL_SETTINGS: TerminalSettings = {
|
||||
systemManagerDockerStatsRefreshInterval: 3,
|
||||
disableBracketedPaste: false, // Bracketed paste enabled by default
|
||||
autoUploadClipboardImageOnPaste: false, // Opt-in: image in clipboard auto-uploads on paste (remote sessions)
|
||||
confirmBeforeMultilinePaste: false, // Opt-in: ask before pasting multiple lines (#3398)
|
||||
multilinePasteConfirmMinLines: 2, // Paste of 2+ lines asks for confirmation
|
||||
clearWipesScrollback: true, // POSIX-standard: shell `clear` clears scrollback too
|
||||
preserveSelectionOnInput: false, // Opt-in: keep selection alive when typing
|
||||
forcePromptNewLine: false, // Opt-in: keep the next shell prompt visually separated from unterminated final output lines
|
||||
|
||||
@@ -370,6 +370,13 @@ export const SETTINGS_SEARCH_CATALOG: readonly SettingsSearchEntry[] = [
|
||||
descriptionKey: "settings.terminal.behavior.autoUploadClipboardImage.desc",
|
||||
sectionKey: "settings.terminal.section.behavior",
|
||||
},
|
||||
{
|
||||
id: "terminal-confirm-multiline-paste",
|
||||
tab: "terminal",
|
||||
labelKey: "settings.terminal.behavior.confirmMultilinePaste",
|
||||
descriptionKey: "settings.terminal.behavior.confirmMultilinePaste.desc",
|
||||
sectionKey: "settings.terminal.section.behavior",
|
||||
},
|
||||
{
|
||||
id: "terminal-shift-enter-newline",
|
||||
tab: "terminal",
|
||||
|
||||
@@ -0,0 +1,40 @@
|
||||
import test from "node:test";
|
||||
import assert from "node:assert/strict";
|
||||
|
||||
import {
|
||||
getMultilinePasteInfo,
|
||||
normalizeMultilinePasteConfirmMinLines,
|
||||
shouldConfirmMultilinePaste,
|
||||
} from "./terminalPasteConfirm";
|
||||
|
||||
test("line count ignores a single trailing newline", () => {
|
||||
assert.equal(getMultilinePasteInfo("line1\nline2\n").lineCount, 2);
|
||||
assert.equal(getMultilinePasteInfo("line1\n").lineCount, 1);
|
||||
assert.equal(getMultilinePasteInfo("").lineCount, 0);
|
||||
});
|
||||
|
||||
test("line count normalizes CRLF and CR line endings", () => {
|
||||
assert.equal(getMultilinePasteInfo("line1\r\nline2\rline3\n").lineCount, 3);
|
||||
assert.equal(getMultilinePasteInfo("a\r\nb\nc").charCount, 6);
|
||||
});
|
||||
|
||||
test("default threshold asks for confirmation at 2+ lines", () => {
|
||||
assert.equal(shouldConfirmMultilinePaste("single"), false);
|
||||
assert.equal(shouldConfirmMultilinePaste("line1\n"), false);
|
||||
assert.equal(shouldConfirmMultilinePaste("line1\nline2"), true);
|
||||
assert.equal(shouldConfirmMultilinePaste("line1\r\nline2\r\nline3"), true);
|
||||
});
|
||||
|
||||
test("configurable threshold is honored", () => {
|
||||
assert.equal(shouldConfirmMultilinePaste("line1\nline2", { minLines: 3 }), false);
|
||||
assert.equal(shouldConfirmMultilinePaste("line1\nline2\nline3", { minLines: 3 }), true);
|
||||
});
|
||||
|
||||
test("threshold normalization clamps to the supported range", () => {
|
||||
assert.equal(normalizeMultilinePasteConfirmMinLines(undefined), 2);
|
||||
assert.equal(normalizeMultilinePasteConfirmMinLines(0), 1);
|
||||
assert.equal(normalizeMultilinePasteConfirmMinLines(-5), 1);
|
||||
assert.equal(normalizeMultilinePasteConfirmMinLines(2000), 1000);
|
||||
assert.equal(normalizeMultilinePasteConfirmMinLines(Number.NaN), 2);
|
||||
assert.equal(normalizeMultilinePasteConfirmMinLines(10.4), 10);
|
||||
});
|
||||
@@ -0,0 +1,65 @@
|
||||
/**
|
||||
* Pure logic for the multi-line paste confirmation dialog (#3398).
|
||||
*
|
||||
* Network-device CLIs (Cisco IOS, Huawei VRP, H3C Comware) do not implement
|
||||
* bracketed paste, so a multi-line paste is executed line-by-line with no
|
||||
* chance to review it. This module decides when a paste needs confirmation
|
||||
* and how the clipboard text is summarized for the dialog.
|
||||
*/
|
||||
|
||||
export type MultilinePasteConfirmAction = "send" | "line-by-line" | "cancel";
|
||||
|
||||
/** Default threshold: any paste of 2+ lines asks for confirmation. */
|
||||
export const MULTILINE_PASTE_CONFIRM_MIN_LINES_DEFAULT = 2;
|
||||
export const MULTILINE_PASTE_CONFIRM_MIN_LINES_MIN = 1;
|
||||
export const MULTILINE_PASTE_CONFIRM_MIN_LINES_MAX = 1000;
|
||||
|
||||
export interface MultilinePasteInfo {
|
||||
/** Number of lines the paste will submit (trailing newline not counted). */
|
||||
lineCount: number;
|
||||
/** Total character count of the clipboard text. */
|
||||
charCount: number;
|
||||
}
|
||||
|
||||
/** Normalize CRLF / CR line endings to LF for line counting and preview. */
|
||||
export const normalizeMultilinePasteText = (text: string): string =>
|
||||
String(text ?? "").replace(/\r\n/g, "\n").replace(/\r/g, "\n");
|
||||
|
||||
export const normalizeMultilinePasteConfirmMinLines = (
|
||||
value?: number | null,
|
||||
): number => {
|
||||
if (typeof value !== "number" || !Number.isFinite(value)) {
|
||||
return MULTILINE_PASTE_CONFIRM_MIN_LINES_DEFAULT;
|
||||
}
|
||||
const rounded = Math.round(value);
|
||||
if (rounded < MULTILINE_PASTE_CONFIRM_MIN_LINES_MIN) {
|
||||
return MULTILINE_PASTE_CONFIRM_MIN_LINES_MIN;
|
||||
}
|
||||
if (rounded > MULTILINE_PASTE_CONFIRM_MIN_LINES_MAX) {
|
||||
return MULTILINE_PASTE_CONFIRM_MIN_LINES_MAX;
|
||||
}
|
||||
return rounded;
|
||||
};
|
||||
|
||||
export const getMultilinePasteInfo = (text: string): MultilinePasteInfo => {
|
||||
const normalized = normalizeMultilinePasteText(text);
|
||||
const lines = normalized.split("\n");
|
||||
// A single trailing newline is just the line terminator, not an extra line.
|
||||
if (lines.length > 0 && lines[lines.length - 1] === "") {
|
||||
lines.pop();
|
||||
}
|
||||
return {
|
||||
lineCount: lines.length,
|
||||
charCount: text.length,
|
||||
};
|
||||
};
|
||||
|
||||
export const shouldConfirmMultilinePaste = (
|
||||
text: string,
|
||||
options?: { minLines?: number | null },
|
||||
): boolean => {
|
||||
const minLines = normalizeMultilinePasteConfirmMinLines(
|
||||
options?.minLines ?? MULTILINE_PASTE_CONFIRM_MIN_LINES_DEFAULT,
|
||||
);
|
||||
return getMultilinePasteInfo(text).lineCount >= minLines;
|
||||
};
|
||||
@@ -300,3 +300,28 @@ test("normalizeTerminalSettings preserves intentional custom autocompleteMaxSugg
|
||||
20,
|
||||
);
|
||||
});
|
||||
|
||||
test("normalizeTerminalSettings keeps multi-line paste confirmation opt-in", () => {
|
||||
assert.equal(normalizeTerminalSettings().confirmBeforeMultilinePaste, false);
|
||||
assert.equal(
|
||||
normalizeTerminalSettings({ confirmBeforeMultilinePaste: true }).confirmBeforeMultilinePaste,
|
||||
true,
|
||||
);
|
||||
});
|
||||
|
||||
test("normalizeTerminalSettings clamps the multi-line paste threshold", () => {
|
||||
assert.equal(normalizeTerminalSettings().multilinePasteConfirmMinLines, 2);
|
||||
assert.equal(
|
||||
normalizeTerminalSettings({ multilinePasteConfirmMinLines: 5 }).multilinePasteConfirmMinLines,
|
||||
5,
|
||||
);
|
||||
assert.equal(
|
||||
normalizeTerminalSettings({ multilinePasteConfirmMinLines: 0 }).multilinePasteConfirmMinLines,
|
||||
1,
|
||||
);
|
||||
assert.equal(
|
||||
normalizeTerminalSettings({ multilinePasteConfirmMinLines: 5000 })
|
||||
.multilinePasteConfirmMinLines,
|
||||
1000,
|
||||
);
|
||||
});
|
||||
@@ -1112,6 +1112,7 @@ function startLocalSession(event, payload) {
|
||||
sessionLogStreamManager.stopStream(sessionId, logStreamToken);
|
||||
if (sessions.get(sessionId) !== session) return;
|
||||
ptyProcessTree.unregisterPid(sessionId);
|
||||
clearPendingAutomatedWrites(session);
|
||||
sessions.delete(sessionId);
|
||||
if (session.closed) return;
|
||||
// Signal present = killed externally (show disconnected UI).
|
||||
@@ -1450,6 +1451,7 @@ async function startSerialSession(event, options) {
|
||||
sessionLogStreamManager.stopStream(sessionId, logStreamToken);
|
||||
const primaryId = session.webContentsId;
|
||||
ptyProcessTree.unregisterPid(sessionId);
|
||||
clearPendingAutomatedWrites(session);
|
||||
sessions.delete(sessionId);
|
||||
if (session.closed) return;
|
||||
fanoutSessionLifecycleEvent(
|
||||
@@ -1522,12 +1524,51 @@ function pauseSshOutputForInterrupt(session, trace) {
|
||||
}
|
||||
|
||||
function clearPendingAutomatedWrites(session) {
|
||||
for (const paste of session?.pendingPasteWrites || []) paste.finish();
|
||||
const timers = session?.pendingAutomatedWriteTimers;
|
||||
if (!Array.isArray(timers) || timers.length === 0) return;
|
||||
for (const timer of timers) clearTimeout(timer);
|
||||
session.pendingAutomatedWriteTimers = [];
|
||||
}
|
||||
|
||||
// Receipts confirm a transport write returned, not remote execution. Plugin
|
||||
// stream facades confirm handoff to main, not completion of main's input chain.
|
||||
// Receipts contain identity only; command text stays in the renderer.
|
||||
function createPasteWriteReceipt(session, payload, count) {
|
||||
const hasReceipt = typeof payload.pasteRequestId === "string" && payload.pasteRequestId.length > 0;
|
||||
// Broadcast peers may omit receipts but still need a cancellable batch.
|
||||
if (!hasReceipt && getTerminalLineDelayMs(payload) === 0) return null;
|
||||
const pending = session.pendingPasteWrites ||= new Set();
|
||||
let remaining = count;
|
||||
const paste = {
|
||||
active: true,
|
||||
finish(index, skipped = false) {
|
||||
if (!paste.active) return;
|
||||
const done = index === undefined || --remaining === 0;
|
||||
if (done) {
|
||||
paste.active = false;
|
||||
pending.delete(paste);
|
||||
}
|
||||
if (!hasReceipt || (skipped && !done)) return;
|
||||
try {
|
||||
const owner = electronModule.webContents?.fromId(session.webContentsId);
|
||||
if (owner && !owner.isDestroyed?.()) {
|
||||
owner.send("netcatty:paste-write", {
|
||||
sessionId: payload.sessionId,
|
||||
requestId: payload.pasteRequestId,
|
||||
...(index === undefined || skipped ? {} : { index }),
|
||||
...(done ? { done: true } : {}),
|
||||
});
|
||||
}
|
||||
} catch {
|
||||
// A closed renderer must not affect transport writes.
|
||||
}
|
||||
},
|
||||
};
|
||||
pending.add(paste);
|
||||
return paste;
|
||||
}
|
||||
|
||||
function splitTerminalInputIntoLineWrites(data) {
|
||||
if (typeof data !== "string") return [data];
|
||||
const chunks = [];
|
||||
@@ -1548,9 +1589,10 @@ function splitTerminalInputIntoLineWrites(data) {
|
||||
return chunks.length > 0 ? chunks : [data];
|
||||
}
|
||||
|
||||
function getAutomatedLineDelayMs(payload) {
|
||||
if (!payload?.automated) return 0;
|
||||
const lineDelayMs = Number(payload.lineDelayMs);
|
||||
// Pacing is independent of input origin: a confirmed paste is still user
|
||||
// input, so protocol features such as Telnet auto-login must yield to it.
|
||||
function getTerminalLineDelayMs(payload) {
|
||||
const lineDelayMs = Number(payload?.lineDelayMs);
|
||||
return Number.isFinite(lineDelayMs) && lineDelayMs > 0 ? Math.min(lineDelayMs, 2000) : 0;
|
||||
}
|
||||
|
||||
@@ -1667,7 +1709,10 @@ function writeToSessionNow(payload, data, logRewrite = payload.logRewrite) {
|
||||
expandSerialBackspace(inputData, payload.serialEraseChar, session.encoding),
|
||||
session.encoding,
|
||||
));
|
||||
} else {
|
||||
return false;
|
||||
}
|
||||
return true;
|
||||
} catch (err) {
|
||||
logTerminalInterruptDebug("write-session-error", {
|
||||
sessionId: payload.sessionId,
|
||||
@@ -1686,7 +1731,24 @@ function writeToSessionWithInterception(
|
||||
data,
|
||||
logRewrite = payload.logRewrite,
|
||||
expectedSession = sessions.get(payload.sessionId),
|
||||
paste = null,
|
||||
index = 0,
|
||||
) {
|
||||
const writeWithReceipt = (nextData) => {
|
||||
if (paste && !paste.active) return;
|
||||
const current = sessions.get(payload.sessionId);
|
||||
if (paste && (current !== expectedSession || current?.closed)) {
|
||||
paste.finish();
|
||||
return;
|
||||
}
|
||||
if (paste && nextData === "") {
|
||||
// An interceptor can drop one chunk without canceling the remaining paste.
|
||||
paste.finish(index, true);
|
||||
return;
|
||||
}
|
||||
const written = writeToSessionNow(payload, nextData, logRewrite);
|
||||
if (paste) paste.finish(written ? index : undefined);
|
||||
};
|
||||
const bypass = payload?.sensitive === true || isTerminalReportSequence(data);
|
||||
const hasInterceptor = Boolean(
|
||||
terminalDataPipeline?.interceptInput
|
||||
@@ -1694,7 +1756,7 @@ function writeToSessionWithInterception(
|
||||
);
|
||||
const previous = terminalInputPipelineBarriers.get(payload.sessionId);
|
||||
if (!hasInterceptor && !previous) {
|
||||
writeToSessionNow(payload, data, logRewrite);
|
||||
writeWithReceipt(data);
|
||||
return;
|
||||
}
|
||||
// Cancel the auto-login detector at input ingress, before the asynchronous
|
||||
@@ -1714,10 +1776,14 @@ function writeToSessionWithInterception(
|
||||
}
|
||||
const writeIfCurrent = (nextData) => {
|
||||
const current = sessions.get(payload.sessionId);
|
||||
if (!current || current !== expectedSession || current.closed) return;
|
||||
writeToSessionNow(payload, nextData, logRewrite);
|
||||
if (!current || current !== expectedSession || current.closed) {
|
||||
paste?.finish();
|
||||
return;
|
||||
}
|
||||
writeWithReceipt(nextData);
|
||||
};
|
||||
const write = async () => {
|
||||
if (paste && !paste.active) return;
|
||||
if (!hasInterceptor) {
|
||||
writeIfCurrent(data);
|
||||
return;
|
||||
@@ -1764,27 +1830,36 @@ function writeToSession(event, payload) {
|
||||
// Activity tracking must not interfere with terminal input.
|
||||
}
|
||||
|
||||
if (!payload.automated && !isTerminalReportSequence(payload.data)) {
|
||||
const lineDelayMs = getTerminalLineDelayMs(payload);
|
||||
const isPasteRequest = typeof payload.pasteRequestId === "string" && payload.pasteRequestId.length > 0;
|
||||
// A replacement supersedes pending paste work even with one line, and even
|
||||
// when the transfer gate below blocks the replacement itself.
|
||||
if (isPasteRequest || lineDelayMs > 0 || (!payload.automated && !isTerminalReportSequence(payload.data))) {
|
||||
clearPendingAutomatedWrites(session);
|
||||
}
|
||||
if (shouldBlockSessionInput(session, payload.data)) {
|
||||
createPasteWriteReceipt(session, payload, 1)?.finish();
|
||||
return;
|
||||
}
|
||||
|
||||
const lineDelayMs = getAutomatedLineDelayMs(payload);
|
||||
const lineChunks = lineDelayMs > 0 ? splitTerminalInputIntoLineWrites(payload.data) : [payload.data];
|
||||
const paste = createPasteWriteReceipt(session, payload, lineChunks.length);
|
||||
if (lineDelayMs > 0 && lineChunks.length > 1) {
|
||||
clearPendingAutomatedWrites(session);
|
||||
session.pendingAutomatedWriteTimers = [];
|
||||
lineChunks.forEach((chunk, index) => {
|
||||
const sendChunk = () => {
|
||||
const current = sessions.get(payload.sessionId);
|
||||
if (!current) return;
|
||||
if (paste && !paste.active) return;
|
||||
if (!current || (paste && current !== session)) {
|
||||
paste?.finish();
|
||||
return;
|
||||
}
|
||||
writeToSessionWithInterception(
|
||||
{ ...payload, lineDelayMs: undefined },
|
||||
chunk,
|
||||
index === 0 ? payload.logRewrite : undefined,
|
||||
current,
|
||||
paste,
|
||||
index,
|
||||
);
|
||||
};
|
||||
if (index === 0) {
|
||||
@@ -1797,7 +1872,7 @@ function writeToSession(event, payload) {
|
||||
return;
|
||||
}
|
||||
|
||||
writeToSessionWithInterception(payload, payload.data, payload.logRewrite, session);
|
||||
writeToSessionWithInterception(payload, payload.data, payload.logRewrite, session, paste);
|
||||
}
|
||||
|
||||
function drainPendingOutputForInterrupt(sessionId, session, trace) {
|
||||
@@ -1831,6 +1906,10 @@ function drainPendingOutputForInterrupt(sessionId, session, trace) {
|
||||
|
||||
function interruptSession(event, payload) {
|
||||
const session = sessions.get(payload.sessionId);
|
||||
if (payload.cancelPendingWritesOnly === true) {
|
||||
clearPendingAutomatedWrites(session);
|
||||
return;
|
||||
}
|
||||
const trace = normalizeTrace(payload);
|
||||
if (!session) {
|
||||
logTerminalInterruptDebug("interrupt-session-missing", {
|
||||
|
||||
@@ -0,0 +1,376 @@
|
||||
"use strict";
|
||||
const test = require("node:test");
|
||||
const assert = require("node:assert/strict");
|
||||
const bridge = require("./terminalBridge.cjs");
|
||||
|
||||
function harness(t, pipeline) {
|
||||
t.mock.timers.enable({ apis: ["setTimeout"] });
|
||||
const writes = [];
|
||||
const receipts = [];
|
||||
const session = {
|
||||
webContentsId: 41,
|
||||
stream: { write(data) { writes.push(data); return false; }, close() {} },
|
||||
};
|
||||
const sessions = new Map([["s", session]]);
|
||||
bridge.init({ sessions, terminalDataPipeline: pipeline, electronModule: {
|
||||
webContents: { fromId(id) {
|
||||
assert.equal(id, 41, "notify session owner, not invoking sender");
|
||||
return { send(channel, payload) {
|
||||
if (channel !== "netcatty:paste-write") return;
|
||||
assert.equal(writes.length >= receipts.filter(r => r.index !== undefined).length, true);
|
||||
receipts.push(payload);
|
||||
} };
|
||||
} },
|
||||
} });
|
||||
const send = (overrides = {}) => bridge.writeToSession({ sender: { id: 99 } }, {
|
||||
sessionId: "s", data: "one\r\ntwo\nthree\r", automated: true,
|
||||
lineDelayMs: 100, pasteRequestId: "request", ...overrides,
|
||||
});
|
||||
const expected = (...entries) => entries.map(entry => ({ sessionId: "s", requestId: "request", ...entry }));
|
||||
return { writes, receipts, session, sessions, send, expected };
|
||||
}
|
||||
const flush = () => new Promise(resolve => setImmediate(resolve));
|
||||
|
||||
for (const mode of ["interrupt", "manual", "ctrl-c"]) {
|
||||
test(`paste ${mode} after first line acknowledges only the sent line`, t => {
|
||||
const h = harness(t);
|
||||
h.send();
|
||||
assert.deepEqual(h.writes, ["one\r"]);
|
||||
if (mode === "interrupt") bridge.interruptSession(null, { sessionId: "s" });
|
||||
else bridge.writeToSession(null, { sessionId: "s", data: mode === "manual" ? "x" : "\x03" });
|
||||
t.mock.timers.tick(1000);
|
||||
assert.deepEqual(h.writes, ["one\r", mode === "manual" ? "x" : "\x03"]);
|
||||
assert.deepEqual(h.receipts, h.expected({ index: 0 }, { done: true }));
|
||||
});
|
||||
}
|
||||
|
||||
test("normal scheduler sends original chunk indices then final done, without content", t => {
|
||||
const h = harness(t);
|
||||
h.send({ data: "one\n\ntail" });
|
||||
assert.deepEqual(h.receipts, h.expected({ index: 0 }));
|
||||
t.mock.timers.tick(100);
|
||||
assert.deepEqual(h.receipts, h.expected({ index: 0 }, { index: 1 }));
|
||||
t.mock.timers.tick(100);
|
||||
assert.deepEqual(h.writes, ["one\r", "\r", "tail"]);
|
||||
assert.deepEqual(h.receipts, h.expected({ index: 0 }, { index: 1 }, { index: 2, done: true }));
|
||||
assert.equal(h.session.pendingPasteWrites.size, 0);
|
||||
});
|
||||
|
||||
test("single line acknowledges only after write returns, including backpressure false", t => {
|
||||
const h = harness(t);
|
||||
h.session.stream.write = data => {
|
||||
assert.deepEqual(h.receipts, []);
|
||||
h.writes.push(data);
|
||||
return false;
|
||||
};
|
||||
h.send({ data: "one" });
|
||||
assert.deepEqual(h.receipts, h.expected({ index: 0, done: true }));
|
||||
});
|
||||
|
||||
for (const failure of ["throw", "blocked", "ymodem", "disconnect", "replacement", "no-transport"]) {
|
||||
test(`${failure} after first line never acknowledges unsent lines`, t => {
|
||||
const h = harness(t);
|
||||
h.send();
|
||||
if (failure === "throw") h.session.stream.write = () => { throw Object.assign(new Error("closed"), { code: "EPIPE" }); };
|
||||
if (failure === "blocked") h.session.zmodemSentry = { isActive: () => true };
|
||||
if (failure === "ymodem") h.session.ymodemActive = true;
|
||||
if (failure === "disconnect") h.sessions.delete("s");
|
||||
if (failure === "replacement") h.sessions.set("s", { stream: { write() { assert.fail("stale paste"); } } });
|
||||
if (failure === "no-transport") delete h.session.stream;
|
||||
t.mock.timers.tick(1000);
|
||||
assert.deepEqual(h.writes, ["one\r"]);
|
||||
assert.deepEqual(h.receipts, h.expected({ index: 0 }, { done: true }));
|
||||
});
|
||||
}
|
||||
|
||||
test("blocked initial paste ends without index", t => {
|
||||
const h = harness(t);
|
||||
h.session.zmodemSentry = { isActive: () => true };
|
||||
h.send();
|
||||
t.mock.timers.tick(1000);
|
||||
assert.deepEqual(h.writes, []);
|
||||
assert.deepEqual(h.receipts, h.expected({ done: true }));
|
||||
});
|
||||
|
||||
for (const cancel of ["interrupt", "manual", "close", "replacement"]) {
|
||||
test(`pending interceptor and queued chunks cannot write after ${cancel}`, async t => {
|
||||
let release;
|
||||
let interceptCount = 0;
|
||||
const h = harness(t, {
|
||||
has: () => true,
|
||||
interceptInput(_id, data) {
|
||||
interceptCount++;
|
||||
if (data === "one\r") return new Promise(resolve => { release = resolve; });
|
||||
return Promise.resolve(data);
|
||||
},
|
||||
});
|
||||
h.send();
|
||||
await flush();
|
||||
t.mock.timers.tick(200); // All timer callbacks have already entered the barrier.
|
||||
if (cancel === "interrupt") bridge.interruptSession(null, { sessionId: "s" });
|
||||
if (cancel === "manual") bridge.writeToSession(null, { sessionId: "s", data: "x" });
|
||||
if (cancel === "close") bridge.closeSession({ sender: {} }, { sessionId: "s" });
|
||||
if (cancel === "replacement") h.send({ data: "new\nlast", pasteRequestId: "new" });
|
||||
release("STALE");
|
||||
await flush();
|
||||
t.mock.timers.tick(1000);
|
||||
await flush();
|
||||
assert.equal(h.writes.includes("STALE"), false);
|
||||
assert.equal(h.writes.includes("two\r"), false);
|
||||
assert.equal(h.writes.includes("three\r"), false);
|
||||
assert.deepEqual(h.receipts.filter(r => r.requestId === "request"), h.expected({ done: true }));
|
||||
assert.equal(interceptCount, cancel === "replacement" ? 3 : cancel === "manual" ? 2 : 1);
|
||||
});
|
||||
}
|
||||
|
||||
test("ordinary writes stay opt-in and terminal replies do not cancel paste", t => {
|
||||
const h = harness(t);
|
||||
h.send({ pasteRequestId: undefined });
|
||||
t.mock.timers.tick(200);
|
||||
assert.deepEqual(h.receipts, []);
|
||||
h.send();
|
||||
bridge.writeToSession(null, { sessionId: "s", data: "\x1b[2;1R" });
|
||||
t.mock.timers.tick(200);
|
||||
assert.deepEqual(h.receipts, h.expected({ index: 0 }, { index: 1 }, { index: 2, done: true }));
|
||||
});
|
||||
|
||||
test("cancel second line while its interceptor waits preserves only first receipt", async t => {
|
||||
let rejectPending;
|
||||
const h = harness(t, {
|
||||
has: () => true,
|
||||
interceptInput(_id, data) {
|
||||
if (data === "two\r") return new Promise((_resolve, reject) => { rejectPending = reject; });
|
||||
return Promise.resolve(data);
|
||||
},
|
||||
});
|
||||
h.send();
|
||||
await flush();
|
||||
t.mock.timers.tick(100);
|
||||
await flush();
|
||||
bridge.interruptSession(null, { sessionId: "s" });
|
||||
rejectPending(new Error("interceptor unavailable"));
|
||||
await flush();
|
||||
t.mock.timers.tick(1000);
|
||||
assert.deepEqual(h.writes, ["one\r", "\x03"]);
|
||||
assert.deepEqual(h.receipts, h.expected({ index: 0 }, { done: true }));
|
||||
});
|
||||
|
||||
test("single line pending interceptor is canceled even with no delayed timers", async t => {
|
||||
let release;
|
||||
const h = harness(t, {
|
||||
has: () => true,
|
||||
interceptInput() { return new Promise(resolve => { release = resolve; }); },
|
||||
});
|
||||
h.send({ data: "one" });
|
||||
await flush();
|
||||
bridge.interruptSession(null, { sessionId: "s" });
|
||||
release("one");
|
||||
await flush();
|
||||
assert.deepEqual(h.writes, ["\x03"]);
|
||||
assert.deepEqual(h.receipts, h.expected({ done: true }));
|
||||
});
|
||||
|
||||
test("failed single write ends without successful index", t => {
|
||||
const h = harness(t);
|
||||
h.session.stream.write = () => { throw Object.assign(new Error("closed"), { code: "EPIPE" }); };
|
||||
h.send({ data: "one" });
|
||||
assert.deepEqual(h.writes, []);
|
||||
assert.deepEqual(h.receipts, h.expected({ done: true }));
|
||||
});
|
||||
|
||||
for (const dropped of [[0], [2], [0, 1, 2]]) {
|
||||
test(`filtered empty paste chunks ${dropped.join(',')} have no receipt but preserve later chunks`, async t => {
|
||||
let nextIndex = 0;
|
||||
const h = harness(t, {
|
||||
has: () => true,
|
||||
async interceptInput(_id, data) {
|
||||
return dropped.includes(nextIndex++) ? "" : data;
|
||||
},
|
||||
});
|
||||
const chunks = ["one\r", "two\r", "three\r"];
|
||||
h.send();
|
||||
for (let index = 0; index < chunks.length; index++) {
|
||||
if (index > 0) t.mock.timers.tick(100);
|
||||
await flush();
|
||||
const sentIndices = chunks.map((_, i) => i).filter(i => i <= index && !dropped.includes(i));
|
||||
assert.deepEqual(h.writes, sentIndices.map(i => chunks[i]));
|
||||
const entries = sentIndices.map(i => i === 2 ? { index: i, done: true } : { index: i });
|
||||
if (index === 2 && dropped.includes(2)) entries.push({ done: true });
|
||||
assert.deepEqual(h.receipts, h.expected(...entries));
|
||||
assert.equal(h.session.pendingPasteWrites.size, index === 2 ? 0 : 1);
|
||||
}
|
||||
assert.equal(nextIndex, 3, "every original chunk still reaches the interceptor");
|
||||
});
|
||||
}
|
||||
|
||||
for (const replacement of [
|
||||
{ name: "paced single line", options: {} },
|
||||
{ name: "paced single line without receipts", options: { pasteRequestId: undefined } },
|
||||
{ name: "identified unpaced paste", options: { lineDelayMs: undefined } },
|
||||
]) {
|
||||
test(`new ${replacement.name} cancels old delayed paste before writing`, async t => {
|
||||
const h = harness(t);
|
||||
h.send();
|
||||
h.send({ data: "replacement\r", pasteRequestId: "new", ...replacement.options });
|
||||
t.mock.timers.tick(1000);
|
||||
await flush();
|
||||
assert.deepEqual(h.writes, ["one\r", "replacement\r"]);
|
||||
assert.deepEqual(h.receipts.filter(r => r.requestId === "request"), h.expected({ index: 0 }, { done: true }));
|
||||
assert.deepEqual(h.receipts.filter(r => r.requestId === "new"), replacement.options.pasteRequestId === undefined && "pasteRequestId" in replacement.options
|
||||
? [] : [{ sessionId: "s", requestId: "new", index: 0, done: true }]);
|
||||
});
|
||||
}
|
||||
|
||||
test("single-line replacement invalidates old chunks already waiting on an interceptor", async t => {
|
||||
let release;
|
||||
const intercepted = [];
|
||||
const h = harness(t, {
|
||||
has: () => true,
|
||||
interceptInput(_id, data) {
|
||||
intercepted.push(data);
|
||||
if (data === "one\r") return new Promise(resolve => { release = resolve; });
|
||||
return Promise.resolve(data);
|
||||
},
|
||||
});
|
||||
h.send();
|
||||
await flush();
|
||||
t.mock.timers.tick(200);
|
||||
h.send({ data: "replacement\r", pasteRequestId: "new" });
|
||||
release("STALE");
|
||||
await flush();
|
||||
assert.deepEqual(h.writes, ["replacement\r"]);
|
||||
assert.deepEqual(intercepted, ["one\r", "replacement\r"]);
|
||||
assert.deepEqual(h.receipts.filter(r => r.requestId === "request"), h.expected({ done: true }));
|
||||
});
|
||||
|
||||
test("blocked replacement still cancels old paste before transfer gate", async t => {
|
||||
let release;
|
||||
const h = harness(t, {
|
||||
has: () => true,
|
||||
interceptInput() { return new Promise(resolve => { release = resolve; }); },
|
||||
});
|
||||
h.send();
|
||||
await flush();
|
||||
h.session.zmodemSentry = { isActive: () => true };
|
||||
h.send({ data: "replacement\r", pasteRequestId: "new" });
|
||||
h.session.zmodemSentry = null;
|
||||
release("STALE");
|
||||
await flush();
|
||||
t.mock.timers.tick(1000);
|
||||
await flush();
|
||||
assert.deepEqual(h.writes, []);
|
||||
assert.deepEqual(h.receipts, [
|
||||
...h.expected({ done: true }),
|
||||
{ sessionId: "s", requestId: "new", done: true },
|
||||
]);
|
||||
});
|
||||
|
||||
for (const automatic of [false, true]) {
|
||||
test(`ordinary empty input preserves existing ${automatic ? "automated" : "manual"} behavior`, t => {
|
||||
const h = harness(t);
|
||||
h.send();
|
||||
bridge.writeToSession(null, { sessionId: "s", data: "", automated: automatic });
|
||||
t.mock.timers.tick(1000);
|
||||
assert.deepEqual(h.writes, automatic ? ["one\r", "", "two\r", "three\r"] : ["one\r", ""]);
|
||||
assert.deepEqual(h.receipts, automatic
|
||||
? h.expected({ index: 0 }, { index: 1 }, { index: 2, done: true })
|
||||
: h.expected({ index: 0 }, { done: true }));
|
||||
});
|
||||
}
|
||||
|
||||
for (const disconnected of ["removed", "closed", "replaced"]) {
|
||||
test(`disconnect ${disconnected} blocks pending interceptor and delayed paste`, async t => {
|
||||
let release;
|
||||
const h = harness(t, {
|
||||
has: () => true,
|
||||
interceptInput() { return new Promise(resolve => { release = resolve; }); },
|
||||
});
|
||||
h.send();
|
||||
await flush();
|
||||
if (disconnected === "removed") h.sessions.delete("s");
|
||||
if (disconnected === "closed") h.session.closed = true;
|
||||
if (disconnected === "replaced") h.sessions.set("s", { stream: { write() { assert.fail("stale write"); } } });
|
||||
release("STALE");
|
||||
await flush();
|
||||
t.mock.timers.tick(1000);
|
||||
await flush();
|
||||
assert.deepEqual(h.writes, []);
|
||||
assert.deepEqual(h.receipts, h.expected({ done: true }));
|
||||
});
|
||||
}
|
||||
|
||||
for (const bypass of ["sensitive", "disabled-interceptor"]) {
|
||||
test(`single-line replacement through ${bypass} does not revive canceled interceptor input`, async t => {
|
||||
let release;
|
||||
let enabled = true;
|
||||
const h = harness(t, {
|
||||
has: () => enabled,
|
||||
interceptInput(_id, data, options) {
|
||||
if (data === "one\r") return new Promise(resolve => { release = resolve; });
|
||||
assert.equal(options.bypass, true);
|
||||
return Promise.resolve(data);
|
||||
},
|
||||
});
|
||||
h.send();
|
||||
await flush();
|
||||
t.mock.timers.tick(200);
|
||||
if (bypass === "disabled-interceptor") enabled = false;
|
||||
h.send({ data: "replacement\r", pasteRequestId: "new", sensitive: bypass === "sensitive" });
|
||||
release("STALE");
|
||||
await flush();
|
||||
assert.deepEqual(h.writes, ["replacement\r"]);
|
||||
assert.deepEqual(h.receipts, [
|
||||
...h.expected({ done: true }),
|
||||
{ sessionId: "s", requestId: "new", index: 0, done: true },
|
||||
]);
|
||||
});
|
||||
}
|
||||
|
||||
for (const cancel of ["interrupt", "manual", "replacement", "cancel-only", "removed", "closed", "replaced"]) {
|
||||
test(`no-receipt paced batch cannot resume from interceptor after ${cancel}`, async t => {
|
||||
let release;
|
||||
const h = harness(t, {
|
||||
has: () => true,
|
||||
interceptInput(_id, data) {
|
||||
if (data === "one\r") return new Promise(resolve => { release = resolve; });
|
||||
return Promise.resolve(data);
|
||||
},
|
||||
});
|
||||
h.send({ pasteRequestId: undefined });
|
||||
await flush();
|
||||
t.mock.timers.tick(200);
|
||||
if (cancel === "interrupt") bridge.interruptSession(null, { sessionId: "s" });
|
||||
if (cancel === "manual") bridge.writeToSession(null, { sessionId: "s", data: "x" });
|
||||
if (cancel === "replacement") h.send({ data: "new\r", pasteRequestId: undefined });
|
||||
if (cancel === "cancel-only") {
|
||||
h.session.stream.signal = () => assert.fail("must not signal");
|
||||
h.session.stream.pause = () => assert.fail("must not pause output");
|
||||
h.session.stream.resume = () => assert.fail("must not resume output");
|
||||
h.session.takePendingData = () => assert.fail("must not drain output");
|
||||
bridge.interruptSession(null, { sessionId: "s", cancelPendingWritesOnly: true });
|
||||
}
|
||||
if (cancel === "removed") h.sessions.delete("s");
|
||||
if (cancel === "closed") h.session.closed = true;
|
||||
if (cancel === "replaced") h.sessions.set("s", { stream: { write() { assert.fail("stale write"); } } });
|
||||
release("STALE");
|
||||
await flush();
|
||||
t.mock.timers.tick(1000);
|
||||
await flush();
|
||||
assert.deepEqual(h.writes, cancel === "interrupt" ? ["\x03"] : cancel === "manual" ? ["x"] : cancel === "replacement" ? ["new\r"] : []);
|
||||
assert.deepEqual(h.receipts, []);
|
||||
assert.equal(h.session.pendingPasteWrites.size, 0);
|
||||
});
|
||||
}
|
||||
|
||||
test("cancel-only ends receipt batch without raw Ctrl+C, including missing session", t => {
|
||||
const h = harness(t);
|
||||
h.send();
|
||||
h.session.zmodemSentry = { isActive: () => true, cancel() { assert.fail("must not cancel transfer"); } };
|
||||
h.session.ymodemActive = true;
|
||||
h.session.ymodemAbortController = { abort() { assert.fail("must not abort transfer"); } };
|
||||
bridge.interruptSession(null, { sessionId: "s", cancelPendingWritesOnly: true });
|
||||
bridge.interruptSession(null, { sessionId: "missing", cancelPendingWritesOnly: true });
|
||||
t.mock.timers.tick(1000);
|
||||
assert.deepEqual(h.writes, ["one\r"]);
|
||||
assert.deepEqual(h.receipts, h.expected({ index: 0 }, { done: true }));
|
||||
});
|
||||
@@ -299,3 +299,50 @@ test("manual Telnet writes cancel auto-login", async () => {
|
||||
await new Promise((resolve) => server.close(resolve));
|
||||
}
|
||||
});
|
||||
|
||||
for (const source of ["confirmed", "broadcast"]) {
|
||||
test(`paced ${source} user paste cancels saved login without losing line spacing`, async () => {
|
||||
const sockets = new Set();
|
||||
const received = [];
|
||||
let prompted = false;
|
||||
const server = net.createServer(socket => {
|
||||
sockets.add(socket);
|
||||
socket.setEncoding("utf8");
|
||||
socket.on("error", () => {});
|
||||
socket.on("close", () => sockets.delete(socket));
|
||||
socket.on("data", chunk => {
|
||||
received.push(chunk);
|
||||
if (!prompted && received.join("").includes("first\r\n")) {
|
||||
prompted = true;
|
||||
// The saved login is still awaiting its first challenge when the
|
||||
// user's first paced line arrives. Never inject saved credentials.
|
||||
socket.write("\r\nUsername: \r\nPassword: ");
|
||||
}
|
||||
});
|
||||
});
|
||||
const port = await listen(server);
|
||||
const events = [];
|
||||
terminalBridge.init({ sessions: new Map(), electronModule: {
|
||||
webContents: { fromId: () => ({ send: (channel, payload) => events.push({ channel, payload }) }) },
|
||||
} });
|
||||
const sessionId = `telnet-paced-${source}`;
|
||||
try {
|
||||
await terminalBridge.startTelnetSession({ sender: { id: 1 } }, {
|
||||
sessionId, hostname: "127.0.0.1", port, username: "saved-user", password: "saved-secret",
|
||||
});
|
||||
terminalBridge.writeToSession({}, {
|
||||
sessionId, data: "first\nsecond\r", automated: false, lineDelayMs: 150,
|
||||
...(source === "confirmed" ? { pasteRequestId: "user-paste" } : {}),
|
||||
});
|
||||
await waitFor(() => received.join("").includes("first\r\n"));
|
||||
assert.equal(received.join(""), "first\r\n", "second line must remain paced");
|
||||
await waitFor(() => received.join("").includes("second\r\n"));
|
||||
assert.equal(received.join(""), "first\r\nsecond\r\n");
|
||||
assert.ok(events.some(event => event.channel === "netcatty:telnet:auto-login-cancelled"));
|
||||
} finally {
|
||||
terminalBridge.cleanupAllSessions();
|
||||
for (const socket of sockets) socket.destroy();
|
||||
await new Promise(resolve => server.close(resolve));
|
||||
}
|
||||
});
|
||||
}
|
||||
@@ -345,6 +345,7 @@ function createPreloadApi(ctx) {
|
||||
sessionId,
|
||||
data,
|
||||
automated: Boolean(options?.automated),
|
||||
pasteRequestId: typeof options?.pasteRequestId === "string" ? options.pasteRequestId : undefined,
|
||||
sensitive: options?.sensitive === true,
|
||||
serialEraseChar: typeof options?.serialEraseChar === "string" ? options.serialEraseChar : undefined,
|
||||
lineDelayMs: Number.isFinite(lineDelayMs) && lineDelayMs > 0 ? lineDelayMs : undefined,
|
||||
@@ -359,8 +360,17 @@ function createPreloadApi(ctx) {
|
||||
notifySessionUserInput: (sessionId) => {
|
||||
ipcRenderer.send("netcatty:terminal:user-input", { sessionId });
|
||||
},
|
||||
interruptSession: (sessionId, trace) => {
|
||||
onTerminalPasteWrite: (cb) => {
|
||||
const listener = (_event, payload) => cb(payload);
|
||||
ipcRenderer.on("netcatty:paste-write", listener);
|
||||
return () => ipcRenderer.removeListener("netcatty:paste-write", listener);
|
||||
},
|
||||
interruptSession: (sessionId, trace, options) => {
|
||||
const sanitizedTrace = sanitizeInterruptTrace(trace);
|
||||
if (options?.cancelPendingWritesOnly === true) {
|
||||
ipcRenderer.send("netcatty:interrupt", { sessionId, trace: sanitizedTrace, cancelPendingWritesOnly: true });
|
||||
return;
|
||||
}
|
||||
if (ctx.terminalUrgentInputPorts?.postInterrupt?.(sessionId, sanitizedTrace)) {
|
||||
return;
|
||||
}
|
||||
|
||||
@@ -0,0 +1,44 @@
|
||||
"use strict";
|
||||
const test = require("node:test");
|
||||
const assert = require("node:assert/strict");
|
||||
const { EventEmitter } = require("node:events");
|
||||
const { createPreloadApi } = require("./api.cjs");
|
||||
|
||||
test("paste ID is opt-in and receipts use direct IPC with removable subscription", () => {
|
||||
const ipcRenderer = new EventEmitter();
|
||||
const sent = [];
|
||||
ipcRenderer.send = (...args) => sent.push(args);
|
||||
const api = createPreloadApi({ ipcRenderer, webUtils: {} });
|
||||
api.writeToSession("s", "secret", { automated: true, lineDelayMs: 100, pasteRequestId: "uuid" });
|
||||
assert.equal(sent[0][0], "netcatty:write");
|
||||
assert.equal(sent[0][1].pasteRequestId, "uuid");
|
||||
api.writeToSession("s", "x");
|
||||
api.writeToSession("s", "x", { pasteRequestId: 17 });
|
||||
assert.equal(sent[1][1].pasteRequestId, undefined);
|
||||
assert.equal(sent[2][1].pasteRequestId, undefined);
|
||||
const calls = [];
|
||||
const unsubscribe = api.onTerminalPasteWrite((...args) => calls.push(args));
|
||||
const receipt = { sessionId: "s", requestId: "uuid", index: 0, done: true };
|
||||
ipcRenderer.emit("netcatty:paste-write", { sender: "private" }, receipt);
|
||||
assert.deepEqual(calls, [[receipt]]);
|
||||
unsubscribe();
|
||||
ipcRenderer.emit("netcatty:paste-write", {}, receipt);
|
||||
assert.equal(calls.length, 1);
|
||||
assert.equal(ipcRenderer.listenerCount("netcatty:paste-write"), 0);
|
||||
});
|
||||
|
||||
test("cancel-only bypasses urgent raw-interrupt port while ordinary interrupt preserves it", () => {
|
||||
const ipcRenderer = new EventEmitter();
|
||||
const sent = [];
|
||||
const urgent = [];
|
||||
ipcRenderer.send = (...args) => sent.push(args);
|
||||
const api = createPreloadApi({ ipcRenderer, webUtils: {}, terminalUrgentInputPorts: {
|
||||
postInterrupt(...args) { urgent.push(args); return true; },
|
||||
} });
|
||||
api.interruptSession("s", undefined, { cancelPendingWritesOnly: true });
|
||||
assert.deepEqual(sent, [["netcatty:interrupt", { sessionId: "s", trace: undefined, cancelPendingWritesOnly: true }]]);
|
||||
assert.deepEqual(urgent, []);
|
||||
api.interruptSession("s");
|
||||
assert.equal(urgent.length, 1);
|
||||
assert.equal(sent.length, 1);
|
||||
});
|
||||
@@ -681,7 +681,9 @@ function createTerminalWorkerRuntime(options = {}) {
|
||||
const listener = ipcMain.listeners.get(message.channel);
|
||||
if (!listener) return;
|
||||
if (message.channel === "netcatty:interrupt") {
|
||||
terminalDataPipeline?.clearSensitiveInput?.(message.payload?.sessionId);
|
||||
if (message.payload?.cancelPendingWritesOnly !== true) {
|
||||
terminalDataPipeline?.clearSensitiveInput?.(message.payload?.sessionId);
|
||||
}
|
||||
const trace = normalizeTrace(message.payload);
|
||||
logTerminalInterruptDebug("worker-received-send", {
|
||||
channel: message.channel,
|
||||
|
||||
@@ -807,6 +807,13 @@ test("runtime clears host-sensitive input state before dispatching an interrupt"
|
||||
});
|
||||
|
||||
assert.deepEqual(order, ["clear:s1", "write:s1"]);
|
||||
parentPort.emitMessage({
|
||||
kind: "send",
|
||||
channel: "netcatty:interrupt",
|
||||
payload: { sessionId: "s1", cancelPendingWritesOnly: true },
|
||||
webContentsId: 7,
|
||||
});
|
||||
assert.deepEqual(order, ["clear:s1", "write:s1", "write:s1"]);
|
||||
});
|
||||
|
||||
test("runtime routes terminal data over output messages", async () => {
|
||||
|
||||
+14
-6
@@ -334,6 +334,7 @@ declare global {
|
||||
data: string,
|
||||
options?: {
|
||||
automated?: boolean;
|
||||
pasteRequestId?: string;
|
||||
/** Host-classified secret/no-echo input; always bypasses plugin observers and interceptors. */
|
||||
sensitive?: boolean;
|
||||
/** Whole character to erase on an explicitly byte-oriented serial device. */
|
||||
@@ -342,13 +343,20 @@ declare global {
|
||||
logRewrite?: { sentCommand: string; displayCommand: string };
|
||||
},
|
||||
): void;
|
||||
/**
|
||||
* Report interactive user input for sessions where keystrokes are buffered
|
||||
* in the renderer (serial line mode), so main-process login-assist
|
||||
* detectors see the user taking control.
|
||||
*/
|
||||
/** Notify login-assist detectors about locally buffered user input. */
|
||||
notifySessionUserInput?(sessionId: string): void;
|
||||
interruptSession?(sessionId: string, trace?: NetcattyTerminalInterruptTrace): void;
|
||||
/** Opt-in transport handoff receipts; cancellation ends with done and no index. */
|
||||
onTerminalPasteWrite?(cb: (event: {
|
||||
sessionId: string;
|
||||
requestId: string;
|
||||
index?: number;
|
||||
done?: boolean;
|
||||
}) => void): () => void;
|
||||
interruptSession?(
|
||||
sessionId: string,
|
||||
trace?: NetcattyTerminalInterruptTrace,
|
||||
options?: { cancelPendingWritesOnly?: boolean },
|
||||
): void;
|
||||
resizeSession(sessionId: string, cols: number, rows: number): void;
|
||||
/**
|
||||
* Sync Windows ConPTY after the renderer clears the xterm viewport.
|
||||
|
||||
Reference in new issue
Block a user