Compare commits

...
Author SHA1 Message Date
陈奇 018a6ab8b2 fix(ai): stop automatic model discovery retries after failure 2026-10-27 05:27:39 +00:00
陈大猫 f0897c3c17 test(transfers): control TERM-SSHD alias arrival order (#3995) 2026-10-27 02:08:36 +08:00
陈大猫 8d826594d3 fix(ssh): support ChaCha20 in Electron crypto runtimes (#3992)
* fix(ssh): support ChaCha20 in Electron crypto runtimes

* test(ssh): verify packaged cipher transport across platforms

* test(ssh): isolate unused Electron UI APIs in packaged transport harness

* test(ssh): isolate packaged dependency resolution from the checkout
2026-10-27 01:59:11 +08:00
a4999fdf1d fix: clear keychain ref when remembering KI password change (#3983)
* fix: clear keychain ref when remembering KI password change

Keyboard-interactive saves wrote the new host password but left
identityId attached. resolveHostAuth prefers the shared identity
password, so the next login kept the stale secret.

Co-authored-by: 陈大猫 <binaricat@users.noreply.github.com>

* fix: snapshot keychain username when remembering KI password

resolveHostAuth prefers identity.username. Detaching the identity
without copying that username left the host's stale account next to
the new password.

Co-authored-by: 陈大猫 <binaricat@users.noreply.github.com>

* fix: fill blank username only when detaching KI keychain identity

detachEffectiveHostIdentity copies the effective username only when the
host username is blank. Always replacing a set username went past that
helper.

Co-authored-by: 陈大猫 <binaricat@users.noreply.github.com>

* fix: snapshot keychain username when remembering KI password

Clearing identityId left a different host username next to the new
password. Copy the identity username onto the host whenever it is
non-empty, matching the vault password detach.

Co-authored-by: 陈大猫 <binaricat@users.noreply.github.com>

* test(ssh): exercise KI remember input and reconnect

---------

Co-authored-by: Cursor Agent <cursoragent@cursor.com>
Co-authored-by: 陈大猫 <binaricat@users.noreply.github.com>
2026-10-27 01:11:50 +08:00
陈大猫 e907336ef5 fix(terminal): preserve active SSH input mode in attached popups (#3991) 2026-10-27 01:07:10 +08:00
陈大猫 58c6a216ac feat: save SSH input compatibility per host (#3977)
* feat: save SSH input compatibility per host

* test: update extracted input harnesses for host mode

* test: provide required sync vault fields

* test: cover legacy cloud host migration after import

* fix: preserve non-SSH input compatibility behavior

* fix: keep Mosh and Eternal Terminal input policy unchanged

* fix: default new SSH link and importer drafts to standard input

* fix: retain connection input mode across renderer hibernation

* docs: distinguish input timing and native SSH evidence

* docs: record final native input compatibility acceptance
2026-10-26 23:16:06 +08:00
陈大猫 eb2c4b1611 fix(terminal): bound printable ZMODEM prefix echo delay (#3966) 2026-10-26 19:16:34 +08:00
陈大猫 392a094297 fix: collapse timestamp gutter on alternate screen (#3975)
Alternate-screen programs still skip line timestamps; collapse the empty left gutter while alt buffer is active and restore on normal screen.

Fixes #3972
2026-10-26 18:13:38 +08:00
陈大猫 f300d583b2 fix(ssh): treat russh banners as single-channel (skip env) (#3974)
Fixes #3973

russh CHANNEL_FAILURE on wantReply=false env desyncs ssh2 FIFO;
add russh to SINGLE_CHANNEL_SOFTWARE_TOKENS so startSession skips env.
OpenSSH path unchanged.
2026-10-26 18:08:34 +08:00
陈大猫 70d58a0cad Merge pull request #3968 from binaricat/codex/fix-sudo-transfer-concurrency
fix(sftp): preserve ordinary sudo transfer concurrency
2026-10-26 17:45:30 +08:00
bincxz b92177ec42 fix(sftp): preserve ordinary sudo transfer concurrency 2026-10-26 17:28:34 +08:00
陈大猫 54481b6306 Merge pull request #3944 from lazzman/fix/single-channel-sftp-folder-upload
fix(sftp): 串行化单通道目录上传事务
2026-10-26 17:04:45 +08:00
bincxz 7e81bece9e test(sftp): wait for the second upload to reach the shared channel 2026-10-26 16:39:44 +08:00
陈大猫 04ee9afa7f fix(mcp): match two-line idle prompts against live PTY CR bytes (#3963)
Related #3958 / follow-up to #3961.

hasExpectedPromptSuffix must match live PTY \r\r\n against a cached two-line idle prompt (CR→LF form) so cancel/completion matching works after readiness.

No release / no tag.
2026-10-26 16:39:38 +08:00
陈大猫 efe5d4530d fix(win): kill leftover Netcatty.exe before NSIS install (#3946) (#3962)
NSIS customInit taskkill /F /IM Netcatty.exe (no /T) so orphaned external-MCP Netcatty.exe cannot lock $INSTDIR during in-app update.

Fixes #3946
2026-10-26 16:30:56 +08:00
陈大猫 32e75b6ace fix(mcp): recognize two-line user@host idle prompts (#3961)
Fixes #3958. extractTrailingIdlePrompt now accepts two-line user@host[:path] + bare $/ # idle prompts (including CR-normalized live PTY form) so MCP terminal_execute getReadyPrompt no longer times out.

No release/tag.
2026-10-26 16:26:29 +08:00
陈大猫 4a831f41c2 Merge pull request #3950 from binaricat/automation/nix-release-v1.1.90
Update Nix release metadata for v1.1.90
2026-10-26 15:09:55 +08:00
github-actions[bot] 141148ae0d Update Nix release metadata for v1.1.90 2026-10-26 05:54:33 +00:00
陈大猫 8d15a29bc6 fix(mcp): recognize Python env-prefixed idle POSIX prompts (#3948)
Idle SSH Bash prompts that start with a Python virtualenv, conda, or BT-panel name such as `(3.12.12) ` were not recognized. Recognition now strips one optional leading `(name) ` prefix, then tests the existing POSIX and Alpine prompt patterns.

Fixes #3947
2026-10-26 13:33:40 +08:00
lazzman fcb6723c3d fix(sftp): 串行化单通道目录上传事务 2026-10-26 12:02:48 +08:00
陈大猫 519c24cc4c fix(terminal): skip keyword highlight on ANSI-colored cells (#3941)
Keyword highlighting no longer overwrites cells whose PTY foreground is already 16/256/truecolor (Starship directory segments). Default-foreground matches still get the unix-path purple.

Fixes #3940
2026-10-26 11:45:28 +08:00
77 changed files with 2859 additions and 89 deletions

No files matched your search

+9
View File
@@ -330,6 +330,9 @@ jobs:
APPLE_TEAM_ID: ${{ matrix.name == 'macos' && secrets.APPLE_TEAM_ID || '' }}
run: npm run ${{ matrix.pack_script }}
- name: Test packaged SSH cipher negotiation
run: node scripts/test-packaged-ssh-ciphers.cjs
- name: Test tray panel layout
run: npm run test:tray-panel-layout
@@ -631,6 +634,9 @@ jobs:
done
npm run pack:linux-x64
- name: Test packaged SSH cipher negotiation
run: node scripts/test-packaged-ssh-ciphers.cjs
- name: Verify packaged node-pty Linux runtime
run: bash scripts/ensure-node-pty-linux.sh verify x64
@@ -765,6 +771,9 @@ jobs:
ELECTRON_BUILDER_PUBLISH: "never"
run: npm run pack:linux-arm64
- name: Test packaged SSH cipher negotiation
run: node scripts/test-packaged-ssh-ciphers.cjs
- name: Verify packaged node-pty Linux runtime
run: bash scripts/ensure-node-pty-linux.sh verify arm64
+10
View File
@@ -52,6 +52,11 @@ jobs:
- name: Test
run: npm test
- name: Test SSH cipher negotiation in Electron's crypto runtime
env:
NETCATTY_SSH_TEST_RUNTIME: electron
run: node --test electron/bridges/sshChaChaTransport.test.cjs
- name: Test browser first-link lifecycle
run: xvfb-run -a ./node_modules/.bin/electron --no-sandbox electron/hostBrowser/hostBrowser.firstLink.live.test.cjs
@@ -138,5 +143,10 @@ jobs:
- name: Build
run: npm run build
- name: Test SSH cipher negotiation in Windows Electron's crypto runtime
env:
NETCATTY_SSH_TEST_RUNTIME: electron
run: node --test electron/bridges/sshChaChaTransport.test.cjs
- name: Exercise two SSH password prompts in the desktop app
run: node scripts/terminal-broadcast-ssh.live.cjs
+215 -1
View File
@@ -7,8 +7,9 @@ import {
handleKeyboardInteractiveSubmitImpl,
handleTrayPanelConnectRequestImpl,
} from './app/AppHandlers.ts';
import { resolveHostAuth } from '../domain/sshAuth.ts';
import { resolveShellSetting } from '../lib/useDiscoveredShells.ts';
import type { Host } from '../types';
import type { Host, Identity } from '../types';
const baseHost: Host = {
id: 'host-1',
@@ -194,6 +195,219 @@ test('keyboard-interactive submit can save login password for the session host',
assert.equal(bridgeResponses.length, 1);
});
test('keyboard-interactive submit detaches keychain identity when remembering a password change', async () => {
const sharedIdentity: Identity = {
id: 'id-1',
label: 'shared',
username: 'root',
authMethod: 'password',
password: 'stale-keychain-password',
created: 0,
};
let hosts: Host[] = [{
...baseHost,
identityId: 'id-1',
password: 'old-host-password',
savePassword: false,
}];
let queue = [{
requestId: 'ki-rotate',
sessionId: 'session-1',
hostname: baseHost.hostname,
allowSavePassword: true,
}];
let identityUpdates = 0;
// resolveHostAuth prefers identity.password, so the stale keychain secret
// shadows whatever is already stored on the host.
assert.equal(
resolveHostAuth({ host: hosts[0], keys: [], identities: [sharedIdentity] }).password,
'stale-keychain-password',
);
await handleKeyboardInteractiveSubmitImpl(
() => ({
hosts,
identities: [sharedIdentity],
keyboardInteractiveQueue: queue,
netcattyBridge: {
get: () => ({
respondKeyboardInteractive: () => ({ success: true }),
}),
},
sessions: [{
id: 'session-1',
hostId: baseHost.id,
hostname: baseHost.hostname,
}],
setKeyboardInteractiveQueue: (updater: (items: typeof queue) => typeof queue) => {
queue = updater(queue);
},
t: (key: string) => key,
toast: { error: () => {} },
updateHosts: (nextHosts: Host[]) => {
hosts = nextHosts;
},
updateIdentities: () => {
identityUpdates += 1;
},
}),
'ki-rotate',
['rotated-password'],
'rotated-password',
);
assert.equal(hosts[0].identityId, '');
assert.equal(hosts[0].password, 'rotated-password');
assert.equal(hosts[0].savePassword, true);
assert.equal(hosts[0].username, baseHost.username);
assert.equal(identityUpdates, 0);
assert.equal(sharedIdentity.password, 'stale-keychain-password');
assert.equal(
resolveHostAuth({ host: hosts[0], keys: [], identities: [sharedIdentity] }).password,
'rotated-password',
);
assert.deepEqual(queue, []);
});
test('keyboard-interactive submit snapshots a different keychain username when remembering a password', async () => {
const sharedIdentity: Identity = {
id: 'id-1',
label: 'shared',
username: 'deploy',
authMethod: 'password',
password: 'stale-keychain-password',
created: 0,
};
let hosts: Host[] = [{
...baseHost,
username: 'root',
identityId: 'id-1',
password: 'old-host-password',
savePassword: false,
}];
let queue = [{
requestId: 'ki-rotate-user',
sessionId: 'session-1',
hostname: baseHost.hostname,
allowSavePassword: true,
}];
let identityUpdates = 0;
await handleKeyboardInteractiveSubmitImpl(
() => ({
hosts,
identities: [sharedIdentity],
keyboardInteractiveQueue: queue,
netcattyBridge: {
get: () => ({
respondKeyboardInteractive: () => ({ success: true }),
}),
},
sessions: [{
id: 'session-1',
hostId: baseHost.id,
hostname: baseHost.hostname,
}],
setKeyboardInteractiveQueue: (updater: (items: typeof queue) => typeof queue) => {
queue = updater(queue);
},
t: (key: string) => key,
toast: { error: () => {} },
updateHosts: (nextHosts: Host[]) => {
hosts = nextHosts;
},
updateIdentities: () => {
identityUpdates += 1;
},
}),
'ki-rotate-user',
['new-password'],
'new-password',
);
assert.equal(hosts[0].identityId, '');
assert.equal(hosts[0].password, 'new-password');
assert.equal(hosts[0].savePassword, true);
assert.equal(hosts[0].username, 'deploy');
assert.equal(identityUpdates, 0);
assert.equal(sharedIdentity.username, 'deploy');
assert.equal(sharedIdentity.password, 'stale-keychain-password');
const resolved = resolveHostAuth({ host: hosts[0], keys: [], identities: [sharedIdentity] });
assert.equal(resolved.password, 'new-password');
assert.equal(resolved.username, 'deploy');
assert.deepEqual(queue, []);
});
test('keyboard-interactive submit fills a blank host username from the keychain identity', async () => {
const sharedIdentity: Identity = {
id: 'id-1',
label: 'shared',
username: 'alice',
authMethod: 'password',
password: 'stale-keychain-password',
created: 0,
};
let hosts: Host[] = [{
...baseHost,
username: '',
identityId: 'id-1',
password: 'old-host-password',
savePassword: false,
}];
let queue = [{
requestId: 'ki-blank-user',
sessionId: 'session-1',
hostname: baseHost.hostname,
allowSavePassword: true,
}];
let identityUpdates = 0;
await handleKeyboardInteractiveSubmitImpl(
() => ({
hosts,
identities: [sharedIdentity],
keyboardInteractiveQueue: queue,
netcattyBridge: {
get: () => ({
respondKeyboardInteractive: () => ({ success: true }),
}),
},
sessions: [{
id: 'session-1',
hostId: baseHost.id,
hostname: baseHost.hostname,
}],
setKeyboardInteractiveQueue: (updater: (items: typeof queue) => typeof queue) => {
queue = updater(queue);
},
t: (key: string) => key,
toast: { error: () => {} },
updateHosts: (nextHosts: Host[]) => {
hosts = nextHosts;
},
updateIdentities: () => {
identityUpdates += 1;
},
}),
'ki-blank-user',
['new-password'],
'new-password',
);
assert.equal(hosts[0].identityId, '');
assert.equal(hosts[0].username, 'alice');
assert.equal(hosts[0].password, 'new-password');
assert.equal(hosts[0].savePassword, true);
assert.equal(identityUpdates, 0);
assert.equal(sharedIdentity.username, 'alice');
assert.equal(sharedIdentity.password, 'stale-keychain-password');
const resolved = resolveHostAuth({ host: hosts[0], keys: [], identities: [sharedIdentity] });
assert.equal(resolved.password, 'new-password');
assert.equal(resolved.username, 'alice');
assert.deepEqual(queue, []);
});
test('keyboard-interactive submit does not save secondary password when allowSavePassword is false', async () => {
let hosts: Host[] = [{
...baseHost,
+21 -5
View File
@@ -393,6 +393,7 @@ export async function handleKeyboardInteractiveSubmitImpl(
const {
hosts,
hostsRef,
identities,
keyboardInteractiveQueue,
sessions,
setKeyboardInteractiveQueue,
@@ -424,12 +425,27 @@ export async function handleKeyboardInteractiveSubmitImpl(
// Save password to host if requested - never for second-factor / EDR prompts
// (allowSavePassword === false) so a secondary secret cannot overwrite the
// host login password (#2150 / Codex review on #2151).
// Detach a Keychain identity when one is attached. resolveHostAuth prefers
// identity.password and identity.username, so snapshot the identity username
// onto the host before clearing identityId (#3982). Same as
// applyVaultHostUpdate's password detach. Do not mutate the identity.
if (savePassword && host && request?.allowSavePassword !== false) {
updateHosts(latestHosts.map((h: Host) => h.id === host.id ? {
...h,
password: savePassword,
savePassword: true,
} : h));
updateHosts(latestHosts.map((h: Host) => {
if (h.id !== host.id) return h;
const identity = h.identityId
? identities?.find((item: { id?: string; username?: string }) => item.id === h.identityId)
: undefined;
const detachedUsername = identity?.username?.trim();
return {
...h,
password: savePassword,
savePassword: true,
...(h.identityId ? {
identityId: '',
...(detachedUsername ? { username: detachedUsername } : {}),
} : {}),
};
}));
}
// Remove from queue by requestId
setKeyboardInteractiveQueue(prev => prev.filter(r => r.requestId !== requestId));
+1 -1
View File
@@ -716,7 +716,7 @@ export function AppSideEffects() {
}, [isVaultInitialized, pendingTrayPanelConnectHostIds]);
// Handle keyboard-interactive submit
const handleKeyboardInteractiveSubmit = useCallback((requestId: string, responses: string[], savePassword?: string) => { return handleKeyboardInteractiveSubmitImpl(() => ({ hosts, hostsRef, keyboardInteractiveQueue, netcattyBridge, requestId, responses, savePassword, sessions, setKeyboardInteractiveQueue, t, toast, updateHosts }), requestId, responses, savePassword); }, [keyboardInteractiveQueue, sessions, hosts, t, updateHosts]);
const handleKeyboardInteractiveSubmit = useCallback((requestId: string, responses: string[], savePassword?: string) => { return handleKeyboardInteractiveSubmitImpl(() => ({ hosts, hostsRef, identities, keyboardInteractiveQueue, netcattyBridge, requestId, responses, savePassword, sessions, setKeyboardInteractiveQueue, t, toast, updateHosts }), requestId, responses, savePassword); }, [identities, keyboardInteractiveQueue, sessions, hosts, t, updateHosts]);
// Handle keyboard-interactive cancel
const handleKeyboardInteractiveCancel = useCallback((requestId: string) => { return handleKeyboardInteractiveCancelImpl(() => ({ netcattyBridge, requestId, setKeyboardInteractiveQueue, t, toast }), requestId); }, [t]);
+3
View File
@@ -975,6 +975,9 @@ 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',
+3
View File
@@ -965,6 +965,9 @@ 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',
+3
View File
@@ -1014,6 +1014,9 @@ 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': 'Прокси через хосты',
+3
View File
@@ -384,6 +384,9 @@ 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': '通过主机代理',
+3
View File
@@ -384,6 +384,9 @@ 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': '透過主機代理',
+1
View File
@@ -335,6 +335,7 @@ export const useTerminalBackend = () => {
contextViewportSnapshot: string;
contextScrollbackSnapshot: string;
alternateScreen: boolean;
perCharacterInput?: boolean;
kittyKeyboardModeState?: NetcattyKittyKeyboardModeState;
kittyKeyboardProtocolEnabled?: boolean;
},
@@ -0,0 +1,108 @@
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,6 +1570,7 @@ export const useVaultState = (options?: UseVaultStateOptions) => {
group: "",
tags: [],
protocol: "ssh",
sshInputCompatibility: false,
order: getNextVaultOrder(hosts),
};
+11
View File
@@ -1,3 +1,14 @@
!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,6 +503,19 @@ 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,6 +182,7 @@ const HostDetailsPanel: React.FC<HostDetailsPanelPropsWithResize> = ({
os: "linux",
authMethod: undefined,
authPolicyVersion: 1,
sshInputCompatibility: false,
charset: groupDefaults?.charset ? undefined : "UTF-8",
distroMode: "auto",
createdAt: Date.now(),
@@ -0,0 +1,138 @@
import assert from "node:assert/strict";
import test from "node:test";
import React from "react";
import type { Host, Identity } from "../types";
import { handleKeyboardInteractiveSubmitImpl } from "../application/app/AppHandlers";
import { createTerminalSessionStarters } from "./terminal/runtime/createTerminalSessionStarters";
import { createPromptLineBreakState } from "./terminal/runtime/promptLineBreak";
import { createDomRenderer, dispatchDomEvent, installDomEnvironment } from "./test-support/renderReactDom";
const noop = () => {};
test("KI remember input preserves the reconnect account and leaves shared hosts unchanged", async (t) => {
const dom = installDomEnvironment();
const globals = {
MutationObserver: dom.window.MutationObserver,
NodeFilter: dom.window.NodeFilter,
HTMLInputElement: dom.window.HTMLInputElement,
localStorage: dom.window.localStorage,
};
const previous = new Map(Object.keys(globals).map((key) => [key, Object.getOwnPropertyDescriptor(globalThis, key)]));
for (const [key, value] of Object.entries(globals)) Object.defineProperty(globalThis, key, { configurable: true, value });
const { KeyboardInteractiveModal } = await import("./KeyboardInteractiveModal");
const { I18nProvider } = await import("../application/i18n/I18nProvider");
const renderer = await createDomRenderer(dom.document);
try {
for (const scenario of [
{ name: "differing usernames", hostUsername: "old-account", identityUsername: "fixtureki", remember: true },
{ name: "blank host username", hostUsername: "", identityUsername: "fixtureki", remember: true },
{ name: "blank identity username", hostUsername: "fixtureki", identityUsername: " ", remember: true },
{ name: "no identity", hostUsername: "fixtureki", identityUsername: undefined, remember: true },
{ name: "remember unchecked", hostUsername: "old-account", identityUsername: "fixtureki", remember: false },
{ name: "secondary password", hostUsername: "old-account", identityUsername: "fixtureki", remember: true, allowSavePassword: false },
]) {
await t.test(scenario.name, async () => {
const identity: Identity = {
id: "shared", label: "Fixture", username: scenario.identityUsername ?? "",
authMethod: "password", password: "fixture-old", created: 0,
};
const identities = scenario.identityUsername === undefined ? [] : [identity];
const originalIdentity = { ...identity };
const host: Host = {
id: "changed", label: "Fixture", hostname: "127.0.0.1", username: scenario.hostUsername,
tags: [], os: "linux", protocol: "ssh", authMethod: "password",
password: "fixture-host-old", savePassword: false,
...(identities.length ? { identityId: identity.id } : {}),
};
const otherHost = { ...host, id: "unchanged", label: "Other fixture" };
let hosts = [host, otherHost];
let updates = 0;
const request = {
requestId: `ki-${scenario.name}`, sessionId: "fixture-session", hostname: host.hostname,
name: "Password expiry", instructions: "", prompts: [{ prompt: "New password:", echo: false }],
allowSavePassword: scenario.allowSavePassword ?? true,
};
let queue = [request];
const responses: unknown[][] = [];
await renderer.render(
<I18nProvider locale="en">
<KeyboardInteractiveModal request={request} onCancel={() => false}
onSubmit={(requestId, values, savePassword) => handleKeyboardInteractiveSubmitImpl(() => ({
hosts, identities, keyboardInteractiveQueue: queue,
sessions: [{ id: "fixture-session", hostId: host.id, hostname: host.hostname }],
netcattyBridge: { get: () => ({ respondKeyboardInteractive: (...args: unknown[]) => {
responses.push(args);
return { success: true };
} }) },
setKeyboardInteractiveQueue: (updater: (items: typeof queue) => typeof queue) => { queue = updater(queue); },
updateHosts: (next: Host[]) => { updates += 1; hosts = next; },
t: (key: string) => key, toast: { error: (message: string) => assert.fail(message) },
}), requestId, values, savePassword)} />
</I18nProvider>,
);
const input = dom.document.querySelector<HTMLInputElement>("#ki-prompt-0");
assert.ok(input);
Object.getOwnPropertyDescriptor(dom.window.HTMLInputElement.prototype, "value")!.set!.call(input, "fixture-new");
await dispatchDomEvent(input, new dom.window.Event("input", { bubbles: true }));
const checkbox = dom.document.querySelector<HTMLInputElement>('input[type="checkbox"]');
if (scenario.allowSavePassword === false) assert.equal(checkbox, null);
else {
assert.ok(checkbox);
assert.equal(checkbox.checked, false);
if (scenario.remember) await dispatchDomEvent(checkbox, new dom.window.MouseEvent("click", { bubbles: true }));
}
const submit = [...dom.document.querySelectorAll("button")].find((button) => button.textContent === "Submit");
assert.ok(submit);
await dispatchDomEvent(submit, new dom.window.MouseEvent("click", { bubbles: true }));
assert.deepEqual(responses, [[request.requestId, ["fixture-new"], false]]);
assert.deepEqual(queue, []);
const saved = scenario.remember && scenario.allowSavePassword !== false;
assert.equal(updates, saved ? 1 : 0);
assert.equal(hosts[0].identityId, saved && identities.length ? "" : host.identityId);
assert.equal(hosts[0].password, saved ? "fixture-new" : host.password);
assert.equal(hosts[0].savePassword, saved);
assert.equal(hosts[1], otherHost);
assert.deepEqual(identity, originalIdentity);
// Exercise the reconnect launcher that builds the actual SSH bridge request.
for (const [index, candidate] of hosts.entries()) {
let started: { username: string; password?: string } | undefined;
const ctx = {
host: candidate, identities, keys: [], knownHosts: [], resolvedChainHosts: [],
sessionId: `reconnect-${index}`, terminalSettings: {},
sessionRef: { current: null }, hasConnectedRef: { current: true },
hasRunStartupCommandRef: { current: true }, disposeDataRef: { current: null },
disposeExitRef: { current: null }, fitAddonRef: { current: null },
serializeAddonRef: { current: null }, pendingAuthRef: { current: null },
promptLineBreakStateRef: { current: createPromptLineBreakState() }, sudoAutofillRef: { current: null },
updateStatus: noop, setStatus: noop, setError: noop, setNeedsAuth: noop,
setAuthRetryMessage: noop, setAuthPassword: noop, setProgressLogs: noop,
setProgressValue: noop, setChainProgress: noop,
terminalBackend: {
backendAvailable: () => true,
startSSHSession: async (options: { username: string; password?: string }) => { started = options; return `reconnect-${index}`; },
onSessionData: () => noop, onSessionExit: () => noop, onChainProgress: () => noop,
writeToSession: noop, resizeSession: noop,
},
};
await createTerminalSessionStarters(ctx as never).startSSH({
cols: 80, rows: 24, write: noop, writeln: noop, scrollToBottom: noop,
} as never);
assert.ok(started);
assert.equal(started.username, scenario.identityUsername?.trim() || scenario.hostUsername);
assert.equal(started.password, saved && index === 0 ? "fixture-new" : identities.length ? "fixture-old" : "fixture-host-old");
}
// A fresh request resets the checkbox and submitting state for the next case.
await renderer.render(<I18nProvider locale="en"><KeyboardInteractiveModal request={null} onSubmit={() => false} onCancel={() => false} /></I18nProvider>);
});
}
} finally {
await renderer.unmount();
await new Promise((resolve) => setTimeout(resolve, 0));
for (const [key, descriptor] of previous) {
if (descriptor) Object.defineProperty(globalThis, key, descriptor);
else Reflect.deleteProperty(globalThis, key);
}
dom.cleanup();
}
});
+11 -1
View File
@@ -158,6 +158,8 @@ import {
} from "./terminal/runtime/createTerminalSessionStarters";
import {
createXTermRuntime,
getTerminalPerCharacterInput,
restoreTerminalPerCharacterInput,
resetKittyKeyboardModeStateForSession,
type XTermRuntime,
} from "./terminal/runtime/createXTermRuntime";
@@ -1683,6 +1685,8 @@ const TerminalComponent: React.FC<TerminalProps> = ({
passwordPromptActiveRef.current,
knownCwdRef.current ?? null,
terminalTitleRef.current ?? null,
xtermRuntimeRef.current?.getPerCharacterInput()
?? getTerminalPerCharacterInput(terminalSettingsRef),
);
}));
}
@@ -1704,6 +1708,10 @@ const TerminalComponent: React.FC<TerminalProps> = ({
throw new Error("Terminal output did not settle before applying the snapshot");
}
}
if (typeof payload.perCharacterInput === "boolean") {
restoreTerminalPerCharacterInput(terminalSettingsRef, payload.perCharacterInput);
xtermRuntimeRef.current?.restorePerCharacterInput(payload.perCharacterInput);
}
if (typeof payload.kittyKeyboardProtocolEnabled === "boolean") {
xtermRuntimeRef.current?.setKittyKeyboardProtocolEnabled(
payload.kittyKeyboardProtocolEnabled,
@@ -1873,6 +1881,8 @@ const TerminalComponent: React.FC<TerminalProps> = ({
snap,
{
...finalContext,
perCharacterInput: xtermRuntimeRef.current?.getPerCharacterInput()
?? getTerminalPerCharacterInput(terminalSettingsRef),
kittyKeyboardModeState: xtermRuntimeRef.current?.getKittyKeyboardModeState(),
kittyKeyboardProtocolEnabled:
xtermRuntimeRef.current?.getKittyKeyboardProtocolEnabled(),
@@ -4063,7 +4073,7 @@ const TerminalComponent: React.FC<TerminalProps> = ({
return;
}
finishReconnectPreparation();
xtermRuntimeRef.current?.resetKittyConnectionInputState();
xtermRuntimeRef.current?.resetConnectionInputState(host);
resetKittyKeyboardModeStateForSession(terminalSettingsRef);
if (effectiveTerminalProtocol.startsWith("plugin:")) {
sessionStarters.startPluginConnection(term);
@@ -0,0 +1,177 @@
import assert from 'node:assert/strict';
import test, { after } from 'node:test';
import React from 'react';
import { act, create, type ReactTestRenderer } from 'react-test-renderer';
import { JSDOM } from 'jsdom';
const dom = new JSDOM('<!doctype html><html><body></body></html>', { url: 'http://localhost/' });
const previous = new Map<string, PropertyDescriptor | undefined>();
for (const name of ['window', 'document', 'navigator', 'HTMLElement', 'Element', 'Node', 'MutationObserver', 'CustomEvent', 'Event'] as const) {
previous.set(name, Object.getOwnPropertyDescriptor(globalThis, name));
Object.defineProperty(globalThis, name, { value: dom.window[name], configurable: true });
}
for (const [name, value] of Object.entries({ IS_REACT_ACT_ENVIRONMENT: true, fetch: () => { throw new Error('Network is forbidden in model discovery tests'); } })) {
previous.set(name, Object.getOwnPropertyDescriptor(globalThis, name));
Object.defineProperty(globalThis, name, { value, configurable: true });
}
after(() => {
dom.window.close();
for (const [name, descriptor] of previous) {
if (descriptor) Object.defineProperty(globalThis, name, descriptor);
else Reflect.deleteProperty(globalThis, name);
}
});
const { I18nProvider } = await import('../../../../application/i18n/I18nProvider');
const { TooltipProvider } = await import('../../../ui/tooltip');
const { ModelSelector } = await import('./ModelSelector');
type Response = { ok: boolean; status?: number; data: string; error?: string };
function deferred() {
let resolve!: (value: Response) => void;
let reject!: (error: Error) => void;
const promise = new Promise<Response>((yes, no) => { resolve = yes; reject = no; });
return { promise, resolve, reject };
}
type Props = React.ComponentProps<typeof ModelSelector>;
const baseProps: Props = {
value: '', onChange: () => {}, baseURL: 'http://127.0.0.1:9217/v1',
providerId: 'custom', style: 'openai', apiKey: 'SYNTHETIC_FIXTURE_KEY', customHeaders: {},
};
async function mount() {
const requests: ({ url: string; method: string; body: string | undefined } & ReturnType<typeof deferred>)[] = [];
const allowlistCalls: string[] = [];
let overflow = false;
Object.defineProperty(window, 'netcatty', { configurable: true, value: {
aiAllowlistAddHost: async (baseURL: string) => { allowlistCalls.push(baseURL); return { ok: true }; },
aiFetch: (url: string, method: string, _headers: unknown, body: string | undefined) => {
// Hold instead of rejecting if a regression exceeds the budget, so even a
// broken auto-fetch effect cannot create an uncontrolled test loop.
if (requests.length >= 4) { overflow = true; return new Promise<Response>(() => {}); }
const pending = deferred();
requests.push({ url, method, body, ...pending });
return pending.promise;
},
} });
let renderer!: ReactTestRenderer;
const view = (updates: Partial<Props> = {}) => <I18nProvider locale="en"><TooltipProvider>
<ModelSelector {...baseProps} {...updates} />
</TooltipProvider></I18nProvider>;
await act(async () => { renderer = create(view()); });
const input = () => renderer.root.findByType('input');
const open = async () => { await act(async () => input().props.onFocus()); };
const close = async () => { await act(async () => input().props.onBlur()); };
const refresh = async () => {
const button = renderer.root.findAllByType('button').find((node) => typeof node.props.onClick === 'function')!;
await act(async () => button.props.onClick({ defaultPrevented: false }));
};
const markup = () => JSON.stringify(renderer.toJSON());
return {
requests, allowlistCalls, input, open, close, refresh, markup,
update: async (updates: Partial<Props>) => { await act(async () => renderer.update(view(updates))); },
assertBounded: () => assert.equal(overflow, false, 'mock request budget was not exceeded'),
unmount: async () => { await act(async () => renderer.unmount()); },
};
}
const notFound: Response = { ok: false, status: 404, data: '{"error":"fixture has no model-list route"}' };
const models = (id: string): Response => ({ ok: true, status: 200, data: JSON.stringify({ data: [{ id }] }) });
for (const failure of ['404', 'transport', 'invalid-json'] as const) {
test(`model discovery ${failure} failure stays visible without auto-retry; Refresh retries once`, async () => {
const h = await mount();
const reject = (index: number) => {
if (failure === 'transport') h.requests[index].reject(new Error('Synthetic model-list transport failure'));
else h.requests[index].resolve(failure === '404' ? notFound : { ok: true, status: 200, data: '{not json' });
};
try {
assert.equal(h.requests.length, 0);
await h.open();
assert.equal(h.requests.length, 1);
assert.equal(h.requests[0].url, 'http://127.0.0.1:9217/v1/models');
assert.equal(h.requests[0].method, 'GET');
assert.equal(h.requests[0].body, undefined);
await act(async () => reject(0));
assert.equal(h.requests.length, 1, 'failure must settle instead of triggering the loading effect again');
assert.match(h.markup(), /text-destructive/);
assert.doesNotMatch(h.markup(), /Loading models/);
await h.close();
await h.open();
await h.update({ customHeaders: {} });
assert.equal(h.requests.length, 1, 'reopen or an equal-config render must not retry a failure');
assert.match(h.markup(), /text-destructive/);
await h.refresh();
assert.equal(h.requests.length, 2);
await act(async () => reject(1));
assert.equal(h.requests.length, 2);
assert.match(h.markup(), /text-destructive/);
assert.equal(h.allowlistCalls.length, 2);
h.assertBounded();
} finally { await h.unmount(); }
});
}
test('changing provider configuration clears a settled error and permits one fresh discovery', async () => {
const h = await mount();
try {
await h.open();
await act(async () => h.requests[0].resolve(notFound));
assert.equal(h.requests.length, 1);
await h.update({ baseURL: 'http://127.0.0.1:9217/second-provider/v1', providerId: 'openai' });
assert.equal(h.requests.length, 2);
assert.equal(h.requests[1].url, 'http://127.0.0.1:9217/second-provider/v1/models');
await act(async () => h.requests[1].resolve(models('second-model')));
assert.equal(h.requests.length, 2);
assert.match(h.markup(), /second-model/);
assert.doesNotMatch(h.markup(), /Failed to fetch models/);
h.assertBounded();
} finally { await h.unmount(); }
});
test('a provider switch ignores an older pending discovery failure', async () => {
const h = await mount();
try {
await h.open();
await h.update({ baseURL: 'http://127.0.0.1:9217/second-provider/v1' });
assert.equal(h.requests.length, 2);
await act(async () => h.requests[1].resolve(models('current-model')));
await act(async () => h.requests[0].resolve(notFound));
assert.equal(h.requests.length, 2);
assert.match(h.markup(), /current-model/);
assert.doesNotMatch(h.markup(), /Failed to fetch models/);
h.assertBounded();
} finally { await h.unmount(); }
});
test('successful discovery remains cached across reopen and manual model entry remains controlled', async () => {
const h = await mount();
try {
await h.open();
await act(async () => h.requests[0].resolve(models('listed-model')));
await h.close();
await h.open();
assert.equal(h.requests.length, 1);
assert.match(h.markup(), /listed-model/);
const changed: string[] = [];
await h.update({ onChange: (value) => { changed.push(value); } });
await act(async () => h.input().props.onChange({ target: { value: 'manual-model' } }));
await h.update({ value: changed[0] });
assert.deepEqual(changed, ['manual-model']);
assert.equal(h.input().props.value, 'manual-model');
assert.equal(h.requests.length, 1);
h.assertBounded();
} finally { await h.unmount(); }
});
test('closing and unmounting with a pending discovery do not trigger another request', async () => {
const h = await mount();
try {
await h.open();
await h.close();
await act(async () => h.requests[0].resolve(notFound));
assert.equal(h.requests.length, 1);
await h.refresh();
assert.equal(h.requests.length, 2);
h.assertBounded();
} finally { await h.unmount(); }
await act(async () => h.requests[1].resolve(notFound));
assert.equal(h.requests.length, 2);
});
@@ -175,12 +175,12 @@ export const ModelSelector: React.FC<{
}
}, [baseURL, effectiveModelsEndpoint, apiKey, customHeaders, resolvedStyle, skipTLSVerify, discoveryKey]);
// Auto-fetch when dropdown first opens
// Failed discovery stays settled until explicit refresh or a new discovery key.
useEffect(() => {
if (isOpen && canFetch && !hasFetched && !isLoading) {
if (isOpen && canFetch && !hasFetched && !isLoading && error === null) {
void fetchModels();
}
}, [isOpen, canFetch, hasFetched, isLoading, fetchModels]);
}, [isOpen, canFetch, hasFetched, isLoading, error, fetchModels]);
// Filter preset and discovered models by current input value (inline autocomplete).
const suggestions = useMemo(() => {
+123
View File
@@ -10,9 +10,11 @@ import {
formatTerminalTitleConnectionAddress,
focusTerminalFromDisconnectedNotice,
getLineTimestampToggleHostUpdate,
resolveLineTimestampGutterLayout,
resolveNetworkDeviceTipRightInset,
resolveTerminalRightInset,
resolveTerminalTopOffsets,
subscribeTerminalAlternateScreenChanges,
shouldBlockTerminalReconnectForTarget,
shouldEnableStatusBarDisconnect,
shouldEnableStatusBarReconnect,
@@ -182,6 +184,127 @@ 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 = () => {};
+116 -2
View File
@@ -1,5 +1,6 @@
/* 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';
@@ -18,6 +19,7 @@ import {
resolveTerminalTimestampGutterColor,
resolveTerminalTimestampGutterWidth,
} from './TerminalTimestampGutter';
import { isTerminalAlternateScreenActive } from './terminalHibernateRuntime';
import {
Dialog,
DialogContent,
@@ -122,6 +124,108 @@ 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,
@@ -486,6 +590,11 @@ 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 })
@@ -497,7 +606,12 @@ function TerminalViewInner({ ctx, isPaneMagnified = false }: { ctx: TerminalView
const handleLineTimestampGutterWidthChange = useCallback((width: number) => {
setLineTimestampGutterWidth((current) => (current === width ? current : width));
}, []);
const activeLineTimestampGutterWidth = showLineTimestampGutter ? lineTimestampGutterWidth : 0;
const lineTimestampGutterLayout = resolveLineTimestampGutterLayout({
showLineTimestampGutter,
alternateScreenActive,
gutterWidth: lineTimestampGutterWidth,
});
const activeLineTimestampGutterWidth = lineTimestampGutterLayout.width;
const lineTimestampToggleLabel = showLineTimestampGutter
? t("terminal.toolbar.timestampsDisable")
: t("terminal.toolbar.timestampsEnable");
@@ -1086,7 +1200,7 @@ function TerminalViewInner({ ctx, isPaneMagnified = false }: { ctx: TerminalView
<TerminalTimestampGutter
termRef={termRef}
containerRef={containerRef}
enabled={showLineTimestampGutter}
enabled={lineTimestampGutterLayout.enabled}
top={terminalContentTop}
left={terminalBodyInset}
bottom={terminalBottomInset}
@@ -5,6 +5,7 @@ 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,
@@ -81,6 +82,76 @@ 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);
+8 -1
View File
@@ -67,6 +67,7 @@ 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;
@@ -1061,7 +1062,13 @@ export class KeywordHighlighter implements IDisposable {
const dataIndex = x * CELL_INDICES;
const content = internal._data[dataIndex + CELL_CONTENT];
const currentFg = internal._data[dataIndex + CELL_FG];
if (!originals.mask[x] || originals.content[x] !== content) {
// 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) {
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]*resetKittyConnectionInputState\(\);[\s\S]*resetKittyKeyboardModeStateForSession/,
/startNewSession = \(\) => \{[\s\S]*resetConnectionInputState\(host\);[\s\S]*resetKittyKeyboardModeStateForSession/,
);
assert.match(
runtimeSource,
@@ -42,7 +42,7 @@ test("reconnect clears keyboard pairing before resetting protocol state", () =>
);
assert.match(
runtimeSource,
/resetKittyConnectionInputState: clearKittyConnectionInputState/,
/resetConnectionInputState: \(host\) => \{[\s\S]*clearKittyConnectionInputState\(\)/,
);
assert.match(
runtimeSource,
@@ -357,6 +357,34 @@ test("startPluginConnection displays status diagnostics when a Provider exits wi
]);
});
test("startSSH forwards a saved ChaCha20-only host setting to the transport", async () => {
const host = JSON.parse(JSON.stringify({
id: "host-chacha",
label: "Synthetic ChaCha20 server",
hostname: "127.0.0.1",
username: "synthetic",
algorithms: { cipher: ["chacha20-poly1305@openssh.com"] },
}));
let captured: Record<string, unknown> | undefined;
const ctx = createStarterContext({
host,
terminalBackend: {
backendAvailable: () => true,
startSSHSession: async (options: Record<string, unknown>) => {
captured = options;
return "ssh-session";
},
onSessionData: () => noop,
onSessionExit: () => noop,
onChainProgress: () => noop,
writeToSession: noop,
resizeSession: noop,
},
});
await createTerminalSessionStarters(ctx as never).startSSH(createTermStub() as never);
assert.deepEqual(captured?.algorithmOverrides, host.algorithms);
});
test("startSSH forwards imported system agent authentication settings", async () => {
let capturedOptions: Record<string, unknown> | null = null;
const terminalBackend = {
@@ -985,13 +985,12 @@ 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");
// 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.
// The legacy workaround selected IME commits and short raw pastes (#3077).
// Host settings now gate that selection at the shared send boundary.
assert.match(source, /perCharacterWrites\?: boolean/);
assert.match(
source,
/getTextInputWireChunks\(outData, options\?\.perCharacterWrites === true\)/,
/getTextInputWireChunks\(outData, usePerCharacterInput && options\?\.perCharacterWrites === true, \(ctx\.host\.protocol === undefined \|\| ctx\.host\.protocol === "ssh"\) && !ctx\.host\.moshEnabled && !ctx\.host\.etEnabled\)/,
);
// Every chunk carries the same write options as the original single write.
const writeLoopIdx = source.indexOf("for (const chunk of getTextInputWireChunks(outData");
@@ -252,6 +252,17 @@ 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>();
// The stable session owner survives xterm disposal while its home renderer sleeps.
export const getTerminalPerCharacterInput = (owner: object): boolean | undefined =>
inputCompatibilityStates.get(owner);
export const restoreTerminalPerCharacterInput = (owner: object, enabled: boolean): void => {
inputCompatibilityStates.set(owner, enabled);
};
const resolveKittyKeyboardModeState = (
ctx: Pick<
@@ -307,8 +318,10 @@ export type XTermRuntime = {
* terminal reset or FIFO eviction.
*/
hasInlineImages: () => boolean;
/** Clear local/per-target keyboard state before reusing this runtime. */
resetKittyConnectionInputState: () => void;
/** Reset keyboard state and snapshot endpoint settings for a fresh connection. */
resetConnectionInputState: (host: Host) => void;
getPerCharacterInput: () => boolean;
restorePerCharacterInput: (enabled: boolean) => void;
/** Emit any owed releases before detaching or closing this renderer. */
flushKittyKeyboardReleases: () => void;
/** Transfer negotiated keyboard state across renderer attach handoffs. */
@@ -515,6 +528,13 @@ 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 }
@@ -1325,10 +1345,9 @@ export const createXTermRuntime = (ctx: CreateXTermRuntimeContext): XTermRuntime
lineDelayMs?: number;
pasteRequestId?: string;
/**
* Send plain text as one write per character. Strict bastion prompts
* (QAX) treat one channel write as a keystroke and drop multi-character
* chunks, so IME commits and short raw pastes must go out per
* character (#3077). Bookkeeping still sees the whole payload once.
* 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.
*/
perCharacterWrites?: boolean;
},
@@ -1550,7 +1569,7 @@ export const createXTermRuntime = (ctx: CreateXTermRuntimeContext): XTermRuntime
}
ctx.onOutputTriggerUserInputRef?.current?.(outData);
for (const chunk of getTextInputWireChunks(outData, options?.perCharacterWrites === true)) {
for (const chunk of getTextInputWireChunks(outData, usePerCharacterInput && options?.perCharacterWrites === true, (ctx.host.protocol === undefined || ctx.host.protocol === "ssh") && !ctx.host.moshEnabled && !ctx.host.etEnabled)) {
ctx.terminalBackend.writeToSession(id, chunk, {
sensitive,
serialEraseChar,
@@ -3140,7 +3159,7 @@ export const createXTermRuntime = (ctx: CreateXTermRuntimeContext): XTermRuntime
broadcastLegacyDataPending = null;
}
// Raw multi-character onData is an unbracketed paste (or a committed
// composition): short plain text must reach strict bastions per character.
// composition): selected short plain text can use compatibility writes.
// Decide on the sanitized payload so removed zero-width characters cannot
// keep a qualifying paste above the split cap (#3138).
const sanitizedRawData = sanitizeTerminalInput(data);
@@ -3479,8 +3498,17 @@ export const createXTermRuntime = (ctx: CreateXTermRuntimeContext): XTermRuntime
ligatures.apply(enabled);
},
hasInlineImages,
resetKittyConnectionInputState: clearKittyConnectionInputState,
resetConnectionInputState: (host) => {
usePerCharacterInput = usesPerCharacterInput(host);
inputCompatibilityStates.set(ctx.terminalSettingsRef, usePerCharacterInput);
clearKittyConnectionInputState();
},
flushKittyKeyboardReleases: clearKittyTransientInputState,
getPerCharacterInput: () => usePerCharacterInput,
restorePerCharacterInput: (enabled) => {
usePerCharacterInput = enabled;
restoreTerminalPerCharacterInput(ctx.terminalSettingsRef, enabled);
},
getKittyKeyboardModeState: () => snapshotKittyKeyboardModeState(kittyKeyboardMode),
restoreKittyKeyboardModeState: (state) => restoreKittyKeyboardModeState(
kittyKeyboardMode,
@@ -27,7 +27,7 @@ for (const prefix of ['fresh','empty-arrow','submit','paste-submit','interrupt',
const session={encoding, serialPort:{write(data: string | Buffer){for(const byte of Buffer.from(data)) { if(byte===127||byte===8) wire=wire.subarray(0,Math.max(0,wire.length-1)); else wire=Buffer.concat([wire,Buffer.from([byte])]);}}}};
bridge.init({sessions:new Map([['s',session]]),electronModule:{}});
const ctx={host:{protocol:'serial',id:'h',label:'h'},sessionId:'s',sessionRef:{current:'s'},statusRef:{current:'connected'},commandBufferRef:{current:''},onBroadcastInputRef:{current:null},isBroadcastEnabledRef:{current:false},terminalBackend:{writeToSession(id:string,data:string,opts: {serialEraseChar?: string; sensitive?: boolean}){bridge.writeToSession({}, {sessionId:id,data,...opts})}},serialByteOrientedBackspace:true,serialLocalEcho:false};
const env={...Object.assign({},...modules),api: undefined as unknown as {handleTerminalInputData: (data: string) => void; recordSerialSnippetInput: (data: string) => void},ctx,term:{buffer:{active:{cursorX:0,cursorY:0,baseY:0,getLine:()=>undefined}},cols:80},suppressNextTerminalDataBroadcast:false,handlingKittyBroadcast:false,shouldBroadcastTerminalUserInput:()=>false,prioritizeTerminalInput:()=>{},getFlowControllerForTerm:()=>null,shouldSuppressTerminalInputScrollForUserPaste:()=>true,shouldOverrideTerminalUserPasteSensitivity:()=>false,scrollToBottomAfterInput:()=>{},writeLocalTerminalData:()=>{}};
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:()=>{}};
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');}
@@ -0,0 +1,171 @@
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();
}
});
@@ -0,0 +1,214 @@
import assert from "node:assert/strict";
import { readFileSync } from "node:fs";
import path from "node:path";
import test from "node:test";
import { fileURLToPath } from "node:url";
import vm from "node:vm";
import { buildSync } from "esbuild";
import { JSDOM } from "jsdom";
import React, { useEffect } from "react";
import { act, create } from "react-test-renderer";
import ts from "typescript";
import type { Host } from "../../../domain/models";
import { resolveTerminalPopupHost } from "../../TerminalPopupPage";
import { useTerminalEffects } from "../useTerminalEffects";
import { isTerminalAlternateScreenActive, readTerminalHibernateContext, resolveTerminalSnapshotCapture } from "../terminalHibernateRuntime";
const root = fileURLToPath(new URL("../../..", import.meta.url));
const terminalSource = readFileSync(path.join(root, "components/Terminal.tsx"), "utf8");
const homeStart = terminalSource.indexOf(" useEffect(() => {", terminalSource.indexOf("// Home renderer for AI observe popups"));
const homeEnd = terminalSource.indexOf(" const cleanupSession =", homeStart);
const homeEffect = ts.transpileModule(terminalSource.slice(homeStart, homeEnd), {
compilerOptions: { target: ts.ScriptTarget.ES2022 },
}).outputText;
const cleanupEnd = terminalSource.indexOf(" const cleanupSessionRef =", homeEnd);
const popupCleanup = ts.transpileModule(`${terminalSource.slice(homeEnd, cleanupEnd)}
globalThis.cleanup = cleanupSession;`, {
compilerOptions: { target: ts.ScriptTarget.ES2022 },
}).outputText;
const noop = () => {};
const ref = <T,>(current: T) => ({ current });
// Actual popup host resolution, home snapshot callbacks, attach boot effect,
// and real xterm writes. Unrelated visual effects are omitted by the hook seam.
test("attached popups retain the active mode across snapshots and return, including hibernated homes", async (t) => {
const bundle = buildSync({
stdin: { contents: 'export * from "./components/terminal/runtime/createXTermRuntime"; export {DEFAULT_TERMINAL_SETTINGS} from "./domain/models/terminal";', 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 });
const keys = ["window", "document", "navigator", "IS_REACT_ACT_ENVIRONMENT"];
const previous = new Map(keys.map(key => [key, Object.getOwnPropertyDescriptor(globalThis, key)]));
for (const [key, value] of Object.entries({ window: dom.window, document: dom.window.document, navigator: dom.window.navigator, IS_REACT_ACT_ENVIRONMENT: true })) {
Object.defineProperty(globalThis, key, { configurable: true, value });
}
Object.assign(dom.window, { structuredClone });
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 runtimeModule = dom.window.eval("runtimeTest");
try {
for (const mode of [false, true]) for (const hibernated of [false, true]) {
await t.test(`connected=${mode}, saved=${!mode}, home hibernated=${hibernated}`, async () => {
const sessionId = `popup-${mode}-${hibernated}`;
const connectedHost: Host = { id: "host", label: "Host", hostname: "127.0.0.1", port: 22379, username: "agent", protocol: "ssh" as const, tags: [], os: "linux" as const, sshInputCompatibility: mode };
const savedHost = { ...connectedHost, sshInputCompatibility: !mode };
const session = { id: sessionId, hostId: "host", hostLabel: "Host", hostname: "127.0.0.1", username: "agent", protocol: "ssh" as const, status: "connected" as const, createdAt: 1 };
const popupHost = resolveTerminalPopupHost({ title: "Host", startupCommand: "", parentSessionId: sessionId, sourceSession: session, attachSessionId: sessionId }, [savedHost]);
assert.equal(popupHost.sshInputCompatibility, !mode, "the popup sees the latest vault preference");
const settings = { ...runtimeModule.DEFAULT_TERMINAL_SETTINGS, rendererType: "dom", fontLigatures: false, cursorBlink: false };
const homeSettings = ref(settings);
const writes: string[] = [];
const makeContext = (host: Host, settingsRef = ref(settings)) => ({
container: document.body.appendChild(document.createElement("div")), host,
fontFamilyId: "jetbrains-mono", resolvedFontFamily: "monospace", fontSize: 14,
terminalTheme: { colors: { background: "#111111", foreground: "#eeeeee", cursor: "#ffffff", selection: "#444444" } },
terminalSettingsRef: settingsRef,
terminalBackend: { writeToSession: (_id: string, data: string) => writes.push(data), resizeSession: noop, openExternalAvailable: false },
sessionRef: ref<string | null>(sessionId), sessionId, statusRef: ref("connected"),
hotkeySchemeRef: ref("disabled"), disableTerminalFontZoomRef: ref(false), keyBindingsRef: ref([]),
onHotkeyActionRef: ref(undefined), isBroadcastEnabledRef: ref(false), onBroadcastInputRef: ref(undefined),
commandBufferRef: ref(""), requestSearchFocus: noop, kittyKeyboardProtocolEnabled: false,
});
const homeContext = makeContext(connectedHost, homeSettings);
const homeRuntime = runtimeModule.createXTermRuntime(homeContext);
const homeRuntimeRef = ref(hibernated ? null : homeRuntime);
const homeTermRef = ref(hibernated ? null : homeRuntime.term);
if (hibernated) homeRuntime.dispose();
let snapshotRequest: (payload: object) => Promise<void>;
let applySnapshot: (payload: object) => Promise<boolean>;
let snapshot: Record<string, unknown> = {};
const bridge = {
onTerminalSessionSnapshotRequest(callback: typeof snapshotRequest) { snapshotRequest = callback; return noop; },
onTerminalSessionApplySnapshot(callback: typeof applySnapshot) { applySnapshot = callback; return noop; },
respondTerminalSessionSnapshot(_request: string, text: string, keyboard: unknown, enabled: boolean, sensitive: boolean, cwd: string, title: string, perCharacterInput: boolean) {
snapshot = { success: true, snapshot: text, kittyKeyboardModeState: keyboard, kittyKeyboardProtocolEnabled: enabled, passwordPromptActive: sensitive, cwd, title, perCharacterInput };
},
};
const homeEnv = {
useEffect: (effect: () => void) => effect(), netcattyBridge: { get: () => bridge },
attachExistingSession: false, sessionId, terminalSettingsRef: homeSettings,
xtermRuntimeRef: homeRuntimeRef, termRef: homeTermRef,
serializeAddonRef: ref(hibernated ? null : homeRuntime.serializeAddon),
hibernatedRef: ref(hibernated), softHiddenRef: ref(false),
hibernateSnapshotRef: ref(""), hibernatePendingBufferRef: ref(""), hibernateViewportSnapshotRef: ref(""), hibernateScrollbackSnapshotRef: ref(""),
hibernateContextSnapshotRef: ref(""), hibernateContextViewportSnapshotRef: ref(""), hibernateContextScrollbackSnapshotRef: ref(""), hibernateAlternateScreenRef: ref(false),
kittyKeyboardProtocolEnabledForSession: false, passwordPromptActiveRef: ref(false), knownCwdRef: ref(null), terminalTitleRef: ref(null), pluginTerminalLifecycleRef: ref(null),
onTerminalCwdChange: undefined, onTerminalTitleChange: undefined, terminalCwdTracker: { setRendererCwd: noop },
flushPendingTerminalWritesBeforeHibernate: async () => true, resetTerminalLineTimestamps: noop,
applyAuthoritativeHibernateSnapshot: noop, logger: { warn: noop },
getTerminalPerCharacterInput: runtimeModule.getTerminalPerCharacterInput,
restoreTerminalPerCharacterInput: runtimeModule.restoreTerminalPerCharacterInput,
};
vm.runInNewContext(homeEffect, homeEnv);
const popupContext = makeContext(popupHost);
popupContext.sessionRef.current = null;
popupContext.statusRef.current = "connecting";
const values = {
...popupContext, host: popupHost, terminalSettings: settings,
containerRef: ref(popupContext.container), xtermRuntimeRef: ref(null), termRef: ref(null), fitAddonRef: ref(null), serializeAddonRef: ref(null), searchAddonRef: ref(null), hasRuntimeRef: ref(false),
attachExistingSession: true, attachAuthorization: "owned-popup", vaultInitialized: true,
CONNECTION_TIMEOUT: 30000, Error, XTERM_PERFORMANCE_CONFIG: {}, effectiveTheme: popupContext.terminalTheme,
effectiveFontSize: 14, effectiveFontWeight: 400, fontWeightFixupDoneRef: ref(false),
isVisible: true, isVisibleRef: ref(true), isFocused: true, inWorkspace: true, isLocalConnection: false, isNetworkDevice: false, isSerialConnection: false,
pluginDecorationRules: [], pluginTerminalLifecycle: new Proxy({}, { get: () => noop }),
onTerminalCwdChange: undefined, onTerminalTitleChange: undefined, keys: [], identities: [], serialConfig: {}, zmodem: {},
connectionLogBufferRef: ref({ reset: noop }), terminalOutputHistory: { clear: noop }, terminalDataCapturedRef: ref(false), hasConnectedRef: ref(false), pendingOutputScrollRef: ref(false), promptLineBreakStateRef: ref(null), terminalLogSanitizerRef: ref(null),
kittyKeyboardProtocolEnabledForSession: false, terminalCwdTracker: { getRendererCwd: () => null, setRendererCwd: noop }, passwordPromptActiveRef: ref(false),
createPromptLineBreakState: () => ({}), createReplaySafeTerminalLogSanitizer: () => ({}),
prepareInitialCwdIntent: noop, prepareRestoredReconnect: noop,
applyUserCursorPreference: noop, applyUserCursorWidthPreference: noop, shouldApplyUserCursorPreference: () => false,
terminalAltKeyOptions: () => ({}), shouldStartTerminalBackend: () => true,
createXTermRuntime: runtimeModule.createXTermRuntime,
terminalBackend: {
...popupContext.terminalBackend,
acquireSessionFlowPauseLease: async () => ({ waitForPause: async () => ({ success: true }), release: noop }),
requestSessionSnapshot: async () => { await snapshotRequest!({ sessionId, requestId: "request" }); return snapshot; },
rebindSessionOutput: async () => ({ success: true, previousWebContentsId: 10 }),
restoreSessionOutput: async () => ({ success: true }),
},
sessionStarters: { reattachSession: (term: { paste: (data: string) => void }) => { term.paste("中文"); return true; } },
setStatus: noop, setProgressLogs: noop, setShowLogs: noop, setIsCancelling: noop, setIsDisconnectedDialogDismissed: noop,
setError: (error: unknown) => { if (error) errors.push(error); },
updateStatus: (status: string) => { popupContext.statusRef.current = status; },
safeFit: noop, logger: { warn: noop, error: (...args: unknown[]) => errors.push(args) },
onPluginRuntimeCwdChange: noop, onOpenExternalError: noop, requestSearchFocus: noop,
requestPluginTerminalProviders: undefined, isPluginTerminalProviderAvailable: () => false,
useEffect: (effect: React.EffectCallback, dependencies: React.DependencyList) => {
useEffect(() => {
if (effect.toString().includes("bootAbort")) return effect();
// The injected hook owns this dependency list; unrelated effects stay inert.
// eslint-disable-next-line react-hooks/exhaustive-deps
}, dependencies);
},
useLayoutEffect: noop,
};
const errors: unknown[] = [];
const defaultRefs = new Map<PropertyKey, ReturnType<typeof ref>>();
const context = new Proxy(values, { get: (target, key) => {
if (key in target) return target[key as keyof typeof target];
if (!defaultRefs.has(key)) defaultRefs.set(key, ref(null));
return defaultRefs.get(key);
} });
const Harness = () => { useTerminalEffects(context); return null; };
let renderer: ReturnType<typeof create>;
await act(async () => { renderer = create(React.createElement(Harness)); });
for (let attempt = 0; popupContext.statusRef.current !== "connected" && attempt < 30 && !errors.length; attempt++) {
await act(async () => { await new Promise(resolve => setTimeout(resolve, 10)); });
}
try {
assert.deepEqual(errors, [], "actual attach boot succeeded");
assert.equal(popupContext.sessionRef.current, sessionId, "attach retains the original SSH backend");
assert.deepEqual(writes.splice(0), mode ? ["中", "文"] : ["中文"], "first attached input must retain the connection mode");
assert.equal(snapshot.perCharacterInput, mode, "home snapshot exports captured mode, including false and no live xterm");
const popupRuntime = values.xtermRuntimeRef.current as typeof homeRuntime;
assert.equal(popupRuntime.getPerCharacterInput(), mode);
const cleanupEnv = {
...homeEnv, attachExistingSession: true, attachAuthorization: "owned-popup",
sessionRef: popupContext.sessionRef, xtermRuntimeRef: values.xtermRuntimeRef,
terminalSettingsRef: popupContext.terminalSettingsRef, termRef: values.termRef,
serializeAddonRef: values.serializeAddonRef, attachHomeWebContentsIdRef: ref(10),
disposeDataRef: ref(null), disposeExitRef: ref(null), disposeTelnetEchoModeRef: ref(null),
telnetLocalEchoRef: ref(false), sessionCleanupPromiseRef: ref(null),
isTerminalAlternateScreenActive, readTerminalHibernateContext, resolveTerminalSnapshotCapture,
releaseTerminalFlowBeforeHibernate: noop,
terminalBackend: {
...values.terminalBackend,
applySessionSnapshot: async (_id: string, text: string, context: { perCharacterInput?: boolean }) => {
assert.equal(context.perCharacterInput, mode, "actual close exports the popup's active choice");
return { success: await applySnapshot!({ sessionId, snapshot: text, ...context }) };
},
},
};
vm.runInNewContext(popupCleanup, cleanupEnv);
await (cleanupEnv as typeof cleanupEnv & { cleanup: () => Promise<void> }).cleanup();
assert.equal(runtimeModule.getTerminalPerCharacterInput(homeSettings), mode, "actual popup close retains the home captured state");
const restoredHome = hibernated ? runtimeModule.createXTermRuntime({ ...homeContext, host: savedHost }) : homeRuntime;
restoredHome.term.paste("中文");
assert.deepEqual(writes.splice(0), mode ? ["中", "文"] : ["中文"], "home input after return and wake retains the active choice");
if (hibernated) restoredHome.dispose();
} finally {
values.hasConnectedRef.current = false;
await act(async () => { renderer!.unmount(); });
homeRuntime.dispose();
}
});
}
} finally {
dom.window.close();
for (const key of keys) { const descriptor = previous.get(key); if (descriptor) Object.defineProperty(globalThis, key, descriptor); else Reflect.deleteProperty(globalThis, key); }
}
});
@@ -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, options\?\.perCharacterWrites === true\)\) \{\s*ctx\.terminalBackend\.writeToSession\(id, chunk, \{\s*sensitive,\s*serialEraseChar,[\s\S]*?\}\);\s*\}/u,
/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,
);
assert.match(
runtimeSource,
@@ -127,6 +127,7 @@ 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,
@@ -341,6 +342,7 @@ 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,4 +66,20 @@ 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,13 +1,16 @@
/**
* 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.
* 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.
*/
/** 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. */
@@ -16,7 +19,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 glyph. */
/** IME commits: any plain text with more than one character splits per code point. */
export const shouldSplitImeTextInputForWire = (text: string): boolean =>
Array.from(text).length > 1 && isPlainTerminalInputText(text);
@@ -35,11 +38,13 @@ export const shouldSplitRawPasteInputForWire = (data: string): boolean => {
return true;
};
/** Chunks to write for one input payload; escape sequences are never split. */
/** Escape sequences never split. SSH callers also preserve terminal controls. */
export const getTextInputWireChunks = (
data: string,
perCharacterWrites: boolean,
preserveTerminalControls = false,
): string[] =>
perCharacterWrites && isPlainTerminalInputText(data)
&& !(preserveTerminalControls && TERMINAL_CONTROL.test(data))
? splitTextIntoCodePointWrites(data)
: [data];
@@ -716,6 +716,11 @@ export function useTerminalEffects(ctx: TerminalEffectsContext) {
if (!snap?.success) {
throw new Error(snap?.error || "Failed to capture terminal snapshot");
}
// Restore the active connection policy before the attached session
// accepts input. Saved host changes still require a reconnect.
if (typeof snap.perCharacterInput === "boolean") {
runtime.restorePerCharacterInput(snap.perCharacterInput);
}
if (typeof snap.kittyKeyboardProtocolEnabled === "boolean") {
runtime.setKittyKeyboardProtocolEnabled(snap.kittyKeyboardProtocolEnabled);
}
+83
View File
@@ -0,0 +1,83 @@
# 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.
Attaching an observe popup transfers this captured choice through the existing terminal snapshot IPC. The popup restores it before attaching input to the live backend. Closing the popup returns it with the display snapshot. A hibernated home reads and restores the same session-owned state without consulting the current Vault preference. These handoffs do not reconnect or apply a newly saved choice.
## 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.
`sshInputCompatibility.popup.test.ts` runs the actual popup host resolver, home snapshot callbacks, attach boot effect, popup close callback, and real xterm paste handlers. Both saved-setting directions are covered with a live or hibernated home, including input after returning and waking. `terminalBridge.rebindOutput.test.cjs` exercises the preload and authorized main-process snapshot round trip, preserving explicit `false` and rejecting invalid metadata. These automated popup checks do not constitute native multi-window or OS IME validation.
`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 original feature was merged in [PR #3977](https://github.com/binaricat/Netcatty/pull/3977); those results predate the popup snapshot follow-up.
+12
View File
@@ -793,3 +793,15 @@ 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,6 +466,11 @@ 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,6 +189,9 @@ 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,3 +820,29 @@ 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,6 +204,7 @@ 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'),
@@ -242,6 +243,7 @@ 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,6 +68,7 @@ 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,6 +16,9 @@
// 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([
@@ -23,6 +26,7 @@ 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,6 +16,9 @@
// 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([
@@ -23,6 +26,7 @@ const SINGLE_CHANNEL_SOFTWARE_TOKENS = Object.freeze([
"TERM-SSHD",
"CLOUDBILITY",
"CLOUDLINKER",
"russh",
]);
// #3146 also drops multi-character writes. That evidence is only for these
+13 -1
View File
@@ -20,6 +20,10 @@ 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);
}
@@ -49,6 +53,12 @@ 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));
}
@@ -59,6 +69,8 @@ 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);
});
@@ -82,7 +94,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-Go", "OpenSSH_9.6", "JumpServer", "",
"BHostSSH_7.0", "TERM-SSHD", "CLOUDBILITY-4.14", "CloudLinker", "SSH-2.0-russh_0.45.0", "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,6 +2,7 @@ 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,
@@ -242,8 +243,10 @@ 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", () => {
@@ -331,3 +334,13 @@ 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,6 +165,7 @@ export const buildSshDeepLinkHostDraft = (
tags: [],
os: "linux",
protocol: "ssh",
sshInputCompatibility: false,
createdAt: options.now,
});
+13
View File
@@ -675,3 +675,16 @@ 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,3 +1956,10 @@ 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,6 +329,7 @@ export function buildVaultHostFromDraft(
os,
osOverride: parsedOs ?? 'auto',
protocol,
sshInputCompatibility: false,
createdAt: now,
...(keyPath
? {
+5
View File
@@ -665,6 +665,7 @@ 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"]);
@@ -795,6 +796,10 @@ 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,3 +842,16 @@ 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]);
});
+3 -2
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"];
const header = ["Groups", "Label", "Tags", "Notes", "Hostname/IP", "Protocol", "Port", "Username", "Password", "KeyPath", "Passphrase", "Proxy", "ProxyJump", "LocalShell", "SshInputCompatibility"];
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"];
const header = ["Groups", "Label", "Tags", "Notes", "Hostname/IP", "Protocol", "Port", "Username", "Password", "KeyPath", "Passphrase", "Proxy", "ProxyJump", "LocalShell", "SshInputCompatibility"];
const rows: string[][] = [header];
let unreadableProxyCredentialCount = 0;
@@ -217,6 +217,7 @@ const exportHostsToCsv = (hosts: Host[], options: VaultCsvExportOptions) => {
encodeCsvProxy(proxyValue),
encodeCsvProxyJump(proxyJumpValue),
localShellSpec ? encodeCsvLocalShell(localShellSpec) : "",
host.protocol === undefined || host.protocol === "ssh" ? String(host.sshInputCompatibility ?? true) : "",
]);
}
+13 -2
View File
@@ -68,7 +68,8 @@ const PROMPT_SUFFIX_WINDOW_PER_PROMPT_CHAR = 8;
function hasExpectedPromptSuffix(text, expectedPrompt) {
if (!expectedPrompt) return false;
const normalizedPrompt = stripAnsi(String(expectedPrompt || "")).replace(/\r/g, "");
const ansiFreePrompt = stripAnsi(String(expectedPrompt || ""));
const normalizedPrompt = ansiFreePrompt.replace(/\r/g, "");
if (!normalizedPrompt) return false;
const fullText = String(text || "");
const windowChars = Math.max(
@@ -76,7 +77,17 @@ function hasExpectedPromptSuffix(text, expectedPrompt) {
String(expectedPrompt).length * PROMPT_SUFFIX_WINDOW_PER_PROMPT_CHAR,
);
const tail = fullText.length > windowChars ? fullText.slice(-windowChars) : fullText;
return stripAnsi(tail).replace(/\r/g, "").endsWith(normalizedPrompt);
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);
}
function escapePosixSingleQuoted(text) {
@@ -3,7 +3,7 @@
const test = require("node:test");
const assert = require("node:assert/strict");
const { hasExpectedPromptSuffix } = require("./ptyExecHelpers.cjs");
const { stripAnsi } = require("./shellUtils.cjs");
const { extractTrailingIdlePrompt, stripAnsi } = require("./shellUtils.cjs");
// The behaviour before the tail window was introduced: normalise the whole
// text, then compare its end.
@@ -41,6 +41,38 @@ 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.
+35 -3
View File
@@ -69,6 +69,11 @@ 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.
@@ -84,7 +89,14 @@ function isDefaultCmdPromptLine(line) {
function isDefaultPosixPromptLine(line) {
const normalized = String(line || "").replace(/\s+$/, "");
return POSIX_PROMPT_PATTERN.test(normalized) || ALPINE_PROMPT_PATTERN.test(normalized);
// 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)
);
}
function extractTrailingIdlePrompt(output) {
@@ -95,7 +107,8 @@ function extractTrailingIdlePrompt(output) {
const normalized = stripAnsi(output).replace(/\r/g, "\n");
if (!normalized || normalized.endsWith("\n")) return "";
const lastLine = normalized.split("\n").pop() || "";
const lines = normalized.split("\n");
const lastLine = lines.pop() || "";
const rightTrimmed = lastLine.replace(/\s+$/, "");
if (!rightTrimmed) return "";
@@ -111,6 +124,24 @@ 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 "";
}
@@ -171,7 +202,8 @@ 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][#$]`, RHEL `[user@host path][#$]`, or Alpine `host:path[#$]`);
// `user@host[:path][#$]`, two-line `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,6 +219,97 @@ 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,6 +836,105 @@ 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 });
+2 -1
View File
@@ -9,7 +9,8 @@ const FIXED_DH_GROUP_BY_KEX = Object.freeze({
});
const CIPHER_SSL_NAME_BY_ALGORITHM = Object.freeze({
"chacha20-poly1305@openssh.com": "chacha20",
// Patched ssh2 supplies original ChaCha20 when Electron/BoringSSL lacks it.
// Do not filter this SSH cipher against the OpenSSL-only inventory.
"aes128-gcm@openssh.com": "aes-128-gcm",
"aes256-gcm@openssh.com": "aes-256-gcm",
"aes128-gcm": "aes-128-gcm",
+7 -4
View File
@@ -144,17 +144,17 @@ for (const [label, buildAlgorithms] of [
});
});
test(`${label} drops chacha20-poly1305 when the runtime lacks chacha20`, () => {
test(`${label} retains chacha20-poly1305 through ssh2's fallback when the runtime lacks chacha20`, () => {
const ciphersWithoutChacha20 = crypto.getCiphers().filter((cipher) => cipher !== "chacha20");
withAlgorithmRuntime({ ciphers: ciphersWithoutChacha20 }, () => {
const algorithms = buildAlgorithms(false);
assert.equal(algorithms.cipher.includes("chacha20-poly1305@openssh.com"), false);
assert.equal(algorithms.cipher.includes("chacha20-poly1305@openssh.com"), true);
});
});
test(`${label} cipher override is filtered against runtime support`, () => {
test(`${label} preserves explicit ChaCha20 and mixed cipher overrides without native crypto`, () => {
const ciphersWithoutChacha20 = crypto.getCiphers().filter((cipher) => cipher !== "chacha20");
withAlgorithmRuntime({ ciphers: ciphersWithoutChacha20 }, () => {
@@ -164,7 +164,10 @@ for (const [label, buildAlgorithms] of [
},
});
assert.deepEqual(algorithms.cipher, ["aes256-ctr"]);
assert.deepEqual(algorithms.cipher, ["chacha20-poly1305@openssh.com", "aes256-ctr"]);
assert.deepEqual(buildAlgorithms(false, {
algorithmOverrides: { cipher: ["chacha20-poly1305@openssh.com"] },
}).cipher, ["chacha20-poly1305@openssh.com"]);
});
});
@@ -0,0 +1,140 @@
const test = require("node:test");
const assert = require("node:assert/strict");
const crypto = require("node:crypto");
const fs = require("node:fs");
const os = require("node:os");
const path = require("node:path");
const { spawn } = require("node:child_process");
const { Server } = require("ssh2");
const CHACHA = "chacha20-poly1305@openssh.com";
const AES = "aes128-gcm@openssh.com";
const root = path.resolve(__dirname, "../..");
const { privateKey } = crypto.generateKeyPairSync("rsa", { modulusLength: 2048 });
const hostKey = privateKey.export({ type: "pkcs1", format: "pem" });
async function connect(t, {
serverCiphers = [CHACHA],
cipher,
entrypoint = "netcatty:start",
withoutNativeChacha = true,
} = {}) {
const homeDir = fs.mkdtempSync(path.join(os.tmpdir(), "netcatty-chacha-"));
t.after(() => fs.rmSync(homeDir, { recursive: true, force: true }));
const offered = {};
let negotiated;
const connections = new Set();
// The server uses Node/OpenSSL's ChaCha20; only the child uses the fallback.
const server = new Server({
hostKeys: [hostKey],
algorithms: { cipher: serverCiphers },
debug(line) {
const match = /Handshake: \(remote\) (C->S|S->C) cipher: (.*)/.exec(line);
if (match) offered[match[1]] = match[2].split(",");
},
}, (client) => {
connections.add(client);
client.on("error", () => {});
client.on("close", () => connections.delete(client));
client.on("handshake", (info) => { negotiated = info; });
client.on("authentication", (ctx) => ctx.accept());
client.on("ready", () => {
client.on("session", (accept) => {
const session = accept();
session.on("pty", (acceptPty) => acceptPty?.());
session.on("env", (acceptEnv) => acceptEnv?.());
session.on("shell", (acceptShell) => {
const stream = acceptShell();
stream.on("data", (data) => stream.write(data));
});
});
});
});
t.after(() => {
for (const client of connections) client.end();
server.close();
});
await new Promise((resolve, reject) => {
server.once("error", reject);
server.listen(0, "127.0.0.1", resolve);
});
const runtime = process.env.NETCATTY_SSH_TEST_RUNTIME === "electron"
? require("electron")
: path.resolve(process.env.NETCATTY_SSH_TEST_RUNTIME || process.execPath);
const child = spawn(runtime, ["scripts/fixtures/ssh-chacha-client.cjs"], {
cwd: root,
env: { ...process.env, ELECTRON_RUN_AS_NODE: "1" },
stdio: ["pipe", "pipe", "pipe"],
});
t.after(() => child.kill());
let stdout = "";
let stderr = "";
child.stdout.on("data", (data) => { stdout += data; });
child.stderr.on("data", (data) => { stderr += data; });
child.stdin.end(JSON.stringify({
port: server.address().port,
appRoot: process.env.NETCATTY_SSH_TEST_APP_ROOT,
homeDir,
// Cross several SSH packets, exercising both nonce/counter directions.
input: "synthetic SSH echo\r\n".repeat(4000),
algorithmOverrides: cipher ? { cipher } : undefined,
entrypoint,
withoutNativeChacha,
}));
const code = await new Promise((resolve, reject) => {
child.once("error", reject);
child.once("close", resolve);
});
assert.equal(code, 0, stderr || stdout);
const resultLine = stdout.split("\n").find((line) => line.startsWith("NETCATTY_TRANSPORT_RESULT="));
assert.ok(resultLine, stderr || stdout);
const result = JSON.parse(resultLine.slice("NETCATTY_TRANSPORT_RESULT=".length));
if (process.env.NETCATTY_SSH_TEST_APP_ROOT && result.connected) {
assert.equal(result.packagedDependency, "node_modules/@noble/ciphers/chacha.js");
}
return { ...result, offered, negotiated };
}
for (const entrypoint of ["netcatty:start", "netcatty:test-connection"]) {
test(`${entrypoint} advertises and negotiates an explicit ChaCha20-only override without native crypto`, { timeout: 15000 }, async (t) => {
const result = await connect(t, { cipher: [CHACHA], entrypoint });
assert.equal(result.connected, true, result.error);
assert.equal(result.nativeChacha, false);
assert.deepEqual(result.offered["C->S"], [CHACHA]);
assert.deepEqual(result.offered["S->C"], [CHACHA]);
assert.equal(result.negotiated.cs.cipher, CHACHA);
assert.equal(result.negotiated.sc.cipher, CHACHA);
if (entrypoint === "netcatty:start") assert.equal(result.echoed, true);
});
}
test("default ciphers can connect to a ChaCha20-only server", async (t) => {
const result = await connect(t);
assert.equal(result.connected, true, result.error);
assert.equal(result.echoed, true);
assert.ok(result.offered["C->S"].includes(CHACHA));
assert.equal(result.negotiated.cs.cipher, CHACHA);
});
test("default and mixed offers retain AES preference and explicit cipher order", async (t) => {
for (const cipher of [undefined, [AES, CHACHA], [CHACHA, AES]]) {
const result = await connect(t, { serverCiphers: [AES, CHACHA], cipher });
assert.equal(result.connected, true, result.error);
assert.equal(result.echoed, true);
assert.equal(result.negotiated.cs.cipher, cipher?.[0] || AES);
if (cipher) assert.deepEqual(result.offered["C->S"], cipher);
}
});
test("an explicit AES-only choice never falls back to ChaCha20", async (t) => {
const result = await connect(t, { cipher: [AES] });
assert.equal(result.connected, false);
assert.match(result.error, /no matching C->S cipher/);
assert.deepEqual(result.offered["C->S"], [AES]);
});
test("the runtime's unmodified crypto path still supports ChaCha20", async (t) => {
const result = await connect(t, { cipher: [CHACHA], withoutNativeChacha: false });
assert.equal(result.connected, true, result.error);
assert.equal(result.echoed, true);
});
+7
View File
@@ -257,6 +257,9 @@ function handleTerminalSessionSnapshotResponse(event, payload) {
pending.resolve({
success: true,
snapshot: typeof payload?.snapshot === "string" ? payload.snapshot : "",
perCharacterInput: typeof payload?.perCharacterInput === "boolean"
? payload.perCharacterInput
: undefined,
kittyKeyboardModeState: normalizeKittyKeyboardModeState(payload?.kittyKeyboardModeState),
kittyKeyboardProtocolEnabled: typeof payload?.kittyKeyboardProtocolEnabled === "boolean"
? payload.kittyKeyboardProtocolEnabled
@@ -316,6 +319,9 @@ function applyTerminalSessionSnapshot(event, payload, terminalWorkerManager = nu
const contextScrollbackSnapshot = hasContextScrollbackSnapshot ? payload.contextScrollbackSnapshot : "";
const hasAlternateScreen = typeof payload?.alternateScreen === "boolean";
const alternateScreen = payload?.alternateScreen === true;
const perCharacterInput = typeof payload?.perCharacterInput === "boolean"
? payload.perCharacterInput
: undefined;
const kittyKeyboardModeState = normalizeKittyKeyboardModeState(payload?.kittyKeyboardModeState);
const kittyKeyboardProtocolEnabled = typeof payload?.kittyKeyboardProtocolEnabled === "boolean"
? payload.kittyKeyboardProtocolEnabled
@@ -368,6 +374,7 @@ function applyTerminalSessionSnapshot(event, payload, terminalWorkerManager = nu
contextViewportSnapshot,
contextScrollbackSnapshot,
alternateScreen,
perCharacterInput,
kittyKeyboardModeState,
kittyKeyboardProtocolEnabled,
passwordPromptActive,
@@ -667,3 +667,65 @@ test("attach snapshots preserve cwd and title updates including explicit clears"
/payload\.cwd !== undefined[\s\S]*?setRendererCwd\(payload\.cwd,\s*"snapshot"\)[\s\S]*?payload\.title !== undefined/,
);
});
test("active input policy roundtrips through preload and authorized snapshot IPC", async () => {
const { EventEmitter } = require("node:events");
const { createPreloadApi } = require("../preload/api.cjs");
const { bridge, fakeChannel } = loadTerminalBridgeWithMocks();
const handles = new Map();
const listeners = new Map();
const homeIpc = new EventEmitter();
const home = { id: 71, isDestroyed: () => false, send: (channel, payload) => homeIpc.emit(channel, {}, payload) };
const popup = { id: 72, isDestroyed: () => false };
const sessionId = "input-policy-snapshot";
const authorization = "input-policy-popup";
bridge.init({
sessions: new Map([[sessionId, { webContentsId: home.id }]]),
electronModule: { webContents: { fromId: (id) => id === home.id ? home : popup } },
terminalOutputChannel: fakeChannel,
});
bridge.registerHandlers({
handle: (channel, handler) => handles.set(channel, handler),
on: (channel, handler) => listeners.set(channel, handler),
}, { terminalWorkerManager: { getAttachHomeWebContentsId: () => home.id } });
const registry = require("./terminalAttachRestore.cjs");
registry.registerAttachPopupAuthorization(authorization, sessionId, popup.id);
homeIpc.send = (channel, payload) => listeners.get(channel)({ sender: home }, payload);
const homeApi = createPreloadApi({ ipcRenderer: homeIpc, webUtils: {} });
const invoke = (channel, payload) => {
const originalRequire = Module.prototype.require;
Module.prototype.require = function(request) {
if (request === "./windowManager.cjs") return { getMainWindows: () => [{ webContents: home }] };
return originalRequire.apply(this, arguments);
};
try { return handles.get(channel)({ sender: popup }, payload); }
finally { Module.prototype.require = originalRequire; }
};
const popupApi = createPreloadApi({ ipcRenderer: { invoke }, webUtils: {} });
let mode;
let received;
const unsubscribeRequest = homeApi.onTerminalSessionSnapshotRequest(({ requestId }) => {
homeApi.respondTerminalSessionSnapshot(requestId, "screen", undefined, false, false, null, null, mode);
});
const unsubscribeApply = homeApi.onTerminalSessionApplySnapshot((payload) => { received = payload; return true; });
try {
for (mode of [false, true, undefined, "false"]) {
const expected = typeof mode === "boolean" ? mode : undefined;
const snapshot = await popupApi.requestTerminalSessionSnapshot(sessionId, authorization);
assert.equal(snapshot.success, true);
assert.equal(snapshot.perCharacterInput, expected, "snapshot keeps explicit false and drops invalid metadata");
const applied = await popupApi.applyTerminalSessionSnapshot(sessionId, "screen", {
contextSnapshot: "", contextViewportSnapshot: "", contextScrollbackSnapshot: "", alternateScreen: false,
perCharacterInput: mode,
}, authorization);
assert.equal(applied.success, true);
assert.equal(received.perCharacterInput, expected, "return snapshot carries the same active choice");
}
assert.equal((await popupApi.requestTerminalSessionSnapshot(sessionId, "wrong-grant")).success, false);
assert.equal((await popupApi.applyTerminalSessionSnapshot(sessionId, "", { perCharacterInput: false }, "wrong-grant")).success, false);
} finally {
unsubscribeRequest();
unsubscribeApply();
registry.releaseAttachPopupAuthorization(authorization);
}
});
+78 -6
View File
@@ -2474,16 +2474,68 @@ async function acquireSessionFastDownloadSlot(client, transfer) {
}
}
// 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.
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.
async function withSequentialSftpTransferSlot(client, transfer, operation) {
if (isScpModeClient(client)) return operation();
await requireSftpChannel(client);
const channel = client.sftp;
if (getSftpTransferConcurrency(channel, DOWNLOAD_TRANSFER_CONCURRENCY) !== 1) {
if (transfer?._exclusiveSftpTransactionChannel === channel) {
return operation();
}
if (!requiresExclusiveSftpTransferChannel(client, channel)) {
return operation();
}
await acquireSessionFastDownloadSlot(channel, transfer);
@@ -6625,6 +6677,7 @@ 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
@@ -6752,6 +6805,16 @@ 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') {
@@ -7380,6 +7443,13 @@ 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"
@@ -7465,6 +7535,8 @@ async function startTransferNow(event, payload, onProgress) {
sendError(err);
}
return { transferId, error: err.message };
} finally {
exclusiveSftpUploadTransaction?.release();
}
}
+110 -17
View File
@@ -233,7 +233,8 @@ async function createSequentialTransferFixture(t) {
return {
payload, files, sftp, handles, clients,
rejectedOpens: () => rejectedOpens,
async start(direction, sftpId = "first", options = {}) {
openCount: () => opens,
async start(direction, sftpId = "first", options = {}, beforeTransfer = null) {
const transferId = options.transferId || crypto.randomUUID();
const localPath = tempDirBridge.getTempFilePath(`term-concurrent-${transferId}.bin`);
paths.push(localPath);
@@ -249,6 +250,7 @@ async function createSequentialTransferFixture(t) {
const remoteStage = `/.${path.basename(targetPath)}.netcatty-${transferId}.part`;
files.set(remoteStage, payload.subarray(0, options.uploadCheckpointBytes || 0));
}
if (beforeTransfer) await beforeTransfer;
const result = await transferBridge.startTransfer({ sender: { send() {} } }, {
transferId,
sourcePath: direction === "upload" ? localPath : "/source.bin", targetPath,
@@ -263,6 +265,61 @@ 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);
@@ -311,22 +368,58 @@ test("TERM-SSHD queued cancellation releases its turn without closing the owner
assert.equal(transferBridge._getSessionFastDownloadWaiterCountForTests(fixture.sftp), 0);
});
test("TERM-SSHD failed transfer releases the physical channel for the next alias", async (t) => {
const fixture = await createSequentialTransferFixture(t);
const originalOpen = fixture.sftp.open;
let rejectFirst = true;
fixture.sftp.open = (...args) => {
if (rejectFirst) {
rejectFirst = false;
args.at(-1)(Object.assign(new Error("Permission denied"), { code: 3 }));
} else originalOpen(...args);
};
const [first, next] = await Promise.all([fixture.start("download"), fixture.start("download", "alias")]);
assert.match(first.result.error || "", /Permission denied/);
assert.equal(next.result.error, undefined);
assert.deepEqual(await fs.readFile(next.targetPath), fixture.payload);
assert.equal(fixture.rejectedOpens(), 0);
});
for (const firstArrival of ["first", "alias"]) {
test(`TERM-SSHD failed transfer releases the physical channel for the next alias (${firstArrival} arrives first)`, { timeout: 5_000 }, async (t) => {
const fixture = await createSequentialTransferFixture(t);
const originalOpen = fixture.sftp.open;
let signalFirstOpen;
let rejectFirstOpen;
let firstOpenPending = true;
const firstOpened = new Promise((resolve) => { signalFirstOpen = resolve; });
fixture.sftp.open = (...args) => {
if (firstOpenPending) {
firstOpenPending = false;
rejectFirstOpen = () => args.at(-1)(Object.assign(new Error("Permission denied"), { code: 3 }));
signalFirstOpen();
} else originalOpen(...args);
};
let releaseDelayed;
const delayed = new Promise((resolve) => { releaseDelayed = resolve; });
// Gate fixture admission to exercise both physical OPEN arrival orders
// while keeping calls-array order fixed. Local I/O can reorder real calls.
const calls = [
fixture.start("download", "first", {}, firstArrival === "alias" ? delayed : null),
fixture.start("download", "alias", {}, firstArrival === "first" ? delayed : null),
];
const settled = Promise.allSettled(calls);
const failedIndex = firstArrival === "first" ? 0 : 1;
let transfers;
try {
await Promise.race([firstOpened, calls[failedIndex].then(() => {
throw new Error("Transfer finished before the controlled OPEN");
})]);
releaseDelayed();
const deadline = Date.now() + 1_000;
while (transferBridge._getSessionFastDownloadWaiterCountForTests(fixture.sftp) === 0 && Date.now() < deadline) {
await new Promise((resolve) => setImmediate(resolve));
}
assert.equal(transferBridge._getSessionFastDownloadWaiterCountForTests(fixture.sftp), 1,
"the other alias must queue before the first OPEN fails");
} finally {
releaseDelayed();
rejectFirstOpen?.();
transfers = await settled;
}
for (const transfer of transfers) assert.equal(transfer.status, "fulfilled");
const failed = transfers[failedIndex].value;
const succeeded = transfers[1 - failedIndex].value;
assert.match(failed.result.error || "", /Permission denied/);
assert.equal(succeeded.result.error, undefined);
assert.deepEqual(await fs.readFile(succeeded.targetPath), fixture.payload);
assert.equal(fixture.rejectedOpens(), 0);
assert.equal(transferBridge._getSessionFastDownloadWaiterCountForTests(fixture.sftp), 0);
});
}
for (const direction of ["download", "upload"]) {
+33 -6
View File
@@ -138,6 +138,7 @@ function createZmodemSentry(opts) {
let pendingTerminalSuppressionAt = 0;
let pendingHexHeaderFragment = null;
let pendingHexHeaderFragmentTimer = null;
let emittedHexHeaderPrefixBytes = 0;
// Set when a stripped hex header's trailing XON (0x11) may arrive in the
// next terminal-output chunk; at most one byte is then dropped.
let suppressNextTerminalXon = false;
@@ -172,6 +173,11 @@ function createZmodemSentry(opts) {
const HEX_HEADER_FRAGMENT_TTL_MS = Number.isFinite(opts.hexHeaderFragmentTtlMs)
? Math.max(1, opts.hexHeaderFragmentTtlMs)
: 1000;
// A bare "*" or "**" is also ordinary shell echo (#3959). Give this
// ambiguous printable prefix at most one frame to complete, rather than
// holding every typed/pasted star for the full protocol timeout. Once
// ZDLE arrives, retain the longer timeout for fragmented protocol bytes.
const PRINTABLE_HEX_PREFIX_TTL_MS = Math.min(16, HEX_HEADER_FRAGMENT_TTL_MS);
/**
* Upper bound on how long a drag-drop echo suppression may stay armed.
* The suppression hides the terminal echo of the upload command, whose echo
@@ -308,6 +314,10 @@ function createZmodemSentry(opts) {
buf.subarray(0, prefixAt),
buf.subarray(prefixAt + headerLength),
]);
// Printable prefix bytes may already have been shown after the short
// echo timeout. If they belonged to this header, do not skip bytes
// from the ordinary output that follows the stripped header.
emittedHexHeaderPrefixBytes = Math.min(emittedHexHeaderPrefixBytes, prefixAt);
searchFrom = prefixAt;
}
@@ -317,8 +327,17 @@ function createZmodemSentry(opts) {
function flushPendingHexHeaderFragment() {
pendingHexHeaderFragmentTimer = null;
const buf = pendingHexHeaderFragment;
pendingHexHeaderFragment = null;
if (buf?.length) onData(buf);
if (!buf?.length) return;
const output = buf.subarray(emittedHexHeaderPrefixBytes);
if (buf.every((byte) => byte === 0x2a)) {
// Keep at most two printable bytes as parsing context: a later ZDLE
// must still be recognized and stripped, without replaying the stars.
emittedHexHeaderPrefixBytes = buf.length;
} else {
pendingHexHeaderFragment = null;
emittedHexHeaderPrefixBytes = 0;
}
if (output.length) onData(output);
}
/**
@@ -412,19 +431,27 @@ function createZmodemSentry(opts) {
const buf = Buffer.isBuffer(data) ? data : Buffer.from(data);
const fragmentAt = findTrailingHexHeaderFragment(buf);
if (fragmentAt === -1) return buf;
const alreadyEmitted = emittedHexHeaderPrefixBytes;
emittedHexHeaderPrefixBytes = fragmentAt === -1
? 0
: Math.max(0, alreadyEmitted - fragmentAt);
if (fragmentAt === -1) return buf.subarray(alreadyEmitted);
if (fragmentAt < buf.length) {
pendingHexHeaderFragment = Buffer.from(buf.subarray(fragmentAt));
if (!pendingHexHeaderFragmentTimer) {
if (!pendingHexHeaderFragmentTimer
&& pendingHexHeaderFragment.length > emittedHexHeaderPrefixBytes) {
const ttl = pendingHexHeaderFragment.every((byte) => byte === 0x2a)
? PRINTABLE_HEX_PREFIX_TTL_MS
: HEX_HEADER_FRAGMENT_TTL_MS;
pendingHexHeaderFragmentTimer = setTimeout(
flushPendingHexHeaderFragment,
HEX_HEADER_FRAGMENT_TTL_MS,
ttl,
);
}
}
return fragmentAt ? buf.subarray(0, fragmentAt) : Buffer.alloc(0);
return buf.subarray(Math.min(alreadyEmitted, fragmentAt), fragmentAt);
}
function looksLikeResidualZmodemData(data) {
+87
View File
@@ -1814,6 +1814,93 @@ function assertNoZmodemHeaderBytes(terminal, context) {
);
}
test("ordinary star echoes wait at most one frame for a possible ZMODEM prefix", (t) => {
t.mock.timers.enable({ apis: ["setTimeout"] });
for (const text of ["*", "**", "ls *", "**/*.txt*"]) {
const { sentry, terminal } = createProbeSentry({ hexHeaderFragmentTtlMs: undefined });
sentry.consume(Buffer.from(text));
t.mock.timers.tick(16);
assert.equal(joinedText(terminal), text, `echo delayed: ${text}`);
t.mock.timers.tick(1000);
assert.equal(joinedText(terminal), text, "the fragment must not be emitted twice");
}
});
test("a following parenthesis and repeated stars preserve echo order", (t) => {
t.mock.timers.enable({ apis: ["setTimeout"] });
const { sentry, terminal } = createProbeSentry({ hexHeaderFragmentTtlMs: undefined });
for (const text of ["*", "(", "*", "*", "*", "!"]) {
sentry.consume(Buffer.from(text));
t.mock.timers.tick(16);
}
assert.equal(joinedText(terminal), "*(***!");
t.mock.timers.tick(1000);
assert.equal(joinedText(terminal), "*(***!");
});
test("expired printable prefixes preserve ordinary bytes across chunk boundaries", (t) => {
t.mock.timers.enable({ apis: ["setTimeout"] });
const input = Buffer.from("a***(b)**/*.txt *\x18X **\x18Bxy more *\uFF0A");
for (const gap of [0, 16, 200]) {
for (let chunkSize = 1; chunkSize <= input.length; chunkSize += 1) {
const { sentry, terminal } = createProbeSentry({ hexHeaderFragmentTtlMs: undefined });
for (let i = 0; i < input.length; i += chunkSize) {
sentry.consume(input.subarray(i, i + chunkSize));
t.mock.timers.tick(gap);
}
t.mock.timers.tick(1000);
assert.deepEqual(Buffer.concat(terminal), input, `chunkSize=${chunkSize}, gap=${gap}`);
}
}
});
test("a prefix containing ZDLE keeps the full fragmented-header timeout", (t) => {
t.mock.timers.enable({ apis: ["setTimeout"] });
const { sentry, terminal } = createProbeSentry({ hexHeaderFragmentTtlMs: undefined });
const header = buildHexHeaderBytes("ZRQINIT");
sentry.consume(header.subarray(0, 3));
t.mock.timers.tick(200);
assert.equal(joinedText(terminal), "");
sentry.consume(header.subarray(3));
t.mock.timers.tick(1000);
assert.equal(joinedText(terminal), "");
assertNoZmodemHeaderBytes(terminal, "delayed ZDLE prefix");
});
test("a slow split header after printable-prefix expiry still detects and hides control bytes", async (t) => {
t.mock.timers.enable({ apis: ["setTimeout"] });
for (const prefixLength of [1, 2]) {
let detected = false;
const { sentry, terminal } = createProbeSentry({
hexHeaderFragmentTtlMs: undefined,
selectDownloadDirectory: async () => { detected = true; return { canceled: true }; },
});
const header = buildHexHeaderBytes("ZRQINIT");
sentry.consume(header.subarray(0, prefixLength));
t.mock.timers.tick(200);
assert.equal(joinedText(terminal), "*".repeat(prefixLength));
sentry.consume(header.subarray(prefixLength, 3));
t.mock.timers.tick(200);
sentry.consume(header.subarray(3));
await Promise.resolve();
assert.equal(detected, true, "the printable echo timeout must not reset protocol detection");
assert.equal(joinedText(terminal), "*".repeat(prefixLength));
assertNoZmodemHeaderBytes(terminal, "slow split after printable prefix");
}
});
test("stripping a slow header does not skip ordinary output after it", (t) => {
t.mock.timers.enable({ apis: ["setTimeout"] });
const { sentry, terminal } = createProbeSentry({ hexHeaderFragmentTtlMs: undefined });
const header = buildHexHeaderBytes("ZRQINIT");
sentry.consume(header.subarray(0, 2));
t.mock.timers.tick(200);
sentry.consume(Buffer.concat([header.subarray(2), Buffer.from("* next prompt")]));
t.mock.timers.tick(1000);
assert.equal(joinedText(terminal), "*** next prompt");
assertNoZmodemHeaderBytes(terminal, "ordinary output after slow header");
});
test("fragmented sz ZRQINIT preamble never reaches the terminal", () => {
const { sentry, terminal } = createProbeSentry();
const stream = Buffer.concat([
+5
View File
@@ -611,10 +611,12 @@ function createPreloadApi(ctx) {
passwordPromptActive,
cwd,
title,
perCharacterInput,
) => {
ipcRenderer.send("netcatty:terminal:snapshot-response", {
requestId,
snapshot: typeof snapshot === "string" ? snapshot : "",
perCharacterInput: typeof perCharacterInput === "boolean" ? perCharacterInput : undefined,
kittyKeyboardModeState: sanitizeKittyKeyboardModeState(kittyKeyboardModeState),
kittyKeyboardProtocolEnabled: typeof kittyKeyboardProtocolEnabled === "boolean"
? kittyKeyboardProtocolEnabled
@@ -638,6 +640,9 @@ function createPreloadApi(ctx) {
? context.contextScrollbackSnapshot
: "",
alternateScreen: context?.alternateScreen === true,
perCharacterInput: typeof context?.perCharacterInput === "boolean"
? context.perCharacterInput
: undefined,
kittyKeyboardModeState: sanitizeKittyKeyboardModeState(context?.kittyKeyboardModeState),
kittyKeyboardProtocolEnabled: typeof context?.kittyKeyboardProtocolEnabled === "boolean"
? context.kittyKeyboardProtocolEnabled
+3 -3
View File
@@ -1,14 +1,14 @@
{
version = "1.1.89";
version = "1.1.90";
sources = {
x86_64-linux = {
appImageArch = "x86_64";
hash = "sha256-Ne5TalZ3G9cHPl0xnQpPq/0GNRe1ErUg1DzE+/nnseM=";
hash = "sha256-oa/T3Zey0FHsH+SOt4C1H4CT6aZRJta+x4x/11EAOJE=";
};
aarch64-linux = {
appImageArch = "arm64";
hash = "sha256-L1aiJ/CroFwLSLH5aDnakogxjP8k5gLt8NJC+IGfXIw=";
hash = "sha256-r5hr3+gpIGrv9XJ7PQkHCdhOd2COkhELMHlnr3xIG7A=";
};
};
}
+13
View File
@@ -20,6 +20,7 @@
"@netcatty/plugin-cli": "0.1.0-internal",
"@netcatty/plugin-contract": "0.1.0-internal",
"@netcatty/plugin-sdk": "0.1.0-internal",
"@noble/ciphers": "2.4.0",
"@openai/codex-sdk": "0.144.3",
"@smithy/node-http-handler": "^4.4.9",
"@withfig/autocomplete": "^2.692.3",
@@ -4683,6 +4684,18 @@
"resolved": "packages/plugin-sdk",
"link": true
},
"node_modules/@noble/ciphers": {
"version": "2.4.0",
"resolved": "https://registry.npmjs.org/@noble/ciphers/-/ciphers-2.4.0.tgz",
"integrity": "sha512-AnjFn0Jv92laAkvMrghlFZq4qQCIN/4DxFV/eooqtC2YTjB7kBeLMS2T9KJX4Dn+ZVXLOwK0lSgqDtx9gvxtiw==",
"license": "MIT",
"engines": {
"node": ">= 20.19.0"
},
"funding": {
"url": "https://paulmillr.com/funding/"
}
},
"node_modules/@noble/hashes": {
"version": "2.2.0",
"resolved": "https://registry.npmjs.org/@noble/hashes/-/hashes-2.2.0.tgz",
+1
View File
@@ -84,6 +84,7 @@
"@netcatty/plugin-cli": "0.1.0-internal",
"@netcatty/plugin-contract": "0.1.0-internal",
"@netcatty/plugin-sdk": "0.1.0-internal",
"@noble/ciphers": "2.4.0",
"@openai/codex-sdk": "0.144.3",
"@smithy/node-http-handler": "^4.4.9",
"@withfig/autocomplete": "^2.692.3",
+120 -5
View File
@@ -860,10 +860,10 @@ index 9f33c02..9751164 100644
if (names !== undefined) {
sftp._debug && sftp._debug(
diff --git a/node_modules/ssh2/lib/protocol/constants.js b/node_modules/ssh2/lib/protocol/constants.js
index ad77592..a249e00 100644
index ad77592..03ab33b 100644
--- a/node_modules/ssh2/lib/protocol/constants.js
+++ b/node_modules/ssh2/lib/protocol/constants.js
@@ -71,9 +71,30 @@ const DEFAULT_SERVER_HOST_KEY = [
@@ -71,14 +71,37 @@ const DEFAULT_SERVER_HOST_KEY = [
];
if (eddsaSupported)
DEFAULT_SERVER_HOST_KEY.unshift('ssh-ed25519');
@@ -894,7 +894,15 @@ index ad77592..a249e00 100644
const canUseCipher = (() => {
@@ -160,6 +181,7 @@ const COMPAT = {
const ciphers = crypto.getCiphers();
- return (name) => ciphers.includes(CIPHER_INFO[name].sslName);
+ // crypto.js supplies the original ChaCha20 primitive on BoringSSL too.
+ return (name) => (name === 'chacha20-poly1305@openssh.com'
+ || ciphers.includes(CIPHER_INFO[name].sslName));
})();
let DEFAULT_CIPHER = [
// http://tools.ietf.org/html/rfc5647
@@ -160,6 +183,7 @@ const COMPAT = {
DYN_RPORT_BUG: 1 << 2,
BUG_DHGEX_LARGE: 1 << 3,
IMPLY_RSA_SHA2_SIGALGS: 1 << 4,
@@ -902,7 +910,7 @@ index ad77592..a249e00 100644
};
module.exports = {
@@ -330,6 +352,7 @@ module.exports = {
@@ -330,6 +354,7 @@ module.exports = {
COMPAT_CHECKS: [
[ 'Cisco-1.25', COMPAT.BAD_DHGEX ],
[ /^Cisco-1[.]/, COMPAT.BUG_DHGEX_LARGE ],
@@ -910,6 +918,113 @@ index ad77592..a249e00 100644
[ /^[0-9.]+$/, COMPAT.OLD_EXIT ], // old SSH.com implementations
[ /^OpenSSH_5[.][0-9]+/, COMPAT.DYN_RPORT_BUG ],
[ /^OpenSSH_7[.]4/, COMPAT.IMPLY_RSA_SHA2_SIGALGS ],
diff --git a/node_modules/ssh2/lib/protocol/crypto.js b/node_modules/ssh2/lib/protocol/crypto.js
index 851cc73..088de2a 100644
--- a/node_modules/ssh2/lib/protocol/crypto.js
+++ b/node_modules/ssh2/lib/protocol/crypto.js
@@ -11,6 +11,7 @@ const {
} = require('crypto');
const { readUInt32BE, writeUInt32BE } = require('./utils.js');
+const { chacha20, nativeChacha20 } = require('./crypto/chacha20.js');
const FastBuffer = Buffer[Symbol.species];
const MAX_SEQNO = 2 ** 32 - 1;
@@ -198,20 +199,17 @@ class ChaChaPolyCipherNative {
POLY1305_OUT_COMPUTE[0] = 0; // Set counter to 0 (little endian)
writeUInt32BE(POLY1305_OUT_COMPUTE, this.outSeqno, 12);
const polyKey =
- createCipheriv('chacha20', this._encKeyMain, POLY1305_OUT_COMPUTE)
- .update(POLY1305_ZEROS);
+ chacha20(this._encKeyMain, POLY1305_OUT_COMPUTE, POLY1305_ZEROS);
// Encrypt packet length
const pktLenEnc =
- createCipheriv('chacha20', this._encKeyPktLen, POLY1305_OUT_COMPUTE)
- .update(packet.slice(0, 4));
+ chacha20(this._encKeyPktLen, POLY1305_OUT_COMPUTE, packet.slice(0, 4));
this._onWrite(pktLenEnc);
// Encrypt rest of packet
POLY1305_OUT_COMPUTE[0] = 1; // Set counter to 1 (little endian)
const payloadEnc =
- createCipheriv('chacha20', this._encKeyMain, POLY1305_OUT_COMPUTE)
- .update(packet.slice(4));
+ chacha20(this._encKeyMain, POLY1305_OUT_COMPUTE, packet.slice(4));
this._onWrite(payloadEnc);
// Calculate Poly1305 MAC
@@ -650,8 +648,7 @@ class ChaChaPolyDecipherNative {
writeUInt32BE(POLY1305_OUT_COMPUTE, this.inSeqno, 12);
const decLenBytes =
- createDecipheriv('chacha20', this._decKeyPktLen, POLY1305_OUT_COMPUTE)
- .update(this._lenBuf);
+ chacha20(this._decKeyPktLen, POLY1305_OUT_COMPUTE, this._lenBuf);
this._len = readUInt32BE(decLenBytes, 0);
if (this._len > MAX_PACKET_SIZE
@@ -706,8 +703,7 @@ class ChaChaPolyDecipherNative {
POLY1305_OUT_COMPUTE[0] = 0; // Set counter to 0 (little endian)
writeUInt32BE(POLY1305_OUT_COMPUTE, this.inSeqno, 12);
const polyKey =
- createCipheriv('chacha20', this._decKeyMain, POLY1305_OUT_COMPUTE)
- .update(POLY1305_ZEROS);
+ chacha20(this._decKeyMain, POLY1305_OUT_COMPUTE, POLY1305_ZEROS);
// Calculate and compare Poly1305 MACs
poly1305_auth(POLY1305_RESULT_MALLOC,
@@ -729,8 +725,7 @@ class ChaChaPolyDecipherNative {
// Decrypt packet
POLY1305_OUT_COMPUTE[0] = 1; // Set counter to 1 (little endian)
const packet =
- createDecipheriv('chacha20', this._decKeyMain, POLY1305_OUT_COMPUTE)
- .update(this._packet);
+ chacha20(this._decKeyMain, POLY1305_OUT_COMPUTE, this._packet);
const payload = new FastBuffer(packet.buffer,
packet.byteOffset + 1,
@@ -1496,7 +1491,7 @@ function createCipher(config) {
? new AESGCMCipherBinding(config)
: new AESGCMCipherNative(config));
case 'chacha20':
- return (ChaChaPolyCipher && !forceNative
+ return (ChaChaPolyCipher && nativeChacha20 && !forceNative
? new ChaChaPolyCipherBinding(config)
: new ChaChaPolyCipherNative(config));
default: {
@@ -1556,7 +1551,7 @@ function createDecipher(config) {
? new AESGCMDecipherBinding(config)
: new AESGCMDecipherNative(config));
case 'chacha20':
- return (ChaChaPolyDecipher && !forceNative
+ return (ChaChaPolyDecipher && nativeChacha20 && !forceNative
? new ChaChaPolyDecipherBinding(config)
: new ChaChaPolyDecipherNative(config));
default: {
diff --git a/node_modules/ssh2/lib/protocol/crypto/chacha20.js b/node_modules/ssh2/lib/protocol/crypto/chacha20.js
new file mode 100644
index 0000000..86dd731
--- /dev/null
+++ b/node_modules/ssh2/lib/protocol/crypto/chacha20.js
@@ -0,0 +1,17 @@
+// OpenSSH uses original ChaCha20 (64-bit nonce), not the IETF/TLS AEAD.
+// Electron's BoringSSL lacks the standalone cipher, including in sshcrypto.
+'use strict';
+const { createCipheriv, getCiphers } = require('crypto');
+const nativeChacha20 = getCiphers().includes('chacha20');
+const { chacha20orig } = nativeChacha20 ? {} : require('@noble/ciphers/chacha.js');
+
+function chacha20(key, iv, data) {
+ if (nativeChacha20)
+ return createCipheriv('chacha20', key, iv).update(data);
+ // ssh2 only uses block counters 0 (length/Poly1305 key) and 1 (payload).
+ // The last eight IV bytes are its big-endian SSH packet sequence nonce.
+ return Buffer.from(chacha20orig(key, iv.subarray(8), data, undefined,
+ iv.readUInt32LE(0)));
+}
+
+module.exports = { chacha20, nativeChacha20 };
diff --git a/node_modules/ssh2/lib/protocol/kex.js b/node_modules/ssh2/lib/protocol/kex.js
index 811e631..422a198 100644
--- a/node_modules/ssh2/lib/protocol/kex.js
@@ -1127,7 +1242,7 @@ index 811e631..422a198 100644
onKEXPayload,
DEFAULT_KEXINIT_CLIENT: new KexInit({
diff --git a/node_modules/ssh2/lib/protocol/keyParser.js b/node_modules/ssh2/lib/protocol/keyParser.js
index a276c1a..c8ff9d1 100644
index a276c1a..57a05d6 100644
--- a/node_modules/ssh2/lib/protocol/keyParser.js
+++ b/node_modules/ssh2/lib/protocol/keyParser.js
@@ -35,6 +35,9 @@ const SYM_PRIV_PEM = Symbol('Private key PEM');
+20
View File
@@ -345,6 +345,26 @@ 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");
+104
View File
@@ -0,0 +1,104 @@
// Runs in a separate Node/Electron process so ssh2 detects its actual crypto
// capabilities at module load. The parent owns the disposable loopback server.
const os = require("node:os");
const path = require("node:path");
const crypto = require("node:crypto");
async function main(config) {
os.homedir = () => config.homeDir;
delete process.env.SSH_AUTH_SOCK;
delete process.env.SSH_AGENT_PID;
if (config.withoutNativeChacha) {
const getCiphers = crypto.getCiphers;
crypto.getCiphers = () => getCiphers().filter((name) => name !== "chacha20");
for (const name of ["createCipheriv", "createDecipheriv"]) {
const original = crypto[name];
crypto[name] = (algorithm, ...args) => {
if (algorithm === "chacha20") throw new Error("Standalone ChaCha20 unavailable");
return original(algorithm, ...args);
};
}
}
let packagedDependency;
if (config.appRoot) {
config.appRoot = require("node:fs").realpathSync(config.appRoot);
// RUN_AS_NODE supports ASAR files, but does not expose Electron's main
// process APIs. The packaged bridge imports sessionLogsBridge's dialog/shell
// even with logging disabled. Supply only that unrelated UI host seam;
// ssh2, crypto, algorithm selection, and sockets remain the shipped code.
const Module = require("node:module");
const load = Module._load;
Module._load = function loadWithHeadlessElectron(request, ...args) {
if (request === "electron") return { dialog: {}, shell: {} };
return load.call(this, request, ...args);
};
const packagedRequire = Module.createRequire(path.join(config.appRoot, "node_modules/ssh2/lib/protocol/crypto/chacha20.js"));
const noblePath = packagedRequire.resolve("@noble/ciphers/chacha.js");
if (!noblePath.startsWith(`${config.appRoot}${path.sep}`)) {
throw new Error("ChaCha20 dependency resolved outside the packaged app");
}
packagedDependency = path.relative(config.appRoot, noblePath).replaceAll("\\", "/");
}
const bridge = require(path.join(config.appRoot || path.resolve(__dirname, "../.."), "electron/bridges/sshBridge.cjs"));
const sessions = new Map();
let output = "";
let resolveOutput;
const echoed = new Promise((resolve) => { resolveOutput = resolve; });
bridge.init({
sessions,
electronModule: {},
terminalOutputChannel: {
openSession() {},
send(_sessionId, data) {
output += data;
if (output.length >= config.input.length) resolveOutput();
return true;
},
},
});
const handlers = new Map();
bridge.registerHandlers({ handle: (name, handler) => handlers.set(name, handler), on() {} });
const sender = new (require("node:events").EventEmitter)();
Object.assign(sender, { id: 1, isDestroyed: () => false, send() {} });
try {
const result = await handlers.get(config.entrypoint)({ sender }, {
sessionId: "synthetic-chacha-test",
hostname: "127.0.0.1",
port: config.port,
username: "synthetic",
authMethod: "password",
useSshAgent: false,
verifyHostKeys: false,
skipShellDiscovery: true,
algorithmOverrides: config.algorithmOverrides,
});
if (config.entrypoint === "netcatty:start") {
sessions.get(result.sessionId).stream.write(config.input);
await echoed;
}
return {
connected: true,
echoed: output === config.input,
electron: process.versions.electron,
nativeChacha: crypto.getCiphers().includes("chacha20"),
packagedDependency,
};
} finally {
for (const session of sessions.values()) session.conn?.destroy();
bridge.stopAllMonitoring();
}
}
let input = "";
process.stdin.setEncoding("utf8");
process.stdin.on("data", (data) => { input += data; });
process.stdin.on("end", async () => {
const config = JSON.parse(input);
const timeout = setTimeout(() => process.exit(2), 10000);
let result;
try { result = await main(config); }
catch (error) { result = { connected: false, error: error.message }; }
clearTimeout(timeout);
// Only public algorithm/runtime information leaves the synthetic client.
console.log(`NETCATTY_TRANSPORT_RESULT=${JSON.stringify(result)}`);
});
+37
View File
@@ -0,0 +1,37 @@
// Exercise shipped JS/dependencies with the same Electron runtime, headlessly.
// No BrowserWindow, real accounts, or external SSH hosts are involved.
const fs = require("node:fs");
const os = require("node:os");
const path = require("node:path");
const { spawnSync } = require("node:child_process");
const releaseDir = path.resolve(process.argv[2] || "release");
const archives = fs.readdirSync(releaseDir, { recursive: true })
.filter((entry) => path.basename(entry) === "app.asar")
.map((entry) => path.join(releaseDir, entry));
if (archives.length === 0) throw new Error("No packaged app.asar found");
for (const appRoot of archives) {
// Outside the checkout, a missing packaged dependency cannot resolve from
// the checkout's node_modules. Copy the paired unpacked payload as shipped.
const detachedDir = fs.mkdtempSync(path.join(os.tmpdir(), "netcatty-packaged-ssh-"));
try {
const detachedRoot = path.join(detachedDir, "app.asar");
fs.copyFileSync(appRoot, detachedRoot);
fs.cpSync(`${appRoot}.unpacked`, `${detachedRoot}.unpacked`, { recursive: true });
const env = {
...process.env,
NETCATTY_SSH_TEST_APP_ROOT: detachedRoot,
NETCATTY_SSH_TEST_RUNTIME: require("electron"),
};
delete env.NODE_PATH;
console.log(`Testing detached packaged SSH transport: ${appRoot}`);
const result = spawnSync(process.execPath, ["--test", "electron/bridges/sshChaChaTransport.test.cjs"], {
env,
stdio: "inherit",
});
if (result.error) throw result.error;
if (result.status !== 0) throw new Error(`Packaged SSH transport tests failed (${result.status})`);
} finally {
fs.rmSync(detachedDir, { recursive: true, force: true });
}
}
+5
View File
@@ -428,6 +428,8 @@ declare global {
requestTerminalSessionSnapshot?(sessionId: string, authorization: string): Promise<{
success: boolean;
snapshot?: string;
/** Captured input policy for the existing backend connection. */
perCharacterInput?: boolean;
kittyKeyboardModeState?: NetcattyKittyKeyboardModeState;
kittyKeyboardProtocolEnabled?: boolean;
passwordPromptActive?: boolean;
@@ -448,6 +450,7 @@ declare global {
passwordPromptActive?: boolean,
cwd?: string | null,
title?: string | null,
perCharacterInput?: boolean,
): void;
/** Observe popup: push current state back to the home renderer before restore. */
applyTerminalSessionSnapshot?(
@@ -458,6 +461,7 @@ declare global {
contextViewportSnapshot: string;
contextScrollbackSnapshot: string;
alternateScreen: boolean;
perCharacterInput?: boolean;
kittyKeyboardModeState?: NetcattyKittyKeyboardModeState;
kittyKeyboardProtocolEnabled?: boolean;
passwordPromptActive?: boolean;
@@ -480,6 +484,7 @@ declare global {
contextViewportSnapshot: string;
contextScrollbackSnapshot: string;
alternateScreen: boolean;
perCharacterInput?: boolean;
kittyKeyboardModeState?: NetcattyKittyKeyboardModeState;
kittyKeyboardProtocolEnabled?: boolean;
passwordPromptActive?: boolean;