Compare commits

..
Author SHA1 Message Date
陈大猫 51d68b51ce fix(terminal): cancel superseded worker starts before queuing
Signal the existing boot-abort controller when a replacement arrives,
reject stale boot epochs, and recheck queued epochs before startup.
Only the latest reconnect may spawn after a pending PATH refresh.

Cover repeated reconnect requests and delayed old starts/closes through
the real worker dispatcher and local-session bridge.
2026-10-08 15:42:49 +08:00
陈大猫 6b1cf802d0 fix(terminal): abort pending starts before worker close queue
Propagate an owned close to the existing pending boot controller before
waiting for its serialized start. Keep transport cleanup ordered and keep
stale boot-epoch closes from cancelling replacements.

Exercise both fire-and-forget and awaited close messages through the real
worker dispatcher and local-session bridge with a controlled PATH wait.
2026-10-08 15:33:04 +08:00
陈大猫 76957e3597 fix(terminal): cancel pending local starts through boot epochs
Register the existing pending boot controller before awaiting PATH refresh.
Honor tab close and reconnect cancellation before spawning, and clear only
the matching controller so stale completion cannot erase a newer boot.

Regression tests reproduce both the closed-tab PTY spawn and stale-close
ownership failures against the prior revision.
2026-10-08 15:09:49 +08:00
陈大猫 64f6214690 fix(terminal): bound asynchronous Windows PATH refresh
Replace blocking per-terminal registry queries with parallel async reads,
a one-second deadline, and a shared five-second snapshot. Preserve machine
before user PATH ordering and place guessed CLI directories last. Retain
per-hive successful values when registry reads fail, and honor explicit
PATH overrides without querying the registry.
2026-10-08 14:46:25 +08:00
Netcatty-Botandnetcatty-bot 6fcd6a3ec3 fix(#3591): automated AI fix (#3593)
Co-authored-by: netcatty-bot <308658023+netcatty-bot@users.noreply.github.com>
2026-10-08 14:31:43 +08:00
github-actions[bot]andgithub-actions[bot] de6d1a28ba chore: update star history charts [skip ci] (#3590)
Co-authored-by: github-actions[bot] <github-actions[bot]@users.noreply.github.com>
2026-10-05 00:11:02 +00:00
b2ed83ae51 fix(tray): add independent persisted show-tray-icon setting (#3572)
* fix(#3553): automated AI fix

* fix(tray): keep icon visible independently of close behavior

* docs(settings): clarify background close behavior

* fix(settings): preserve tray visibility for existing users

---------

Co-authored-by: netcatty-bot <308658023+netcatty-bot@users.noreply.github.com>
Co-authored-by: binaricat <16399091+binaricat@users.noreply.github.com>
2026-09-30 15:11:23 +08:00
陈大猫 f91b2cd04e feat(ai): scope external MCP approvals to connection or host (#3569)
* feat(ai): scope external MCP approvals to connection or host

* test(ai): keep MCP fixture in Netcatty temp directory

* fix(ai): refine external MCP approval UI and grant safety

* fix(ai): wrap long approval targets and support Node 22

* fix(ai): keep automatic scripts in observer mode

* fix(sftp): release preflight markers after transfer

* fix(sftp): retain preflight cap through conflict resolution

* Keep SFTP preflight cap across panel remount

* Update worker terminal metadata expectations

* Persist SFTP preflight skip state across restart

* Limit persistent host approvals to saved hosts

* Scope host-open approvals to the selected saved host

* Exclude serial hosts from persistent approval scopes
2026-09-30 09:57:47 +08:00
1d10d1679e fix(terminal): opt-in broadcast without password protection during interactive prompts (#3491)
* fix(#3488): automated AI fix

* fix: address Codex review on PR #3491

* fix: address Codex review on PR #3491

* fix: address Codex review on PR #3491

* fix: address Codex review on PR #3491

* fix: address Codex review on PR #3491

* fix: address Codex review on PR #3491

* fix: address Codex review on PR #3491

* fix: address Codex review on PR #3491

* fix: address Codex review on PR #3491

* fix: address Codex review on PR #3491

* fix: address Codex review on PR #3491

* fix: address Codex review on PR #3491

* test: verify broadcast password prompts on Windows desktop

* test: run desktop SSH check only in Windows job

* fix: preserve sensitive peer input across compose wake

* fix: retain compose history when sensitive peer receives nothing

* fix: protect compose history during sensitive fallback writes

---------

Co-authored-by: netcatty-bot <308658023+netcatty-bot@users.noreply.github.com>
Co-authored-by: bincxz <16399091+binaricat@users.noreply.github.com>
2026-09-30 00:38:50 +08:00
陈大猫 8d2058267a fix(terminal): commit live IME transcription once (#3568)
* fix(terminal): commit live IME transcription once

xterm sends a rewritten dictation hypothesis as the interim, the full
final string, and the tail. Keep the pending textarea diff from firing
again when composition starts, and send only the part that is not
already on the PTY.

* fix(terminal): retype rewritten IME tails as separate backspaces

A shared suffix has to be deleted and typed again because the cursor
stays at the end of the line. One-character hypotheses are real
prefixes, and each backspace is its own input event so Ctrl-H hosts
still erase.

* fix(terminal): rewrite a revised IME hypothesis in place

A composition that replaces the snapshot, instead of extending it,
sends the cancelled textarea edit. Deletions are counted in graphemes
so a surrogate pair is one backspace.

* fix(terminal): send a new IME word that shares a finished prefix

A composition that starts at or after the snapshot is a new word.
Sharing the first character with text already on the PTY is not
enough to treat it as the same hypothesis.

* fix(terminal): remember a rewritten IME hypothesis in full

A 229 rewrite stored only the inserted tail, so a later
compositionend with no composition marker sent the revised
line again. Remember the whole revised textarea instead.

* fix(terminal): preserve IME rewrite state across delayed commits
2026-09-29 23:07:10 +08:00
陈大猫 8020eaa427 fix(scripts): let explicit user scripts run in observer mode (#3566)
Observer mode stays on AI-started scripts that write to the terminal. Compose bar, connect, and panel runs are explicit user actions and no longer read that setting.
2026-09-29 20:50:11 +08:00
3298a70f32 fix(sftp): re-stat remote source before dual-pane transfer plan (#3560)
* fix(#3559): automated AI fix

* fix: address Codex review on PR #3560

* fix: address Codex review on PR #3560

* fix: address Codex review on PR #3560

* fix: address Codex review on PR #3560

* fix: keep listing size out of the dual-pane transfer plan

A stat-less source can show the pane listing in the overwrite dialog, but that size must not be written back as totalBytes or a short listing truncates the file again. Files past the preflight cap stay on an unknown plan and are not statted a second time.

* fix: stop preflight after a timeout and honor cancel

A half-open browse session no longer walks the whole selection at 10 seconds per file. Cancel during the overwrite size stat is checked before an apply-to-all replace deletes the existing target.

* fix: do not stat remote files again after a failed preflight

A stat that returns no usable size, times out, or never runs is not repeated for the whole selection. The transfer plan stays unknown so the bridge measures the live size.

---------

Co-authored-by: netcatty-bot <308658023+netcatty-bot@users.noreply.github.com>
Co-authored-by: bincxz <16399091+binaricat@users.noreply.github.com>
2026-09-29 20:44:57 +08:00
icelee123 e7202f392a feat(cli): preserve launcher-provided terminal tab names (#3473)
Carry PuTTY -newtab and SecureCRT /N names through the launch queue so the new terminal tab uses the requested name.
2026-09-29 20:40:37 +08:00
陈大猫 9423a1d1cd fix(ssh): drop the manual single-channel SSH toggle (#3561)
* fix(ssh): 去掉单通道 SSH 的手动开关

单通道只在握手后匹配 BHostSSH、TERM-SSHD、CLOUDBILITY 时生效。
主机和分组不再保存这个选项,读到旧字段时直接丢掉。

* test(sftp): 对齐单通道登录后的重连扫描

专用 SFTP 登录改为 reuseTerminalTransport 之后,旧的 sourceSessionId 三元表达式扫描不再匹配,lint-and-test 因此失败。扫描改为锁定当前门槛:已连接,或已经钉住严格来源,才复用终端传输。
2026-09-29 20:05:51 +08:00
116 changed files with 5116 additions and 462 deletions

No files matched your search

+23
View File
@@ -70,3 +70,26 @@ jobs:
- name: Build
run: npm run build
windows-broadcast-ssh:
name: windows-broadcast-ssh
runs-on: windows-latest
timeout-minutes: 20
steps:
- name: Checkout
uses: actions/checkout@v7
- name: Setup Node
uses: actions/setup-node@v7
with:
node-version: 22
cache: npm
- name: Install deps
run: npm ci
- name: Build
run: npm run build
- name: Exercise two SSH password prompts in the desktop app
run: node scripts/terminal-broadcast-ssh.live.cjs
@@ -0,0 +1,21 @@
import assert from "node:assert/strict";
import { readFileSync } from "node:fs";
import test from "node:test";
const source = readFileSync(new URL("./AppSideEffects.tsx", import.meta.url), "utf8");
test("deep-link tab names are applied to terminal sessions", () => {
assert.match(source, /type DeepLinkPayload = \{[\s\S]*tabName\?: string/);
assert.match(source, /const handleConnectToHostWithTabName = useCallback/);
assert.match(source, /renameSessionInline\(sessionId, tabName\)/);
assert.match(source, /handleConnectToHostWithTabName\(ephemeralHost, tabName\)/);
assert.match(
source,
/handleConnectToHostWithTabName\(buildSshDeepLinkConnectionHost\(originalHost\), tabName\)/,
);
assert.match(
source,
/handleConnectToHostWithTabName\(buildTelnetDeepLinkConnectionHost\(matchedEffectiveHost\), tabName\)/,
);
assert.match(source, /label: tabName/);
});
+30 -13
View File
@@ -98,6 +98,11 @@ type OpenSessionInNewWindowPayload = {
localShellType?: TerminalSession['shellType'];
};
type DeepLinkPayload = {
url?: string;
tabName?: string;
};
const IS_DEV = import.meta.env.DEV;
const HOTKEY_DEBUG =
IS_DEV && localStorageAdapter.readString(STORAGE_KEY_DEBUG_HOTKEYS) === '1';
@@ -260,6 +265,7 @@ export function AppSideEffects() {
copyWorkspace,
createSessionFromCloneSource,
getSessionRestoreCwd,
renameSessionInline,
} = sessionState;
// Presentation-field thrash stays off Host bags (Hosts use retainStable);
@@ -1398,6 +1404,14 @@ export function AppSideEffects() {
}), host, hidden);
}, [addConnectionLog, connectToHost, resolveEffectiveHost, identities, keys]);
const handleConnectToHostWithTabName = useCallback((host: Host, tabName?: string) => {
const sessionId = handleConnectToHost(host);
if (sessionId && tabName) {
renameSessionInline(sessionId, tabName);
}
return sessionId;
}, [handleConnectToHost, renameSessionInline]);
const openHostForVaultAgent = useCallback((host: Host, isExternalMcpCall: boolean) => {
// Silent sessions only apply to actual external MCP clients (chatSessionId
// equals the reserved external-MCP scope). The in-app Catty AI chat's
@@ -1465,15 +1479,16 @@ export function AppSideEffects() {
// deep links until unlock so saved-credential connects cannot start behind
// the lock screen.
const pendingDeepLinksWhileLockedRef = useRef<Array<
| { kind: 'ssh'; payload: { url?: string } }
| { kind: 'telnet'; payload: { url?: string } }
| { kind: 'ssh'; payload: DeepLinkPayload }
| { kind: 'telnet'; payload: DeepLinkPayload }
| { kind: 'jms'; payload: { url?: string } }
| { kind: 'open-terminal-path'; payload: { path?: string } }
>>([]);
const _processSshDeepLink = useEffectEvent((payload: { url?: string }) => {
const _processSshDeepLink = useEffectEvent((payload: DeepLinkPayload) => {
startupLaunchIntentReceivedRef.current = true;
const rawUrl = payload?.url || '';
const tabName = payload?.tabName?.trim();
const target = parseSshDeepLink(rawUrl);
if (!target) {
toast.warning(t('deepLink.ssh.invalid'));
@@ -1502,25 +1517,26 @@ export function AppSideEffects() {
? buildSshDeepLinkEphemeralHostFromSaved(matchedEffectiveHost, target, draftOptions)
: buildSshDeepLinkEphemeralHost(target, draftOptions);
setEphemeralHosts((prev) => [...prev, ephemeralHost]);
handleConnectToHost(ephemeralHost);
handleConnectToHostWithTabName(ephemeralHost, tabName);
return;
}
if (matchedEffectiveHost) {
const originalHost = hosts.find((host) => host.id === matchedEffectiveHost.id) ?? matchedEffectiveHost;
handleConnectToHost(buildSshDeepLinkConnectionHost(originalHost));
handleConnectToHostWithTabName(buildSshDeepLinkConnectionHost(originalHost), tabName);
return;
}
setDeepLinkHostDraft(buildSshDeepLinkHostDraft(target, {
const hostDraft = buildSshDeepLinkHostDraft(target, {
id: crypto.randomUUID(),
now: Date.now(),
}));
});
setDeepLinkHostDraft(tabName ? { ...hostDraft, label: tabName } : hostDraft);
setNavigateToSection('hosts');
setActiveTabId('vault');
});
const _handleSshDeepLink = useEffectEvent((payload: { url?: string }) => {
const _handleSshDeepLink = useEffectEvent((payload: DeepLinkPayload) => {
if (shouldDeferExternalActionWhileAppLocked({ locked: appLockLocked })) {
pendingDeepLinksWhileLockedRef.current.push({ kind: 'ssh', payload: payload || {} });
return;
@@ -1537,9 +1553,10 @@ export function AppSideEffects() {
});
}, [isPeerSessionWindow]);
const _processTelnetDeepLink = useEffectEvent((payload: { url?: string }) => {
const _processTelnetDeepLink = useEffectEvent((payload: DeepLinkPayload) => {
startupLaunchIntentReceivedRef.current = true;
const rawUrl = payload?.url || '';
const tabName = payload?.tabName?.trim();
const target = parseTelnetDeepLink(rawUrl);
if (!target) {
toast.warning(t('deepLink.telnet.invalid'));
@@ -1560,10 +1577,10 @@ export function AppSideEffects() {
now: Date.now(),
});
setEphemeralHosts((prev) => [...prev, ephemeralHost]);
handleConnectToHost(ephemeralHost);
handleConnectToHostWithTabName(ephemeralHost, tabName);
return;
}
handleConnectToHost(buildTelnetDeepLinkConnectionHost(matchedEffectiveHost));
handleConnectToHostWithTabName(buildTelnetDeepLinkConnectionHost(matchedEffectiveHost), tabName);
return;
}
@@ -1572,10 +1589,10 @@ export function AppSideEffects() {
now: Date.now(),
});
setEphemeralHosts((prev) => [...prev, ephemeralHost]);
handleConnectToHost(ephemeralHost);
handleConnectToHostWithTabName(ephemeralHost, tabName);
});
const _handleTelnetDeepLink = useEffectEvent((payload: { url?: string }) => {
const _handleTelnetDeepLink = useEffectEvent((payload: DeepLinkPayload) => {
if (shouldDeferExternalActionWhileAppLocked({ locked: appLockLocked })) {
pendingDeepLinksWhileLockedRef.current.push({ kind: 'telnet', payload: payload || {} });
return;
+7
View File
@@ -336,6 +336,13 @@ export const enAiMessages: Messages = {
// External MCP (productized catalog MCP for Codex / Claude Code / Cursor)
'ai.externalMcp.title': 'External MCP',
'ai.externalMcp.allowCommandOnHost': 'Always',
'ai.externalMcp.allowCommandOnHostHint': 'Always allow this action on this server.',
'ai.externalMcp.allowAll': 'Allow all actions',
'ai.externalMcp.allowSession': 'This connection',
'ai.externalMcp.allowSessionHint': 'Allow all actions from this client until it disconnects.',
'ai.externalMcp.allowHost': 'This server',
'ai.externalMcp.allowHostHint': 'Always allow actions on this server.',
'ai.externalMcp.description': 'Expose Netcatty as an MCP server for external clients such as Codex, Claude Code, Cursor, and Grok. Uses the same catalog tools as in-app agents (terminal, SFTP, Vault, port forwarding). Keep Netcatty running while clients are connected.',
'ai.externalMcp.sessionsExposed': 'Sessions in scope: {count}',
'ai.externalMcp.mode': 'Availability mode',
+5 -2
View File
@@ -312,8 +312,11 @@ export const enCoreMessages: Messages = {
'settings.globalHotkey.toggleWindowDesc': 'Press a key combination to set a global shortcut for showing/hiding the window.',
'settings.globalHotkey.notSet': 'Not set',
'settings.globalHotkey.reset': 'Reset to default',
'settings.globalHotkey.closeToTray': 'Close to System Tray',
'settings.globalHotkey.closeToTrayDesc': 'When enabled, closing the window will minimize to the system tray instead of quitting.',
'settings.globalHotkey.closeToTray': 'Keep Running When Window Closes',
'settings.globalHotkey.closeToTrayDesc': 'When enabled, closing the window hides it while the app keeps running. Tray icon visibility is set separately.',
'settings.globalHotkey.showTrayIcon': 'Show System Tray Icon',
'settings.globalHotkey.showTrayIconDesc': 'When disabled, the tray icon is hidden but the app keeps running in the background.',
'settings.globalHotkey.showTrayIconHiddenHint': 'Tray icon hidden. Launch the app again or use the global toggle hotkey to bring the window back, then re-enable this setting to restore the icon.',
'settings.globalHotkey.enabled': 'Enable Global Hotkey',
'settings.globalHotkey.enabledDesc': 'Register system-wide keyboard shortcuts. When disabled, all global hotkeys are unregistered.',
'settings.globalHotkey.hint': 'Global hotkey works system-wide to quickly show or hide the window (Quake-style terminal).',
+2
View File
@@ -61,6 +61,7 @@ export const enTerminalMessages: Messages = {
'terminal.composeBar.send': 'Send',
'terminal.composeBar.close': 'Close compose bar',
'terminal.composeBar.broadcasting': 'Broadcasting to all sessions',
'terminal.composeBar.broadcastAllowPassword': 'Broadcast without password protection',
'terminal.composeBar.resize': 'Resize compose bar height',
'terminal.composeBar.manageSnippets': 'Manage quick snippets',
'terminal.composeBar.searchSnippets': 'Search snippets...',
@@ -134,6 +135,7 @@ export const enTerminalMessages: Messages = {
'terminal.search.noResults': 'No results',
'terminal.search.prevMatch': 'Previous match (Shift+Enter)',
'terminal.search.nextMatch': 'Next match (Enter)',
'terminal.menu.cut': 'Cut',
'terminal.menu.copy': 'Copy',
'terminal.menu.paste': 'Paste',
'terminal.menu.uploadClipboardImage': 'Upload clipboard image',
+7
View File
@@ -327,6 +327,13 @@ export const esAiMessages: Messages = {
// External MCP (productized catalog MCP for Codex / Claude Code / Cursor)
'ai.externalMcp.title': 'MCP externo',
'ai.externalMcp.allowCommandOnHost': 'Siempre',
'ai.externalMcp.allowCommandOnHostHint': 'Permitir siempre esta acción en este servidor.',
'ai.externalMcp.allowAll': 'Permitir todas las acciones',
'ai.externalMcp.allowSession': 'Esta conexión',
'ai.externalMcp.allowSessionHint': 'Permitir todas las acciones de este cliente hasta que se desconecte.',
'ai.externalMcp.allowHost': 'Este servidor',
'ai.externalMcp.allowHostHint': 'Permitir siempre acciones en este servidor.',
'ai.externalMcp.description': 'Expón Netcatty como un servidor MCP para clientes externos como Codex, Claude Code, Cursor y Grok. Usa las mismas herramientas del catálogo que los agentes de la aplicación (terminal, SFTP, Vault, reenvío de puertos). Mantén Netcatty en ejecución mientras los clientes estén conectados.',
'ai.externalMcp.sessionsExposed': 'Sesiones en alcance: {count}',
'ai.externalMcp.mode': 'Modo de disponibilidad',
+5 -2
View File
@@ -227,8 +227,11 @@ export const esCoreMessages: Messages = {
'settings.globalHotkey.toggleWindowDesc': 'Presiona una combinación de teclas para definir un atajo global que muestre u oculte la ventana.',
'settings.globalHotkey.notSet': 'No configurada',
'settings.globalHotkey.reset': 'Restablecer al valor predeterminado',
'settings.globalHotkey.closeToTray': 'Cerrar a la bandeja del sistema',
'settings.globalHotkey.closeToTrayDesc': 'Cuando está activado, cerrar la ventana la minimiza a la bandeja del sistema en lugar de salir.',
'settings.globalHotkey.closeToTray': 'Seguir en segundo plano al cerrar la ventana',
'settings.globalHotkey.closeToTrayDesc': 'Al cerrar la ventana, se oculta y la aplicación sigue activa. La visibilidad del icono de la bandeja se configura aparte.',
'settings.globalHotkey.showTrayIcon': 'Mostrar icono de la bandeja del sistema',
'settings.globalHotkey.showTrayIconDesc': 'Cuando está desactivado, el icono de la bandeja se oculta, pero la aplicación sigue ejecutándose en segundo plano.',
'settings.globalHotkey.showTrayIconHiddenHint': 'Icono de la bandeja oculto. Vuelve a iniciar la aplicación o usa el atajo global para recuperar la ventana y vuelve a activar este ajuste para restaurar el icono.',
'settings.globalHotkey.enabled': 'Activar tecla de acceso global',
'settings.globalHotkey.enabledDesc': 'Registra atajos de teclado a nivel de todo el sistema. Cuando está desactivado, todas las teclas de acceso global se desregistran.',
'settings.globalHotkey.hint': 'La tecla de acceso global funciona en todo el sistema para mostrar u ocultar la ventana rápidamente (terminal estilo Quake).',
+2
View File
@@ -61,6 +61,7 @@ export const esTerminalMessages: Messages = {
'terminal.composeBar.send': 'Enviar',
'terminal.composeBar.close': 'Cerrar barra de redacción',
'terminal.composeBar.broadcasting': 'Difundiendo a todas las sesiones',
'terminal.composeBar.broadcastAllowPassword': 'Difundir sin protección de contraseña',
'terminal.composeBar.resize': 'Cambiar la altura de la barra de redacción',
'terminal.composeBar.manageSnippets': 'Administrar snippets rápidos',
'terminal.composeBar.searchSnippets': 'Buscar snippets...',
@@ -134,6 +135,7 @@ export const esTerminalMessages: Messages = {
'terminal.search.noResults': 'Sin resultados',
'terminal.search.prevMatch': 'Coincidencia anterior (Shift+Enter)',
'terminal.search.nextMatch': 'Coincidencia siguiente (Enter)',
'terminal.menu.cut': 'Cortar',
'terminal.menu.copy': 'Copiar',
'terminal.menu.paste': 'Pegar',
'terminal.menu.uploadClipboardImage': 'Subir imagen del portapapeles',
+7
View File
@@ -283,6 +283,13 @@ export const ruAiMessages: Messages = {
'ai.toolAccess.skills.unavailable': 'Путь к skill-файлу недоступен',
'ai.externalMcp.title': 'Внешний MCP',
'ai.externalMcp.allowCommandOnHost': 'Всегда',
'ai.externalMcp.allowCommandOnHostHint': 'Всегда разрешать это действие на этом сервере.',
'ai.externalMcp.allowAll': 'Разрешить все действия',
'ai.externalMcp.allowSession': 'Это подключение',
'ai.externalMcp.allowSessionHint': 'Разрешать все действия клиента до отключения.',
'ai.externalMcp.allowHost': 'Этот сервер',
'ai.externalMcp.allowHostHint': 'Всегда разрешать действия на этом сервере.',
'ai.externalMcp.description': 'Откройте Netcatty как MCP-сервер для Codex, Claude Code, Cursor и Grok. Набор инструментов тот же, что у встроенных агентов. Держите Netcatty запущенным, пока клиенты подключены.',
'ai.externalMcp.sessionsExposed': 'Сессий в области: {count}',
'ai.externalMcp.mode': 'Режим доступности',
+5 -2
View File
@@ -296,8 +296,11 @@ export const ruCoreMessages: Messages = {
'settings.globalHotkey.toggleWindowDesc': 'Нажмите сочетание клавиш, чтобы задать глобальную горячую клавишу для показа или скрытия окна.',
'settings.globalHotkey.notSet': 'Не задано',
'settings.globalHotkey.reset': 'Сбросить по умолчанию',
'settings.globalHotkey.closeToTray': 'Сворачивать в системный трей',
'settings.globalHotkey.closeToTrayDesc': 'Если включено, при закрытии окно будет сворачиваться в системный трей вместо выхода из приложения.',
'settings.globalHotkey.closeToTray': 'Оставлять приложение в фоне при закрытии окна',
'settings.globalHotkey.closeToTrayDesc': 'При закрытии окно скрывается, а приложение продолжает работать. Отображение значка в трее настраивается отдельно.',
'settings.globalHotkey.showTrayIcon': 'Показывать значок в системном трее',
'settings.globalHotkey.showTrayIconDesc': 'Если отключено, значок в трее скрыт, но приложение продолжает работать в фоне.',
'settings.globalHotkey.showTrayIconHiddenHint': 'Значок в трее скрыт. Запустите приложение снова или используйте глобальную горячую клавишу, чтобы вернуть окно, затем снова включите этот параметр, чтобы восстановить значок.',
'settings.globalHotkey.enabled': 'Включить глобальную горячую клавишу',
'settings.globalHotkey.enabledDesc': 'Регистрировать системные сочетания клавиш. Когда отключено, все глобальные горячие клавиши снимаются с регистрации.',
'settings.globalHotkey.hint': 'Глобальная горячая клавиша работает на уровне всей системы и позволяет быстро показывать или скрывать окно (терминал в стиле Quake).',
+2
View File
@@ -82,6 +82,7 @@ export const ruTerminalMessages: Messages = {
'terminal.composeBar.send': 'Отправить',
'terminal.composeBar.close': 'Закрыть строку ввода',
'terminal.composeBar.broadcasting': 'Трансляция во все сессии',
'terminal.composeBar.broadcastAllowPassword': 'Транслировать без защиты пароля',
'terminal.composeBar.resize': 'Изменить высоту строки ввода',
'terminal.composeBar.manageSnippets': 'Управление быстрыми сниппетами',
'terminal.composeBar.searchSnippets': 'Поиск сниппетов...',
@@ -155,6 +156,7 @@ export const ruTerminalMessages: Messages = {
'terminal.search.noResults': 'Ничего не найдено',
'terminal.search.prevMatch': 'Предыдущее совпадение (Shift+Enter)',
'terminal.search.nextMatch': 'Следующее совпадение (Enter)',
'terminal.menu.cut': 'Вырезать',
'terminal.menu.copy': 'Копировать',
'terminal.menu.paste': 'Вставить',
'terminal.menu.uploadClipboardImage': 'Загрузить изображение из буфера',
+7
View File
@@ -336,6 +336,13 @@ export const zhCNAiMessages: Messages = {
// External MCP
'ai.externalMcp.title': '对外 MCP',
'ai.externalMcp.allowCommandOnHost': '始终',
'ai.externalMcp.allowCommandOnHostHint': '始终允许在此服务器执行同类操作。',
'ai.externalMcp.allowAll': '允许所有操作',
'ai.externalMcp.allowSession': '此次连接',
'ai.externalMcp.allowSessionHint': '在客户端断开前,允许此次连接的所有操作。',
'ai.externalMcp.allowHost': '此服务器',
'ai.externalMcp.allowHostHint': '始终允许操作此服务器。',
'ai.externalMcp.description': '把 Netcatty 作为 MCP 服务器暴露给 Codex、Claude Code、Cursor、Grok 等外部客户端。工具面与应用内 Agent 相同(终端、SFTP、Vault、端口转发)。客户端连接期间请保持 Netcatty 运行。',
'ai.externalMcp.sessionsExposed': '作用域内会话:{count}',
'ai.externalMcp.mode': '可用模式',
+5 -2
View File
@@ -316,8 +316,11 @@ export const zhCNCoreMessages: Messages = {
'settings.globalHotkey.toggleWindowDesc': '按下组合键以设置显示/隐藏窗口的全局快捷键。',
'settings.globalHotkey.notSet': '未设置',
'settings.globalHotkey.reset': '恢复默认',
'settings.globalHotkey.closeToTray': '关闭时最小化到托盘',
'settings.globalHotkey.closeToTrayDesc': '启用后,关闭窗口将最小化到系统托盘而不是退出程序。',
'settings.globalHotkey.closeToTray': '关闭窗口后在后台运行',
'settings.globalHotkey.closeToTrayDesc': '启用后,关闭窗口会隐藏窗口,应用继续在后台运行。托盘图标可单独开关。',
'settings.globalHotkey.showTrayIcon': '显示系统托盘图标',
'settings.globalHotkey.showTrayIconDesc': '关闭后,托盘图标将被隐藏,但应用仍会在后台正常运行。',
'settings.globalHotkey.showTrayIconHiddenHint': '托盘图标已隐藏。再次启动应用或使用全局显示/隐藏快捷键即可找回窗口,然后重新打开此设置即可恢复图标。',
'settings.globalHotkey.enabled': '启用全局快捷键',
'settings.globalHotkey.enabledDesc': '注册系统级键盘快捷键。禁用后将取消所有全局快捷键注册。',
'settings.globalHotkey.hint': '全局快捷键在系统范围内工作,可快速显示或隐藏窗口(下拉式终端风格)。',
+2
View File
@@ -466,6 +466,7 @@ export const zhCNVaultMessages: Messages = {
'terminal.composeBar.send': '发送',
'terminal.composeBar.close': '关闭撰写栏',
'terminal.composeBar.broadcasting': '正在广播到所有会话',
'terminal.composeBar.broadcastAllowPassword': '广播时不对密码进行保护',
'terminal.composeBar.resize': '拖拽调整撰写栏高度',
'terminal.composeBar.manageSnippets': '管理快捷代码片段',
'terminal.composeBar.searchSnippets': '搜索代码片段...',
@@ -537,6 +538,7 @@ export const zhCNVaultMessages: Messages = {
'terminal.search.noResults': '无结果',
'terminal.search.prevMatch': '上一个匹配 (Shift+Enter)',
'terminal.search.nextMatch': '下一个匹配 (Enter)',
'terminal.menu.cut': '剪切',
'terminal.menu.copy': '复制',
'terminal.menu.paste': '粘贴',
'terminal.menu.addSelectionToAI': '添加到对话',
+7
View File
@@ -326,6 +326,13 @@ export const zhTWAiMessages: Messages = {
'ai.toolAccess.skills.unavailable': '暫時無法取得 Skill 檔案路徑',
'ai.externalMcp.title': '對外 MCP',
'ai.externalMcp.allowCommandOnHost': '始終',
'ai.externalMcp.allowCommandOnHostHint': '始終允許在此伺服器執行同類操作。',
'ai.externalMcp.allowAll': '允許所有操作',
'ai.externalMcp.allowSession': '此次連線',
'ai.externalMcp.allowSessionHint': '在用戶端中斷前,允許此次連線的所有操作。',
'ai.externalMcp.allowHost': '此伺服器',
'ai.externalMcp.allowHostHint': '始終允許操作此伺服器。',
'ai.externalMcp.description': '將 Netcatty 作為 MCP 伺服器暴露給 Codex、Claude Code、Cursor、Grok 等外部用戶端。工具面與應用內 Agent 相同。用戶端連線期間請保持 Netcatty 執行。',
'ai.externalMcp.sessionsExposed': '作用域內工作階段:{count}',
'ai.externalMcp.mode': '可用模式',
+5 -2
View File
@@ -318,8 +318,11 @@ export const zhTWCoreMessages: Messages = {
'settings.globalHotkey.toggleWindowDesc': '按下組合鍵以設定顯示/隱藏視窗的全域快速鍵。',
'settings.globalHotkey.notSet': '未設定',
'settings.globalHotkey.reset': '恢復預設',
'settings.globalHotkey.closeToTray': '關閉時最小化到系統匣',
'settings.globalHotkey.closeToTrayDesc': '啟用後,關閉視窗將最小化到系統匣而不是結束程式。',
'settings.globalHotkey.closeToTray': '關閉視窗後在背景執行',
'settings.globalHotkey.closeToTrayDesc': '啟用後,關閉視窗會隱藏視窗,應用程式繼續在背景執行。系統匣圖示可另外開關。',
'settings.globalHotkey.showTrayIcon': '顯示系統匣圖示',
'settings.globalHotkey.showTrayIconDesc': '關閉後,系統匣圖示將被隱藏,但應用程式仍會在背景正常執行。',
'settings.globalHotkey.showTrayIconHiddenHint': '系統匣圖示已隱藏。再次啟動應用程式或使用全域顯示/隱藏快捷鍵即可找回視窗,然後重新開啟此設定即可恢復圖示。',
'settings.globalHotkey.enabled': '啟用全域快速鍵',
'settings.globalHotkey.enabledDesc': '註冊系統級鍵盤快速鍵。停用後將取消所有全域快速鍵註冊。',
'settings.globalHotkey.hint': '全域快速鍵在系統範圍內工作,可快速顯示或隱藏視窗(下拉式終端風格)。',
+2
View File
@@ -466,6 +466,7 @@ export const zhTWVaultMessages: Messages = {
'terminal.composeBar.send': '傳送',
'terminal.composeBar.close': '關閉撰寫欄',
'terminal.composeBar.broadcasting': '正在廣播到所有工作階段',
'terminal.composeBar.broadcastAllowPassword': '廣播時不對密碼進行保護',
'terminal.composeBar.resize': '拖曳調整撰寫欄高度',
'terminal.composeBar.manageSnippets': '管理快捷程式碼片段',
'terminal.composeBar.searchSnippets': '搜尋程式碼片段...',
@@ -537,6 +538,7 @@ export const zhTWVaultMessages: Messages = {
'terminal.search.noResults': '無結果',
'terminal.search.prevMatch': '上一個相符 (Shift+Enter)',
'terminal.search.nextMatch': '下一個相符 (Enter)',
'terminal.menu.cut': '剪下',
'terminal.menu.copy': '複製',
'terminal.menu.paste': '貼上',
'terminal.menu.addSelectionToAI': '新增到對話',
@@ -2,6 +2,7 @@ import test from "node:test";
import assert from "node:assert/strict";
import {
formatSessionClosedExitMessage,
resolveTerminalSessionExitIntent,
shouldCloseTerminalPopupOnExit,
shouldRevealTerminalPopupOnExit,
@@ -120,3 +121,32 @@ test("attached terminal popups keep their existing auto-close behavior by defaul
true,
);
});
test("exit messages show the reported code for a real remote exit", () => {
assert.equal(
formatSessionClosedExitMessage({ reason: "exited", exitCode: 0 }),
"\r\n[session closed (code 0)]",
);
assert.equal(
formatSessionClosedExitMessage({ reason: "exited", exitCode: 127 }),
"\r\n[session closed (code 127)]",
);
});
test("a channel closed without exit-status or exit-signal must not render as code 0", () => {
assert.equal(
formatSessionClosedExitMessage({
reason: "closed",
remoteClosedWithoutExitStatus: true,
}),
"\r\n[session closed by remote (no exit status)]",
);
});
test("exit messages without a reported code stay plain instead of showing a sentinel", () => {
assert.equal(
formatSessionClosedExitMessage({ reason: "closed" }),
"\r\n[session closed]",
);
assert.equal(formatSessionClosedExitMessage({}), "\r\n[session closed]");
});
@@ -5,9 +5,20 @@ export type TerminalSessionExitEvent = {
signal?: number;
error?: string;
reason?: "exited" | "error" | "timeout" | "closed";
// True when the remote closed the channel without sending exit-status or
// exit-signal (#3591): no numeric exit code exists, and the renderer shows
// a distinct message instead of a misleading "code 0".
remoteClosedWithoutExitStatus?: boolean;
diagnostics?: ReadonlyArray<ProviderValidationIssue>;
};
export function formatSessionClosedExitMessage(evt: TerminalSessionExitEvent): string {
if (evt?.remoteClosedWithoutExitStatus) {
return "\r\n[session closed by remote (no exit status)]";
}
return `\r\n[session closed${evt?.exitCode !== undefined ? ` (code ${evt.exitCode})` : ""}]`;
}
export type TerminalSessionExitIntent =
| { kind: "closeSession" }
| { kind: "markDisconnected" };
@@ -1,6 +1,7 @@
import assert from 'node:assert/strict';
import test from 'node:test';
import {
runAutomationScript,
runConnectScriptsSequential,
selectScriptOverlayRun,
setScriptRuns,
@@ -8,6 +9,7 @@ import {
} from './scriptAutomationCoordinator.ts';
import type { ScriptRun } from '@/types/global/netcatty-bridge-script.d.ts';
import type { Snippet } from '@/domain/models';
import { STORAGE_KEY_AI_PERMISSION_MODE } from '@/infrastructure/config/storageKeys.ts';
import { netcattyBridge } from '@/infrastructure/services/netcattyBridge.ts';
test('waitForScriptRun resolves when run is already completed on subscribe', async () => {
@@ -246,3 +248,127 @@ test('runConnectScriptsSequential does not treat a script error named Aborted as
Reflect.deleteProperty(globalThis, 'localStorage');
}
});
function installPermissionMode(mode: string) {
const storage = new Map<string, string>([[STORAGE_KEY_AI_PERMISSION_MODE, mode]]);
Object.defineProperty(globalThis, 'localStorage', {
configurable: true,
value: {
getItem: (key: string) => storage.get(key) ?? null,
setItem: (key: string, value: string) => { storage.set(key, String(value)); },
removeItem: (key: string) => { storage.delete(key); },
clear: () => { storage.clear(); },
},
});
}
const writeScript: Snippet = {
id: 'write-script',
label: 'Write',
command: "await nct.screen.sendLine('echo hi');",
kind: 'script',
};
test('runAutomationScript executes a user write script while AI permission mode is observer', async () => {
const originalGet = netcattyBridge.get;
installPermissionMode('observer');
let seenMode: string | undefined;
netcattyBridge.get = () => ({
scriptRun: async (params) => {
seenMode = params.permissionMode;
return { runId: 'user-run', runIds: ['user-run'] };
},
}) as ReturnType<typeof netcattyBridge.get>;
try {
const result = await runAutomationScript({
snippet: writeScript,
sessionId: 'sess-user',
initiatedBy: 'user',
});
assert.equal(result.runId, 'user-run');
assert.equal(seenMode, 'auto');
} finally {
netcattyBridge.get = originalGet;
Reflect.deleteProperty(globalThis, 'localStorage');
}
});
test('runAutomationScript blocks automatic write scripts in observer mode', async () => {
const originalGet = netcattyBridge.get;
installPermissionMode('observer');
let called = false;
netcattyBridge.get = () => ({
scriptRun: async () => {
called = true;
return { runId: 'automatic-run', runIds: ['automatic-run'] };
},
}) as ReturnType<typeof netcattyBridge.get>;
try {
await assert.rejects(
() => runAutomationScript({ snippet: writeScript, sessionId: 'sess-automatic' }),
/Observer mode blocks scripts that write to the terminal/,
);
assert.equal(called, false);
} finally {
netcattyBridge.get = originalGet;
Reflect.deleteProperty(globalThis, 'localStorage');
}
});
test('runAutomationScript blocks an AI write script in observer mode', async () => {
const originalGet = netcattyBridge.get;
installPermissionMode('observer');
let called = false;
netcattyBridge.get = () => ({
scriptRun: async () => {
called = true;
return { runId: 'ai-run', runIds: ['ai-run'] };
},
}) as ReturnType<typeof netcattyBridge.get>;
try {
await assert.rejects(
() => runAutomationScript({
snippet: writeScript,
sessionId: 'sess-ai',
initiatedBy: 'ai',
}),
/Observer mode blocks scripts that write to the terminal/,
);
assert.equal(called, false);
} finally {
netcattyBridge.get = originalGet;
Reflect.deleteProperty(globalThis, 'localStorage');
}
});
test('runAutomationScript keeps observer on an AI read-only script so later writes stay blocked', async () => {
const originalGet = netcattyBridge.get;
installPermissionMode('observer');
let seenMode: string | undefined;
netcattyBridge.get = () => ({
scriptRun: async (params) => {
seenMode = params.permissionMode;
return { runId: 'ai-read', runIds: ['ai-read'] };
},
}) as ReturnType<typeof netcattyBridge.get>;
try {
await runAutomationScript({
snippet: {
id: 'read-script',
label: 'Read',
command: "await nct.screen.waitForPrompt(1000);",
kind: 'script',
},
sessionId: 'sess-ai-read',
initiatedBy: 'ai',
});
assert.equal(seenMode, 'observer');
} finally {
netcattyBridge.get = originalGet;
Reflect.deleteProperty(globalThis, 'localStorage');
}
});
@@ -95,8 +95,10 @@ export async function runAutomationScript(params: {
hostname?: string;
username?: string;
};
/** Only a direct user action bypasses Observer; AI and automatic runs honor it. */
initiatedBy?: 'user' | 'ai';
}): Promise<{ runId: string; runIds: string[] }> {
const permissionMode = readPermissionMode();
const permissionMode = params.initiatedBy === 'user' ? 'auto' : readPermissionMode();
if (permissionMode === 'observer' && scriptContainsWriteOperations(params.snippet.command)) {
throw new Error('Observer mode blocks scripts that write to the terminal.');
}
@@ -0,0 +1,563 @@
/** @test issue #3559 — dual-pane transfers must re-stat remote sources before planning */
import assert from "node:assert/strict";
import test from "node:test";
import React from "react";
import { act, create, type ReactTestRenderer } from "react-test-renderer";
import { PREFLIGHT_STAT_CONCURRENCY, PREFLIGHT_STAT_MAX_FILES, useSftpTransfers } from "./useSftpTransfers";
import { sftpTransferCenterStore } from "../sftpTransferCenterStore";
import type { SftpPane } from "./types";
type StartOptions = {
transferId: string;
sourcePath: string;
sourceType: string;
targetType: string;
totalBytes?: number;
sourceSftpId?: string;
};
/** Remote pane whose listing was captured before the file grew to 3626 bytes. */
function makePane(side: "left" | "right"): SftpPane {
const isLocal = side === "right";
return {
id: side,
connection: {
id: isLocal ? "local-conn" : "remote-conn",
hostId: isLocal ? undefined : "host-1",
hostLabel: isLocal ? "Local" : "SSH host",
isLocal,
status: "connected",
currentPath: isLocal ? "/Users/reporter/Downloads" : "/opt",
},
files: isLocal
? []
: [
{
name: "aops_dns_view_demo.sh",
type: "file" as const,
size: 3622,
sizeFormatted: "3.5 KB",
lastModified: 1758000000000,
lastModifiedFormatted: "",
},
],
loading: false,
reconnecting: false,
error: null,
connectionLogs: [],
selectedFiles: new Set<string>(),
filter: "",
filenameEncoding: "auto",
showHiddenFiles: true,
transferMutationToken: 0,
};
}
function installGlobals(netty: Record<string, unknown>) {
const previousWindow = (globalThis as { window?: unknown }).window;
const previousActFlag = (globalThis as { IS_REACT_ACT_ENVIRONMENT?: boolean }).IS_REACT_ACT_ENVIRONMENT;
(globalThis as { IS_REACT_ACT_ENVIRONMENT?: boolean }).IS_REACT_ACT_ENVIRONMENT = true;
const previousStorage = (globalThis as { localStorage?: unknown }).localStorage;
const previousRaf = (globalThis as { requestAnimationFrame?: unknown }).requestAnimationFrame;
const previousCancelRaf = (globalThis as { cancelAnimationFrame?: unknown }).cancelAnimationFrame;
// Progress patches are rAF-coalesced; provide a microtask-ish shim.
const raf = (cb: (time: number) => void) => setTimeout(() => cb(Date.now()), 0);
(globalThis as { requestAnimationFrame?: unknown }).requestAnimationFrame = raf;
(globalThis as { cancelAnimationFrame?: unknown }).cancelAnimationFrame = (id: unknown) => clearTimeout(id as number);
(globalThis as { window?: unknown }).window = {
netcatty: netty,
requestAnimationFrame: raf,
cancelAnimationFrame: (id: unknown) => clearTimeout(id as number),
};
(globalThis as { localStorage?: unknown }).localStorage = {
getItem: () => null,
setItem: () => undefined,
removeItem: () => undefined,
};
return () => {
(globalThis as { window?: unknown }).window = previousWindow;
(globalThis as { localStorage?: unknown }).localStorage = previousStorage;
(globalThis as { IS_REACT_ACT_ENVIRONMENT?: boolean }).IS_REACT_ACT_ENVIRONMENT = previousActFlag;
(globalThis as { requestAnimationFrame?: unknown }).requestAnimationFrame = previousRaf;
(globalThis as { cancelAnimationFrame?: unknown }).cancelAnimationFrame = previousCancelRaf;
};
}
for (const liveStatAvailable of [true, false]) {
test(
liveStatAvailable
? "dual-pane download plan uses the live source size, not the stale listing (#3559)"
: "dual-pane download plan falls back to live size measurement when stat is unavailable (#3559)",
async () => {
const statCalls: Array<{ sftpId: string; path: string }> = [];
const startedOptions: StartOptions[] = [];
let resolveTransfer!: (options: StartOptions) => void;
const transferStarted = new Promise<StartOptions>((resolve) => { resolveTransfer = resolve; });
const restore = installGlobals({
statSftp: async (sftpId: string, target: string) => {
if (!liveStatAvailable) return undefined;
statCalls.push({ sftpId, path: target });
return {
name: "aops_dns_view_demo.sh",
type: "file" as const,
size: 3626,
sizeKnown: true,
lastModified: 1758000001000,
};
},
statLocal: async () => null,
startStreamTransfer: async (options: StartOptions) => {
startedOptions.push(options);
resolveTransfer(options);
sftpTransferCenterStore.ingestBackgroundEvent({
type: "completed",
transferId: options.transferId,
transferred: options.totalBytes ?? 0,
totalBytes: options.totalBytes ?? 0,
lifecycleEpoch: 0,
});
return {};
},
pauseTransfer: async () => ({ success: false, reason: "Transfer is no longer active" }),
resumeTransfer: async () => ({ success: false, reason: "Transfer is no longer active" }),
});
let ops: ReturnType<typeof useSftpTransfers> | undefined;
let renderer: ReactTestRenderer | undefined;
function Probe() {
ops = useSftpTransfers({
ownerId: "plan-owner",
getActivePane: (side) => (side === "left" ? makePane("left") : makePane("right")),
getPaneByConnectionId: () => null,
getTabByConnectionId: () => null,
updateTab: () => undefined,
refresh: async () => undefined,
clearCacheForConnection: () => undefined,
handleSessionError: () => undefined,
sftpSessionsRef: { current: new Map([["remote-conn", "sftp-remote"]]) },
connectionCacheKeyMapRef: { current: new Map() },
listLocalFiles: async () => [],
listRemoteFiles: async () => [],
});
return null;
}
try {
await act(async () => { renderer = create(React.createElement(Probe)); });
await act(async () => {
const running = ops!.startTransfer(
[{ name: "aops_dns_view_demo.sh", isDirectory: false }],
"left",
"right",
);
const options = await transferStarted;
startedOptions.length = 0;
startedOptions.push(options);
await running;
});
// Every fresh remote plan must come from a live re-stat of the source.
if (liveStatAvailable) {
assert.equal(statCalls.length, 1);
assert.equal(statCalls[0].sftpId, "sftp-remote");
assert.equal(statCalls[0].path, "/opt/aops_dns_view_demo.sh");
}
const planBytes = startedOptions[0]?.totalBytes;
if (liveStatAvailable) {
assert.equal(planBytes, 3626, "plan must use the live remote size, not the stale listing 3622");
} else {
// No live stat possible: plan 0 must be omitted so the transfer
// bridge measures the live size instead of truncating.
assert.equal(planBytes, undefined);
}
} finally {
renderer?.unmount();
restore();
}
},
);
}
test("stat-less remote source keeps the listing size in the overwrite dialog and leaves the plan unknown", async () => {
const started: StartOptions[] = [];
const restore = installGlobals({
statSftp: async () => ({
name: "aops_dns_view_demo.sh",
type: "file" as const,
size: 0,
sizeKnown: false,
lastModified: 0,
}),
statLocal: async () => ({
name: "aops_dns_view_demo.sh",
type: "file" as const,
size: 100,
lastModified: 1,
}),
startStreamTransfer: async (options: StartOptions) => {
started.push(options);
return {};
},
pauseTransfer: async () => ({ success: false, reason: "Transfer is no longer active" }),
resumeTransfer: async () => ({ success: false, reason: "Transfer is no longer active" }),
});
let ops: ReturnType<typeof useSftpTransfers> | undefined;
let renderer: ReactTestRenderer | undefined;
function Probe() {
ops = useSftpTransfers({
ownerId: "plan-owner-statless",
getActivePane: (side) => (side === "left" ? makePane("left") : makePane("right")),
getPaneByConnectionId: () => null,
getTabByConnectionId: () => null,
updateTab: () => undefined,
refresh: async () => undefined,
clearCacheForConnection: () => undefined,
handleSessionError: () => undefined,
sftpSessionsRef: { current: new Map([["remote-conn", "sftp-remote"]]) },
connectionCacheKeyMapRef: { current: new Map() },
listLocalFiles: async () => [],
listRemoteFiles: async () => [],
});
return null;
}
try {
await act(async () => { renderer = create(React.createElement(Probe)); });
await act(async () => {
await ops!.startTransfer(
[{ name: "aops_dns_view_demo.sh", isDirectory: false }],
"left",
"right",
);
});
assert.equal(started.length, 0, "an unresolved conflict must not start the transfer");
assert.equal(ops!.conflicts.length, 1);
assert.equal(ops!.conflicts[0].newSize, 3622);
assert.equal(ops!.conflicts[0].newModified, 1758000000000);
const task = ops!.transfers.find((row) => row.fileName === "aops_dns_view_demo.sh");
assert.equal(task?.totalBytes, 0, "listing size must not become the transfer plan");
assert.equal(task?.status, "attention");
} finally {
renderer?.unmount();
restore();
}
});
test("remote transfer preflight stats stay within the concurrency cap and file bound", async () => {
const limit = PREFLIGHT_STAT_CONCURRENCY;
const fileCount = limit + 4;
let inflight = 0;
let maxInflight = 0;
let releaseAll = () => {};
const gate = new Promise<void>((resolve) => { releaseAll = resolve; });
let reachedCap = () => {};
const hitCap = new Promise<void>((resolve) => { reachedCap = resolve; });
const names = Array.from({ length: fileCount }, (_, index) => `file-${index}.bin`);
const restore = installGlobals({
statSftp: async () => {
inflight += 1;
maxInflight = Math.max(maxInflight, inflight);
if (inflight >= limit) reachedCap();
try {
await gate;
} finally {
inflight -= 1;
}
return {
name: "file",
type: "file" as const,
size: 10,
sizeKnown: true,
lastModified: 1,
};
},
statLocal: async () => null,
startStreamTransfer: async (options: StartOptions) => {
sftpTransferCenterStore.ingestBackgroundEvent({
type: "completed",
transferId: options.transferId,
transferred: options.totalBytes ?? 0,
totalBytes: options.totalBytes ?? 0,
lifecycleEpoch: 0,
});
return {};
},
pauseTransfer: async () => ({ success: false, reason: "Transfer is no longer active" }),
resumeTransfer: async () => ({ success: false, reason: "Transfer is no longer active" }),
});
let ops: ReturnType<typeof useSftpTransfers> | undefined;
let renderer: ReactTestRenderer | undefined;
function Probe() {
ops = useSftpTransfers({
ownerId: "plan-owner-concurrency",
getActivePane: (side) => (side === "left" ? makePane("left") : makePane("right")),
getPaneByConnectionId: () => null,
getTabByConnectionId: () => null,
updateTab: () => undefined,
refresh: async () => undefined,
clearCacheForConnection: () => undefined,
handleSessionError: () => undefined,
sftpSessionsRef: { current: new Map([["remote-conn", "sftp-remote"]]) },
connectionCacheKeyMapRef: { current: new Map() },
listLocalFiles: async () => [],
listRemoteFiles: async () => [],
});
return null;
}
try {
await act(async () => { renderer = create(React.createElement(Probe)); });
let running: Promise<unknown> = Promise.resolve();
await act(async () => {
running = ops!.startTransfer(
names.map((name) => ({ name, isDirectory: false })),
"left",
"right",
);
const capTimeout = new Promise<never>((_, reject) => {
setTimeout(() => reject(new Error("preflight did not reach the concurrency cap")), 2000);
});
await Promise.race([hitCap, capTimeout]);
});
assert.equal(maxInflight, limit);
assert.ok(inflight <= limit);
releaseAll();
await act(async () => { await running; });
assert.equal(maxInflight, limit);
} finally {
releaseAll();
renderer?.unmount();
restore();
}
});
test("a preflight timeout stops the rest of the batch", async () => {
const extra = 4;
const names = Array.from(
{ length: PREFLIGHT_STAT_CONCURRENCY + extra },
(_, index) => `hung-${index}.bin`,
);
let statCalls = 0;
const restore = installGlobals({
statSftp: () => {
statCalls += 1;
return new Promise(() => {});
},
statLocal: async () => null,
startStreamTransfer: async (options: StartOptions) => {
sftpTransferCenterStore.ingestBackgroundEvent({
type: "completed",
transferId: options.transferId,
transferred: options.totalBytes ?? 0,
totalBytes: options.totalBytes ?? 0,
lifecycleEpoch: 0,
});
return {};
},
pauseTransfer: async () => ({ success: false, reason: "Transfer is no longer active" }),
resumeTransfer: async () => ({ success: false, reason: "Transfer is no longer active" }),
});
let ops: ReturnType<typeof useSftpTransfers> | undefined;
let renderer: ReactTestRenderer | undefined;
function Probe() {
ops = useSftpTransfers({
ownerId: "plan-owner-timeout",
getActivePane: (side) => (side === "left" ? makePane("left") : makePane("right")),
getPaneByConnectionId: () => null,
getTabByConnectionId: () => null,
updateTab: () => undefined,
refresh: async () => undefined,
clearCacheForConnection: () => undefined,
handleSessionError: () => undefined,
sftpSessionsRef: { current: new Map([["remote-conn", "sftp-remote"]]) },
connectionCacheKeyMapRef: { current: new Map() },
listLocalFiles: async () => [],
listRemoteFiles: async () => [],
});
return null;
}
try {
await act(async () => { renderer = create(React.createElement(Probe)); });
await act(async () => {
await ops!.startTransfer(
names.map((name) => ({ name, isDirectory: false })),
"left",
"right",
);
});
// One preflight wave, then stop. Timed-out files are not statted again.
assert.equal(statCalls, PREFLIGHT_STAT_CONCURRENCY);
} finally {
renderer?.unmount();
restore();
}
});
test("stat-less preflight results are not statted again before transfer", async () => {
const names = Array.from(
{ length: PREFLIGHT_STAT_CONCURRENCY + 2 },
(_, index) => `legacy-${index}.bin`,
);
let statCalls = 0;
let inflight = 0;
let maxInflight = 0;
const started: StartOptions[] = [];
const restore = installGlobals({
statSftp: async () => {
statCalls += 1;
inflight += 1;
maxInflight = Math.max(maxInflight, inflight);
await Promise.resolve();
inflight -= 1;
return {
name: "legacy",
type: "file" as const,
size: 0,
sizeKnown: false,
lastModified: 0,
};
},
statLocal: async () => null,
startStreamTransfer: async (options: StartOptions) => {
started.push(options);
sftpTransferCenterStore.ingestBackgroundEvent({
type: "completed",
transferId: options.transferId,
transferred: options.totalBytes ?? 0,
totalBytes: options.totalBytes ?? 0,
lifecycleEpoch: 0,
});
return {};
},
pauseTransfer: async () => ({ success: false, reason: "Transfer is no longer active" }),
resumeTransfer: async () => ({ success: false, reason: "Transfer is no longer active" }),
});
let ops: ReturnType<typeof useSftpTransfers> | undefined;
let renderer: ReactTestRenderer | undefined;
function Probe() {
ops = useSftpTransfers({
ownerId: "plan-owner-statless-batch",
getActivePane: (side) => (side === "left" ? makePane("left") : makePane("right")),
getPaneByConnectionId: () => null,
getTabByConnectionId: () => null,
updateTab: () => undefined,
refresh: async () => undefined,
clearCacheForConnection: () => undefined,
handleSessionError: () => undefined,
sftpSessionsRef: { current: new Map([["remote-conn", "sftp-remote"]]) },
connectionCacheKeyMapRef: { current: new Map() },
listLocalFiles: async () => [],
listRemoteFiles: async () => [],
});
return null;
}
try {
await act(async () => { renderer = create(React.createElement(Probe)); });
await act(async () => {
await ops!.startTransfer(
names.map((name) => ({ name, isDirectory: false })),
"left",
"right",
);
});
assert.equal(statCalls, names.length);
assert.ok(maxInflight <= PREFLIGHT_STAT_CONCURRENCY);
assert.equal(started.length, names.length);
assert.equal(started.filter((options) => options.totalBytes === undefined).length, names.length);
} finally {
renderer?.unmount();
restore();
}
});
test("files past the preflight bound stay un-statted after conflict resolution", async () => {
const extra = 2;
const names = Array.from({ length: PREFLIGHT_STAT_MAX_FILES + extra }, (_, index) => `bulk-${index}.bin`);
let statCalls = 0;
const started: StartOptions[] = [];
const restore = installGlobals({
statSftp: async () => {
statCalls += 1;
return {
name: "bulk",
type: "file" as const,
size: 20,
sizeKnown: true,
lastModified: 5,
};
},
statLocal: async (target: string) => names.slice(-extra).some((name) => target.endsWith(`/${name}`))
? { name: target.split("/").pop() || "", type: "file" as const, size: 1, lastModified: 1 }
: null,
startStreamTransfer: async (options: StartOptions) => {
started.push(options);
sftpTransferCenterStore.ingestBackgroundEvent({
type: "completed",
transferId: options.transferId,
transferred: options.totalBytes ?? 0,
totalBytes: options.totalBytes ?? 0,
lifecycleEpoch: 0,
});
return {};
},
pauseTransfer: async () => ({ success: false, reason: "Transfer is no longer active" }),
resumeTransfer: async () => ({ success: false, reason: "Transfer is no longer active" }),
});
let ops: ReturnType<typeof useSftpTransfers> | undefined;
let renderer: ReactTestRenderer | undefined;
function Probe() {
ops = useSftpTransfers({
ownerId: "plan-owner-bound",
getActivePane: (side) => (side === "left" ? makePane("left") : makePane("right")),
getPaneByConnectionId: () => null,
getTabByConnectionId: () => null,
updateTab: () => undefined,
refresh: async () => undefined,
clearCacheForConnection: () => undefined,
handleSessionError: () => undefined,
sftpSessionsRef: { current: new Map([["remote-conn", "sftp-remote"]]) },
connectionCacheKeyMapRef: { current: new Map() },
listLocalFiles: async () => [],
listRemoteFiles: async () => [],
});
return null;
}
try {
await act(async () => { renderer = create(React.createElement(Probe)); });
await act(async () => {
await ops!.startTransfer(
names.map((name) => ({ name, isDirectory: false })),
"left",
"right",
);
});
assert.equal(statCalls, PREFLIGHT_STAT_MAX_FILES);
assert.equal(started.length, names.length - extra);
assert.equal(ops!.conflicts.length, extra);
await act(async () => { renderer?.unmount(); });
await act(async () => { renderer = create(React.createElement(Probe)); });
assert.equal(ops!.conflicts.length, extra, "conflicts must survive panel remount");
await act(async () => {
await ops!.resolveConflict(ops!.conflicts[0].transferId, "replace", true);
await new Promise((resolve) => setTimeout(resolve, 300));
});
assert.equal(started.length, names.length);
assert.equal(statCalls, PREFLIGHT_STAT_MAX_FILES, "conflict resolution must keep the preflight cap");
const omitted = started.filter((options) => options.totalBytes === undefined);
assert.equal(omitted.length, extra);
} finally {
renderer?.unmount();
restore();
}
});
+205 -21
View File
@@ -63,6 +63,21 @@ import {
retryExternalDragDropFileUpload,
} from "./externalDragDropRetry";
// The bridge's statSftp awaits an unbounded SFTP callback, so a half-open
// session can leave a stat pending forever. Every stat issued outside the
// bridge is bounded with this timeout; on timeout the caller falls back to
// stale metadata / a 0 plan instead of one hung stat blocking a batch.
const STAT_SFTP_TIMEOUT_MS = 10_000;
// Preflight re-stats share the browse SFTP session and the renderer IPC
// queue. Launching one stat per file for a huge multi-file selection would
// flood both before any transfer task reaches the UI, so cap the number of
// in-flight preflight stats and skip the re-stat entirely once the selection
// grows past a bound (files beyond the cap stay on the safe 0 plan, which
// makes the bridge measure the live size anyway).
export const PREFLIGHT_STAT_CONCURRENCY = 8;
export const PREFLIGHT_STAT_MAX_FILES = 128;
/** Keep the MutableRefObject mirror in sync with the process-global latch set. */
function syncPausedTasksRef(ref: { current: Set<string> }, taskId: string, latched: boolean) {
if (latched) ref.current.add(taskId);
@@ -207,7 +222,6 @@ export const useSftpTransfers = ({
const transfersRef = useRef(transfers);
const conflictsRef = useRef(conflicts);
conflictsRef.current = conflicts;
// When the retained panel is re-opened, catch up React state from the ref that
// kept receiving progress while the surface was hidden.
useEffect(() => {
@@ -613,9 +627,30 @@ export const useSftpTransfers = ({
const sameHost = sameHostEndpoints && !!sourceSftpId && !!targetSftpId;
const discoverTransferSize = async () => {
// Returns the discovered stat so callers that need the size (e.g. the
// overwrite conflict dialog) can await this instead of reading stale
// metadata from the local task copy.
const discoverTransferSize = async (): Promise<{
size: number;
lastModified?: number;
/** False for pane-listing display data. That size must not become the plan. */
plan: boolean;
} | null> => {
// When the endpoint cannot stat the source (legacy SCP returns the
// sizeKnown: false placeholder) or the stat fails, the transfer plan
// stays unknown (0 bytes, so the bridge measures the live size). The
// overwrite dialog still needs display metadata, so fall back to the
// retained cached listing entry instead of reporting 0 bytes — but only
// for the dialog; the plan itself must never take the listing's
// possibly stale size back.
const cachedListingStatForDialog = (): { size: number; lastModified?: number; plan: false } | null => {
if (getParentPath(task.sourcePath) !== sourcePane.connection?.currentPath) return null;
const entry = sourcePane.files.find((candidate) => candidate.name === task.fileName);
if (!entry || !(entry.size > 0)) return null;
return { size: entry.size, lastModified: entry.lastModified, plan: false };
};
try {
if (task.totalBytes > 0 || !!task.sourceLastModified) return;
if (task.totalBytes > 0 || !!task.sourceLastModified) return null;
if (sourcePane.connection?.isLocal) {
const stat = await netcattyBridge.get()?.statLocal?.(task.sourcePath);
@@ -628,17 +663,41 @@ export const useSftpTransfers = ({
totalBytes: stat.size,
});
}
return { size: stat.size, lastModified: stat.lastModified, plan: true };
}
return;
return null;
}
if (sourceSftpId) {
const stat = await netcattyBridge.get()?.statSftp?.(
sourceSftpId,
task.sourcePath,
sourceEncoding,
);
if (stat) {
if (task.preflightStatSkipped) {
return cachedListingStatForDialog();
}
// This stat runs deferred (started before the conflict check) and
// can be awaited by the conflict path, so it must not hang on a
// half-open session. Bound it like the preflight stat and settle
// early once the task is cancelled, since a marked task cannot
// settle an in-flight SFTP callback itself.
let timeoutTimer: ReturnType<typeof setTimeout> | undefined;
let cancelTimer: ReturnType<typeof setInterval> | undefined;
const stat = await Promise.race([
Promise.resolve(netcattyBridge.get()?.statSftp?.(
sourceSftpId,
task.sourcePath,
sourceEncoding,
)),
new Promise<undefined>((resolve) => {
timeoutTimer = setTimeout(() => resolve(undefined), STAT_SFTP_TIMEOUT_MS);
}),
new Promise<undefined>((resolve) => {
cancelTimer = setInterval(() => {
if (cancelledTasksRef.current.has(task.id)) resolve(undefined);
}, 250);
}),
]).finally(() => {
if (timeoutTimer) clearTimeout(timeoutTimer);
if (cancelTimer) clearInterval(cancelTimer);
});
if (stat && stat.sizeKnown !== false) {
if (!task.sourceLastModified && stat.lastModified) {
task.sourceLastModified = stat.lastModified;
}
@@ -647,12 +706,18 @@ export const useSftpTransfers = ({
totalBytes: stat.size,
});
}
return { size: stat.size, lastModified: stat.lastModified, plan: true };
}
// Unknown size (e.g. legacy SCP stat): keep the transfer plan
// unknown so a 0-byte placeholder never replaces trusted metadata,
// but give the conflict dialog the retained cached listing entry.
return cachedListingStatForDialog();
}
} catch (err) {
if (!isTransferCancelledError(err)) {
logger.debug?.("[SFTP] Deferred transfer size discovery failed", err);
}
return cachedListingStatForDialog();
}
};
@@ -746,16 +811,35 @@ export const useSftpTransfers = ({
return null;
})();
// For single files: fire-and-forget size discovery
if (!task.isDirectory) {
void discoverTransferSize();
}
// For single files: start size discovery without blocking the conflict
// check, but keep the promise so an actual conflict can await fresh
// size metadata before the dialog is shown.
const sizeDiscoveryPromise = task.isDirectory ? null : discoverTransferSize();
// Only await conflict check (fast single stat call)
const conflict = await conflictCheckPromise;
// Cancel/Stop may have won while conflict stats were in flight.
if (cancelledTasksRef.current.has(task.id)) return "cancelled";
// null means the plan size is still unknown. A verified 0-byte stat is 0,
// not null, so an empty file does not pick up a stale listing size.
let verifiedPlanBytes: number | null = task.totalBytes > 0 ? task.totalBytes : null;
if (conflict && sizeDiscoveryPromise) {
// The task's totalBytes may still be 0 here because discovery ran
// deferred and updateTask never mutates the local task copy. Await
// discovery (bounded per-task; the task is already visible/cancellable)
// so the overwrite dialog shows the real source size, not 0 (#3559).
const discovered = await sizeDiscoveryPromise;
// Cancel during this stat must win before an apply-to-all Replace
// deletes an existing symlink.
if (cancelledTasksRef.current.has(task.id)) return "cancelled";
if (discovered && discovered.size >= 0) {
conflict.newSize = discovered.size;
if (discovered.lastModified) conflict.newModified = discovered.lastModified;
if (discovered.plan) verifiedPlanBytes = discovered.size;
}
}
if (conflict) {
const defaultAction = conflictDefaultsRef.current
.get(task.batchId ?? "global")
@@ -819,7 +903,9 @@ export const useSftpTransfers = ({
setConflicts((prev) => [...prev, conflict]);
updateTask({
status: "attention",
totalBytes: conflict.newSize || task.totalBytes || 0,
// Dialog newSize may be the listing fallback (plan: false). Only a
// verified stat may become the plan; 0 stays omitted at startStreamTransfer.
totalBytes: verifiedPlanBytes ?? 0,
conflict,
});
return "attention";
@@ -1094,6 +1180,84 @@ export const useSftpTransfers = ({
? new Map(sourcePane.files.map(f => [f.name, f]))
: null;
// Pane listings can be stale when the file changed in a terminal or
// another session after the pane was listed (#3559). A stale undersized
// size becomes the transfer plan and silently truncates the download, so
// remote single files are always re-statted before the plan is built.
// When no live stat is possible the plan stays 0, which makes the bridge
// measure the live size instead of trusting the stale listing.
const freshRemoteMetadata = new Map<string, { size: number; lastModified: number }>();
const sourceSftpIdForPlan = sourcePane.connection.isLocal
? null
: (sftpSessionsRef.current.get(sourceConnectionId) ?? null);
const remoteSingleFiles = sourcePane.connection.isLocal
? []
: sourceFiles.filter((file) => !file.isDirectory);
const preflightFiles = remoteSingleFiles.slice(0, PREFLIGHT_STAT_MAX_FILES);
// Remote files without a verified live stat are not statted again in
// discoverTransferSize. That covers the tail past the cap, a missing
// browse session, a timeout that stops the pool, and a stat that
// returns no usable size. The plan stays unknown so the bridge measures
// the live size; the overwrite dialog still uses the pane listing.
const preflightSkippedNames = new Set<string>();
if (remoteSingleFiles.length > 0 && sourceSftpIdForPlan) {
const sourceEncoding = sourcePane.filenameEncoding || "auto";
// The bridge's statSftp awaits an unbounded SFTP callback, so a
// half-open session can leave a stat pending forever. Bound each
// preflight stat: on timeout the file keeps its 0 plan and the bridge
// measures the live size during the transfer instead of one hung stat
// blocking the entire selected batch.
const PREFLIGHT_STAT_TIMEOUT_MS = STAT_SFTP_TIMEOUT_MS;
// These stats share the browse session. Keep a few in flight, and
// after the first timeout stop claiming more files so a half-open
// session cannot run the whole selection out to 10s each.
let preflightStopped = false;
const statOneRemoteFile = async (file: { name: string; isDirectory: boolean }) => {
let timeoutTimer: ReturnType<typeof setTimeout> | undefined;
let timedOut = false;
try {
const stat = await Promise.race([
Promise.resolve(netcattyBridge.get()?.statSftp?.(
sourceSftpIdForPlan,
joinPath(sourcePath, file.name),
sourceEncoding,
)),
new Promise<undefined>((resolve) => {
timeoutTimer = setTimeout(() => {
timedOut = true;
resolve(undefined);
}, PREFLIGHT_STAT_TIMEOUT_MS);
}),
]);
if (timedOut) {
preflightStopped = true;
return;
}
if (stat && stat.type !== "symlink" && stat.sizeKnown !== false && stat.size >= 0) {
freshRemoteMetadata.set(file.name, { size: stat.size, lastModified: stat.lastModified });
}
} catch (err) {
// Fall through to a 0 plan so the bridge measures the live size.
logger.debug?.("[SFTP] Transfer plan re-stat failed; falling back to live size", err);
} finally {
if (timeoutTimer) clearTimeout(timeoutTimer);
}
};
const workers = Math.min(PREFLIGHT_STAT_CONCURRENCY, preflightFiles.length);
let preflightCursor = 0;
await Promise.allSettled(
Array.from({ length: workers }, async () => {
while (!preflightStopped && preflightCursor < preflightFiles.length) {
const file = preflightFiles[preflightCursor++];
await statOneRemoteFile(file);
}
}),
);
}
for (const file of remoteSingleFiles) {
if (!freshRemoteMetadata.has(file.name)) preflightSkippedNames.add(file.name);
}
for (const file of sourceFiles) {
const direction: TransferDirection =
sourcePane.connection!.isLocal && !targetPane.connection!.isLocal
@@ -1102,12 +1266,30 @@ export const useSftpTransfers = ({
? "download"
: "remote-to-remote";
// Use cached metadata from the source pane's file list to avoid
// redundant stat calls over the network, but only when the transfer
// source matches the pane's currently listed directory.
// Prefer the fresh re-stat for remote files: a cached listing size can
// be stale and undersized, which would silently truncate downloads
// (#3559). Remote files without a live stat use plan 0 so the bridge
// measures the live size; local sources keep the pane metadata.
const fileEntry = fileEntryMap?.get(file.name);
const fileSize = file.isDirectory ? 0 : (fileEntry?.size ?? 0);
const sourceLastModified = fileEntry?.lastModified ?? 0;
const freshMetadata = freshRemoteMetadata.get(file.name);
const fileSize = file.isDirectory
? 0
: freshMetadata
? freshMetadata.size
: sourcePane.connection!.isLocal
? (fileEntry?.size ?? 0)
: 0;
// Only keep an mtime that is paired with trusted metadata (live re-stat,
// local source size, or directory totals). A remote file whose re-stat
// failed must stay fully unknown (size 0 AND mtime 0 in the plan):
// discoverTransferSize and the conflict check both treat a nonzero
// sourceLastModified as proof the size is already known, so a stale
// mtime would suppress the deferred source re-stat. The overwrite
// dialog still gets the pane listing's cached entry via
// discoverTransferSize's cachedListingStatForDialog fallback.
const sourceLastModified = !file.isDirectory && !sourcePane.connection!.isLocal && !freshMetadata
? 0
: (freshMetadata?.lastModified ?? fileEntry?.lastModified ?? 0);
const nextSourcePath = joinPath(sourcePath, file.name);
const nextTargetPath = joinPath(targetPath, file.name);
@@ -1167,8 +1349,9 @@ export const useSftpTransfers = ({
}
continue;
}
const taskId = crypto.randomUUID();
newTasks.push({
id: crypto.randomUUID(),
id: taskId,
batchId,
fileName: file.name,
originalFileName: file.name,
@@ -1189,6 +1372,7 @@ export const useSftpTransfers = ({
isDirectory: file.isDirectory,
progressMode: file.isDirectory ? "files" : "bytes",
sourceLastModified,
preflightStatSkipped: preflightSkippedNames.has(file.name),
origin: "manual",
resumable: !usesLegacyScp,
pauseUnavailableReason: usesLegacyScp ? "This server uses legacy SCP; cancel and retry from the beginning instead" : undefined,
@@ -2,6 +2,7 @@ import { useEffect, useRef, type MutableRefObject } from 'react';
import {
STORAGE_KEY_AUTO_UPDATE_ENABLED,
STORAGE_KEY_CLOSE_TO_TRAY,
STORAGE_KEY_SHOW_TRAY_ICON,
STORAGE_KEY_AUTO_LAUNCH_ENABLED,
STORAGE_KEY_GLOBAL_HOTKEY_ENABLED,
STORAGE_KEY_TOGGLE_WINDOW_HOTKEY,
@@ -42,6 +43,7 @@ interface UseSystemSettingsEffectsParams {
toggleWindowHotkey: string;
globalHotkeyEnabled: boolean;
closeToTray: boolean;
showTrayIcon: boolean;
autoLaunchEnabled: boolean;
windowOpacityRecord: WindowOpacityRecord;
windowOpacityMutationSourceRef: MutableRefObject<WindowOpacityMutationSource>;
@@ -62,6 +64,7 @@ export function useSystemSettingsEffects({
toggleWindowHotkey,
globalHotkeyEnabled,
closeToTray,
showTrayIcon,
autoLaunchEnabled,
windowOpacityRecord,
windowOpacityMutationSourceRef,
@@ -173,6 +176,24 @@ export function useSystemSettingsEffects({
notifySettingsChanged(STORAGE_KEY_CLOSE_TO_TRAY, closeToTray);
}, [enabled, closeToTray, notifySettingsChanged, persistMountedRef]);
// Persist and sync the show-tray-icon setting. Runs after the close-to-tray
// effect above (declaration order) so a hidden icon destroys the tray that
// close-to-tray's mount-time push may have just created.
useEffect(() => {
if (!enabled) return;
// Update main process tray visibility (needed on mount)
const bridge = netcattyBridge.get();
if (bridge?.setShowTrayIcon) {
bridge.setShowTrayIcon(showTrayIcon).catch((err) => {
console.warn('[SystemTray] Failed to set show-tray-icon:', err);
});
}
localStorageAdapter.writeString(STORAGE_KEY_SHOW_TRAY_ICON, showTrayIcon ? 'true' : 'false');
// Skip IPC on initial mount
if (!persistMountedRef.current) return;
notifySettingsChanged(STORAGE_KEY_SHOW_TRAY_ICON, showTrayIcon);
}, [enabled, showTrayIcon, notifySettingsChanged, persistMountedRef]);
// Hydrate auto-launch from the main process on mount — the OS login item
// is the real source of truth (the user may have toggled it outside the
// app), localStorage is only an optimistic cache for first paint.
+7 -1
View File
@@ -10,5 +10,11 @@ export const useClipboardBackend = () => {
return typeof text === "string" ? text : "";
}, []);
return { readClipboardText };
const writeClipboardText = useCallback(async (text: string): Promise<boolean> => {
const bridge = netcattyBridge.get();
if (!bridge?.writeClipboardText) return false;
return await bridge.writeClipboardText(text) === true;
}, []);
return { readClipboardText, writeClipboardText };
};
+11
View File
@@ -67,6 +67,7 @@ import {
STORAGE_KEY_EXPLORER_CONTEXT_MENU_ENABLED,
STORAGE_KEY_TOGGLE_WINDOW_HOTKEY,
STORAGE_KEY_CLOSE_TO_TRAY,
STORAGE_KEY_SHOW_TRAY_ICON,
STORAGE_KEY_AUTO_LAUNCH_ENABLED,
STORAGE_KEY_HTTP_NETWORK_PROXY,
STORAGE_KEY_GLOBAL_HOTKEY_ENABLED,
@@ -550,6 +551,13 @@ export const useSettingsState = (options: { enableSettingsSync?: boolean; enable
if (stored === null) return true;
return stored === 'true';
});
// Preserve the old tray visibility for existing users until they set this
// independently. Fresh installs still default to showing the icon.
const [showTrayIcon, setShowTrayIcon] = useState<boolean>(() => {
const stored = readStoredString(STORAGE_KEY_SHOW_TRAY_ICON);
if (stored === null) return closeToTray;
return stored === 'true';
});
// Optimistic cache for first paint; useSystemSettingsEffects hydrates this
// from app.getLoginItemSettings() (the real source of truth) on mount.
const [autoLaunchEnabled, setAutoLaunchEnabled] = useState<boolean>(() => {
@@ -1791,6 +1799,7 @@ export const useSettingsState = (options: { enableSettingsSync?: boolean; enable
toggleWindowHotkey,
globalHotkeyEnabled,
closeToTray,
showTrayIcon,
autoLaunchEnabled,
windowOpacityRecord,
windowOpacityMutationSourceRef,
@@ -2156,6 +2165,8 @@ export const useSettingsState = (options: { enableSettingsSync?: boolean; enable
setToggleWindowHotkey,
closeToTray,
setCloseToTray,
showTrayIcon,
setShowTrayIcon,
autoLaunchEnabled,
setAutoLaunchEnabled,
autoLaunchSupported,
+2
View File
@@ -677,6 +677,8 @@ const SettingsPageContent: React.FC<{ settings: SettingsState; appLock?: AppLock
setToggleWindowHotkey={settings.setToggleWindowHotkey}
closeToTray={settings.closeToTray}
setCloseToTray={settings.setCloseToTray}
showTrayIcon={settings.showTrayIcon}
setShowTrayIcon={settings.setShowTrayIcon}
autoLaunchEnabled={settings.autoLaunchEnabled}
setAutoLaunchEnabled={settings.setAutoLaunchEnabled}
autoLaunchSupported={settings.autoLaunchSupported}
+1 -1
View File
@@ -76,7 +76,7 @@ import {
resolveLocateSftpPathSessionId,
} from "../domain/sftpLocatePathInTerminal";
import { classifyDistroId } from "../domain/host";
import { remoteSoftwareRequiresSingleChannel } from "../domain/singleChannelSshBanner.shared.cjs";
import { remoteSoftwareRequiresSingleChannel } from "../domain/singleChannelSshBanner.shared.mjs";
import { useTerminalBackend } from "../application/state/useTerminalBackend";
import { isTerminalSensitiveInputActive } from "./terminal/runtime/terminalSensitiveInputRegistry";
import { isTerminalReadyForCommandInjection } from "./terminal/runtime/terminalCommandInjectionReadyRegistry";
+69 -10
View File
@@ -1,4 +1,6 @@
import { clearTerminalBroadcastUserInput, markTerminalBroadcastUserInput } from "./terminal/runtime/terminalPacedBroadcast";
import { shouldBroadcastDuringSensitivePrompt } from "../domain/terminalBroadcast";
import { STORAGE_KEY_TERMINAL_BROADCAST_PASSWORD_BYPASS } from "../infrastructure/config/storageKeys";
import { createTerminalReflowReadingPosition } from "./terminal/terminalReflowReadingPosition";
import { resolveHostOs } from '../domain/host';
import { Terminal as XTerm } from "@xterm/xterm";
@@ -38,6 +40,7 @@ import {
type TerminalEncodingAttachConnection,
} from "../domain/terminalEncodingPreference";
import { resolveRestoreCwdIntent, resolveInheritedCwdIntent } from "../domain/sessionRestore";
import { formatSessionClosedExitMessage } from "../application/state/resolveTerminalSessionExitIntent";
import {
buildTerminalContextReadResult,
buildTerminalContextSnapshotText,
@@ -836,6 +839,15 @@ const TerminalComponent: React.FC<TerminalProps> = ({
isBroadcastEnabledRef.current = isBroadcastEnabled;
onBroadcastInputRef.current = onBroadcastInput;
// Opt-in "broadcast without password protection" (#3488): when enabled, the
// fail-closed password-prompt heuristic no longer pauses broadcast fan-out.
const [broadcastPasswordBypass] = useStoredBoolean(
STORAGE_KEY_TERMINAL_BROADCAST_PASSWORD_BYPASS,
false,
);
const broadcastPasswordBypassRef = useRef(broadcastPasswordBypass);
broadcastPasswordBypassRef.current = broadcastPasswordBypass;
// Snippets ref for shortkey support in terminal
const snippetsRef = useRef(snippets);
snippetsRef.current = snippets;
@@ -1066,8 +1078,17 @@ const TerminalComponent: React.FC<TerminalProps> = ({
}
// Broadcast to other sessions if broadcast mode is enabled
if (!sensitive && isBroadcastEnabledRef.current && onBroadcastInputRef.current) {
onBroadcastInputRef.current(text, sessionId);
if (
shouldBroadcastDuringSensitivePrompt({
sensitivePromptActive: sensitive,
broadcastPasswordBypass: broadcastPasswordBypassRef.current,
})
&& isBroadcastEnabledRef.current
&& onBroadcastInputRef.current
) {
// Bypassed password fan-out (#3488): retain the sensitive marker so
// peer writes keep skipping input interceptors.
onBroadcastInputRef.current(text, sessionId, sensitive ? { sourceSensitive: true } : undefined);
}
// ESC-prefixed writes (Esc+. yank-last-arg) are shell editor commands.
@@ -2046,7 +2067,7 @@ const TerminalComponent: React.FC<TerminalProps> = ({
if (evt.error) {
setError(evt.error);
}
const exitMessage = `\r\n[session closed${evt?.exitCode !== undefined ? ` (code ${evt.exitCode})` : ""}]`;
const exitMessage = formatSessionClosedExitMessage(evt);
hibernatePendingBufferRef.current = hibernatePendingCapDisabledRef.current
? hibernatePendingBufferRef.current + exitMessage
: appendHibernatePendingBuffer(
@@ -3277,15 +3298,26 @@ const TerminalComponent: React.FC<TerminalProps> = ({
const broadcastUserPasteData = useCallback((
data: string,
options?: { lineDelayMs?: number },
options?: { lineDelayMs?: number; sensitive?: boolean },
) => {
if (
!passwordPromptActiveRef.current
shouldBroadcastDuringSensitivePrompt({
sensitivePromptActive: passwordPromptActiveRef.current,
broadcastPasswordBypass: broadcastPasswordBypassRef.current,
})
&& sessionRef.current
&& isBroadcastEnabledRef.current
&& onBroadcastInputRef.current
) {
onBroadcastInputRef.current(data, sessionId, options);
// Bypassed password fan-out (#3488): retain the sensitive marker so
// peer writes keep skipping input interceptors. A paste confirmed at
// a sensitive prompt keeps its pre-dialog snapshot (#3491), so its
// carried sensitivity tags the fan-out even when the dialog await
// cleared the live prompt ref.
const sourceSensitive = passwordPromptActiveRef.current || options?.sensitive === true;
onBroadcastInputRef.current(data, sessionId, sourceSensitive
? { ...options, sourceSensitive: true }
: options);
return true;
}
return false;
@@ -3299,6 +3331,10 @@ const TerminalComponent: React.FC<TerminalProps> = ({
multiLineRunMode?: Snippet["multiLineRunMode"];
/** When false, skip term.focus() so multi-tab fan-out does not steal focus. */
focus?: boolean;
/** Force sensitive classification on the write (bypassed password fan-out, #3488). */
sensitive?: boolean;
/** Observer for the broadcast recipients that received the payload (#3491). */
onBroadcastDelivered?: (sessionIds: readonly string[]) => void;
},
): Promise<boolean> => {
// Hidden-tab hibernation clears termRef. Wake via the *connected* path so
@@ -3353,13 +3389,32 @@ const TerminalComponent: React.FC<TerminalProps> = ({
// without re-wrapping. Without broadcasting at all, accepting a snippet in
// broadcast mode would clear peer input (the clear keystrokes already go
// through the broadcast-aware path) but never send the command.
const sensitive = passwordPromptActiveRef.current;
if (!sensitive && options?.broadcast !== false && isBroadcastEnabledRef.current && onBroadcastInputRef.current) {
onBroadcastInputRef.current(data, sessionId, {
// A bypassed password fan-out (#3488) forces the sensitive marker even
// when this target has not itself classified its prompt as sensitive.
const sensitive = passwordPromptActiveRef.current || options?.sensitive === true;
if (
shouldBroadcastDuringSensitivePrompt({
sensitivePromptActive: sensitive,
broadcastPasswordBypass: broadcastPasswordBypassRef.current,
})
&& options?.broadcast !== false
&& isBroadcastEnabledRef.current
&& onBroadcastInputRef.current
) {
const broadcastDeliveredSessionIds = onBroadcastInputRef.current(data, sessionId, {
automated: true,
noAutoRun,
// Bypassed password fan-out (#3488): retain the sensitive marker so
// peer writes keep skipping input interceptors.
...(sensitive ? { sourceSensitive: true } : {}),
...(lineDelayMs ? { lineDelayMs } : {}),
});
// #3491: report the sessions that received the fan-out so callers (the
// solo compose bar) can detect a payload delivered into a sensitive
// peer prompt and keep it out of send history.
if (broadcastDeliveredSessionIds) {
options?.onBroadcastDelivered?.(broadcastDeliveredSessionIds);
}
}
data = prepareProgrammaticSudoInput(data);
@@ -3389,6 +3444,7 @@ const TerminalComponent: React.FC<TerminalProps> = ({
await runAutomationScript({
snippet,
sessionId,
initiatedBy: 'user',
sessionMeta: {
connected: true,
name: scriptSessionName,
@@ -3428,6 +3484,7 @@ const TerminalComponent: React.FC<TerminalProps> = ({
isBroadcastEnabledRef,
onBroadcastInputRef,
passwordPromptActiveRef,
broadcastPasswordBypassRef,
isLocalConnection,
supportsRemoteImagePaste,
autoUploadClipboardImageOnPasteRef,
@@ -4018,6 +4075,7 @@ const TerminalComponent: React.FC<TerminalProps> = ({
terminalBackend,
isSensitiveInput: () => passwordPromptActiveRef.current,
scrollOnPasteRef,
broadcastPasswordBypassRef,
onPasteData: broadcastUserPasteData,
scrollToBottomAfterProgrammaticInput,
containerRef,
@@ -4260,6 +4318,7 @@ const TerminalComponent: React.FC<TerminalProps> = ({
onOpenExternalError,
isBroadcastEnabledRef,
onBroadcastInputRef,
broadcastPasswordBypassRef,
snippetsRef,
onSnippetShortkeyRef,
sessionId,
@@ -4566,7 +4625,7 @@ const TerminalComponent: React.FC<TerminalProps> = ({
onWake: wakeFromHibernateRuntime,
});
useTerminalEffects({ CONNECTION_TIMEOUT, Error, XTERM_PERFORMANCE_CONFIG, applyUserCursorPreference, applyUserCursorWidthPreference, shouldApplyUserCursorPreference, snapshotUserCursorPreference, auth, autocompleteCloseRef, autocompleteInputRef, autocompleteKeyEventRef, autocompleteRepositionRef, captureTerminalLogData, chainHosts: resolvedChainHosts, chainProgress, clearTerminalCwd, commandBufferRef, connectionLogBufferRef, containerRef, createPromptLineBreakState, createReplaySafeTerminalLogSanitizer, createXTermRuntime, deferTerminalResizeRef, disableTerminalFontZoomRef, effectiveFontSize, effectiveFontWeight, effectiveTheme, error, executeSnippetCommand, finalizeTerminalLogData, fitAddonRef, fontFamilyId, fontSize, fontWeightFixupDoneRef, forceCloseHibernatedSession, forceSyncRenderAfterResize, handleOsc52ReadRequest, handleTerminalDataCaptureOnce, hasConnectedRef, hasRuntimeRef, host, hotkeySchemeRef, hibernatedRef, identities, inWorkspace, isBootActiveRef, bootEpochRef, isBroadcastEnabledRef, isComposeBarOpen: effectiveComposeBarOpen, isConnectionAwaitingUserInput, isConnectionPastTcpDial, isFocusMode, isFocused, isLocalConnection, isNetworkDevice, isResizing: deferTerminalResize, isRestoringSelectionRef, isSearchOpen, isSerialConnection, isVisible, isVisibleRef, keyBindingsRef, keys, kittyKeyboardProtocolEnabledForSession, knownCwdRef, lastFittedSizeRef, lastToastedErrorRef, logger, mouseTrackingRef, needsHostKeyVerification, onBroadcastInputRef, onBroadcastInterruptPriorityChange, onCommandExecuted, onCommandSubmitted: cwdAwareOnCommandSubmitted, onHotkeyActionRef, onOpenExternalError, onOutputTriggerUserInputRef: noteOutputTriggerUserInputRef, onPluginRuntimeCwdChange: pluginAwareOnRuntimeCwdChange, onSnippetShortkeyRef, onSnippetExecutorChange, onTerminalCwdChange, onTerminalTitleChange, onTerminalBell, onTerminalFontSizeChange, paneLayoutKey, passwordPromptActiveRef, pendingAuthRef, pendingOutputScrollRef, pluginDecorationRefreshRef, pluginDecorationRules, pluginDecorationRulesRef, pluginTerminalLifecycle, pluginTerminalProviderRevision, isPluginTerminalProviderAvailable, requestPluginTerminalProviders, prepareRestoredReconnect, prepareInitialCwdIntent, prevIsResizingRef, promptLineBreakStateRef, resizeSession, resolveHostAuth, resolvedFontFamily, safeFit, scriptRecorderRef: recorderRef, searchAddonRef, serialConfig, serialLineBufferRef, serializeAddonRef, sessionId, sessionRef, sessionStarters, setError, setHasMouseTracking, setIsCancelling, setIsDisconnectedDialogDismissed, requestSearchFocus, setNeedsHostKeyVerification, setPendingHostKeyInfo, setPendingHostKeyRequestId, setProgressLogs, setProgressValue, setShowLogs, setStatus, setTimeLeft, shellType, shouldEnableNativeUserInputAutoScroll, shouldProbeSessionCwd, shouldStartTerminalBackend, vaultInitialized, attachExistingSession, attachAuthorization, attachHomeWebContentsIdRef, snippetsRef, splitResizeActive: isResizing, status, statusRef, sudoAutofillRef, t, teardown, telnetLocalEchoRef, termRef, terminalAltKeyOptions, terminalBackend, terminalContextActionsRef, terminalCwdTracker, terminalDataCapturedRef, terminalLogSanitizerRef, terminalOutputHistory: terminalOutputHistoryRef.current, terminalSettings, terminalSettingsRef, terminalTitleRef, toHostKeyInfo, toast, updateStatus, useEffect, useLayoutEffect, workspaceId, xtermRuntimeRef, zmodem, zmodemToastedRef, restoreState });
useTerminalEffects({ CONNECTION_TIMEOUT, Error, XTERM_PERFORMANCE_CONFIG, applyUserCursorPreference, applyUserCursorWidthPreference, shouldApplyUserCursorPreference, snapshotUserCursorPreference, auth, autocompleteCloseRef, autocompleteInputRef, autocompleteKeyEventRef, autocompleteRepositionRef, captureTerminalLogData, chainHosts: resolvedChainHosts, chainProgress, clearTerminalCwd, commandBufferRef, connectionLogBufferRef, containerRef, createPromptLineBreakState, createReplaySafeTerminalLogSanitizer, createXTermRuntime, deferTerminalResizeRef, disableTerminalFontZoomRef, effectiveFontSize, effectiveFontWeight, effectiveTheme, error, executeSnippetCommand, finalizeTerminalLogData, fitAddonRef, fontFamilyId, fontSize, fontWeightFixupDoneRef, forceCloseHibernatedSession, forceSyncRenderAfterResize, handleOsc52ReadRequest, handleTerminalDataCaptureOnce, hasConnectedRef, hasRuntimeRef, host, hotkeySchemeRef, hibernatedRef, identities, inWorkspace, isBootActiveRef, bootEpochRef, isBroadcastEnabledRef, broadcastPasswordBypassRef, isComposeBarOpen: effectiveComposeBarOpen, isConnectionAwaitingUserInput, isConnectionPastTcpDial, isFocusMode, isFocused, isLocalConnection, isNetworkDevice, isResizing: deferTerminalResize, isRestoringSelectionRef, isSearchOpen, isSerialConnection, isVisible, isVisibleRef, keyBindingsRef, keys, kittyKeyboardProtocolEnabledForSession, knownCwdRef, lastFittedSizeRef, lastToastedErrorRef, logger, mouseTrackingRef, needsHostKeyVerification, onBroadcastInputRef, onBroadcastInterruptPriorityChange, onCommandExecuted, onCommandSubmitted: cwdAwareOnCommandSubmitted, onHotkeyActionRef, onOpenExternalError, onOutputTriggerUserInputRef: noteOutputTriggerUserInputRef, onPluginRuntimeCwdChange: pluginAwareOnRuntimeCwdChange, onSnippetShortkeyRef, onSnippetExecutorChange, onTerminalCwdChange, onTerminalTitleChange, onTerminalBell, onTerminalFontSizeChange, paneLayoutKey, passwordPromptActiveRef, pendingAuthRef, pendingOutputScrollRef, pluginDecorationRefreshRef, pluginDecorationRules, pluginDecorationRulesRef, pluginTerminalLifecycle, pluginTerminalProviderRevision, isPluginTerminalProviderAvailable, requestPluginTerminalProviders, prepareRestoredReconnect, prepareInitialCwdIntent, prevIsResizingRef, promptLineBreakStateRef, resizeSession, resolveHostAuth, resolvedFontFamily, safeFit, scriptRecorderRef: recorderRef, searchAddonRef, serialConfig, serialLineBufferRef, serializeAddonRef, sessionId, sessionRef, sessionStarters, setError, setHasMouseTracking, setIsCancelling, setIsDisconnectedDialogDismissed, requestSearchFocus, setNeedsHostKeyVerification, setPendingHostKeyInfo, setPendingHostKeyRequestId, setProgressLogs, setProgressValue, setShowLogs, setStatus, setTimeLeft, shellType, shouldEnableNativeUserInputAutoScroll, shouldProbeSessionCwd, shouldStartTerminalBackend, vaultInitialized, attachExistingSession, attachAuthorization, attachHomeWebContentsIdRef, snippetsRef, splitResizeActive: isResizing, status, statusRef, sudoAutofillRef, t, teardown, telnetLocalEchoRef, termRef, terminalAltKeyOptions, terminalBackend, terminalContextActionsRef, terminalCwdTracker, terminalDataCapturedRef, terminalLogSanitizerRef, terminalOutputHistory: terminalOutputHistoryRef.current, terminalSettings, terminalSettingsRef, terminalTitleRef, toHostKeyInfo, toast, updateStatus, useEffect, useLayoutEffect, workspaceId, xtermRuntimeRef, zmodem, zmodemToastedRef, restoreState });
return (
<>
+67 -11
View File
@@ -27,6 +27,7 @@ import { detectLocalOs } from '../lib/localShell';
import { useStoredString } from '../application/state/useStoredString';
import { useStoredNumber } from '../application/state/useStoredNumber';
import { useStoredBoolean } from '../application/state/useStoredBoolean';
import { STORAGE_KEY_TERMINAL_BROADCAST_PASSWORD_BYPASS } from '../infrastructure/config/storageKeys';
import {
STORAGE_KEY_SIDE_PANEL_HEIGHT,
STORAGE_KEY_SIDE_PANEL_WIDTH,
@@ -741,6 +742,14 @@ const TerminalLayerInner: React.FC<TerminalLayerProps> = ({
STORAGE_KEY_TERMINAL_COMPOSE_BAR_OPEN,
false,
);
// Opt-in "broadcast without password protection" (#3488): when enabled, the
// fail-closed password-prompt heuristic no longer pauses workspace broadcast.
const [broadcastPasswordBypass] = useStoredBoolean(
STORAGE_KEY_TERMINAL_BROADCAST_PASSWORD_BYPASS,
false,
);
const broadcastPasswordBypassRef = useRef(broadcastPasswordBypass);
broadcastPasswordBypassRef.current = broadcastPasswordBypass;
const sessionsRef = useRef(sessions);
sessionsRef.current = sessions;
const workspacesRef = useRef(workspaces);
@@ -1241,10 +1250,13 @@ const TerminalLayerInner: React.FC<TerminalLayerProps> = ({
options?: TerminalBroadcastInputOptions,
) => {
const paced = options?.pacedBroadcast;
// Opt-in #3488 bypass: when enabled, sensitive-prompt targets stay in the
// fan-out instead of being silently dropped here.
const passwordBypass = broadcastPasswordBypassRef.current === true;
if (paced && !options?.preparePacedBroadcast) {
if (!paced.targets || isTerminalSensitiveInputActive(sourceSessionId)) return [];
if (!paced.targets || (!passwordBypass && isTerminalSensitiveInputActive(sourceSessionId))) return [];
paced.targets = paced.targets.filter(target => isTerminalBroadcastInputCurrent(target)
&& !isTerminalSensitiveInputActive(target.sessionId));
&& (passwordBypass || !isTerminalSensitiveInputActive(target.sessionId)));
}
const targetSessionIds = resolveTerminalBroadcastTargetIds({
sessions: sessionsRef.current,
@@ -1253,7 +1265,7 @@ const TerminalLayerInner: React.FC<TerminalLayerProps> = ({
directTargetSessionIds: options?.kittyKeyboardTargetSessionIds,
}).filter(id => !paced?.targets || paced.targets.some(target => target.sessionId === id));
if (options?.preparePacedBroadcast && paced) {
paced.targets = targetSessionIds.filter(id => !isTerminalSensitiveInputActive(id)).map(id => {
paced.targets = targetSessionIds.filter(id => passwordBypass || !isTerminalSensitiveInputActive(id)).map(id => {
markTerminalBroadcastUserInput(id);
terminalBackend.interruptSession(id, undefined, { cancelPendingWritesOnly: true });
return captureTerminalBroadcastInput(id);
@@ -1275,6 +1287,9 @@ const TerminalLayerInner: React.FC<TerminalLayerProps> = ({
beforeUrgentInterrupt: () => {
broadcastInterruptPrioritizersRef.current.get(session.id)?.();
},
// Bypassed password fan-out (#3488): the receiver forces sensitive
// writes so its input interceptors stay skipped for the secret.
...(options?.sourceSensitive === true ? { sourceSensitive: true } : {}),
});
deliveredSessionIds.push(session.id);
continue;
@@ -1297,10 +1312,15 @@ const TerminalLayerInner: React.FC<TerminalLayerProps> = ({
terminalBackend.interruptSession(session.id);
continue;
}
if (isTerminalSensitiveInputActive(session.id)) continue;
if (!passwordBypass && isTerminalSensitiveInputActive(session.id)) continue;
terminalBackend.writeToSession(session.id, data, {
automated: options?.automated === true,
sensitive: false,
// Retain the source-sensitive marker (bypassed password fan-out, #3488)
// so peer input interceptors stay skipped for the secret payload. A
// target that is itself awaiting a secret keeps its classification:
// bypassing the fan-out pause must not downgrade its own sensitive
// state (#3491).
sensitive: options?.sourceSensitive === true || isTerminalSensitiveInputActive(session.id),
...(lineDelayMs ? { lineDelayMs } : {}),
});
deliveredSessionIds.push(session.id);
@@ -2135,6 +2155,7 @@ const TerminalLayerInner: React.FC<TerminalLayerProps> = ({
await runAutomationScript({
snippet,
sessionId,
initiatedBy: 'user',
sessionMeta: buildScriptSessionMeta(sessionId, sessionsRef.current, hosts),
});
} catch (err) {
@@ -2160,6 +2181,7 @@ const TerminalLayerInner: React.FC<TerminalLayerProps> = ({
await runAutomationScript({
snippet,
sessionId,
initiatedBy: 'user',
sessionMeta: buildScriptSessionMeta(sessionId, sessionsRef.current, hosts),
});
} catch (err) {
@@ -2245,6 +2267,7 @@ const TerminalLayerInner: React.FC<TerminalLayerProps> = ({
await runAutomationScript({
snippet,
sessionId,
initiatedBy: 'user',
sessionMeta: buildScriptSessionMeta(sessionId, sessionsRef.current, hosts),
});
} catch (err) {
@@ -2314,6 +2337,7 @@ const TerminalLayerInner: React.FC<TerminalLayerProps> = ({
const runOnSession = (sid: string) => runAutomationScript({
snippet,
sessionId: sid,
initiatedBy: 'user',
sessionMeta: buildScriptSessionMeta(sid, sessionsRef.current, hosts),
});
if (mode === 'sequential') {
@@ -2363,29 +2387,55 @@ const TerminalLayerInner: React.FC<TerminalLayerProps> = ({
const activeWorkspace = activeWorkspaceRef.current;
if (!activeWorkspace) return false;
let recordHistory = false;
// Any recipient at a sensitive prompt turns the fan-out into a secret
// delivery: the payload is that peer's password/MFA input and must not be
// recallable from compose history, even if the focused session itself is
// non-sensitive.
let anyRecipientSensitive = false;
const pendingSends: Promise<boolean>[] = [];
const payload = text + '\r';
const broadcastEnabled = isBroadcastEnabled?.(activeWorkspace.id);
const focusedSessionId = activeWorkspace.focusedSessionId;
const broadcastPasswordBypass = broadcastPasswordBypassRef.current;
const focusedSensitive = focusedSessionId
? isTerminalSensitiveInputActive(focusedSessionId)
: false;
if (broadcastEnabled && !focusedSensitive) {
if (broadcastEnabled && (broadcastPasswordBypass || !focusedSensitive)) {
const allSessionIds = sessionsRef.current
.filter((session) => session.workspaceId === activeWorkspace.id)
.map((session) => session.id);
for (const sid of allSessionIds) {
if (isTerminalSensitiveInputActive(sid)) continue;
const recipientSensitive = isTerminalSensitiveInputActive(sid);
if (!broadcastPasswordBypass && recipientSensitive) continue;
const executor = snippetExecutorsRef.current.get(sid);
if (executor) {
pendingSends.push(Promise.resolve(executor(text, false, { broadcast: false })).then(
(sent) => sent && !isTerminalSensitiveInputActive(sid),
pendingSends.push(Promise.resolve(executor(text, false, {
broadcast: false,
// Preserve both prompt snapshots across an asynchronous wake: the
// peer may clear its prompt classification before the actual write.
...(focusedSensitive || recipientSensitive ? { sensitive: true } : {}),
})).then(
(sent) => {
// A lagging peer can reach its password prompt by delivery time;
// anything it receives there is its secret input, so mark the
// whole fan-out as history-ineligible.
if (sent && (recipientSensitive || isTerminalSensitiveInputActive(sid))) anyRecipientSensitive = true;
return sent && (broadcastPasswordBypass || !isTerminalSensitiveInputActive(sid));
},
));
} else {
const session = sessionsRef.current.find((candidate) => candidate.id === sid);
if (!session || !canUseDirectSessionWriteFallback(session)) continue;
terminalBackend.writeToSession(sid, payload, { sensitive: false });
// Keep the source-sensitive marker when the #3488 bypass let a
// password-prompt payload through, so interceptors stay skipped.
// A non-sensitive focused pane can still fan into a sensitive peer
// (its password/MFA input): mark the peer's write sensitive too,
// like the broadcast dispatcher does for its executor writes.
terminalBackend.writeToSession(sid, payload, {
sensitive: focusedSensitive || recipientSensitive,
});
if (recipientSensitive) anyRecipientSensitive = true;
recordHistory = recordHistory || session.status === 'connected';
}
}
@@ -2413,7 +2463,13 @@ const TerminalLayerInner: React.FC<TerminalLayerProps> = ({
}
}
const results = await Promise.allSettled(pendingSends);
return recordHistory || results.some((result) => result.status === 'fulfilled' && result.value);
const delivered = recordHistory || results.some((result) => result.status === 'fulfilled' && result.value);
// A bypassed send typed at a sensitive prompt (#3488), or delivered into
// any recipient's sensitive prompt, was delivered but must not be
// recallable from compose history via ArrowUp, so report no history
// eligibility for it.
if (focusedSensitive || anyRecipientSensitive) return false;
return delivered;
}, [isBroadcastEnabled, terminalBackend]);
const sessionLogConfig = useMemo(
+29 -10
View File
@@ -249,7 +249,7 @@ function formatToolResult(result: unknown): string {
const obj = JSON.parse(parsed);
if (obj && typeof obj === 'object') parsed = obj;
} catch {
return parsed;
return result as string;
}
}
@@ -272,6 +272,7 @@ function formatToolResult(result: unknown): string {
export interface ToolCallProps extends HTMLAttributes<HTMLDivElement> {
name: string;
approvalTarget?: { label: string; hostname: string };
className?: string;
args?: Record<string, unknown>;
result?: unknown;
@@ -292,6 +293,7 @@ export interface ToolCallProps extends HTMLAttributes<HTMLDivElement> {
onAlwaysAllow?: () => void;
/** Optional source-specific label for the persistent/session approval action. */
alwaysAllowLabel?: string;
alwaysAllowTitle?: string;
}
async function copyTextToClipboard(text: string): Promise<boolean> {
@@ -321,8 +323,8 @@ async function copyTextToClipboard(text: string): Promise<boolean> {
}
export const ToolCall = ({
name, args, result, isError, isLoading, isInterrupted,
approvalStatus, approvalId, onApprove, onReject, onApproveOnce, onAlwaysAllow, alwaysAllowLabel,
name, args, result, isError, isLoading, isInterrupted, approvalTarget,
approvalStatus, approvalId, onApprove, onReject, onApproveOnce, onAlwaysAllow, alwaysAllowLabel, alwaysAllowTitle,
className, ...props
}: ToolCallProps) => {
const { t } = useI18n();
@@ -520,31 +522,47 @@ export const ToolCall = ({
{statusIcon}
</button>
{isPendingApproval && approvalTarget && !showApprovalCommand && (
<div className="min-w-0 break-words border-t border-border/20 px-3 py-1.5 text-xs font-medium">
{t('ai.chat.targetLabel')}: <span className="break-all">{approvalTarget.label}</span>
{approvalTarget.hostname && approvalTarget.hostname !== approvalTarget.label && (
<span className="ml-1 break-all text-muted-foreground">({approvalTarget.hostname})</span>
)}
</div>
)}
{expanded && (
<div className="border-t border-border/20">
{showApprovalCommand && reviewCommand && (
<div className="px-3 py-2 space-y-1.5">
{executionContext && (
<div className="flex flex-wrap gap-x-3 gap-y-0.5 text-[10px] text-muted-foreground/45">
<span className="font-medium uppercase tracking-wider text-muted-foreground/30">
{(approvalTarget || executionContext) && (
<div className="flex flex-wrap items-baseline gap-x-3 gap-y-0.5 text-[11px] text-muted-foreground">
<span className="font-medium uppercase tracking-wider text-muted-foreground/60">
{t('ai.chat.targetLabel')}
</span>
{executionContext.sessionId && (
{approvalTarget ? (
<span className="min-w-0 break-all font-medium text-foreground/90">
{approvalTarget.label}
{approvalTarget.hostname && approvalTarget.hostname !== approvalTarget.label && (
<span className="ml-1 font-normal text-muted-foreground">({approvalTarget.hostname})</span>
)}
</span>
) : executionContext?.sessionId && (
<span className="font-mono truncate" title={executionContext.sessionId}>
{t('ai.chat.approvalSession')}: {executionContext.sessionId}
</span>
)}
{executionContext.shell && (
{executionContext?.shell && (
<span className="font-mono">
{t('ai.chat.approvalShell')}: {executionContext.shell}
</span>
)}
{executionContext.cwd && (
{executionContext?.cwd && (
<span className="font-mono truncate" title={executionContext.cwd}>
{t('ai.chat.approvalCwd')}: {executionContext.cwd}
</span>
)}
{executionContext.reason && (
{executionContext?.reason && (
<span className="truncate" title={executionContext.reason}>
{t('ai.chat.approvalReason')}: {executionContext.reason}
</span>
@@ -656,6 +674,7 @@ export const ToolCall = ({
size="sm"
className="h-7 min-w-0 flex-1 gap-1 px-1.5 text-[11px] font-normal border-green-500/35 text-green-300/95 hover:bg-green-500/10 hover:text-green-300 hover:border-green-500/50"
onClick={handleAlwaysAllow}
title={alwaysAllowTitle}
>
<Check size={12} className="shrink-0" />
<span className="truncate">{alwaysAllowLabel || t('ai.chat.alwaysAllow')}</span>
+1
View File
@@ -843,6 +843,7 @@ const ChatMessageList: React.FC<ChatMessageListProps> = ({
<ToolCall
name={req.toolName}
args={req.args}
approvalTarget={req.target}
isLoading={false}
isInterrupted={false}
approvalStatus={'pending'}
+95 -14
View File
@@ -6,6 +6,7 @@
import React, { useCallback, useEffect, useState } from 'react';
import { ToolCall } from '../ai-elements/tool-call';
import { Button } from '../ui/button';
import {
onApprovalCleared,
onApprovalRequest,
@@ -15,6 +16,7 @@ import {
} from '../../infrastructure/ai/shared/approvalGate';
import {
buildGrantsFromApproval,
createPermissionGrantId,
resolveCapabilityId,
} from '../../infrastructure/ai/harness/permissionGrants';
import { useI18n } from '../../application/i18n/I18nProvider';
@@ -61,7 +63,10 @@ export const ExternalMcpApprovalsHost: React.FC = () => {
const handleAlwaysAllow = useCallback((toolCallId: string, request: ApprovalRequest) => {
const capabilityId = request.capabilityId ?? resolveCapabilityId(request.toolName);
const persistGrants = buildGrantsFromApproval(capabilityId, request.args, request.chatSessionId);
const persistGrants = buildGrantsFromApproval(capabilityId, request.args, request.chatSessionId)
.map((grant) => request.target?.hostId
? { ...grant, sessionPattern: `host:${request.target.hostId}` }
: grant);
resolveApproval(toolCallId, { approved: true, persistGrants });
setPendingApprovals((prev) => {
const next = new Map(prev);
@@ -70,6 +75,35 @@ export const ExternalMcpApprovalsHost: React.FC = () => {
});
}, []);
const handleAllowSession = useCallback((toolCallId: string) => {
resolveApproval(toolCallId, { approved: true, scope: 'session' });
setPendingApprovals((prev) => {
const next = new Map(prev);
next.delete(toolCallId);
return next;
});
}, []);
const handleAllowHost = useCallback((toolCallId: string, request: ApprovalRequest) => {
const hostId = request.target?.hostId;
if (!hostId) return;
resolveApproval(toolCallId, {
approved: true,
persistGrant: {
id: createPermissionGrantId(),
capabilityId: '*',
sessionPattern: `host:${hostId}`,
createdAt: Date.now(),
note: request.target?.label,
},
});
setPendingApprovals((prev) => {
const next = new Map(prev);
next.delete(toolCallId);
return next;
});
}, []);
const handleReject = useCallback((toolCallId: string) => {
resolveApproval(toolCallId, false);
setPendingApprovals((prev) => {
@@ -84,7 +118,7 @@ export const ExternalMcpApprovalsHost: React.FC = () => {
return (
<div
className="pointer-events-auto fixed bottom-4 right-4 z-[80] flex w-[min(420px,calc(100vw-2rem))] flex-col gap-2"
className="pointer-events-auto fixed bottom-4 right-4 z-[80] flex max-h-[calc(100vh-2rem)] w-[min(420px,calc(100vw-2rem))] flex-col gap-2 overflow-y-auto overscroll-contain"
data-testid="external-mcp-approvals-host"
>
<div className="rounded-lg border border-border/60 bg-background/95 p-3 shadow-lg backdrop-blur-sm">
@@ -93,18 +127,65 @@ export const ExternalMcpApprovalsHost: React.FC = () => {
</div>
<div className="space-y-2">
{entries.map(([id, req]) => (
<ToolCall
key={id}
name={req.toolName}
args={req.args}
isLoading={false}
isInterrupted={false}
approvalStatus="pending"
approvalId={id}
onApproveOnce={() => handleApproveOnce(id)}
onAlwaysAllow={() => handleAlwaysAllow(id, req)}
onReject={() => handleReject(id)}
/>
<div key={id} className="space-y-1.5">
<ToolCall
name={req.toolName}
args={req.args}
approvalTarget={req.target}
isLoading={false}
isInterrupted={false}
approvalStatus="pending"
approvalId={id}
onApproveOnce={() => handleApproveOnce(id)}
onAlwaysAllow={req.target && !req.target.hostId ? undefined : () => handleAlwaysAllow(id, req)}
onReject={() => handleReject(id)}
alwaysAllowLabel={req.target?.hostId ? t('ai.externalMcp.allowCommandOnHost') : undefined}
alwaysAllowTitle={req.target?.hostId ? t('ai.externalMcp.allowCommandOnHostHint') : undefined}
/>
{(req.allowSession || req.target?.hostId) && (
<div className="space-y-1.5">
<div className="text-[10px] font-medium uppercase tracking-wide text-muted-foreground/60">
{t('ai.externalMcp.allowAll')}
</div>
<div className="grid grid-cols-2 gap-1.5">
{req.allowSession && (
<Button
variant="outline"
size="sm"
className={`h-7 min-w-0 px-2 text-[11px] ${req.target?.hostId ? '' : 'col-span-2'}`}
title={t('ai.externalMcp.allowSessionHint')}
onClick={() => handleAllowSession(id)}
onKeyDown={(event) => {
if (event.key !== 'Escape') return;
event.preventDefault();
event.stopPropagation();
handleReject(id);
}}
>
{t('ai.externalMcp.allowSession')}
</Button>
)}
{req.target?.hostId && (
<Button
variant="outline"
size="sm"
className={`h-7 min-w-0 px-2 text-[11px] ${req.allowSession ? '' : 'col-span-2'}`}
title={t('ai.externalMcp.allowHostHint')}
onClick={() => handleAllowHost(id, req)}
onKeyDown={(event) => {
if (event.key !== 'Escape') return;
event.preventDefault();
event.stopPropagation();
handleReject(id);
}}
>
{t('ai.externalMcp.allowHost')}
</Button>
)}
</div>
</div>
)}
</div>
))}
</div>
</div>
@@ -121,6 +121,8 @@ interface SettingsSystemTabProps {
setToggleWindowHotkey: (hotkey: string) => void;
closeToTray: boolean;
setCloseToTray: (enabled: boolean) => void;
showTrayIcon: boolean;
setShowTrayIcon: (enabled: boolean) => void;
autoLaunchEnabled: boolean;
setAutoLaunchEnabled: (enabled: boolean) => void;
autoLaunchSupported: boolean;
@@ -173,6 +175,8 @@ const SettingsSystemTab: React.FC<SettingsSystemTabProps> = ({
setToggleWindowHotkey,
closeToTray,
setCloseToTray,
showTrayIcon,
setShowTrayIcon,
autoLaunchEnabled,
setAutoLaunchEnabled,
autoLaunchSupported,
@@ -1313,6 +1317,23 @@ const SettingsSystemTab: React.FC<SettingsSystemTabProps> = ({
onChange={setCloseToTray}
/>
</SettingRow>
{/* Show System Tray Icon */}
<SettingRow
anchorId="system-show-tray-icon"
label={t("settings.globalHotkey.showTrayIcon")}
description={t("settings.globalHotkey.showTrayIconDesc")}
>
<Toggle
checked={showTrayIcon}
onChange={setShowTrayIcon}
/>
</SettingRow>
{!showTrayIcon && (
<p className="text-sm text-muted-foreground">
{t("settings.globalHotkey.showTrayIconHiddenHint")}
</p>
)}
</SettingCard>
<SettingHint>
@@ -38,6 +38,7 @@ const GrantCapabilitySelect: React.FC<{
options: readonly string[];
onChange: (value: string) => void;
}> = ({ value, options, onChange }) => {
const { t } = useI18n();
const selectOptions = useMemo(() => {
if (options.includes(value)) return options;
return [value, ...options];
@@ -52,7 +53,7 @@ const GrantCapabilitySelect: React.FC<{
>
{selectOptions.map((capabilityId) => (
<option key={capabilityId} value={capabilityId}>
{capabilityId}
{capabilityId === '*' ? t('ai.externalMcp.allowHost') : capabilityId}
</option>
))}
</select>
@@ -204,6 +205,11 @@ export const PermissionGrantsSettings: React.FC<{
updateGrant(grant.id, updates);
}}
/>
{grant.sessionPattern.startsWith('host:') && (
<div className="mt-1 truncate text-[10px] text-muted-foreground" title={grant.sessionPattern}>
{t('ai.chat.targetLabel')}: {grant.note || grant.sessionPattern.slice(5)}
</div>
)}
</td>
<td className="px-2 py-2 align-middle max-w-0">
{supportsCommandPattern ? (
@@ -5,7 +5,7 @@ import { useI18n } from '../../application/i18n/I18nProvider';
import { SYSTEM_MANAGER_TAB_LAYOUT_DEFAULTS } from '../../application/state/systemManagerTabLayout';
import { useSystemManagerBackend } from '../../application/state/useSystemManagerBackend';
import { useTerminalBackend } from '../../application/state/useTerminalBackend';
import { remoteSoftwareRequiresSingleChannel } from '../../domain/singleChannelSshBanner.shared.cjs';
import { remoteSoftwareRequiresSingleChannel } from '../../domain/singleChannelSshBanner.shared.mjs';
import { useToolbarItemLayout } from '../../application/state/useToolbarItemLayout';
import type { TerminalSettings } from '../../domain/models';
import type { Host } from '../../domain/models/connection';
+135
View File
@@ -7,9 +7,12 @@ import { GripHorizontal, Pin, Plus, Radio, Search, X } from 'lucide-react';
import React, { memo, useCallback, useEffect, useMemo, useRef, useState } from 'react';
import { useComposeBarHistory } from '../../application/state/useComposeBarHistory';
import { canNavigateComposeBarHistory } from '../../domain/composeBarHistory';
import { STORAGE_KEY_TERMINAL_BROADCAST_PASSWORD_BYPASS } from '../../infrastructure/config/storageKeys';
import { useStoredBoolean } from '../../application/state/useStoredBoolean';
import { useComposeBarHeight } from '../../application/state/useComposeBarHeight';
import { useComposeBarPinnedSnippets } from '../../application/state/useComposeBarPinnedSnippets';
import { useI18n } from '../../application/i18n/I18nProvider';
import { useClipboardBackend } from '../../application/state/useClipboardBackend';
import { resolveSnippetCommand } from '../SnippetExecutionProvider';
import { Snippet } from '../../types';
import { cn } from '../../lib/utils';
@@ -292,6 +295,7 @@ export const TerminalComposeBar: React.FC<TerminalComposeBarProps> = ({
themeColors,
}) => {
const { t } = useI18n();
const { readClipboardText, writeClipboardText } = useClipboardBackend();
const textareaRef = useRef<HTMLTextAreaElement>(null);
const restoreDraft = useCallback((draft: string) => {
if (textareaRef.current) textareaRef.current.value = draft;
@@ -319,6 +323,88 @@ export const TerminalComposeBar: React.FC<TerminalComposeBarProps> = ({
const theme = useMemo(() => buildTheme(themeColors), [themeColors]);
// Opt-in "broadcast without password protection" (#3488). Shared storage key
// with the terminal runtime, which reads it to keep broadcasting during
// password / interactive prompts while this switch is on.
const [allowPasswordBroadcast, setAllowPasswordBroadcast] = useStoredBoolean(
STORAGE_KEY_TERMINAL_BROADCAST_PASSWORD_BYPASS,
false,
);
const [broadcastMenuAnchor, setBroadcastMenuAnchor] = useState<{ x: number; y: number; inTextarea: boolean } | null>(null);
useEffect(() => {
if (!broadcastMenuAnchor) return;
const handlePointerDown = (event: PointerEvent) => {
if (event.target instanceof Element && event.target.closest('[data-compose-broadcast-menu]')) return;
setBroadcastMenuAnchor(null);
};
const handleKeyDown = (event: KeyboardEvent) => {
if (event.key === 'Escape') setBroadcastMenuAnchor(null);
};
window.addEventListener('pointerdown', handlePointerDown);
window.addEventListener('keydown', handleKeyDown);
return () => {
window.removeEventListener('pointerdown', handlePointerDown);
window.removeEventListener('keydown', handleKeyDown);
};
}, [broadcastMenuAnchor]);
const handleBroadcastContextMenu = useCallback((event: React.MouseEvent) => {
if (!isBroadcastEnabled) return;
event.preventDefault();
event.stopPropagation();
setBroadcastMenuAnchor({
x: event.clientX,
y: event.clientY,
inTextarea: event.target instanceof Element && Boolean(event.target.closest('textarea')),
});
}, [isBroadcastEnabled]);
const handleComposeMenuEdit = useCallback(async (action: 'cut' | 'copy' | 'paste' | 'selectAll') => {
setBroadcastMenuAnchor(null);
const textarea = textareaRef.current;
if (!textarea) return;
textarea.focus();
if (action === 'selectAll') {
textarea.select();
return;
}
const start = textarea.selectionStart;
const end = textarea.selectionEnd;
try {
if (action === 'paste') {
let text: string | undefined;
try { text = await readClipboardText(); } catch { /* Use browser clipboard. */ }
if (typeof text !== 'string') text = await navigator.clipboard.readText();
if (!text) return;
textarea.setSelectionRange(start, end);
if (!document.execCommand('insertText', false, text)) {
textarea.setRangeText(text, start, end, 'end');
textarea.dispatchEvent(new Event('input', { bubbles: true }));
}
reset();
return;
}
if (start === end) return;
const selectedText = textarea.value.slice(start, end);
let wroteToBridge = false;
try { wroteToBridge = await writeClipboardText(selectedText); } catch { /* Use browser clipboard. */ }
if (!wroteToBridge) {
await navigator.clipboard.writeText(selectedText);
}
if (action === 'cut') {
textarea.setSelectionRange(start, end);
if (!document.execCommand('delete')) {
textarea.setRangeText('', start, end, 'end');
textarea.dispatchEvent(new Event('input', { bubbles: true }));
}
reset();
}
} catch (error) {
console.error('Compose bar clipboard action failed', error);
}
}, [readClipboardText, reset, writeClipboardText]);
const snippetsById = useMemo(
() => mergeComposeBarSnippetMap(snippets),
[snippets],
@@ -459,6 +545,7 @@ export const TerminalComposeBar: React.FC<TerminalComposeBarProps> = ({
backgroundColor: theme.resolvedBg,
borderTop: `1px solid ${theme.borderColor}`,
}}
onContextMenu={handleBroadcastContextMenu}
>
<div
role="separator"
@@ -561,6 +648,54 @@ export const TerminalComposeBar: React.FC<TerminalComposeBarProps> = ({
</Tooltip>
</div>
</div>
{broadcastMenuAnchor && (
<div
data-compose-broadcast-menu
className="fixed z-[120] min-w-[240px] rounded-md border py-1 shadow-lg"
style={{
// Clamp horizontally so the 240px-min menu stays on-screen even
// when the compose bar is right-clicked near the right edge.
left: Math.max(4, Math.min(broadcastMenuAnchor.x, window.innerWidth - 244)),
top: Math.max(4, Math.min(broadcastMenuAnchor.y, window.innerHeight - (broadcastMenuAnchor.inTextarea ? 160 : 48))),
backgroundColor: theme.resolvedBg,
borderColor: theme.borderColor,
color: theme.resolvedFg,
}}
onMouseDown={(event) => event.preventDefault()}
onContextMenu={(e) => e.preventDefault()}
>
{broadcastMenuAnchor.inTextarea && (
<>
{(['cut', 'copy', 'paste', 'selectAll'] as const).map((action) => (
<button
key={action}
type="button"
className="flex w-full items-center px-2.5 py-1.5 text-left text-[11px] transition-colors duration-150 hover:bg-black/20"
onClick={() => { void handleComposeMenuEdit(action); }}
>
{t(`terminal.menu.${action}`)}
</button>
))}
<div className="my-1 border-t" style={{ borderColor: theme.borderColor }} />
</>
)}
<button
data-compose-broadcast-toggle
type="button"
className="flex w-full items-center gap-2 px-2.5 py-1.5 text-left text-[11px] transition-colors duration-150 hover:bg-black/20"
onClick={() => {
setAllowPasswordBroadcast((prev) => !prev);
setBroadcastMenuAnchor(null);
}}
>
<span className="w-3 shrink-0 text-center" aria-hidden="true">
{allowPasswordBroadcast ? '✓' : ''}
</span>
{t('terminal.composeBar.broadcastAllowPassword')}
</button>
</div>
)}
</div>
);
};
+22 -2
View File
@@ -1315,8 +1315,28 @@ function TerminalViewInner({ ctx, isPaneMagnified = false }: { ctx: TerminalView
onSend={async (text) => {
if (sessionRef.current) {
const sensitive = isTerminalSensitiveInputActive(sessionId);
const sent = await executeSnippetCommand(text, false);
return sent && !sensitive && !isTerminalSensitiveInputActive(sessionId);
// A bypassed fan-out (#3488) can deliver the payload into a
// peer's sensitive prompt even when this source session is
// non-sensitive (#3491): that payload is the peer's
// password/MFA input, so the send must stay history-
// ineligible, like the workspace compose path does.
let anyRecipientSensitive = false;
let broadcastRecipientIds: readonly string[] = [];
const sent = await executeSnippetCommand(text, false, {
onBroadcastDelivered: (sessionIds) => {
broadcastRecipientIds = sessionIds;
if (sessionIds.some((id) => isTerminalSensitiveInputActive(id))) {
anyRecipientSensitive = true;
}
},
});
// A lagging peer can reach its password prompt by delivery
// time; re-check the recorded recipients after the send.
if (broadcastRecipientIds.some((id) => isTerminalSensitiveInputActive(id))) {
anyRecipientSensitive = true;
}
return sent && !sensitive && !anyRecipientSensitive
&& !isTerminalSensitiveInputActive(sessionId);
}
return false;
}}
@@ -22,6 +22,7 @@ import {
} from "../runtime/terminalHistoryScrollOverride";
import { readTerminalScreenText } from "../terminalContextBuffer";
import { shouldBroadcastDuringSensitivePrompt } from "../../../domain/terminalBroadcast";
import { useI18n } from "../../../application/i18n/I18nProvider";
import { toast } from "../../ui/toast";
@@ -33,6 +34,7 @@ type BroadcastPasteRefs = {
((data: string, sourceSessionId: string, options?: TerminalBroadcastInputOptions) => void) | undefined
>;
passwordPromptActiveRef?: RefObject<boolean | undefined>;
broadcastPasswordBypassRef?: RefObject<boolean | undefined>;
};
export const broadcastTerminalPasteData = (
@@ -43,16 +45,31 @@ export const broadcastTerminalPasteData = (
isBroadcastEnabledRef,
onBroadcastInputRef,
passwordPromptActiveRef,
broadcastPasswordBypassRef,
}: BroadcastPasteRefs,
options?: TerminalBroadcastInputOptions,
): boolean => {
if (
passwordPromptActiveRef?.current !== true
shouldBroadcastDuringSensitivePrompt({
sensitivePromptActive: passwordPromptActiveRef?.current === true,
broadcastPasswordBypass: broadcastPasswordBypassRef?.current === true,
})
&& sessionRef.current
&& isBroadcastEnabledRef?.current
&& onBroadcastInputRef?.current
) {
onBroadcastInputRef.current(data, sourceSessionId, options);
// Bypassed password fan-out (#3488): when the prompt itself is sensitive,
// tag the payload so peer writes keep input interceptors skipped. A paste
// confirmed at a sensitive prompt keeps its pre-dialog snapshot (#3491),
// so its carried sensitivity tags the fan-out even when the dialog await
// cleared the live prompt ref.
const dispatchingFromPasswordPrompt = passwordPromptActiveRef?.current === true;
const sourceSensitive = dispatchingFromPasswordPrompt || options?.sensitive === true;
onBroadcastInputRef.current(
data,
sourceSessionId,
sourceSensitive ? { ...options, sourceSensitive: true } : options,
);
return true;
}
return false;
@@ -68,6 +85,7 @@ export const useTerminalContextActions = ({
isBroadcastEnabledRef,
onBroadcastInputRef,
passwordPromptActiveRef,
broadcastPasswordBypassRef,
isLocalConnection,
supportsRemoteImagePaste,
autoUploadClipboardImageOnPasteRef,
@@ -90,6 +108,7 @@ export const useTerminalContextActions = ({
((data: string, sourceSessionId: string, options?: TerminalBroadcastInputOptions) => void) | undefined
>;
passwordPromptActiveRef?: RefObject<boolean | undefined>;
broadcastPasswordBypassRef?: RefObject<boolean | undefined>;
isLocalConnection: boolean;
supportsRemoteImagePaste: boolean;
/** When true, paste auto-uploads a clipboard image (remote sessions only). */
@@ -143,8 +162,9 @@ export const useTerminalContextActions = ({
isBroadcastEnabledRef,
onBroadcastInputRef,
passwordPromptActiveRef,
broadcastPasswordBypassRef,
}, options);
}, [isBroadcastEnabledRef, onBroadcastInputRef, passwordPromptActiveRef, sessionRef, sourceSessionId]);
}, [isBroadcastEnabledRef, onBroadcastInputRef, passwordPromptActiveRef, broadcastPasswordBypassRef, sessionRef, sourceSessionId]);
const onCopy = useCallback(() => {
const term = termRef.current;
@@ -178,6 +198,7 @@ export const useTerminalContextActions = ({
getRemoteCwd,
isLocalConnection,
isSensitiveInput: () => passwordPromptActiveRef?.current === true,
broadcastPasswordBypass: () => broadcastPasswordBypassRef?.current === true,
onClipboardImageUploadResult,
readClipboardText: () => navigator.clipboard.readText(),
scrollOnPaste: scrollOnPasteRef?.current ?? false,
@@ -192,6 +213,7 @@ export const useTerminalContextActions = ({
}
}, [
autoUploadClipboardImageOnPasteRef,
broadcastPasswordBypassRef,
broadcastUserPasteData,
multilinePasteConfirmRef,
getRemoteCwd,
@@ -256,6 +278,7 @@ export const useTerminalContextActions = ({
: undefined,
getCurrentSessionId: () => sessionRef.current,
isSensitiveInput: () => passwordPromptActiveRef?.current === true,
broadcastPasswordBypass: () => broadcastPasswordBypassRef?.current === true,
onPasteData: broadcastUserPasteData,
scrollOnPaste: scrollOnPasteRef?.current ?? false,
scrollToBottomAfterProgrammaticInput,
@@ -264,6 +287,7 @@ export const useTerminalContextActions = ({
term,
});
}, [
broadcastPasswordBypassRef,
broadcastUserPasteData,
multilinePasteConfirmRef,
normalizeTextOnCopyRef,
@@ -20,7 +20,9 @@ interface UseTerminalFilePasteOptions {
};
isSensitiveInput?: () => boolean;
scrollOnPasteRef?: React.RefObject<boolean>;
onPasteData?: (data: string, options?: { lineDelayMs?: number }) => boolean | void;
/** Live #3488 bypass probe; forwarded to the multiline-confirm gate. */
broadcastPasswordBypassRef?: React.RefObject<boolean | undefined>;
onPasteData?: (data: string, options?: { lineDelayMs?: number; sensitive?: boolean }) => boolean | void;
scrollToBottomAfterProgrammaticInput: (data: string) => void;
containerRef: React.RefObject<HTMLDivElement | null>;
/** Remote sessions only: auto-upload a clipboard image on paste. */
@@ -39,6 +41,7 @@ export function useTerminalFilePaste({
terminalBackend,
isSensitiveInput,
scrollOnPasteRef,
broadcastPasswordBypassRef,
onPasteData,
scrollToBottomAfterProgrammaticInput,
containerRef,
@@ -100,6 +103,10 @@ export function useTerminalFilePaste({
getRemoteCwd,
isLocalConnection,
isSensitiveInput,
// Forward the #3488 bypass probe so a paste confirmed at a
// sensitive prompt still fans out to broadcast peers, matching
// the context-menu and shortcut paste paths.
broadcastPasswordBypass: () => broadcastPasswordBypassRef?.current === true,
onClipboardImageUploadResult,
readClipboardText: async () => {
if (eventText) return eventText;
@@ -124,6 +131,7 @@ export function useTerminalFilePaste({
};
}, [
autoUploadClipboardImage,
broadcastPasswordBypassRef,
containerRef,
multilinePasteConfirmRef,
getRemoteCwd,
@@ -2,7 +2,7 @@ import type { Terminal as XTerm } from "@xterm/xterm";
import type { ProviderValidationIssue } from "@netcatty/plugin-contract";
import { logger } from "../../../lib/logger";
import type { Host, Identity, SSHKey } from "../../../types";
import type { TerminalSessionExitEvent } from "../../../application/state/resolveTerminalSessionExitIntent";
import { type TerminalSessionExitEvent, formatSessionClosedExitMessage } from "../../../application/state/resolveTerminalSessionExitIntent";
import { setTerminalBootEpoch } from "../../../domain/terminalBootEpoch";
import type { TerminalSessionStartersContext } from "./createTerminalSessionStarters.types";
export type {
@@ -820,8 +820,7 @@ export const createTerminalSessionStarters = (ctx: TerminalSessionStartersContex
isCurrentAttempt,
bootEpoch,
onConnected: () => ctx.setChainProgress(null),
onExitMessage: (evt) =>
`\r\n[session closed${evt?.exitCode !== undefined ? ` (code ${evt.exitCode})` : ""}]`,
onExitMessage: formatSessionClosedExitMessage,
sudoAutofillPassword: resolveSavedSudoAutofillPassword(),
sudoAutofillCandidates: resolveSudoAutofillCandidates(),
})) {
@@ -1027,7 +1027,7 @@ test("multi-character plain text goes out as per-character writes (#3077)", asyn
// broadcast keep using the unsplit payload.
const writeSite = source.slice(writeLoopIdx - 600, writeLoopIdx + 320);
assert.match(writeSite, /ctx\.onOutputTriggerUserInputRef\?\.current\?\.\(outData\)/);
assert.match(source, /onBroadcastInput\?\.\(broadcastData, ctx\.sessionId\)/);
assert.match(source, /onBroadcastInput\?\.\(broadcastData, ctx\.sessionId, sensitive \? \{ sourceSensitive: true \} : undefined\)/);
});
test("Command+Period interrupt press is keyed apart from an outstanding physical KeyC press (#3409)", async () => {
@@ -1088,3 +1088,28 @@ test("Command+Period interrupt yields to a user-assigned snippet or shortcut cho
/const macCommandPeriodInterrupt =\s*isMacPlatform\(\)\s*&& isMacCommandPeriodInterruptChord\(e\)\s*&& !\(ctx\.snippetsRef\?\.current \?\? \[\]\)\.some\(\(snippet\) => \(\s*snippet\.shortkey && matchesKeyBinding\(e, snippet\.shortkey, isMac\)\s*\)\)\s*&& !\(currentScheme !== "disabled"\s*&& checkAppShortcut\(e, ctx\.keyBindingsRef\.current, isMac\) !== null\);/,
);
});
test("Shift+Enter send-text fallback snapshots prompt sensitivity before the local write (#3491)", async () => {
const { readFileSync } = await import("node:fs");
const source = readFileSync(new URL("./createXTermRuntime.ts", import.meta.url), "utf8");
// The Shift+Enter fallback reaches the PTY as a bare newline via
// handleTerminalInputData, which submits synchronously and clears
// passwordPromptActiveRef. The subsequent key-chord broadcast must carry a
// pre-write sensitivity snapshot, otherwise the live prompt-source check
// reports the dispatch as nonsensitive and a peer commits its buffered
// password characters as ordinary input.
const fallbackIdx = source.indexOf(
'handleTerminalInputData(shiftEnterText, {',
);
assert.ok(fallbackIdx >= 0);
const fallback = source.slice(fallbackIdx, fallbackIdx + 400);
const snapshotIdx = source.slice(0, fallbackIdx).lastIndexOf(
"const sourceSensitivePrompt = ctx.passwordPromptActiveRef?.current === true;",
);
assert.ok(snapshotIdx >= 0, "snapshot must precede the local Shift+Enter write");
assert.match(
fallback,
/broadcastKittyInput\(\s*\{\s*kind: "key",\s*event: kittyEvent,\s*fallbackToLegacy: true,\s*\},\s*false,\s*undefined,\s*sourceSensitivePrompt \? \{ sourceSensitive: true \} : undefined\)/,
);
});
+117 -21
View File
@@ -129,6 +129,7 @@ import {
shouldFlushStaleDeferredImeTextInput,
} from "./terminalImeTextInput";
import { keepImeCommittedTextThroughModifierKeyDowns } from "./imeModifierKeyDownSeenGuard";
import { keepLiveImeTranscriptionSingle } from "./imeCompositionRewrite";
import { formatSerialLocalEcho } from "./serialLocalEcho";
import { getLastChar, removeLastChar, isPrintableInput } from "../../../domain/serialCharMetrics";
import { mapTerminalBackspaceInput } from "./terminalBackspaceInput";
@@ -382,6 +383,11 @@ export type CreateXTermRuntimeContext = {
captureSubmittedLineRecorder?: () => ((line: string, options?: { sensitive?: boolean; includePendingInput?: boolean; consumePendingInput?: boolean }) => Promise<void>) | undefined;
} | undefined>;
passwordPromptActiveRef?: RefObject<boolean>;
/**
* Opt-in "broadcast without password protection" (#3488). When true, an
* active password prompt no longer pauses raw-string broadcast fan-out.
*/
broadcastPasswordBypassRef?: RefObject<boolean>;
allowHostStyleGreaterThanPrompt?: boolean;
onOutputTriggerUserInputRef?: RefObject<((data: string) => void) | undefined>;
sudoAutofillRef?: RefObject<SudoPasswordAutofill | null>;
@@ -705,6 +711,9 @@ export const createXTermRuntime = (ctx: CreateXTermRuntimeContext): XTermRuntime
}
term.open(ctx.container);
// CompositionHelper is created in open(). Live transcription rewrites the
// hypothesis instead of appending; commit that utterance once (#3421).
keepLiveImeTranscriptionSingle(term);
// Inline raster images (Kitty graphics / SIXEL / iTerm IIP). Loaded right after
// term.open so the addon can patch IRenderService.setRenderer before the WebGL
@@ -980,12 +989,23 @@ export const createXTermRuntime = (ctx: CreateXTermRuntimeContext): XTermRuntime
data: string,
options?: TerminalBroadcastInputOptions,
) => {
// Password bypass (#3488): the opt-in lifts the password-prompt pause.
// Payloads dispatched from an active prompt stay marked sourceSensitive so
// peer writes keep skipping input interceptors.
const dispatchingFromPasswordPrompt = ctx.passwordPromptActiveRef?.current === true;
// A paste confirmed at a sensitive prompt keeps its pre-dialog snapshot
// (#3491): the dialog await can clear the live prompt ref, so the saved
// classification carried in the paste options must still tag the fan-out.
const sourceSensitive = dispatchingFromPasswordPrompt || options?.sensitive === true;
if (
ctx.passwordPromptActiveRef?.current !== true
(!dispatchingFromPasswordPrompt
|| ctx.broadcastPasswordBypassRef?.current === true)
&& ctx.isBroadcastEnabledRef.current
&& ctx.onBroadcastInputRef.current
) {
ctx.onBroadcastInputRef.current(data, ctx.sessionId, options);
ctx.onBroadcastInputRef.current(data, ctx.sessionId, sourceSensitive
? { ...options, sourceSensitive: true }
: options);
return true;
}
return false;
@@ -1259,6 +1279,13 @@ export const createXTermRuntime = (ctx: CreateXTermRuntimeContext): XTermRuntime
skipBroadcast?: boolean;
/** Confirmed paste: preserve classification and backend pacing. */
sensitive?: boolean;
/**
* The sensitive marker came from a source-sensitive broadcast peer
* (#3488), not from this session's own prompt detector. Semantic input
* consumers such as autocomplete must not see the secret payload: their
* gate only checks this peer's own prompt state (#3491).
*/
sourceSensitiveBroadcast?: boolean;
lineDelayMs?: number;
pasteRequestId?: string;
/**
@@ -1326,7 +1353,11 @@ export const createXTermRuntime = (ctx: CreateXTermRuntimeContext): XTermRuntime
if (suppressTerminalBroadcast) suppressNextTerminalDataBroadcast = false;
// skipBroadcast only suppresses the raw-string fan-out. Peers still receive
// the Shift+Enter chord, so sudo autofill must treat this as a broadcast.
const canBroadcastInput = !sensitive &&
// The password bypass (#3488) only lifts the live password-prompt pause.
// Explicitly classified sensitive payloads (confirmed pastes, preloaded
// sudo credentials) stay non-broadcastable regardless of the opt-in.
const canBroadcastInput = (!sensitive
|| (ctx.broadcastPasswordBypassRef?.current === true && options?.sensitive !== true)) &&
inputSource !== "kitty" &&
!handlingKittyBroadcast &&
!suppressTerminalBroadcast &&
@@ -1544,7 +1575,9 @@ export const createXTermRuntime = (ctx: CreateXTermRuntimeContext): XTermRuntime
ctx.host.backspaceBehavior,
);
if (willBroadcastInput) {
onBroadcastInput?.(broadcastData, ctx.sessionId);
// If the bypass (#3488) let a password-prompt payload through, keep the
// sensitive marker on peer writes so input interceptors stay skipped.
onBroadcastInput?.(broadcastData, ctx.sessionId, sensitive ? { sourceSensitive: true } : undefined);
}
if (
@@ -1554,8 +1587,11 @@ export const createXTermRuntime = (ctx: CreateXTermRuntimeContext): XTermRuntime
scrollToBottomAfterInput(logicalData);
}
// Notify autocomplete of input
if (logicalData !== null) ctx.onAutocompleteInput?.(logicalData);
// Notify autocomplete of input. Payloads whose sensitivity arrived from
// a source-sensitive broadcast peer (#3488) stay out of autocomplete:
// this peer's own prompt gate has not latched, so the secret could reach
// enabled plugin completion providers (#3491).
if (logicalData !== null && !options?.sourceSensitiveBroadcast) ctx.onAutocompleteInput?.(logicalData);
if (ctx.statusRef.current === "connected" && logicalData !== null) {
if (handledSubmittedInput || submittedInput) {
@@ -1605,6 +1641,8 @@ export const createXTermRuntime = (ctx: CreateXTermRuntimeContext): XTermRuntime
let win32InputModePendingEvent: {
event: KittyKeyboardEvent;
logicalData: string | null;
/** Forced sensitive marker from a bypassed password-prompt source (#3488). */
sensitive?: boolean;
} | null = null;
const win32InputModeForwardedKeys = new Map<string, KittyKeyboardForwardedPress>();
const kittyForwardedKeys = new Map<string, KittyKeyboardForwardedPress>();
@@ -1652,7 +1690,12 @@ export const createXTermRuntime = (ctx: CreateXTermRuntimeContext): XTermRuntime
sourceSessionId: ctx.sessionId,
isHandlingBroadcast: () => handlingKittyBroadcast,
isBroadcastEnabled: () => ctx.isBroadcastEnabledRef.current,
isSensitiveInput: () => ctx.passwordPromptActiveRef?.current === true,
// The #3488 bypass lifts the password-prompt pause for Kitty key fan-out too.
isSensitiveInput: () => ctx.passwordPromptActiveRef?.current === true
&& ctx.broadcastPasswordBypassRef?.current !== true,
// Dispatching from an active prompt under the bypass must tag the payload
// so peer writes keep input interceptors skipped.
isSensitivePromptSource: () => ctx.passwordPromptActiveRef?.current === true,
getDispatcher: () => ctx.onBroadcastInputRef.current,
});
@@ -2400,6 +2443,7 @@ export const createXTermRuntime = (ctx: CreateXTermRuntimeContext): XTermRuntime
},
getCurrentSessionId: () => ctx.sessionRef.current,
isSensitiveInput: () => ctx.passwordPromptActiveRef?.current === true,
broadcastPasswordBypass: () => ctx.broadcastPasswordBypassRef?.current === true,
onPasteData: broadcastUserPasteData,
scrollOnPaste: shouldScrollOnTerminalPaste(ctx.terminalSettingsRef.current),
scrollToBottomAfterProgrammaticInput: scrollToBottomAfterInput,
@@ -2495,6 +2539,13 @@ export const createXTermRuntime = (ctx: CreateXTermRuntimeContext): XTermRuntime
if (shiftEnterText) {
e.preventDefault();
e.stopPropagation();
// Snapshot source-prompt sensitivity before the local write (#3491):
// the default bare-newline send-text reaches the PTY as a
// submission that clears passwordPromptActiveRef, so the live
// prompt-source check in broadcastKittyInput would report this
// Shift+Enter as nonsensitive and a peer would commit its buffered
// source-sensitive password characters as ordinary input.
const sourceSensitivePrompt = ctx.passwordPromptActiveRef?.current === true;
// Skip string broadcast: peers resolve Shift+Enter from their own
// negotiated keyboard mode via the key chord below.
handleTerminalInputData(shiftEnterText, {
@@ -2505,7 +2556,7 @@ export const createXTermRuntime = (ctx: CreateXTermRuntimeContext): XTermRuntime
kind: "key",
event: kittyEvent,
fallbackToLegacy: true,
});
}, false, undefined, sourceSensitivePrompt ? { sourceSensitive: true } : undefined);
if (forwarded) {
upsertKittyKeyboardForwardedPress(
broadcastForwardedKeys,
@@ -2529,6 +2580,13 @@ export const createXTermRuntime = (ctx: CreateXTermRuntimeContext): XTermRuntime
kittyEvent,
[],
);
// Snapshot source-prompt sensitivity before the local write (#3491):
// a Kitty-encoded Enter that reaches the PTY as bare CR submits here
// and clears passwordPromptActiveRef, so the live prompt-source check
// in broadcastKittyInput would report this Enter as nonsensitive and
// a peer would commit its buffered source-sensitive password
// characters as ordinary command/script bookkeeping.
const sourceSensitivePrompt = ctx.passwordPromptActiveRef?.current === true;
handleTerminalInputData(kittySequenceForKeyDown, { source: "kitty" });
const forwarded = broadcastKittyInput({
kind: "key",
@@ -2537,7 +2595,7 @@ export const createXTermRuntime = (ctx: CreateXTermRuntimeContext): XTermRuntime
urgentInterrupt: shouldUseUrgentTerminalInterrupt(e, {
hasSelection: hasCopyableSelection,
}),
});
}, false, undefined, sourceSensitivePrompt ? { sourceSensitive: true } : undefined);
if (forwarded) {
upsertKittyKeyboardForwardedPress(
broadcastForwardedKeys,
@@ -2733,8 +2791,15 @@ export const createXTermRuntime = (ctx: CreateXTermRuntimeContext): XTermRuntime
const writeWin32InputModeEvent = (
event: KittyKeyboardEvent,
logicalData: string | null,
writeOptions?: { sensitive?: boolean },
) => {
const pending = { event, logicalData };
const pending = {
event,
logicalData,
// A bypassed source-prompt fan-out (#3488) forces the sensitive marker so
// this peer's Win32 write keeps input interceptors skipped.
...(writeOptions?.sensitive === true ? { sensitive: true } : {}),
};
win32InputModePendingEvent = pending;
dispatchWin32InputModeEvent(term, event);
if (win32InputModePendingEvent === pending) {
@@ -2880,9 +2945,15 @@ export const createXTermRuntime = (ctx: CreateXTermRuntimeContext): XTermRuntime
acknowledgedWrite: true,
});
if (pending.broadcast && isTerminalBroadcastInputCurrent(pending.sourceInput)
&& ctx.isBroadcastEnabledRef.current && !sensitive) {
&& ctx.isBroadcastEnabledRef.current
// The #3488 bypass keeps the paced fan-out alive when this line was
// confirmed at (or lands on) a sensitive prompt.
&& (!sensitive || ctx.broadcastPasswordBypassRef?.current === true)) {
ctx.onBroadcastInputRef.current?.(`${index === 0 ? pending.firstPastedLine : command}\r`, ctx.sessionId, {
pacedBroadcast: pending.broadcast,
// Bypassed sensitive fan-out (#3488): tag the line so peer writes
// keep input interceptors skipped, like raw-string broadcast does.
...(sensitive ? { sourceSensitive: true } : {}),
});
}
void pending.recordLine?.(index === 0 ? pending.firstPastedLine : command, {
@@ -2898,7 +2969,11 @@ export const createXTermRuntime = (ctx: CreateXTermRuntimeContext): XTermRuntime
if (!sessionId) return;
markTerminalBroadcastUserInput(ctx.sessionId);
const sourceInput = captureTerminalBroadcastInput(ctx.sessionId);
const broadcast: TerminalPacedBroadcast | undefined = options.broadcast && !options.sensitive
// The #3488 bypass lets a paste confirmed at a sensitive prompt keep the
// paced fan-out: each acknowledged line is re-guarded on its receipt and
// the dispatcher keeps peer writes sensitive via the source marker.
const broadcast: TerminalPacedBroadcast | undefined = options.broadcast
&& (!options.sensitive || ctx.broadcastPasswordBypassRef?.current === true)
&& ctx.isBroadcastEnabledRef.current ? {} : undefined;
if (broadcast) ctx.onBroadcastInputRef.current?.("", ctx.sessionId, { pacedBroadcast: broadcast, preparePacedBroadcast: true });
const requestId = crypto.randomUUID();
@@ -2970,6 +3045,11 @@ export const createXTermRuntime = (ctx: CreateXTermRuntimeContext): XTermRuntime
handleTerminalInputData(data, {
logicalData: win32Input.logicalData,
skipBroadcast: true,
// A bypassed source-prompt fan-out (#3488) forces the sensitive marker
// so this peer's write keeps input interceptors skipped.
...(win32Input.sensitive === true
? { sensitive: true, sourceSensitiveBroadcast: true }
: {}),
});
return;
}
@@ -2977,7 +3057,9 @@ export const createXTermRuntime = (ctx: CreateXTermRuntimeContext): XTermRuntime
// must not classify the next physical key as a second composition commit.
// Actual composition/input handlers clear the physical-key pairing first.
if (broadcastLegacyDataPending) kittyCompositionPending = false;
if (kittyCompositionPending && !data.startsWith("\u001b")) {
// A rewritten IME hypothesis first emits standalone deletions. Route those
// as backspaces and keep the composition marker for the replacement text.
if (kittyCompositionPending && data !== "\x7f" && !data.startsWith("\u001b")) {
kittyCompositionPending = false;
if (kittyCompositionClearTimer !== undefined) {
window.clearTimeout(kittyCompositionClearTimer);
@@ -3039,31 +3121,45 @@ export const createXTermRuntime = (ctx: CreateXTermRuntimeContext): XTermRuntime
win32ShiftEnterTextKeys: broadcastWin32ShiftEnterTextKeys,
}),
getSessionId: () => ctx.sessionRef.current,
isSensitiveInput: () => ctx.passwordPromptActiveRef?.current === true,
// Receiving side: the #3488 bypass lets Kitty broadcast land at password prompts too.
isSensitiveInput: () => ctx.passwordPromptActiveRef?.current === true
&& ctx.broadcastPasswordBypassRef?.current !== true,
isConnected: () => ctx.statusRef.current === "connected",
isRuntimeDisposed: () => runtimeDisposed,
interruptSession: ctx.terminalBackend.interruptSession
? (id) => ctx.terminalBackend.interruptSession?.(id)
: undefined,
writeDisposed: (id, data) => ctx.terminalBackend.writeToSession(
writeDisposed: (id, data, writeOptions) => ctx.terminalBackend.writeToSession(
id,
mapTerminalBackspaceInput(data, ctx.host.backspaceBehavior),
{ sensitive: ctx.passwordPromptActiveRef?.current === true },
{
sensitive: ctx.passwordPromptActiveRef?.current === true
|| writeOptions?.sensitive === true,
},
),
writeActive: (data, logicalData) => handleTerminalInputData(data, {
writeActive: (data, logicalData, writeOptions) => handleTerminalInputData(data, {
source: "kitty",
logicalData,
// A bypassed source-prompt fan-out (#3488) forces the sensitive marker so
// this peer's write keeps input interceptors skipped.
...(writeOptions?.sensitive === true
? { sensitive: true, sourceSensitiveBroadcast: true }
: {}),
}),
writeWin32Event: (event, logicalData) => {
writeWin32InputModeEvent(event, logicalData);
writeWin32Event: (event, logicalData, writeOptions) => {
writeWin32InputModeEvent(event, logicalData, writeOptions);
},
});
registerKittyKeyboardBroadcastHandler(
ctx.sessionId,
(input) => {
// Forward the dispatch options (#3488): source-prompt fan-outs tag their
// payload with sourceSensitive / beforeUrgentInterrupt, and dropping the
// second argument here would downgrade bypassed password keystrokes to
// ordinary peer input (leaving input interceptors / autocomplete exposed).
(input, dispatchOptions) => {
handlingKittyBroadcast = true;
try {
handleKittyKeyboardBroadcast(input);
handleKittyKeyboardBroadcast(input, dispatchOptions);
} finally {
handlingKittyBroadcast = false;
}
@@ -0,0 +1,516 @@
import assert from "node:assert/strict";
import test from "node:test";
import { readFileSync } from "node:fs";
import { dirname, join } from "node:path";
import { fileURLToPath } from "node:url";
import {
commitTextareaChange,
continuedCompositionData,
continuedCompositionPrefix,
keepLiveImeTranscriptionSingle,
rememberTextareaCommit,
type ImeCompositionCommitTarget,
} from "./imeCompositionRewrite";
const BACKSPACE = "\x7f";
test("commitTextareaChange appends the new suffix", () => {
assert.equal(commitTextareaChange("\u6211\u662f\u771f\u7684", "\u6211\u662f\u771f\u7684\u725b\u903c"), "\u725b\u903c");
assert.equal(commitTextareaChange("", "\u6211\u662f\u771f\u7684"), "\u6211\u662f\u771f\u7684");
assert.equal(commitTextareaChange("ls ", "ls \uff0c"), "\uff0c");
});
test("commitTextareaChange counts a supplementary-plane character as one grapheme", () => {
const emoji = "\u{1F600}";
const other = "\u{1F601}";
assert.equal(commitTextareaChange(`a${emoji}`, "aX"), `${BACKSPACE}X`);
assert.equal(commitTextareaChange(emoji, other), `${BACKSPACE}${other}`);
assert.equal(commitTextareaChange(`a${emoji}`, "a"), BACKSPACE);
});
test("commitTextareaChange counts only characters forwarded to the terminal", () => {
assert.equal(commitTextareaChange("ab\u200bcd", "abXcd"), `${BACKSPACE}${BACKSPACE}Xcd`);
});
test("commitTextareaChange deletes the whole tail and retypes the new one", () => {
assert.equal(commitTextareaChange("abcd", "abXY"), `${BACKSPACE}${BACKSPACE}XY`);
assert.equal(commitTextareaChange("abcde", "abXde"), `${BACKSPACE}${BACKSPACE}${BACKSPACE}Xde`);
assert.equal(commitTextareaChange("abcd", "ab"), `${BACKSPACE}${BACKSPACE}`);
assert.equal(commitTextareaChange("hello", ""), BACKSPACE);
assert.equal(commitTextareaChange("same", "same"), "");
});
test("rememberTextareaCommit grows a hypothesis and replaces a rewrite", () => {
assert.equal(rememberTextareaCommit("", "", "\u6211\u662f\u771f\u7684"), "\u6211\u662f\u771f\u7684");
assert.equal(rememberTextareaCommit("\u6211\u662f\u771f\u7684", "\u6211\u662f\u771f\u7684", "\u725b\u903c"), "\u6211\u662f\u771f\u7684\u725b\u903c");
assert.equal(
rememberTextareaCommit("abcd", "abcd", `${BACKSPACE}${BACKSPACE}XY`),
"abXY",
);
assert.equal(
rememberTextareaCommit("abcde", "abcde", `${BACKSPACE}${BACKSPACE}${BACKSPACE}Xde`, "abXde"),
"abXde",
);
assert.equal(
rememberTextareaCommit("a\u{1F600}", "a\u{1F600}", `${BACKSPACE}X`),
"aX",
);
assert.equal(rememberTextareaCommit("hello", "hello", BACKSPACE, ""), "");
});
test("continuedCompositionPrefix keeps a new composition after a finished line", () => {
assert.equal(continuedCompositionPrefix(undefined, "\u4f60\u597d"), "");
assert.equal(
continuedCompositionPrefix({
continued: true,
pendingPrevious: "hello\uff0c",
alreadySent: "\uff0c",
textareaAtStart: "hello\uff0c",
}, "\u4f60\u597d"),
"",
);
assert.equal(
continuedCompositionPrefix({
continued: false,
pendingPrevious: "a",
alreadySent: "a",
textareaAtStart: "a",
}, "apple"),
"",
);
assert.equal(
continuedCompositionPrefix({
continued: true,
pendingPrevious: "\u6211",
alreadySent: "\u6211",
textareaAtStart: "\u6211\u771f",
}, "\u6211\u771f"),
"\u6211",
);
});
test("continuedCompositionData rewrites a revised hypothesis instead of appending it", () => {
const marker = {
continued: true,
pendingPrevious: "abcde",
alreadySent: "abcde",
textareaAtStart: "abXde",
};
assert.equal(
continuedCompositionData(marker, "abXde", "abXde", 0),
`${BACKSPACE}${BACKSPACE}${BACKSPACE}Xde`,
);
// The new character starts at the caret after "hello", so this is a new
// composition. The caller sends the slice unchanged.
assert.equal(
continuedCompositionData({
continued: true,
pendingPrevious: "hello",
alreadySent: "",
textareaAtStart: "hello",
}, "\u4f60", "hello\u4f60", 5),
null,
);
// In-place extension of a one-character hypothesis still strips the prefix.
assert.equal(
continuedCompositionData({
continued: true,
pendingPrevious: "\u6211",
alreadySent: "\u6211",
textareaAtStart: "\u6211\u771f",
}, "\u6211\u771f", "\u6211\u771f", 0),
"\u771f",
);
// A new word that merely starts with the finished character is not a prefix.
assert.equal(
continuedCompositionData({
continued: true,
pendingPrevious: "\u6211",
alreadySent: "\u6211",
textareaAtStart: "\u6211\u6211\u4eec",
}, "\u6211\u4eec", "\u6211\u6211\u4eec", 1),
null,
);
});
test("continuedCompositionPrefix returns the hypothesis already on the PTY", () => {
assert.equal(
continuedCompositionPrefix({
continued: true,
pendingPrevious: "\u6211\u662f\u771f\u7684",
alreadySent: "\u6211\u662f\u771f\u7684",
textareaAtStart: "\u6211\u662f\u771f\u7684\u725b\u903c",
}, "\u6211\u662f\u771f\u7684\u725b\u903c"),
"\u6211\u662f\u771f\u7684",
);
assert.equal(
continuedCompositionPrefix({
continued: true,
pendingPrevious: "ls \u6211\u662f\u771f\u7684",
alreadySent: "\u6211\u662f\u771f\u7684",
textareaAtStart: "ls \u6211\u662f\u771f\u7684\u725b\u903c",
}, "ls \u6211\u662f\u771f\u7684\u725b\u903c"),
"ls \u6211\u662f\u771f\u7684",
);
});
type Harness = ImeCompositionCommitTarget & {
compositionstart: () => void;
compositionend: () => void;
keydown: (event: { keyCode: number }) => boolean;
sent: string[];
};
function createHarness(): Harness {
const sent: string[] = [];
const helper: Harness = {
_textarea: { value: "" },
_compositionView: { classList: { remove() {} } },
_isComposing: false,
_isSendingComposition: false,
_compositionPosition: { start: 0, end: 0 },
_compositionSuffix: "",
_dataAlreadySent: "",
_coreService: {
triggerDataEvent(data: string) {
sent.push(data);
},
},
sent,
compositionstart() {
this._isComposing = true;
this._compositionPosition = { start: 0, end: this._textarea.value.length };
this._compositionSuffix = "";
this._dataAlreadySent = "";
this._compositionView.classList.remove("active");
},
compositionend() {
this._finalizeComposition?.(true);
},
keydown(event) {
if (this._isComposing || this._isSendingComposition) {
if (event.keyCode === 229 || event.keyCode === 16) return false;
this._finalizeComposition?.(false);
}
if (event.keyCode === 229) {
this._handleAnyTextareaChanges?.();
return false;
}
return true;
},
};
return helper;
}
function install(helper: Harness): void {
keepLiveImeTranscriptionSingle({ _core: { _compositionHelper: helper } });
}
async function flushTimers(): Promise<void> {
await new Promise((resolve) => setTimeout(resolve, 0));
}
test("live transcription of one sentence is written once", async () => {
const helper = createHarness();
install(helper);
helper.keydown({ keyCode: 229 });
helper._textarea.value = "\u6211\u662f\u771f\u7684";
await flushTimers();
helper.keydown({ keyCode: 229 });
helper._textarea.value = "\u6211\u662f\u771f\u7684\u725b\u903c";
helper.compositionstart();
helper.compositionend();
await flushTimers();
await flushTimers();
assert.equal(helper.sent.join(""), "\u6211\u662f\u771f\u7684\u725b\u903c");
});
test("a composition range that contains only the new tail is not prefixed again", async () => {
const helper = createHarness();
install(helper);
helper.keydown({ keyCode: 229 });
helper._textarea.value = "ls \u6211\u662f\u771f\u7684";
await flushTimers();
helper.keydown({ keyCode: 229 });
helper._textarea.value = "ls \u6211\u662f\u771f\u7684\u725b\u903c";
helper.compositionstart();
helper._compositionPosition = { start: "ls \u6211\u662f\u771f\u7684".length, end: "ls \u6211\u662f\u771f\u7684\u725b\u903c".length };
helper.compositionend();
await flushTimers();
await flushTimers();
assert.equal(helper.sent.join(""), "ls \u6211\u662f\u771f\u7684\u725b\u903c");
});
test("a composition that only confirms the hypothesis does not append it again", async () => {
const helper = createHarness();
install(helper);
helper.keydown({ keyCode: 229 });
helper._textarea.value = "\u6211\u662f\u771f\u7684\u725b\u903c";
await flushTimers();
helper.keydown({ keyCode: 229 });
helper.compositionstart();
helper.compositionend();
await flushTimers();
await flushTimers();
assert.equal(helper.sent.join(""), "\u6211\u662f\u771f\u7684\u725b\u903c");
});
test("normal compositionend still sends the committed word once", async () => {
const helper = createHarness();
install(helper);
helper._textarea.value = "hello";
helper.keydown({ keyCode: 229 });
helper._textarea.value = "hello\u4f60";
helper.compositionstart();
helper._compositionPosition = { start: 5, end: 6 };
helper.compositionend();
await flushTimers();
assert.deepEqual(helper.sent, ["\u4f60"]);
});
test("punctuation entered through keyCode 229 sends only the new character", async () => {
const helper = createHarness();
install(helper);
helper._textarea.value = "ls ";
helper.keydown({ keyCode: 229 });
helper._textarea.value = "ls \uff0c";
await flushTimers();
assert.deepEqual(helper.sent, ["\uff0c"]);
});
test("keydown text already sent is not repeated on compositionend", async () => {
const helper = createHarness();
install(helper);
helper.keydown({ keyCode: 229 });
helper._textarea.value = "abc";
await flushTimers();
helper._compositionPosition = { start: 0, end: 4 };
helper._textarea.value = "abcd";
helper.compositionend();
await flushTimers();
assert.equal(helper.sent.join(""), "abcd");
});
test("a rewritten hypothesis is not sent again on markerless compositionend", async () => {
const helper = createHarness();
install(helper);
helper.keydown({ keyCode: 229 });
helper._textarea.value = "abcde";
await flushTimers();
helper.keydown({ keyCode: 229 });
helper._textarea.value = "abXde";
await flushTimers();
helper._compositionPosition = { start: 0, end: "abXde".length };
helper.compositionend();
await flushTimers();
assert.deepEqual(helper.sent, ["abcde", BACKSPACE, BACKSPACE, BACKSPACE, "Xde"]);
});
test("an equal-length rewrite does not resend the shared prefix", async () => {
const helper = createHarness();
install(helper);
helper.keydown({ keyCode: 229 });
helper._textarea.value = "abcd";
await flushTimers();
helper.sent.length = 0;
helper.keydown({ keyCode: 229 });
helper._textarea.value = "abXY";
await flushTimers();
assert.deepEqual(helper.sent, [BACKSPACE, BACKSPACE, "XY"]);
});
test("a revised composition replaces the hypothesis already on the PTY", async () => {
const helper = createHarness();
install(helper);
helper.keydown({ keyCode: 229 });
helper._textarea.value = "abcde";
await flushTimers();
helper.keydown({ keyCode: 229 });
helper._textarea.value = "abXde";
helper.compositionstart();
helper.compositionend();
await flushTimers();
await flushTimers();
assert.deepEqual(helper.sent, ["abcde", BACKSPACE, BACKSPACE, BACKSPACE, "Xde"]);
});
test("a one-character hypothesis is not repeated when composition extends it", async () => {
const helper = createHarness();
install(helper);
helper.keydown({ keyCode: 229 });
helper._textarea.value = "\u6211";
await flushTimers();
helper.keydown({ keyCode: 229 });
helper._textarea.value = "\u6211\u771f";
helper.compositionstart();
helper.compositionend();
await flushTimers();
await flushTimers();
assert.equal(helper.sent.join(""), "\u6211\u771f");
});
test("a new word that shares a prefix with a finished character is sent whole", async () => {
const helper = createHarness();
install(helper);
helper.keydown({ keyCode: 229 });
helper._textarea.value = "\u6211";
await flushTimers();
helper.keydown({ keyCode: 229 });
helper._textarea.value = "\u6211\u6211\u4eec";
helper.compositionstart();
helper._compositionPosition = { start: 1, end: "\u6211\u6211\u4eec".length };
helper.compositionend();
await flushTimers();
await flushTimers();
assert.equal(helper.sent.join(""), "\u6211\u6211\u4eec");
});
test("a paused live transcription still extends its previously sent hypothesis", async () => {
const helper = createHarness();
install(helper);
helper.keydown({ keyCode: 229 });
helper._textarea.value = "\u6211\u662f\u771f\u7684";
await flushTimers();
helper.compositionstart();
helper._textarea.value = "\u6211\u662f\u771f\u7684\u725b\u903c";
helper._compositionPosition = { start: 0, end: helper._textarea.value.length };
helper.compositionend();
await flushTimers();
assert.deepEqual(helper.sent, ["\u6211\u662f\u771f\u7684", "\u725b\u903c"]);
});
test("a later new word with the same prefix is sent whole", async () => {
const helper = createHarness();
install(helper);
helper.keydown({ keyCode: 229 });
helper._textarea.value = "\u6211";
await flushTimers();
helper.compositionstart();
helper._textarea.value = "\u6211\u6211\u4eec";
helper._compositionPosition = { start: 1, end: helper._textarea.value.length };
helper.compositionend();
await flushTimers();
assert.deepEqual(helper.sent, ["\u6211", "\u6211\u4eec"]);
});
test("a sent rewrite is not repeated when its composition later confirms", async () => {
const helper = createHarness();
install(helper);
helper.keydown({ keyCode: 229 });
helper._textarea.value = "ls abcde";
await flushTimers();
helper.keydown({ keyCode: 229 });
helper._textarea.value = "ls abXde";
await flushTimers();
helper.compositionstart();
helper._compositionPosition = { start: 3, end: helper._textarea.value.length };
helper.compositionend();
await flushTimers();
assert.deepEqual(helper.sent, ["ls abcde", BACKSPACE, BACKSPACE, BACKSPACE, "Xde"]);
});
test("an earlier rewrite is delivered when the next composition starts first", async () => {
const helper = createHarness();
install(helper);
helper.keydown({ keyCode: 229 });
helper._textarea.value = "abcde";
await flushTimers();
helper.compositionstart();
helper._compositionPosition = { start: 0, end: 5 };
helper._textarea.value = "abXde";
helper.compositionend();
helper.compositionstart();
helper._compositionPosition = { start: 5, end: 5 };
helper._textarea.value = "abXdeY";
helper._compositionPosition = { start: 5, end: 6 };
await flushTimers();
helper.compositionend();
await flushTimers();
assert.deepEqual(helper.sent, ["abcde", BACKSPACE, BACKSPACE, BACKSPACE, "Xde", "Y"]);
});
test("a punctuation commit does not make the next word repeat", async () => {
const helper = createHarness();
install(helper);
helper.keydown({ keyCode: 229 });
helper._textarea.value = "\uff0c";
await flushTimers();
helper.keydown({ keyCode: 229 });
helper._textarea.value = "\uff0c\u4f60";
await flushTimers();
helper._compositionPosition = { start: 1, end: 2 };
helper.compositionend();
await flushTimers();
assert.deepEqual(helper.sent, ["\uff0c", "\u4f60"]);
});
test("filtered formatting characters do not break final composition deduplication", async () => {
const helper = createHarness();
install(helper);
helper.keydown({ keyCode: 229 });
helper._textarea.value = "ab";
await flushTimers();
helper.keydown({ keyCode: 229 });
helper._textarea.value = "ab\u200b";
await flushTimers();
helper.keydown({ keyCode: 229 });
helper._textarea.value = "ab\u200bX";
await flushTimers();
helper._compositionPosition = { start: 0, end: helper._textarea.value.length };
helper.compositionend();
await flushTimers();
assert.deepEqual(helper.sent, ["ab", "X"]);
});
test("keepLiveImeTranscriptionSingle is a no-op without a composition helper", () => {
assert.doesNotThrow(() => keepLiveImeTranscriptionSingle({}));
const helper = createHarness();
install(helper);
install(helper);
assert.equal(helper.__ncImeCommitInstalled, true);
});
test("createXTermRuntime installs the guard after the composition helper exists", () => {
const source = readFileSync(
join(dirname(fileURLToPath(import.meta.url)), "createXTermRuntime.ts"),
"utf8",
);
const openAt = source.indexOf("term.open(ctx.container);");
const installAt = source.indexOf("keepLiveImeTranscriptionSingle(term);");
assert.ok(openAt >= 0);
assert.ok(installAt > openAt);
});
@@ -0,0 +1,342 @@
/**
* xterm.js commits an IME textarea change with `newValue.replace(oldValue, "")`
* and, on compositionend, skips `_dataAlreadySent.length` characters. Both
* steps assume the IME only appends. Live transcription rewrites the whole
* hypothesis, so the utterance is written as the
* interim, again as the full final string, and again as the tail (#3421).
*
* Append-only commits (punctuation, a normal compositionend) stay as they are.
* A composition that continues a multi-character hypothesis already sent to
* the PTY writes only the unsent suffix, and the in-flight textarea timer is
* dropped so it cannot append that suffix a second time.
*/
import { sanitizeTerminalInput } from "./terminalInputSanitize";
const BACKSPACE = "\x7f";
const graphemeSegmenter = new Intl.Segmenter(undefined, { granularity: "grapheme" });
function graphemes(value: string): string[] {
return Array.from(graphemeSegmenter.segment(value), (part) => part.segment);
}
export type ImeCompositionCommitTarget = {
_textarea: { value: string };
_compositionView: { classList: { remove(token: string): void } };
_isComposing: boolean;
_isSendingComposition: boolean;
_compositionPosition: { start: number; end: number };
_compositionSuffix: string;
_dataAlreadySent: string;
_textareaChangeTimer?: ReturnType<typeof setTimeout>;
__ncPendingPrevious?: string;
_coreService: { triggerDataEvent(data: string, wasUserInput?: boolean): void };
compositionstart: () => void;
_handleAnyTextareaChanges?: () => void;
_finalizeComposition?: (waitForPropagation: boolean) => void;
__ncImeCommitInstalled?: boolean;
__ncImeCommit?: ImeCompositionMarker;
};
export type ImeCompositionMarker = {
/** A keydown-229 textarea timer was still pending when composition started. */
continued: boolean;
/** Textarea value that timer snapshotted, already reflected on the PTY. */
pendingPrevious: string;
alreadySent: string;
textareaAtStart: string;
};
/**
* PTY edit for one textarea rewrite.
* A pure append sends the new suffix. A pure shrink sends one backspace per
* deleted character, except a cleared textarea, which stays a single
* backspace. xterm empties the textarea when a line is submitted, and that
* reset is not a dictation rewrite. Any other rewrite deletes only the
* diverging span and inserts its replacement, instead of `String.replace`
* or the whole new value.
*/
export function commitTextareaChange(previous: string, next: string): string {
previous = sanitizeTerminalInput(previous);
next = sanitizeTerminalInput(next);
if (previous === next) return "";
if (next.startsWith(previous)) return next.slice(previous.length);
if (previous.startsWith(next)) {
if (next.length === 0) return BACKSPACE;
return BACKSPACE.repeat(graphemes(previous.slice(next.length)).length);
}
return rewriteDivergingSpan(previous, next);
}
export function rememberTextareaCommit(
alreadySent: string,
previous: string,
sent: string,
next?: string,
): string {
if (!sent) return alreadySent;
let index = 0;
while (sent.charCodeAt(index) === 0x7f) index += 1;
const inserted = sent.slice(index);
if (index > 0) {
// Backspaces edit the PTY in place. A later markerless compositionend
// only skips `_dataAlreadySent` when it is a prefix of the final text,
// so the whole revised value has to be remembered, not just the tail.
// A cleared textarea is xterm's line-submit reset, not that value.
if (typeof next === "string") return sanitizeTerminalInput(next);
const parts = graphemes(previous);
const kept = parts.slice(0, Math.max(0, parts.length - index)).join("");
return kept + inserted;
}
if (sanitizeTerminalInput(previous).endsWith(alreadySent)) return alreadySent + inserted;
return sent;
}
/**
* Prefix of `compositionText` already on the PTY. Set only when composition
* started while a 229 textarea timer was still pending, so this composition
* finishes the update that timer snapshotted, not the next word.
*
* The timer's snapshot is the text already written, including a one-character
* hypothesis. A later composition that does not begin with that snapshot
* (normal pinyin after a finished line) keeps every character.
*/
export function continuedCompositionPrefix(
marker: ImeCompositionMarker | undefined,
compositionText: string,
): string {
if (!marker?.continued) return "";
if (
marker.pendingPrevious.length > 0
&& compositionText.startsWith(marker.pendingPrevious)
) {
return marker.pendingPrevious;
}
if (
marker.alreadySent.length > 0
&& compositionText.startsWith(marker.alreadySent)
&& marker.textareaAtStart.endsWith(marker.alreadySent)
) {
return marker.alreadySent;
}
return "";
}
function rewriteDivergingSpan(previous: string, next: string): string {
const previousParts = graphemes(previous);
const nextParts = graphemes(next);
let start = 0;
const shared = Math.min(previousParts.length, nextParts.length);
while (start < shared && previousParts[start] === nextParts[start]) start += 1;
// The cursor stays after the last cell. A shared suffix is still part of
// the tail that must be deleted and typed again. Count graphemes so an
// emoji is one deletion, not the two UTF-16 units of a surrogate pair.
return BACKSPACE.repeat(previousParts.length - start) + nextParts.slice(start).join("");
}
/**
* What a continued composition should write. Null means this composition is a
* new word and the caller sends the slice unchanged.
*
* An extension of the snapshot sends only the new suffix. A revision sends
* the same edit the cancelled textarea timer would have sent, so `abcde`
* replaced by `abXde` does not get appended after the old hypothesis.
* A composition that starts at or after the snapshot is a new word. A
* finished character followed by a new word that begins with that same
* character must be sent whole.
*/
export function continuedCompositionData(
marker: ImeCompositionMarker | undefined,
compositionText: string,
textareaNow: string,
compositionStart: number,
followingComposition = false,
): string | null {
if (!marker || !compositionText) return null;
// A live hypothesis can settle before the next composition starts. In that
// case the timer has fired, but its sent text is still the textarea suffix.
const previous = marker.continued ? marker.pendingPrevious : marker.textareaAtStart;
if (!marker.continued && (
!marker.alreadySent || !sanitizeTerminalInput(previous).endsWith(marker.alreadySent)
)) {
return null;
}
if (!previous) return null;
if (compositionStart >= previous.length) return null;
if (!marker.continued && compositionText === previous.slice(compositionStart)) return "";
if (compositionText.startsWith(previous)) return compositionText.slice(previous.length);
if (
marker.alreadySent.length > 0
&& sanitizeTerminalInput(compositionText).startsWith(marker.alreadySent)
&& (
sanitizeTerminalInput(textareaNow).endsWith(marker.alreadySent)
|| sanitizeTerminalInput(marker.textareaAtStart).endsWith(marker.alreadySent)
)
) {
return sanitizeTerminalInput(compositionText).slice(marker.alreadySent.length);
}
if (textareaNow === compositionText || marker.textareaAtStart === compositionText) {
return commitTextareaChange(previous, compositionText);
}
if (
followingComposition
&& textareaNow.startsWith(compositionText, compositionStart)
&& textareaNow.length > compositionStart + compositionText.length
) {
return commitTextareaChange(previous.slice(compositionStart), compositionText);
}
if (textareaNow.endsWith(compositionText) && textareaNow.length > compositionText.length) {
const headLength = textareaNow.length - compositionText.length;
if (
previous.length >= headLength
&& textareaNow.startsWith(previous.slice(0, headLength))
) {
return commitTextareaChange(previous.slice(headLength), compositionText);
}
}
return null;
}
function emitPtyData(helper: ImeCompositionCommitTarget, data: string): void {
// One event per backspace so mapTerminalBackspaceInput and the command
// buffer see a standalone DEL or Ctrl-H, then the replacement text.
let index = 0;
while (data.charCodeAt(index) === 0x7f) {
helper._coreService.triggerDataEvent(BACKSPACE, true);
index += 1;
}
const inserted = data.slice(index);
if (inserted.length > 0) helper._coreService.triggerDataEvent(inserted, true);
}
function compositionSlice(
helper: ImeCompositionCommitTarget,
rangeStart: number,
suffix: string,
composingNow: boolean,
newCompositionStart: number,
): string {
const value = helper._textarea.value;
if (composingNow) return value.substring(rangeStart, newCompositionStart);
const valueEnd = suffix.length > 0 && value.endsWith(suffix)
? value.length - suffix.length
: value.length;
return value.substring(rangeStart, Math.max(rangeStart, valueEnd));
}
function deliverComposition(
helper: ImeCompositionCommitTarget,
compositionText: string,
marker: ImeCompositionMarker | undefined,
compositionStart: number,
followingComposition = false,
): void {
const continued = continuedCompositionData(
marker,
compositionText,
helper._textarea.value,
compositionStart,
followingComposition,
);
if (continued !== null) {
if (continued.length > 0) emitPtyData(helper, continued);
if (!helper._isComposing) helper._dataAlreadySent = "";
return;
}
const cleanText = sanitizeTerminalInput(compositionText);
const live = helper._dataAlreadySent && cleanText.startsWith(helper._dataAlreadySent)
? helper._dataAlreadySent
: "";
const data = live
? cleanText.slice(live.length)
: helper._dataAlreadySent.endsWith(cleanText) ? "" : cleanText;
if (data.length > 0) emitPtyData(helper, data);
if (!helper._isComposing) helper._dataAlreadySent = "";
}
/**
* Installs the commit guard on one terminal. CompositionHelper is created in
* `terminal.open()`, so this runs after open. Missing helpers (tests, log
* view) are left untouched.
*/
export function keepLiveImeTranscriptionSingle(term: object): void {
const helper = (term as { _core?: { _compositionHelper?: ImeCompositionCommitTarget } })
._core?._compositionHelper;
if (!helper || helper.__ncImeCommitInstalled || typeof helper.compositionstart !== "function") {
return;
}
helper.__ncImeCommitInstalled = true;
const originalStart = helper.compositionstart.bind(helper);
helper.compositionstart = () => {
const alreadySent = helper._dataAlreadySent;
const pendingPrevious = helper.__ncPendingPrevious ?? "";
const continued = helper._textareaChangeTimer !== undefined;
if (helper._textareaChangeTimer !== undefined) {
clearTimeout(helper._textareaChangeTimer);
helper._textareaChangeTimer = undefined;
}
helper.__ncPendingPrevious = undefined;
originalStart();
helper.__ncImeCommit = {
continued,
pendingPrevious,
alreadySent,
textareaAtStart: helper._textarea.value,
};
};
helper._handleAnyTextareaChanges = () => {
if (helper._textareaChangeTimer !== undefined) return;
const previous = helper._textarea.value;
helper.__ncPendingPrevious = previous;
helper._textareaChangeTimer = setTimeout(() => {
helper._textareaChangeTimer = undefined;
helper.__ncPendingPrevious = undefined;
helper.__ncImeCommit = undefined;
if (helper._isComposing || helper._isSendingComposition) return;
const next = helper._textarea.value;
const data = commitTextareaChange(previous, next);
if (!data) return;
helper._dataAlreadySent = rememberTextareaCommit(helper._dataAlreadySent, previous, data, next);
emitPtyData(helper, data);
}, 0);
};
helper._finalizeComposition = (waitForPropagation: boolean) => {
const marker = helper.__ncImeCommit;
helper.__ncImeCommit = undefined;
helper._compositionView.classList.remove("active");
helper._isComposing = false;
if (!waitForPropagation) {
helper._isSendingComposition = false;
const compositionText = helper._textarea.value.substring(
helper._compositionPosition.start,
helper._compositionPosition.end,
);
deliverComposition(helper, compositionText, marker, helper._compositionPosition.start);
return;
}
const rangeStart = helper._compositionPosition.start;
const suffix = helper._compositionSuffix;
const alreadySent = helper._dataAlreadySent;
helper._isSendingComposition = true;
setTimeout(() => {
if (!helper._isSendingComposition) return;
helper._isSendingComposition = false;
const compositionText = compositionSlice(
helper,
rangeStart,
suffix,
helper._isComposing,
helper._compositionPosition.start,
);
// compositionstart may have cleared the live field. The no-composition
// path (#3191) still has the keydown commit in this snapshot.
if (!marker && alreadySent) helper._dataAlreadySent = alreadySent;
deliverComposition(helper, compositionText, marker, rangeStart, helper._isComposing && !suffix);
}, 0);
};
}
@@ -1163,3 +1163,139 @@ test("a dedicated keyIdentity pairs the interrupt press with its release (#3409)
assert.equal(defaultRelease?.data, "\x1b[97;1:3u");
assert.equal(defaultOptions.encodedKeys.has("KeyA"), false);
});
test("a bypassed password-prompt source tags Kitty broadcasts as sourceSensitive", () => {
const dispatched: Array<{
kittyKeyboardInput?: KittyKeyboardBroadcastInput;
sourceSensitive?: boolean;
}> = [];
const forward = createKittyKeyboardBroadcastForwarder({
sourceSessionId: "source",
isHandlingBroadcast: () => false,
isBroadcastEnabled: () => true,
// #3488 bypass: the sensitive-prompt pause is lifted, but the source still
// tags the dispatch so peers keep input interceptors skipped.
isSensitiveInput: () => false,
isSensitivePromptSource: () => true,
getDispatcher: () => (_data, _sourceSessionId, dispatchOptions) => {
dispatched.push(dispatchOptions);
return ["target-a"];
},
});
const press: KittyKeyboardBroadcastInput = {
kind: "key",
event: { type: "keydown", key: "x", code: "KeyX" },
};
assert.deepEqual(forward(press), { targetSessionIds: ["target-a"] });
assert.deepEqual(dispatched, [
{ kittyKeyboardInput: press, sourceSensitive: true },
]);
});
test("a pre-write sensitivity snapshot tags the Kitty dispatch as sourceSensitive", () => {
const dispatched: Array<{
kittyKeyboardInput?: KittyKeyboardBroadcastInput;
sourceSensitive?: boolean;
}> = [];
const forward = createKittyKeyboardBroadcastForwarder({
sourceSessionId: "source",
isHandlingBroadcast: () => false,
isBroadcastEnabled: () => true,
isSensitiveInput: () => false,
// #3491: the source's local Enter submission already cleared the live
// prompt flag before this dispatch runs, so the live check reports
// nonsensitive and only the pre-write snapshot can restore the tag.
isSensitivePromptSource: () => false,
getDispatcher: () => (_data, _sourceSessionId, dispatchOptions) => {
dispatched.push(dispatchOptions);
return ["target-a"];
},
});
const enter: KittyKeyboardBroadcastInput = {
kind: "key",
event: { type: "keydown", key: "Enter", code: "Enter" },
};
// Snapshot taken before the local write says the source sat at a prompt.
assert.deepEqual(
forward(enter, false, undefined, { sourceSensitive: true }),
{ targetSessionIds: ["target-a"] },
);
// Without a snapshot the dispatch stays nonsensitive (live flag is false).
forward(enter);
assert.deepEqual(dispatched, [
{ kittyKeyboardInput: enter, sourceSensitive: true },
{ kittyKeyboardInput: enter },
]);
});
test("a source-sensitive Kitty dispatch forces sensitive peer writes", () => {
const mode = createKittyKeyboardModeState();
const activeWrites: Array<{ data: string; sensitive?: boolean }> = [];
const handler = createKittyKeyboardBroadcastHandler({
resolveOptions: () => ({
kittyProtocolEnabled: false,
kittyMode: mode,
applicationCursorMode: false,
encodedKeys: new Set<string>(),
}),
getSessionId: () => "peer-session",
// The peer has not classified its own prompt as sensitive.
isSensitiveInput: () => false,
isConnected: () => true,
isRuntimeDisposed: () => false,
writeDisposed: () => {},
writeActive: (data, _logicalData, writeOptions) => {
activeWrites.push({ data, sensitive: writeOptions?.sensitive === true });
},
});
handler({ kind: "text", text: "secret" });
assert.deepEqual(activeWrites, [{ data: "secret", sensitive: false }]);
handler({ kind: "text", text: "secret" }, { sourceSensitive: true });
assert.deepEqual(activeWrites, [
{ data: "secret", sensitive: false },
{ data: "secret", sensitive: true },
]);
});
test("a source-sensitive dispatch forces sensitive Win32 peer writes", () => {
const encodedKeys = new Set<string>();
const win32Writes: Array<{ type?: string; sensitive?: boolean }> = [];
const handler = createKittyKeyboardBroadcastHandler({
resolveOptions: () => ({
kittyProtocolEnabled: false,
kittyMode: createKittyKeyboardModeState(),
applicationCursorMode: false,
encodedKeys,
win32InputMode: true,
}),
getSessionId: () => "win32-peer",
// The peer has not classified its own prompt as sensitive.
isSensitiveInput: () => false,
isConnected: () => true,
isRuntimeDisposed: () => false,
writeDisposed: () => {},
writeActive: () => {},
writeWin32Event: (event, _logicalData, writeOptions) => {
win32Writes.push({
type: event.type,
sensitive: writeOptions?.sensitive === true,
});
},
});
handler({ kind: "key", event: { type: "keydown", key: "Enter", code: "Enter", shiftKey: true } });
assert.deepEqual(win32Writes, [{ type: "keydown", sensitive: false }]);
handler(
{ kind: "key", event: { type: "keydown", key: "Enter", code: "Enter", shiftKey: true } },
{ sourceSensitive: true },
);
assert.deepEqual(win32Writes, [
{ type: "keydown", sensitive: false },
{ type: "keydown", sensitive: true },
]);
});
@@ -39,6 +39,12 @@ export type KittyKeyboardBroadcastInput =
type KittyKeyboardBroadcastDispatchOptions = {
beforeUrgentInterrupt?: () => void;
/**
* The source session dispatched this input from an active password /
* sensitive prompt under the #3488 bypass. The receiving peer must keep the
* sensitive marker on its own writes so input interceptors stay skipped.
*/
sourceSensitive?: boolean;
};
type KittyKeyboardBroadcastHandler = (
@@ -60,12 +66,15 @@ export const createKittyKeyboardBroadcastForwarder = (options: {
isHandlingBroadcast: () => boolean;
isBroadcastEnabled: () => boolean;
isSensitiveInput?: () => boolean;
/** True while the source session sits at an active password / sensitive prompt. */
isSensitivePromptSource?: () => boolean;
getDispatcher: () => ((
data: string,
sourceSessionId: string,
options: {
kittyKeyboardInput: KittyKeyboardBroadcastInput;
kittyKeyboardTargetSessionIds?: string[];
sourceSensitive?: boolean;
},
) => string[] | void) | null | undefined;
}) => {
@@ -74,6 +83,13 @@ export const createKittyKeyboardBroadcastForwarder = (options: {
input: KittyKeyboardBroadcastInput,
forcePairedRelease = false,
targetSessionIds?: string[],
/**
* Source-prompt sensitivity snapshotted before the source's own local
* write (#3491): a submitting key such as Enter clears the live prompt
* flag before this dispatch runs, so the live check alone would report
* the payload as nonsensitive.
*/
dispatchOptions?: { sourceSensitive?: boolean },
): { targetSessionIds: string[] } | null => {
const currentDispatcher = options.getDispatcher();
if (currentDispatcher) lastDispatcher = currentDispatcher;
@@ -84,8 +100,15 @@ export const createKittyKeyboardBroadcastForwarder = (options: {
(!forcePairedRelease && !options.isBroadcastEnabled()) ||
!dispatcher
) return null;
// Bypassed password fan-out (#3488): tag the dispatch so peer writes keep
// input interceptors skipped for the secret keystrokes. A pre-write
// snapshot wins (#3491): the local submission may have already cleared
// the live prompt flag by the time this dispatch runs.
const sourceSensitive = dispatchOptions?.sourceSensitive === true
|| options.isSensitivePromptSource?.() === true;
const deliveredSessionIds = dispatcher("", options.sourceSessionId, {
kittyKeyboardInput: input,
...(sourceSensitive ? { sourceSensitive: true } : {}),
...(targetSessionIds ? { kittyKeyboardTargetSessionIds: targetSessionIds } : {}),
});
return { targetSessionIds: deliveredSessionIds ?? targetSessionIds ?? [] };
@@ -416,9 +439,17 @@ export const createKittyKeyboardBroadcastHandler = (options: {
isConnected: () => boolean;
isRuntimeDisposed: () => boolean;
interruptSession?: (sessionId: string) => void;
writeDisposed: (sessionId: string, data: string) => void;
writeActive: (data: string, logicalData?: string | null) => void;
writeWin32Event?: (event: KittyKeyboardEvent, logicalData: string | null) => void;
writeDisposed: (sessionId: string, data: string, writeOptions?: { sensitive?: boolean }) => void;
writeActive: (
data: string,
logicalData?: string | null,
writeOptions?: { sensitive?: boolean },
) => void;
writeWin32Event?: (
event: KittyKeyboardEvent,
logicalData: string | null,
writeOptions?: { sensitive?: boolean },
) => void;
}): KittyKeyboardBroadcastHandler => (input, dispatchOptions) => {
const sessionId = options.getSessionId();
if (!sessionId || !options.isConnected()) return;
@@ -433,20 +464,34 @@ export const createKittyKeyboardBroadcastHandler = (options: {
options.interruptSession(sessionId);
return;
}
// The source dispatched from a bypassed password prompt: keep the sensitive
// marker on the peer write even when this peer's own prompt is not (yet)
// classified sensitive, so input interceptors stay skipped for the secret.
// Computed before the Win32 branch so its write stays sensitive too.
const forceSensitiveWrite = dispatchOptions?.sourceSensitive === true;
if (resolved.win32Event) {
if (!options.isRuntimeDisposed()) {
options.writeWin32Event?.(
resolved.win32Event,
resolved.logicalData ?? null,
forceSensitiveWrite ? { sensitive: true } : undefined,
);
}
return;
}
if (options.isRuntimeDisposed()) {
options.writeDisposed(sessionId, resolved.data);
options.writeDisposed(
sessionId,
resolved.data,
forceSensitiveWrite ? { sensitive: true } : undefined,
);
return;
}
options.writeActive(resolved.data, resolved.logicalData);
options.writeActive(
resolved.data,
resolved.logicalData,
forceSensitiveWrite ? { sensitive: true } : undefined,
);
};
const handlers = new Map<string, KittyKeyboardBroadcastHandler>();
@@ -215,11 +215,11 @@ test("runtime routes Shift+Enter text through the shared input handler", () => {
);
assert.match(
source,
/const shiftEnterText = resolveShiftEnterText\([\s\S]*?if \(shiftEnterText\) \{[\s\S]*?handleTerminalInputData\(shiftEnterText, \{\s*source: "shift-enter",\s*skipBroadcast: true,\s*\}\);\s*const forwarded = broadcastKittyInput\(\{\s*kind: "key",\s*event: kittyEvent,\s*fallbackToLegacy: true,\s*\}\);/s,
/const shiftEnterText = resolveShiftEnterText\([\s\S]*?if \(shiftEnterText\) \{[\s\S]*?const sourceSensitivePrompt = ctx\.passwordPromptActiveRef\?\.current === true;[\s\S]*?handleTerminalInputData\(shiftEnterText, \{\s*source: "shift-enter",\s*skipBroadcast: true,\s*\}\);\s*const forwarded = broadcastKittyInput\(\{\s*kind: "key",\s*event: kittyEvent,\s*fallbackToLegacy: true,\s*\}, false, undefined, sourceSensitivePrompt \? \{ sourceSensitive: true \} : undefined\);/s,
);
assert.match(
source,
/const canBroadcastInput = !sensitive &&[\s\S]*?const willBroadcastInput = canBroadcastInput && options\?\.skipBroadcast !== true;[\s\S]*?if \(!canBroadcastInput && !handlingKittyBroadcast\) \{\s*prepareSudoAutofillInput/s,
/const canBroadcastInput = \(!sensitive\s*\|\|\s*\(ctx\.broadcastPasswordBypassRef\?\.current === true && options\?\.sensitive !== true\)\) &&[\s\S]*?const willBroadcastInput = canBroadcastInput && options\?\.skipBroadcast !== true;[\s\S]*?if \(!canBroadcastInput && !handlingKittyBroadcast\) \{\s*prepareSudoAutofillInput/s,
);
assert.match(
source,
@@ -39,10 +39,10 @@ const callbackCode = ts.transpileModule(`
${textInputMarker}
${keyboardCallback}
${dataCallback}
globalThis.controls = { mark: markBroadcastLegacyDataPending, clear: clearBroadcastLegacyDataPending, input: markKittyTextInput };
globalThis.controls = { mark: markBroadcastLegacyDataPending, clear: clearBroadcastLegacyDataPending, input: markKittyTextInput, composition: markKittyCompositionPending };
`, { compilerOptions: { target: ts.ScriptTarget.ES2022 } }).outputText;
function setup(flags = 0) {
function setup(flags = 0, sourceFlags = 0) {
const writes: string[] = [];
const timers = new Map<number, () => void>();
let timerId = 0;
@@ -51,6 +51,8 @@ function setup(flags = 0) {
const forwarded = new Map<string, { targetSessionIds: string[] }>();
const mode = createKittyKeyboardModeState();
setKittyKeyboardModeFlags(mode, flags);
const sourceMode = createKittyKeyboardModeState();
setKittyKeyboardModeFlags(sourceMode, sourceFlags);
const options = {
kittyProtocolEnabled: flags !== 0, kittyMode: mode, applicationCursorMode: false,
encodedKeys: new Set<string>(), legacySuppressedKeys: new Set<string>(),
@@ -60,7 +62,7 @@ function setup(flags = 0) {
if (result) writes.push(result.data);
};
const context = {
controls: undefined as unknown as { mark: (identity: string) => void; clear: () => void; input: (event: { data: string; inputType: string }) => void },
controls: undefined as unknown as { mark: (identity: string) => void; clear: () => void; input: (event: { data: string; inputType: string }) => void; composition: () => void },
window: {
setTimeout(fn: () => void) { const id = ++timerId; timers.set(id, fn); return id; },
clearTimeout(id: number) { timers.delete(id); },
@@ -73,7 +75,7 @@ function setup(flags = 0) {
ctx: { terminalSettingsRef: { current: {} }, isBroadcastEnabledRef: { current: true }, onBroadcastInputRef: { current: () => undefined } },
imeTextInputDeferredKey: null, imeTextInputDeferredKittyEvent: null,
shouldBlockKeyPressForImeTextInput, shouldCommitDeferredImeTextInput, shouldMarkKittyTextInputEvent, shouldEncodeKittyCompositionText, encodeKittyCompositionText,
kittyKeyboardMode: createKittyKeyboardModeState(), shouldSplitImeTextInputForWire: () => false,
kittyKeyboardMode: sourceMode, shouldSplitImeTextInputForWire: () => false,
kittyKeyIdentity: (event: Partial<KeyboardEvent>) => event.code || event.key,
broadcastForwardedKeys: forwarded,
broadcastKittyInput: normalized,
@@ -99,6 +101,16 @@ function setup(flags = 0) {
};
}
test("IME rewrite backspaces leave Kitty composition routing for replacement text", () => {
const runtime = setup(0, 24);
runtime.controls.composition();
runtime.receive("\x7f");
runtime.receive("Xde");
assert.equal(runtime.writes[0], "\x7f");
assert.ok(runtime.writes.includes("\x1b[0;;88:100:101u"));
assert.ok(!runtime.writes.includes("\x1b[0;;127u"));
});
for (const flags of [0, 8]) {
for (const [key, code] of [["A", "KeyA"], [" ", "Space"]]) {
test(`broadcast pairs delayed ${code} keypress once with target flags ${flags}`, () => {
@@ -4,7 +4,7 @@ import {
hostRestrictsExtraSshChannels,
normalizeDistroId,
} from "../../../domain/host";
import { remoteSoftwareRequiresSingleChannel } from "../../../domain/singleChannelSshBanner.shared.cjs";
import { remoteSoftwareRequiresSingleChannel } from "../../../domain/singleChannelSshBanner.shared.mjs";
import { logger } from "../../../lib/logger";
import type { TerminalSessionStartersContext } from "./createTerminalSessionStarters.types";
@@ -34,7 +34,7 @@ test("password-prompt input is classified before prompt state reset and cannot b
);
assert.match(
runtimeSource,
/const sensitive = ctx\.passwordPromptActiveRef\?\.current === true[\s\S]*?const canBroadcastInput = !sensitive &&[\s\S]*?const willBroadcastInput = canBroadcastInput && options\?\.skipBroadcast !== true;/u,
/const sensitive = ctx\.passwordPromptActiveRef\?\.current === true[\s\S]*?const canBroadcastInput = \(!sensitive\s*\|\|\s*\(ctx\.broadcastPasswordBypassRef\?\.current === true && options\?\.sensitive !== true\)\) &&[\s\S]*?const willBroadcastInput = canBroadcastInput && options\?\.skipBroadcast !== true;/u,
);
assert.match(
runtimeSource,
@@ -46,11 +46,11 @@ test("password-prompt input is classified before prompt state reset and cannot b
);
assert.match(
runtimeSource,
/const broadcastUserPasteData = \(\s*data: string,\s*options\?: TerminalBroadcastInputOptions,\s*\) => \{[\s\S]*?passwordPromptActiveRef\?\.current !== true[\s\S]*?onBroadcastInputRef\.current\(\s*data,\s*ctx\.sessionId,\s*options\)/u,
/const broadcastUserPasteData = \(\s*data: string,\s*options\?: TerminalBroadcastInputOptions,\s*\) => \{[\s\S]*?const dispatchingFromPasswordPrompt = ctx\.passwordPromptActiveRef\?\.current === true;[\s\S]*?const sourceSensitive = dispatchingFromPasswordPrompt \|\| options\?\.sensitive === true;[\s\S]*?\|\| ctx\.broadcastPasswordBypassRef\?\.current === true\)[\s\S]*?onBroadcastInputRef\.current\(\s*data,\s*ctx\.sessionId,\s*sourceSensitive[\s\S]*?sourceSensitive: true/u,
);
assert.match(
terminalSource,
/const sensitive = passwordPromptActiveRef\.current;[\s\S]*?!sensitive && isBroadcastEnabledRef\.current[\s\S]*?writeToSession\(id, data, \{[\s\S]*?sensitive,/u,
/const sensitive = passwordPromptActiveRef\.current;[\s\S]*?shouldBroadcastDuringSensitivePrompt\(\{[\s\S]*?isBroadcastEnabledRef\.current[\s\S]*?writeToSession\(id, data, \{[\s\S]*?sensitive,/u,
);
});
@@ -128,6 +128,6 @@ test("active and hibernated output share host-owned sensitive prompt classificat
test("ordinary broadcast skips targets that are waiting for sensitive input", () => {
assert.match(
terminalLayerSource,
/if \(isTerminalSensitiveInputActive\(session\.id\)\) continue;[\s\S]*?writeToSession\(session\.id, data/u,
/if \(!passwordBypass && isTerminalSensitiveInputActive\(session\.id\)\) continue;[\s\S]*?writeToSession\(session\.id, data, \{[\s\S]*?sensitive: options\?\.sourceSensitive === true \|\| isTerminalSensitiveInputActive\(session\.id\),/u,
);
});
@@ -295,3 +295,80 @@ for (const [protocol, lineMode, sensitive] of [
});
}
}
test("bypassed sensitive line paste keeps the paced fan-out with sourceSensitive tagging (#3488)", async (t) => {
t.mock.timers.enable({ apis: ["setTimeout"] });
const wire: string[] = [];
const broadcast: Array<{ data: string; options?: { sourceSensitive?: boolean } }> = [];
const writes: Array<{ data: string; sensitive?: boolean; lineDelayMs?: number; automated?: boolean }> = [];
let receiptListener: ((event: unknown) => void) | undefined;
const session = { socket: { write: (data: string) => wire.push(String(data)) } };
bridge.init({
sessions: new Map([["serial-1", session]]),
electronModule: { webContents: { fromId: () => ({ send(channel: string, event: unknown) {
if (channel === "netcatty:paste-write") receiptListener?.(event);
} }) } },
});
const ctx = {
host: { protocol: "telnet", id: "h", label: "h" }, sessionId: "tab-1",
sessionRef: { current: "serial-1" }, statusRef: { current: "connected" },
commandBufferRef: { current: "" }, serialLineBufferRef: { current: "" },
serialLineMode: false, serialLocalEcho: false, telnetLocalEchoRef: { current: false },
passwordPromptActiveRef: { current: true },
broadcastPasswordBypassRef: { current: true },
onCommandSubmitted: () => {}, onCommandExecuted: () => {},
onAutocompleteInput: () => {},
onOutputTriggerUserInputRef: { current: () => {} },
scriptRecorderRef: { current: { isRecording: false } },
isBroadcastEnabledRef: { current: true },
onBroadcastInputRef: { current: (data: string, _id: string, options?: { sourceSensitive?: boolean; preparePacedBroadcast?: boolean }) => {
if (!options?.preparePacedBroadcast) broadcast.push({ data, options });
} },
terminalBackend: {
notifyUserInput() {},
interruptSession(_id: string, _trace?: unknown, options?: { cancelPendingWritesOnly?: boolean }) {
bridge.interruptSession({}, { sessionId: "serial-1", ...options });
},
writeToSession(sessionId: string, data: string, options?: { sensitive?: boolean; lineDelayMs?: number; automated?: boolean }) {
writes.push({ data, ...options });
bridge.writeToSession({}, { sessionId, data, ...options });
},
},
};
const term = {
paste: () => assert.fail("paced paste must not gain bracketed-paste markers"),
scrollToBottom() {}, cols: 80,
buffer: { active: { cursorX: 0, cursorY: 0, baseY: 0, getLine: () => undefined } },
};
const env = {
...Object.assign({}, ...helpers), ...userPaste, ctx, term, crypto, isPluginHostProtocol: () => false, logger: { warn() {} },
netcattyBridge: { get: () => ({ onTerminalPasteWrite: (listener: typeof receiptListener) => { receiptListener = listener; return () => { receiptListener = undefined; }; } }) },
suppressNextTerminalDataBroadcast: false, handlingKittyBroadcast: false,
prioritizeTerminalInput() {}, getFlowControllerForTerm: () => null,
scrollToBottomAfterInput() {}, writeLocalTerminalData: () => {},
api: undefined as unknown as { urgent: () => void; input: (data: string) => void; dispose: () => void },
};
vm.runInNewContext(code, env);
t.after(() => env.api.dispose());
await pasteTextWithMultilineConfirm("version\nshow clock", {
term, sessionId: "serial-1", terminalBackend: ctx.terminalBackend,
getCurrentSessionId: () => ctx.sessionRef.current,
isSensitiveInput: () => ctx.passwordPromptActiveRef.current,
broadcastPasswordBypass: () => ctx.broadcastPasswordBypassRef?.current === true,
confirmMultilinePaste: {
enabled: true, minLines: 2,
requestConfirm: async () => ({ action: "line-by-line" }),
},
onPasteData: () => assert.fail("paced broadcast must use actual write receipts"),
});
assert.equal(writes[0]?.sensitive, true);
assert.equal(writes[0]?.automated, false);
assert.equal(writes[0]?.lineDelayMs, 250);
t.mock.timers.tick(1000);
await new Promise(resolve => setImmediate(resolve));
// The bypassed fan-out stays alive at the sensitive prompt, and each line
// keeps the source-sensitive marker so peer writes skip interceptors.
assert.deepEqual(broadcast.map((entry) => entry.data), ["version\r", "show clock\r"]);
for (const entry of broadcast) assert.equal(entry.options?.sourceSensitive, true);
env.api.dispose();
});
@@ -44,7 +44,7 @@ for (const path of ["source", "broadcast"] as const) {
terminalBackend, isPluginHostProtocol: () => true,
useCallback: (fn: unknown) => fn, resolveTerminalBroadcastTargetIds: () => ["s"],
sessionsRef: { current: [{ id: "s", protocol: "plugin:test" }] }, isGlobalBroadcastEnabled: true,
canUseDirectSessionWriteFallback: () => true, broadcastInterruptPrioritizersRef: { current: new Map() },
canUseDirectSessionWriteFallback: () => true, broadcastPasswordBypassRef: { current: false }, broadcastInterruptPrioritizersRef: { current: new Map() },
isTerminalSensitiveInputActive: () => false,
invoke: undefined as unknown as (data: string, source: string) => void,
};
@@ -69,7 +69,7 @@ test("ordinary broadcast typing supersedes the recipient's paced paste", t => {
terminalBackend: { writeToSession: (sessionId: string, data: string, options: object) => bridge.writeToSession(null, { sessionId, data, ...options }) },
useCallback: (fn: unknown) => fn, resolveTerminalBroadcastTargetIds: () => ["s"],
sessionsRef: { current: [{ id: "s", protocol: "ssh" }] }, isGlobalBroadcastEnabled: true,
canUseDirectSessionWriteFallback: () => true, isTerminalSensitiveInputActive: () => false,
canUseDirectSessionWriteFallback: () => true, broadcastPasswordBypassRef: { current: false }, isTerminalSensitiveInputActive: () => false,
invoke: undefined as unknown as (data: string, source: string, options?: object) => void,
};
vm.runInNewContext(compile(layer.slice(broadcastStart, broadcastEnd) + "\nglobalThis.invoke = handleBroadcastInput;"), env);
@@ -103,7 +103,7 @@ for (const change of ["source-sensitive", "peer-sensitive", "peer-typing", "peer
const env = {
...pacedHelpers, useCallback: (fn: unknown) => fn, resolveTerminalBroadcastTargetIds,
sessionsRef: { current: [{ id: "source", protocol: "ssh" }, { id: "peer", protocol: "ssh" }] as Array<{ id: string; protocol: string; workspaceId?: string }> },
isGlobalBroadcastEnabled: true, canUseDirectSessionWriteFallback: () => true,
isGlobalBroadcastEnabled: true, canUseDirectSessionWriteFallback: () => true, broadcastPasswordBypassRef: { current: false },
isTerminalSensitiveInputActive: (id: string) => sensitive.has(id),
isPluginHostProtocol: () => false, broadcastInterruptPrioritizersRef: { current: new Map() },
terminalBackend: {
@@ -11,7 +11,7 @@ type PasteTarget = Pick<XTerm, "paste" | "scrollToBottom"> &
type PasteOptions = {
scrollOnPaste?: boolean;
requestAnimationFrame?: (callback: () => void) => unknown;
onPasteData?: (data: string) => boolean | void;
onPasteData?: (data: string, options?: { sensitive?: boolean }) => boolean | void;
/**
* Pre-computed sensitivity for this paste. When true, the terminal input
* handler must treat the pasted bytes as sensitive even if its live
@@ -342,7 +342,13 @@ export function pasteTextIntoTerminal(
if (options.onPasteData) {
const pasteData = getPasteInputData(term, text);
const didBroadcast = options.onPasteData(pasteData) === true;
// Forward the pre-dialog sensitivity snapshot (#3491): the broadcast
// callback re-reads the live password-prompt ref, which the confirm
// dialog await may have cleared, so the saved classification must ride
// along to keep the peer fan-out tagged sourceSensitive.
const didBroadcast = options.onPasteData(pasteData, {
sensitive: options.sensitive === true,
}) === true;
if (didBroadcast) {
pasteBroadcastStates.set(term, {
expiresAt: getNow() + PASTE_INPUT_SCROLL_WINDOW_MS,
@@ -831,3 +831,119 @@ for (const [preview, expected] of [
assert.deepEqual(broadcasts, [], "without a runtime receipt owner, never broadcast an unchecked batch");
});
}
test("bypassed paste confirmed at a sensitive prompt still fans out but keeps its sensitive marker (#3488)", async () => {
const { shouldOverrideTerminalUserPasteSensitivity } = await import("./runtime/terminalUserPaste");
const pasted: string[] = [];
const broadcasted: string[] = [];
const term = {
paste(data: string) {
// xterm normalizes pasted line endings before delivering onData.
assert.equal(shouldOverrideTerminalUserPasteSensitivity(term, data.replace(/\n/g, "\r")), true);
pasted.push(data);
},
scrollToBottom() {},
};
await handleTerminalClipboardPaste({
isLocalConnection: false,
sessionId: "session-1",
term,
isSensitiveInput: () => true,
broadcastPasswordBypass: () => true,
confirmMultilinePaste: {
enabled: true,
minLines: 2,
requestConfirm: async () => ({ action: "send" }),
},
readClipboardText: async () => "secret\nsecond secret",
onPasteData: (data) => {
broadcasted.push(data);
return true;
},
terminalBackend: {
writeToSession: () => assert.fail("normal send should use xterm paste handling"),
},
});
assert.deepEqual(pasted, ["secret\nsecond secret"]);
assert.deepEqual(broadcasted, ["secret\rsecond secret"]);
});
test("bypassed line-by-line paste keeps the paced broadcast flag with the sensitive marker (#3488)", async () => {
const { registerTerminalLinePasteHandler } = await import("./runtime/terminalUserPaste");
const lineOptions: Array<{ lineDelayMs: number; sensitive: boolean; broadcast?: boolean }> = [];
const writes: Array<{ data: string; options?: { sensitive?: boolean; lineDelayMs?: number } }> = [];
const term = {
paste: () => assert.fail("line-by-line must use delayed writes"),
scrollToBottom() {},
};
const dispose = registerTerminalLinePasteHandler(term, (_data, options) => lineOptions.push(options));
try {
await handleTerminalClipboardPaste({
isLocalConnection: false,
sessionId: "session-1",
term,
isSensitiveInput: () => true,
broadcastPasswordBypass: () => true,
confirmMultilinePaste: {
enabled: true,
minLines: 2,
requestConfirm: async () => ({ action: "line-by-line", text: "conf t\nint gi0/0" }),
},
readClipboardText: async () => "conf t\nint gi0/0",
onPasteData: () => assert.fail("paced broadcast must use actual write receipts"),
terminalBackend: {
writeToSession: (_id, data, options) => writes.push({ data, options }),
},
});
assert.deepEqual(lineOptions, [{
lineDelayMs: 250,
sensitive: true,
broadcast: true,
}]);
assert.deepEqual(writes, []);
} finally {
dispose();
}
});
test("without the bypass, a paste confirmed at a sensitive prompt still broadcasts only to the source (#3488)", async () => {
const { registerTerminalLinePasteHandler } = await import("./runtime/terminalUserPaste");
const lineOptions: Array<{ lineDelayMs: number; sensitive: boolean; broadcast?: boolean }> = [];
const pasted: string[] = [];
const term = {
paste: (text: string) => pasted.push(text),
scrollToBottom() {},
};
const dispose = registerTerminalLinePasteHandler(term, (_data, options) => lineOptions.push(options));
try {
await handleTerminalClipboardPaste({
isLocalConnection: false,
sessionId: "session-1",
term,
isSensitiveInput: () => true,
confirmMultilinePaste: {
enabled: true,
minLines: 2,
requestConfirm: async () => ({ action: "line-by-line", text: "conf t\nint gi0/0" }),
},
readClipboardText: async () => "conf t\nint gi0/0",
onPasteData: () => assert.fail("unbypassed sensitive paste must not fan out"),
terminalBackend: {
writeToSession: (_id, data, options) => {
// The registered line-paste handler owns the paced path in this
// test, so the backend fallback must not be used.
void data;
void options;
},
},
});
assert.deepEqual(lineOptions, [{
lineDelayMs: 250,
sensitive: true,
broadcast: false,
}]);
assert.deepEqual(pasted, []);
} finally {
dispose();
}
});
+24 -5
View File
@@ -74,8 +74,10 @@ type TerminalClipboardPasteOptions = {
getRemoteCwd?: () => Promise<string | null | undefined>;
isLocalConnection: boolean;
isSensitiveInput?: () => boolean;
/** Live #3488 bypass probe; forwarded to the multiline-confirm gate. */
broadcastPasswordBypass?: () => boolean;
onClipboardImageUploadResult?: (result: RemoteClipboardImageUploadResult) => void;
onPasteData?: (data: string, options?: { lineDelayMs?: number }) => boolean | void;
onPasteData?: (data: string, options?: { lineDelayMs?: number; sensitive?: boolean }) => boolean | void;
readClipboardText: () => Promise<string>;
scrollOnPaste?: boolean;
scrollToBottomAfterProgrammaticInput?: (data: string) => void;
@@ -96,7 +98,14 @@ type MultilineGatedPasteOptions = {
*/
getCurrentSessionId?: () => string | null | undefined;
isSensitiveInput?: () => boolean;
onPasteData?: (data: string, options?: { lineDelayMs?: number }) => boolean | void;
/**
* Live probe for the opt-in #3488 "broadcast without password protection"
* bypass. When true, a paste confirmed at a sensitive prompt still fans out
* to broadcast peers through onPasteData / the paced line broadcast, while
* the paste itself keeps its sensitive marker for the source write.
*/
broadcastPasswordBypass?: () => boolean;
onPasteData?: (data: string, options?: { lineDelayMs?: number; sensitive?: boolean }) => boolean | void;
scrollOnPaste?: boolean;
scrollToBottomAfterProgrammaticInput?: (data: string) => void;
sessionId: string | null | undefined;
@@ -118,6 +127,7 @@ export async function pasteTextWithMultilineConfirm(
confirmMultilinePaste,
getCurrentSessionId,
isSensitiveInput,
broadcastPasswordBypass,
onPasteData,
scrollOnPaste = false,
scrollToBottomAfterProgrammaticInput,
@@ -150,6 +160,11 @@ export async function pasteTextWithMultilineConfirm(
const currentSessionId = getCurrentSessionId ? getCurrentSessionId() : session;
if (!currentSessionId) return;
const confirmedSensitive = sensitive || isSensitiveInput?.() === true;
// The #3488 bypass keeps the broadcast fan-out alive for a paste
// confirmed at a sensitive prompt: onPasteData / the paced line
// broadcast re-check broadcast state and re-guard targets at dispatch
// time, and the sensitive marker below still governs the source write.
const broadcastBypassActive = broadcastPasswordBypass?.() === true;
if (decision.action === "line-by-line") {
// An explicitly emptied preview means "send nothing"; only a missing
// value falls back to the original clipboard text.
@@ -158,7 +173,7 @@ export async function pasteTextWithMultilineConfirm(
const lineOptions = {
lineDelayMs: AUTO_RUN_SNIPPET_LINE_DELAY_MS,
sensitive: confirmedSensitive,
broadcast: !confirmedSensitive && !!onPasteData,
broadcast: (!confirmedSensitive || broadcastBypassActive) && !!onPasteData,
};
if (!dispatchTerminalLinePaste(term, lineData, lineOptions)) {
terminalBackend.writeToSession(currentSessionId, lineData, {
@@ -176,8 +191,9 @@ export async function pasteTextWithMultilineConfirm(
pasteTextIntoTerminal(term, decision.text ?? text, {
scrollOnPaste,
// Same post-await race as above: never fan a sensitive paste out to
// broadcast peers via onPasteData.
onPasteData: confirmedSensitive ? undefined : onPasteData,
// broadcast peers via onPasteData — unless the #3488 bypass is active,
// in which case the dispatcher tags the payload and re-guards targets.
onPasteData: confirmedSensitive && !broadcastBypassActive ? undefined : onPasteData,
// Carry the pre-dialog sensitivity snapshot through the normal Send
// path too: term.paste's input handler recomputes `sensitive` from the
// live password-prompt ref, which the dialog await may have cleared.
@@ -200,6 +216,7 @@ export async function handleTerminalClipboardPaste({
getRemoteCwd,
isLocalConnection,
isSensitiveInput,
broadcastPasswordBypass,
onClipboardImageUploadResult,
onPasteData,
readClipboardText,
@@ -278,6 +295,7 @@ export async function handleTerminalClipboardPaste({
confirmMultilinePaste,
getCurrentSessionId,
isSensitiveInput,
broadcastPasswordBypass,
onPasteData,
scrollOnPaste,
scrollToBottomAfterProgrammaticInput,
@@ -315,6 +333,7 @@ export async function handleTerminalClipboardPaste({
confirmMultilinePaste,
getCurrentSessionId,
isSensitiveInput,
broadcastPasswordBypass,
onPasteData,
scrollOnPaste,
scrollToBottomAfterProgrammaticInput,
+20
View File
@@ -35,6 +35,26 @@ export interface TerminalBroadcastInputOptions {
lineDelayMs?: number;
kittyKeyboardInput?: KittyKeyboardBroadcastInput;
kittyKeyboardTargetSessionIds?: string[];
/**
* Source session captured the payload at a password / sensitive prompt (the
* #3488 bypass allowed the fan-out). Peer writes must retain that marker so
* input interceptors on the receiving side stay skipped.
*/
sourceSensitive?: boolean;
/**
* Paste payload classified sensitive before a confirm-dialog await (#3491):
* the callback resolves the live prompt state itself, which the dialog can
* have cleared, so the saved classification rides into the broadcast and
* the dispatcher tags the payload sourceSensitive.
*/
sensitive?: boolean;
/**
* Observer for the sessions that actually received the payload (#3491):
* the solo compose bar uses it to detect a fan-out delivered into a peer's
* sensitive prompt (its password/MFA input) and keep that send
* history-ineligible.
*/
onBroadcastDelivered?: (sessionIds: readonly string[]) => void;
}
export { resolveSessionTabTitle };
@@ -90,3 +90,24 @@ test("terminal context paste never broadcasts password-prompt input", () => {
assert.equal(didBroadcast, false);
});
test("bypassed context paste at a password prompt is marked sourceSensitive (#3488)", () => {
const broadcasted: Array<{ options?: { sourceSensitive?: boolean } }> = [];
const didBroadcast = broadcastTerminalPasteData("secret", {
sourceSessionId: "workspace-session-1",
sessionRef: { current: "session-1" },
isBroadcastEnabledRef: { current: true },
passwordPromptActiveRef: { current: true },
broadcastPasswordBypassRef: { current: true },
onBroadcastInputRef: {
current: (_data, _sourceSessionId, options) => {
broadcasted.push({ options });
},
},
}, { lineDelayMs: 250 });
assert.equal(didBroadcast, true);
assert.deepEqual(broadcasted, [
{ options: { lineDelayMs: 250, sourceSensitive: true } },
]);
});
+2 -1
View File
@@ -213,7 +213,7 @@ export function resolveSelectionOverlayPosition(term: any, container: HTMLElemen
}
export function useTerminalEffects(ctx: TerminalEffectsContext) {
const { CONNECTION_TIMEOUT, Error, XTERM_PERFORMANCE_CONFIG, applyUserCursorPreference, applyUserCursorWidthPreference, shouldApplyUserCursorPreference, auth, autocompleteCloseRef, autocompleteInputRef, autocompleteKeyEventRef, autocompleteRepositionRef, captureTerminalLogData, chainHosts, chainProgress, clearTerminalCwd, commandBufferRef, connectionLogBufferRef, containerRef, createPromptLineBreakState, createReplaySafeTerminalLogSanitizer, createXTermRuntime, deferTerminalResizeRef, disableTerminalFontZoomRef, effectiveFontSize, effectiveFontWeight, effectiveTheme, error, executeSnippetCommand, finalizeTerminalLogData, fitAddonRef, fontFamilyId, fontSize, fontWeightFixupDoneRef, forceCloseHibernatedSession, forceSyncRenderAfterResize, handleOsc52ReadRequest, handleTerminalDataCaptureOnce, hasConnectedRef, hasRuntimeRef, host, hotkeySchemeRef, hibernatedRef, identities, inWorkspace, isBootActiveRef, bootEpochRef, isBroadcastEnabledRef, isComposeBarOpen, isConnectionAwaitingUserInput, isConnectionPastTcpDial, isFocusMode, isFocused, isLocalConnection, isNetworkDevice, isResizing, isRestoringSelectionRef, isSearchOpen, isSerialConnection, isVisible, isVisibleRef, keyBindingsRef, keys, kittyKeyboardProtocolEnabledForSession, knownCwdRef, lastFittedSizeRef, lastToastedErrorRef, logger, mouseTrackingRef, needsHostKeyVerification, onBroadcastInputRef, onBroadcastInterruptPriorityChange, onCommandExecuted, onCommandSubmitted, onHotkeyActionRef, onOpenExternalError, onOutputTriggerUserInputRef, onPluginRuntimeCwdChange, onSnippetExecutorChange, onTerminalCwdChange, onTerminalTitleChange, onTerminalBell, onTerminalFontSizeChange, paneLayoutKey, passwordPromptActiveRef, pendingAuthRef, pendingOutputScrollRef, pluginDecorationRules, pluginTerminalLifecycle, pluginTerminalProviderRevision, isPluginTerminalProviderAvailable, requestPluginTerminalProviders, prepareRestoredReconnect, prepareInitialCwdIntent, prevIsResizingRef, promptLineBreakStateRef, resizeSession, resolveHostAuth, resolvedFontFamily, safeFit, scriptRecorderRef, searchAddonRef, serialConfig, serialLineBufferRef, serializeAddonRef, sessionId, sessionRef, sessionStarters, setError, setHasMouseTracking, setIsCancelling, setIsDisconnectedDialogDismissed, requestSearchFocus, setNeedsHostKeyVerification, setPendingHostKeyInfo, setPendingHostKeyRequestId, setProgressLogs, setProgressValue, setShowLogs, setStatus, setTimeLeft, shouldEnableNativeUserInputAutoScroll, shouldProbeSessionCwd, shouldStartTerminalBackend, attachExistingSession, attachAuthorization, attachHomeWebContentsIdRef, onSnippetShortkeyRef, snippetsRef, splitResizeActive, status, statusRef, sudoAutofillRef, t, teardown, telnetLocalEchoRef, termRef, terminalAltKeyOptions, terminalBackend, terminalContextActionsRef, terminalCwdTracker, terminalDataCapturedRef, terminalLogSanitizerRef, terminalOutputHistory, terminalSettings, terminalSettingsRef, terminalTitleRef, toHostKeyInfo, toast, updateStatus, useEffect, useLayoutEffect, xtermRuntimeRef, zmodem, zmodemToastedRef, restoreState, vaultInitialized } = ctx;
const { CONNECTION_TIMEOUT, Error, XTERM_PERFORMANCE_CONFIG, applyUserCursorPreference, applyUserCursorWidthPreference, shouldApplyUserCursorPreference, auth, autocompleteCloseRef, autocompleteInputRef, autocompleteKeyEventRef, autocompleteRepositionRef, captureTerminalLogData, chainHosts, chainProgress, clearTerminalCwd, commandBufferRef, connectionLogBufferRef, containerRef, createPromptLineBreakState, createReplaySafeTerminalLogSanitizer, createXTermRuntime, deferTerminalResizeRef, disableTerminalFontZoomRef, effectiveFontSize, effectiveFontWeight, effectiveTheme, error, executeSnippetCommand, finalizeTerminalLogData, fitAddonRef, fontFamilyId, fontSize, fontWeightFixupDoneRef, forceCloseHibernatedSession, forceSyncRenderAfterResize, handleOsc52ReadRequest, handleTerminalDataCaptureOnce, hasConnectedRef, hasRuntimeRef, host, hotkeySchemeRef, hibernatedRef, identities, inWorkspace, isBootActiveRef, bootEpochRef, isBroadcastEnabledRef, broadcastPasswordBypassRef, isComposeBarOpen, isConnectionAwaitingUserInput, isConnectionPastTcpDial, isFocusMode, isFocused, isLocalConnection, isNetworkDevice, isResizing, isRestoringSelectionRef, isSearchOpen, isSerialConnection, isVisible, isVisibleRef, keyBindingsRef, keys, kittyKeyboardProtocolEnabledForSession, knownCwdRef, lastFittedSizeRef, lastToastedErrorRef, logger, mouseTrackingRef, needsHostKeyVerification, onBroadcastInputRef, onBroadcastInterruptPriorityChange, onCommandExecuted, onCommandSubmitted, onHotkeyActionRef, onOpenExternalError, onOutputTriggerUserInputRef, onPluginRuntimeCwdChange, onSnippetExecutorChange, onTerminalCwdChange, onTerminalTitleChange, onTerminalBell, onTerminalFontSizeChange, paneLayoutKey, passwordPromptActiveRef, pendingAuthRef, pendingOutputScrollRef, pluginDecorationRules, pluginTerminalLifecycle, pluginTerminalProviderRevision, isPluginTerminalProviderAvailable, requestPluginTerminalProviders, prepareRestoredReconnect, prepareInitialCwdIntent, prevIsResizingRef, promptLineBreakStateRef, resizeSession, resolveHostAuth, resolvedFontFamily, safeFit, scriptRecorderRef, searchAddonRef, serialConfig, serialLineBufferRef, serializeAddonRef, sessionId, sessionRef, sessionStarters, setError, setHasMouseTracking, setIsCancelling, setIsDisconnectedDialogDismissed, requestSearchFocus, setNeedsHostKeyVerification, setPendingHostKeyInfo, setPendingHostKeyRequestId, setProgressLogs, setProgressValue, setShowLogs, setStatus, setTimeLeft, shouldEnableNativeUserInputAutoScroll, shouldProbeSessionCwd, shouldStartTerminalBackend, attachExistingSession, attachAuthorization, attachHomeWebContentsIdRef, onSnippetShortkeyRef, snippetsRef, splitResizeActive, status, statusRef, sudoAutofillRef, t, teardown, telnetLocalEchoRef, termRef, terminalAltKeyOptions, terminalBackend, terminalContextActionsRef, terminalCwdTracker, terminalDataCapturedRef, terminalLogSanitizerRef, terminalOutputHistory, terminalSettings, terminalSettingsRef, terminalTitleRef, toHostKeyInfo, toast, updateStatus, useEffect, useLayoutEffect, xtermRuntimeRef, zmodem, zmodemToastedRef, restoreState, vaultInitialized } = ctx;
const effectiveTerminalProtocol = resolveEffectiveTerminalProtocol(host);
const hibernateHiddenTabs = resolveTerminalHibernateEnabledForProtocol(
terminalSettings,
@@ -533,6 +533,7 @@ export function useTerminalEffects(ctx: TerminalEffectsContext) {
onOpenExternalError,
isBroadcastEnabledRef,
onBroadcastInputRef,
broadcastPasswordBypassRef,
snippetsRef,
onSnippetShortkeyRef,
sessionId,
@@ -113,6 +113,12 @@ export type SnippetExecutor = (
multiLineRunMode?: Snippet["multiLineRunMode"];
/** When false, do not steal keyboard focus (multi-tab fan-out). Default true. */
focus?: boolean;
/**
* Force sensitive classification on the target's own write (bypassed
* password fan-out, #3488): the peer has not necessarily classified its
* own prompt as sensitive, so it must keep input interceptors skipped.
*/
sensitive?: boolean;
},
/**
* Returns true when the command was written to the session. False means the
@@ -15,16 +15,27 @@ const code = ts.transpileModule(source.slice(start, end) + '\nglobalThis.send =
}).outputText;
type Status = 'connected' | 'connecting' | 'disconnected';
type Executor = () => boolean | Promise<boolean>;
function setup(statuses: Status[], broadcast = false, executors = new Map<string, Executor>(), sensitive = new Set<string>()) {
type Executor = (text: string, executeImmediately: boolean, options?: { broadcast?: boolean; sensitive?: boolean }) => boolean | Promise<boolean>;
function setup(
statuses: Status[],
broadcast = false,
executors = new Map<string, Executor>(),
sensitive = new Set<string>(),
broadcastPasswordBypass = false,
restoredDisconnected = new Set<string>(),
) {
const writes: string[] = [];
const context = {
send: undefined as unknown as (text: string) => Promise<boolean>,
useCallback: (callback: unknown) => callback,
activeWorkspaceRef: { current: { id: 'workspace', focusedSessionId: '0' } },
sessionsRef: { current: statuses.map((status, index) => ({ id: String(index), workspaceId: 'workspace', status })) },
sessionsRef: { current: statuses.map((status, index) => ({
id: String(index), workspaceId: 'workspace', status,
restoreState: restoredDisconnected.has(String(index)) ? 'restored-disconnected' : undefined,
})) },
isBroadcastEnabled: () => broadcast,
isTerminalSensitiveInputActive: (id: string) => sensitive.has(id),
broadcastPasswordBypassRef: { current: broadcastPasswordBypass },
snippetExecutorsRef: { current: executors },
canUseDirectSessionWriteFallback,
terminalBackend: { writeToSession: (id: string) => writes.push(id) },
@@ -53,7 +64,68 @@ test('a disconnected broadcast peer cannot undo another successful fallback', as
assert.equal(await setup(['connected', 'disconnected'], true).send('command'), true);
});
test('sensitive input is excluded for executor and fallback paths', async () => {
test('the password bypass delivers sensitive sends but keeps them out of compose history (#3488)', async () => {
let executorCalls = 0;
const executors = new Map<string, Executor>([['0', async () => {
executorCalls += 1;
return true;
}]]);
// The payload was delivered (the executor ran), but the send originates from
// a sensitive prompt, so the compose bar must not record it for ArrowUp.
assert.equal(
await setup(['connected'], true, executors, new Set(['0']), true).send('secret'),
false,
);
assert.equal(executorCalls, 1);
});
test('bypassed fan-out into a lagging sensitive peer keeps the whole send out of history', async () => {
// Focused session 0 is non-sensitive; lagging peer 1 sits at a password
// prompt. The bypass delivers the payload there, but that text is peer 1's
// password input, so the send must not be recallable via ArrowUp.
for (const executorOwner of ['0', '1'] as const) {
const executors = new Map<string, Executor>([[executorOwner, async () => true]]);
assert.equal(
await setup(['connected', 'connected'], true, executors, new Set(['1']), true).send('secret'),
false,
);
}
});
test('bypassed fan-out preserves a sensitive peer snapshot across executor wake', async () => {
const sensitive = new Set(['1']);
let sensitiveAtWrite: boolean | undefined;
const executors = new Map<string, Executor>([['1', async (_text, _executeImmediately, options) => {
await Promise.resolve();
sensitive.delete('1');
sensitiveAtWrite = options?.sensitive;
return true;
}]]);
assert.equal(await setup(['connected', 'connected'], true, executors, sensitive, true).send('secret'), false);
assert.equal(sensitiveAtWrite, true);
});
test('a bypassed sensitive peer that rejects delivery does not hide another successful send', async () => {
const executors = new Map<string, Executor>([
['0', async () => true],
['1', async () => false],
]);
assert.equal(await setup(['connected', 'connected'], true, executors, new Set(['1']), true).send('command'), true);
});
test('a bypassed sensitive peer with no write route does not hide another successful send', async () => {
const { send, writes } = setup(
['connected', 'disconnected'], true, new Map(), new Set(['1']), true, new Set(['1']),
);
assert.equal(await send('command'), true);
assert.deepEqual(writes, ['0']);
});
test('a connecting sensitive fallback keeps attempted input out of history', async () => {
assert.equal(await setup(['connected', 'connecting'], true, new Map(), new Set(['1']), true).send('secret'), false);
});
test('without the password bypass sensitive input is excluded for executor and fallback paths', async () => {
for (const broadcast of [false, true]) {
for (const executors of [new Map<string, Executor>(), new Map<string, Executor>([['0', async () => true]])]) {
assert.equal(await setup(['connected'], broadcast, executors, new Set(['0'])).send('secret'), false);
+12 -12
View File
@@ -31,20 +31,20 @@
<text x="85" y="85.0" fill="#e6edf3" font-size="16" font-family="Handlee, cursive" font-weight="bold" text-anchor="end">7K</text>
<text x="43" y="271.5" fill="#e6edf3" font-size="17" font-family="Handlee, cursive" font-weight="bold" transform="rotate(-90, 43, 271.5)">GitHub Stars</text>
<text x="90.0" y="488" fill="#e6edf3" font-size="16" font-family="Handlee, cursive" font-weight="bold" text-anchor="middle">Jan 2026</text>
<text x="163.3" y="488" fill="#e6edf3" font-size="16" font-family="Handlee, cursive" font-weight="bold" text-anchor="middle">Feb 2026</text>
<text x="234.1" y="488" fill="#e6edf3" font-size="16" font-family="Handlee, cursive" font-weight="bold" text-anchor="middle">Mar 2026</text>
<text x="312.5" y="488" fill="#e6edf3" font-size="16" font-family="Handlee, cursive" font-weight="bold" text-anchor="middle">Apr 2026</text>
<text x="388.3" y="488" fill="#e6edf3" font-size="16" font-family="Handlee, cursive" font-weight="bold" text-anchor="middle">May 2026</text>
<text x="466.7" y="488" fill="#e6edf3" font-size="16" font-family="Handlee, cursive" font-weight="bold" text-anchor="middle">Jun 2026</text>
<text x="542.5" y="488" fill="#e6edf3" font-size="16" font-family="Handlee, cursive" font-weight="bold" text-anchor="middle">Jul 2026</text>
<text x="620.9" y="488" fill="#e6edf3" font-size="16" font-family="Handlee, cursive" font-weight="bold" text-anchor="middle">Aug 2026</text>
<text x="699.3" y="488" fill="#e6edf3" font-size="16" font-family="Handlee, cursive" font-weight="bold" text-anchor="middle">Sep 2026</text>
<text x="161.4" y="488" fill="#e6edf3" font-size="16" font-family="Handlee, cursive" font-weight="bold" text-anchor="middle">Feb 2026</text>
<text x="306.8" y="488" fill="#e6edf3" font-size="16" font-family="Handlee, cursive" font-weight="bold" text-anchor="middle">Apr 2026</text>
<text x="380.7" y="488" fill="#e6edf3" font-size="16" font-family="Handlee, cursive" font-weight="bold" text-anchor="middle">May 2026</text>
<text x="457.1" y="488" fill="#e6edf3" font-size="16" font-family="Handlee, cursive" font-weight="bold" text-anchor="middle">Jun 2026</text>
<text x="531.0" y="488" fill="#e6edf3" font-size="16" font-family="Handlee, cursive" font-weight="bold" text-anchor="middle">Jul 2026</text>
<text x="607.3" y="488" fill="#e6edf3" font-size="16" font-family="Handlee, cursive" font-weight="bold" text-anchor="middle">Aug 2026</text>
<text x="683.7" y="488" fill="#e6edf3" font-size="16" font-family="Handlee, cursive" font-weight="bold" text-anchor="middle">Sep 2026</text>
<text x="757.6" y="488" fill="#e6edf3" font-size="16" font-family="Handlee, cursive" font-weight="bold" text-anchor="middle">Oct 2026</text>
<text x="50%" y="525" fill="#e6edf3" font-size="17" font-family="Handlee, cursive" font-weight="bold" text-anchor="middle">Date</text>
<line x1="90" y1="80" x2="90" y2="463" stroke="#30363d" stroke-width="2.5" filter="url(#xkcdify)"/>
<line x1="90" y1="463" x2="765" y2="463" stroke="#30363d" stroke-width="2.5" filter="url(#xkcdify)"/>
<path d="M 90.0,462.2 C 90.4,462.1 92.1,461.8 92.5,461.7 C 93.4,461.6 94.2,461.4 95.1,461.2 C 95.9,461.1 96.7,460.8 97.6,460.8 C 98.4,460.7 99.3,460.7 100.1,460.7 C 101.0,460.7 101.8,460.6 102.6,460.6 C 103.5,460.5 104.3,460.5 105.2,460.4 C 106.0,460.4 106.4,460.3 107.7,460.3 C 109.0,460.2 111.5,460.1 112.8,460.1 C 114.0,460.1 114.4,460.0 115.3,460.0 C 116.1,460.0 117.0,460.0 117.8,459.9 C 118.7,459.9 119.5,459.9 120.3,459.9 C 121.2,459.9 122.0,459.9 122.9,459.8 C 123.7,459.8 124.6,459.7 125.4,459.7 C 126.2,459.6 127.1,459.6 127.9,459.4 C 128.8,459.3 129.6,459.0 130.4,458.7 C 131.3,458.3 132.1,457.6 133.0,457.3 C 133.8,457.1 134.7,457.2 135.5,457.1 C 136.3,457.1 137.2,457.1 138.0,457.0 C 138.9,457.0 139.3,457.0 140.6,456.9 C 141.8,456.9 143.9,456.9 145.6,456.9 C 147.3,456.8 149.4,456.8 150.7,456.8 C 151.9,456.7 152.4,456.7 153.2,456.7 C 154.0,456.6 154.9,456.6 155.7,456.5 C 156.6,456.5 157.4,456.5 158.3,456.4 C 159.1,456.4 159.9,456.4 160.8,456.4 C 161.6,456.4 162.1,456.4 163.3,456.3 C 164.6,456.3 166.3,456.2 168.4,456.2 C 170.5,456.2 173.8,456.2 176.0,456.2 C 178.1,456.1 178.5,456.2 181.0,456.1 C 183.5,456.1 189.0,455.9 191.1,455.9 C 193.2,455.8 192.4,455.9 193.7,455.8 C 194.9,455.8 196.6,455.8 198.7,455.8 C 200.8,455.8 202.1,455.7 206.3,455.7 C 210.5,455.6 220.6,455.5 224.0,455.5 C 227.4,455.5 225.7,455.5 226.5,455.4 C 227.4,455.4 227.8,456.1 229.0,455.4 C 230.3,454.7 232.8,454.1 234.1,451.2 C 235.4,448.4 235.8,440.7 236.6,438.3 C 237.5,435.9 238.3,437.2 239.2,436.8 C 240.0,436.5 240.8,436.2 241.7,436.0 C 242.5,435.7 243.4,435.5 244.2,435.4 C 245.1,435.2 245.9,435.2 246.7,435.0 C 247.6,434.9 248.4,434.8 249.3,434.7 C 250.1,434.6 251.0,434.7 251.8,434.7 C 252.6,434.6 253.5,434.4 254.3,434.3 C 255.2,434.2 256.0,434.2 256.9,434.1 C 257.7,434.0 258.5,433.6 259.4,433.5 C 260.2,433.4 261.1,433.4 261.9,433.3 C 262.8,433.3 263.2,433.1 264.4,433.0 C 265.7,432.9 268.2,432.9 269.5,432.8 C 270.8,432.7 271.2,432.7 272.0,432.6 C 272.9,432.6 273.7,432.6 274.6,432.5 C 275.4,432.4 276.2,432.1 277.1,432.0 C 277.9,431.8 278.8,432.5 279.6,431.6 C 280.4,430.7 281.3,427.5 282.1,426.5 C 283.0,425.4 283.8,425.5 284.7,425.4 C 285.5,425.2 286.3,425.4 287.2,425.3 C 288.0,425.2 288.9,424.8 289.7,424.5 C 290.6,424.2 291.4,424.1 292.2,423.6 C 293.1,423.0 293.9,422.1 294.8,421.5 C 295.6,420.9 296.5,420.3 297.3,419.8 C 298.1,419.4 299.0,419.0 299.8,418.8 C 300.7,418.5 301.5,418.4 302.4,418.3 C 303.2,418.2 304.0,418.2 304.9,418.1 C 305.7,418.0 306.6,417.7 307.4,417.6 C 308.3,417.6 309.1,417.6 309.9,417.5 C 310.8,417.5 311.6,417.4 312.5,417.3 C 313.3,417.2 314.2,417.2 315.0,416.8 C 315.8,416.4 316.7,415.4 317.5,414.9 C 318.4,414.3 319.2,414.2 320.1,413.8 C 320.9,413.4 321.7,414.2 322.6,412.6 C 323.4,411.0 324.3,406.3 325.1,404.5 C 326.0,402.6 326.8,402.3 327.6,401.6 C 328.5,400.8 329.3,400.3 330.2,399.8 C 331.0,399.2 331.9,398.6 332.7,398.2 C 333.5,397.8 334.4,397.4 335.2,397.2 C 336.1,396.9 336.9,396.9 337.8,396.6 C 338.6,396.4 339.4,396.2 340.3,395.9 C 341.1,395.6 342.0,395.1 342.8,394.8 C 343.7,394.6 344.5,394.6 345.3,394.3 C 346.2,394.1 347.0,393.6 347.9,393.3 C 348.7,393.0 349.6,392.7 350.4,392.5 C 351.2,392.2 352.1,392.0 352.9,391.9 C 353.8,391.7 354.6,391.7 355.4,391.5 C 356.3,391.4 357.1,391.2 358.0,391.1 C 358.8,390.9 359.7,390.7 360.5,390.5 C 361.3,390.3 362.2,390.1 363.0,389.8 C 363.9,389.5 364.7,388.9 365.6,388.6 C 366.4,388.3 367.2,388.2 368.1,388.0 C 368.9,387.9 369.8,387.8 370.6,387.7 C 371.5,387.5 372.3,387.4 373.1,387.3 C 374.0,387.2 374.8,387.1 375.7,387.0 C 376.5,386.9 377.4,387.0 378.2,386.8 C 379.0,386.7 379.9,386.5 380.7,386.2 C 381.6,386.0 382.4,385.5 383.3,385.1 C 384.1,384.8 384.9,384.4 385.8,384.2 C 386.6,384.0 387.5,384.0 388.3,383.8 C 389.2,383.7 390.0,383.6 390.8,383.4 C 391.7,383.2 392.5,382.6 393.4,382.4 C 394.2,382.2 395.1,382.3 395.9,382.1 C 396.7,382.0 397.6,381.8 398.4,381.6 C 399.3,381.4 400.1,381.2 401.0,381.0 C 401.8,380.8 402.6,380.6 403.5,380.4 C 404.3,380.3 405.2,380.2 406.0,379.9 C 406.9,379.7 407.7,379.4 408.5,379.1 C 409.4,378.9 410.2,378.8 411.1,378.5 C 411.9,378.3 412.8,378.0 413.6,377.7 C 414.4,377.4 415.3,377.1 416.1,376.9 C 417.0,376.6 417.8,376.6 418.7,376.2 C 419.5,375.8 420.3,374.8 421.2,374.5 C 422.0,374.1 422.9,374.1 423.7,373.9 C 424.6,373.8 425.4,373.8 426.2,373.7 C 427.1,373.6 427.9,373.4 428.8,373.3 C 429.6,373.2 430.4,373.4 431.3,373.0 C 432.1,372.7 433.0,371.6 433.8,371.2 C 434.7,370.8 435.5,370.8 436.3,370.6 C 437.2,370.3 438.0,369.9 438.9,369.7 C 439.7,369.5 440.6,369.6 441.4,369.4 C 442.2,369.2 443.1,368.7 443.9,368.5 C 444.8,368.3 445.6,368.3 446.5,368.2 C 447.3,368.1 448.1,368.1 449.0,367.9 C 449.8,367.6 450.7,367.4 451.5,366.9 C 452.4,366.4 453.2,365.3 454.0,364.8 C 454.9,364.2 455.7,364.0 456.6,363.6 C 457.4,363.2 458.3,363.2 459.1,362.4 C 459.9,361.7 460.8,360.0 461.6,359.2 C 462.5,358.4 463.3,358.0 464.2,357.6 C 465.0,357.1 465.8,357.0 466.7,356.3 C 467.5,355.6 468.4,354.0 469.2,353.2 C 470.1,352.5 470.9,352.4 471.7,352.0 C 472.6,351.6 473.4,351.2 474.3,350.7 C 475.1,Line truncated
<path d="M 90.0,462.2 C 90.4,462.1 92.1,461.8 92.5,461.7 C 93.4,461.6 94.2,461.4 95.1,461.2 C 95.9,461.1 96.7,460.8 97.6,460.8 C 98.4,460.7 99.3,460.7 100.1,460.7 C 101.0,460.7 101.8,460.6 102.6,460.6 C 103.5,460.5 104.3,460.5 105.2,460.4 C 106.0,460.4 106.4,460.3 107.7,460.3 C 109.0,460.2 111.5,460.1 112.8,460.1 C 114.0,460.1 114.4,460.0 115.3,460.0 C 116.1,460.0 117.0,460.0 117.8,459.9 C 118.7,459.9 119.5,459.9 120.3,459.9 C 121.2,459.9 122.0,459.9 122.9,459.8 C 123.7,459.8 124.6,459.7 125.4,459.7 C 126.2,459.6 127.1,459.6 127.9,459.4 C 128.8,459.3 129.6,459.0 130.4,458.7 C 131.3,458.3 132.1,457.6 133.0,457.3 C 133.8,457.1 134.7,457.2 135.5,457.1 C 136.3,457.1 137.2,457.1 138.0,457.0 C 138.9,457.0 139.3,457.0 140.6,456.9 C 141.8,456.9 143.9,456.9 145.6,456.9 C 147.3,456.8 149.4,456.8 150.7,456.8 C 151.9,456.7 152.4,456.7 153.2,456.7 C 154.0,456.6 154.9,456.6 155.7,456.5 C 156.6,456.5 157.4,456.5 158.3,456.4 C 159.1,456.4 159.9,456.4 160.8,456.4 C 161.6,456.4 162.1,456.4 163.3,456.3 C 164.6,456.3 166.3,456.2 168.4,456.2 C 170.5,456.2 173.8,456.2 176.0,456.2 C 178.1,456.1 178.5,456.2 181.0,456.1 C 183.5,456.1 189.0,455.9 191.1,455.9 C 193.2,455.8 192.4,455.9 193.7,455.8 C 194.9,455.8 196.6,455.8 198.7,455.8 C 200.8,455.8 202.1,455.7 206.3,455.7 C 210.5,455.6 220.6,455.5 224.0,455.5 C 227.4,455.5 225.7,455.5 226.5,455.4 C 227.4,455.4 227.8,456.1 229.0,455.4 C 230.3,454.7 232.8,454.1 234.1,451.2 C 235.4,448.4 235.8,440.7 236.6,438.3 C 237.5,435.9 238.3,437.2 239.2,436.8 C 240.0,436.5 240.8,436.2 241.7,436.0 C 242.5,435.7 243.4,435.5 244.2,435.4 C 245.1,435.2 245.9,435.2 246.7,435.0 C 247.6,434.9 248.4,434.8 249.3,434.7 C 250.1,434.6 251.0,434.7 251.8,434.7 C 252.6,434.6 253.5,434.4 254.3,434.3 C 255.2,434.2 256.0,434.2 256.9,434.1 C 257.7,434.0 258.5,433.6 259.4,433.5 C 260.2,433.4 261.1,433.4 261.9,433.3 C 262.8,433.3 263.2,433.1 264.4,433.0 C 265.7,432.9 268.2,432.9 269.5,432.8 C 270.8,432.7 271.2,432.7 272.0,432.6 C 272.9,432.6 273.7,432.6 274.6,432.5 C 275.4,432.4 276.2,432.1 277.1,432.0 C 277.9,431.8 278.8,432.5 279.6,431.6 C 280.4,430.7 281.3,427.5 282.1,426.5 C 283.0,425.4 283.8,425.5 284.7,425.4 C 285.5,425.2 286.3,425.4 287.2,425.3 C 288.0,425.2 288.9,424.8 289.7,424.5 C 290.6,424.2 291.4,424.1 292.2,423.6 C 293.1,423.0 293.9,422.1 294.8,421.5 C 295.6,420.9 296.5,420.3 297.3,419.8 C 298.1,419.4 299.0,419.0 299.8,418.8 C 300.7,418.5 301.5,418.4 302.4,418.3 C 303.2,418.2 304.0,418.2 304.9,418.1 C 305.7,418.0 306.6,417.7 307.4,417.6 C 308.3,417.6 309.1,417.6 309.9,417.5 C 310.8,417.5 311.6,417.4 312.5,417.3 C 313.3,417.2 314.2,417.2 315.0,416.8 C 315.8,416.4 316.7,415.4 317.5,414.9 C 318.4,414.3 319.2,414.2 320.1,413.8 C 320.9,413.4 321.7,414.2 322.6,412.6 C 323.4,411.0 324.3,406.3 325.1,404.5 C 326.0,402.6 326.8,402.3 327.6,401.6 C 328.5,400.8 329.3,400.3 330.2,399.8 C 331.0,399.2 331.9,398.6 332.7,398.2 C 333.5,397.8 334.4,397.4 335.2,397.2 C 336.1,396.9 336.9,396.9 337.8,396.6 C 338.6,396.4 339.4,396.2 340.3,395.9 C 341.1,395.6 342.0,395.1 342.8,394.8 C 343.7,394.6 344.5,394.6 345.3,394.3 C 346.2,394.1 347.0,393.6 347.9,393.3 C 348.7,393.0 349.6,392.7 350.4,392.5 C 351.2,392.2 352.1,392.0 352.9,391.9 C 353.8,391.7 354.6,391.7 355.4,391.5 C 356.3,391.4 357.1,391.2 358.0,391.1 C 358.8,390.9 359.7,390.7 360.5,390.5 C 361.3,390.3 362.2,390.1 363.0,389.8 C 363.9,389.5 364.7,388.9 365.6,388.6 C 366.4,388.3 367.2,388.2 368.1,388.0 C 368.9,387.9 369.8,387.8 370.6,387.7 C 371.5,387.5 372.3,387.4 373.1,387.3 C 374.0,387.2 374.8,387.1 375.7,387.0 C 376.5,386.9 377.4,387.0 378.2,386.8 C 379.0,386.7 379.9,386.5 380.7,386.2 C 381.6,386.0 382.4,385.5 383.3,385.1 C 384.1,384.8 384.9,384.4 385.8,384.2 C 386.6,384.0 387.5,384.0 388.3,383.8 C 389.2,383.7 390.0,383.6 390.8,383.4 C 391.7,383.2 392.5,382.6 393.4,382.4 C 394.2,382.2 395.1,382.3 395.9,382.1 C 396.7,382.0 397.6,381.8 398.4,381.6 C 399.3,381.4 400.1,381.2 401.0,381.0 C 401.8,380.8 402.6,380.6 403.5,380.4 C 404.3,380.3 405.2,380.2 406.0,379.9 C 406.9,379.7 407.7,379.4 408.5,379.1 C 409.4,378.9 410.2,378.8 411.1,378.5 C 411.9,378.3 412.8,378.0 413.6,377.7 C 414.4,377.4 415.3,377.1 416.1,376.9 C 417.0,376.6 417.8,376.6 418.7,376.2 C 419.5,375.8 420.3,374.8 421.2,374.5 C 422.0,374.1 422.9,374.1 423.7,373.9 C 424.6,373.8 425.4,373.8 426.2,373.7 C 427.1,373.6 427.9,373.4 428.8,373.3 C 429.6,373.2 430.4,373.4 431.3,373.0 C 432.1,372.7 433.0,371.6 433.8,371.2 C 434.7,370.8 435.5,370.8 436.3,370.6 C 437.2,370.3 438.0,369.9 438.9,369.7 C 439.7,369.5 440.6,369.6 441.4,369.4 C 442.2,369.2 443.1,368.7 443.9,368.5 C 444.8,368.3 445.6,368.3 446.5,368.2 C 447.3,368.1 448.1,368.1 449.0,367.9 C 449.8,367.6 450.7,367.4 451.5,366.9 C 452.4,366.4 453.2,365.3 454.0,364.8 C 454.9,364.2 455.7,364.0 456.6,363.6 C 457.4,363.2 458.3,363.2 459.1,362.4 C 459.9,361.7 460.8,360.0 461.6,359.2 C 462.5,358.4 463.3,358.0 464.2,357.6 C 465.0,357.1 465.8,357.0 466.7,356.3 C 467.5,355.6 468.4,354.0 469.2,353.2 C 470.1,352.5 470.9,352.4 471.7,352.0 C 472.6,351.6 473.4,351.2 474.3,350.7 C 475.1,Line truncated
<circle cx="765.0" cy="119.7" r="5" fill="#dd4528" stroke="#0d1117" stroke-width="2" filter="url(#xkcdify)"/>
<text x="753.0" y="77.0" fill="#dd4528" font-size="18" font-family="Handlee, cursive" font-weight="bold" text-anchor="end">6275</text>
<path d="M 90.0,462.2 C 90.4,462.1 92.1,461.8 92.5,461.7 C 93.3,461.6 94.1,461.4 94.9,461.2 C 95.7,461.1 96.6,460.8 97.4,460.8 C 98.2,460.7 99.0,460.7 99.9,460.7 C 100.7,460.7 101.5,460.6 102.3,460.6 C 103.1,460.5 104.0,460.5 104.8,460.4 C 105.6,460.4 106.0,460.3 107.2,460.3 C 108.5,460.2 110.9,460.1 112.2,460.1 C 113.4,460.1 113.8,460.0 114.6,460.0 C 115.5,460.0 116.3,460.0 117.1,459.9 C 117.9,459.9 118.7,459.9 119.6,459.9 C 120.4,459.9 121.2,459.9 122.0,459.8 C 122.8,459.8 123.7,459.7 124.5,459.7 C 125.3,459.6 126.1,459.6 127.0,459.4 C 127.8,459.3 128.6,459.0 129.4,458.7 C 130.2,458.3 131.1,457.6 131.9,457.3 C 132.7,457.1 133.5,457.2 134.3,457.1 C 135.2,457.1 136.0,457.1 136.8,457.0 C 137.6,457.0 138.0,457.0 139.3,456.9 C 140.5,456.9 142.6,456.9 144.2,456.9 C 145.8,456.8 147.9,456.8 149.1,456.8 C 150.4,456.7 150.8,456.7 151.6,456.7 C 152.4,456.6 153.2,456.6 154.1,456.5 C 154.9,456.5 155.7,456.5 156.5,456.4 C 157.3,456.4 158.2,456.4 159.0,456.4 C 159.8,456.4 160.2,456.4 161.4,456.3 C 162.7,456.3 164.3,456.2 166.4,456.2 C 168.4,456.2 171.7,456.2 173.8,456.2 C 175.8,456.1 176.2,456.2 178.7,456.1 C 181.1,456.1 186.5,455.9 188.5,455.9 C 190.6,455.8 189.8,455.9 191.0,455.8 C 192.2,455.8 193.9,455.8 195.9,455.8 C 198.0,455.8 199.2,455.7 203.3,455.7 C 207.4,455.6 217.3,455.5 220.6,455.5 C 223.9,455.5 222.2,455.5 223.0,455.4 C 223.9,455.4 224.3,456.1 225.5,455.4 C 226.7,454.7 229.2,454.1 230.4,451.2 C 231.7,448.4 232.1,440.7 232.9,438.3 C 233.7,435.9 234.5,437.2 235.3,436.8 C 236.2,436.5 237.0,436.2 237.8,436.0 C 238.6,435.7 239.5,435.5 240.3,435.4 C 241.1,435.2 241.9,435.2 242.7,435.0 C 243.6,434.9 244.4,434.8 245.2,434.7 C 246.0,434.6 246.8,434.7 247.7,434.7 C 248.5,434.6 249.3,434.4 250.1,434.3 C 250.9,434.2 251.8,434.2 252.6,434.1 C 253.4,434.0 254.2,433.6 255.1,433.5 C 255.9,433.4 256.7,433.4 257.5,433.3 C 258.3,433.3 258.8,433.1 260.0,433.0 C 261.2,432.9 263.7,432.9 264.9,432.8 C 266.1,432.7 266.6,432.7 267.4,432.6 C 268.2,432.6 269.0,432.6 269.8,432.5 C 270.7,432.4 271.5,432.1 272.3,432.0 C 273.1,431.8 273.9,432.5 274.8,431.6 C 275.6,430.7 276.4,427.5 277.2,426.5 C 278.0,425.4 278.9,425.5 279.7,425.4 C 280.5,425.2 281.3,425.4 282.2,425.3 C 283.0,425.2 283.8,424.8 284.6,424.5 C 285.4,424.2 286.3,424.1 287.1,423.6 C 287.9,423.0 288.7,422.1 289.5,421.5 C 290.4,420.9 291.2,420.3 292.0,419.9 C 292.8,419.4 293.6,419.1 294.5,418.8 C 295.3,418.6 296.1,418.5 296.9,418.4 C 297.8,418.2 298.6,418.2 299.4,418.1 C 300.2,418.0 301.0,417.8 301.9,417.7 C 302.7,417.6 303.5,417.6 304.3,417.6 C 305.1,417.5 306.0,417.5 306.8,417.4 C 307.6,417.2 308.4,417.3 309.3,416.9 C 310.1,416.5 310.9,415.4 311.7,414.9 C 312.5,414.4 313.4,414.2 314.2,413.9 C 315.0,413.5 315.8,414.2 316.6,412.7 C 317.5,411.1 318.3,406.3 319.1,404.5 C 319.9,402.6 320.7,402.3 321.6,401.6 C 322.4,400.8 323.2,400.3 324.0,399.8 C 324.9,399.2 325.7,398.7 326.5,398.3 C 327.3,397.9 328.1,397.5 329.0,397.2 C 329.8,397.0 330.6,396.9 331.4,396.7 C 332.2,396.5 333.1,396.2 333.9,395.9 C 334.7,395.6 335.5,395.1 336.4,394.9 C 337.2,394.6 338.0,394.6 338.8,394.4 C 339.6,394.1 340.5,393.7 341.3,393.3 C 342.1,393.0 342.9,392.8 343.7,392.5 C 344.6,392.3 345.4,392.1 346.2,391.9 C 347.0,391.8 347.8,391.7 348.7,391.6 C 349.5,391.5 350.3,391.3 351.1,391.1 C 352.0,390.9 352.8,390.8 353.6,390.6 C 354.4,390.3 355.2,390.2 356.1,389.8 C 356.9,389.5 357.7,388.9 358.5,388.6 C 359.3,388.4 360.2,388.3 361.0,388.1 C 361.8,387.9 362.6,387.8 363.4,387.7 C 364.3,387.6 365.1,387.5 365.9,387.4 C 366.7,387.3 367.6,387.1 368.4,387.1 C 369.2,387.0 370.0,387.0 370.8,386.9 C 371.7,386.8 372.5,386.6 373.3,386.3 C 374.1,386.0 374.9,385.5 375.8,385.2 C 376.6,384.9 377.4,384.5 378.2,384.3 C 379.1,384.0 379.9,384.0 380.7,383.9 C 381.5,383.7 382.3,383.7 383.2,383.4 C 384.0,383.2 384.8,382.7 385.6,382.5 C 386.4,382.3 387.3,382.3 388.1,382.2 C 388.9,382.1 389.7,381.8 390.5,381.6 C 391.4,381.5 392.2,381.3 393.0,381.1 C 393.8,380.9 394.7,380.7 395.5,380.5 C 396.3,380.3 397.1,380.2 397.9,380.0 C 398.8,379.8 399.6,379.4 400.4,379.2 C 401.2,378.9 402.0,378.8 402.9,378.6 C 403.7,378.3 404.5,378.0 405.3,377.8 C 406.1,377.5 407.0,377.2 407.8,376.9 C 408.6,376.7 409.4,376.6 410.3,376.2 C 411.1,375.8 411.9,374.9 412.7,374.5 C 413.5,374.2 414.4,374.1 415.2,374.0 C 416.0,373.9 416.8,373.9 417.6,373.8 C 418.5,373.7 419.3,373.5 420.1,373.4 C 420.9,373.3 421.8,373.5 422.6,373.1 C 423.4,372.7 424.2,371.7 425.0,371.2 C 425.9,370.8 426.7,370.9 427.5,370.6 C 428.3,370.4 429.1,369.9 430.0,369.7 C 430.8,369.5 431.6,369.6 432.4,369.4 C 433.2,369.2 434.1,368.7 434.9,368.5 C 435.7,368.3 436.5,368.3 437.4,368.2 C 438.2,368.1 439.0,368.1 439.8,367.9 C 440.6,367.7 441.5,367.4 442.3,366.9 C 443.1,366.4 443.9,365.4 444.7,364.8 C 445.6,364.3 446.4,364.1 447.2,363.7 C 448.0,363.3 448.9,363.2 449.7,362.5 C 450.5,361.8 451.3,360.1 452.1,359.3 C 453.0,358.5 453.8,358.1 454.6,357.6 C 455.4,357.1 456.2,357.1 457.1,356.4 C 457.9,355.6 458.7,354.0 459.5,353.3 C 460.3,352.6 461.2,352.5 462.0,352.0 C 462.8,351.6 463.6,351.3 464.5,350.8 C 465.3,3Line truncated
<path d="M 90.0,462.2 C 90.4,462.1 92.1,461.8 92.5,461.7 C 93.3,461.6 94.1,461.4 94.9,461.2 C 95.7,461.1 96.6,460.8 97.4,460.8 C 98.2,460.7 99.0,460.7 99.9,460.7 C 100.7,460.7 101.5,460.6 102.3,460.6 C 103.1,460.5 104.0,460.5 104.8,460.4 C 105.6,460.4 106.0,460.3 107.2,460.3 C 108.5,460.2 110.9,460.1 112.2,460.1 C 113.4,460.1 113.8,460.0 114.6,460.0 C 115.5,460.0 116.3,460.0 117.1,459.9 C 117.9,459.9 118.7,459.9 119.6,459.9 C 120.4,459.9 121.2,459.9 122.0,459.8 C 122.8,459.8 123.7,459.7 124.5,459.7 C 125.3,459.6 126.1,459.6 127.0,459.4 C 127.8,459.3 128.6,459.0 129.4,458.7 C 130.2,458.3 131.1,457.6 131.9,457.3 C 132.7,457.1 133.5,457.2 134.3,457.1 C 135.2,457.1 136.0,457.1 136.8,457.0 C 137.6,457.0 138.0,457.0 139.3,456.9 C 140.5,456.9 142.6,456.9 144.2,456.9 C 145.8,456.8 147.9,456.8 149.1,456.8 C 150.4,456.7 150.8,456.7 151.6,456.7 C 152.4,456.6 153.2,456.6 154.1,456.5 C 154.9,456.5 155.7,456.5 156.5,456.4 C 157.3,456.4 158.2,456.4 159.0,456.4 C 159.8,456.4 160.2,456.4 161.4,456.3 C 162.7,456.3 164.3,456.2 166.4,456.2 C 168.4,456.2 171.7,456.2 173.8,456.2 C 175.8,456.1 176.2,456.2 178.7,456.1 C 181.1,456.1 186.5,455.9 188.5,455.9 C 190.6,455.8 189.8,455.9 191.0,455.8 C 192.2,455.8 193.9,455.8 195.9,455.8 C 198.0,455.8 199.2,455.7 203.3,455.7 C 207.4,455.6 217.3,455.5 220.6,455.5 C 223.9,455.5 222.2,455.5 223.0,455.4 C 223.9,455.4 224.3,456.1 225.5,455.4 C 226.7,454.7 229.2,454.1 230.4,451.2 C 231.7,448.4 232.1,440.7 232.9,438.3 C 233.7,435.9 234.5,437.2 235.3,436.8 C 236.2,436.5 237.0,436.2 237.8,436.0 C 238.6,435.7 239.5,435.5 240.3,435.4 C 241.1,435.2 241.9,435.2 242.7,435.0 C 243.6,434.9 244.4,434.8 245.2,434.7 C 246.0,434.6 246.8,434.7 247.7,434.7 C 248.5,434.6 249.3,434.4 250.1,434.3 C 250.9,434.2 251.8,434.2 252.6,434.1 C 253.4,434.0 254.2,433.6 255.1,433.5 C 255.9,433.4 256.7,433.4 257.5,433.3 C 258.3,433.3 258.8,433.1 260.0,433.0 C 261.2,432.9 263.7,432.9 264.9,432.8 C 266.1,432.7 266.6,432.7 267.4,432.6 C 268.2,432.6 269.0,432.6 269.8,432.5 C 270.7,432.4 271.5,432.1 272.3,432.0 C 273.1,431.8 273.9,432.5 274.8,431.6 C 275.6,430.7 276.4,427.5 277.2,426.5 C 278.0,425.4 278.9,425.5 279.7,425.4 C 280.5,425.2 281.3,425.4 282.2,425.3 C 283.0,425.2 283.8,424.8 284.6,424.5 C 285.4,424.2 286.3,424.1 287.1,423.6 C 287.9,423.0 288.7,422.1 289.5,421.5 C 290.4,420.9 291.2,420.3 292.0,419.9 C 292.8,419.4 293.6,419.1 294.5,418.8 C 295.3,418.6 296.1,418.5 296.9,418.4 C 297.8,418.2 298.6,418.2 299.4,418.1 C 300.2,418.0 301.0,417.8 301.9,417.7 C 302.7,417.6 303.5,417.6 304.3,417.6 C 305.1,417.5 306.0,417.5 306.8,417.4 C 307.6,417.2 308.4,417.3 309.3,416.9 C 310.1,416.5 310.9,415.4 311.7,414.9 C 312.5,414.4 313.4,414.2 314.2,413.9 C 315.0,413.5 315.8,414.2 316.6,412.7 C 317.5,411.1 318.3,406.3 319.1,404.5 C 319.9,402.6 320.7,402.3 321.6,401.6 C 322.4,400.8 323.2,400.3 324.0,399.8 C 324.9,399.2 325.7,398.7 326.5,398.3 C 327.3,397.9 328.1,397.5 329.0,397.2 C 329.8,397.0 330.6,396.9 331.4,396.7 C 332.2,396.5 333.1,396.2 333.9,395.9 C 334.7,395.6 335.5,395.1 336.4,394.9 C 337.2,394.6 338.0,394.6 338.8,394.4 C 339.6,394.1 340.5,393.7 341.3,393.3 C 342.1,393.0 342.9,392.8 343.7,392.5 C 344.6,392.3 345.4,392.1 346.2,391.9 C 347.0,391.8 347.8,391.7 348.7,391.6 C 349.5,391.5 350.3,391.3 351.1,391.1 C 352.0,390.9 352.8,390.8 353.6,390.6 C 354.4,390.3 355.2,390.2 356.1,389.8 C 356.9,389.5 357.7,388.9 358.5,388.6 C 359.3,388.4 360.2,388.3 361.0,388.1 C 361.8,387.9 362.6,387.8 363.4,387.7 C 364.3,387.6 365.1,387.5 365.9,387.4 C 366.7,387.3 367.6,387.1 368.4,387.1 C 369.2,387.0 370.0,387.0 370.8,386.9 C 371.7,386.8 372.5,386.6 373.3,386.3 C 374.1,386.0 374.9,385.5 375.8,385.2 C 376.6,384.9 377.4,384.5 378.2,384.3 C 379.1,384.0 379.9,384.0 380.7,383.9 C 381.5,383.7 382.3,383.7 383.2,383.4 C 384.0,383.2 384.8,382.7 385.6,382.5 C 386.4,382.3 387.3,382.3 388.1,382.2 C 388.9,382.1 389.7,381.8 390.5,381.6 C 391.4,381.5 392.2,381.3 393.0,381.1 C 393.8,380.9 394.7,380.7 395.5,380.5 C 396.3,380.3 397.1,380.2 397.9,380.0 C 398.8,379.8 399.6,379.4 400.4,379.2 C 401.2,378.9 402.0,378.8 402.9,378.6 C 403.7,378.3 404.5,378.0 405.3,377.8 C 406.1,377.5 407.0,377.2 407.8,376.9 C 408.6,376.7 409.4,376.6 410.3,376.2 C 411.1,375.8 411.9,374.9 412.7,374.5 C 413.5,374.2 414.4,374.1 415.2,374.0 C 416.0,373.9 416.8,373.9 417.6,373.8 C 418.5,373.7 419.3,373.5 420.1,373.4 C 420.9,373.3 421.8,373.5 422.6,373.1 C 423.4,372.7 424.2,371.7 425.0,371.2 C 425.9,370.8 426.7,370.9 427.5,370.6 C 428.3,370.4 429.1,369.9 430.0,369.7 C 430.8,369.5 431.6,369.6 432.4,369.4 C 433.2,369.2 434.1,368.7 434.9,368.5 C 435.7,368.3 436.5,368.3 437.4,368.2 C 438.2,368.1 439.0,368.1 439.8,367.9 C 440.6,367.7 441.5,367.4 442.3,366.9 C 443.1,366.4 443.9,365.4 444.7,364.8 C 445.6,364.3 446.4,364.1 447.2,363.7 C 448.0,363.3 448.9,363.2 449.7,362.5 C 450.5,361.8 451.3,360.1 452.1,359.3 C 453.0,358.5 453.8,358.1 454.6,357.6 C 455.4,357.1 456.2,357.1 457.1,356.4 C 457.9,355.6 458.7,354.0 459.5,353.3 C 460.3,352.6 461.2,352.5 462.0,352.0 C 462.8,351.6 463.6,351.3 464.5,350.8 C 465.3,3Line truncated
<circle cx="765.0" cy="113.6" r="5" fill="#dd4528" stroke="#0d1117" stroke-width="2" filter="url(#xkcdify)"/>
<text x="753.0" y="77.0" fill="#dd4528" font-size="18" font-family="Handlee, cursive" font-weight="bold" text-anchor="end">6385</text>
<a href="https://github.com/carsteneu/mystarhistory" target="_blank" rel="noopener"><text x="763" y="527" font-size="13" font-family="Handlee, cursive" fill="#fff" text-anchor="end">Made with carsteneu/mystarhistory</text></a>
</svg>

Before

Width:  |  Height:  |  Size: 34 KiB

After

Width:  |  Height:  |  Size: 35 KiB

+12 -12
View File
@@ -31,20 +31,20 @@
<text x="85" y="85.0" fill="#000" font-size="16" font-family="Handlee, cursive" font-weight="bold" text-anchor="end">7K</text>
<text x="43" y="271.5" fill="#000" font-size="17" font-family="Handlee, cursive" font-weight="bold" transform="rotate(-90, 43, 271.5)">GitHub Stars</text>
<text x="90.0" y="488" fill="#000" font-size="16" font-family="Handlee, cursive" font-weight="bold" text-anchor="middle">Jan 2026</text>
<text x="163.3" y="488" fill="#000" font-size="16" font-family="Handlee, cursive" font-weight="bold" text-anchor="middle">Feb 2026</text>
<text x="234.1" y="488" fill="#000" font-size="16" font-family="Handlee, cursive" font-weight="bold" text-anchor="middle">Mar 2026</text>
<text x="312.5" y="488" fill="#000" font-size="16" font-family="Handlee, cursive" font-weight="bold" text-anchor="middle">Apr 2026</text>
<text x="388.3" y="488" fill="#000" font-size="16" font-family="Handlee, cursive" font-weight="bold" text-anchor="middle">May 2026</text>
<text x="466.7" y="488" fill="#000" font-size="16" font-family="Handlee, cursive" font-weight="bold" text-anchor="middle">Jun 2026</text>
<text x="542.5" y="488" fill="#000" font-size="16" font-family="Handlee, cursive" font-weight="bold" text-anchor="middle">Jul 2026</text>
<text x="620.9" y="488" fill="#000" font-size="16" font-family="Handlee, cursive" font-weight="bold" text-anchor="middle">Aug 2026</text>
<text x="699.3" y="488" fill="#000" font-size="16" font-family="Handlee, cursive" font-weight="bold" text-anchor="middle">Sep 2026</text>
<text x="161.4" y="488" fill="#000" font-size="16" font-family="Handlee, cursive" font-weight="bold" text-anchor="middle">Feb 2026</text>
<text x="306.8" y="488" fill="#000" font-size="16" font-family="Handlee, cursive" font-weight="bold" text-anchor="middle">Apr 2026</text>
<text x="380.7" y="488" fill="#000" font-size="16" font-family="Handlee, cursive" font-weight="bold" text-anchor="middle">May 2026</text>
<text x="457.1" y="488" fill="#000" font-size="16" font-family="Handlee, cursive" font-weight="bold" text-anchor="middle">Jun 2026</text>
<text x="531.0" y="488" fill="#000" font-size="16" font-family="Handlee, cursive" font-weight="bold" text-anchor="middle">Jul 2026</text>
<text x="607.3" y="488" fill="#000" font-size="16" font-family="Handlee, cursive" font-weight="bold" text-anchor="middle">Aug 2026</text>
<text x="683.7" y="488" fill="#000" font-size="16" font-family="Handlee, cursive" font-weight="bold" text-anchor="middle">Sep 2026</text>
<text x="757.6" y="488" fill="#000" font-size="16" font-family="Handlee, cursive" font-weight="bold" text-anchor="middle">Oct 2026</text>
<text x="50%" y="525" fill="#000" font-size="17" font-family="Handlee, cursive" font-weight="bold" text-anchor="middle">Date</text>
<line x1="90" y1="80" x2="90" y2="463" stroke="#222" stroke-width="2.5" filter="url(#xkcdify)"/>
<line x1="90" y1="463" x2="765" y2="463" stroke="#222" stroke-width="2.5" filter="url(#xkcdify)"/>
<path d="M 90.0,462.2 C 90.4,462.1 92.1,461.8 92.5,461.7 C 93.4,461.6 94.2,461.4 95.1,461.2 C 95.9,461.1 96.7,460.8 97.6,460.8 C 98.4,460.7 99.3,460.7 100.1,460.7 C 101.0,460.7 101.8,460.6 102.6,460.6 C 103.5,460.5 104.3,460.5 105.2,460.4 C 106.0,460.4 106.4,460.3 107.7,460.3 C 109.0,460.2 111.5,460.1 112.8,460.1 C 114.0,460.1 114.4,460.0 115.3,460.0 C 116.1,460.0 117.0,460.0 117.8,459.9 C 118.7,459.9 119.5,459.9 120.3,459.9 C 121.2,459.9 122.0,459.9 122.9,459.8 C 123.7,459.8 124.6,459.7 125.4,459.7 C 126.2,459.6 127.1,459.6 127.9,459.4 C 128.8,459.3 129.6,459.0 130.4,458.7 C 131.3,458.3 132.1,457.6 133.0,457.3 C 133.8,457.1 134.7,457.2 135.5,457.1 C 136.3,457.1 137.2,457.1 138.0,457.0 C 138.9,457.0 139.3,457.0 140.6,456.9 C 141.8,456.9 143.9,456.9 145.6,456.9 C 147.3,456.8 149.4,456.8 150.7,456.8 C 151.9,456.7 152.4,456.7 153.2,456.7 C 154.0,456.6 154.9,456.6 155.7,456.5 C 156.6,456.5 157.4,456.5 158.3,456.4 C 159.1,456.4 159.9,456.4 160.8,456.4 C 161.6,456.4 162.1,456.4 163.3,456.3 C 164.6,456.3 166.3,456.2 168.4,456.2 C 170.5,456.2 173.8,456.2 176.0,456.2 C 178.1,456.1 178.5,456.2 181.0,456.1 C 183.5,456.1 189.0,455.9 191.1,455.9 C 193.2,455.8 192.4,455.9 193.7,455.8 C 194.9,455.8 196.6,455.8 198.7,455.8 C 200.8,455.8 202.1,455.7 206.3,455.7 C 210.5,455.6 220.6,455.5 224.0,455.5 C 227.4,455.5 225.7,455.5 226.5,455.4 C 227.4,455.4 227.8,456.1 229.0,455.4 C 230.3,454.7 232.8,454.1 234.1,451.2 C 235.4,448.4 235.8,440.7 236.6,438.3 C 237.5,435.9 238.3,437.2 239.2,436.8 C 240.0,436.5 240.8,436.2 241.7,436.0 C 242.5,435.7 243.4,435.5 244.2,435.4 C 245.1,435.2 245.9,435.2 246.7,435.0 C 247.6,434.9 248.4,434.8 249.3,434.7 C 250.1,434.6 251.0,434.7 251.8,434.7 C 252.6,434.6 253.5,434.4 254.3,434.3 C 255.2,434.2 256.0,434.2 256.9,434.1 C 257.7,434.0 258.5,433.6 259.4,433.5 C 260.2,433.4 261.1,433.4 261.9,433.3 C 262.8,433.3 263.2,433.1 264.4,433.0 C 265.7,432.9 268.2,432.9 269.5,432.8 C 270.8,432.7 271.2,432.7 272.0,432.6 C 272.9,432.6 273.7,432.6 274.6,432.5 C 275.4,432.4 276.2,432.1 277.1,432.0 C 277.9,431.8 278.8,432.5 279.6,431.6 C 280.4,430.7 281.3,427.5 282.1,426.5 C 283.0,425.4 283.8,425.5 284.7,425.4 C 285.5,425.2 286.3,425.4 287.2,425.3 C 288.0,425.2 288.9,424.8 289.7,424.5 C 290.6,424.2 291.4,424.1 292.2,423.6 C 293.1,423.0 293.9,422.1 294.8,421.5 C 295.6,420.9 296.5,420.3 297.3,419.8 C 298.1,419.4 299.0,419.0 299.8,418.8 C 300.7,418.5 301.5,418.4 302.4,418.3 C 303.2,418.2 304.0,418.2 304.9,418.1 C 305.7,418.0 306.6,417.7 307.4,417.6 C 308.3,417.6 309.1,417.6 309.9,417.5 C 310.8,417.5 311.6,417.4 312.5,417.3 C 313.3,417.2 314.2,417.2 315.0,416.8 C 315.8,416.4 316.7,415.4 317.5,414.9 C 318.4,414.3 319.2,414.2 320.1,413.8 C 320.9,413.4 321.7,414.2 322.6,412.6 C 323.4,411.0 324.3,406.3 325.1,404.5 C 326.0,402.6 326.8,402.3 327.6,401.6 C 328.5,400.8 329.3,400.3 330.2,399.8 C 331.0,399.2 331.9,398.6 332.7,398.2 C 333.5,397.8 334.4,397.4 335.2,397.2 C 336.1,396.9 336.9,396.9 337.8,396.6 C 338.6,396.4 339.4,396.2 340.3,395.9 C 341.1,395.6 342.0,395.1 342.8,394.8 C 343.7,394.6 344.5,394.6 345.3,394.3 C 346.2,394.1 347.0,393.6 347.9,393.3 C 348.7,393.0 349.6,392.7 350.4,392.5 C 351.2,392.2 352.1,392.0 352.9,391.9 C 353.8,391.7 354.6,391.7 355.4,391.5 C 356.3,391.4 357.1,391.2 358.0,391.1 C 358.8,390.9 359.7,390.7 360.5,390.5 C 361.3,390.3 362.2,390.1 363.0,389.8 C 363.9,389.5 364.7,388.9 365.6,388.6 C 366.4,388.3 367.2,388.2 368.1,388.0 C 368.9,387.9 369.8,387.8 370.6,387.7 C 371.5,387.5 372.3,387.4 373.1,387.3 C 374.0,387.2 374.8,387.1 375.7,387.0 C 376.5,386.9 377.4,387.0 378.2,386.8 C 379.0,386.7 379.9,386.5 380.7,386.2 C 381.6,386.0 382.4,385.5 383.3,385.1 C 384.1,384.8 384.9,384.4 385.8,384.2 C 386.6,384.0 387.5,384.0 388.3,383.8 C 389.2,383.7 390.0,383.6 390.8,383.4 C 391.7,383.2 392.5,382.6 393.4,382.4 C 394.2,382.2 395.1,382.3 395.9,382.1 C 396.7,382.0 397.6,381.8 398.4,381.6 C 399.3,381.4 400.1,381.2 401.0,381.0 C 401.8,380.8 402.6,380.6 403.5,380.4 C 404.3,380.3 405.2,380.2 406.0,379.9 C 406.9,379.7 407.7,379.4 408.5,379.1 C 409.4,378.9 410.2,378.8 411.1,378.5 C 411.9,378.3 412.8,378.0 413.6,377.7 C 414.4,377.4 415.3,377.1 416.1,376.9 C 417.0,376.6 417.8,376.6 418.7,376.2 C 419.5,375.8 420.3,374.8 421.2,374.5 C 422.0,374.1 422.9,374.1 423.7,373.9 C 424.6,373.8 425.4,373.8 426.2,373.7 C 427.1,373.6 427.9,373.4 428.8,373.3 C 429.6,373.2 430.4,373.4 431.3,373.0 C 432.1,372.7 433.0,371.6 433.8,371.2 C 434.7,370.8 435.5,370.8 436.3,370.6 C 437.2,370.3 438.0,369.9 438.9,369.7 C 439.7,369.5 440.6,369.6 441.4,369.4 C 442.2,369.2 443.1,368.7 443.9,368.5 C 444.8,368.3 445.6,368.3 446.5,368.2 C 447.3,368.1 448.1,368.1 449.0,367.9 C 449.8,367.6 450.7,367.4 451.5,366.9 C 452.4,366.4 453.2,365.3 454.0,364.8 C 454.9,364.2 455.7,364.0 456.6,363.6 C 457.4,363.2 458.3,363.2 459.1,362.4 C 459.9,361.7 460.8,360.0 461.6,359.2 C 462.5,358.4 463.3,358.0 464.2,357.6 C 465.0,357.1 465.8,357.0 466.7,356.3 C 467.5,355.6 468.4,354.0 469.2,353.2 C 470.1,352.5 470.9,352.4 471.7,352.0 C 472.6,351.6 473.4,351.2 474.3,350.7 C 475.1,Line truncated
<path d="M 90.0,462.2 C 90.4,462.1 92.1,461.8 92.5,461.7 C 93.4,461.6 94.2,461.4 95.1,461.2 C 95.9,461.1 96.7,460.8 97.6,460.8 C 98.4,460.7 99.3,460.7 100.1,460.7 C 101.0,460.7 101.8,460.6 102.6,460.6 C 103.5,460.5 104.3,460.5 105.2,460.4 C 106.0,460.4 106.4,460.3 107.7,460.3 C 109.0,460.2 111.5,460.1 112.8,460.1 C 114.0,460.1 114.4,460.0 115.3,460.0 C 116.1,460.0 117.0,460.0 117.8,459.9 C 118.7,459.9 119.5,459.9 120.3,459.9 C 121.2,459.9 122.0,459.9 122.9,459.8 C 123.7,459.8 124.6,459.7 125.4,459.7 C 126.2,459.6 127.1,459.6 127.9,459.4 C 128.8,459.3 129.6,459.0 130.4,458.7 C 131.3,458.3 132.1,457.6 133.0,457.3 C 133.8,457.1 134.7,457.2 135.5,457.1 C 136.3,457.1 137.2,457.1 138.0,457.0 C 138.9,457.0 139.3,457.0 140.6,456.9 C 141.8,456.9 143.9,456.9 145.6,456.9 C 147.3,456.8 149.4,456.8 150.7,456.8 C 151.9,456.7 152.4,456.7 153.2,456.7 C 154.0,456.6 154.9,456.6 155.7,456.5 C 156.6,456.5 157.4,456.5 158.3,456.4 C 159.1,456.4 159.9,456.4 160.8,456.4 C 161.6,456.4 162.1,456.4 163.3,456.3 C 164.6,456.3 166.3,456.2 168.4,456.2 C 170.5,456.2 173.8,456.2 176.0,456.2 C 178.1,456.1 178.5,456.2 181.0,456.1 C 183.5,456.1 189.0,455.9 191.1,455.9 C 193.2,455.8 192.4,455.9 193.7,455.8 C 194.9,455.8 196.6,455.8 198.7,455.8 C 200.8,455.8 202.1,455.7 206.3,455.7 C 210.5,455.6 220.6,455.5 224.0,455.5 C 227.4,455.5 225.7,455.5 226.5,455.4 C 227.4,455.4 227.8,456.1 229.0,455.4 C 230.3,454.7 232.8,454.1 234.1,451.2 C 235.4,448.4 235.8,440.7 236.6,438.3 C 237.5,435.9 238.3,437.2 239.2,436.8 C 240.0,436.5 240.8,436.2 241.7,436.0 C 242.5,435.7 243.4,435.5 244.2,435.4 C 245.1,435.2 245.9,435.2 246.7,435.0 C 247.6,434.9 248.4,434.8 249.3,434.7 C 250.1,434.6 251.0,434.7 251.8,434.7 C 252.6,434.6 253.5,434.4 254.3,434.3 C 255.2,434.2 256.0,434.2 256.9,434.1 C 257.7,434.0 258.5,433.6 259.4,433.5 C 260.2,433.4 261.1,433.4 261.9,433.3 C 262.8,433.3 263.2,433.1 264.4,433.0 C 265.7,432.9 268.2,432.9 269.5,432.8 C 270.8,432.7 271.2,432.7 272.0,432.6 C 272.9,432.6 273.7,432.6 274.6,432.5 C 275.4,432.4 276.2,432.1 277.1,432.0 C 277.9,431.8 278.8,432.5 279.6,431.6 C 280.4,430.7 281.3,427.5 282.1,426.5 C 283.0,425.4 283.8,425.5 284.7,425.4 C 285.5,425.2 286.3,425.4 287.2,425.3 C 288.0,425.2 288.9,424.8 289.7,424.5 C 290.6,424.2 291.4,424.1 292.2,423.6 C 293.1,423.0 293.9,422.1 294.8,421.5 C 295.6,420.9 296.5,420.3 297.3,419.8 C 298.1,419.4 299.0,419.0 299.8,418.8 C 300.7,418.5 301.5,418.4 302.4,418.3 C 303.2,418.2 304.0,418.2 304.9,418.1 C 305.7,418.0 306.6,417.7 307.4,417.6 C 308.3,417.6 309.1,417.6 309.9,417.5 C 310.8,417.5 311.6,417.4 312.5,417.3 C 313.3,417.2 314.2,417.2 315.0,416.8 C 315.8,416.4 316.7,415.4 317.5,414.9 C 318.4,414.3 319.2,414.2 320.1,413.8 C 320.9,413.4 321.7,414.2 322.6,412.6 C 323.4,411.0 324.3,406.3 325.1,404.5 C 326.0,402.6 326.8,402.3 327.6,401.6 C 328.5,400.8 329.3,400.3 330.2,399.8 C 331.0,399.2 331.9,398.6 332.7,398.2 C 333.5,397.8 334.4,397.4 335.2,397.2 C 336.1,396.9 336.9,396.9 337.8,396.6 C 338.6,396.4 339.4,396.2 340.3,395.9 C 341.1,395.6 342.0,395.1 342.8,394.8 C 343.7,394.6 344.5,394.6 345.3,394.3 C 346.2,394.1 347.0,393.6 347.9,393.3 C 348.7,393.0 349.6,392.7 350.4,392.5 C 351.2,392.2 352.1,392.0 352.9,391.9 C 353.8,391.7 354.6,391.7 355.4,391.5 C 356.3,391.4 357.1,391.2 358.0,391.1 C 358.8,390.9 359.7,390.7 360.5,390.5 C 361.3,390.3 362.2,390.1 363.0,389.8 C 363.9,389.5 364.7,388.9 365.6,388.6 C 366.4,388.3 367.2,388.2 368.1,388.0 C 368.9,387.9 369.8,387.8 370.6,387.7 C 371.5,387.5 372.3,387.4 373.1,387.3 C 374.0,387.2 374.8,387.1 375.7,387.0 C 376.5,386.9 377.4,387.0 378.2,386.8 C 379.0,386.7 379.9,386.5 380.7,386.2 C 381.6,386.0 382.4,385.5 383.3,385.1 C 384.1,384.8 384.9,384.4 385.8,384.2 C 386.6,384.0 387.5,384.0 388.3,383.8 C 389.2,383.7 390.0,383.6 390.8,383.4 C 391.7,383.2 392.5,382.6 393.4,382.4 C 394.2,382.2 395.1,382.3 395.9,382.1 C 396.7,382.0 397.6,381.8 398.4,381.6 C 399.3,381.4 400.1,381.2 401.0,381.0 C 401.8,380.8 402.6,380.6 403.5,380.4 C 404.3,380.3 405.2,380.2 406.0,379.9 C 406.9,379.7 407.7,379.4 408.5,379.1 C 409.4,378.9 410.2,378.8 411.1,378.5 C 411.9,378.3 412.8,378.0 413.6,377.7 C 414.4,377.4 415.3,377.1 416.1,376.9 C 417.0,376.6 417.8,376.6 418.7,376.2 C 419.5,375.8 420.3,374.8 421.2,374.5 C 422.0,374.1 422.9,374.1 423.7,373.9 C 424.6,373.8 425.4,373.8 426.2,373.7 C 427.1,373.6 427.9,373.4 428.8,373.3 C 429.6,373.2 430.4,373.4 431.3,373.0 C 432.1,372.7 433.0,371.6 433.8,371.2 C 434.7,370.8 435.5,370.8 436.3,370.6 C 437.2,370.3 438.0,369.9 438.9,369.7 C 439.7,369.5 440.6,369.6 441.4,369.4 C 442.2,369.2 443.1,368.7 443.9,368.5 C 444.8,368.3 445.6,368.3 446.5,368.2 C 447.3,368.1 448.1,368.1 449.0,367.9 C 449.8,367.6 450.7,367.4 451.5,366.9 C 452.4,366.4 453.2,365.3 454.0,364.8 C 454.9,364.2 455.7,364.0 456.6,363.6 C 457.4,363.2 458.3,363.2 459.1,362.4 C 459.9,361.7 460.8,360.0 461.6,359.2 C 462.5,358.4 463.3,358.0 464.2,357.6 C 465.0,357.1 465.8,357.0 466.7,356.3 C 467.5,355.6 468.4,354.0 469.2,353.2 C 470.1,352.5 470.9,352.4 471.7,352.0 C 472.6,351.6 473.4,351.2 474.3,350.7 C 475.1,Line truncated
<circle cx="765.0" cy="119.7" r="5" fill="#dd4528" stroke="#fff" stroke-width="2" filter="url(#xkcdify)"/>
<text x="753.0" y="77.0" fill="#dd4528" font-size="18" font-family="Handlee, cursive" font-weight="bold" text-anchor="end">6275</text>
<path d="M 90.0,462.2 C 90.4,462.1 92.1,461.8 92.5,461.7 C 93.3,461.6 94.1,461.4 94.9,461.2 C 95.7,461.1 96.6,460.8 97.4,460.8 C 98.2,460.7 99.0,460.7 99.9,460.7 C 100.7,460.7 101.5,460.6 102.3,460.6 C 103.1,460.5 104.0,460.5 104.8,460.4 C 105.6,460.4 106.0,460.3 107.2,460.3 C 108.5,460.2 110.9,460.1 112.2,460.1 C 113.4,460.1 113.8,460.0 114.6,460.0 C 115.5,460.0 116.3,460.0 117.1,459.9 C 117.9,459.9 118.7,459.9 119.6,459.9 C 120.4,459.9 121.2,459.9 122.0,459.8 C 122.8,459.8 123.7,459.7 124.5,459.7 C 125.3,459.6 126.1,459.6 127.0,459.4 C 127.8,459.3 128.6,459.0 129.4,458.7 C 130.2,458.3 131.1,457.6 131.9,457.3 C 132.7,457.1 133.5,457.2 134.3,457.1 C 135.2,457.1 136.0,457.1 136.8,457.0 C 137.6,457.0 138.0,457.0 139.3,456.9 C 140.5,456.9 142.6,456.9 144.2,456.9 C 145.8,456.8 147.9,456.8 149.1,456.8 C 150.4,456.7 150.8,456.7 151.6,456.7 C 152.4,456.6 153.2,456.6 154.1,456.5 C 154.9,456.5 155.7,456.5 156.5,456.4 C 157.3,456.4 158.2,456.4 159.0,456.4 C 159.8,456.4 160.2,456.4 161.4,456.3 C 162.7,456.3 164.3,456.2 166.4,456.2 C 168.4,456.2 171.7,456.2 173.8,456.2 C 175.8,456.1 176.2,456.2 178.7,456.1 C 181.1,456.1 186.5,455.9 188.5,455.9 C 190.6,455.8 189.8,455.9 191.0,455.8 C 192.2,455.8 193.9,455.8 195.9,455.8 C 198.0,455.8 199.2,455.7 203.3,455.7 C 207.4,455.6 217.3,455.5 220.6,455.5 C 223.9,455.5 222.2,455.5 223.0,455.4 C 223.9,455.4 224.3,456.1 225.5,455.4 C 226.7,454.7 229.2,454.1 230.4,451.2 C 231.7,448.4 232.1,440.7 232.9,438.3 C 233.7,435.9 234.5,437.2 235.3,436.8 C 236.2,436.5 237.0,436.2 237.8,436.0 C 238.6,435.7 239.5,435.5 240.3,435.4 C 241.1,435.2 241.9,435.2 242.7,435.0 C 243.6,434.9 244.4,434.8 245.2,434.7 C 246.0,434.6 246.8,434.7 247.7,434.7 C 248.5,434.6 249.3,434.4 250.1,434.3 C 250.9,434.2 251.8,434.2 252.6,434.1 C 253.4,434.0 254.2,433.6 255.1,433.5 C 255.9,433.4 256.7,433.4 257.5,433.3 C 258.3,433.3 258.8,433.1 260.0,433.0 C 261.2,432.9 263.7,432.9 264.9,432.8 C 266.1,432.7 266.6,432.7 267.4,432.6 C 268.2,432.6 269.0,432.6 269.8,432.5 C 270.7,432.4 271.5,432.1 272.3,432.0 C 273.1,431.8 273.9,432.5 274.8,431.6 C 275.6,430.7 276.4,427.5 277.2,426.5 C 278.0,425.4 278.9,425.5 279.7,425.4 C 280.5,425.2 281.3,425.4 282.2,425.3 C 283.0,425.2 283.8,424.8 284.6,424.5 C 285.4,424.2 286.3,424.1 287.1,423.6 C 287.9,423.0 288.7,422.1 289.5,421.5 C 290.4,420.9 291.2,420.3 292.0,419.9 C 292.8,419.4 293.6,419.1 294.5,418.8 C 295.3,418.6 296.1,418.5 296.9,418.4 C 297.8,418.2 298.6,418.2 299.4,418.1 C 300.2,418.0 301.0,417.8 301.9,417.7 C 302.7,417.6 303.5,417.6 304.3,417.6 C 305.1,417.5 306.0,417.5 306.8,417.4 C 307.6,417.2 308.4,417.3 309.3,416.9 C 310.1,416.5 310.9,415.4 311.7,414.9 C 312.5,414.4 313.4,414.2 314.2,413.9 C 315.0,413.5 315.8,414.2 316.6,412.7 C 317.5,411.1 318.3,406.3 319.1,404.5 C 319.9,402.6 320.7,402.3 321.6,401.6 C 322.4,400.8 323.2,400.3 324.0,399.8 C 324.9,399.2 325.7,398.7 326.5,398.3 C 327.3,397.9 328.1,397.5 329.0,397.2 C 329.8,397.0 330.6,396.9 331.4,396.7 C 332.2,396.5 333.1,396.2 333.9,395.9 C 334.7,395.6 335.5,395.1 336.4,394.9 C 337.2,394.6 338.0,394.6 338.8,394.4 C 339.6,394.1 340.5,393.7 341.3,393.3 C 342.1,393.0 342.9,392.8 343.7,392.5 C 344.6,392.3 345.4,392.1 346.2,391.9 C 347.0,391.8 347.8,391.7 348.7,391.6 C 349.5,391.5 350.3,391.3 351.1,391.1 C 352.0,390.9 352.8,390.8 353.6,390.6 C 354.4,390.3 355.2,390.2 356.1,389.8 C 356.9,389.5 357.7,388.9 358.5,388.6 C 359.3,388.4 360.2,388.3 361.0,388.1 C 361.8,387.9 362.6,387.8 363.4,387.7 C 364.3,387.6 365.1,387.5 365.9,387.4 C 366.7,387.3 367.6,387.1 368.4,387.1 C 369.2,387.0 370.0,387.0 370.8,386.9 C 371.7,386.8 372.5,386.6 373.3,386.3 C 374.1,386.0 374.9,385.5 375.8,385.2 C 376.6,384.9 377.4,384.5 378.2,384.3 C 379.1,384.0 379.9,384.0 380.7,383.9 C 381.5,383.7 382.3,383.7 383.2,383.4 C 384.0,383.2 384.8,382.7 385.6,382.5 C 386.4,382.3 387.3,382.3 388.1,382.2 C 388.9,382.1 389.7,381.8 390.5,381.6 C 391.4,381.5 392.2,381.3 393.0,381.1 C 393.8,380.9 394.7,380.7 395.5,380.5 C 396.3,380.3 397.1,380.2 397.9,380.0 C 398.8,379.8 399.6,379.4 400.4,379.2 C 401.2,378.9 402.0,378.8 402.9,378.6 C 403.7,378.3 404.5,378.0 405.3,377.8 C 406.1,377.5 407.0,377.2 407.8,376.9 C 408.6,376.7 409.4,376.6 410.3,376.2 C 411.1,375.8 411.9,374.9 412.7,374.5 C 413.5,374.2 414.4,374.1 415.2,374.0 C 416.0,373.9 416.8,373.9 417.6,373.8 C 418.5,373.7 419.3,373.5 420.1,373.4 C 420.9,373.3 421.8,373.5 422.6,373.1 C 423.4,372.7 424.2,371.7 425.0,371.2 C 425.9,370.8 426.7,370.9 427.5,370.6 C 428.3,370.4 429.1,369.9 430.0,369.7 C 430.8,369.5 431.6,369.6 432.4,369.4 C 433.2,369.2 434.1,368.7 434.9,368.5 C 435.7,368.3 436.5,368.3 437.4,368.2 C 438.2,368.1 439.0,368.1 439.8,367.9 C 440.6,367.7 441.5,367.4 442.3,366.9 C 443.1,366.4 443.9,365.4 444.7,364.8 C 445.6,364.3 446.4,364.1 447.2,363.7 C 448.0,363.3 448.9,363.2 449.7,362.5 C 450.5,361.8 451.3,360.1 452.1,359.3 C 453.0,358.5 453.8,358.1 454.6,357.6 C 455.4,357.1 456.2,357.1 457.1,356.4 C 457.9,355.6 458.7,354.0 459.5,353.3 C 460.3,352.6 461.2,352.5 462.0,352.0 C 462.8,351.6 463.6,351.3 464.5,350.8 C 465.3,3Line truncated
<path d="M 90.0,462.2 C 90.4,462.1 92.1,461.8 92.5,461.7 C 93.3,461.6 94.1,461.4 94.9,461.2 C 95.7,461.1 96.6,460.8 97.4,460.8 C 98.2,460.7 99.0,460.7 99.9,460.7 C 100.7,460.7 101.5,460.6 102.3,460.6 C 103.1,460.5 104.0,460.5 104.8,460.4 C 105.6,460.4 106.0,460.3 107.2,460.3 C 108.5,460.2 110.9,460.1 112.2,460.1 C 113.4,460.1 113.8,460.0 114.6,460.0 C 115.5,460.0 116.3,460.0 117.1,459.9 C 117.9,459.9 118.7,459.9 119.6,459.9 C 120.4,459.9 121.2,459.9 122.0,459.8 C 122.8,459.8 123.7,459.7 124.5,459.7 C 125.3,459.6 126.1,459.6 127.0,459.4 C 127.8,459.3 128.6,459.0 129.4,458.7 C 130.2,458.3 131.1,457.6 131.9,457.3 C 132.7,457.1 133.5,457.2 134.3,457.1 C 135.2,457.1 136.0,457.1 136.8,457.0 C 137.6,457.0 138.0,457.0 139.3,456.9 C 140.5,456.9 142.6,456.9 144.2,456.9 C 145.8,456.8 147.9,456.8 149.1,456.8 C 150.4,456.7 150.8,456.7 151.6,456.7 C 152.4,456.6 153.2,456.6 154.1,456.5 C 154.9,456.5 155.7,456.5 156.5,456.4 C 157.3,456.4 158.2,456.4 159.0,456.4 C 159.8,456.4 160.2,456.4 161.4,456.3 C 162.7,456.3 164.3,456.2 166.4,456.2 C 168.4,456.2 171.7,456.2 173.8,456.2 C 175.8,456.1 176.2,456.2 178.7,456.1 C 181.1,456.1 186.5,455.9 188.5,455.9 C 190.6,455.8 189.8,455.9 191.0,455.8 C 192.2,455.8 193.9,455.8 195.9,455.8 C 198.0,455.8 199.2,455.7 203.3,455.7 C 207.4,455.6 217.3,455.5 220.6,455.5 C 223.9,455.5 222.2,455.5 223.0,455.4 C 223.9,455.4 224.3,456.1 225.5,455.4 C 226.7,454.7 229.2,454.1 230.4,451.2 C 231.7,448.4 232.1,440.7 232.9,438.3 C 233.7,435.9 234.5,437.2 235.3,436.8 C 236.2,436.5 237.0,436.2 237.8,436.0 C 238.6,435.7 239.5,435.5 240.3,435.4 C 241.1,435.2 241.9,435.2 242.7,435.0 C 243.6,434.9 244.4,434.8 245.2,434.7 C 246.0,434.6 246.8,434.7 247.7,434.7 C 248.5,434.6 249.3,434.4 250.1,434.3 C 250.9,434.2 251.8,434.2 252.6,434.1 C 253.4,434.0 254.2,433.6 255.1,433.5 C 255.9,433.4 256.7,433.4 257.5,433.3 C 258.3,433.3 258.8,433.1 260.0,433.0 C 261.2,432.9 263.7,432.9 264.9,432.8 C 266.1,432.7 266.6,432.7 267.4,432.6 C 268.2,432.6 269.0,432.6 269.8,432.5 C 270.7,432.4 271.5,432.1 272.3,432.0 C 273.1,431.8 273.9,432.5 274.8,431.6 C 275.6,430.7 276.4,427.5 277.2,426.5 C 278.0,425.4 278.9,425.5 279.7,425.4 C 280.5,425.2 281.3,425.4 282.2,425.3 C 283.0,425.2 283.8,424.8 284.6,424.5 C 285.4,424.2 286.3,424.1 287.1,423.6 C 287.9,423.0 288.7,422.1 289.5,421.5 C 290.4,420.9 291.2,420.3 292.0,419.9 C 292.8,419.4 293.6,419.1 294.5,418.8 C 295.3,418.6 296.1,418.5 296.9,418.4 C 297.8,418.2 298.6,418.2 299.4,418.1 C 300.2,418.0 301.0,417.8 301.9,417.7 C 302.7,417.6 303.5,417.6 304.3,417.6 C 305.1,417.5 306.0,417.5 306.8,417.4 C 307.6,417.2 308.4,417.3 309.3,416.9 C 310.1,416.5 310.9,415.4 311.7,414.9 C 312.5,414.4 313.4,414.2 314.2,413.9 C 315.0,413.5 315.8,414.2 316.6,412.7 C 317.5,411.1 318.3,406.3 319.1,404.5 C 319.9,402.6 320.7,402.3 321.6,401.6 C 322.4,400.8 323.2,400.3 324.0,399.8 C 324.9,399.2 325.7,398.7 326.5,398.3 C 327.3,397.9 328.1,397.5 329.0,397.2 C 329.8,397.0 330.6,396.9 331.4,396.7 C 332.2,396.5 333.1,396.2 333.9,395.9 C 334.7,395.6 335.5,395.1 336.4,394.9 C 337.2,394.6 338.0,394.6 338.8,394.4 C 339.6,394.1 340.5,393.7 341.3,393.3 C 342.1,393.0 342.9,392.8 343.7,392.5 C 344.6,392.3 345.4,392.1 346.2,391.9 C 347.0,391.8 347.8,391.7 348.7,391.6 C 349.5,391.5 350.3,391.3 351.1,391.1 C 352.0,390.9 352.8,390.8 353.6,390.6 C 354.4,390.3 355.2,390.2 356.1,389.8 C 356.9,389.5 357.7,388.9 358.5,388.6 C 359.3,388.4 360.2,388.3 361.0,388.1 C 361.8,387.9 362.6,387.8 363.4,387.7 C 364.3,387.6 365.1,387.5 365.9,387.4 C 366.7,387.3 367.6,387.1 368.4,387.1 C 369.2,387.0 370.0,387.0 370.8,386.9 C 371.7,386.8 372.5,386.6 373.3,386.3 C 374.1,386.0 374.9,385.5 375.8,385.2 C 376.6,384.9 377.4,384.5 378.2,384.3 C 379.1,384.0 379.9,384.0 380.7,383.9 C 381.5,383.7 382.3,383.7 383.2,383.4 C 384.0,383.2 384.8,382.7 385.6,382.5 C 386.4,382.3 387.3,382.3 388.1,382.2 C 388.9,382.1 389.7,381.8 390.5,381.6 C 391.4,381.5 392.2,381.3 393.0,381.1 C 393.8,380.9 394.7,380.7 395.5,380.5 C 396.3,380.3 397.1,380.2 397.9,380.0 C 398.8,379.8 399.6,379.4 400.4,379.2 C 401.2,378.9 402.0,378.8 402.9,378.6 C 403.7,378.3 404.5,378.0 405.3,377.8 C 406.1,377.5 407.0,377.2 407.8,376.9 C 408.6,376.7 409.4,376.6 410.3,376.2 C 411.1,375.8 411.9,374.9 412.7,374.5 C 413.5,374.2 414.4,374.1 415.2,374.0 C 416.0,373.9 416.8,373.9 417.6,373.8 C 418.5,373.7 419.3,373.5 420.1,373.4 C 420.9,373.3 421.8,373.5 422.6,373.1 C 423.4,372.7 424.2,371.7 425.0,371.2 C 425.9,370.8 426.7,370.9 427.5,370.6 C 428.3,370.4 429.1,369.9 430.0,369.7 C 430.8,369.5 431.6,369.6 432.4,369.4 C 433.2,369.2 434.1,368.7 434.9,368.5 C 435.7,368.3 436.5,368.3 437.4,368.2 C 438.2,368.1 439.0,368.1 439.8,367.9 C 440.6,367.7 441.5,367.4 442.3,366.9 C 443.1,366.4 443.9,365.4 444.7,364.8 C 445.6,364.3 446.4,364.1 447.2,363.7 C 448.0,363.3 448.9,363.2 449.7,362.5 C 450.5,361.8 451.3,360.1 452.1,359.3 C 453.0,358.5 453.8,358.1 454.6,357.6 C 455.4,357.1 456.2,357.1 457.1,356.4 C 457.9,355.6 458.7,354.0 459.5,353.3 C 460.3,352.6 461.2,352.5 462.0,352.0 C 462.8,351.6 463.6,351.3 464.5,350.8 C 465.3,3Line truncated
<circle cx="765.0" cy="113.6" r="5" fill="#dd4528" stroke="#fff" stroke-width="2" filter="url(#xkcdify)"/>
<text x="753.0" y="77.0" fill="#dd4528" font-size="18" font-family="Handlee, cursive" font-weight="bold" text-anchor="end">6385</text>
<a href="https://github.com/carsteneu/mystarhistory" target="_blank" rel="noopener"><text x="763" y="527" font-size="13" font-family="Handlee, cursive" fill="#000" text-anchor="end">Made with carsteneu/mystarhistory</text></a>
</svg>

Before

Width:  |  Height:  |  Size: 34 KiB

After

Width:  |  Height:  |  Size: 35 KiB

+21 -1
View File
@@ -2,7 +2,7 @@ import test from "node:test";
import assert from "node:assert/strict";
import { buildAITerminalSessionInfo } from "./buildAITerminalSessionInfo.ts";
import type { Host, TerminalSession } from "../../types";
import type { Host, TerminalSession } from "../types";
const baseHost = (overrides: Partial<Host> = {}): Host =>
({
@@ -93,3 +93,23 @@ test("prefers a session custom name over the shared host label", () => {
);
assert.equal(info.label, "Bastion - production");
});
test("only saved remote hosts can receive persistent server approvals", () => {
const saved = baseHost();
assert.equal(buildAITerminalSessionInfo(baseSession(), saved, "linux", {
allHosts: [saved],
}).savedHostId, "h1");
assert.equal(buildAITerminalSessionInfo(baseSession(), saved, "linux", {
allHosts: [],
}).savedHostId, undefined);
const temporary = baseHost({ ephemeral: true });
assert.equal(buildAITerminalSessionInfo(baseSession(), temporary, "linux", {
allHosts: [temporary],
}).savedHostId, undefined);
assert.equal(buildAITerminalSessionInfo(baseSession({ protocol: "local" }), saved, "linux", {
allHosts: [saved],
}).savedHostId, undefined);
assert.equal(buildAITerminalSessionInfo(baseSession({ protocol: "serial" }), saved, "linux", {
allHosts: [saved],
}).savedHostId, undefined);
});
+6
View File
@@ -1,10 +1,12 @@
import { classifyDistroId, resolveHostOs } from './host';
import { isSavedVaultHost } from './ephemeralHosts';
import type { PortForwardingRule } from './models';
import type { Host, TerminalSession } from '../types';
export type AITerminalSessionInfo = {
sessionId: string;
hostId: string;
savedHostId?: string;
hostname: string;
label: string;
os?: string;
@@ -50,6 +52,9 @@ export const buildAITerminalSessionInfo = (
const protocol = session?.protocol || host?.protocol;
const isLocalSession = protocol === 'local' || session?.hostId?.startsWith('local-');
const allHosts = options?.allHosts ?? (host ? [host] : []);
const savedHostId = !isLocalSession && protocol !== 'serial'
? allHosts.find((entry) => entry.id === session?.hostId && isSavedVaultHost(entry))?.id
: undefined;
const hostChain = summarizeHostChain(host, allHosts);
const activePortForwards = host?.id && options?.portForwardingRules
? options.portForwardingRules
@@ -81,6 +86,7 @@ export const buildAITerminalSessionInfo = (
return {
sessionId: session?.id || '',
hostId: session?.hostId || '',
...(savedHostId ? { savedHostId } : {}),
hostname: host?.hostname || session?.hostname || '',
// A session rename is scoped to the individual terminal tab. Prefer it
// over the saved Host label so duplicate connections to the same host can
+1 -1
View File
@@ -1,4 +1,4 @@
import { remoteSoftwareRequiresSingleChannel } from './singleChannelSshBanner.shared.cjs';
import { remoteSoftwareRequiresSingleChannel } from './singleChannelSshBanner.shared.mjs';
import { Host, Snippet, TerminalSettings } from './models';
import type { HostOperatingSystem, HostOsSelection } from './models/connection';
import { sanitizeHostIconFields } from './hostIcon';
+2
View File
@@ -86,6 +86,8 @@ export interface TransferTask {
childTasks?: string[]; // For directory transfers
parentTaskId?: string;
sourceLastModified?: number; // Cached from file list to avoid redundant stat
/** Bounded remote preflight already skipped this file; keep the cap after panel remount. */
preflightStatSkipped?: boolean;
skipConflictCheck?: boolean; // Skip conflict check for replace operations
replaceExistingTarget?: boolean; // Delete the existing target before transferring
retryable?: boolean; // False for task types that cannot be safely replayed through generic retry
+7
View File
@@ -961,6 +961,13 @@ export const SETTINGS_SEARCH_CATALOG: readonly SettingsSearchEntry[] = [
descriptionKey: "settings.globalHotkey.closeToTrayDesc",
sectionKey: "settings.globalHotkey.title",
},
{
id: "system-show-tray-icon",
tab: "system",
labelKey: "settings.globalHotkey.showTrayIcon",
descriptionKey: "settings.globalHotkey.showTrayIconDesc",
sectionKey: "settings.globalHotkey.title",
},
// Plugins
{
+7
View File
@@ -156,6 +156,13 @@ test("restoring terminal tasks strips stale conflict payloads (skip-without-clea
assert.equal(restored.tasks[1]?.conflict?.fileName, "css");
});
test("a preflight-skipped conflict keeps its stat limit after restart", () => {
const pending = { ...task("late-file", "attention", 1), preflightStatSkipped: true };
const restored = deserializeSftpTransferCenter(serializeSftpTransferCenter([pending]));
assert.equal(restored.tasks[0]?.status, "attention");
assert.equal(restored.tasks[0]?.preflightStatSkipped, true);
});
test("history keeps unfinished tasks and caps terminal tasks by age and count", () => {
const now = Date.UTC(2026, 6, 23);
const old = now - 31 * 24 * 60 * 60 * 1000;
+2 -1
View File
@@ -34,7 +34,7 @@ const SAFE_TASK_KEYS: ReadonlySet<keyof TransferTask> = new Set([
"sourceConnectionId", "targetConnectionId", "targetHostId", "targetConnectionKey",
"direction", "status", "totalBytes", "transferredBytes", "speed", "error",
"startTime", "endTime", "isDirectory", "progressMode", "childTasks", "parentTaskId",
"sourceLastModified", "skipConflictCheck", "replaceExistingTarget", "retryable",
"sourceLastModified", "preflightStatSkipped", "skipConflictCheck", "replaceExistingTarget", "retryable",
"ownerId", "sourceHostId", "sourceHostLabel", "targetHostLabel", "origin", "background",
"phase", "controlKind", "resumable", "checkpointBytes", "priority", "updatedAt", "pauseUnavailableReason",
"resumeStage", "downloadCheckpointBytes", "uploadCheckpointBytes",
@@ -58,6 +58,7 @@ export function sanitizeSftpTransferTask(value: unknown): TransferTask | null {
if (source[key] !== undefined) sanitized[key] = source[key];
}
const task = sanitized as unknown as TransferTask;
if (task.preflightStatSkipped !== true) task.preflightStatSkipped = undefined;
if (!Number.isSafeInteger(task.directoryEntryIndex) || (task.directoryEntryIndex ?? -1) < 0) {
task.directoryEntryIndex = undefined;
}
@@ -0,0 +1,7 @@
export const SINGLE_CHANNEL_SOFTWARE_TOKENS: readonly string[];
export function remoteSoftwareRequiresSingleChannel(remoteSshVersion?: string): boolean;
export function remoteDisallowsChunkedChannelWrite(remoteSshVersion?: string): boolean;
export function optionsForPeerSingleChannel<T extends { singleChannelSsh?: boolean }>(
options: T,
conn?: { _remoteVer?: string } | null,
): T;
+70
View File
@@ -0,0 +1,70 @@
"use strict";
// SSH software tokens that allow one session channel per TCP connection.
// Match the identification software token only. Do not match product names,
// OpenSSH, JumpServer/KoKo, or a "term-sshd" substring inside another token.
//
// BHostSSH_7.0 — issue #3146 log field remoteSshVersion. PR #3313: a second
// exec channel tears down the first interactive session. Multi-character
// channel writes are also dropped on this banner.
// TERM-SSHD — issue #2923 comment 5314292157 logs remoteSshVersion
// "TERM-SSHD". PR #3030: a later session channel on that TCP is accepted
// and the shell exits 0.
// CLOUDBILITY-4.14 — PR #3458 reproduction note: SSH-2.0-CLOUDBILITY-4.14
// drops the interactive shell when another session channel opens. No other
// public packet was found; do not add aliases.
// JumpServer KoKo advertises Version "JumpServer" and multiplexes. Omit it.
const SINGLE_CHANNEL_SOFTWARE_TOKENS = Object.freeze([
"BHostSSH",
"TERM-SSHD",
"CLOUDBILITY",
]);
// #3146 also drops multi-character writes. That evidence is only for these
// two tokens, not for CLOUDBILITY.
const CHUNKED_WRITE_SOFTWARE_TOKENS = Object.freeze([
"BHostSSH",
"TERM-SSHD",
]);
function escapeRegExp(value) {
return String(value).replace(/[.*+?^${}()|[\]\\]/g, "\\$&");
}
function softwareTokenMatches(remoteSshVersion, token) {
const software = String(remoteSshVersion || "").trim();
if (!software) return false;
const pattern = new RegExp(
`(?:^|[^A-Za-z0-9])${escapeRegExp(token)}(?:[^A-Za-z0-9]|$)`,
"i",
);
return pattern.test(software);
}
function remoteSoftwareMatchesAny(remoteSshVersion, tokens) {
return tokens.some((token) => softwareTokenMatches(remoteSshVersion, token));
}
function remoteSoftwareRequiresSingleChannel(remoteSshVersion) {
return remoteSoftwareMatchesAny(remoteSshVersion, SINGLE_CHANNEL_SOFTWARE_TOKENS);
}
function remoteDisallowsChunkedChannelWrite(remoteSshVersion) {
return remoteSoftwareMatchesAny(remoteSshVersion, CHUNKED_WRITE_SOFTWARE_TOKENS);
}
function optionsForPeerSingleChannel(options, conn) {
const remoteSshVersion = conn && typeof conn._remoteVer === "string" ? conn._remoteVer : "";
if (!options || options.singleChannelSsh === true || !remoteSoftwareRequiresSingleChannel(remoteSshVersion)) {
return options;
}
return { ...options, singleChannelSsh: true };
}
export {
SINGLE_CHANNEL_SOFTWARE_TOKENS,
remoteSoftwareRequiresSingleChannel,
remoteDisallowsChunkedChannelWrite,
optionsForPeerSingleChannel,
};
@@ -69,3 +69,18 @@ test("optionsForPeerSingleChannel stamps the runtime flag from the peer banner",
true,
);
});
test("CommonJS and renderer modules agree on SSH banner behavior", async () => {
const browserModule = await import("./singleChannelSshBanner.shared.mjs");
const nodeModule = require("./singleChannelSshBanner.shared.cjs");
for (const version of [
"BHostSSH_7.0", "TERM-SSHD", "CLOUDBILITY-4.14", "OpenSSH_9.6", "JumpServer", "",
]) {
assert.equal(browserModule.remoteSoftwareRequiresSingleChannel(version), nodeModule.remoteSoftwareRequiresSingleChannel(version));
assert.equal(browserModule.remoteDisallowsChunkedChannelWrite(version), nodeModule.remoteDisallowsChunkedChannelWrite(version));
assert.deepEqual(
browserModule.optionsForPeerSingleChannel({ singleChannelSsh: false }, { _remoteVer: version }),
nodeModule.optionsForPeerSingleChannel({ singleChannelSsh: false }, { _remoteVer: version }),
);
}
});
+1 -1
View File
@@ -30,7 +30,7 @@ await nct.dialog.alert('Netcatty script smoke test OK');
/** Full integration test for onConnect / manual run; dialog API enabled by default. */
export const SCRIPT_INTEGRATION_TEST = `// Netcatty Integration Test — onConnect / manual full API exercise
// Trigger: onConnect or Run now | Permission: Auto or Confirm (dialogs need non-Observer)
// Trigger: onConnect or Run now
const CONFIG = {
SAMPLE_COUNT: 8,
+28 -1
View File
@@ -1,7 +1,10 @@
import assert from 'node:assert/strict';
import test from 'node:test';
import { resolveTerminalBroadcastTargetIds } from './terminalBroadcast.ts';
import {
resolveTerminalBroadcastTargetIds,
shouldBroadcastDuringSensitivePrompt,
} from './terminalBroadcast.ts';
import type { TerminalSession } from './models.ts';
const session = (
@@ -89,3 +92,27 @@ test('missing or hidden global sources do not fan out', () => {
[],
);
});
test('sensitive prompts pause broadcasting unless the password bypass is on', () => {
assert.equal(
shouldBroadcastDuringSensitivePrompt({
sensitivePromptActive: false,
broadcastPasswordBypass: false,
}),
true,
);
assert.equal(
shouldBroadcastDuringSensitivePrompt({
sensitivePromptActive: true,
broadcastPasswordBypass: false,
}),
false,
);
assert.equal(
shouldBroadcastDuringSensitivePrompt({
sensitivePromptActive: true,
broadcastPasswordBypass: true,
}),
true,
);
});
+13
View File
@@ -2,6 +2,19 @@ import type { TerminalSession } from './models';
type BroadcastSession = Pick<TerminalSession, 'id' | 'workspaceId' | 'hiddenFromTabs'>;
/**
* The fail-closed password-prompt heuristic pauses broadcasting while an
* untrusted prompt is on screen. With the opt-in bypass enabled (SecureCRT
* "Send commands to all sessions" style), input keeps flowing to peer tabs
* even during interactive / password prompts.
*/
export function shouldBroadcastDuringSensitivePrompt(input: {
sensitivePromptActive: boolean;
broadcastPasswordBypass: boolean;
}): boolean {
return !input.sensitivePromptActive || input.broadcastPasswordBypass;
}
export function resolveTerminalBroadcastTargetIds({
sessions,
sourceSessionId,
+1
View File
@@ -71,6 +71,7 @@ module.exports = {
// Main-process SSH, SFTP, and port-forward bridges share this matcher.
// TypeScript under domain/ is excluded below, so the .cjs must be listed.
'domain/singleChannelSshBanner.shared.cjs',
'domain/singleChannelSshBanner.shared.mjs',
'lib/**/*.cjs',
'lib/**/*.json',
'skills/**/*',
+86 -81
View File
@@ -832,93 +832,98 @@ function getWindowsKnownCliPathDirs(env = process.env) {
return dirs.filter((dir) => existsSync(dir));
}
// The hives a brand-new shell reads its PATH from, in Windows' own order:
// user scope first, then machine scope.
// Windows appends the user PATH to the machine PATH. Keep that ordering even
// though both registry queries run concurrently.
const WINDOWS_PATH_HIVES = [
"HKCU\\Environment",
"HKLM\\SYSTEM\\CurrentControlSet\\Control\\Session Manager\\Environment",
"HKCU\\Environment",
];
function collectWindowsRegistryPath(stdout, env, parts) {
const raw = parseRegQueryPath(stdout);
if (raw) parts.push(expandWindowsEnvRefs(raw, env));
}
async function readWindowsRegistryPath({ exec = execFileAsync, env = process.env } = {}) {
const parts = [];
for (const hive of WINDOWS_PATH_HIVES) {
try {
const { stdout } = await exec("reg", ["query", hive, "/v", "Path"], {
encoding: "utf8",
timeout: 3000,
});
collectWindowsRegistryPath(stdout, env, parts);
} catch {
// Hive unreadable / value missing — skip and rely on other sources.
}
}
return parts.join(";");
}
/**
* Synchronous twin of readWindowsRegistryPath() for callers that cannot await,
* such as local terminal startup, which spawns the PTY inline.
*/
function readWindowsRegistryPathSync({ exec = execFileSync, env = process.env } = {}) {
const parts = [];
for (const hive of WINDOWS_PATH_HIVES) {
try {
const stdout = exec("reg", ["query", hive, "/v", "Path"], {
encoding: "utf8",
timeout: 3000,
windowsHide: true,
});
collectWindowsRegistryPath(stdout, env, parts);
} catch {
// Hive unreadable / value missing — skip and rely on other sources.
}
}
return parts.join(";");
}
/**
* Windows only: put the live registry PATH ahead of an already-captured PATH.
*
* Long-lived processes keep the PATH they were launched with, so anything the
* user installs or upgrades afterwards is invisible to them — including tools
* whose installer rewrites a versioned user-PATH entry, where the stale entry
* points at a directory the upgrade removed. Merging the registry value (what
* a fresh shell inherits) ahead of the captured one resolves those CLIs
* without a restart. Returns `basePath` unchanged off Windows, or when the
* registry cannot be read.
*/
function resolveWindowsLivePath({
basePath,
exec,
async function readWindowsRegistryPaths({
exec = execFileAsync,
env = process.env,
platform = process.platform,
knownCliDirs = getWindowsKnownCliPathDirs,
timeoutMs = 1000,
} = {}) {
if (platform !== "win32") return basePath;
let registryPath = "";
try {
registryPath = readWindowsRegistryPathSync({ exec, env });
} catch {
registryPath = "";
}
let knownDirs = "";
try {
knownDirs = (knownCliDirs(env) || []).join(";");
} catch {
knownDirs = "";
}
if (!registryPath && !knownDirs) return basePath;
return mergeWindowsPath(registryPath, knownDirs, basePath || "");
return Promise.all(WINDOWS_PATH_HIVES.map(async (hive) => {
let timer;
try {
// The JS deadline also bounds an unresponsive process callback. The
// child-process timeout remains responsible for terminating reg.exe.
const result = await Promise.race([
exec("reg", ["query", hive, "/v", "Path"], {
encoding: "utf8",
timeout: timeoutMs,
windowsHide: true,
}),
new Promise((resolve) => {
timer = setTimeout(() => resolve(null), timeoutMs);
}),
]);
return result ? expandWindowsEnvRefs(parseRegQueryPath(result.stdout), env) : null;
} catch {
return null;
} finally {
clearTimeout(timer);
}
}));
}
async function readWindowsRegistryPath(options) {
return mergeWindowsPath(...await readWindowsRegistryPaths(options));
}
// Share a short-lived registry snapshot across simultaneous local terminals.
// Cache raw values so each terminal expands references using its own env.
function createWindowsLivePathResolver({
exec = execFileAsync,
now = Date.now,
ttlMs = 5000,
timeoutMs = 1000,
} = {}) {
const cachedPaths = [null, null];
let refreshedAt = -Infinity;
let pending = null;
return async function resolveWindowsLivePath({
basePath,
env = process.env,
platform = process.platform,
knownCliDirs = getWindowsKnownCliPathDirs,
} = {}) {
if (platform !== "win32") return basePath;
if (!pending && now() - refreshedAt >= ttlMs) {
pending = readWindowsRegistryPaths({ exec, env: {}, timeoutMs })
.then((paths) => {
// A failed hive retains its last successful value; one unavailable
// hive must not discard the other half of a cached PATH.
paths.forEach((value, index) => {
if (value !== null) cachedPaths[index] = value;
});
refreshedAt = now();
})
.finally(() => { pending = null; });
}
if (pending) await pending;
let knownDirs = "";
try {
knownDirs = (knownCliDirs(env) || []).join(";");
} catch { /* Best-effort fallback only. */ }
const registryPath = expandWindowsEnvRefs(mergeWindowsPath(...cachedPaths), env);
if (!registryPath && !knownDirs) return basePath;
// Registry order reflects a fresh shell; inherited entries remain a
// fallback, and guessed package-manager bins must never take precedence.
// With no machine snapshot, retain inherited system precedence rather
// than accidentally promoting user directories above system binaries.
return cachedPaths[0] === null
? mergeWindowsPath(basePath || "", registryPath, knownDirs)
: mergeWindowsPath(registryPath, basePath || "", knownDirs);
};
}
const resolveWindowsLivePath = createWindowsLivePathResolver();
async function getShellEnv() {
if (_cachedShellEnv) return _cachedShellEnv;
if (_shellEnvPromise) return _shellEnvPromise;
@@ -946,7 +951,7 @@ async function getShellEnv() {
const knownDirs = getWindowsKnownCliPathDirs().join(path.delimiter);
const nextEnv = {
...process.env,
PATH: mergeWindowsPath(registryPath, knownDirs, process.env.PATH || ""),
PATH: mergeWindowsPath(registryPath, process.env[getPathEnvKey(process.env, "win32")] || "", knownDirs),
};
if (generation === _shellEnvGeneration) {
_cachedShellEnv = nextEnv;
@@ -1062,7 +1067,7 @@ module.exports = {
expandWindowsEnvRefs,
mergeWindowsPath,
readWindowsRegistryPath,
readWindowsRegistryPathSync,
createWindowsLivePathResolver,
resolveWindowsLivePath,
getShellEnv,
invalidateShellEnvCache,
+93 -76
View File
@@ -21,8 +21,7 @@ const {
expandWindowsEnvRefs,
mergeWindowsPath,
readWindowsRegistryPath,
readWindowsRegistryPathSync,
resolveWindowsLivePath,
createWindowsLivePathResolver,
trackSessionIdlePrompt,
} = require("./shellUtils.cjs");
const fs = require("node:fs");
@@ -591,7 +590,7 @@ test("readWindowsRegistryPath merges HKCU and HKLM and expands refs", async () =
return { stdout: " Path REG_EXPAND_SZ C:\\Windows\\System32\r\n" };
};
const out = await readWindowsRegistryPath({ exec, env: { APPDATA: "C:\\Roaming" } });
assert.equal(out, "C:\\Roaming\\npm;C:\\Windows\\System32");
assert.equal(out, "C:\\Windows\\System32;C:\\Roaming\\npm");
});
test("readWindowsRegistryPath tolerates a failing hive query", async () => {
@@ -603,79 +602,6 @@ test("readWindowsRegistryPath tolerates a failing hive query", async () => {
assert.equal(out, "C:\\tools");
});
test("readWindowsRegistryPathSync mirrors the async reader for non-awaiting callers", () => {
const exec = (cmd, args) => {
assert.equal(cmd, "reg");
if (args[1] === "HKCU\\Environment") {
return " Path REG_EXPAND_SZ %APPDATA%\\npm\r\n";
}
return " Path REG_EXPAND_SZ C:\\Windows\\System32\r\n";
};
const out = readWindowsRegistryPathSync({ exec, env: { APPDATA: "C:\\Roaming" } });
assert.equal(out, "C:\\Roaming\\npm;C:\\Windows\\System32");
});
test("readWindowsRegistryPathSync tolerates a failing hive query", () => {
const exec = (cmd, args) => {
if (args[1] === "HKCU\\Environment") throw new Error("ERROR: cannot read");
return " Path REG_SZ C:\\tools\r\n";
};
assert.equal(readWindowsRegistryPathSync({ exec, env: {} }), "C:\\tools");
});
test("resolveWindowsLivePath front-loads a freshly installed CLI over the stale PATH", () => {
// The reported regression: a long-running app captured a PATH whose herdr
// entry still points at the previous release directory, which the upgrade
// removed, so the stale entry no longer resolves anything.
const stalePath = "C:\\Users\\me\\.herdr\\packages\\standalone\\releases\\0.8.2-x86_64;C:\\Windows\\System32";
const exec = (cmd, args) => {
if (args[1] === "HKCU\\Environment") {
return " Path REG_EXPAND_SZ C:\\Users\\me\\.herdr\\packages\\standalone\\releases\\0.9.1-x86_64\r\n";
}
return " Path REG_EXPAND_SZ C:\\Windows\\System32\r\n";
};
const out = resolveWindowsLivePath({
basePath: stalePath,
exec,
env: {},
platform: "win32",
knownCliDirs: () => [],
});
assert.equal(
out,
"C:\\Users\\me\\.herdr\\packages\\standalone\\releases\\0.9.1-x86_64;C:\\Windows\\System32;"
+ "C:\\Users\\me\\.herdr\\packages\\standalone\\releases\\0.8.2-x86_64",
);
});
test("resolveWindowsLivePath keeps the captured PATH off Windows", () => {
const exec = () => {
throw new Error("the registry must not be queried off Windows");
};
const out = resolveWindowsLivePath({
basePath: "/usr/local/bin:/usr/bin",
exec,
platform: "darwin",
});
assert.equal(out, "/usr/local/bin:/usr/bin");
});
test("resolveWindowsLivePath falls back to the captured PATH when the registry is unreadable", () => {
const exec = () => {
throw new Error("access denied");
};
const out = resolveWindowsLivePath({
basePath: "C:\\Windows\\System32",
exec,
env: {},
platform: "win32",
knownCliDirs: () => [],
});
assert.equal(out, "C:\\Windows\\System32");
});
test("tracks PowerShell idle prompt after SSH output", () => {
const session = {};
@@ -820,3 +746,94 @@ function withExecPath(fakePath, fn) {
Object.defineProperty(process, "execPath", { value: original, configurable: true, writable: true });
}
}
test("Windows live PATH refresh is shared, asynchronous, bounded, and cached", async () => {
let calls = 0;
let tick = 0;
const resolve = createWindowsLivePathResolver({
exec: () => { calls += 1; return new Promise(() => {}); },
timeoutMs: 20,
now: () => tick,
ttlMs: 50,
});
const options = { basePath: "C:\\Windows\\System32", platform: "win32", env: {}, knownCliDirs: () => [] };
let eventLoopRan = false;
setTimeout(() => { eventLoopRan = true; }, 0);
const first = resolve(options);
const second = resolve(options);
assert.equal(calls, 2, "concurrent terminals share one query per hive");
assert.deepEqual(await Promise.all([first, second]), [options.basePath, options.basePath]);
assert.equal(eventLoopRan, true, "registry reads must not block the main event loop");
await resolve(options);
assert.equal(calls, 2, "even failed queries are briefly cached");
tick = 51;
await resolve(options);
assert.equal(calls, 4, "expired snapshots refresh");
});
test("Windows live PATH keeps machine before user and guesses after inherited entries", async () => {
const resolve = createWindowsLivePathResolver({
exec: async (_cmd, args, options) => {
assert.equal(options.windowsHide, true);
assert.equal(options.timeout, 1000);
return { stdout: args[1].startsWith("HKLM")
? " Path REG_SZ C:\\Windows\\System32;C:\\shared\\\r\n"
: " Path REG_EXPAND_SZ %APPDATA%\\npm;c:\\SHARED;C:\\new-release\r\n" };
},
});
const result = await resolve({
basePath: "C:\\old-release;C:\\Windows\\System32;C:\\launcher",
env: { APPDATA: "C:\\Roaming" },
platform: "win32",
knownCliDirs: () => ["C:\\guess", "C:\\launcher"],
});
assert.equal(result, "C:\\Windows\\System32;C:\\shared\\;C:\\Roaming\\npm;C:\\new-release;C:\\old-release;C:\\launcher;C:\\guess");
});
test("Windows live PATH picks up upgraded directories after cache expiry", async () => {
let tick = 0;
let release = "v1";
let unavailable = false;
const resolve = createWindowsLivePathResolver({
now: () => tick,
ttlMs: 10,
exec: async () => {
if (unavailable) throw new Error("registry unavailable");
return { stdout: ` Path REG_SZ C:\\${release}\r\n` };
},
});
const options = { platform: "win32", basePath: "C:\\old", env: {}, knownCliDirs: () => [] };
assert.equal(await resolve(options), "C:\\v1;C:\\old");
release = "v2";
tick = 11;
assert.equal(await resolve(options), "C:\\v2;C:\\old");
unavailable = true;
tick = 22;
assert.equal(await resolve(options), "C:\\v2;C:\\old", "query failure retains the last successful snapshot");
});
test("cached Windows registry references expand per terminal environment", async () => {
const resolve = createWindowsLivePathResolver({
exec: async () => ({ stdout: " Path REG_EXPAND_SZ %APPDATA%\\npm\r\n" }),
});
const options = { platform: "win32", basePath: "", knownCliDirs: () => [] };
assert.equal(await resolve({ ...options, env: { APPDATA: "C:\\first" } }), "C:\\first\\npm");
assert.equal(await resolve({ ...options, env: { APPDATA: "C:\\second" } }), "C:\\second\\npm");
});
test("Windows live PATH resolver does not query the registry off Windows", async () => {
const resolve = createWindowsLivePathResolver({ exec: () => { throw new Error("must not query"); } });
assert.equal(await resolve({ basePath: "/usr/bin:/bin", platform: "linux" }), "/usr/bin:/bin");
});
test("a failed machine hive does not promote user directories ahead of inherited system paths", async () => {
const resolve = createWindowsLivePathResolver({
exec: async (_cmd, args) => {
if (args[1].startsWith("HKLM")) throw new Error("unavailable");
return { stdout: " Path REG_SZ C:\\user-tools\r\n" };
},
});
assert.equal(await resolve({
platform: "win32", basePath: "C:\\Windows\\System32", env: {}, knownCliDirs: () => [],
}), "C:\\Windows\\System32;C:\\user-tools");
});
@@ -135,11 +135,11 @@ function registerAgentProcessHandlers(ctx) {
});
// ── MCP Approval response (renderer → main) ──
ipcMain.handle("netcatty:ai:mcp:approval-response", async (event, { approvalId, approved }) => {
ipcMain.handle("netcatty:ai:mcp:approval-response", async (event, { approvalId, approved, scope }) => {
// Settings window also hosts External MCP approval cards.
if (!validateSenderOrSettings(event)) return { ok: false, error: "Unauthorized IPC sender" };
mcpServerBridge.resolveApprovalFromRenderer(approvalId, approved);
return { ok: true };
const accepted = mcpServerBridge.resolveApprovalFromRenderer(approvalId, approved, scope);
return { ok: true, accepted };
});
// Cancel MCP approval auto-deny after the user starts reviewing the card.
+52 -13
View File
@@ -19,6 +19,11 @@ let sendWhenRendererReady = null;
let getSystemMenuMainWindow = null;
let tray = null;
let closeToTray = false;
// User preference for whether the tray/menu bar icon is shown at all.
// Independent of close-to-tray: the app keeps running in the background with
// the icon hidden, and window close still hides (not quits) when close-to-tray
// was explicitly enabled.
let showTrayIcon = true;
let windowsTrayScaleListener = null;
let currentHotkey = null;
let hotkeyEnabled = false;
@@ -1292,18 +1297,12 @@ function setCloseToTray(enabled) {
closeToTray = !!enabled;
if (closeToTray) {
// Create tray if it doesn't exist
if (!tray) {
// Tray visibility is controlled separately by showTrayIcon.
if (!tray && showTrayIcon) {
createTray();
}
} else {
clearPendingFullscreenHide(getMainWindow());
// A hidden auto-launch cold start pins the tray regardless of this
// preference until its window is actually shown once — otherwise a user
// with close-to-tray off would get a windowless, trayless zombie process.
if (!hiddenLaunchTrayPinned) {
destroyTray();
}
}
return { success: true, enabled: closeToTray };
@@ -1321,18 +1320,45 @@ function pinTrayForHiddenLaunch() {
}
/**
* Release the hidden-launch tray pin once its window has been shown. If the
* user's close-to-tray preference is off, the tray is destroyed now instead
* of lingering until the next close-to-tray toggle.
* Release the hidden-launch tray pin once its window has been shown. The
* hidden icon preference can now take effect safely.
*/
function releaseHiddenLaunchTrayPin() {
if (!hiddenLaunchTrayPinned) return;
hiddenLaunchTrayPinned = false;
if (!closeToTray) {
if (!showTrayIcon) {
destroyTray();
}
}
/**
* Show or hide the tray icon without changing close-to-tray behavior. The
* app keeps running in the background while the icon is hidden; close-to-tray
* still hides the window on close.
*/
function setShowTrayIcon(enabled) {
showTrayIcon = !!enabled;
if (showTrayIcon) {
if (!tray) {
createTray();
}
} else if (!hiddenLaunchTrayPinned) {
// A hidden auto-launch cold start keeps its safety pin until its window
// is shown once, so a trayless zombie never appears without consent.
destroyTray();
}
return { success: true, enabled: showTrayIcon };
}
/**
* Check if the tray icon is currently meant to be shown
*/
function isShowTrayIconEnabled() {
return showTrayIcon;
}
/**
* Check if close-to-tray is enabled
*/
@@ -1354,7 +1380,10 @@ function getHotkeyStatus() {
* Handle window close event - hide to tray instead of closing
*/
function handleWindowClose(event, win) {
if (closeToTray && tray) {
// With the tray icon hidden by preference there is no `tray` object, but a
// user who enabled close-to-tray still expects the window to hide (app
// stays in the background), not to quit.
if (closeToTray && (tray || !showTrayIcon)) {
event.preventDefault();
hideWindowRespectingMacFullscreen(win);
return true; // Prevented close
@@ -1392,6 +1421,16 @@ function registerHandlers(ipcMain) {
return { enabled: closeToTray };
});
// Show/hide the tray icon itself (independent of close-to-tray)
ipcMain.handle("netcatty:tray:setShowTrayIcon", async (_event, { enabled }) => {
return setShowTrayIcon(enabled);
});
// Get show-tray-icon status
ipcMain.handle("netcatty:tray:isShowTrayIcon", async () => {
return { enabled: showTrayIcon };
});
// Update tray menu data
ipcMain.handle("netcatty:tray:updateMenuData", async (_event, data) => {
setTrayMenuData(data);
+109 -3
View File
@@ -1705,7 +1705,7 @@ test("a hidden-launch tray pin survives close-to-tray being turned off", async (
}
});
test("releasing the hidden-launch tray pin destroys the tray if close-to-tray is off", async () => {
test("releasing the hidden-launch tray pin keeps the visible tray when close-to-tray is off", async () => {
const bridge = loadBridge();
const electronModule = createElectronStub();
bridge.init({ electronModule, getMainWindow: () => null });
@@ -1719,7 +1719,27 @@ test("releasing the hidden-launch tray pin destroys the tray if close-to-tray is
bridge.releaseHiddenLaunchTrayPin();
assert.equal(bridge.getTray(), null, "once the window is shown, close-to-tray=off should win");
assert.notEqual(bridge.getTray(), null, "show-tray-icon=on keeps the tray visible");
} finally {
bridge.cleanup();
}
});
test("releasing the hidden-launch tray pin hides the icon when requested", async () => {
const bridge = loadBridge();
const electronModule = createElectronStub();
bridge.init({ electronModule, getMainWindow: () => null });
const ipcMain = createIpcMainStub();
bridge.registerHandlers(ipcMain);
try {
bridge.pinTrayForHiddenLaunch();
await ipcMain.handlers.get("netcatty:tray:setShowTrayIcon")(null, { enabled: false });
assert.notEqual(bridge.getTray(), null, "the hidden launch keeps its recovery icon");
bridge.releaseHiddenLaunchTrayPin();
assert.equal(bridge.getTray(), null);
} finally {
bridge.cleanup();
}
@@ -1757,7 +1777,7 @@ test("releaseHiddenLaunchTrayPin is a no-op when the pin was never set", () => {
}
});
test("setCloseToTray(false) still destroys an unpinned tray as before", async () => {
test("setCloseToTray(false) keeps a visible tray without hiding the window on close", async () => {
const bridge = loadBridge();
const electronModule = createElectronStub();
const { ipcMain } = await enableCloseToTray(bridge, electronModule);
@@ -1765,6 +1785,92 @@ test("setCloseToTray(false) still destroys an unpinned tray as before", async ()
try {
assert.notEqual(bridge.getTray(), null);
await ipcMain.handlers.get("netcatty:tray:setCloseToTray")(null, { enabled: false });
assert.notEqual(bridge.getTray(), null);
assert.equal(bridge.handleWindowClose({ preventDefault() { assert.fail("close was prevented"); } }, new FakeWindow()), false);
} finally {
bridge.cleanup();
}
});
test("setShowTrayIcon(false) destroys the tray even when close-to-tray stays on", async () => {
const bridge = loadBridge();
const electronModule = createElectronStub();
const { ipcMain } = await enableCloseToTray(bridge, electronModule);
try {
assert.notEqual(bridge.getTray(), null);
const result = await ipcMain.handlers.get("netcatty:tray:setShowTrayIcon")(null, { enabled: false });
assert.deepEqual(result, { success: true, enabled: false });
assert.equal(bridge.getTray(), null);
} finally {
bridge.cleanup();
}
});
test("handleWindowClose still hides to tray after the icon was hidden by preference", async () => {
await withPlatform("darwin", async () => {
const bridge = loadBridge();
const electronModule = createElectronStub();
const { ipcMain } = await enableCloseToTray(bridge, electronModule);
try {
await ipcMain.handlers.get("netcatty:tray:setShowTrayIcon")(null, { enabled: false });
assert.equal(bridge.getTray(), null);
const win = new FakeWindow({ fullscreen: false });
let prevented = false;
const result = bridge.handleWindowClose({ preventDefault() { prevented = true; } }, win);
assert.equal(result, true);
assert.equal(prevented, true);
assert.equal(win.hideCalls, 1);
} finally {
bridge.cleanup();
}
});
});
test("setShowTrayIcon(true) restores the tray when close-to-tray is on", async () => {
const bridge = loadBridge();
const electronModule = createElectronStub();
const { ipcMain } = await enableCloseToTray(bridge, electronModule);
try {
await ipcMain.handlers.get("netcatty:tray:setShowTrayIcon")(null, { enabled: false });
assert.equal(bridge.getTray(), null);
await ipcMain.handlers.get("netcatty:tray:setShowTrayIcon")(null, { enabled: true });
assert.notEqual(bridge.getTray(), null);
} finally {
bridge.cleanup();
}
});
test("setShowTrayIcon(true) creates a tray when close-to-tray is off", async () => {
const bridge = loadBridge();
const electronModule = createElectronStub();
bridge.init({ electronModule, getMainWindow: () => null });
const ipcMain = createIpcMainStub();
bridge.registerHandlers(ipcMain);
try {
await ipcMain.handlers.get("netcatty:tray:setCloseToTray")(null, { enabled: false });
await ipcMain.handlers.get("netcatty:tray:setShowTrayIcon")(null, { enabled: true });
assert.notEqual(bridge.getTray(), null);
} finally {
bridge.cleanup();
}
});
test("setCloseToTray(true) does not recreate the tray while the icon is hidden by preference", async () => {
const bridge = loadBridge();
const electronModule = createElectronStub();
bridge.init({ electronModule, getMainWindow: () => null });
const ipcMain = createIpcMainStub();
bridge.registerHandlers(ipcMain);
try {
await ipcMain.handlers.get("netcatty:tray:setShowTrayIcon")(null, { enabled: false });
await ipcMain.handlers.get("netcatty:tray:setCloseToTray")(null, { enabled: true });
assert.equal(bridge.getTray(), null);
} finally {
bridge.cleanup();
+84 -12
View File
@@ -63,6 +63,7 @@ const DISCOVERY_SELF_HEAL_INTERVAL_MS = 15000;
const authenticatedSockets = new WeakSet();
// Sockets authenticated with the External MCP token (or that used the reserved scope).
const externalMcpSockets = new Set();
const sessionApprovedExternalSockets = new WeakSet();
function markExternalMcpSocket(socket) {
if (!socket || socket.destroyed) return;
@@ -71,6 +72,8 @@ function markExternalMcpSocket(socket) {
socket.__netcattyExternalMcpCleanupBound = true;
const cleanup = () => {
externalMcpSockets.delete(socket);
sessionApprovedExternalSockets.delete(socket);
clearPendingApprovalsForSocket(socket);
};
socket.once("close", cleanup);
socket.once("end", cleanup);
@@ -85,6 +88,9 @@ function issueExternalMcpAuthToken() {
function revokeExternalMcpAuthToken() {
externalAuthToken = null;
for (const socket of externalMcpSockets) {
sessionApprovedExternalSockets.delete(socket);
}
}
function getExternalMcpAuthToken() {
@@ -97,6 +103,7 @@ function disconnectExternalMcpClients() {
// is reserved for process shutdown paths that call this intentionally.
for (const socket of Array.from(externalMcpSockets)) {
externalMcpSockets.delete(socket);
sessionApprovedExternalSockets.delete(socket);
try {
if (!socket.destroyed) socket.destroy();
} catch {
@@ -207,7 +214,7 @@ function broadcastApprovalEvent(channel, payload) {
}
}
function requestApprovalFromRenderer(toolName, args, chatSessionId) {
function requestApprovalFromRenderer(toolName, args, chatSessionId, approvalContext = {}) {
return new Promise((resolve) => {
debugLog("requestApprovalFromRenderer", { toolName, args, chatSessionId });
const targets = listApprovalTargetWindows();
@@ -261,25 +268,59 @@ function requestApprovalFromRenderer(toolName, args, chatSessionId) {
absoluteExpiresAt,
idleCancelled: false,
chatSessionId: chatSessionId || null,
externalSocket: approvalContext.externalSocket || null,
});
broadcastApprovalEvent('netcatty:ai:mcp:approval-request', {
approvalId,
toolName,
args,
chatSessionId: chatSessionId || undefined,
target: approvalContext.target || undefined,
allowSession: chatSessionId === EXTERNAL_MCP_CHAT_SESSION_ID
&& externalMcpSockets.has(approvalContext.externalSocket),
});
});
}
function resolveApprovalFromRenderer(approvalId, approved) {
function resolveApprovalFromRenderer(approvalId, approved, scope = 'once') {
debugLog("resolveApprovalFromRenderer", { approvalId, approved });
const entry = pendingApprovals.get(approvalId);
if (entry) {
pendingApprovals.delete(approvalId);
entry.resolve(approved);
// Main + settings both receive approval requests; clear the sibling card.
notifyRendererApprovalCleared([approvalId]);
if (!entry) return false;
pendingApprovals.delete(approvalId);
const externalSocket = entry.chatSessionId === EXTERNAL_MCP_CHAT_SESSION_ID
? entry.externalSocket
: null;
const socketValid = entry.chatSessionId !== EXTERNAL_MCP_CHAT_SESSION_ID
|| Boolean(externalSocket
&& !externalSocket.destroyed
&& externalMcpSockets.has(externalSocket)
&& externalMcpActivityHook?.isEnabled?.());
const accepted = approved === true && socketValid;
const clearedIds = [approvalId];
if (accepted && scope === 'session' && externalSocket) {
sessionApprovedExternalSockets.add(externalSocket);
for (const [id, pending] of pendingApprovals) {
if (pending.externalSocket !== externalSocket) continue;
pendingApprovals.delete(id);
pending.resolve(true);
clearedIds.push(id);
}
}
entry.resolve(accepted);
// Main + settings both receive approval requests; clear sibling cards.
notifyRendererApprovalCleared(clearedIds);
return socketValid;
}
function clearPendingApprovalsForSocket(socket) {
const clearedIds = [];
for (const [id, entry] of pendingApprovals) {
if (entry.externalSocket !== socket) continue;
pendingApprovals.delete(id);
entry.resolve(false);
clearedIds.push(id);
}
notifyRendererApprovalCleared(clearedIds);
}
/**
@@ -642,6 +683,7 @@ function syncLiveSessionsToExternalScope(chatSessionId = EXTERNAL_MCP_CHAT_SESSI
sessionList.push({
sessionId,
hostId: session.hostId || previous.hostId || "",
savedHostId: session.savedHostId || previous.savedHostId || "",
hostname: session.hostname || session.host || previous.hostname || "",
label: session.label || session.hostname || previous.label || sessionId,
os: session.os || previous.os || "",
@@ -726,6 +768,7 @@ function updateLiveSessionMetadata(sessionList) {
deviceType: entry.deviceType || "",
connected: entry.connected !== false,
hostId: entry.hostId || "",
savedHostId: entry.savedHostId || "",
hostChain: Array.isArray(entry.hostChain) ? entry.hostChain : [],
activePortForwards: Array.isArray(entry.activePortForwards) ? entry.activePortForwards : [],
_revision: updateRevision,
@@ -850,6 +893,7 @@ function updateSessionMetadata(sessionList, chatSessionId) {
deviceType: s.deviceType || "",
connected: s.connected !== false,
hostId: s.hostId || "",
savedHostId: s.savedHostId || "",
hostChain: Array.isArray(s.hostChain) ? s.hostChain : [],
activePortForwards: Array.isArray(s.activePortForwards) ? s.activePortForwards : [],
_revision: Number.isSafeInteger(s._revision) ? s._revision : updateRevision,
@@ -890,6 +934,9 @@ function mergeSessionMetadata(sessionList, chatSessionId) {
deviceType: entry.deviceType || previous.deviceType || "",
connected: entry.connected !== undefined ? entry.connected !== false : previous.connected !== false,
hostId: entry.hostId || previous.hostId || "",
savedHostId: Object.prototype.hasOwnProperty.call(entry, "savedHostId")
? entry.savedHostId || ""
: previous.savedHostId || "",
hostChain: Array.isArray(entry.hostChain)
? entry.hostChain
: (Array.isArray(previous.hostChain) ? previous.hostChain : []),
@@ -1130,6 +1177,18 @@ function toPublicSessionMeta(meta) {
return publicMeta;
}
function getApprovalTarget(params) {
if (typeof params?.sessionId !== 'string' || !params.sessionId) return null;
const meta = getSessionMeta(params.sessionId, params.chatSessionId);
if (!meta) return null;
return {
sessionId: params.sessionId,
hostId: meta.savedHostId || '',
label: meta.label || meta.hostname || params.sessionId,
hostname: meta.hostname || '',
};
}
function buildOpenedSessionMeta(result, sessionId) {
const host = result?.host && typeof result.host === "object" ? result.host : {};
return {
@@ -1143,6 +1202,9 @@ function buildOpenedSessionMeta(result, sessionId) {
deviceType: host.deviceType || "",
connected: result?.status === "connected",
hostId: result?.hostId || host.id || "",
savedHostId: result?.savedHostId && result.savedHostId === (result?.hostId || host.id)
? result.savedHostId
: "",
hostChain: [],
activePortForwards: [],
};
@@ -1410,7 +1472,9 @@ async function handleMessage(socket, line) {
);
}
notifyExternalMcpActivity(method, callParams);
const result = await dispatch(method, callParams);
const result = await dispatch(method, callParams, {
externalSocket: externalMcpSockets.has(socket) ? socket : null,
});
const response = JSON.stringify({ jsonrpc: "2.0", id, result }) + "\n";
if (!socket.destroyed) socket.write(response);
} catch (err) {
@@ -1537,6 +1601,8 @@ const dispatchCapabilityRpc = createCapabilityRpcDispatcher({
},
isChatSessionCancelled,
requestApprovalFromRenderer,
getApprovalTarget,
hasSessionApproval: (socket) => Boolean(socket && sessionApprovedExternalSockets.has(socket)),
USER_DENIED_MESSAGE,
listPortForwards: () => listActivePortForwards(),
sessionService,
@@ -1973,11 +2039,11 @@ function getBuiltinRpcHandlerRegistry() {
return builtinRpcHandlerRegistry;
}
async function dispatch(method, params) {
async function dispatch(method, params, approvalContext = {}) {
debugLog("dispatch", { method, params, permissionMode });
if (!method.startsWith("netcatty/")) {
const capabilityResult = await dispatchCapabilityRpc(method, params || {});
const capabilityResult = await dispatchCapabilityRpc(method, params || {}, approvalContext);
if (capabilityResult !== UNROUTED) {
return capabilityResult;
}
@@ -1989,6 +2055,7 @@ async function dispatch(method, params) {
: null;
pruneCompletedBackgroundJobs();
const approvalTarget = getApprovalTarget(params);
const permission = evaluatePermissionWithGrants({
rpcMethod: method,
surface: CAPABILITY_SURFACES.BUILTIN,
@@ -1996,6 +2063,7 @@ async function dispatch(method, params) {
params,
context: {
chatSessionCancelled: isChatSessionCancelled(params?.chatSessionId),
hostId: approvalTarget?.hostId,
},
}, permissionGrantsSnapshot);
if (!permission.allowed) {
@@ -2051,9 +2119,13 @@ async function dispatch(method, params) {
// netcatty/jobStop bypasses approval — it's a stop/cancel action that
// must remain available even if the renderer is unavailable; otherwise
// a runaway terminal_start job could not be interrupted at all.
if (permission.requiresApproval) {
if (permission.requiresApproval
&& !sessionApprovedExternalSockets.has(approvalContext.externalSocket)) {
const { chatSessionId, ...toolArgs } = params || {};
const approved = await requestApprovalFromRenderer(method, toolArgs, chatSessionId);
const approved = await requestApprovalFromRenderer(method, toolArgs, chatSessionId, {
...approvalContext,
target: approvalTarget,
});
if (!approved) {
return { ok: false, error: USER_DENIED_MESSAGE };
}
@@ -0,0 +1,150 @@
"use strict";
const test = require("node:test");
const assert = require("node:assert/strict");
const fs = require("node:fs");
const path = require("node:path");
const net = require("node:net");
const { createNdjsonRpcClient } = require("../capabilities/rpcTransport.cjs");
const tempDirBridge = require("./tempDirBridge.cjs");
const bridge = require("./mcpServerBridge.cjs");
test("external MCP approval names the host and isolates connection and host grants", async (t) => {
const dir = fs.mkdtempSync(`${tempDirBridge.getTempFilePath("host-approval")}-`);
const clients = [];
t.after(() => {
for (const client of clients) client.close();
bridge.disconnectExternalMcpClients();
bridge.setMainWindowGetter(() => null);
bridge.setVaultAgentInvoker(null);
bridge.setExternalMcpHooks(null);
bridge.setPermissionGrants([]);
bridge.cleanup();
fs.rmSync(dir, { recursive: true, force: true });
});
const approvals = [];
const waiters = [];
const nextApproval = () => new Promise((resolve) => waiters.push(resolve));
bridge.init({
sessions: new Map(),
cliDiscoveryFilePath: path.join(dir, "discovery.json"),
terminalWorkerManager: {
request: async () => ({ ok: true, stdout: "done", stderr: "", exitCode: 0 }),
},
});
bridge.setMainWindowGetter(() => ({
isDestroyed: () => false,
webContents: {
id: 42,
send(channel, payload) {
if (channel !== "netcatty:ai:mcp:approval-request") return;
approvals.push(payload);
waiters.shift()?.(payload);
},
},
}));
let externalEnabled = true;
bridge.setExternalMcpHooks({ isEnabled: () => externalEnabled, recordActivity: () => {} });
bridge.setPermissionMode("confirm");
bridge.setCommandBlocklist([]);
bridge.setVaultAgentInvoker(async () => ({ ok: true }));
const token = bridge.issueExternalMcpAuthToken();
const port = await bridge.getOrCreateHost();
bridge.updateSessionMetadata([
{ sessionId: "ssh-test", hostId: "host-test", savedHostId: "host-test", label: "Test server", hostname: "test.local", protocol: "ssh", connected: true },
{ sessionId: "ssh-prod", hostId: "host-prod", savedHostId: "host-prod", label: "Production", hostname: "prod.local", protocol: "ssh", connected: true },
], bridge.EXTERNAL_MCP_CHAT_SESSION_ID);
async function connect() {
const socket = net.createConnection({ port, host: "127.0.0.1" });
await new Promise((resolve, reject) => {
socket.once("connect", resolve);
socket.once("error", reject);
});
const client = createNdjsonRpcClient({ socket });
clients.push(client);
assert.equal((await client.call("auth/verify", { token })).ok, true);
return client;
}
const clientA = await connect();
const clientB = await connect();
const exec = (client, sessionId) => client.call("netcatty/exec", {
sessionId,
chatSessionId: bridge.EXTERNAL_MCP_CHAT_SESSION_ID,
command: "date",
});
const firstApproval = nextApproval();
const firstCall = exec(clientA, "ssh-test");
const request = await firstApproval;
assert.deepEqual(request.target, {
sessionId: "ssh-test", hostId: "host-test", label: "Test server", hostname: "test.local",
});
assert.equal(request.allowSession, true);
bridge.resolveApprovalFromRenderer(request.approvalId, true, "session");
assert.equal((await firstCall).ok, true);
assert.equal((await exec(clientA, "ssh-prod")).ok, true);
assert.equal(approvals.length, 1, "session approval stays on the first connection");
const secondApproval = nextApproval();
const secondCall = exec(clientB, "ssh-prod");
const prodRequest = await secondApproval;
assert.equal(prodRequest.target.label, "Production");
bridge.resolveApprovalFromRenderer(prodRequest.approvalId, false);
assert.equal((await secondCall).ok, false);
bridge.setPermissionGrants([{
id: "allow-test-host", capabilityId: "*", sessionPattern: "host:host-test", createdAt: Date.now(),
}]);
assert.equal((await exec(clientB, "ssh-test")).ok, true);
assert.equal(approvals.length, 2, "host grant applies to the second connection without approving production");
bridge.setPermissionGrants([]);
clientA.close();
const clientC = await connect();
const thirdApproval = nextApproval();
const thirdCall = exec(clientC, "ssh-test");
const reconnectRequest = await thirdApproval;
assert.equal(reconnectRequest.target.label, "Test server");
bridge.resolveApprovalFromRenderer(reconnectRequest.approvalId, false);
assert.equal((await thirdCall).ok, false, "a new connection does not inherit temporary approval");
const pendingA = nextApproval();
const pendingB = nextApproval();
const callA = clientB.call("public/vault/hosts/delete", { hostId: "missing-a" });
const callB = clientB.call("public/vault/hosts/update", { hostId: "missing-b", label: "test" });
const [approvalA, approvalB] = await Promise.all([pendingA, pendingB]);
assert.equal(bridge.resolveApprovalFromRenderer(approvalA.approvalId, true, "session"), true);
assert.equal((await callA).ok, true);
assert.equal((await callB).ok, true, "connection approval clears other pending requests on that socket");
assert.equal(bridge.resolveApprovalFromRenderer(approvalB.approvalId, true), false);
bridge.revokeExternalMcpAuthToken();
const afterRevoke = nextApproval();
const afterRevokeCall = exec(clientB, "ssh-test");
const revokeRequest = await afterRevoke;
bridge.resolveApprovalFromRenderer(revokeRequest.approvalId, false);
assert.equal((await afterRevokeCall).ok, false, "re-enabling requires fresh approval");
bridge.updateSessionMetadata([
{ sessionId: "local-test", hostId: "local-1", savedHostId: "", label: "Local Terminal", hostname: "localhost", protocol: "local", connected: true },
], bridge.EXTERNAL_MCP_CHAT_SESSION_ID);
const localApproval = nextApproval();
const localCall = exec(clientB, "local-test");
const localRequest = await localApproval;
assert.equal(localRequest.target.label, "Local Terminal");
assert.equal(localRequest.target.hostname, "localhost");
assert.equal(localRequest.target.hostId, "", "local sessions cannot get a persistent host grant");
bridge.resolveApprovalFromRenderer(localRequest.approvalId, false);
assert.equal((await localCall).ok, false);
const staleApproval = nextApproval();
const staleCall = exec(clientC, "local-test");
const staleRequest = await staleApproval;
externalEnabled = false;
assert.equal(bridge.resolveApprovalFromRenderer(staleRequest.approvalId, true), false);
assert.equal((await staleCall).ok, false);
});
@@ -120,6 +120,7 @@ test("MCP/Catty terminal_execute proxies to worker when terminal sessions live i
deviceType: "",
connected: true,
hostId: "",
savedHostId: "",
hostChain: [],
activePortForwards: [],
},
@@ -431,6 +432,7 @@ test("MCP/Catty terminal_start, poll, and stop proxy worker background jobs", as
deviceType: "",
connected: true,
hostId: "",
savedHostId: "",
hostChain: [],
activePortForwards: [],
},
@@ -1,6 +1,6 @@
"use strict";
const { CAPABILITY_STATUS, CAPABILITY_SURFACES } = require("../../capabilities/constants.cjs");
const { CAPABILITY_STATUS, CAPABILITY_SURFACES, PERMISSION_MODES } = require("../../capabilities/constants.cjs");
const { getCapabilityByRpcMethod } = require("../../capabilities/registry.cjs");
const { getMcpToolNameForRpcMethod } = require("../../capabilities/adapters/mcpAdapter.cjs");
const { createVaultService } = require("../../capabilities/services/vaultService.cjs");
@@ -106,7 +106,7 @@ function createCapabilityRpcDispatcher(deps) {
session: sessionService,
};
return async function dispatchCapabilityRpc(rpcMethod, params = {}) {
return async function dispatchCapabilityRpc(rpcMethod, params = {}, approvalContext = {}) {
if (typeof rpcMethod !== "string" || rpcMethod.startsWith("netcatty/")) {
return UNROUTED;
}
@@ -130,6 +130,33 @@ function createCapabilityRpcDispatcher(deps) {
return UNROUTED;
}
// Only session.close executes against params.sessionId. host.open instead
// targets its requested vault host; other vault/script operations must not
// borrow an unrelated session's host grant.
let approvalTarget = capability.id === "session.close"
? (deps.getApprovalTarget?.(params) || null)
: null;
if (capability.id === "vault.host.open" && deps.permissionMode === PERMISSION_MODES.CONFIRM) {
const requestedHostId = typeof params?.hostId === "string" ? params.hostId.trim() : "";
let lookup = null;
if (requestedHostId) {
try {
lookup = await vaultService.getHost({ hostId: requestedHostId });
} catch {
// The metadata lookup is best-effort; without it, approval cannot
// offer a persistent host grant.
}
}
const host = lookup?.host?.id === requestedHostId ? lookup.host : null;
approvalTarget = {
sessionId: "",
hostId: host && host.ephemeral !== true && host.protocol !== "serial" && host.protocol !== "local"
? requestedHostId
: "",
label: host?.label || host?.hostname || requestedHostId,
hostname: host?.hostname || "",
};
}
const permission = evaluatePermissionWithGrants({
rpcMethod,
surface,
@@ -137,6 +164,7 @@ function createCapabilityRpcDispatcher(deps) {
params,
context: {
chatSessionCancelled: isChatSessionCancelled(params?.chatSessionId),
hostId: approvalTarget?.hostId,
},
}, deps.permissionGrantsSnapshot);
@@ -144,12 +172,15 @@ function createCapabilityRpcDispatcher(deps) {
return { ok: false, error: permission.error };
}
if (permission.requiresApproval) {
if (permission.requiresApproval && !deps.hasSessionApproval?.(approvalContext.externalSocket)) {
const { chatSessionId, ...toolArgs } = params || {};
const toolName = getMcpToolNameForRpcMethod(rpcMethod, surface)
|| capability.surfaces?.[CAPABILITY_SURFACES.PUBLIC]?.mcpTool
|| capability.id;
const approved = await requestApprovalFromRenderer(toolName, toolArgs, chatSessionId);
const approved = await requestApprovalFromRenderer(toolName, toolArgs, chatSessionId, {
...approvalContext,
target: approvalTarget,
});
if (!approved) {
return { ok: false, error: USER_DENIED_MESSAGE };
}
@@ -5,6 +5,7 @@ const assert = require("node:assert/strict");
const { createCapabilityRpcDispatcher, UNROUTED } = require("./capabilityRpcDispatch.cjs");
const { CAPABILITY_SURFACES, PERMISSION_MODES } = require("../../capabilities/constants.cjs");
const { evaluatePermissionWithGrants } = require("../../capabilities/policy.cjs");
function createTestDispatcher(overrides = {}) {
const invokeVaultAgent = overrides.invokeVaultAgent || (async (op, params) => ({
@@ -84,6 +85,75 @@ test("dispatchCapabilityRpc routes public vault host notes set through approval"
assert.equal(result.notes, "updated");
});
test("vault host deletion cannot borrow a different session's host grant", async () => {
let approvalTarget;
let deleted = false;
const dispatch = createTestDispatcher({
evaluatePermissionWithGrants,
permissionGrantsSnapshot: [{
id: "trusted-host", capabilityId: "*", sessionPattern: "host:trusted", createdAt: 1,
}],
getApprovalTarget: () => ({ sessionId: "trusted-session", hostId: "trusted", label: "Trusted" }),
requestApprovalFromRenderer: async (_name, _args, _chatSessionId, context) => {
approvalTarget = context.target;
return false;
},
invokeVaultAgent: async () => {
deleted = true;
return { ok: true };
},
});
const result = await dispatch("public/vault/hosts/delete", {
chatSessionId: "__external_mcp__",
sessionId: "trusted-session",
hostId: "other-host",
});
assert.equal(result.ok, false);
assert.equal(deleted, false);
assert.equal(approvalTarget, null);
});
test("host_open names its vault host and cannot borrow another host's grant", async () => {
const approvals = [];
const opened = [];
const hosts = {
test: { id: "test", label: "Test server", hostname: "test.example" },
prod: { id: "prod", label: "Production", hostname: "prod.example" },
temporary: { id: "temporary", label: "Temporary", hostname: "temp.example", ephemeral: true },
serial: { id: "serial", label: "Serial device", hostname: "ttyUSB0", protocol: "serial" },
};
const dispatch = createTestDispatcher({
evaluatePermissionWithGrants,
permissionGrantsSnapshot: [{
id: "test-only", capabilityId: "vault.host.open", sessionPattern: "host:test", createdAt: 1,
}],
requestApprovalFromRenderer: async (_name, _args, _chat, context) => {
approvals.push(context.target);
return false;
},
invokeVaultAgent: async (op, params) => {
if (op === "host.get") return { ok: true, host: hosts[params.hostId] };
if (op === "host.open") {
opened.push(params.hostId);
return { ok: true, sessionId: `session-${params.hostId}` };
}
return { ok: false };
},
});
assert.equal((await dispatch("public/vault/hosts/open", { hostId: "test" })).ok, true);
assert.equal((await dispatch("public/vault/hosts/open", { hostId: "prod" })).ok, false);
assert.equal((await dispatch("public/vault/hosts/open", { hostId: "temporary" })).ok, false);
assert.equal((await dispatch("public/vault/hosts/open", { hostId: "serial" })).ok, false);
assert.deepEqual(opened, ["test"]);
assert.deepEqual(approvals, [
{ sessionId: "", hostId: "prod", label: "Production", hostname: "prod.example" },
{ sessionId: "", hostId: "", label: "Temporary", hostname: "temp.example" },
{ sessionId: "", hostId: "", label: "Serial device", hostname: "ttyUSB0" },
]);
});
test("dispatchCapabilityRpc asks for approval before importing vault notes from the CLI rpc", async () => {
const approvalCalls = [];
let invokedOp = null;
@@ -35,6 +35,9 @@ function mergeRetentionMeta(previous, fallback) {
shellType: newer.shellType || older.shellType,
deviceType: newer.deviceType || older.deviceType,
hostId: newer.hostId || older.hostId,
savedHostId: Object.prototype.hasOwnProperty.call(newer, "savedHostId")
? newer.savedHostId || ""
: older.savedHostId,
hostChain: Array.isArray(newer.hostChain) ? newer.hostChain : older.hostChain,
// Explicit empty arrays in the newer snapshot clear stopped forwards.
activePortForwards: Array.isArray(newer.activePortForwards)
@@ -105,6 +108,7 @@ function retainOwnedSessions({
deviceType: meta.deviceType || "",
connected: meta.connected !== false,
hostId: meta.hostId || "",
savedHostId: meta.savedHostId || "",
hostChain: Array.isArray(meta.hostChain) ? meta.hostChain : [],
activePortForwards: Array.isArray(meta.activePortForwards) ? meta.activePortForwards : [],
...(Number.isSafeInteger(meta._revision) ? { _revision: meta._revision } : {}),
@@ -2318,3 +2318,104 @@ test("single-channel Copy Tab dials separately and keeps the original shell", as
assert.notEqual(sessions.get("copy").conn, sourceConn);
assert.equal(sessions.get("source").conn, sourceConn);
});
function waitForExitDeliveries() {
// finalizeClose runs after the paced output buffer flushes on the next
// event-loop turns; give both queue turns and timers a chance to run.
return new Promise((resolve) => setTimeout(resolve, 50));
}
function getExitPayloads(sender) {
return sender.sent
.filter((m) => m.channel === "netcatty:exit")
.map((m) => m.payload);
}
test("a channel closed without exit-status or exit-signal reports no exit code (#3591)", async (t) => {
const { bridge } = loadBridgeWithMockedSsh2(t, { connectReady: true });
const sessions = new Map();
const start = registerStartHandler(bridge, sessions);
const sender = makeSender();
const options = {
hostname: "bastion.example",
username: "alice",
port: 22,
authMethod: "password",
password: "secret",
useSshAgent: false,
verifyHostKeys: false,
};
await start({ sender }, { ...options, sessionId: "no-status-close" });
const stream = sessions.get("no-status-close").stream;
// Bastions/gateways built on stacks like golang.org/x/crypto/ssh close the
// session channel without ever sending exit-status or exit-signal, so ssh2
// never emits stream "exit".
stream.emit("close");
await waitForExitDeliveries();
const exits = getExitPayloads(sender);
assert.equal(exits.length, 1);
assert.equal(exits[0].reason, "closed");
assert.equal(exits[0].exitCode, undefined, "the sentinel 0 must not be reported as a real exit code");
assert.equal(exits[0].remoteClosedWithoutExitStatus, true);
});
test("a real exit-status still reports its numeric exit code", async (t) => {
const { bridge } = loadBridgeWithMockedSsh2(t, { connectReady: true });
const sessions = new Map();
const start = registerStartHandler(bridge, sessions);
const sender = makeSender();
const options = {
hostname: "10.0.0.50",
username: "alice",
port: 22,
authMethod: "password",
password: "secret",
useSshAgent: false,
verifyHostKeys: false,
};
await start({ sender }, { ...options, sessionId: "clean-exit" });
const stream = sessions.get("clean-exit").stream;
stream.emit("exit", 0);
stream.emit("close");
await waitForExitDeliveries();
const exits = getExitPayloads(sender);
assert.equal(exits.length, 1);
assert.equal(exits[0].reason, "exited");
assert.equal(exits[0].exitCode, 0);
assert.equal(exits[0].remoteClosedWithoutExitStatus, undefined);
});
test("an exit-signal with no code does not fabricate an exit code 0", async (t) => {
const { bridge } = loadBridgeWithMockedSsh2(t, { connectReady: true });
const sessions = new Map();
const start = registerStartHandler(bridge, sessions);
const sender = makeSender();
const options = {
hostname: "10.0.0.50",
username: "alice",
port: 22,
authMethod: "password",
password: "secret",
useSshAgent: false,
verifyHostKeys: false,
};
await start({ sender }, { ...options, sessionId: "signal-exit" });
const stream = sessions.get("signal-exit").stream;
stream.emit("exit", undefined, "SIGUSR2");
stream.emit("close");
await waitForExitDeliveries();
const exits = getExitPayloads(sender);
assert.equal(exits.length, 1);
assert.equal(exits[0].reason, "closed");
assert.equal(exits[0].exitCode, undefined);
assert.equal(exits[0].remoteClosedWithoutExitStatus, undefined, "the remote did report a signal");
});
+21 -6
View File
@@ -611,12 +611,18 @@ function createStartSessionApi(ctx) {
// Only treat it as user-initiated exit if "exit" fired with a numeric
// code and no signal. Signal terminations (e.g. server kill, idle
// timeout) have code=null and signal set — those are not user exits.
let streamExitCode = 0;
// A third case exists: a remote that closes the channel without sending
// exit-status or exit-signal never fires "exit" at all (#3591). The
// initial values below stay null in that case and must not be reported
// as a real "code 0" — that reads like a clean, user-typed `exit`.
let streamExitCode = null;
let streamExitSignal = null;
let streamExited = false;
stream.on("exit", (code, signal) => {
log("shell exit", { sessionId, hostname: options.hostname, code, signal, reused: !!isReused });
streamExitCode = typeof code === "number" ? code : 0;
streamExited = typeof code === "number" && !signal;
streamExitCode = typeof code === "number" ? code : null;
streamExitSignal = typeof signal === "string" && signal ? signal : null;
streamExited = typeof code === "number" && !streamExitSignal;
});
let closeFinalized = false;
@@ -663,12 +669,21 @@ function createStartSessionApi(ctx) {
// for reconnect instead of auto-closing it (#1062 / #977).
const idleTimedOut = streamExited && looksLikeIdleAutoLogout(liveSession?._promptTrackTail);
const reason = idleTimedOut ? "timeout" : (streamExited ? "exited" : "closed");
safeSendSessionExit({ safeSend, electronModule, sessions }, contents, sessionId, {
const exitPayload = {
sessionId,
exitCode: streamExitCode,
reason,
_terminalSessionGeneration: liveSession?._terminalSessionGeneration,
});
};
// Report only exit facts the remote actually sent (#3591). When
// the peer closed the channel without exit-status/exit-signal,
// there is no code to show — flag it instead so the renderer can
// distinguish an abnormal teardown from a user-typed `exit 0`.
if (typeof streamExitCode === "number") {
exitPayload.exitCode = streamExitCode;
} else if (streamExitSignal === null) {
exitPayload.remoteClosedWithoutExitStatus = true;
}
safeSendSessionExit({ safeSend, electronModule, sessions }, contents, sessionId, exitPayload);
}
liveSession?.zmodemSentry?.cancel();
// Release this channel's hold on the shared connection. The transport
+21 -4
View File
@@ -936,7 +936,7 @@ const applyLocaleDefaults = (env) => {
* Host-level `environmentVariables` overrides are applied on top, and an
* explicit PATH among them always wins — widening it would defeat the override.
*/
function buildLocalSessionEnv(
async function buildLocalSessionEnv(
payload,
{ baseEnv = process.env, platform = process.platform, refreshWindowsPath } = {},
) {
@@ -953,7 +953,7 @@ function buildLocalSessionEnv(
const pathKey = overridePathKey || getPathEnvKey(env, platform);
if (!overridePathKey) {
const livePath = (refreshWindowsPath || resolveWindowsLivePath)({
const livePath = await (refreshWindowsPath || resolveWindowsLivePath)({
basePath: env[pathKey],
env,
platform,
@@ -972,7 +972,7 @@ function buildLocalSessionEnv(
/**
* Start a local terminal session
*/
function startLocalSession(event, payload) {
async function startLocalSession(event, payload) {
const sessionId = payload?.sessionId || randomUUID();
const defaultShell = getDefaultLocalShell();
// payload.shell may be a discovered shell ID (e.g., "wsl-ubuntu") — resolve it
@@ -995,12 +995,29 @@ function startLocalSession(event, payload) {
requestedCwd,
);
const shellKind = detectShellKind(shell);
const { registerPendingBootAbort, clearPendingBootAbort } = require("./sessionBootEpoch.cjs");
const pendingBootAbort = registerPendingBootAbort(sessionId, payload?.bootEpoch);
let sessionEnv;
try {
sessionEnv = await buildLocalSessionEnv(payload);
} finally {
clearPendingBootAbort(sessionId, pendingBootAbort);
}
if (pendingBootAbort.signal.aborted) {
const supersededError = new Error("Local session closed or superseded before startup");
supersededError.code = "NETCATTY_BOOT_SUPERSEDED";
throw supersededError;
}
const env = applyLocaleDefaults({
...buildLocalSessionEnv(payload),
...sessionEnv,
TERM: "xterm-256color",
COLORTERM: "truecolor",
});
if (event.sender.isDestroyed?.()) {
throw new Error("Local terminal window closed before startup");
}
// Determine the starting directory
// Default to home directory if not specified or if specified path is invalid
const defaultCwd = os.homedir();
@@ -37,8 +37,8 @@ const STALE_PATH =
const LIVE_PATH =
"C:\\Users\\me\\.herdr\\packages\\standalone\\releases\\0.9.1-x86_64-pc-windows-msvc;C:\\Windows\\System32";
test("buildLocalSessionEnv keeps the captured PATH untouched off Windows", () => {
const env = bridge.buildLocalSessionEnv(
test("buildLocalSessionEnv keeps the captured PATH untouched off Windows", async () => {
const env = await bridge.buildLocalSessionEnv(
{ env: { NETCATTY_MARKER: "1" } },
{
baseEnv: { PATH: "/usr/bin:/bin", HOME: "/home/me" },
@@ -51,14 +51,14 @@ test("buildLocalSessionEnv keeps the captured PATH untouched off Windows", () =>
assert.equal(env.NETCATTY_MARKER, "1");
});
test("buildLocalSessionEnv refreshes PATH from the registry on Windows", () => {
test("buildLocalSessionEnv refreshes PATH from the registry on Windows", async () => {
const calls = [];
const env = bridge.buildLocalSessionEnv(
const env = await bridge.buildLocalSessionEnv(
{},
{
baseEnv: { Path: STALE_PATH, SystemRoot: "C:\\Windows" },
platform: "win32",
refreshWindowsPath: (options) => {
refreshWindowsPath: async (options) => {
calls.push(options);
return LIVE_PATH;
},
@@ -73,8 +73,8 @@ test("buildLocalSessionEnv refreshes PATH from the registry on Windows", () => {
assert.equal(env.SystemRoot, "C:\\Windows");
});
test("buildLocalSessionEnv collapses a duplicate PATH sitting next to Path", () => {
const env = bridge.buildLocalSessionEnv(
test("buildLocalSessionEnv collapses a duplicate PATH sitting next to Path", async () => {
const env = await bridge.buildLocalSessionEnv(
{},
{
baseEnv: { Path: STALE_PATH, PATH: STALE_PATH },
@@ -91,9 +91,9 @@ test("buildLocalSessionEnv collapses a duplicate PATH sitting next to Path", ()
);
});
test("buildLocalSessionEnv never widens an explicit PATH override", () => {
test("buildLocalSessionEnv never widens an explicit PATH override", async () => {
let refreshed = false;
const env = bridge.buildLocalSessionEnv(
const env = await bridge.buildLocalSessionEnv(
{ env: { PATH: "C:\\pinned\\bin" } },
{
baseEnv: { Path: STALE_PATH },
@@ -109,3 +109,18 @@ test("buildLocalSessionEnv never widens an explicit PATH override", () => {
assert.equal(env.PATH, "C:\\pinned\\bin");
assert.equal(env.Path, undefined);
});
for (const pathKey of ["Path", "PATH", "path"]) {
test(`an explicit empty ${pathKey} override stays empty without a registry query`, async () => {
const env = await bridge.buildLocalSessionEnv(
{ env: { [pathKey]: "" } },
{
baseEnv: { Path: STALE_PATH, PATH: STALE_PATH },
platform: "win32",
refreshWindowsPath: () => { throw new Error("must not refresh an explicit override"); },
},
);
assert.equal(env[pathKey], "");
assert.deepEqual(Object.keys(env).filter((key) => key.toLowerCase() === "path"), [pathKey]);
});
}
@@ -160,7 +160,7 @@ test("WSL default directory stays outside the Linux command and option values",
}
});
test("WSL local sessions pass the home directory option to the spawned process", () => {
test("WSL local sessions pass the home directory option to the spawned process", async () => {
const spawns = [];
const bridge = loadBridgeWithFakes(spawns, []);
const platformDescriptor = Object.getOwnPropertyDescriptor(process, "platform");
@@ -172,10 +172,10 @@ test("WSL local sessions pass the home directory option to the spawned process",
});
const shellArgs = ["-d", "Ubuntu", "--", "zsh", "-l"];
const payload = { shell: "C:\\Windows\\System32\\wsl.exe", shellArgs };
bridge.startLocalSession({ sender: { id: 7 } }, { ...payload, sessionId: "wsl-home" });
await bridge.startLocalSession({ sender: { id: 7 } }, { ...payload, sessionId: "wsl-home" });
assert.deepEqual(spawns[0].spawnArgs[1], ["-d", "Ubuntu", "--cd", "~", "--", "zsh", "-l"]);
assert.deepEqual(shellArgs, ["-d", "Ubuntu", "--", "zsh", "-l"]);
bridge.startLocalSession(
await bridge.startLocalSession(
{ sender: { id: 7 } },
{ ...payload, sessionId: "wsl-explicit-cwd", cwd: process.cwd() },
);
@@ -186,7 +186,7 @@ test("WSL local sessions pass the home directory option to the spawned process",
}
});
test("Windows local terminals enable the bundled ConPTY implementation required for clear", () => {
test("Windows local terminals enable the bundled ConPTY implementation required for clear", async () => {
const spawns = [];
const sentries = [];
const sessions = new Map();
@@ -203,7 +203,7 @@ test("Windows local terminals enable the bundled ConPTY implementation required
},
},
});
bridge.startLocalSession(
await bridge.startLocalSession(
{ sender: { id: 7 } },
{ sessionId: "windows-clear", shell: "C:\\Windows\\System32\\WindowsPowerShell\\v1.0\\powershell.exe" },
);
@@ -215,7 +215,7 @@ test("Windows local terminals enable the bundled ConPTY implementation required
}
});
test("local terminal buffers incoming flood while renderer flow is paused", () => {
test("local terminal buffers incoming flood while renderer flow is paused", async () => {
const spawns = [];
const sentries = [];
const sent = [];
@@ -233,7 +233,7 @@ test("local terminal buffers incoming flood while renderer flow is paused", () =
},
});
bridge.startLocalSession(
await bridge.startLocalSession(
{ sender: { id: 7 } },
{ sessionId: "local-flood", shell: "/bin/sh", cols: 80, rows: 24 },
);
@@ -258,7 +258,7 @@ test("local terminal buffers incoming flood while renderer flow is paused", () =
assert.equal(sentries[0].consumeCalls.length, 2);
});
test("local terminal keeps source paused while paced backlog absorbs fresh flood", () => {
test("local terminal keeps source paused while paced backlog absorbs fresh flood", async () => {
const spawns = [];
const sentries = [];
const sent = [];
@@ -276,7 +276,7 @@ test("local terminal keeps source paused while paced backlog absorbs fresh flood
},
});
bridge.startLocalSession(
await bridge.startLocalSession(
{ sender: { id: 7 } },
{ sessionId: "local-flood-paced-fresh", shell: "/bin/sh", cols: 80, rows: 24 },
);
@@ -307,7 +307,7 @@ test("local terminal keeps source paused while paced backlog absorbs fresh flood
assert.ok(session.flowState.bufferedBytes >= FLOW_HIGH_WATER_MARK);
});
test("closing a local terminal discards buffered output instead of flushing it", () => {
test("closing a local terminal discards buffered output instead of flushing it", async () => {
const spawns = [];
const sentries = [];
const sent = [];
@@ -325,7 +325,7 @@ test("closing a local terminal discards buffered output instead of flushing it",
},
});
bridge.startLocalSession(
await bridge.startLocalSession(
{ sender: { id: 7 } },
{ sessionId: "local-flood-close", shell: "/bin/sh", cols: 80, rows: 24 },
);
@@ -339,7 +339,7 @@ test("closing a local terminal discards buffered output instead of flushing it",
}]);
});
test("app cleanup discards buffered output instead of flushing it", () => {
test("app cleanup discards buffered output instead of flushing it", async () => {
const spawns = [];
const sentries = [];
const sent = [];
@@ -357,7 +357,7 @@ test("app cleanup discards buffered output instead of flushing it", () => {
},
});
bridge.startLocalSession(
await bridge.startLocalSession(
{ sender: { id: 7 } },
{ sessionId: "local-flood-cleanup", shell: "/bin/sh", cols: 80, rows: 24 },
);
@@ -387,7 +387,7 @@ test("local terminal exit waits for paced buffered output drain", async () => {
},
});
bridge.startLocalSession(
await bridge.startLocalSession(
{ sender: { id: 7 } },
{ sessionId: "local-flood-exit", shell: "/bin/sh", cols: 80, rows: 24 },
);
@@ -414,7 +414,7 @@ test("local terminal exit waits for paced buffered output drain", async () => {
assert.equal(sessions.has("local-flood-exit"), false);
});
test("local terminal exit completes while renderer flow is paused", () => {
test("local terminal exit completes while renderer flow is paused", async () => {
const spawns = [];
const sentries = [];
const sent = [];
@@ -432,7 +432,7 @@ test("local terminal exit completes while renderer flow is paused", () => {
},
});
bridge.startLocalSession(
await bridge.startLocalSession(
{ sender: { id: 7 } },
{ sessionId: "local-paused-exit", shell: "/bin/sh", cols: 80, rows: 24 },
);
@@ -448,3 +448,181 @@ test("local terminal exit completes while renderer flow is paused", () => {
assert.equal(sent.some((item) => item.channel === "netcatty:exit"), true);
assert.equal(sessions.has("local-paused-exit"), false);
});
test("a window closed while preparing the local environment does not spawn a PTY", async () => {
const spawns = [];
const bridge = loadBridgeWithFakes(spawns, []);
let destroyed = false;
const pending = bridge.startLocalSession(
{ sender: { id: 7, isDestroyed: () => destroyed } },
{ sessionId: "closed-before-spawn", shell: "/bin/sh" },
);
destroyed = true;
await assert.rejects(pending, /window closed before startup/);
assert.equal(spawns.length, 0);
});
async function withPendingLocalPath(run) {
const shellUtils = require("./ai/shellUtils.cjs");
const originalResolve = shellUtils.resolveWindowsLivePath;
const platformDescriptor = Object.getOwnPropertyDescriptor(process, "platform");
const resolvers = [];
const spawns = [];
const sessions = new Map();
const bridge = loadBridgeWithFakes(spawns, []);
bridge.init({ sessions, electronModule: { webContents: { fromId: () => null } } });
try {
Object.defineProperty(process, "platform", { configurable: true, value: "win32" });
shellUtils.resolveWindowsLivePath = () => new Promise((resolve) => resolvers.push(resolve));
await run({ bridge, resolvers, spawns, sessions });
} finally {
shellUtils.resolveWindowsLivePath = originalResolve;
Object.defineProperty(process, "platform", platformDescriptor);
}
}
test("closing a local tab during PATH refresh prevents the pending PTY spawn", async () => {
await withPendingLocalPath(async ({ bridge, resolvers, spawns, sessions }) => {
const event = { sender: { id: 7, isDestroyed: () => false } };
const payload = { sessionId: "closed-pending-path", bootEpoch: 1, shell: "C:\\Windows\\cmd.exe" };
const pending = bridge.startLocalSession(event, payload);
const rejected = assert.rejects(pending, { code: "NETCATTY_BOOT_SUPERSEDED" });
assert.equal(resolvers.length, 1);
bridge.closeSession(event, payload);
resolvers[0]("C:\\Windows\\System32");
await rejected;
assert.equal(spawns.length, 0);
assert.equal(sessions.has(payload.sessionId), false);
});
});
test("a stale close and old PATH completion cannot cancel a newer local boot", async () => {
await withPendingLocalPath(async ({ bridge, resolvers, spawns, sessions }) => {
const event = { sender: { id: 7, isDestroyed: () => false } };
const payload = { sessionId: "restarted-pending-path", shell: "C:\\Windows\\cmd.exe" };
const older = bridge.startLocalSession(event, { ...payload, bootEpoch: 1 });
const rejected = assert.rejects(older, { code: "NETCATTY_BOOT_SUPERSEDED" });
const newer = bridge.startLocalSession(event, { ...payload, bootEpoch: 2 });
assert.equal(resolvers.length, 2);
assert.deepEqual(bridge.closeSession(event, { ...payload, bootEpoch: 1 }), {
skipped: true, reason: "boot-epoch-mismatch",
});
resolvers[0]("C:\\old");
await rejected;
const { hasPendingBootAfter } = require("./sessionBootEpoch.cjs");
assert.equal(hasPendingBootAfter(payload.sessionId, 1), true);
resolvers[1]("C:\\new");
await newer;
assert.equal(spawns.length, 1);
assert.equal(sessions.get(payload.sessionId)?.bootEpoch, 2);
bridge.closeSession(event, { ...payload, bootEpoch: 1 });
assert.equal(sessions.get(payload.sessionId)?.bootEpoch, 2);
bridge.closeSession(event, { ...payload, bootEpoch: 2 });
});
});
for (const closeChannel of ["netcatty:close", "netcatty:close:await"]) {
test(`worker ${closeChannel} cancels a local start before PATH refresh can spawn it`, async () => {
await withPendingLocalPath(async ({ bridge, resolvers, spawns, sessions }) => {
const { createTerminalWorkerRuntime } = require("../terminalWorker/runtime.cjs");
const messages = [];
let receiveMessage;
const parentPort = {
on: (_channel, listener) => { receiveMessage = listener; },
postMessage: (message) => messages.push(message),
};
createTerminalWorkerRuntime({
parentPort,
registerBridges: (ipcMain) => bridge.registerHandlers(ipcMain),
}).start();
const payload = { sessionId: `worker-pending-${closeChannel}`, shell: "C:\\Windows\\cmd.exe" };
const start = (bootEpoch) => receiveMessage({
kind: "request", requestId: `start-${bootEpoch}`, channel: "netcatty:local:start",
webContentsId: 7, payload: { ...payload, bootEpoch },
});
const close = (bootEpoch) => receiveMessage({
kind: closeChannel.endsWith(":await") ? "request" : "send",
requestId: `close-${bootEpoch}`, channel: closeChannel,
webContentsId: 7, payload: { ...payload, bootEpoch },
});
const drainMessages = () => new Promise((resolve) => setImmediate(resolve));
start(1);
await drainMessages();
assert.equal(resolvers.length, 1, "the real bridge is waiting for PATH inside the worker queue");
close(1);
resolvers[0]("C:\\Windows\\System32");
await drainMessages();
await drainMessages();
assert.equal(spawns.length, 0, "a closed tab must not run shell startup scripts");
assert.equal(sessions.has(payload.sessionId), false);
assert.match(messages.find((message) => message.requestId === "start-1")?.error ?? "", /closed or superseded/);
if (closeChannel.endsWith(":await")) {
assert.equal(messages.find((message) => message.requestId === "close-1")?.result?.closed, false);
}
start(2);
await drainMessages();
assert.equal(resolvers.length, 2);
close(1); // A delayed old close must not abort the replacement's PATH wait.
resolvers[1]("C:\\Windows\\System32");
await drainMessages();
await drainMessages();
assert.equal(spawns.length, 1);
assert.equal(sessions.get(payload.sessionId)?.bootEpoch, 2);
close(2);
await drainMessages();
await drainMessages();
assert.equal(sessions.has(payload.sessionId), false);
});
});
}
test("worker replacements abort pending and skip superseded queued local starts", async () => {
await withPendingLocalPath(async ({ bridge, resolvers, spawns, sessions }) => {
const { createTerminalWorkerRuntime } = require("../terminalWorker/runtime.cjs");
const messages = [];
let receiveMessage;
createTerminalWorkerRuntime({
parentPort: {
on: (_channel, listener) => { receiveMessage = listener; },
postMessage: (message) => messages.push(message),
},
registerBridges: (ipcMain) => bridge.registerHandlers(ipcMain),
}).start();
const payload = { sessionId: "worker-replaced-path", shell: "C:\\Windows\\cmd.exe" };
const start = (bootEpoch, requestId) => receiveMessage({
kind: "request", requestId, channel: "netcatty:local:start",
webContentsId: 7, payload: { ...payload, bootEpoch },
});
const drainMessages = () => new Promise((resolve) => setImmediate(resolve));
start(1, "old-start");
await drainMessages();
start(2, "queued-replacement");
start(3, "replacement");
resolvers[0]("C:\\Windows\\System32");
await drainMessages();
await drainMessages();
assert.equal(spawns.length, 0, "a superseded PATH wait must not run shell startup scripts");
assert.equal(resolvers.length, 2, "the replacement now owns the startup queue");
assert.match(messages.find((message) => message.requestId === "old-start")?.error ?? "", /closed or superseded/);
assert.match(messages.find((message) => message.requestId === "queued-replacement")?.error ?? "", /superseded/);
start(2, "stale-start");
receiveMessage({
kind: "send", channel: "netcatty:close", webContentsId: 7,
payload: { ...payload, bootEpoch: 2 },
});
resolvers[1]("C:\\Windows\\System32");
await drainMessages();
await drainMessages();
assert.equal(spawns.length, 1);
assert.equal(resolvers.length, 2, "a stale start must not even prepare a new environment");
assert.equal(sessions.get(payload.sessionId)?.bootEpoch, 3);
assert.match(messages.find((message) => message.requestId === "stale-start")?.error ?? "", /superseded/);
receiveMessage({
kind: "send", channel: "netcatty:close", webContentsId: 7,
payload: { ...payload, bootEpoch: 3 },
});
await drainMessages();
await drainMessages();
});
});
+1
View File
@@ -96,6 +96,7 @@ function evaluatePermissionWithGrants(ctx, grants = []) {
capabilityId: base.capability.id,
chatSessionId: params.chatSessionId,
sessionId: params.sessionId,
hostId: ctx?.context?.hostId,
args: params,
});
+21
View File
@@ -236,6 +236,27 @@ test("evaluatePermissionWithGrants skips approval when a grant matches", () => {
assert.equal(decision.requiresApproval, false);
});
test("a server grant approves only that server's terminal and SFTP writes", () => {
const grants = [{
id: "test-server",
capabilityId: "*",
sessionPattern: "host:host-test",
createdAt: Date.now(),
}];
const decide = (rpcMethod, hostId, sessionId = "ssh-1") => evaluatePermissionWithGrants({
rpcMethod,
permissionMode: PERMISSION_MODES.CONFIRM,
params: { chatSessionId: "chat-1", sessionId, command: "rm -rf /tmp/build", path: "/tmp/build" },
context: { hostId },
}, grants);
assert.equal(decide("netcatty/exec", "host-test").requiresApproval, false);
assert.equal(decide("netcatty/sftp/delete", "host-test").requiresApproval, false);
assert.equal(decide("netcatty/exec", "host-prod").requiresApproval, true);
assert.equal(decide("netcatty/exec", undefined).requiresApproval, true);
assert.equal(decide("netcatty/exec", "host-test", "").requiresApproval, true);
});
test("evaluatePermissionWithGrants does not let a comment grant approve a multiline command", () => {
const decision = evaluatePermissionWithGrants({
rpcMethod: "netcatty/exec",
+25 -8
View File
@@ -68,7 +68,7 @@ function collectJmsDeepLinkUrls(argv) {
return collectDeepLinkUrls(argv, JMS_PROTOCOL);
}
function collectPuttyStyleDeepLinkUrls(argv) {
function collectPuttyStyleDeepLinkIntents(argv) {
if (
collectSshDeepLinkUrls(argv).length > 0
|| collectTelnetDeepLinkUrls(argv).length > 0
@@ -84,10 +84,19 @@ function collectPuttyStyleDeepLinkUrls(argv) {
const secureCrt = parseSecureCrtCommandLineTokens(argv);
const parsed = secureCrt ? secureCrt.result : parsePuttyCommandLine(argv);
if (!parsed?.url) return { ssh: [], telnet: [] };
const intent = { rawUrl: parsed.url, tabName: parsed.tabName };
if (parsed.protocol === TELNET_PROTOCOL) {
return { ssh: [], telnet: [parsed.url] };
return { ssh: [], telnet: [intent] };
}
return { ssh: [parsed.url], telnet: [] };
return { ssh: [intent], telnet: [] };
}
function collectPuttyStyleDeepLinkUrls(argv) {
const intents = collectPuttyStyleDeepLinkIntents(argv);
return {
ssh: intents.ssh.map((intent) => intent.rawUrl),
telnet: intents.telnet.map((intent) => intent.rawUrl),
};
}
/**
@@ -98,7 +107,7 @@ function collectPuttyStyleDeepLinkUrls(argv) {
* gated by includeSchemeUrls through the scheme-URL part of the queue.
*/
function collectSshDeepLinkQueueItems(argv, { includeSchemeUrls = true } = {}) {
const puttyStyleDeepLinks = collectPuttyStyleDeepLinkUrls(argv);
const puttyStyleDeepLinks = collectPuttyStyleDeepLinkIntents(argv);
const queueItems = {
ssh: [],
telnet: [],
@@ -111,11 +120,19 @@ function collectSshDeepLinkQueueItems(argv, { includeSchemeUrls = true } = {}) {
queueItems.telnet.push({ rawUrl, viaCommandLine: false });
});
}
puttyStyleDeepLinks.ssh.forEach((rawUrl) => {
queueItems.ssh.push({ rawUrl, viaCommandLine: true });
puttyStyleDeepLinks.ssh.forEach((intent) => {
queueItems.ssh.push({
rawUrl: intent.rawUrl,
viaCommandLine: true,
...(intent.tabName ? { tabName: intent.tabName } : {}),
});
});
puttyStyleDeepLinks.telnet.forEach((rawUrl) => {
queueItems.telnet.push({ rawUrl, viaCommandLine: true });
puttyStyleDeepLinks.telnet.forEach((intent) => {
queueItems.telnet.push({
rawUrl: intent.rawUrl,
viaCommandLine: true,
...(intent.tabName ? { tabName: intent.tabName } : {}),
});
});
return queueItems;
}
+46
View File
@@ -75,6 +75,52 @@ test("collectPuttyStyleDeepLinkUrls leaves ssh:// tokens to the existing collect
);
});
test("collectSshDeepLinkQueueItems preserves tolerated PuTTY-style -newtab names", () => {
assert.deepEqual(
collectSshDeepLinkQueueItems([
"Netcatty.exe",
"-newtab",
"Production",
"-ssh",
"root@192.168.1.122",
"-P",
"22",
], { includeSchemeUrls: false }),
{
ssh: [{
rawUrl: "ssh://root@192.168.1.122:22",
viaCommandLine: true,
tabName: "Production",
}],
telnet: [],
},
);
});
test("collectSshDeepLinkQueueItems preserves SecureCRT /N names", () => {
assert.deepEqual(
collectSshDeepLinkQueueItems([
"Netcatty.exe",
"/SSH2",
"/L",
"root",
"/P",
"22",
"/N",
"Production",
"192.168.1.122",
], { includeSchemeUrls: false }),
{
ssh: [{
rawUrl: "ssh://root@192.168.1.122:22",
viaCommandLine: true,
tabName: "Production",
}],
telnet: [],
},
);
});
test("collectSshDeepLinkQueueItems keeps PuTTY CLI launches when scheme URLs are disabled", () => {
// Warm second-instance case: the running app forwarded another launch's
// argv while the ssh:// protocol-client preference is disabled.
+12 -10
View File
@@ -723,19 +723,19 @@ let jmsDeepLinkDeliveryGeneration = 0;
let explorerContextMenuEnabled = resolveExplorerContextMenuEnabled({ app }).enabled === true;
function queueSshDeepLink(rawUrl, { viaCommandLine = false } = {}) {
function queueSshDeepLink(rawUrl, { viaCommandLine = false, tabName } = {}) {
if (!viaCommandLine && !sshDeepLinkEnabled) return;
if (!isSshDeepLinkUrl(rawUrl)) return;
pendingSshDeepLinkUrls.push({ rawUrl, viaCommandLine });
pendingSshDeepLinkUrls.push({ rawUrl, viaCommandLine, ...(tabName ? { tabName } : {}) });
if (app.isReady?.()) {
void flushPendingSshDeepLinks();
}
}
function queueTelnetDeepLink(rawUrl, { viaCommandLine = false } = {}) {
function queueTelnetDeepLink(rawUrl, { viaCommandLine = false, tabName } = {}) {
if (!viaCommandLine && !sshDeepLinkEnabled) return;
if (!isTelnetDeepLinkUrl(rawUrl)) return;
pendingTelnetDeepLinkUrls.push({ rawUrl, viaCommandLine });
pendingTelnetDeepLinkUrls.push({ rawUrl, viaCommandLine, ...(tabName ? { tabName } : {}) });
if (app.isReady?.()) {
void flushPendingTelnetDeepLinks();
}
@@ -935,7 +935,7 @@ async function flushPendingJmsDeepLinks() {
}
}
async function deliverSshDeepLink(rawUrl, expectedGeneration = sshSchemeDeliveryGeneration, { viaCommandLine = false } = {}) {
async function deliverSshDeepLink(rawUrl, expectedGeneration = sshSchemeDeliveryGeneration, { viaCommandLine = false, tabName } = {}) {
// The ssh:// preference can flip while a delivery is waiting for the
// renderer, so re-check it at every gate instead of reusing the queue-time
// snapshot. Command-line (PuTTY-style) launches bypass the preference.
@@ -958,7 +958,7 @@ async function deliverSshDeepLink(rawUrl, expectedGeneration = sshSchemeDelivery
const result = await windowManager.sendWhenRendererReady?.(
win,
SSH_DEEP_LINK_CHANNEL,
{ url: rawUrl },
{ url: rawUrl, ...(tabName ? { tabName } : {}) },
{
timeoutMs: getSshDeepLinkRendererReadyTimeoutMs({ isDev }),
shouldSend: shouldDeliver,
@@ -971,7 +971,7 @@ async function deliverSshDeepLink(rawUrl, expectedGeneration = sshSchemeDelivery
return result || { success: true };
}
async function deliverTelnetDeepLink(rawUrl, expectedGeneration = sshSchemeDeliveryGeneration, { viaCommandLine = false } = {}) {
async function deliverTelnetDeepLink(rawUrl, expectedGeneration = sshSchemeDeliveryGeneration, { viaCommandLine = false, tabName } = {}) {
// Mirror deliverSshDeepLink: gate on the live preference so disabling
// protocol handling cancels in-flight scheme deliveries, while
// command-line launches stay deliverable.
@@ -988,7 +988,7 @@ async function deliverTelnetDeepLink(rawUrl, expectedGeneration = sshSchemeDeliv
const result = await windowManager.sendWhenRendererReady?.(
win,
TELNET_DEEP_LINK_CHANNEL,
{ url: rawUrl },
{ url: rawUrl, ...(tabName ? { tabName } : {}) },
{
timeoutMs: 0,
shouldSend: shouldDeliver,
@@ -1015,6 +1015,7 @@ async function flushPendingSshDeepLinks() {
const expectedGeneration = sshSchemeDeliveryGeneration;
const result = await deliverSshDeepLink(item.rawUrl, expectedGeneration, {
viaCommandLine: item.viaCommandLine === true,
tabName: item.tabName,
});
if (shouldRequeueFailedSshDeepLinkDelivery({
enabled: item.viaCommandLine === true || sshDeepLinkEnabled,
@@ -1060,6 +1061,7 @@ async function flushPendingTelnetDeepLinks() {
const expectedGeneration = sshSchemeDeliveryGeneration;
const result = await deliverTelnetDeepLink(item.rawUrl, expectedGeneration, {
viaCommandLine: item.viaCommandLine === true,
tabName: item.tabName,
});
if (shouldRequeueFailedSshDeepLinkDelivery({
enabled: item.viaCommandLine === true || sshDeepLinkEnabled,
@@ -1263,13 +1265,13 @@ if (!gotLock) {
}
if (deepLinkQueueItems.telnet.length > 0) {
deepLinkQueueItems.telnet.forEach((item) => {
queueTelnetDeepLink(item.rawUrl, { viaCommandLine: item.viaCommandLine });
queueTelnetDeepLink(item.rawUrl, { viaCommandLine: item.viaCommandLine, tabName: item.tabName });
});
return;
}
if (deepLinkQueueItems.ssh.length > 0) {
deepLinkQueueItems.ssh.forEach((item) => {
queueSshDeepLink(item.rawUrl, { viaCommandLine: item.viaCommandLine });
queueSshDeepLink(item.rawUrl, { viaCommandLine: item.viaCommandLine, tabName: item.tabName });
});
return;
}
+6 -2
View File
@@ -1557,6 +1557,10 @@ function createPreloadApi(ctx) {
ipcRenderer.invoke("netcatty:tray:setCloseToTray", { enabled }),
isCloseToTray: () =>
ipcRenderer.invoke("netcatty:tray:isCloseToTray"),
setShowTrayIcon: (enabled) =>
ipcRenderer.invoke("netcatty:tray:setShowTrayIcon", { enabled }),
isShowTrayIcon: () =>
ipcRenderer.invoke("netcatty:tray:isShowTrayIcon"),
// Auto Launch at system login (hidden to tray)
getAutoLaunch: () =>
@@ -1887,8 +1891,8 @@ function createPreloadApi(ctx) {
ipcRenderer.on("netcatty:ai:mcp:approval-request", handler);
return () => ipcRenderer.removeListener("netcatty:ai:mcp:approval-request", handler);
},
respondMcpApproval: async (approvalId, approved) => {
return ipcRenderer.invoke("netcatty:ai:mcp:approval-response", { approvalId, approved });
respondMcpApproval: async (approvalId, approved, scope) => {
return ipcRenderer.invoke("netcatty:ai:mcp:approval-response", { approvalId, approved, scope });
},
cancelMcpApprovalTimeout: async (approvalId) => {
return ipcRenderer.invoke("netcatty:ai:mcp:approval-cancel-timeout", { approvalId });
Loaded 100 of 116 files, more files were not shown because too many files have changed in this diff. Show more