Compare commits

..
69 changed files with 506 additions and 1344 deletions

No files matched your search

+18
View File
@@ -1235,6 +1235,24 @@ Highlight the focused split pane:
'deepLink.jms.invalid': 'Invalid JumpServer link',
'deepLink.jms.unsupported': 'Unsupported JumpServer protocol: {{protocol}}',
'settings.tab.plugins': 'Plugins',
"settings.plugins.manager.installed": "Installed plugins",
"settings.plugins.manager.import": "Import .ncpkg",
"settings.plugins.manager.preview": "Developer preview. Imported packages remain disabled until you review and enable them.",
"settings.plugins.manager.empty": "Import a local plugin package to get started.",
"settings.plugins.manager.enabled": "Enabled",
"settings.plugins.manager.disabled": "Disabled",
"settings.plugins.manager.enable": "Enable",
"settings.plugins.manager.disable": "Disable",
"settings.plugins.manager.remove": "Uninstall",
"settings.plugins.manager.removeNote": "Uninstall removes package code and local KV data. User-owned settings, secrets and sync sidecars are retained.",
"settings.plugins.manager.permissions": "Declared permissions",
"settings.plugins.manager.permissionNote": "Read from the installed manifest. Declarations are not grants; sensitive operations still require host approval.",
"settings.plugins.manager.noPermissions": "No privileged permissions declared.",
"settings.plugins.manager.required": "Required",
"settings.plugins.manager.optional": "Optional",
"settings.plugins.manager.browser": "Browser runtime: privileged access goes through host permission brokers.",
"settings.plugins.manager.advanced": "Advanced runtime: full Node filesystem and network authority. Broker permissions cannot restrict this access. Development preview only.",
"settings.plugins.manager.data": "Local KV stays on this device. Only non-secret settings explicitly marked for sync participate in encrypted cloud sidecars. Packages and permission grants do not sync.",
'settings.plugins.title': 'Plugin settings',
'settings.plugins.description': 'Native, validated settings contributed by development plugins.',
'settings.plugins.loading': 'Loading plugin settings…',
-3
View File
@@ -975,9 +975,6 @@ export const enVaultMessages: Messages = {
'hostDetails.sshTimeouts.tcpConnect.desc': 'Seconds to wait for the network connection to this host.',
'hostDetails.sshTimeouts.authReady': 'SSH auth timeout',
'hostDetails.sshTimeouts.authReady.desc': 'Seconds to wait after connecting for SSH negotiation and authentication to finish.',
'hostDetails.sshInputCompatibility': 'Input compatibility: send character by character',
'hostDetails.sshInputCompatibility.desc': 'Try enabling this if Chinese input or short pastes lose characters.',
'hostDetails.sshInputCompatibility.reconnect': 'Saved changes take effect after reconnecting.',
'hostDetails.backspaceBehavior': 'Backspace Behavior',
'hostDetails.backspaceBehavior.default': 'Default',
'hostDetails.jumpHosts': 'Proxy via Hosts',
+18
View File
@@ -1235,6 +1235,24 @@ Resalta el panel dividido enfocado:
'deepLink.jms.invalid': 'Enlace JumpServer inválido',
'deepLink.jms.unsupported': 'Protocolo JumpServer no compatible: {{protocol}}',
'settings.tab.plugins': 'Plugins',
"settings.plugins.manager.installed": "Plugins instalados",
"settings.plugins.manager.import": "Importar .ncpkg",
"settings.plugins.manager.preview": "Vista previa para desarrolladores. Los paquetes importados permanecen desactivados hasta que los revises y actives.",
"settings.plugins.manager.empty": "Importa un paquete local para empezar.",
"settings.plugins.manager.enabled": "Activado",
"settings.plugins.manager.disabled": "Desactivado",
"settings.plugins.manager.enable": "Activar",
"settings.plugins.manager.disable": "Desactivar",
"settings.plugins.manager.remove": "Desinstalar",
"settings.plugins.manager.removeNote": "La desinstalación elimina el código y los datos KV locales. Se conservan los ajustes del usuario, los secretos y los datos auxiliares de sincronización.",
"settings.plugins.manager.permissions": "Permisos declarados",
"settings.plugins.manager.permissionNote": "Obtenidos del manifiesto instalado. Las declaraciones no son autorizaciones; las operaciones sensibles requieren aprobación del anfitrión.",
"settings.plugins.manager.noPermissions": "No se han declarado permisos privilegiados.",
"settings.plugins.manager.required": "Obligatorio",
"settings.plugins.manager.optional": "Opcional",
"settings.plugins.manager.browser": "Entorno de navegador: el acceso privilegiado pasa por los controles de permisos del anfitrión.",
"settings.plugins.manager.advanced": "Entorno avanzado: acceso completo de Node al sistema de archivos y a la red. Los permisos del intermediario no restringen este acceso. Solo para desarrollo.",
"settings.plugins.manager.data": "Los datos KV permanecen en este dispositivo. Solo los ajustes no secretos marcados para sincronización participan en los datos auxiliares cifrados. Los paquetes y las autorizaciones no se sincronizan.",
'settings.plugins.title': 'Configuración de plugins',
'settings.plugins.description': 'Ajustes nativos y validados que aportan los plugins de desarrollo.',
'settings.plugins.loading': 'Cargando ajustes del plugin…',
-3
View File
@@ -965,9 +965,6 @@ export const esVaultMessages: Messages = {
'hostDetails.sshTimeouts.tcpConnect.desc': 'Segundos de espera para la conexión de red a este host.',
'hostDetails.sshTimeouts.authReady': 'Tiempo de espera de autenticación SSH',
'hostDetails.sshTimeouts.authReady.desc': 'Segundos de espera después de conectar para que terminen la negociación y la autenticación SSH.',
'hostDetails.sshInputCompatibility': 'Compatibilidad de entrada: enviar carácter por carácter',
'hostDetails.sshInputCompatibility.desc': 'Actívalo si se pierden caracteres al escribir chino o pegar textos cortos.',
'hostDetails.sshInputCompatibility.reconnect': 'Los cambios guardados se aplican al volver a conectar.',
'hostDetails.backspaceBehavior': 'Comportamiento de la tecla Retroceso',
'hostDetails.backspaceBehavior.default': 'Predeterminado',
'hostDetails.jumpHosts': 'Proxy mediante hosts',
+18
View File
@@ -1130,6 +1130,24 @@ export const ruCoreMessages: Messages = {
'deepLink.jms.unsupported': 'Неподдерживаемый протокол JumpServer: {{protocol}}',
'settings.tab.plugins': 'Плагины',
"settings.plugins.manager.installed": "Установленные плагины",
"settings.plugins.manager.import": "Импорт .ncpkg",
"settings.plugins.manager.preview": "Предварительная версия для разработчиков. Импортированные пакеты отключены до проверки и включения.",
"settings.plugins.manager.empty": "Импортируйте локальный пакет плагина.",
"settings.plugins.manager.enabled": "Включён",
"settings.plugins.manager.disabled": "Отключён",
"settings.plugins.manager.enable": "Включить",
"settings.plugins.manager.disable": "Отключить",
"settings.plugins.manager.remove": "Удалить",
"settings.plugins.manager.removeNote": "Удаление очищает код пакета и локальные данные KV. Настройки пользователя, секреты и данные синхронизации сохраняются.",
"settings.plugins.manager.permissions": "Заявленные разрешения",
"settings.plugins.manager.permissionNote": "Из установленного манифеста. Объявление не является разрешением; чувствительные операции требуют одобрения приложения.",
"settings.plugins.manager.noPermissions": "Привилегированные разрешения не заявлены.",
"settings.plugins.manager.required": "Обязательно",
"settings.plugins.manager.optional": "Необязательно",
"settings.plugins.manager.browser": "Браузерная среда: привилегированный доступ проходит через проверку разрешений приложения.",
"settings.plugins.manager.advanced": "Расширенная среда: полный доступ Node к файловой системе и сети. Разрешения посредника не ограничивают этот доступ. Только для разработки.",
"settings.plugins.manager.data": "Данные KV остаются на устройстве. Синхронизируются только явно отмеченные несекретные настройки в зашифрованных данных. Пакеты и разрешения не синхронизируются.",
'settings.plugins.title': 'Настройки плагинов',
'settings.plugins.description': 'Проверенные нативные настройки от плагинов разработки.',
'settings.plugins.loading': 'Загрузка настроек плагинов…',
-3
View File
@@ -1014,9 +1014,6 @@ export const ruVaultMessages: Messages = {
'hostDetails.sshTimeouts.tcpConnect.desc': 'Сколько секунд ждать сетевого подключения к этому хосту.',
'hostDetails.sshTimeouts.authReady': 'Тайм-аут SSH-входа',
'hostDetails.sshTimeouts.authReady.desc': 'Сколько секунд ждать согласования SSH и завершения аутентификации после подключения.',
'hostDetails.sshInputCompatibility': 'Совместимость ввода: отправка по символам',
'hostDetails.sshInputCompatibility.desc': 'Включите, если при вводе китайского текста или короткой вставке теряются символы.',
'hostDetails.sshInputCompatibility.reconnect': 'Сохранённые изменения применяются после повторного подключения.',
'hostDetails.backspaceBehavior': 'Поведение Backspace',
'hostDetails.backspaceBehavior.default': 'По умолчанию',
'hostDetails.jumpHosts': 'Прокси через хосты',
+18
View File
@@ -1150,6 +1150,24 @@ Vaults 标签保持扁平,不使用这块背景。
'qs.localShells': '本地 Shell',
'qs.default': '默认',
'settings.tab.plugins': '插件',
"settings.plugins.manager.installed": "已安装插件",
"settings.plugins.manager.import": "导入 .ncpkg",
"settings.plugins.manager.preview": "开发者预览。导入的插件保持停用,请检查后再启用。",
"settings.plugins.manager.empty": "导入本地插件包以开始使用。",
"settings.plugins.manager.enabled": "已启用",
"settings.plugins.manager.disabled": "已停用",
"settings.plugins.manager.enable": "启用",
"settings.plugins.manager.disable": "停用",
"settings.plugins.manager.remove": "卸载",
"settings.plugins.manager.removeNote": "卸载会移除插件代码和本地 KV 数据,保留用户设置、秘密存储和同步 sidecar。",
"settings.plugins.manager.permissions": "声明的权限",
"settings.plugins.manager.permissionNote": "来自已安装的 manifest。声明不等于授权,敏感操作仍须通过宿主批准。",
"settings.plugins.manager.noPermissions": "未声明特权权限。",
"settings.plugins.manager.required": "必需",
"settings.plugins.manager.optional": "可选",
"settings.plugins.manager.browser": "浏览器运行时:特权访问通过宿主权限代理。",
"settings.plugins.manager.advanced": "高级运行时:拥有完整 Node 文件系统和网络能力,代理权限无法限制这些访问。仅供开发预览。",
"settings.plugins.manager.data": "本地 KV 仅保留在此设备。只有明确标记为同步的非秘密设置进入加密云 sidecar。插件包和权限授权不参与同步。",
'settings.plugins.title': '插件设置',
'settings.plugins.description': '由开发插件提供、经 Netcatty 原生组件验证的设置。',
'settings.plugins.loading': '正在加载插件设置…',
-3
View File
@@ -384,9 +384,6 @@ export const zhCNVaultMessages: Messages = {
'hostDetails.sshTimeouts.tcpConnect.desc': '等待与该主机建立网络连接的最长时间。',
'hostDetails.sshTimeouts.authReady': '认证超时(秒)',
'hostDetails.sshTimeouts.authReady.desc': '网络连接建立后,等待 SSH 协商和认证完成的最长时间。',
'hostDetails.sshInputCompatibility': '输入兼容模式:逐字发送',
'hostDetails.sshInputCompatibility.desc': '中文输入或短粘贴出现丢字时尝试开启。',
'hostDetails.sshInputCompatibility.reconnect': '保存后重新连接生效。',
'hostDetails.backspaceBehavior': 'Backspace 行为',
'hostDetails.backspaceBehavior.default': '默认',
'hostDetails.jumpHosts': '通过主机代理',
+18
View File
@@ -1153,6 +1153,24 @@ Vaults 分頁維持扁平,不使用這塊背景。
'qs.default': '預設',
'settings.tab.plugins': '外掛程式',
"settings.plugins.manager.installed": "已安裝插件",
"settings.plugins.manager.import": "導入 .ncpkg",
"settings.plugins.manager.preview": "開發者預覽。導入的插件保持停用,請檢查後再啟用。",
"settings.plugins.manager.empty": "導入本地插件包以開始使用。",
"settings.plugins.manager.enabled": "已啟用",
"settings.plugins.manager.disabled": "已停用",
"settings.plugins.manager.enable": "啟用",
"settings.plugins.manager.disable": "停用",
"settings.plugins.manager.remove": "卸載",
"settings.plugins.manager.removeNote": "卸載會移除插件代碼和本地 KV 數據,保留用戶設置、秘密存儲和同步 sidecar。",
"settings.plugins.manager.permissions": "聲明的權限",
"settings.plugins.manager.permissionNote": "來自已安裝的 manifest。聲明不等於授權,敏感操作仍須通過宿主批准。",
"settings.plugins.manager.noPermissions": "未聲明特權權限。",
"settings.plugins.manager.required": "必需",
"settings.plugins.manager.optional": "可選",
"settings.plugins.manager.browser": "瀏覽器運行時:特權訪問通過宿主權限代理。",
"settings.plugins.manager.advanced": "高級運行時:擁有完整 Node 文件系統和網絡能力,代理權限無法限制這些訪問。僅供開發預覽。",
"settings.plugins.manager.data": "本地 KV 僅保留在此設備。只有明確標記為同步的非秘密設置進入加密雲 sidecar。插件包和權限授權不參與同步。",
'settings.plugins.title': '外掛程式設定',
'settings.plugins.description': '由開發外掛程式提供、經 Netcatty 原生元件驗證的設定。',
'settings.plugins.loading': '正在載入外掛程式設定…',
-3
View File
@@ -384,9 +384,6 @@ export const zhTWVaultMessages: Messages = {
'hostDetails.sshTimeouts.tcpConnect.desc': '等待與該主機建立網路連線的最長時間。',
'hostDetails.sshTimeouts.authReady': '驗證逾時(秒)',
'hostDetails.sshTimeouts.authReady.desc': '網路連線建立後,等待 SSH 交握和驗證完成的最長時間。',
'hostDetails.sshInputCompatibility': '輸入相容模式:逐字傳送',
'hostDetails.sshInputCompatibility.desc': '中文輸入或短貼上出現漏字時嘗試開啟。',
'hostDetails.sshInputCompatibility.reconnect': '儲存後重新連線生效。',
'hostDetails.backspaceBehavior': 'Backspace 行為',
'hostDetails.backspaceBehavior.default': '預設',
'hostDetails.jumpHosts': '透過主機代理',
+56
View File
@@ -0,0 +1,56 @@
import { useCallback, useEffect, useRef, useState } from 'react';
import { netcattyBridge } from '../../infrastructure/services/netcattyBridge';
export function usePluginManager() {
const [plugins, setPlugins] = useState<NetcattyInstalledPlugin[]>([]);
const [available, setAvailable] = useState(false);
const [busy, setBusy] = useState(false);
const [error, setError] = useState<string | null>(null);
const generation = useRef(0);
const mutation = useRef(false);
const refresh = useCallback(async () => {
const current = ++generation.current;
const bridge = netcattyBridge.get();
const status = await bridge?.getPluginRuntimeStatus?.();
const list = status?.available ? await bridge?.listPlugins?.() ?? [] : [];
if (current !== generation.current) return;
setAvailable(status?.available === true);
setPlugins(list);
}, []);
useEffect(() => {
const update = () => { void refresh().catch((cause) => setError(String(cause))); };
update();
const unsubscribe = netcattyBridge.get()?.onPluginContributionsChanged?.(update);
const refreshGeneration = generation;
return () => { refreshGeneration.current++; unsubscribe?.(); };
}, [refresh]);
const run = useCallback(async (action: (bridge: NetcattyBridge) => Promise<unknown>) => {
if (mutation.current) return;
mutation.current = true;
setBusy(true);
setError(null);
try { await action(netcattyBridge.require()); }
catch (cause) { setError(cause instanceof Error ? cause.message : String(cause)); }
finally {
try { await refresh(); } catch (cause) { setError(String(cause)); }
mutation.current = false;
setBusy(false);
}
}, [refresh]);
return {
plugins, available, busy, error,
importPackage: () => run(async (bridge) => {
if (!bridge.selectFile || !bridge.installPluginPackage) throw new Error('Plugin import unavailable');
const path = await bridge.selectFile('Import plugin package', undefined, [{ name: 'Netcatty plugin', extensions: ['ncpkg'] }]);
if (path) await bridge.installPluginPackage(path, { enable: false });
}),
setEnabled: (id: string, enabled: boolean) => run(async (bridge) => {
if (!bridge.setPluginEnabled) throw new Error('Plugin management unavailable');
return bridge.setPluginEnabled(id, enabled);
}),
uninstall: (id: string) => run(async (bridge) => {
if (!bridge.uninstallPlugin) throw new Error('Plugin management unavailable');
return bridge.uninstallPlugin(id);
}),
};
}
@@ -1,108 +0,0 @@
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 { JSDOM } from "jsdom";
import { installBrowserLockFixture } from "../testSupport/browserLockFixture";
import type { Host } from "../../domain/models";
import { STORAGE_KEY_HOSTS } from "../../infrastructure/config/storageKeys";
// Exercise actual vault save/load/JSON import and sync boundaries, including
// the asynchronous encrypted-field adapter (without a native safeStorage key).
test("host input choices survive save, restart, duplicate, backup and cloud sync", async () => {
const dom = new JSDOM("", { url: "http://localhost" });
const globals = ["window", "localStorage", "CustomEvent", "IS_REACT_ACT_ENVIRONMENT", "navigator"];
const previous = new Map(globals.map(key => [key, Object.getOwnPropertyDescriptor(globalThis, key)]));
Object.defineProperties(globalThis, {
window: { value: dom.window, configurable: true },
localStorage: { value: dom.window.localStorage, configurable: true },
CustomEvent: { value: dom.window.CustomEvent, configurable: true },
IS_REACT_ACT_ENVIRONMENT: { value: true, configurable: true },
});
installBrowserLockFixture();
const { useVaultState } = await import("./useVaultState");
const { buildSyncPayload, applySyncPayload, buildLocalVaultPayload, applyLocalVaultPayload } = await import("../syncPayload");
const { buildVaultHostFromDraft } = await import("../../domain/vaultHostCreate");
const legacy: Host = { id: "legacy", label: "Legacy", hostname: "localhost", username: "agent", protocol: "ssh", tags: [], os: "linux" };
dom.window.localStorage.setItem(STORAGE_KEY_HOSTS, JSON.stringify([legacy]));
let state: ReturnType<typeof useVaultState>;
let renderer: ReactTestRenderer | undefined;
const Probe = () => { state = useVaultState({ syncConnectionLogTerminalData: false }); return null; };
const mount = async () => {
await act(async () => { renderer = create(React.createElement(Probe)); });
for (let attempt = 0; !state!.isInitialized && attempt < 50; attempt++) {
await act(async () => { await new Promise(resolve => setTimeout(resolve, 10)); });
}
assert.equal(state!.isInitialized, true);
await act(async () => { await state!.readPersistedHosts(); });
};
const choices = () => state!.hosts.map(host => [host.id, host.sshInputCompatibility]);
try {
await mount();
assert.deepEqual(choices(), [["legacy", true]]);
const created = buildVaultHostFromDraft({ hostname: "new.example.test", username: "agent" });
if (created.ok === false) throw Error(created.error);
assert.equal(created.ok, true);
const standard = { ...created.host, id: "standard" };
await act(async () => { await state!.updateHosts([...state!.hosts, standard]); });
assert.deepEqual(choices(), [["legacy", true], ["standard", false]]);
const duplicate = { ...state!.hosts[0], id: "copy", label: "Legacy copy" };
await act(async () => { await state!.updateHosts([...state!.hosts, duplicate]); });
await act(async () => { renderer!.unmount(); });
await mount();
assert.deepEqual(choices(), [["legacy", true], ["standard", false], ["copy", true]]);
// A later deliberate false must survive migration, including another boot.
await act(async () => { await state!.updateHosts(state!.hosts.map(host => host.id === "legacy" ? { ...host, sshInputCompatibility: false } : host)); });
const expected = [["legacy", false], ["standard", false], ["copy", true]];
const exported = JSON.stringify(state!.exportData());
await act(async () => { await state!.updateHosts([]); await state!.importDataFromString(exported); });
assert.deepEqual(choices(), expected);
// Use the real sync payload builder and apply importer, not JSON alone.
const cloud = JSON.parse(JSON.stringify(buildSyncPayload({ ...state!.exportData(), identities: state!.identities, knownHosts: state!.knownHosts })));
await act(async () => { await state!.updateHosts([]); await applySyncPayload(cloud, { importVaultData: state!.importDataFromString }); });
assert.deepEqual(choices(), expected);
const backup = JSON.parse(JSON.stringify(buildLocalVaultPayload({ ...state!.exportData(), identities: state!.identities, knownHosts: state!.knownHosts })));
await act(async () => { await state!.updateHosts([]); await applyLocalVaultPayload(backup, { importVaultData: state!.importDataFromString }); });
assert.deepEqual(choices(), expected);
await act(async () => { renderer!.unmount(); });
await mount();
assert.deepEqual(choices(), expected);
assert.deepEqual((await state!.readPersistedHosts()).map(host => [host.id, host.sshInputCompatibility]), expected);
// Older cloud snapshots enter through the same per-record migration.
cloud.hosts = [{ ...legacy, id: "cloud-legacy" }];
await act(async () => { await applySyncPayload(cloud, { importVaultData: state!.importDataFromString }); });
assert.deepEqual(choices(), [["cloud-legacy", true]]);
await act(async () => { await state!.updateHosts(state!.hosts.map(host => ({ ...host, sshInputCompatibility: false }))); });
await act(async () => { renderer!.unmount(); });
await mount();
assert.deepEqual(choices(), [["cloud-legacy", false]]);
// These drafts enter HostDetailsPanel as initialData, then the real vault
// save/normalization path. They must not be mistaken for legacy records.
const { buildSshDeepLinkHostDraft, parseSshDeepLink } = await import("../../domain/sshDeepLink");
const { normalizePluginImporterRecords } = await import("../../domain/pluginImporter");
const linkDraft = buildSshDeepLinkHostDraft(parseSshDeepLink("ssh://agent@new.test")!, { id: "new-link", now: 1 });
const imported = normalizePluginImporterRecords([undefined, false, true].map(choice => ({
type: "draft" as const, draft: { kind: "host" as const, value: {
hostname: `import-${choice}.test`, protocol: "ssh",
...(choice === undefined ? {} : { sshInputCompatibility: choice }),
} },
})));
assert.deepEqual(imported.errors, []);
const explicitCopy = { ...imported.hosts[2], id: "copy-explicit-input" };
const drafts = [linkDraft, ...imported.hosts, explicitCopy];
const draftChoices = drafts.map(host => [host.id, host.sshInputCompatibility]);
assert.deepEqual(drafts.map(host => host.sshInputCompatibility), [false, false, false, true, true]);
await act(async () => { await state!.updateHosts([...state!.hosts, ...drafts]); });
await act(async () => { renderer!.unmount(); });
await mount();
assert.deepEqual(choices(), [["cloud-legacy", false], ...draftChoices]);
} finally {
await act(async () => { renderer?.unmount(); });
dom.window.close();
for (const key of globals) {
const descriptor = previous.get(key);
if (descriptor) Object.defineProperty(globalThis, key, descriptor);
else Reflect.deleteProperty(globalThis, key);
}
}
});
-1
View File
@@ -1570,7 +1570,6 @@ export const useVaultState = (options?: UseVaultStateOptions) => {
group: "",
tags: [],
protocol: "ssh",
sshInputCompatibility: false,
order: getNextVaultOrder(hosts),
};
-11
View File
@@ -1,14 +1,3 @@
!macro customInit
; #3946: external MCP hosts launch Netcatty.exe with ELECTRON_RUN_AS_NODE=1
; via netcatty-external-mcp.cmd. Those processes can outlive the host UI as
; orphans and keep $INSTDIR locked, so the in-app updater hangs (AppHangB1).
; Force-kill leftover Netcatty.exe before files are replaced. Do not pass /T:
; electron-updater spawns this installer as a child of the UI Netcatty.exe,
; and /T would terminate the installer itself. Ignore a missing process.
nsExec::ExecToLog "taskkill /F /IM Netcatty.exe"
Pop $0
!macroend
!macro customInstall
; Open a selected folder in a new Netcatty local terminal.
; Args go after `--` so Electron/Chromium does not swallow them. Use
@@ -503,19 +503,6 @@ export const HostDetailsAdvancedSections: React.FC<HostDetailsAdvancedSectionsPr
enabled={!!form.showLineTimestamps}
onToggle={() => update("showLineTimestamps", !form.showLineTimestamps)}
/>
{(form.protocol === undefined || form.protocol === "ssh") && !form.moshEnabled && !form.etEnabled && (
<>
<ToggleRow
label={t("hostDetails.sshInputCompatibility")}
hint={t("hostDetails.sshInputCompatibility.desc")}
enabled={form.sshInputCompatibility ?? true}
onToggle={() => update("sshInputCompatibility", !(form.sshInputCompatibility ?? true))}
/>
<p className="text-xs text-muted-foreground">
{t("hostDetails.sshInputCompatibility.reconnect")}
</p>
</>
)}
<HostDetailsSettingRow label={t("hostDetails.backspaceBehavior")}>
<Select
value={form.backspaceBehavior ?? "default"}
-1
View File
@@ -182,7 +182,6 @@ const HostDetailsPanel: React.FC<HostDetailsPanelPropsWithResize> = ({
os: "linux",
authMethod: undefined,
authPolicyVersion: 1,
sshInputCompatibility: false,
charset: groupDefaults?.charset ? undefined : "UTF-8",
distroMode: "auto",
createdAt: Date.now(),
+1 -1
View File
@@ -4063,7 +4063,7 @@ const TerminalComponent: React.FC<TerminalProps> = ({
return;
}
finishReconnectPreparation();
xtermRuntimeRef.current?.resetConnectionInputState(host);
xtermRuntimeRef.current?.resetKittyConnectionInputState();
resetKittyKeyboardModeStateForSession(terminalSettingsRef);
if (effectiveTerminalProtocol.startsWith("plugin:")) {
sessionStarters.startPluginConnection(term);
@@ -0,0 +1,27 @@
import assert from 'node:assert/strict';
import test from 'node:test';
import React from 'react';
import { renderToStaticMarkup } from 'react-dom/server';
import { I18nProvider } from '../../../application/i18n/I18nProvider';
import { PluginPackageDetails } from './PluginPackageManager';
test('package detail renders manifest resources as text and explains advanced authority', () => {
const plugin = {
id: 'com.example.synthetic', enabled: false, activeVersion: '1.0.0',
archiveSha256: 'a'.repeat(64),
manifest: {
manifestVersion: 1, id: 'com.example.synthetic', name: 'synthetic', version: '1.0.0', publisher: 'example',
engines: { netcatty: '>=1.0.0 <2.0.0', api: '0.1.0-internal' },
main: { node: 'index.js' },
permissions: { required: [{ permission: 'network', resources: ['https://example.com'], reason: '<script>attack()</script>' }] },
},
runtime: { status: 'stopped', kind: null, lastError: null, quarantinedAt: null },
} as NetcattyInstalledPlugin;
const html = renderToStaticMarkup(<I18nProvider locale="en"><PluginPackageDetails plugin={plugin} /></I18nProvider>);
assert.match(html, /full Node filesystem and network authority/);
assert.match(html, /https:\/\/example.com/);
assert.match(html, /&lt;script&gt;/);
assert.doesNotMatch(html, /<script>/);
assert.match(html, /Declarations are not grants/);
assert.match(html, /aaaaaaaaaaaaaaaa/);
});
@@ -0,0 +1,72 @@
import React, { useState } from 'react';
import { Package, Upload } from 'lucide-react';
import { usePluginManager } from '../../../application/state/usePluginManager';
import { useI18n } from '../../../application/i18n/I18nProvider';
import { Button } from '../../ui/button';
export function PluginPackageDetails({ plugin }: { plugin: NetcattyInstalledPlugin }) {
const { t } = useI18n();
const manifest = plugin.manifest;
if (!manifest) return null;
const permissions = [
...(manifest.permissions?.required ?? []).map((value) => ({ value, required: true })),
...(manifest.permissions?.optional ?? []).map((value) => ({ value, required: false })),
];
const advanced = !manifest.main.browser || Boolean(manifest.companionExecutables?.length)
|| manifest.contributes?.providers?.some((p) => p.kind === 'terminal.interceptor.input' || p.kind === 'terminal.interceptor.output');
return <div className="space-y-4 border-t border-border/60 pt-4 text-sm">
<dl className="grid grid-cols-[auto_1fr] gap-x-4 gap-y-2 text-xs">
<dt className="text-muted-foreground">ID</dt><dd className="break-all font-mono">{plugin.id}</dd>
<dt className="text-muted-foreground">Netcatty</dt><dd>{manifest.engines.netcatty}</dd>
<dt className="text-muted-foreground">API</dt><dd>{manifest.engines.api}</dd>
<dt className="text-muted-foreground">SHA-256</dt><dd className="break-all font-mono">{plugin.archiveSha256 ?? '-'}</dd>
</dl>
<p className={advanced ? 'text-destructive' : 'text-muted-foreground'}>{t(advanced ? 'settings.plugins.manager.advanced' : 'settings.plugins.manager.browser')}</p>
<div className="space-y-2">
<h4 className="font-medium">{t('settings.plugins.manager.permissions')}</h4>
<p className="text-xs text-muted-foreground">{t('settings.plugins.manager.permissionNote')}</p>
{permissions.length === 0 && <p className="text-xs text-muted-foreground">{t('settings.plugins.manager.noPermissions')}</p>}
{permissions.map(({ value, required }, index) => <div key={index} className="rounded-md bg-muted/40 p-3">
<div className="flex justify-between gap-3"><code className="break-all text-xs">{typeof value === 'string' ? value : value.permission}</code><span className="text-xs text-muted-foreground">{t(required ? 'settings.plugins.manager.required' : 'settings.plugins.manager.optional')}</span></div>
{typeof value !== 'string' && <><p className="mt-1 whitespace-pre-wrap break-all text-xs">{value.resources.join('\n')}</p>{value.reason && <p className="mt-1 text-xs text-muted-foreground">{value.reason}</p>}</>}
</div>)}
</div>
<p className="text-xs text-muted-foreground">{t('settings.plugins.manager.data')}</p>
</div>;
}
export function PluginPackageManager() {
const { t, resolvedLocale } = useI18n();
const manager = usePluginManager();
const [selected, setSelected] = useState<string | null>(null);
const [removing, setRemoving] = useState<string | null>(null);
if (!manager.available) return null;
return <section className="space-y-4" aria-label={t('settings.plugins.manager.installed')}>
<div className="flex items-center justify-between gap-3">
<h3 className="font-medium">{t('settings.plugins.manager.installed')} <span className="text-muted-foreground">{manager.plugins.length}</span></h3>
<Button variant="outline" size="sm" disabled={manager.busy} onClick={() => void manager.importPackage()}><Upload size={14} className="mr-2" />{t('settings.plugins.manager.import')}</Button>
</div>
<p className="text-xs text-muted-foreground">{t('settings.plugins.manager.preview')}</p>
{manager.error && <p role="alert" className="text-sm text-destructive">{manager.error}</p>}
{!manager.plugins.length && <div className="rounded-xl border border-dashed border-border p-8 text-center text-sm text-muted-foreground">{t('settings.plugins.manager.empty')}</div>}
{manager.plugins.map((plugin) => <article key={plugin.id} className="space-y-4 rounded-xl border border-border/70 bg-background p-4">
<div className="flex items-center gap-3">
<div className="rounded-lg bg-muted p-2"><Package size={20} /></div>
<button type="button" className="min-w-0 flex-1 text-left" aria-expanded={selected === plugin.id} onClick={() => setSelected(selected === plugin.id ? null : plugin.id)}>
<div className="truncate text-sm font-medium">{typeof plugin.manifest?.displayName === 'string' ? plugin.manifest.displayName : plugin.manifest?.displayName?.[resolvedLocale] ?? plugin.manifest?.displayName?.en ?? plugin.manifest?.name ?? plugin.id}</div>
<div className="text-xs text-muted-foreground">{plugin.activeVersion} · {t(plugin.enabled ? 'settings.plugins.manager.enabled' : 'settings.plugins.manager.disabled')}</div>
</button>
<Button variant="outline" size="sm" disabled={manager.busy} onClick={() => void manager.setEnabled(plugin.id, !plugin.enabled)}>{t(plugin.enabled ? 'settings.plugins.manager.disable' : 'settings.plugins.manager.enable')}</Button>
</div>
{plugin.runtime.lastError && <p role="status" className="break-words text-xs text-destructive">{plugin.runtime.lastError}</p>}
{selected === plugin.id && <>
<PluginPackageDetails plugin={plugin} />
{removing === plugin.id ? <div className="space-y-2 rounded-lg border border-destructive/30 p-3">
<p className="text-xs">{t('settings.plugins.manager.removeNote')}</p>
<Button variant="destructive" size="sm" disabled={manager.busy} onClick={() => { void manager.uninstall(plugin.id); setRemoving(null); }}>{t('settings.plugins.manager.remove')}</Button>
<Button variant="ghost" size="sm" onClick={() => setRemoving(null)}>{t('common.cancel')}</Button>
</div> : <Button variant="ghost" size="sm" disabled={manager.busy} onClick={() => setRemoving(plugin.id)}>{t('settings.plugins.manager.remove')}</Button>}
</>}
</article>)}
</section>;
}
@@ -1,3 +1,4 @@
import { PluginPackageManager } from './PluginPackageManager';
import { FolderOpen } from 'lucide-react';
import React, { useEffect, useMemo, useState } from 'react';
@@ -307,6 +308,7 @@ export default function SettingsPluginsTab() {
<h2 className="text-xl font-semibold">{t('settings.plugins.title')}</h2>
<p className="mt-1 text-sm text-muted-foreground">{t('settings.plugins.description')}</p>
</div>
<PluginPackageManager />
{contextualScopes.size > 0 && (
<section className="grid gap-3 rounded-lg border border-border/70 bg-muted/10 p-4 sm:grid-cols-2" aria-label={t('settings.plugins.scopeTargets')}>
{[...contextualScopes].map((kind) => (
-123
View File
@@ -10,11 +10,9 @@ import {
formatTerminalTitleConnectionAddress,
focusTerminalFromDisconnectedNotice,
getLineTimestampToggleHostUpdate,
resolveLineTimestampGutterLayout,
resolveNetworkDeviceTipRightInset,
resolveTerminalRightInset,
resolveTerminalTopOffsets,
subscribeTerminalAlternateScreenChanges,
shouldBlockTerminalReconnectForTarget,
shouldEnableStatusBarDisconnect,
shouldEnableStatusBarReconnect,
@@ -184,127 +182,6 @@ test("line timestamp toolbar toggle is hidden when timestamps are unavailable",
assert.equal(shouldShowLineTimestampToolbarToggle(true, undefined), false);
});
test("line timestamp gutter reserves no space while the alternate screen is active", () => {
assert.deepEqual(resolveLineTimestampGutterLayout({
showLineTimestampGutter: true,
alternateScreenActive: true,
gutterWidth: 72,
}), { enabled: false, width: 0 });
assert.deepEqual(resolveLineTimestampGutterLayout({
showLineTimestampGutter: true,
alternateScreenActive: false,
gutterWidth: 72,
}), { enabled: true, width: 72 });
assert.deepEqual(resolveLineTimestampGutterLayout({
showLineTimestampGutter: false,
alternateScreenActive: false,
gutterWidth: 72,
}), { enabled: false, width: 0 });
const source = readFileSync(new URL("./TerminalView.tsx", import.meta.url), "utf8");
assert.match(source, /const activeLineTimestampGutterWidth = lineTimestampGutterLayout\.width/);
assert.match(source, /enabled=\{lineTimestampGutterLayout\.enabled\}/);
assert.doesNotMatch(source, /enabled=\{showLineTimestampGutter\}/);
assert.match(source, /aria-pressed=\{showLineTimestampGutter\}/);
assert.doesNotMatch(source, /aria-pressed=\{[^}]*alternateScreenActive/);
});
type AlternateScreenWatchTerm = {
buffer: { active: { type: string } };
onRender: (callback: () => void) => { dispose: () => void };
};
function createAlternateScreenWatchTerm(type: "normal" | "alternate") {
const renders: Array<() => void> = [];
let disposed = 0;
const term: AlternateScreenWatchTerm = {
buffer: { active: { type } },
onRender(callback) {
renders.push(callback);
return { dispose: () => { disposed += 1; } };
},
};
return {
term,
renders,
disposeCount: () => disposed,
};
}
test("alternate screen enter and leave drive the timestamp gutter", (t) => {
const watched = createAlternateScreenWatchTerm("normal");
const seen: boolean[] = [];
const stop = subscribeTerminalAlternateScreenChanges(
() => watched.term,
(active) => { seen.push(active); },
);
t.after(stop);
assert.deepEqual(seen, [false]);
watched.term.buffer.active.type = "alternate";
watched.renders[0]();
watched.renders[0]();
watched.term.buffer.active.type = "normal";
watched.renders[0]();
assert.deepEqual(seen, [false, true, false]);
stop();
assert.equal(watched.disposeCount(), 1);
watched.term.buffer.active.type = "alternate";
watched.renders[0]();
assert.deepEqual(seen, [false, true, false]);
});
test("alternate screen watch attaches after the terminal runtime exists", (t) => {
t.mock.timers.enable({ apis: ["setTimeout"] });
const watched = createAlternateScreenWatchTerm("alternate");
let current: AlternateScreenWatchTerm | null = null;
const seen: boolean[] = [];
const stop = subscribeTerminalAlternateScreenChanges(
() => current,
(active) => { seen.push(active); },
);
t.after(() => {
stop();
t.mock.timers.reset();
});
assert.deepEqual(seen, [false]);
current = watched.term;
t.mock.timers.tick(50);
assert.deepEqual(seen, [false, true]);
assert.equal(watched.renders.length, 1);
t.mock.timers.tick(500);
assert.equal(watched.renders.length, 1);
assert.deepEqual(seen, [false, true]);
});
test("alternate screen watch follows a replacement terminal runtime", (t) => {
t.mock.timers.enable({ apis: ["setTimeout"] });
const first = createAlternateScreenWatchTerm("alternate");
const second = createAlternateScreenWatchTerm("normal");
let current: AlternateScreenWatchTerm = first.term;
const seen: boolean[] = [];
const stop = subscribeTerminalAlternateScreenChanges(
() => current,
(active) => { seen.push(active); },
);
t.after(() => {
stop();
t.mock.timers.reset();
});
assert.deepEqual(seen, [true]);
current = second.term;
t.mock.timers.tick(250);
assert.deepEqual(seen, [true, false]);
assert.equal(first.disposeCount(), 1);
assert.equal(second.renders.length, 1);
second.term.buffer.active.type = "alternate";
second.renders[0]();
assert.deepEqual(seen, [true, false, true]);
});
test("selection AI overlay honors the visibility preference", () => {
const overlayPosition = { left: 120, top: 80 };
const addSelection = () => {};
+2 -116
View File
@@ -1,6 +1,5 @@
/* eslint-disable @typescript-eslint/no-explicit-any */
import React, { memo, useCallback, useEffect, useRef, useState } from 'react';
import type { Terminal as XTerm } from '@xterm/xterm';
import { ChevronsLeft, GripVertical, Minimize2, Network, PanelLeft, X as XIcon } from 'lucide-react';
import { isTerminalSensitiveInputActive } from './runtime/terminalSensitiveInputRegistry';
import { isSessionReconnectDisabled } from '../top-tabs/SessionTabContextMenuContent';
@@ -19,7 +18,6 @@ import {
resolveTerminalTimestampGutterColor,
resolveTerminalTimestampGutterWidth,
} from './TerminalTimestampGutter';
import { isTerminalAlternateScreenActive } from './terminalHibernateRuntime';
import {
Dialog,
DialogContent,
@@ -124,108 +122,6 @@ export function shouldShowLineTimestampToolbarToggle(
return lineTimestampsAvailable !== false && Boolean(onUpdateHost);
}
export function resolveLineTimestampGutterLayout({
showLineTimestampGutter,
alternateScreenActive,
gutterWidth,
}: {
showLineTimestampGutter: boolean;
alternateScreenActive: boolean;
gutterWidth: number;
}): { enabled: boolean; width: number } {
const enabled = showLineTimestampGutter && !alternateScreenActive;
return { enabled, width: enabled ? gutterWidth : 0 };
}
const ALTERNATE_SCREEN_ATTACH_RETRY_MS = 50;
const ALTERNATE_SCREEN_RUNTIME_WATCH_MS = 250;
type TerminalAlternateScreenDisposable = { dispose: () => void };
type TerminalAlternateScreenWatchTarget = {
buffer?: { active?: { type?: string } };
onRender?: (callback: () => void) => TerminalAlternateScreenDisposable;
};
const readAlternateScreenActive = (
term: TerminalAlternateScreenWatchTarget | null | undefined,
): boolean => {
try {
if (!term?.buffer?.active) return false;
return isTerminalAlternateScreenActive(term as XTerm);
} catch {
return false;
}
};
/**
* Alternate-screen programs intentionally skip timestamp recording. onRender
* collapses and restores the gutter immediately. The timer attaches once xterm
* exists and follows a replacement runtime after hibernate or reconnect.
*/
export function subscribeTerminalAlternateScreenChanges(
getTerm: () => TerminalAlternateScreenWatchTarget | null | undefined,
onChange: (alternateScreenActive: boolean) => void,
): () => void {
let disposed = false;
let timer: ReturnType<typeof setTimeout> | null = null;
let renderDisposable: TerminalAlternateScreenDisposable | null = null;
let attachedTerm: TerminalAlternateScreenWatchTarget | null = null;
let lastActive: boolean | null = null;
const publish = (active: boolean) => {
if (disposed || lastActive === active) return;
lastActive = active;
onChange(active);
};
const detachRender = () => {
renderDisposable?.dispose();
renderDisposable = null;
};
const watch = () => {
if (disposed) return;
if (timer !== null) {
clearTimeout(timer);
timer = null;
}
const term = getTerm() ?? null;
if (term !== attachedTerm) {
detachRender();
attachedTerm = term;
if (term?.onRender) {
renderDisposable = term.onRender(() => {
if (disposed) return;
const current = getTerm() ?? null;
if (current !== term) {
watch();
return;
}
publish(readAlternateScreenActive(current));
});
}
}
publish(readAlternateScreenActive(term));
if (disposed) return;
const delay = term?.onRender
? ALTERNATE_SCREEN_RUNTIME_WATCH_MS
: ALTERNATE_SCREEN_ATTACH_RETRY_MS;
timer = setTimeout(watch, delay);
};
watch();
return () => {
disposed = true;
if (timer !== null) clearTimeout(timer);
detachRender();
attachedTerm = null;
};
}
/** Keep the tab/pane; only tear down the live transport. */
export function shouldEnableStatusBarDisconnect(
status: 'connecting' | 'connected' | 'disconnected' | undefined,
@@ -590,11 +486,6 @@ function TerminalViewInner({ ctx, isPaneMagnified = false }: { ctx: TerminalView
}, [hostTimestampsEnabled, timestampOverride]);
const showLineTimestampGutter = lineTimestampsAvailable !== false
&& (timestampOverride ?? hostTimestampsEnabled);
const [alternateScreenActive, setAlternateScreenActive] = useState(false);
useEffect(() => subscribeTerminalAlternateScreenChanges(
() => termRef.current,
setAlternateScreenActive,
), [sessionId, status, termRef]);
const lineTimestampColor = resolveTerminalTimestampGutterColor(effectiveTheme.colors);
const [lineTimestampGutterWidth, setLineTimestampGutterWidth] = useState(() => (
resolveTerminalTimestampGutterWidth({ fontSize: effectiveFontSize })
@@ -606,12 +497,7 @@ function TerminalViewInner({ ctx, isPaneMagnified = false }: { ctx: TerminalView
const handleLineTimestampGutterWidthChange = useCallback((width: number) => {
setLineTimestampGutterWidth((current) => (current === width ? current : width));
}, []);
const lineTimestampGutterLayout = resolveLineTimestampGutterLayout({
showLineTimestampGutter,
alternateScreenActive,
gutterWidth: lineTimestampGutterWidth,
});
const activeLineTimestampGutterWidth = lineTimestampGutterLayout.width;
const activeLineTimestampGutterWidth = showLineTimestampGutter ? lineTimestampGutterWidth : 0;
const lineTimestampToggleLabel = showLineTimestampGutter
? t("terminal.toolbar.timestampsDisable")
: t("terminal.toolbar.timestampsEnable");
@@ -1200,7 +1086,7 @@ function TerminalViewInner({ ctx, isPaneMagnified = false }: { ctx: TerminalView
<TerminalTimestampGutter
termRef={termRef}
containerRef={containerRef}
enabled={lineTimestampGutterLayout.enabled}
enabled={showLineTimestampGutter}
top={terminalContentTop}
left={terminalBodyInset}
bottom={terminalBottomInset}
@@ -5,7 +5,6 @@ import { createRequire } from "node:module";
import type { SerializeAddon as SerializeAddonType } from "@xterm/addon-serialize";
import type { Terminal as XTermType } from "@xterm/xterm";
import { DEFAULT_KEYWORD_HIGHLIGHT_RULES } from "../../domain/models/terminal.ts";
import { writeTerminalDataWithLineTimestamps } from "./runtime/terminalLineTimestamps.ts";
import {
noteTerminalOutputPressureData,
@@ -82,76 +81,6 @@ test("new output is colored on the written cells without decorations", async ()
term.dispose();
});
test("keyword highlight skips cells whose PTY foreground is already colored", async () => {
// Starship directory segments set a truecolor foreground (#3940). Those
// cells must keep the PTY color; only default-foreground matches are recolored.
const unixPath = DEFAULT_KEYWORD_HIGHLIGHT_RULES.find((entry) => entry.id === "unix-path");
assert.ok(unixPath);
const keywordRgb = Number.parseInt(unixPath.color.slice(1), 16);
const cream = 0xfbf1c7;
const yellow = 0xfabd2f;
const term = new XTerm({ allowProposedApi: true, cols: 80, rows: 5, scrollback: 20 });
const highlighter = new KeywordHighlighter(term);
highlighter.setRules([unixPath], true);
await write(
term,
"\x1b[38;2;251;241;199m\x1b[48;2;250;189;47m/opt\x1b[0m"
+ " \x1b[38;5;220m/mnt\x1b[0m"
+ " \x1b[31m/bin\x1b[0m"
+ " /var",
);
const tokenCells = (text: string) => {
const line = term.buffer.active.getLine(0);
const index = (line?.translateToString(true) ?? "").indexOf(text);
assert.ok(index >= 0, text);
return Array.from({ length: text.length }, (_, offset) => {
const cell = line?.getCell(index + offset);
assert.ok(cell, `${text}[${offset}]`);
return cell;
});
};
for (const cell of tokenCells("/opt")) {
assert.equal(cell.isFgRGB(), true);
assert.equal(cell.getFgColorMode(), 0x3000000);
assert.equal(cell.getFgColor(), cream);
assert.equal(cell.isBgRGB(), true);
assert.equal(cell.getBgColor(), yellow);
}
for (const cell of tokenCells("/mnt")) {
assert.equal(cell.isFgPalette(), true);
assert.equal(cell.getFgColorMode(), 0x2000000);
assert.equal(cell.getFgColor(), 220);
}
for (const cell of tokenCells("/bin")) {
assert.equal(cell.isFgPalette(), true);
assert.equal(cell.getFgColorMode(), 0x1000000);
assert.equal(cell.getFgColor(), 1);
}
for (const cell of tokenCells("/var")) {
assert.equal(cell.isFgRGB(), true);
assert.equal(cell.getFgColor(), keywordRgb);
}
highlighter.setRules([{ ...unixPath, color: "#60A5FA" }], true);
await highlighter.whenSettled();
assert.equal(tokenCells("/opt")[0]?.getFgColor(), cream);
assert.equal(tokenCells("/mnt")[0]?.getFgColor(), 220);
assert.equal(tokenCells("/bin")[0]?.getFgColor(), 1);
assert.equal(tokenCells("/var")[0]?.getFgColor(), BLUE);
highlighter.setRules([{ ...unixPath, color: "#60A5FA" }], false);
await highlighter.whenSettled();
assert.equal(tokenCells("/opt")[0]?.getFgColor(), cream);
assert.equal(tokenCells("/mnt")[0]?.getFgColor(), 220);
assert.equal(tokenCells("/bin")[0]?.getFgColor(), 1);
assert.equal(tokenCells("/var")[0]?.getFgColor(), -1);
highlighter.dispose();
term.dispose();
});
test("ordinary Enter output does not revisit existing history", async () => {
const term = new XTerm({ allowProposedApi: true, cols: 80, rows: 5, scrollback: 20 });
const highlighter = new KeywordHighlighter(term);
+1 -8
View File
@@ -67,7 +67,6 @@ const CELL_INDICES = 3;
const CELL_CONTENT = 0;
const CELL_FG = 1;
const STYLE_MASK = 0xfc000000;
const CM_MASK = 0x3000000;
const CM_RGB = 0x3000000;
const MAX_PLUGIN_HIGHLIGHT_SCAN_CHARS = 4_096;
const MAX_PLUGIN_HIGHLIGHT_MATCHES_PER_WRITE = 256;
@@ -1062,13 +1061,7 @@ export class KeywordHighlighter implements IDisposable {
const dataIndex = x * CELL_INDICES;
const content = internal._data[dataIndex + CELL_CONTENT];
const currentFg = internal._data[dataIndex + CELL_FG];
// A cell already carrying a PTY palette/truecolor foreground (starship
// directory segments, ANSI logs) must keep that color. Keyword RGB is
// only for the default foreground.
const maskedForContent = originals.mask[x] === 1 && originals.content[x] === content;
const ptyFg = maskedForContent ? originals.fg[x] : currentFg;
if ((ptyFg & CM_MASK) !== 0) continue;
if (!maskedForContent) {
if (!originals.mask[x] || originals.content[x] !== content) {
originals.fg[x] = currentFg;
originals.content[x] = content;
originals.mask[x] = 1;
@@ -34,7 +34,7 @@ test("local terminals stay out of hibernate and hidden-renderer paths", () => {
test("reconnect clears keyboard pairing before resetting protocol state", () => {
assert.match(
terminalSource,
/startNewSession = \(\) => \{[\s\S]*resetConnectionInputState\(host\);[\s\S]*resetKittyKeyboardModeStateForSession/,
/startNewSession = \(\) => \{[\s\S]*resetKittyConnectionInputState\(\);[\s\S]*resetKittyKeyboardModeStateForSession/,
);
assert.match(
runtimeSource,
@@ -42,7 +42,7 @@ test("reconnect clears keyboard pairing before resetting protocol state", () =>
);
assert.match(
runtimeSource,
/resetConnectionInputState: \(host\) => \{[\s\S]*clearKittyConnectionInputState\(\)/,
/resetKittyConnectionInputState: clearKittyConnectionInputState/,
);
assert.match(
runtimeSource,
@@ -985,12 +985,13 @@ test("multi-character plain text goes out as per-character writes (#3077)", asyn
const { readFileSync } = await import("node:fs");
const source = readFileSync(new URL("./createXTermRuntime.ts", import.meta.url), "utf8");
// The legacy workaround selected IME commits and short raw pastes (#3077).
// Host settings now gate that selection at the shared send boundary.
// Strict bastion prompts (QAX) treat one channel write as one keystroke and
// drop multi-character chunks, so IME commits and short raw pastes must be
// sent one character per write instead of one write per commit.
assert.match(source, /perCharacterWrites\?: boolean/);
assert.match(
source,
/getTextInputWireChunks\(outData, usePerCharacterInput && options\?\.perCharacterWrites === true, \(ctx\.host\.protocol === undefined \|\| ctx\.host\.protocol === "ssh"\) && !ctx\.host\.moshEnabled && !ctx\.host\.etEnabled\)/,
/getTextInputWireChunks\(outData, options\?\.perCharacterWrites === true\)/,
);
// Every chunk carries the same write options as the original single write.
const writeLoopIdx = source.indexOf("for (const chunk of getTextInputWireChunks(outData");
@@ -252,9 +252,6 @@ type TerminalBackendApi = {
// renderer hibernation. Weak ownership keeps negotiated keyboard state alive
// for that session without introducing a process-wide session registry.
const kittyKeyboardStates = createKittyKeyboardSessionStateStore();
// The same session-owned ref also keeps the connection's input choice alive
// when hibernation destroys xterm. Only a real reconnect refreshes this choice.
const inputCompatibilityStates = new WeakMap<object, boolean>();
const resolveKittyKeyboardModeState = (
ctx: Pick<
@@ -310,8 +307,8 @@ export type XTermRuntime = {
* terminal reset or FIFO eviction.
*/
hasInlineImages: () => boolean;
/** Reset keyboard state and snapshot endpoint settings for a fresh connection. */
resetConnectionInputState: (host: Host) => void;
/** Clear local/per-target keyboard state before reusing this runtime. */
resetKittyConnectionInputState: () => void;
/** Emit any owed releases before detaching or closing this renderer. */
flushKittyKeyboardReleases: () => void;
/** Transfer negotiated keyboard state across renderer attach handoffs. */
@@ -518,13 +515,6 @@ export const createXTermRuntime = (ctx: CreateXTermRuntimeContext): XTermRuntime
const rendererType = settings?.rendererType ?? "auto";
const bridge = netcattyBridge.get();
const isLocalTerminalHost = ctx.host.protocol === "local";
// Capture the terminal endpoint's choice for this connection, never a chain hop.
const usesPerCharacterInput = (host: Host) =>
(host.protocol !== undefined && host.protocol !== "ssh") || host.moshEnabled === true
|| host.etEnabled === true || host.sshInputCompatibility === true;
let usePerCharacterInput = inputCompatibilityStates.get(ctx.terminalSettingsRef)
?? usesPerCharacterInput(ctx.host);
inputCompatibilityStates.set(ctx.terminalSettingsRef, usePerCharacterInput);
const windowsPty =
platform === "win32" && isLocalTerminalHost
? bridge?.getWindowsPtyInfo?.() ?? { backend: "conpty" as const }
@@ -1335,9 +1325,10 @@ export const createXTermRuntime = (ctx: CreateXTermRuntimeContext): XTermRuntime
lineDelayMs?: number;
pasteRequestId?: string;
/**
* Request the legacy IME/short-paste workaround reported in #3077.
* The SSH host's compatibility choice gates the final writes below.
* Bookkeeping still sees the whole payload once.
* Send plain text as one write per character. Strict bastion prompts
* (QAX) treat one channel write as a keystroke and drop multi-character
* chunks, so IME commits and short raw pastes must go out per
* character (#3077). Bookkeeping still sees the whole payload once.
*/
perCharacterWrites?: boolean;
},
@@ -1559,7 +1550,7 @@ export const createXTermRuntime = (ctx: CreateXTermRuntimeContext): XTermRuntime
}
ctx.onOutputTriggerUserInputRef?.current?.(outData);
for (const chunk of getTextInputWireChunks(outData, usePerCharacterInput && options?.perCharacterWrites === true, (ctx.host.protocol === undefined || ctx.host.protocol === "ssh") && !ctx.host.moshEnabled && !ctx.host.etEnabled)) {
for (const chunk of getTextInputWireChunks(outData, options?.perCharacterWrites === true)) {
ctx.terminalBackend.writeToSession(id, chunk, {
sensitive,
serialEraseChar,
@@ -3149,7 +3140,7 @@ export const createXTermRuntime = (ctx: CreateXTermRuntimeContext): XTermRuntime
broadcastLegacyDataPending = null;
}
// Raw multi-character onData is an unbracketed paste (or a committed
// composition): selected short plain text can use compatibility writes.
// composition): short plain text must reach strict bastions per character.
// Decide on the sanitized payload so removed zero-width characters cannot
// keep a qualifying paste above the split cap (#3138).
const sanitizedRawData = sanitizeTerminalInput(data);
@@ -3488,11 +3479,7 @@ export const createXTermRuntime = (ctx: CreateXTermRuntimeContext): XTermRuntime
ligatures.apply(enabled);
},
hasInlineImages,
resetConnectionInputState: (host) => {
usePerCharacterInput = usesPerCharacterInput(host);
inputCompatibilityStates.set(ctx.terminalSettingsRef, usePerCharacterInput);
clearKittyConnectionInputState();
},
resetKittyConnectionInputState: clearKittyConnectionInputState,
flushKittyKeyboardReleases: clearKittyTransientInputState,
getKittyKeyboardModeState: () => snapshotKittyKeyboardModeState(kittyKeyboardMode),
restoreKittyKeyboardModeState: (state) => restoreKittyKeyboardModeState(
@@ -27,7 +27,7 @@ for (const prefix of ['fresh','empty-arrow','submit','paste-submit','interrupt',
const session={encoding, serialPort:{write(data: string | Buffer){for(const byte of Buffer.from(data)) { if(byte===127||byte===8) wire=wire.subarray(0,Math.max(0,wire.length-1)); else wire=Buffer.concat([wire,Buffer.from([byte])]);}}}};
bridge.init({sessions:new Map([['s',session]]),electronModule:{}});
const ctx={host:{protocol:'serial',id:'h',label:'h'},sessionId:'s',sessionRef:{current:'s'},statusRef:{current:'connected'},commandBufferRef:{current:''},onBroadcastInputRef:{current:null},isBroadcastEnabledRef:{current:false},terminalBackend:{writeToSession(id:string,data:string,opts: {serialEraseChar?: string; sensitive?: boolean}){bridge.writeToSession({}, {sessionId:id,data,...opts})}},serialByteOrientedBackspace:true,serialLocalEcho:false};
const env={usePerCharacterInput:true,...Object.assign({},...modules),api: undefined as unknown as {handleTerminalInputData: (data: string) => void; recordSerialSnippetInput: (data: string) => void},ctx,term:{buffer:{active:{cursorX:0,cursorY:0,baseY:0,getLine:()=>undefined}},cols:80},suppressNextTerminalDataBroadcast:false,handlingKittyBroadcast:false,shouldBroadcastTerminalUserInput:()=>false,prioritizeTerminalInput:()=>{},getFlowControllerForTerm:()=>null,shouldSuppressTerminalInputScrollForUserPaste:()=>true,shouldOverrideTerminalUserPasteSensitivity:()=>false,scrollToBottomAfterInput:()=>{},writeLocalTerminalData:()=>{}};
const env={...Object.assign({},...modules),api: undefined as unknown as {handleTerminalInputData: (data: string) => void; recordSerialSnippetInput: (data: string) => void},ctx,term:{buffer:{active:{cursorX:0,cursorY:0,baseY:0,getLine:()=>undefined}},cols:80},suppressNextTerminalDataBroadcast:false,handlingKittyBroadcast:false,shouldBroadcastTerminalUserInput:()=>false,prioritizeTerminalInput:()=>{},getFlowControllerForTerm:()=>null,shouldSuppressTerminalInputScrollForUserPaste:()=>true,shouldOverrideTerminalUserPasteSensitivity:()=>false,scrollToBottomAfterInput:()=>{},writeLocalTerminalData:()=>{}};
vm.runInNewContext(code,env); const {handleTerminalInputData:input,recordSerialSnippetInput:snippet}=env.api;
if(prefix==='empty-arrow') input('\x1b[D');
if(prefix==='submit') {input('旧');input('\x1b[D');input('\r');}
@@ -1,171 +0,0 @@
import assert from "node:assert/strict";
import path from "node:path";
import test from "node:test";
import { fileURLToPath } from "node:url";
import { buildSync } from "esbuild";
import { JSDOM } from "jsdom";
// Real xterm + runtime input callbacks; canvas measurement is stubbed because
// these assertions concern writes, not rendering or a native OS input method.
test("SSH host mode gates every interactive text entry and stays fixed until reconnect", async () => {
const root = fileURLToPath(new URL("../../..", import.meta.url));
const bundle = buildSync({
stdin: {
contents: 'export {createXTermRuntime} from "./components/terminal/runtime/createXTermRuntime"; export {DEFAULT_TERMINAL_SETTINGS} from "./domain/models/terminal"; export {dispatchTerminalLinePaste} from "./components/terminal/runtime/terminalUserPaste"; export {wakeTerminalFromHibernate} from "./components/terminal/terminalRuntimeMount";',
loader: "ts", resolveDir: root,
},
bundle: true, format: "iife", globalName: "runtimeTest", platform: "browser", write: false,
alias: {
"node:diagnostics_channel": path.join(root, "infrastructure/shims/nodeDiagnosticsChannel.ts"),
"diagnostics_channel": path.join(root, "infrastructure/shims/nodeDiagnosticsChannel.ts"),
},
define: { "import.meta.env.DEV": "false", "import.meta.env.PROD": "true", "import.meta": "{}" },
}).outputFiles[0].text;
const dom = new JSDOM("<body/>", { url: "http://localhost", runScripts: "outside-only", pretendToBeVisual: true });
Object.assign(dom.window, { structuredClone });
try {
dom.window.eval(`
window.matchMedia = () => ({matches:false,addListener(){},removeListener(){},addEventListener(){},removeEventListener(){}});
window.ResizeObserver = window.IntersectionObserver = class {observe(){} unobserve(){} disconnect(){}};
HTMLCanvasElement.prototype.getContext = function(type) {
if (type !== '2d') return null;
return new Proxy({canvas:this,measureText:()=>({width:8,fontBoundingBoxAscent:12,fontBoundingBoxDescent:3}),
getImageData:()=>({data:new Uint8ClampedArray(4)}),createLinearGradient:()=>({addColorStop(){}})},
{get:(target,key)=>key in target ? target[key] : ()=>{}});
};
`);
dom.window.eval(bundle);
const result = await dom.window.eval(`(async () => {
const {createXTermRuntime, DEFAULT_TERMINAL_SETTINGS, dispatchTerminalLinePaste, wakeTerminalFromHibernate} = runtimeTest;
const ref = current => ({current});
const panes = [];
const make = host => {
const el = document.createElement('div'); document.body.appendChild(el);
const writes = [], logical = [], broadcasts = [];
const context = {
container:el, host, getHost:()=>host,
fontFamilyId:'jetbrains-mono', resolvedFontFamily:'monospace', fontSize:14,
terminalTheme:{colors:{background:'#111111',foreground:'#eeeeee',cursor:'#ffffff',selection:'#444444'}},
terminalSettingsRef:ref({...DEFAULT_TERMINAL_SETTINGS,rendererType:'dom',fontLigatures:false,cursorBlink:false}),
terminalBackend:{writeToSession:(_,data)=>writes.push(data),resizeSession(){},openExternalAvailable:false},
sessionRef:ref(host.id),hotkeySchemeRef:ref('disabled'),disableTerminalFontZoomRef:ref(false),
keyBindingsRef:ref([]),onHotkeyActionRef:ref(undefined),isBroadcastEnabledRef:ref(true),
onBroadcastInputRef:ref(data=>{broadcasts.push(data);return [];}),
onOutputTriggerUserInputRef:ref(data=>logical.push(data)),
sessionId:host.id,statusRef:ref('connected'),commandBufferRef:ref(''),requestSearchFocus(){},
kittyKeyboardProtocolEnabled:true,
};
const runtime = createXTermRuntime(context);
const pane={...runtime,writes,logical,broadcasts,context,el}; panes.push(pane); return pane;
};
const equal = (actual, expected, label) => {
if (JSON.stringify(actual) !== JSON.stringify(expected)) throw Error(label+': '+JSON.stringify({actual,expected}));
};
const wait = () => new Promise(resolve=>setTimeout(resolve,20));
const check = (pane, text, split, label) => {
equal(pane.writes.splice(0),split ? Array.from(text) : [text],label);
equal(pane.logical.splice(0),[text],label+' logical payload once');
pane.broadcasts.length=0;
};
const hosts=[false,true].map((mode,index)=>({id:'mode-'+index,label:'Mode',hostname:'localhost',protocol:'ssh',sshInputCompatibility:mode}));
const metrics=[];
for (const host of hosts) {
const pane=make(host), term=pane.term, ta=term.textarea, mode=host.sshInputCompatibility;
for (const text of ['中文','👩🏽‍💻é','x'.repeat(32),'x'.repeat(33)]) {
term.paste(text); check(pane,text,mode && Array.from(text).length<=32,'raw paste '+mode);
}
ta.dispatchEvent(new InputEvent('input',{bubbles:true,inputType:'insertText',data:'中国'}));
check(pane,'中国',mode,'insertText');
const longIme='中'.repeat(40);
ta.dispatchEvent(new InputEvent('input',{bubbles:true,inputType:'insertText',data:longIme}));
check(pane,longIme,mode,'IME keeps its uncapped scope');
ta.dispatchEvent(new KeyboardEvent('keydown',{bubbles:true,keyCode:229,key:'Process'}));
ta.dispatchEvent(new InputEvent('input',{bubbles:true,inputType:'insertText',data:'中文'}));
check(pane,'中文',mode,'IME 229 insertText');
ta.dispatchEvent(new CompositionEvent('compositionstart',{bubbles:true,data:''}));
ta.value='中文';
ta.dispatchEvent(new CompositionEvent('compositionupdate',{bubbles:true,data:'中文'}));
ta.dispatchEvent(new CompositionEvent('compositionend',{bubbles:true,data:'中文'}));
await wait(); check(pane,'中文',mode,'compositionend');
// xterm's composition fallback reaches onData independently of insertText.
ta.dispatchEvent(new CompositionEvent('compositionstart',{bubbles:true,data:''}));
term.input('中国',true); check(pane,'中国',mode,'composition onData fallback');
ta.dispatchEvent(new CompositionEvent('compositionend',{bubbles:true,data:''})); await wait();
pane.writes.length=0; pane.logical.length=0;
for (const data of ['\\x1b[A','\\x03','\\x03\\x04','\\x1b[0;;200u']) {
term.input(data,true); check(pane,data,false,'terminal control');
}
await new Promise(resolve=>term.write('\\x1b[?2004h',resolve));
term.paste('中文'); check(pane,'\\x1b[200~中文\\x1b[201~',false,'bracketed paste');
await new Promise(resolve=>term.write('\\x1b[?2004l',resolve));
dispatchTerminalLinePaste(term,'中文\\r',{lineDelayMs:1,sensitive:false});
equal(pane.writes.splice(0),['中文\\r'],'existing line-paste path stays intact');
pane.logical.length=0;
await new Promise(resolve=>term.write('\\x1b[>25u',resolve));
ta.dispatchEvent(new InputEvent('input',{bubbles:true,inputType:'insertText',data:'中文'}));
const kittyWrites=pane.writes.splice(0);
equal(kittyWrites,['\\x1b[0;;20013:25991u'],'Kitty associated text stays in one transport write');
pane.logical.length=0;
await new Promise(resolve=>term.write('\\x1b[<u',resolve));
host.sshInputCompatibility=!mode;
term.paste('中文'); check(pane,'中文',mode,'save does not change active runtime');
// The app reconnects with the existing runtime, so exercise that reset too.
pane.resetConnectionInputState(host);
term.paste('中文'); check(pane,'中文',!mode,'existing runtime reconnect');
pane.dispose();
const reconnected=make(host);
reconnected.term.paste('中文'); check(reconnected,'中文',!mode,'reconnect reads saved mode');
metrics.push({mode,entryPoints:5,reconnectedMode:!mode});
}
// Exercise the actual wake entry that recreates xterm but only reattaches
// the existing backend. Its context contains the newly saved host record.
for (const mode of [false,true]) {
const connectedHost={id:'hibernate-'+mode,label:'Hibernate',hostname:'127.0.0.1',protocol:'ssh',sshInputCompatibility:mode};
const savedHost={...connectedHost,sshInputCompatibility:!mode};
let pane=make(connectedHost), reattached=0;
const wake = async old => {
old.dispose();
const refs={xtermRuntimeRef:ref(null),termRef:ref(null),fitAddonRef:ref(null),
serializeAddonRef:ref(null),searchAddonRef:ref(null),hasRuntimeRef:ref(false)};
const context={...old.context,host:savedHost};
const success=await wakeTerminalFromHibernate({
refs,runtimeContext:context,container:old.el,
getPayload:()=>({snapshot:'',alternateScreen:false,pendingBuffer:''}),
prepareWakeFlow:async()=>true,takePendingBuffer:()=>'',
stopHibernateDataListener(){},stopHibernateListeners(){},
reattachSession(){reattached++;},safeFit(){},resizeSession(){},forceSyncRenderAfterResize(){},
lastFittedSizeRef:ref(null),isBootActiveRef:ref(false),sessionId:connectedHost.id,updateStatus(){},
});
equal(success,true,'hibernate wake succeeds');
equal(context.sessionRef.current,connectedHost.id,'hibernate retains backend session');
const next={...refs.xtermRuntimeRef.current,context,el:old.el,
writes:old.writes,logical:old.logical,broadcasts:old.broadcasts};
panes.push(next); next.writes.length=0; next.logical.length=0; return next;
};
pane=await wake(pane);
pane.term.paste('中文');check(pane,'中文',mode,'wake retains connection-time mode');
pane.resetConnectionInputState(savedHost);
pane.term.paste('中文');check(pane,'中文',!mode,'real reconnect refreshes mode after wake');
pane=await wake(pane);
pane.term.paste('中文');check(pane,'中文',!mode,'next wake retains refreshed connection mode');
equal(reattached,2,'both wakes reattach the existing backend');
}
// The SSH-only field cannot change other transports' existing policy.
for (const protocol of ['local','telnet','serial','mosh','et','ssh-mosh','ssh-et']) {
const pane=make({id:'other-'+protocol,label:protocol,hostname:'127.0.0.1',
protocol:protocol.startsWith('ssh-')?'ssh':protocol,
moshEnabled:protocol==='ssh-mosh',etEnabled:protocol==='ssh-et',sshInputCompatibility:false});
pane.term.paste('中文'); check(pane,'中文',true,protocol+' retains legacy short paste');
pane.term.input('a\\rb',true); check(pane,'a\\rb',true,protocol+' retains legacy control-containing text');
}
for (const pane of panes) { try {pane.dispose();} catch {} }
return JSON.stringify(metrics);
})()`);
assert.deepEqual(JSON.parse(result), [
{ mode: false, entryPoints: 5, reconnectedMode: true },
{ mode: true, entryPoints: 5, reconnectedMode: false },
]);
} finally {
dom.window.close();
}
});
@@ -42,7 +42,7 @@ test("password-prompt input is classified before prompt state reset and cannot b
);
assert.match(
runtimeSource,
/for \(const chunk of getTextInputWireChunks\(outData, usePerCharacterInput && options\?\.perCharacterWrites === true, \(ctx\.host\.protocol === undefined \|\| ctx\.host\.protocol === "ssh"\) && !ctx\.host\.moshEnabled && !ctx\.host\.etEnabled\)\) \{\s*ctx\.terminalBackend\.writeToSession\(id, chunk, \{\s*sensitive,\s*serialEraseChar,[\s\S]*?\}\);\s*\}/u,
/for \(const chunk of getTextInputWireChunks\(outData, options\?\.perCharacterWrites === true\)\) \{\s*ctx\.terminalBackend\.writeToSession\(id, chunk, \{\s*sensitive,\s*serialEraseChar,[\s\S]*?\}\);\s*\}/u,
);
assert.match(
runtimeSource,
@@ -127,7 +127,6 @@ for (const [protocol, lineMode, sensitive] of [
? { isWrapped: false, translateToString: () => liveLine } : undefined } },
};
const env = {
usePerCharacterInput: true,
...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,
@@ -342,7 +341,6 @@ test("bypassed sensitive line paste keeps the paced fan-out with sourceSensitive
buffer: { active: { cursorX: 0, cursorY: 0, baseY: 0, getLine: () => undefined } },
};
const env = {
usePerCharacterInput: true,
...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,
@@ -66,20 +66,4 @@ test("wire chunking honors the per-character request only for plain text", () =>
assert.deepEqual(getTextInputWireChunks("\x1b[200~ab\x1b[201~", true), [
"\x1b[200~ab\x1b[201~",
]);
});
test("compatibility preserves exact Unicode byte order and keeps terminal controls intact", () => {
for (const text of ["中文", "👩🏽‍💻", "e\u0301", "क्‍ष"]) {
assert.deepEqual(Buffer.from(getTextInputWireChunks(text, true).join("")), Buffer.from(text));
}
for (const data of ["\x03", "\x1b[A", "\x1b[200~中文\x1b[201~", "\x1b[0;;200u", "\x03\x04", "a\r\nb"]) {
assert.deepEqual(getTextInputWireChunks(data, true, true), [data]);
}
});
test("non-SSH callers retain legacy control-containing text chunking", () => {
assert.equal(shouldSplitRawPasteInputForWire("a\rb"), true);
assert.deepEqual(getTextInputWireChunks("a\rb", true), ["a", "\r", "b"]);
assert.deepEqual(getTextInputWireChunks("a\rb", true, true), ["a\rb"]);
});
});
@@ -1,16 +1,13 @@
/**
* Legacy workaround introduced by #3168 after #3077 reported that individual
* hanzi worked where multi-character input did not. The remote parser and
* other terminals' default send behavior were not verified. SSH sessions now
* select this workaround in their host settings.
* Strict bastion prompts (e.g. QAX/奇安信) treat one SSH channel write as a
* single keystroke and silently drop every multi-character chunk (#3077). IME
* commits and short raw pastes therefore have to leave the renderer as
* per-character writes, which is what typing produces and what Xshell does.
*/
/** Raw pastes longer than this keep the single-write behavior. */
export const MAX_RAW_PASTE_PER_CHARACTER_LENGTH = 32;
// Terminal control bytes are intentional: never fragment shortcuts or reports.
// eslint-disable-next-line no-control-regex
const TERMINAL_CONTROL = /[\x00-\x1f\x7f-\x9f]/;
const ESC = "\x1b";
/** True while the payload only carries plain text and no escape sequence. */
@@ -19,7 +16,7 @@ export const isPlainTerminalInputText = (data: string): boolean => !data.include
/** One chunk per Unicode code point so surrogate pairs stay intact. */
export const splitTextIntoCodePointWrites = (data: string): string[] => Array.from(data);
/** IME commits: any plain text with more than one character splits per code point. */
/** IME commits: any plain text with more than one character splits per glyph. */
export const shouldSplitImeTextInputForWire = (text: string): boolean =>
Array.from(text).length > 1 && isPlainTerminalInputText(text);
@@ -38,13 +35,11 @@ export const shouldSplitRawPasteInputForWire = (data: string): boolean => {
return true;
};
/** Escape sequences never split. SSH callers also preserve terminal controls. */
/** Chunks to write for one input payload; escape sequences are never split. */
export const getTextInputWireChunks = (
data: string,
perCharacterWrites: boolean,
preserveTerminalControls = false,
): string[] =>
perCharacterWrites && isPlainTerminalInputText(data)
&& !(preserveTerminalControls && TERMINAL_CONTROL.test(data))
? splitTextIntoCodePointWrites(data)
: [data];
+67
View File
@@ -0,0 +1,67 @@
# Plugin platform implementation review
Baseline: main `0256e7471d66bbbca077c361ef379d7b11ce8465` (2026-10-26).
## Existing capabilities and gaps
| Area | Implemented source | Remaining boundary |
| --- | --- | --- |
| Contract, SDK, package CLI | `packages/plugin-contract/schema`, `packages/plugin-sdk`, `packages/plugin-cli` | Internal API `0.1.0-internal`; no public API stability promise |
| Runtime | `electron/plugins/browserPluginRuntime.cjs`, `utilityPluginRuntime.cjs`, `hostBootstrap.cjs` | Entire host remains development opt-in; advanced utility is ambient Node authority, not a fine-grained sandbox |
| Permissions | `permissionEngine.cjs`, `hostRpcRegistry.cjs`, `secureCapabilities.cjs` | Broker checks at privileged boundary; unsigned principal bound to package digest; verified publisher/distribution not implemented |
| UI contributions | `contributionService.cjs`, `components/plugins`, `SettingsPluginsTab.tsx` | Commands, menus, keybindings, settings and views exist; current Settings page is contributed settings, not a package manager/marketplace |
| Terminal | `terminalProviderService.cjs`, `terminalDataPipelineService.cjs` | Completion/link/visual providers and lifecycle; interceptors require privileged utility placement; not arbitrary terminal object access |
| Connection, authentication, importer | `extensionProviderService.cjs`, `application/state/pluginExtensionBridge.ts` | Existing operation-bound provider integration; not general vault/SFTP/clipboard CRUD |
| Sync | `pluginSyncSidecarService.cjs`, `application/pluginSyncSidecarBridge.ts` | Opt-in non-secret settings and account/CRDT sidecars; ordinary KV is local, package code/permissions/secrets are not cloud authority |
| Compatibility | `plugin-cli/src/compatibility.ts`, `packageStore.cjs`, `runtimeSupervisor.cjs` | Install/start checks existed; startup reconciliation and lazy re-enable checks missing |
| Local package | `packageStore.cjs`, CLI `pack`, first-party `examples/plugins/hello-netcatty` | Immutable snapshot, hash/integrity validation and safe extraction exist; no ordinary import management UI |
| Distribution | No public catalog implementation | Version/hash/pinned README, review workflow, manual update UI still needed |
Historical merged steps: #2276 contract/SDK; #2303 runtime; #2315 permission/security;
#2324 UI contributions; #2338 terminal providers; #2402 privileged terminal pipeline;
#2413 connection/auth/importers; #2713 sync; #2748 and #2752 sync follow-up fixes.
The repository AGENTS.md runtime section describes an earlier phase and must not
be mistaken for a complete current capability inventory. No repository `.agents/skills`
directory was present at this baseline.
## Small implementation slices
1. Reconcile host/API/features compatibility before contribution enumeration;
disable incompatible plugins and retain package/data, record the reason;
block lazy re-enable. Do not auto-enable after a host downgrade.
2. Reuse the existing Settings Plugins tab and management IPC for installed
packages, manifest-derived permissions, version/hash and lifecycle actions;
local import starts disabled for review. Keep native permission mediation.
3. Add a lightweight first-party catalog and pinned safe README detail renderer,
shared package verification, digest-bound manual update flow and author docs.
Do not introduce remote accounts, reviews, payments or a new repository.
4. Reproduce the actual Electron flow with synthetic data and the first-party
sample; transfer source/build instructions if GUI is unavailable here.
## Decisions and explicit release gates
User approved public source plus a clear license for catalog entries, and manual
confirmation of plugin updates. Private local packages remain separate.
Recommended public v1 eligibility: browser runtime only. Utility Node, native
companions and raw terminal interceptors retain the development preview path.
This excludes advanced provider implementations requiring Node/native binaries;
ordinary browser contributions, broker-backed networking and providers remain
possible. This policy needs product confirmation; no existing advanced path is
removed by this review.
Keep `NETCATTY_PLUGIN_DEV=1` until distribution trust, permission UI, compatibility
migration and native acceptance are reviewed. Passing tests is not proof of
security isolation. Do not describe utility processes as broker-constrained.
A future KV sync feature needs an explicit manifest opt-in schema, size limits,
namespace and schema-version migrations, deterministic conflict/deletion rules,
account switching semantics and secret exclusion. It is deliberately not silently
enabled in this iteration. Reuse existing settings sidecars for small preferences.
Catalog entries should bind ID, repository, license, author, version, commit,
artifact SHA-256 and URL, platform/engine ranges, permissions and README revision.
A hash mismatch must fail rather than fall back to latest. README must not execute
HTML or automatically fetch remote media. Directory PR checks are a bounded
review process, not a security certification or a guarantee that maintainers will
repair third-party plugins.
-79
View File
@@ -1,79 +0,0 @@
# SSH input compatibility
The host advanced settings include **输入兼容模式:逐字发送** (input compatibility: send character by character). The help text is **中文输入或短粘贴出现丢字时尝试开启。** Saved changes take effect after reconnecting.
New SSH hosts use standard mode. Compatibility mode retains the interactive text workaround introduced by [PR #3168](https://github.com/binaricat/Netcatty/pull/3168):
- Literal IME commits with multiple Unicode code points use separate writes.
- Plain, unbracketed pastes of at most 32 Unicode code points use separate writes.
- Longer raw pastes, bracketed pastes, Kitty associated text, and the existing confirmed line-paste path retain their current transport behavior.
- Terminal controls stay together. Unicode code points retain their byte order, including combining marks and emoji sequences.
The setting belongs to the terminal endpoint host. A host used only as an SSH network jump does not supply the target's input setting. Other transports retain their existing input behavior. Automated commands, connect scripts, and SFTP do not use this setting.
## Stored records
`Host.sshInputCompatibility` stores the choice as a boolean. `sanitizeHost` migrates an SSH record with a missing field to `true`, including records whose omitted protocol means SSH. It preserves explicit `false` and `true` values on every subsequent load, import, and save. The existing vault normalization and persistence paths save the migrated value. No global migration marker or new storage key is required.
New host creation paths save `false`. Duplicates copy the source value. JSON vault backups and cloud sync retain the field. CSV exports include `SshInputCompatibility`; imports accept `true` and `false`. External imports without that column create standard-mode hosts.
The connection captures the host's setting under its session-owned terminal settings ref. This snapshot survives xterm disposal and the actual hibernate wake/reattach path. Reconnect refreshes the setting through `resetConnectionInputState(host)`, including reconnects that reuse the existing runtime.
## Historical evidence
[Issue #3077](https://github.com/binaricat/Netcatty/issues/3077#issuecomment-5433744875) reports that individual hanzi worked in a Qianxin search prompt where multi-character input failed. The report also says Windows with Xshell worked. Those observations do not establish the remote parser's SSH-write semantics or Xshell's default implementation.
The [Xshell 7 manual](https://cdn.netsarang.net/docs/Xshell7_manual.pdf), section 5.2.21, printed page 27, describes session paste character delay, line delay, and prompt waiting. [SecureCRT's echo flow control tip](https://www.vandyke.com/support/tips/echoflowctrl.html) describes a session line delay and an optional script that waits for each line's echo. These references support configurable send compatibility. They do not establish default per-character IME sends or equivalence to a Qianxin-specific fix. This feature adds no delays or echo waiting.
## Verification boundaries
`sshInputCompatibility.integration.test.ts` runs real xterm and `createXTermRuntime` callbacks under JSDOM. It covers raw paste, literal `insertText`, key code 229, composition events, composition fallback, long IME text, terminal controls, bracketed paste, Kitty text, reconnect with a reused runtime, and the actual `wakeTerminalFromHibernate` entry after a saved setting changes. Local, Telnet, serial, Mosh, and Eternal Terminal cases verify that the SSH field leaves their legacy text and control-containing payload behavior unchanged. Canvas measurement is stubbed. These tests do not exercise native input methods or application windows.
`useVaultState.inputCompatibility.test.ts` exercises the actual vault hook's asynchronous save, reload, duplicate, JSON backup, local backup payload, and cloud payload apply paths. It uses test localStorage and no native safeStorage key. CSV and endpoint selection have separate round-trip and jump-host tests.
## Isolated OpenSSH timing fixture
The implementation-side fixture connected to an owned OpenSSH server on `127.0.0.1:22379` using public-key authentication. It executed `cat` without a PTY because the unprivileged daemon could not allocate one. Production IME/raw-paste selectors and the code-point chunker supplied an `ssh2` client's `channel.write` calls. This fixture did not include native GUI events, renderer IPC, clipboard handling, or an OS input method.
Each sample used 15 consecutive repetitions per mode on the same ready SSH channel. Standard mode ran before compatibility mode for each sample; the modes were not randomized or interleaved. Authentication, command startup, selector evaluation, and chunk-array construction finished before timing. Each repetition started immediately before its first `channel.write` call and ended after the awaited receive-length condition resolved: the client's echo buffer contained at least the expected UTF-8 byte length. The complete received buffer was then compared byte-for-byte with the input, outside the timer. No artificial send delay was added.
The table reports milliseconds. Median is the eighth sorted observation. P95 uses nearest rank, which is the largest of 15 observations here. Every repetition in all six tested sample pairs preserved the exact input bytes.
| Sample | UTF-8 bytes | Client writes, standard / compatibility | Echo median, standard / compatibility | Echo P95, standard / compatibility |
|---|---:|---:|---:|---:|
| `中文输入` (IME selector) | 12 | 1 / 4 | 47.439 / 44.572 | 57.971 / 48.418 |
| `中文👩🏽‍💻é-hostname-123` (raw paste) | 37 | 1 / 21 | 46.628 / 47.957 | 51.753 / 55.012 |
| `中` repeated 32 times (raw paste) | 96 | 1 / 32 | 44.122 / 47.536 | 49.040 / 54.734 |
| `中文abc👩🏽‍💻é` repeated 4,096 times (raw paste) | 110,592 | 1 / 1 | 5.433 / 6.240 | 26.105 / 54.073 |
| Arrow sequence `ESC [ A` | 3 | 1 / 1 | 44.320 / 47.657 | 55.726 / 52.414 |
| Bracketed paste containing `中文` | 18 | 1 / 1 | 44.151 / 44.995 | 48.959 / 55.769 |
These are local end-to-end SSH echo completion times for this fixture. They are not native app paste latency, remote processing time, a throughput benchmark, or evidence that the previous behavior caused lag. Client write-call counts do not establish SSH packet boundaries, server read boundaries, or SSH channel-data block counts.
## Native desktop results
The final native report tested production code commit `bd5f27ff42d29b18c0e9cb3513415da3243dd636` against two owned loopback SSH endpoints in an isolated profile. The following checks passed:
- New hosts defaulted to standard mode. Duplicate, save, and application restart preserved each host's choice.
- Saving during a connection did not change its input mode. A real reconnect adopted the saved mode in both directions.
- Full hibernation used a 5-second hidden-tab delay and zero retained renderers, with xterm absence confirmed. Wake/reattach retained the old connection choice in both directions; a subsequent reconnect adopted the saved choice.
- A missing-field legacy record was seeded into the isolated host store. Real application startup migrated it to `true`. After the user disabled compatibility, saved, and restarted, the value remained `false`.
- The Chinese setting label, help text, and reconnect notice matched the required text and were captured in screenshots. Arrow and Ctrl+C bytes remained intact.
| Native sample | Received-content result | Observed SSH channel-data blocks, standard / compatibility |
|---|---|---|
| `中文测试🙂é输入兼容模式` — 37 UTF-8 bytes, 13 code points | Hashes matched between modes | 1 / 13 |
| Long sample — 131,072 bytes | Hashes matched between modes | Four 32,768-byte frames / four 32,768-byte frames |
| Negotiated bracketed paste — 49 bytes | Preserved | 1 / 1 |
The native short sample differs from the implementation fixture's 37-byte sample above. Its 1 / 13 SSH channel-data block observation is not the implementation fixture's 1 / 21 client write-call count. The native 131,072-byte long sample is also distinct from the 110,592-byte timing sample.
The native timing observation was one PTY first-to-last receive span per mode: standard **3.408724 ms**, compatibility **1.600044 ms**. It starts at the first received PTY payload and ends at the last received payload. It excludes the input-to-first-receive interval and is not total GUI paste latency. One observation per mode does not provide a median, P95, or causal performance comparison. These spans cannot be substituted for the 15-repetition client echo timings above. Neither run establishes throughput superiority or that the previous workaround caused lag.
Actual OS IME input and Qianxin hardware were not tested. Native networked cloud sync, import, and jump-chain behavior were also not tested; those data flows have automated coverage rather than claimed native validation. Synthetic DOM composition events, native GUI/paste coverage, client write calls, SSH data blocks, and receive timing remain separate evidence categories.
Independent review confirmed the three reported P2 findings resolved, with no new blocking findings. The native application, SSH and clipboard fixture processes, isolated profile, and test keys were cleaned up; the source tree was clean after that run.
## CI result
The `test` workflow completed successfully for documentation commit `38c5f4744cb5fc3ecdd4e624b323587a471e4333`: [run 40319492139](https://github.com/binaricat/Netcatty/actions/runs/40319492139). That workflow includes `npm run lint`, `npm test`, generated-spec checks, and `npm run build`. The production code is unchanged from the native-tested `bd5f27ff` commit. Subsequent acceptance-text edits change documentation only. The PR remains draft and unmerged.
-12
View File
@@ -793,15 +793,3 @@ test("sanitizeHost preserves named-pipe spaces and restores the authoritative se
assert.equal(sanitizeHost(host).serialConfig?.path, path);
assert.equal(sanitizeHost(makeHost({ hostname: 'example.com extra' })).hostname, 'example.com');
});
test("SSH input compatibility migrates legacy hosts once and preserves later choices", () => {
const legacy = { id: "legacy", label: "Legacy", hostname: "localhost", username: "user", tags: [], os: "linux" } as Host;
for (const protocol of [undefined, "ssh"] as const) {
const migrated = sanitizeHost({ ...legacy, protocol });
assert.equal(migrated.sshInputCompatibility, true);
assert.equal(sanitizeHost(migrated).sshInputCompatibility, true);
assert.equal(sanitizeHost({ ...migrated, sshInputCompatibility: false }).sshInputCompatibility, false);
}
assert.equal(sanitizeHost({ ...legacy, protocol: "telnet" }).sshInputCompatibility, undefined);
});
-5
View File
@@ -466,11 +466,6 @@ export const sanitizeHost = (host: Host, snippets: Snippet[] = []): Host => {
? undefined
: migrated.authMethod ?? inferredLegacyAuthMethod,
authPolicyVersion: 1,
// Legacy SSH records used per-character IME/short-paste writes globally.
// Persist the choice on normalization; explicit false survives every reload.
sshInputCompatibility: host.protocol === undefined || host.protocol === 'ssh'
? host.sshInputCompatibility ?? true
: host.sshInputCompatibility,
hostname: cleanHostname,
osOverride: osSelection,
os: osSelection === 'linux' || osSelection === 'windows' || osSelection === 'macos' ? osSelection : host.os,
-3
View File
@@ -189,9 +189,6 @@ export interface Host {
identityFileId?: string; // Reference to SSHKey
protocol?: HostProtocol; // Default/primary protocol, including namespaced plugin protocols
pluginConnection?: PluginConnectionConfig;
// SSH interactive IME/short-paste compatibility; applied on reconnect.
// Missing on legacy records; sanitizeHost preserves their previous behavior.
sshInputCompatibility?: boolean;
// Runtime marker for in-memory-only hosts (e.g. password deep links).
// Ephemeral hosts are never persisted to the vault or session restore.
ephemeral?: boolean;
-26
View File
@@ -820,29 +820,3 @@ test('plugin importer preview is bounded and never exposes secret or command pay
assert.equal(JSON.stringify(preview).includes('private-key-material'), false);
assert.equal(JSON.stringify(preview).includes('contains-sensitive-command'), false);
});
test('new imported SSH drafts default to standard input and preserve explicit boolean choices', () => {
for (const protocol of [undefined, 'ssh'] as const) {
for (const choice of [undefined, false, true]) {
const result = normalizePluginImporterRecords([{ type: 'draft', draft: {
kind: 'host', value: {
hostname: 'new.test', ...(protocol ? { protocol } : {}),
...(choice === undefined ? {} : { sshInputCompatibility: choice }),
},
} }]);
assert.deepEqual(result.errors, []);
assert.equal(result.hosts[0].sshInputCompatibility, choice ?? false);
}
}
});
test('imported host drafts reject non-boolean input compatibility choices', () => {
for (const choice of ['false', 'true', 0, null]) {
const result = normalizePluginImporterRecords([{ type: 'draft', draft: {
kind: 'host', value: { hostname: 'new.test', protocol: 'ssh', sshInputCompatibility: choice },
} }]);
assert.deepEqual(result.hosts, []);
assert.deepEqual(result.errors, ['Importer returned an invalid host draft.']);
}
});
-2
View File
@@ -204,7 +204,6 @@ const normalizeHost = (value: JsonValue): Host | null => {
};
if (Object.values(optionalStrings).some((item) => item === INVALID_FIELD)) return null;
const optionalBooleans = {
sshInputCompatibility: optionalBooleanValue(object, 'sshInputCompatibility'),
moshEnabled: optionalBooleanValue(object, 'moshEnabled'),
etEnabled: optionalBooleanValue(object, 'etEnabled'),
telnetEnabled: optionalBooleanValue(object, 'telnetEnabled'),
@@ -243,7 +242,6 @@ const normalizeHost = (value: JsonValue): Host | null => {
createdAt: Date.now(),
ephemeral: false,
managedSourceId: undefined,
sshInputCompatibility: false,
...(protocol ? { protocol } : {}),
...(pluginConnection ? { pluginConnection: structuredClone(pluginConnection) } : {}),
...(deviceType ? { deviceType } : {}),
-1
View File
@@ -68,7 +68,6 @@ export const buildQuickConnectHost = ({
os: "linux",
protocol: protocol === "mosh" || protocol === "et" ? "ssh" : protocol,
authMethod,
sshInputCompatibility: false,
identityId: applicableIdentityId,
password: !applicableIdentityId && authMethod === "password" ? password : undefined,
identityFileId:
-4
View File
@@ -16,9 +16,6 @@
// CloudLinker — issue #3592: the vendor identifies a single-channel gateway
// and proposes this distinctive banner. Older "Go" banners cannot identify it
// safely; this compatibility entry requires the vendor banner change.
// russh — issue #3973: russh returns CHANNEL_FAILURE for wantReply=false env
// requests, desynchronizing ssh2's callback FIFO so pty-req/shell appear to
// fail. Skip env via the single-channel path. No chunked-write evidence.
// JumpServer KoKo advertises Version "JumpServer" and multiplexes. Omit it.
const SINGLE_CHANNEL_SOFTWARE_TOKENS = Object.freeze([
@@ -26,7 +23,6 @@ const SINGLE_CHANNEL_SOFTWARE_TOKENS = Object.freeze([
"TERM-SSHD",
"CLOUDBILITY",
"CLOUDLINKER",
"russh",
]);
// #3146 also drops multi-character writes. That evidence is only for these
-4
View File
@@ -16,9 +16,6 @@
// CloudLinker — issue #3592: the vendor identifies a single-channel gateway
// and proposes this distinctive banner. Older "Go" banners cannot identify it
// safely; this compatibility entry requires the vendor banner change.
// russh — issue #3973: russh returns CHANNEL_FAILURE for wantReply=false env
// requests, desynchronizing ssh2's callback FIFO so pty-req/shell appear to
// fail. Skip env via the single-channel path. No chunked-write evidence.
// JumpServer KoKo advertises Version "JumpServer" and multiplexes. Omit it.
const SINGLE_CHANNEL_SOFTWARE_TOKENS = Object.freeze([
@@ -26,7 +23,6 @@ const SINGLE_CHANNEL_SOFTWARE_TOKENS = Object.freeze([
"TERM-SSHD",
"CLOUDBILITY",
"CLOUDLINKER",
"russh",
]);
// #3146 also drops multi-character writes. That evidence is only for these
+1 -13
View File
@@ -20,10 +20,6 @@ test("single-channel banners match only the supported software tokens", () => {
"CloudLinker",
"SSH-2.0-CloudLinker",
"cloudlinker-1.0",
"russh",
"SSH-2.0-russh",
"SSH-2.0-russh_0.45.0",
"RuSSH",
]) {
assert.equal(remoteSoftwareRequiresSingleChannel(version), true, version);
}
@@ -53,12 +49,6 @@ test("single-channel banner match ignores OpenSSH, JumpServer, and lookalike tok
"SSH-2.0-Go",
"notcloudlinker",
"CloudLinkerProxy",
"russhd",
"myrussh",
"prerussh",
"russhserver",
"SSH-2.0-russhd",
"notRussh",
]) {
assert.equal(remoteSoftwareRequiresSingleChannel(version), false, String(version));
}
@@ -69,8 +59,6 @@ test("chunked-write limit stays on BHostSSH and TERM-SSHD", () => {
assert.equal(remoteDisallowsChunkedChannelWrite("SSH-2.0-TERM-SSHD"), true);
assert.equal(remoteDisallowsChunkedChannelWrite("CLOUDBILITY-4.14"), false);
assert.equal(remoteDisallowsChunkedChannelWrite("SSH-2.0-CloudLinker"), false);
assert.equal(remoteDisallowsChunkedChannelWrite("SSH-2.0-russh"), false);
assert.equal(remoteDisallowsChunkedChannelWrite("SSH-2.0-russh_0.45.0"), false);
assert.equal(remoteDisallowsChunkedChannelWrite("OpenSSH_9.6"), false);
assert.equal(remoteDisallowsChunkedChannelWrite("superterm-sshd"), false);
});
@@ -94,7 +82,7 @@ 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", "CloudLinker", "SSH-2.0-russh_0.45.0", "SSH-2.0-Go", "OpenSSH_9.6", "JumpServer", "",
"BHostSSH_7.0", "TERM-SSHD", "CLOUDBILITY-4.14", "CloudLinker", "SSH-2.0-Go", "OpenSSH_9.6", "JumpServer", "",
]) {
assert.equal(browserModule.remoteSoftwareRequiresSingleChannel(version), nodeModule.remoteSoftwareRequiresSingleChannel(version));
assert.equal(browserModule.remoteDisallowsChunkedChannelWrite(version), nodeModule.remoteDisallowsChunkedChannelWrite(version));
-13
View File
@@ -2,7 +2,6 @@ import test from "node:test";
import assert from "node:assert/strict";
import type { Host } from "./models";
import { resolveHostAutofillPassword } from "./sshAuth";
import { sanitizeHost } from "./host";
import {
buildSshDeepLinkConnectionHost,
buildSshDeepLinkEphemeralHost,
@@ -243,10 +242,8 @@ test("buildSshDeepLinkHostDraft prepares an editable new ssh host", () => {
tags: [],
os: "linux",
protocol: "ssh",
sshInputCompatibility: false,
createdAt: 123,
});
assert.equal(sanitizeHost(draft).sshInputCompatibility, false);
});
test("buildSshDeepLinkOpenHost falls back to a draft host when no saved host matches", () => {
@@ -334,13 +331,3 @@ test("buildSshNoteLinkOpenHost ignores unrelated external links", () => {
assert.equal(openHost, null);
});
test("deep links derived from saved hosts preserve explicit input choices", () => {
const target = parseSshDeepLink("ssh://alice:secret@example.com")!;
for (const sshInputCompatibility of [false, true]) {
const saved = host({ sshInputCompatibility });
assert.equal(buildSshDeepLinkConnectionHost(saved).sshInputCompatibility, sshInputCompatibility);
assert.equal(buildSshDeepLinkEphemeralHostFromSaved(saved, target, { id: "copy", now: 1 }).sshInputCompatibility, sshInputCompatibility);
}
});
-1
View File
@@ -165,7 +165,6 @@ export const buildSshDeepLinkHostDraft = (
tags: [],
os: "linux",
protocol: "ssh",
sshInputCompatibility: false,
createdAt: options.now,
});
-13
View File
@@ -675,16 +675,3 @@ test("resolveTerminalChainHosts skips non-SSH jump candidates", () => {
assert.deepEqual(resolved.map((host) => host.id), ["jump-ssh", "jump-mosh"]);
});
test("input compatibility belongs to the terminal endpoint, independently of SSH jump hops", () => {
const target: Host = { id: "target", label: "Target", hostname: "target.example.test", username: "alice",
protocol: "ssh", tags: [], os: "linux", sshInputCompatibility: false, hostChain: { hostIds: ["bastion"] } };
const bastion: Host = { ...target, id: "bastion", hostChain: undefined, sshInputCompatibility: true };
const resolve = (hostId: string) => resolveTerminalSessionHost({
session: { ...baseSession, hostId }, hosts: [target, bastion], groupConfigs: [], proxyProfiles: [], localOs: "linux",
});
assert.equal(resolve("target").sshInputCompatibility, false);
assert.equal(resolve("bastion").sshInputCompatibility, true);
assert.equal(resolveTerminalChainHosts({ host: target, hosts: [target, bastion], groupConfigs: [], proxyProfiles: [] })[0].sshInputCompatibility, true);
});
-7
View File
@@ -1956,10 +1956,3 @@ test('applyVaultHostDelete keeps jump hosts referenced by empty group defaults',
assert.equal(result.ok, false);
if (!result.ok) assert.match(result.error, /still used as a group jump host/i);
});
test('new SSH hosts explicitly use standard interactive input', () => {
const created = buildVaultHostFromDraft({ hostname: 'localhost', username: 'user' });
assert.equal(created.ok, true);
if (created.ok) assert.equal(created.host.sshInputCompatibility, false);
});
-1
View File
@@ -329,7 +329,6 @@ export function buildVaultHostFromDraft(
os,
osOverride: parsedOs ?? 'auto',
protocol,
sshInputCompatibility: false,
createdAt: now,
...(keyPath
? {
-5
View File
@@ -665,7 +665,6 @@ const importFromCsv = (
const proxyIdx = findExactHeaderIndex(header, ["proxy", "proxyserver", "proxy server"]);
const proxyJumpIdx = findExactHeaderIndex(header, ["proxyjump"]);
const localShellIdx = findExactHeaderIndex(header, ["localshell"]);
const inputCompatibilityIdx = findExactHeaderIndex(header, ["sshinputcompatibility"]);
const passphraseIdx = keyPathIdx >= 0 ? explicitPassphraseIdx : -1;
const exactPasswordIdx = findExactHeaderIndex(header, ["password", "pass", "passwd"]);
const fuzzyNamedPasswordIdx = findHeaderIndex(header, ["password", "passwd"]);
@@ -796,10 +795,6 @@ const importFromCsv = (
tags,
notes,
});
if (inputCompatibilityIdx >= 0 && (protocol === undefined || protocol === "ssh")) {
const choice = row[inputCompatibilityIdx]?.trim().toLowerCase();
if (choice === "true" || choice === "false") host.sshInputCompatibility = choice === "true";
}
if (proxyConfig) host.proxyConfig = proxyConfig;
if (directProxy) host.directProxy = true;
if (localShellSpec && protocol === "local") {
-13
View File
@@ -842,16 +842,3 @@ test("CSV import warns and ignores a malformed LocalShell value", () => {
assert.equal(imported.hosts[0]?.localShell, undefined);
assert.ok(imported.issues.some((issue) => issue.message.includes("row 2") && issue.message.includes("LocalShell")));
});
test("CSV export/import preserves independent SSH input compatibility choices", () => {
const hosts: Host[] = [false, true].map((choice, index) => ({
...hostDefaults, id: `mode-${index}`, label: `Mode ${index}`,
hostname: `mode-${index}.example.test`, protocol: "ssh", sshInputCompatibility: choice,
}));
const { csv } = exportHostsToCsvWithStats(hosts);
const imported = importVaultHostsFromText("csv", csv);
assert.deepEqual(imported.hosts.map(host => host.sshInputCompatibility), [false, true]);
const second = importVaultHostsFromText("csv", exportHostsToCsvWithStats(imported.hosts).csv);
assert.deepEqual(second.hosts.map(host => host.sshInputCompatibility), [false, true]);
});
+2 -3
View File
@@ -37,7 +37,7 @@ export const getVaultCsvTemplate = (
opts: VaultCsvTemplateOptions = {},
): string => {
const includeExampleRows = opts.includeExampleRows !== false;
const header = ["Groups", "Label", "Tags", "Notes", "Hostname/IP", "Protocol", "Port", "Username", "Password", "KeyPath", "Passphrase", "Proxy", "ProxyJump", "LocalShell", "SshInputCompatibility"];
const header = ["Groups", "Label", "Tags", "Notes", "Hostname/IP", "Protocol", "Port", "Username", "Password", "KeyPath", "Passphrase", "Proxy", "ProxyJump", "LocalShell"];
const rows: string[][] = [header];
if (includeExampleRows) {
rows.push(["Project/Dev", "Web Server (dev)", "dev,web", "Dev web tier", "192.168.1.10", "ssh", "22", "root", "", "~/.ssh/id_ed25519", "", "socks5://127.0.0.1:1080"]);
@@ -115,7 +115,7 @@ const formatCsvProxyJump = (host: Host, allHosts: readonly Host[]): string => {
};
const exportHostsToCsv = (hosts: Host[], options: VaultCsvExportOptions) => {
const header = ["Groups", "Label", "Tags", "Notes", "Hostname/IP", "Protocol", "Port", "Username", "Password", "KeyPath", "Passphrase", "Proxy", "ProxyJump", "LocalShell", "SshInputCompatibility"];
const header = ["Groups", "Label", "Tags", "Notes", "Hostname/IP", "Protocol", "Port", "Username", "Password", "KeyPath", "Passphrase", "Proxy", "ProxyJump", "LocalShell"];
const rows: string[][] = [header];
let unreadableProxyCredentialCount = 0;
@@ -217,7 +217,6 @@ const exportHostsToCsv = (hosts: Host[], options: VaultCsvExportOptions) => {
encodeCsvProxy(proxyValue),
encodeCsvProxyJump(proxyJumpValue),
localShellSpec ? encodeCsvLocalShell(localShellSpec) : "",
host.protocol === undefined || host.protocol === "ssh" ? String(host.sshInputCompatibility ?? true) : "",
]);
}
+2 -13
View File
@@ -68,8 +68,7 @@ const PROMPT_SUFFIX_WINDOW_PER_PROMPT_CHAR = 8;
function hasExpectedPromptSuffix(text, expectedPrompt) {
if (!expectedPrompt) return false;
const ansiFreePrompt = stripAnsi(String(expectedPrompt || ""));
const normalizedPrompt = ansiFreePrompt.replace(/\r/g, "");
const normalizedPrompt = stripAnsi(String(expectedPrompt || "")).replace(/\r/g, "");
if (!normalizedPrompt) return false;
const fullText = String(text || "");
const windowChars = Math.max(
@@ -77,17 +76,7 @@ function hasExpectedPromptSuffix(text, expectedPrompt) {
String(expectedPrompt).length * PROMPT_SUFFIX_WINDOW_PER_PROMPT_CHAR,
);
const tail = fullText.length > windowChars ? fullText.slice(-windowChars) : fullText;
const strippedTail = stripAnsi(tail);
// Drop CR so a redraw inside one line (`root@host:~\r# `) still matches
// the cached prompt. That collapses a two-line PS1's `\r\r\n` to a single
// LF, which no longer ends with the cache (`user@host: /root\n\n\n$ `).
if (strippedTail.replace(/\r/g, "").endsWith(normalizedPrompt)) return true;
// The idle-prompt cache turns those CRs into LFs and keeps the blank
// lines. Compare that form too, only when the cache itself spans lines,
// so a bare `$`/`#` is unchanged.
if (!ansiFreePrompt.includes("\n")) return false;
const crAsLfPrompt = ansiFreePrompt.replace(/\r/g, "\n");
return strippedTail.replace(/\r/g, "\n").endsWith(crAsLfPrompt);
return stripAnsi(tail).replace(/\r/g, "").endsWith(normalizedPrompt);
}
function escapePosixSingleQuoted(text) {
@@ -3,7 +3,7 @@
const test = require("node:test");
const assert = require("node:assert/strict");
const { hasExpectedPromptSuffix } = require("./ptyExecHelpers.cjs");
const { extractTrailingIdlePrompt, stripAnsi } = require("./shellUtils.cjs");
const { stripAnsi } = require("./shellUtils.cjs");
// The behaviour before the tail window was introduced: normalise the whole
// text, then compare its end.
@@ -41,38 +41,6 @@ test("prompt suffix is found after a very large flood of CRLF output", () => {
assert.equal(hasExpectedPromptSuffix(`${flood}${COLORED_PROMPT}`, "root@3160T:~# "), true);
});
test("live PTY CR bytes match a cached two-line idle prompt", () => {
// Bash writes the PS1 newline as CR and ONLCR adds another, so the bytes
// are `\r\r\n`. The idle-prompt cache keeps those as blank lines. Suffix
// matching has to accept that form; stripping CR down to one LF does not.
const live = "user@host: /workspace\r\r\n$ ";
const cached = extractTrailingIdlePrompt(`welcome\r\n${live}`);
assert.equal(cached, "user@host: /workspace\n\n\n$ ");
const matchLive = hasExpectedPromptSuffix(live, cached);
assert.equal(matchLive, true);
assert.equal(hasExpectedPromptSuffix(`hostname\r\nbox\r\n${live}`, cached), true);
assert.equal(
hasExpectedPromptSuffix("\x1b[?2004huser@host: /workspace\r\r\n$ ", cached),
true,
);
const rootLive = "user@host: /root\r\r\n# ";
const rootCached = extractTrailingIdlePrompt(rootLive);
assert.equal(rootCached, "user@host: /root\n\n\n# ");
assert.equal(hasExpectedPromptSuffix(rootLive, rootCached), true);
// Readiness for a real LF two-line prompt still matches, and a bare
// `$`/`#` is neither this prompt nor a POSIX prompt on its own.
const lfCached = extractTrailingIdlePrompt("welcome\r\nuser@host: /workspace\n$ ");
assert.equal(lfCached, "user@host: /workspace\n$ ");
assert.equal(hasExpectedPromptSuffix("user@host: /workspace\n$ ", lfCached), true);
assert.equal(hasExpectedPromptSuffix("output\r\n$ ", cached), false);
assert.equal(hasExpectedPromptSuffix("$ ", cached), false);
assert.equal(hasExpectedPromptSuffix("# ", rootCached), false);
assert.equal(extractTrailingIdlePrompt("$ "), "");
assert.equal(extractTrailingIdlePrompt("# "), "");
});
test("prompt suffix check does not scan the whole buffer on every call", () => {
// 2M characters of CRLF output used to cost seconds per call because every
// call stripped ANSI and carriage returns over the entire text.
+3 -35
View File
@@ -69,11 +69,6 @@ const CMD_PROMPT_PATTERN = /^[A-Za-z]:(?:\\[^<>"|]*)?>$/;
// the final `]` before `$`/`#` is the delimiter.
const POSIX_PROMPT_PATTERN = /^(?:[^\s@]+@[^\s:]+(?::[^\n\r]*)?|\[[^\s@\]]+@[^\s\]:]+(?:\s+[^\n\r]+)?\])[#$]$/;
// Host line of a two-line bash PS1 (`\u@\h: \w` then a following `$` or `#`).
// Same user@host[:path] class as POSIX_PROMPT_PATTERN's first alternative,
// without a prompt marker on this line. A bare `$`/`#` alone is not a prompt.
const POSIX_PROMPT_HOST_LINE_PATTERN = /^[^\s@]+@[^\s:]+(?::[^\n\r]*)?$/;
// Alpine's default ash PS1 omits the username: `\h:\w\$ ` -> `localhost:~# `.
// Require a hostname and a home/absolute path, excluding prompt delimiters
// within the path so partially entered commands do not count as idle prompts.
@@ -89,14 +84,7 @@ function isDefaultCmdPromptLine(line) {
function isDefaultPosixPromptLine(line) {
const normalized = String(line || "").replace(/\s+$/, "");
// Optional virtualenv / conda / BT-panel style env name prefix: "(name) "
// before the shell prompt. Recognition only; callers still return the full
// prompt line so suffix matching sees the visible prefix (#3947).
const withoutEnvPrefix = normalized.replace(/^\([^()\n\r]+\)\s+/, "");
return (
POSIX_PROMPT_PATTERN.test(withoutEnvPrefix) ||
ALPINE_PROMPT_PATTERN.test(withoutEnvPrefix)
);
return POSIX_PROMPT_PATTERN.test(normalized) || ALPINE_PROMPT_PATTERN.test(normalized);
}
function extractTrailingIdlePrompt(output) {
@@ -107,8 +95,7 @@ function extractTrailingIdlePrompt(output) {
const normalized = stripAnsi(output).replace(/\r/g, "\n");
if (!normalized || normalized.endsWith("\n")) return "";
const lines = normalized.split("\n");
const lastLine = lines.pop() || "";
const lastLine = normalized.split("\n").pop() || "";
const rightTrimmed = lastLine.replace(/\s+$/, "");
if (!rightTrimmed) return "";
@@ -124,24 +111,6 @@ function extractTrailingIdlePrompt(output) {
return lastLine;
}
// Two-line PS1 (`export PS1="\u@\h: \w\n$ "`). Bash emits the newline as
// CR and the PTY's ONLCR adds another, so the bytes are `\r\r\n`. The
// CR-to-LF normalize above turns that into blank lines between the host
// line and `$`/`#`. Keep those blanks in the cache: getFreshIdlePrompt
// uses the same normalize, and hasExpectedPromptSuffix compares this
// CR-as-LF form against live PTY bytes. A lone `$` must not satisfy endsWith.
if (rightTrimmed === "$" || rightTrimmed === "#") {
let hostIndex = lines.length - 1;
while (hostIndex >= 0 && lines[hostIndex].replace(/\s+$/, "") === "") {
hostIndex -= 1;
}
const previousLine = hostIndex >= 0 ? lines[hostIndex] : "";
const previousRightTrimmed = previousLine.replace(/\s+$/, "");
if (POSIX_PROMPT_HOST_LINE_PATTERN.test(previousRightTrimmed)) {
return `${lines.slice(hostIndex).join("\n")}\n${lastLine}`;
}
}
return "";
}
@@ -202,8 +171,7 @@ function trackSessionIdlePrompt(session, chunk) {
// Return `session.lastIdlePrompt` only if the PTY's recent rolling tail
// still ends with it. The cached prompt is updated only when
// extractTrailingIdlePrompt recognizes a known shape (PowerShell, cmd.exe,
// `user@host[:path][#$]`, two-line `user@host[:path]` + `$`/`#`,
// RHEL `[user@host path][#$]`, or Alpine `host:path[#$]`);
// `user@host[:path][#$]`, RHEL `[user@host path][#$]`, or Alpine `host:path[#$]`);
// a remote shell switch into another shell, an oh-my-posh / starship / custom
// PS1, or any unrecognized prompt would
// otherwise leave a stale value behind, which `resolveEffectiveShellKind`
-91
View File
@@ -219,97 +219,6 @@ test("extracts a trailing RHEL-family bracketed idle prompt", () => {
assert.equal(extractTrailingIdlePrompt("welcome\r\nroot@host ~#"), "");
});
test("extractTrailingIdlePrompt recognizes a two-line user@host then bare $/# PS1", () => {
// export PS1="\u@\h: \w\n$ " paints the host line, then a lone "$ ".
// The cached prompt must keep both lines so a lone "$" cannot satisfy endsWith.
assert.equal(
extractTrailingIdlePrompt("welcome\r\nuser@host: /home/foo\n$ "),
"user@host: /home/foo\n$ ",
);
assert.equal(
extractTrailingIdlePrompt("welcome\r\nroot@host: /root\n# "),
"root@host: /root\n# ",
);
// Live bash PTY: prompt CR plus ONLCR is `\r\r\n`, which this normalizer
// stores as blank lines. The cache must keep them so endsWith still matches.
assert.equal(
extractTrailingIdlePrompt("\x1b[?2004huser@host: /home/foo\r\r\n$ "),
"user@host: /home/foo\n\n\n$ ",
);
assert.equal(
extractTrailingIdlePrompt("\x1b[?2004hroot@host: /root\r\r\n# "),
"root@host: /root\n\n\n# ",
);
assert.equal(extractTrailingIdlePrompt("$ "), "");
assert.equal(extractTrailingIdlePrompt("# "), "");
assert.equal(extractTrailingIdlePrompt("welcome\r\n$ "), "");
assert.equal(extractTrailingIdlePrompt("not a host line\n$ "), "");
assert.equal(extractTrailingIdlePrompt("copied output\n# "), "");
assert.equal(isDefaultPosixPromptLine("$"), false);
assert.equal(isDefaultPosixPromptLine("#"), false);
assert.equal(isDefaultPosixPromptLine("$ "), false);
assert.equal(isDefaultPosixPromptLine("# "), false);
});
test("trackSessionIdlePrompt and getFreshIdlePrompt accept a two-line user@host PS1", () => {
const session = {};
const prompt = "user@host: /home/foo\n$ ";
trackSessionIdlePrompt(session, `welcome\r\n${prompt}`);
assert.equal(getFreshIdlePrompt(session), prompt);
const ptySession = {};
trackSessionIdlePrompt(ptySession, "\x1b[?2004huser@host: /home/foo\r\r\n$ ");
assert.equal(getFreshIdlePrompt(ptySession), "user@host: /home/foo\n\n\n$ ");
});
test("extractTrailingIdlePrompt still recognizes a classic single-line POSIX prompt", () => {
assert.equal(extractTrailingIdlePrompt("welcome\r\nuser@host:~$ "), "user@host:~$ ");
});
test("isDefaultPosixPromptLine accepts virtualenv, conda, and BT-panel env prefixes", () => {
// Python venv / conda / BT-panel prepend "(name) " before the real prompt.
// Recognition strips that prefix; a missing space after ")" is already
// accepted because the username class absorbs the parens (#3947).
for (const prompt of [
"(3.12.12) root@example:~# ",
"(3.12.12) root@example:~#",
"(venv) alice@host:~$ ",
"(base) [opc@host ~]$ ",
"(my-env) localhost:~# ",
"(3.12.12)root@example:~# ",
]) {
assert.equal(isDefaultPosixPromptLine(prompt), true, prompt);
}
});
test("extractTrailingIdlePrompt keeps the full env-prefixed POSIX prompt", () => {
// Suffix matching compares the visible PTY line, so the captured prompt
// must still include the "(name) " prefix rather than the stripped form.
for (const prompt of [
"(3.12.12) root@example:~# ",
"(3.12.12) root@example:~#",
"(venv) alice@host:~$ ",
"(base) [opc@host ~]$ ",
"(my-env) localhost:~# ",
]) {
assert.equal(extractTrailingIdlePrompt(`Last login\r\n${prompt}`), prompt, prompt);
}
});
test("env-prefixed POSIX recognition rejects incomplete prefixes and non-prompts", () => {
for (const line of [
"(3.12.12 root@example:~# ",
"(3.12.12) root@example:~# printf busy",
"(venv) not a prompt",
"root@host ~#",
"(base) [opc@host ~]$ printf busy",
"(my-env) localhost:~# printf busy",
]) {
assert.equal(isDefaultPosixPromptLine(line), false, line);
assert.equal(extractTrailingIdlePrompt(`welcome\r\n${line}`), "", line);
}
});
test("isPlausibleCliVersionOutput rejects stack traces and file URLs", () => {
assert.equal(isPlausibleCliVersionOutput("2.1.123 (Claude Code)"), true);
assert.equal(isPlausibleCliVersionOutput("codex-cli 0.125.0"), true);
@@ -836,105 +836,6 @@ test("sudo SFTP-page transfer reuses the elevated channel instead of dialing aga
assert.equal(sftpClients.has("browse-sudo"), true);
});
for (const [ident, sequential] of [
["OpenSSH_9.6", false],
["CLOUDBILITY-4.14", true],
["TERM-SSHD", true],
]) {
test(`sudo transfer aliases preserve ${ident} upload concurrency`, { timeout: 15000 }, async (t) => {
const server = await startOtpOnlyServer({ ident });
pool.resetSshTransportRegistryForTests({ defaultIdleTtlMs: 0 });
const sftpClients = new Map();
sftpBridge.init({ sftpClients, sessions: new Map(), electronModule: {} });
transferBridge.init({ sftpClients });
t.after(async () => {
for (const sftpId of [...sftpClients.keys()].reverse()) {
await sftpBridge.closeSftp(null, { sftpId }).catch(() => {});
}
pool.discardAllTransports("test-end");
pool.resetSshTransportRegistryForTests({ defaultIdleTtlMs: 0 });
await server.close();
});
const options = { ...BASE_OPTIONS(server.port), reuseTransport: false };
const sender = createSender();
const opening = sftpBridge.openSftp({ sender }, { ...options, sessionId: "sudo-owner" });
opening.catch(() => {});
await waitFor(() => answerOtpPrompts(sender));
const opened = await opening;
const owner = sftpClients.get(opened.sftpId);
// Model an already-elevated channel. The real SSH/SFTP peer remains the
// ordinary server or bastion above; no second sudo/authentication is needed.
const sudoOptions = { ...BASE_OPTIONS(server.port), sudo: true };
const endpoint = pool.buildConnectionReuseEndpoint(sudoOptions, { sftpSudo: true });
owner.__netcattySudoMode = true;
owner.__netcattyEndpointKey = pool.buildEndpointKey(endpoint);
owner.__netcattyChannelAdoptionKey = pool.buildChannelAdoptionKey(endpoint);
const firstAlias = await sftpBridge.openSftp({ sender: createSender() }, sudoOptions);
const secondAlias = await sftpBridge.openSftp({ sender: createSender() }, sudoOptions);
for (const alias of [firstAlias, secondAlias]) {
const client = sftpClients.get(alias.sftpId);
assert.equal(client.__netcattySharedChannelAlias, true);
assert.equal(client.__netcattySudoMode, true);
assert.equal(client.sftp, owner.sftp, "both uploads must retain the elevated channel");
}
const root = fs.mkdtempSync(`${tempDirBridge.getTempFilePath("sudo-concurrency")}-`);
t.after(() => fs.rmSync(root, { recursive: true, force: true }));
const source = path.join(root, "source.bin");
const payload = Buffer.alloc(3 * 32768 + 17, 0x5a);
fs.writeFileSync(source, payload);
const firstPath = "/home/otpuser/first.bin";
const secondPath = "/home/otpuser/second.bin";
server.files.set(firstPath, Buffer.from("original file"));
const channel = owner.sftp;
const originalLstat = channel.lstat.bind(channel);
let firstTargetChecks = 0;
let promotionStarted;
const promotionStartedPromise = new Promise((resolve) => { promotionStarted = resolve; });
let releasePromotion;
const promotionGate = new Promise((resolve) => { releasePromotion = resolve; });
channel.lstat = (remotePath, ...args) => {
if (String(remotePath) === firstPath && ++firstTargetChecks === 2) {
promotionStarted();
void promotionGate.then(() => originalLstat(remotePath, ...args));
return;
}
originalLstat(remotePath, ...args);
};
const upload = (alias, targetPath) => transferBridge.startTransfer({ sender: createSender() }, {
transferId: `sudo-concurrency-${ident}-${path.basename(targetPath)}`,
sourcePath: source, targetPath,
sourceType: "local", targetType: "sftp", targetSftpId: alias.sftpId,
totalBytes: payload.length, resumable: true,
});
let firstFinished = false;
let secondFinished = false;
const first = upload(firstAlias, firstPath).then((result) => { firstFinished = true; return result; });
let second;
let results;
try {
await promotionStartedPromise;
second = upload(secondAlias, secondPath).then((result) => { secondFinished = true; return result; });
await waitFor(() => secondFinished
|| transferBridge._getSessionFastDownloadWaiterCountForTests(channel) > 0);
assert.equal(firstFinished, false, "the first upload must still be held before publication");
assert.equal(secondFinished, !sequential,
sequential ? "a bastion must wait for the first promotion" : "ordinary sudo uploads must remain concurrent");
assert.equal(transferBridge._getSessionFastDownloadWaiterCountForTests(channel), sequential ? 1 : 0);
} finally {
releasePromotion();
results = await Promise.all(second ? [first, second] : [first]);
channel.lstat = originalLstat;
}
for (const result of results) assert.equal(result.error, undefined, result.error);
assert.deepEqual(server.files.get(firstPath), payload);
assert.deepEqual(server.files.get(secondPath), payload);
assert.equal(server.getConnectionCount(), 1, "sudo uploads must not open an unelevated connection");
assert.equal(server.getKeyboardInteractiveRounds(), 1, "uploads must not ask for another OTP");
});
}
test("reconnecting the soft-closed owner id does not replace it with a self-alias (#3310)", async (t) => {
const { sftpTransferSessionLeaseStore, channelAliasLeaseId } = require("./sftpTransferSessionLease.cjs");
pool.resetSshTransportRegistryForTests({ defaultIdleTtlMs: 0 });
+6 -78
View File
@@ -2474,68 +2474,16 @@ async function acquireSessionFastDownloadSlot(client, transfer) {
}
}
function isSingleChannelSftpClient(client) {
return Boolean(
client?.__netcattySingleChannelSsh
|| client?.client?.__netcattySingleChannelSsh,
);
}
function requiresExclusiveSftpTransferChannel(client, channel) {
// Aliases stay on one authenticated/elevated channel, even on ordinary SSH
// servers. Only the owning peer's restriction requires serial transfers.
const peer = client?.__netcattySharedChannelAlias
? sftpClients?.get(client.__netcattySharedChannelOwnerId) || client
: client;
return isSingleChannelSftpClient(peer)
|| getSftpTransferConcurrency(channel, DOWNLOAD_TRANSFER_CONCURRENCY) === 1;
}
/**
* Some single-channel gateways cannot safely interleave SFTP metadata and
* write requests from different files. Serialize the complete upload
* transaction per raw SFTP stream while leaving independent connections free.
*/
async function acquireExclusiveSftpTransferTransaction(client, transfer) {
if (isScpModeClient(client)) return null;
// Preserve the normal lazy SFTP-open path. Only known single-channel peers
// acquire the transaction slot before the upload itself opens a handle.
const knownChannel = client?.sftp || { _client: client?.client };
if (!requiresExclusiveSftpTransferChannel(client, knownChannel)) return null;
await requireSftpChannel(client);
const channel = client.sftp;
if (transfer?._exclusiveSftpTransactionChannel === channel) return null;
await acquireSessionFastDownloadSlot(channel, transfer);
transfer._exclusiveSftpTransactionChannel = channel;
let released = false;
return {
client,
channel,
release() {
if (released) return;
released = true;
if (transfer._exclusiveSftpTransactionChannel === channel) {
transfer._exclusiveSftpTransactionChannel = null;
}
releaseSessionFastDownloadSlot(channel);
},
};
}
// Single-channel compatibility permits only one transfer operation per raw
// SFTP stream. A per-file request limit is insufficient because pooled aliases
// share the physical channel, and even a tiny file's second OPEN or verification
// read can fail. Reuse the cancelable one-slot queue keyed by stream rather
// than wrapper client across both directions and complete verification.
// TERM-SSHD compatibility requires one file handle per channel. Per-file request
// concurrency is not enough: pooled SFTP aliases share that physical channel,
// and a second body or verification OPEN would fail even for tiny files.
// Reuse the cancelable one-slot queue, keyed by the stream rather than its
// wrapper client, across both directions and the complete verification phase.
async function withSequentialSftpTransferSlot(client, transfer, operation) {
if (isScpModeClient(client)) return operation();
await requireSftpChannel(client);
const channel = client.sftp;
if (transfer?._exclusiveSftpTransactionChannel === channel) {
return operation();
}
if (!requiresExclusiveSftpTransferChannel(client, channel)) {
if (getSftpTransferConcurrency(channel, DOWNLOAD_TRANSFER_CONCURRENCY) !== 1) {
return operation();
}
await acquireSessionFastDownloadSlot(channel, transfer);
@@ -6677,7 +6625,6 @@ async function startTransferNow(event, payload, onProgress) {
});
};
let exclusiveSftpUploadTransaction = null;
try {
// Explicit 0 is a valid empty-snapshot plan (e.g. download of an empty log
// that may grow later). Do not treat it as "size unknown" and re-stat into
@@ -6805,16 +6752,6 @@ async function startTransferNow(event, payload, onProgress) {
else await verify();
}
// A single-channel upload holds the raw SFTP stream from directory setup
// through stage verification, destination safety checks, promotion, and
// timestamp restoration so directory children cannot interleave requests.
if (sourceType === "local" && targetType === "sftp" && targetClient) {
exclusiveSftpUploadTransaction = await acquireExclusiveSftpTransferTransaction(
targetClient,
transfer,
);
}
sendProgress(transfer.checkpointBytes, fileSize, { force: true });
if (sourceType === 'local' && targetType === 'sftp') {
@@ -7443,13 +7380,6 @@ async function startTransferNow(event, payload, onProgress) {
return { transferId, totalBytes: fileSize, ...(transfer.warning ? { warning: transfer.warning } : {}) };
} catch (err) {
if (
exclusiveSftpUploadTransaction
&& err?.uncleanSftpChannel
&& exclusiveSftpUploadTransaction.client.sftp === exclusiveSftpUploadTransaction.channel
) {
abandonWedgedVerificationSftpChannel(exclusiveSftpUploadTransaction.client);
}
const failureMessage = err?.message || String(err);
if (
sourceType === "local"
@@ -7535,8 +7465,6 @@ async function startTransferNow(event, payload, onProgress) {
sendError(err);
}
return { transferId, error: err.message };
} finally {
exclusiveSftpUploadTransaction?.release();
}
}
@@ -233,7 +233,6 @@ async function createSequentialTransferFixture(t) {
return {
payload, files, sftp, handles, clients,
rejectedOpens: () => rejectedOpens,
openCount: () => opens,
async start(direction, sftpId = "first", options = {}) {
const transferId = options.transferId || crypto.randomUUID();
const localPath = tempDirBridge.getTempFilePath(`term-concurrent-${transferId}.bin`);
@@ -264,61 +263,6 @@ async function createSequentialTransferFixture(t) {
};
}
test("single-channel upload waits for the prior promotion before starting the next file", { timeout: 5_000 }, async (t) => {
const fixture = await createSequentialTransferFixture(t);
const originalLstat = fixture.sftp.lstat;
let lstatCalls = 0;
let promotionStarted;
const promotionStartedPromise = new Promise((resolve) => { promotionStarted = resolve; });
let releasePromotion;
const promotionGate = new Promise((resolve) => { releasePromotion = resolve; });
fixture.sftp.lstat = (remotePath, callback) => {
lstatCalls += 1;
// The first two calls inspect the recovery backup and initial destination.
// The third is the final safety check after the .part write; hold it to
// prove that the next file cannot acquire the channel yet.
if (lstatCalls === 3) {
promotionStarted();
void promotionGate.then(() => originalLstat(remotePath, callback));
return;
}
originalLstat(remotePath, callback);
};
const first = fixture.start("upload", "first");
await promotionStartedPromise;
const openCountAtPromotion = fixture.openCount();
const second = fixture.start("upload", "alias");
let firstResult;
let secondResult;
try {
// start() first writes its local source. Wait until the second upload
// actually queues, or incorrectly starts remote work, before asserting.
const deadline = Date.now() + 1_000;
while (
transferBridge._getSessionFastDownloadWaiterCountForTests(fixture.sftp) === 0
&& lstatCalls === 3
&& fixture.openCount() === openCountAtPromotion
&& Date.now() < deadline
) {
await new Promise((resolve) => setImmediate(resolve));
}
assert.equal(lstatCalls, 3, "the next file must not inspect its target during prior promotion");
assert.equal(fixture.openCount(), openCountAtPromotion, "the next file must not open a remote handle during prior promotion");
assert.equal(transferBridge._getSessionFastDownloadWaiterCountForTests(fixture.sftp), 1,
"the second upload must be waiting for the shared channel");
} finally {
releasePromotion();
[firstResult, secondResult] = await Promise.all([first, second]);
}
assert.equal(firstResult.result.error, undefined);
assert.equal(secondResult.result.error, undefined);
assert.deepEqual(fixture.files.get(firstResult.targetPath), fixture.payload);
assert.deepEqual(fixture.files.get(secondResult.targetPath), fixture.payload);
assert.equal(fixture.rejectedOpens(), 0);
});
for (const secondId of ["first", "alias"]) {
test(`TERM-SSHD small downloads serialize through body and verification (${secondId})`, async (t) => {
const fixture = await createSequentialTransferFixture(t);
+36 -5
View File
@@ -151,6 +151,35 @@ class PackageStore {
.filter((value) => typeof value === "string" && value !== this.paths.database)
.map((directory) => mkdir(directory, { recursive: true, mode: 0o700 })));
await this.recover();
// Reconcile every installed manifest before lazy contributions are exposed.
// An application/API upgrade must not leave incompatible providers enabled.
for (const plugin of this.database.listPlugins()) {
if (!plugin.enabled) continue;
await this.assertCompatible(plugin).catch((error) => {
if (error.code !== "PLUGIN_INCOMPATIBLE") throw error;
});
}
}
async assertCompatible(plugin) {
const pluginCli = await loadPluginCli();
const result = pluginCli.checkPluginCompatibility(plugin.manifest, {
netcattyVersion: this.netcattyVersion,
apiVersion: this.apiVersion,
features: this.supportedFeatures,
});
if (result.compatible) return result;
const error = new Error(`Plugin is incompatible: ${result.errors.join("; ")}`);
error.code = "PLUGIN_INCOMPATIBLE";
const current = this.database.getActivePlugin(plugin.id);
if (current?.activeVersion === plugin.activeVersion) {
this.database.setEnabled(plugin.id, false);
this.database.setRuntimeState(plugin.id, "error", {
pluginVersion: plugin.activeVersion,
error: error.message,
});
}
throw error;
}
async install(archivePath, options = {}) {
@@ -187,7 +216,9 @@ class PackageStore {
const version = assertPluginStorageSegment(manifest.version, "version");
const targetDirectory = resolveInstalledVersionDirectory(this.paths, pluginId, version);
const previousPlugin = this.database.getActivePlugin(pluginId);
const enableAfterActivation = options.enable === true || previousPlugin?.enabled === true;
const enableAfterActivation = options.enable === false
? false
: options.enable === true || previousPlugin?.enabled === true;
let activationPrepared = false;
const prepareActivation = async (reason) => {
if (activationPrepared) return;
@@ -214,7 +245,7 @@ class PackageStore {
await rm(targetDirectory, { recursive: true, force: true });
}
if (existingValidation) {
if (previousPlugin?.activeVersion !== version) await prepareActivation("switch-active-version");
if (previousPlugin?.activeVersion !== version || options.enable === false) await prepareActivation("switch-active-version");
const existingPackage = path.join(targetDirectory, PACKAGE_DIRECTORY);
this.database.installVersion({
pluginId,
@@ -222,7 +253,7 @@ class PackageStore {
manifest: existingValidation.manifest,
archiveSha256: existing.archiveSha256,
packageRelativePath: path.relative(this.paths.packages, existingPackage),
}, { enable: enableAfterActivation });
}, { enable: enableAfterActivation, forceDisabled: options.enable === false });
return this.database.getActivePlugin(pluginId);
}
}
@@ -241,7 +272,7 @@ class PackageStore {
await writeDurableMetadata(path.join(stagingDirectory, INSTALL_METADATA_FILE), metadata);
await this.syncDirectory(stagingDirectory);
await mkdir(path.dirname(targetDirectory), { recursive: true, mode: 0o700 });
if (previousPlugin?.activeVersion !== version) await prepareActivation("switch-active-version");
if (previousPlugin?.activeVersion !== version || options.enable === false) await prepareActivation("switch-active-version");
await rename(stagingDirectory, targetDirectory);
await this.syncDirectory(path.dirname(targetDirectory));
const packageRelativePath = path.relative(
@@ -255,7 +286,7 @@ class PackageStore {
manifest,
archiveSha256: snapshot.sha256,
packageRelativePath,
}, { enable: enableAfterActivation });
}, { enable: enableAfterActivation, forceDisabled: options.enable === false });
} catch (error) {
this.verifiedArchives.delete(this.#versionKey(pluginId, version));
await rm(targetDirectory, { recursive: true, force: true });
+51
View File
@@ -370,3 +370,54 @@ test("failed package validation removes every extracted staging file", async (co
assert.deepEqual(await fs.promises.readdir(fixture.paths.staging), []);
assert.deepEqual(fixture.database.listPlugins(), []);
});
test("host upgrade disables incompatible lazy plugins before contributions and retains data", async (context) => {
const fixture = createStore(context);
await fixture.store.initialize();
const archive = await createPackage(fixture.root, {
manifest: { engines: { netcatty: "<1.0.0", api: ">=0.1.0-internal <0.2.0" } },
});
const installed = await fixture.store.install(archive.archive, { enable: true });
fixture.database.setValue(installed.id, "synthetic", { retained: true });
fixture.store.netcattyVersion = "1.0.0";
await fixture.store.initialize();
const disabled = fixture.database.getActivePlugin(installed.id);
assert.equal(disabled.enabled, false);
assert.match(disabled.runtime.lastError, /incompatible.*Netcatty/);
assert.equal(disabled.activeVersion, installed.activeVersion);
assert.deepEqual(fixture.database.getValue(installed.id, "synthetic"), { retained: true });
assert.equal(fs.existsSync(fixture.store.resolvePackageRoot(disabled)), true);
await assert.rejects(fixture.store.assertCompatible(disabled), { code: "PLUGIN_INCOMPATIBLE" });
fixture.store.netcattyVersion = "0.0.0";
await fixture.store.initialize();
assert.equal(fixture.database.getActivePlugin(installed.id).enabled, false,
"returning to a compatible host must not silently re-enable code");
});
for (const target of [
{ apiVersion: "0.2.0" },
{ supportedFeatures: [] },
]) {
test(`startup checks API and required feature compatibility: ${JSON.stringify(target)}`, async (context) => {
const fixture = createStore(context, { supportedFeatures: ["example.feature"] });
await fixture.store.initialize();
const archive = await createPackage(fixture.root, {
manifest: { features: { required: ["example.feature"] } },
});
const installed = await fixture.store.install(archive.archive, { enable: true });
Object.assign(fixture.store, target);
await fixture.store.initialize();
assert.equal(fixture.database.getActivePlugin(installed.id).enabled, false);
});
}
test("explicit disabled import never auto-enables an update of an enabled plugin", async (context) => {
const fixture = createStore(context);
await fixture.store.initialize();
const first = await createPackage(fixture.root);
await fixture.store.install(first.archive, { enable: true });
const update = await createPackage(fixture.root, { manifest: { version: "1.1.0" } });
const installed = await fixture.store.install(update.archive, { enable: false });
assert.equal(installed.activeVersion, "1.1.0");
assert.equal(installed.enabled, false);
});
+1 -1
View File
@@ -646,7 +646,7 @@ function registerPluginBridge(ipcMain, options) {
handle(CHANNELS.list, async (activeManager) => activeManager.list());
handle(CHANNELS.install, async (activeManager, payload) => activeManager.install(
payload?.archivePath,
{ enable: payload?.enable === true },
{ enable: typeof payload?.enable === "boolean" ? payload.enable : undefined },
));
handle(CHANNELS.setEnabled, async (activeManager, payload) => activeManager.setEnabled(
payload?.pluginId,
+2 -1
View File
@@ -52,7 +52,7 @@ class PluginManager {
let plugin;
try {
plugin = await this.packageStore.install(archivePath, {
enable: options?.enable === true,
enable: options?.enable,
beforeActivate: async ({ pluginId, previousPlugin }) => {
if (!previousPlugin?.enabled) return;
this.database.setEnabled(pluginId, false);
@@ -107,6 +107,7 @@ class PluginManager {
await this.#ready();
if (enabled) {
const plugin = this.database.getActivePlugin(pluginId);
if (plugin) await this.packageStore.assertCompatible?.(plugin);
if (plugin?.runtime?.quarantinedAt != null) {
this.database.clearQuarantine(pluginId, plugin.activeVersion);
}
+23
View File
@@ -488,3 +488,26 @@ test("shutdown cancels an in-flight runtime mutation before waiting for its queu
"database:close",
]);
});
test("explicit enable checks compatibility before clearing quarantine or exposing lazy contributions", async () => {
const calls = [];
const plugin = { id: "com.example.incompatible", enabled: false, runtime: { quarantinedAt: 1 } };
const manager = new PluginManager({
database: {
getActivePlugin: () => plugin,
clearQuarantine: () => calls.push("clear"),
setEnabled: () => calls.push("enable"),
},
packageStore: {
async initialize() {},
async assertCompatible() { throw new Error("Plugin is incompatible"); },
},
runtimeSupervisor: {},
contributionService: {
async initialize() {},
onPluginEnabled: () => calls.push("contribute"),
},
});
await assert.rejects(manager.setEnabled(plugin.id, true), /incompatible/);
assert.deepEqual(calls, []);
});
+29 -3
View File
@@ -9,9 +9,9 @@ From the repository root:
```bash
npm run build:plugin-packages
npm exec -- netcatty-plugin validate examples/plugins/hello-netcatty
npm exec -- netcatty-plugin compatibility examples/plugins/hello-netcatty --netcatty 0.0.0
npm exec -- netcatty-plugin pack examples/plugins/hello-netcatty --out /tmp/hello-netcatty.ncpkg
node packages/plugin-cli/dist/cli.js validate examples/plugins/hello-netcatty
node packages/plugin-cli/dist/cli.js compatibility examples/plugins/hello-netcatty --netcatty 0.0.0
node packages/plugin-cli/dist/cli.js pack examples/plugins/hello-netcatty --out /tmp/hello-netcatty.ncpkg
```
After installation, change **Greeting** under **Settings → Plugins**, then run
@@ -21,3 +21,29 @@ sandboxed plugin runtime. Type `netc` at a shell prompt to exercise the bounded
completion Provider, and print `Hello from Netcatty` to exercise the declarative
decoration Provider. Neither Provider receives raw xterm objects or sensitive
terminal input/output streams.
## Import and inspect
Launch a development build with `NETCATTY_PLUGIN_DEV=1`, using a fresh Electron
user-data directory for demonstrations. In **Settings > Plugins**, choose
**Import .ncpkg**, select the generated archive, and open its details. Review the
manifest permissions, engine ranges and SHA-256, then choose **Enable**. Native
permission prompts still mediate activation and sensitive operations.
Importing an update is a manual action and leaves it disabled, including when
the previous version was enabled. Review the new package before enabling it.
Changing unsigned package contents changes its security principal; previous
persistent grants are not transferred to different bytes.
An incompatible application/API upgrade disables installed plugins before
publishing their contributions. The reason remains in the installed list, and
package files and local data remain available. Returning to a compatible host
requires explicit re-enable. Uninstall is different: package code and local KV
are deleted; user-owned settings, secret storage and sync sidecars are retained.
For a synthetic demonstration, use only this first-party example, change the
Greeting, invoke **Examples: Say Hello**, disable/re-enable, restart the app and
inspect the package again. Do not connect real hosts or a cloud account. The
existing greeting setting is local; declaring `sync: true` on a non-secret
setting opts it into the existing encrypted settings sidecar. Generic SDK KV
storage does not sync.
Vendored
+2 -1
View File
@@ -337,7 +337,8 @@ declare global {
id: string;
enabled: boolean;
activeVersion: string | null;
manifest: unknown;
manifest: import("@netcatty/plugin-contract").PluginManifest | null;
archiveSha256?: string | null;
runtime: {
status: string;
kind: 'browser' | 'utility' | null;
+3 -3
View File
@@ -1,14 +1,14 @@
{
version = "1.1.90";
version = "1.1.89";
sources = {
x86_64-linux = {
appImageArch = "x86_64";
hash = "sha256-oa/T3Zey0FHsH+SOt4C1H4CT6aZRJta+x4x/11EAOJE=";
hash = "sha256-Ne5TalZ3G9cHPl0xnQpPq/0GNRe1ErUg1DzE+/nnseM=";
};
aarch64-linux = {
appImageArch = "arm64";
hash = "sha256-r5hr3+gpIGrv9XJ7PQkHCdhOd2COkhELMHlnr3xIG7A=";
hash = "sha256-L1aiJ/CroFwLSLH5aDnakogxjP8k5gLt8NJC+IGfXIw=";
};
};
}
+2 -2
View File
@@ -581,11 +581,11 @@ test("example README commands use repository-root CLI paths", async () => {
assert.match(readme, /npm run build:plugin-packages/);
assert.match(
readme,
/npm exec -- netcatty-plugin validate examples\/plugins\/hello-netcatty/,
/node packages\/plugin-cli\/dist\/cli\.js validate examples\/plugins\/hello-netcatty/,
);
assert.match(
readme,
/npm exec -- netcatty-plugin compatibility examples\/plugins\/hello-netcatty --netcatty 0\.0\.0/,
/node packages\/plugin-cli\/dist\/cli\.js compatibility examples\/plugins\/hello-netcatty --netcatty 0\.0\.0/,
);
assert.doesNotMatch(readme, /npm exec --workspace @netcatty\/plugin-cli/);
});
-20
View File
@@ -345,26 +345,6 @@ test("windows packaging includes a zip archive target", () => {
);
});
test("windows installer kills leftover Netcatty.exe before file replacement", () => {
const fs = require("node:fs");
const path = require("node:path");
const installerScript = fs.readFileSync(
path.join(__dirname, "..", config.nsis.include),
"utf8",
);
assert.match(installerScript, /!macro customInit\b/);
assert.match(installerScript, /#3946/);
assert.match(installerScript, /ELECTRON_RUN_AS_NODE/);
assert.match(
installerScript,
/nsExec::ExecToLog "taskkill \/F \/IM Netcatty\.exe"/,
);
assert.match(installerScript, /Pop \$0/);
// /T would kill this installer while it is still a child of the UI Netcatty.exe.
assert.doesNotMatch(installerScript, /taskkill [^\r\n]*\/T/);
});
test("windows installer registers and removes Explorer folder context menu entries", () => {
const fs = require("node:fs");
const path = require("node:path");