feat(ssh): add single-channel SSH option for bastion hosts (#3458)
Add a host and group toggle for bastions that allow one session channel per TCP connection. After the handshake, BHostSSH, TERM-SSHD, and CLOUDBILITY take that path automatically. File transfer uses a separate SFTP login, and the interactive terminal is not given another exec, SFTP, copy/split, or port-forward channel. The manual toggle remains for bastions that still advertise OpenSSH.
This commit is contained in:
1 parent
bd8476fb34
commit
5805d15746
104 files changed
+2465
-151
No files matched your search
@@ -3,7 +3,7 @@ import type React from 'react';
|
||||
import type { Host, HostProtocol, TerminalSession } from '../../types';
|
||||
import type { PassphraseRequest } from '../../components/PassphraseModal';
|
||||
import type { TerminalPopupPayload } from '../../domain/systemManager/types';
|
||||
import { getEffectiveHostDistro, classifyDistroId, shouldProbeSessionCwd } from '../../domain/host';
|
||||
import { getEffectiveHostDistro, classifyDistroId, hostRestrictsExtraSshChannels, shouldProbeSessionCwd } from '../../domain/host';
|
||||
import { getAvailablePaneMagnificationController } from '../../domain/paneMagnification';
|
||||
import { sanitizeHostIconFields } from '../../domain/hostIcon';
|
||||
import { resolveEffectiveTerminalProtocol } from '../../domain/terminalProtocol';
|
||||
@@ -535,7 +535,11 @@ async function captureCtxInheritedCwd(getCtx: AppContextGetter, sessionId: strin
|
||||
if (!liveCwd && isConnectedSsh && !isNetworkDevice) {
|
||||
try {
|
||||
const info = await bridge?.getSessionRemoteInfo?.(sessionId);
|
||||
allowSshProbe = shouldProbeSessionCwd({ isNetworkDevice: false, remoteSshVersion: info?.remoteSshVersion });
|
||||
allowSshProbe = shouldProbeSessionCwd({
|
||||
isNetworkDevice: false,
|
||||
remoteSshVersion: info?.remoteSshVersion,
|
||||
restrictExtraSshChannels: hostRestrictsExtraSshChannels(host),
|
||||
});
|
||||
} catch {
|
||||
allowSshProbe = false;
|
||||
}
|
||||
|
||||
@@ -31,6 +31,7 @@ export const enSystemManagerMessages: Messages = {
|
||||
'systemManager.errors.loadPorts': 'Failed to load listening ports',
|
||||
'systemManager.errors.loadServices': 'Failed to load systemd services',
|
||||
'systemManager.errors.sshChannelUnavailable': 'The server refused to open a new execution channel. Try again later, or reconnect this host.',
|
||||
'systemManager.unsupportedRemote': 'This host does not allow extra SSH exec channels, so System Manager is unavailable. Opening this panel will no longer disconnect the terminal.',
|
||||
|
||||
'systemManager.overview.empty': 'No system overview data yet.',
|
||||
'systemManager.overview.loading': 'Loading system overview…',
|
||||
|
||||
@@ -900,6 +900,9 @@ export const enVaultMessages: Messages = {
|
||||
'hostDetails.deviceType': 'Network Device Mode',
|
||||
'hostDetails.deviceType.desc': 'Enable for network equipment (switches, routers, firewalls) connected via SSH. Commands are sent as-is without shell wrapping, compatible with vendor CLIs like Huawei VRP and Cisco IOS.',
|
||||
'hostDetails.deviceType.warning': 'AI agent commands will be sent directly without exit code tracking. Only enable for devices that do not run a standard shell.',
|
||||
'hostDetails.singleChannelSsh': 'Single-channel SSH (bastion)',
|
||||
'hostDetails.singleChannelSsh.desc': 'Do not open extra SFTP or exec channels on the terminal SSH connection. Recognized one-channel bastion banners do this automatically. Turn this on when the bastion still advertises OpenSSH. File transfer uses a separate login.',
|
||||
'hostDetails.singleChannelSsh.warning': 'System Manager is unavailable while this is on. File transfer uses a second login and may be limited by concurrent-session policy.',
|
||||
'hostDetails.section.sshAlgorithms': 'SSH Algorithms',
|
||||
'hostDetails.section.terminalBehavior': 'Terminal Behavior',
|
||||
'hostDetails.lineTimestamps': 'Show output timestamps',
|
||||
|
||||
@@ -31,6 +31,7 @@ export const esSystemManagerMessages: Messages = {
|
||||
'systemManager.errors.loadPorts': 'No se pudieron cargar los puertos en escucha',
|
||||
'systemManager.errors.loadServices': 'No se pudieron cargar los servicios de systemd',
|
||||
'systemManager.errors.sshChannelUnavailable': 'El servidor se negó a abrir un nuevo canal de ejecución. Inténtalo de nuevo más tarde o vuelve a conectar este host.',
|
||||
'systemManager.unsupportedRemote': 'Este host no permite canales SSH de ejecución adicionales, por lo que el administrador del sistema no está disponible. Abrir este panel ya no desconectará la terminal.',
|
||||
|
||||
'systemManager.overview.empty': 'Aún no hay datos de resumen del sistema.',
|
||||
'systemManager.overview.loading': 'Cargando resumen del sistema…',
|
||||
|
||||
@@ -900,6 +900,9 @@ export const esVaultMessages: Messages = {
|
||||
'hostDetails.deviceType': 'Modo de dispositivo de red',
|
||||
'hostDetails.deviceType.desc': 'Habilítalo para equipos de red (switches, routers, firewalls) conectados mediante SSH. Los comandos se envían tal cual, sin envoltura de shell, compatible con las CLI de proveedores como Huawei VRP y Cisco IOS.',
|
||||
'hostDetails.deviceType.warning': 'Los comandos del agente de IA se enviarán directamente sin seguimiento del código de salida. Habilítalo solo para dispositivos que no ejecutan un shell estándar.',
|
||||
'hostDetails.singleChannelSsh': 'SSH de un solo canal (bastión)',
|
||||
'hostDetails.singleChannelSsh.desc': 'No abrir canales extra de SFTP o exec en la conexión SSH del terminal. Los banners de bastión de un solo canal ya reconocidos lo hacen solos. Actívalo si el bastión sigue anunciando OpenSSH. La transferencia de archivos usa un inicio de sesión aparte.',
|
||||
'hostDetails.singleChannelSsh.warning': 'El administrador del sistema no estará disponible. La transferencia de archivos usa un segundo inicio de sesión y puede estar limitada por la política de sesiones concurrentes.',
|
||||
'hostDetails.section.sshAlgorithms': 'Algoritmos SSH',
|
||||
'hostDetails.section.terminalBehavior': 'Comportamiento de la terminal',
|
||||
'hostDetails.lineTimestamps': 'Mostrar marcas de tiempo de salida',
|
||||
|
||||
@@ -31,6 +31,7 @@ export const ruSystemManagerMessages: Messages = {
|
||||
'systemManager.errors.loadPorts': 'Не удалось загрузить слушающие порты',
|
||||
'systemManager.errors.loadServices': 'Не удалось загрузить службы systemd',
|
||||
'systemManager.errors.sshChannelUnavailable': 'Сервер отказался открыть новый канал выполнения. Повторите попытку позже или переподключите этот хост.',
|
||||
'systemManager.unsupportedRemote': 'Этот хост не разрешает дополнительные SSH exec-каналы, поэтому системная панель недоступна. Открытие панели больше не разрывает терминал.',
|
||||
|
||||
'systemManager.overview.empty': 'Данных обзора пока нет.',
|
||||
'systemManager.overview.loading': 'Загрузка обзора системы…',
|
||||
|
||||
@@ -935,6 +935,9 @@ export const ruVaultMessages: Messages = {
|
||||
'hostDetails.deviceType': 'Режим сетевого устройства',
|
||||
'hostDetails.deviceType.desc': 'Включайте для сетевого оборудования (коммутаторов, маршрутизаторов, межсетевых экранов), подключённого по SSH. Команды отправляются как есть, без обёртки оболочки, что совместимо с CLI вендоров вроде Huawei VRP и Cisco IOS.',
|
||||
'hostDetails.deviceType.warning': 'Команды AI-агента будут отправляться напрямую без отслеживания кода выхода. Включайте только для устройств, на которых нет стандартной оболочки.',
|
||||
'hostDetails.singleChannelSsh': 'Одноканальный SSH (бастион)',
|
||||
'hostDetails.singleChannelSsh.desc': 'Не открывать дополнительные SFTP/exec-каналы на SSH-соединении терминала. Уже распознанные баннеры одноканальных бастионов обрабатываются сами. Включите, если бастион всё ещё представляется как OpenSSH. Передача файлов идёт отдельным входом.',
|
||||
'hostDetails.singleChannelSsh.warning': 'Системная панель будет недоступна. Передача файлов использует отдельный вход и может быть ограничена политикой одновременных сессий.',
|
||||
'hostDetails.section.sshAlgorithms': 'SSH-алгоритмы',
|
||||
'hostDetails.section.terminalBehavior': 'Поведение терминала',
|
||||
'hostDetails.lineTimestamps': 'Показывать время вывода',
|
||||
|
||||
@@ -31,6 +31,7 @@ export const zhCnSystemManagerMessages: Messages = {
|
||||
'systemManager.errors.loadPorts': '加载监听端口失败',
|
||||
'systemManager.errors.loadServices': '加载 systemd 服务失败',
|
||||
'systemManager.errors.sshChannelUnavailable': '服务器拒绝打开新的执行通道。请稍后重试,或重新连接当前主机。',
|
||||
'systemManager.unsupportedRemote': '该主机不允许额外的 SSH 执行通道,系统面板不可用。打开此面板不会再断开当前终端。',
|
||||
|
||||
'systemManager.overview.empty': '暂无系统概览数据。',
|
||||
'systemManager.overview.loading': '正在加载系统概览…',
|
||||
|
||||
@@ -316,6 +316,9 @@ export const zhCNVaultMessages: Messages = {
|
||||
'hostDetails.deviceType': '网络设备模式',
|
||||
'hostDetails.deviceType.desc': '适用于通过 SSH 连接的网络设备(交换机、路由器、防火墙)。命令将原样发送,不进行 Shell 包装,兼容华为 VRP、Cisco IOS 等厂商 CLI。',
|
||||
'hostDetails.deviceType.warning': 'AI 代理命令将直接发送,无法获取退出码。仅建议在设备不运行标准 Shell 时启用。',
|
||||
'hostDetails.singleChannelSsh': '单通道 SSH(堡垒机)',
|
||||
'hostDetails.singleChannelSsh.desc': '禁止在当前终端 SSH 上再开 SFTP/系统探测等通道。已识别的单通道堡垒机软件标识会自动分开登录。仍显示 OpenSSH 标识的堡垒机需要手动开启。文件传输会改走独立连接。',
|
||||
'hostDetails.singleChannelSsh.warning': '开启后系统面板不可用。文件传输使用单独登录,可能受堡垒机并发会话限制。',
|
||||
'hostDetails.section.sshAlgorithms': 'SSH 算法',
|
||||
'hostDetails.section.terminalBehavior': '终端行为',
|
||||
'hostDetails.lineTimestamps': '显示输出时间',
|
||||
|
||||
@@ -31,6 +31,7 @@ export const zhTwSystemManagerMessages: Messages = {
|
||||
'systemManager.errors.loadPorts': '載入監聽連接埠失敗',
|
||||
'systemManager.errors.loadServices': '載入 systemd 服務失敗',
|
||||
'systemManager.errors.sshChannelUnavailable': '伺服器拒絕開啟新的執行通道。請稍後重試,或重新連線目前主機。',
|
||||
'systemManager.unsupportedRemote': '此主機不允許額外的 SSH 執行通道,系統面板無法使用。開啟此面板不會再中斷目前終端。',
|
||||
|
||||
'systemManager.overview.empty': '暫無系統概覽資料。',
|
||||
'systemManager.overview.loading': '正在載入系統概覽…',
|
||||
|
||||
@@ -316,6 +316,9 @@ export const zhTWVaultMessages: Messages = {
|
||||
'hostDetails.deviceType': '網路裝置模式',
|
||||
'hostDetails.deviceType.desc': '適用於透過 SSH 連線的網路裝置(交換機、路由器、防火牆)。指令將原樣傳送,不進行 Shell 包裝,相容華為 VRP、Cisco IOS 等廠商 CLI。',
|
||||
'hostDetails.deviceType.warning': 'AI 代理指令將直接傳送,無法取得結束代碼。僅建議在裝置不執行標準 Shell 時啟用。',
|
||||
'hostDetails.singleChannelSsh': '單通道 SSH(堡壘機)',
|
||||
'hostDetails.singleChannelSsh.desc': '禁止在目前終端 SSH 上再開 SFTP/系統探測等通道。已識別的單通道堡壘機軟體識別字串會自動分開登入。仍顯示 OpenSSH 識別字串的堡壘機需要手動開啟。檔案傳輸會改走獨立連線。',
|
||||
'hostDetails.singleChannelSsh.warning': '開啟後系統面板無法使用。檔案傳輸使用單獨登入,可能受堡壘機並行會話限制。',
|
||||
'hostDetails.section.sshAlgorithms': 'SSH 演算法',
|
||||
'hostDetails.section.terminalBehavior': '終端行為',
|
||||
'hostDetails.lineTimestamps': '顯示輸出時間',
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
import assert from "node:assert/strict";
|
||||
import test from "node:test";
|
||||
import { isMissingStatError } from "./errors";
|
||||
import { isMissingStatError, unwrapSftpIpcError } from "./errors";
|
||||
|
||||
test("isMissingStatError accepts only true path absence codes", () => {
|
||||
for (const code of [2, "ENOENT", "NO_SUCH_FILE", "SSH_FX_NO_SUCH_FILE"] as const) {
|
||||
@@ -64,3 +64,15 @@ test("isMissingStatError rejects unsupported LSTAT and other failures", () => {
|
||||
false,
|
||||
);
|
||||
});
|
||||
|
||||
|
||||
test("unwrapSftpIpcError strips the Electron IPC wrapper", () => {
|
||||
assert.equal(
|
||||
unwrapSftpIpcError(
|
||||
new Error("Error invoking remote method 'netcatty:sftp:delete': Error: Permission denied"),
|
||||
),
|
||||
"Permission denied",
|
||||
);
|
||||
assert.equal(unwrapSftpIpcError(new Error("Permission denied")), "Permission denied");
|
||||
assert.equal(unwrapSftpIpcError("channel closed"), "channel closed");
|
||||
});
|
||||
@@ -18,4 +18,13 @@ export const isSessionError = (err: unknown): boolean => {
|
||||
);
|
||||
};
|
||||
|
||||
const SFTP_IPC_ERROR_PREFIX = /^Error invoking remote method 'netcatty:[^']+':\s*/i;
|
||||
|
||||
/** Drop Electron IPC wrapper so dialogs can show the server error. */
|
||||
export function unwrapSftpIpcError(err: unknown): string {
|
||||
const raw = err instanceof Error ? err.message : String(err ?? "");
|
||||
const unwrapped = raw.replace(SFTP_IPC_ERROR_PREFIX, "").replace(/^Error:\s*/, "").trim();
|
||||
return unwrapped || raw;
|
||||
}
|
||||
|
||||
export { isMissingStatError } from "../../../domain/sftpStatError";
|
||||
@@ -62,6 +62,8 @@ export interface SftpConnectOptions {
|
||||
sourceSessionId?: string;
|
||||
/** Fail instead of opening a new route when the requested terminal transport cannot be reused. */
|
||||
requireSourceSessionReuse?: boolean;
|
||||
/** When false, never attach SFTP to a live terminal SSH transport. */
|
||||
reuseTransport?: boolean;
|
||||
/** Prevent a forced terminal-drop route rebind from sharing an older open. */
|
||||
connectRequestKey?: string;
|
||||
/** Identifies the exact tab and connection created for a route-bound request. */
|
||||
@@ -1074,6 +1076,7 @@ export const useSftpConnections = ({
|
||||
openOptions: {
|
||||
sessionId: sftpSessionId,
|
||||
...credentials,
|
||||
...(options?.reuseTransport === false ? { reuseTransport: false } : {}),
|
||||
},
|
||||
});
|
||||
|
||||
|
||||
@@ -85,6 +85,60 @@ test("first-open follow uses a fresh terminal cwd instead of the cached home pat
|
||||
await act(async () => renderer?.unmount());
|
||||
});
|
||||
|
||||
test("first-open follow stays put when the terminal cwd is still an unresolved ~", async () => {
|
||||
const navigatedPaths: string[] = [];
|
||||
const connection = {
|
||||
id: "conn-1",
|
||||
hostId: "host-1",
|
||||
currentPath: "/root",
|
||||
homeDir: "/root",
|
||||
status: "connected",
|
||||
isLocal: false,
|
||||
};
|
||||
const sftpRef = {
|
||||
current: {
|
||||
leftPane: { connection, loading: false },
|
||||
navigateTo: async (_side: "left", path: string) => {
|
||||
navigatedPaths.push(path);
|
||||
connection.currentPath = path;
|
||||
return "reached" as const;
|
||||
},
|
||||
},
|
||||
};
|
||||
let renderer: ReactTestRenderer | null = null;
|
||||
|
||||
function Probe() {
|
||||
useSftpFollowTerminalCwd({
|
||||
activeSessionId: "session-1",
|
||||
activeTerminalCwd: "~",
|
||||
canFollowTerminalCwd: true,
|
||||
connectionId: connection.id,
|
||||
connectionIsLocal: connection.isLocal,
|
||||
connectionLoading: false,
|
||||
connectionPath: connection.currentPath,
|
||||
connectionStatus: connection.status,
|
||||
effectiveFollowTerminalCwd: true,
|
||||
followTerminalCwdHost: host,
|
||||
hasActiveWork: false,
|
||||
isVisible: true,
|
||||
ownerPanelOpen: true,
|
||||
onGetTerminalCwd: async () => "~",
|
||||
onPendingFollowOverride: () => {},
|
||||
sftpRef,
|
||||
});
|
||||
return null;
|
||||
}
|
||||
|
||||
await act(async () => {
|
||||
renderer = create(React.createElement(Probe));
|
||||
await new Promise((resolve) => setImmediate(resolve));
|
||||
});
|
||||
|
||||
assert.deepEqual(navigatedPaths, []);
|
||||
assert.equal(connection.currentPath, "/root");
|
||||
await act(async () => renderer?.unmount());
|
||||
});
|
||||
|
||||
test("first-open follow does not use a stale cached cwd while the fresh probe is pending", async () => {
|
||||
let resolveCwd: ((cwd: string) => void) | null = null;
|
||||
const navigatedPaths: string[] = [];
|
||||
|
||||
@@ -16,6 +16,9 @@ import {
|
||||
shouldReleaseInitialFollowSyncAttempt,
|
||||
shouldResetInitialFollowTerminalCwdSync,
|
||||
type SftpFollowTerminalCwdBlock,
|
||||
resolveTerminalCwdForSftp,
|
||||
isSftpFollowTargetPath,
|
||||
fallbackFollowPathForUntrackedSession,
|
||||
} from "../../../domain/sftpFollowTerminalCwd";
|
||||
import type { Host } from "../../../types";
|
||||
import type { SftpNavigateOptions, SftpNavigateResult } from "./useSftpPaneActions";
|
||||
@@ -23,6 +26,7 @@ import type { SftpNavigateOptions, SftpNavigateResult } from "./useSftpPaneActio
|
||||
type FollowConnection = {
|
||||
id: string;
|
||||
currentPath?: string | null;
|
||||
homeDir?: string | null;
|
||||
status: string;
|
||||
isLocal?: boolean;
|
||||
};
|
||||
@@ -48,6 +52,7 @@ type GetTerminalCwd = (options?: {
|
||||
type InitialFollowConnection = {
|
||||
id: string;
|
||||
currentPath?: string | null;
|
||||
homeDir?: string | null;
|
||||
status: string;
|
||||
isLocal?: boolean;
|
||||
};
|
||||
@@ -85,13 +90,21 @@ const runInitialFollowTerminalCwdSync = async ({
|
||||
return false;
|
||||
}
|
||||
|
||||
const targetPath = resolveTerminalCwdForSftp(cwd, live.homeDir, live.currentPath);
|
||||
if (!isSftpFollowTargetPath(targetPath)) {
|
||||
// Prompt-only shortcuts such as `~` are not a real SFTP path yet. Stay on
|
||||
// the connection's landing directory instead of listing `/~`.
|
||||
setHandled({ connectionId: expectedConnectionId, terminalCwd: cwd });
|
||||
return true;
|
||||
}
|
||||
|
||||
setHandled({
|
||||
connectionId: expectedConnectionId,
|
||||
terminalCwd: staleTerminalCwd && staleTerminalCwd !== cwd ? staleTerminalCwd : cwd,
|
||||
terminalCwd: staleTerminalCwd && staleTerminalCwd !== targetPath ? staleTerminalCwd : targetPath,
|
||||
});
|
||||
if (live.currentPath === cwd) return true;
|
||||
if (live.currentPath === targetPath) return true;
|
||||
|
||||
const navigateResult = await navigate(cwd, isEligible);
|
||||
const navigateResult = await navigate(targetPath, isEligible);
|
||||
if (!isEligible()) return false;
|
||||
const current = getConnection();
|
||||
if (!current || current.id !== expectedConnectionId || current.status !== "connected") {
|
||||
@@ -174,6 +187,7 @@ export function useSftpFollowTerminalCwd({
|
||||
const ownerPanelOpenRef = useRef(ownerPanelOpen);
|
||||
const hasActiveWorkRef = useRef(hasActiveWork);
|
||||
const initialFollowReadyConnectionRef = useRef<string | null>(null);
|
||||
const lastFollowOriginIdRef = useRef<string | null>(null);
|
||||
|
||||
effectiveFollowTerminalCwdRef.current = effectiveFollowTerminalCwd;
|
||||
canFollowTerminalCwdRef.current = canFollowTerminalCwd;
|
||||
@@ -257,10 +271,16 @@ export function useSftpFollowTerminalCwd({
|
||||
});
|
||||
if (!cwd) return;
|
||||
if (!shouldApply()) return;
|
||||
const navigateResult = await sftpRef.current.navigateTo("left", cwd, { shouldApply });
|
||||
const connection = sftpRef.current.leftPane.connection;
|
||||
const targetPath = resolveTerminalCwdForSftp(
|
||||
cwd,
|
||||
connection?.homeDir,
|
||||
connection?.currentPath,
|
||||
);
|
||||
if (!isSftpFollowTargetPath(targetPath)) return;
|
||||
const navigateResult = await sftpRef.current.navigateTo("left", targetPath, { shouldApply });
|
||||
if (navigateResult !== "reached" || !shouldApply()) return;
|
||||
blockedFollowRef.current = null;
|
||||
const connection = sftpRef.current.leftPane.connection;
|
||||
if (connection?.id) {
|
||||
handledFollowRef.current = { connectionId: connection.id, terminalCwd: cwd };
|
||||
}
|
||||
@@ -275,9 +295,23 @@ export function useSftpFollowTerminalCwd({
|
||||
const syncGeneration = followSyncGenerationRef.current;
|
||||
const expectedSessionId = focusedSessionIdRef.current ?? activeSessionIdRef.current ?? null;
|
||||
const expectedConnectionIdAtStart = connectionIdRef.current ?? liveConnectionId;
|
||||
const previousOriginId = lastFollowOriginIdRef.current;
|
||||
const originChanged = Boolean(
|
||||
previousOriginId
|
||||
&& expectedSessionId
|
||||
&& previousOriginId !== expectedSessionId,
|
||||
);
|
||||
if (expectedSessionId) lastFollowOriginIdRef.current = expectedSessionId;
|
||||
const usesLiveTerminalCwd = Boolean(activeTerminalCwd && activeTerminalCwdTrusted);
|
||||
let terminalCwd = usesLiveTerminalCwd ? activeTerminalCwd : null;
|
||||
if (!terminalCwd) {
|
||||
if (!terminalCwd && originChanged) {
|
||||
// Do not reuse an in-flight probe from the pane we just left.
|
||||
terminalCwd = fallbackFollowPathForUntrackedSession({
|
||||
originChanged,
|
||||
homeDir: sftpRef.current.leftPane.connection?.homeDir,
|
||||
currentPath: sftpRef.current.leftPane.connection?.currentPath,
|
||||
});
|
||||
} else if (!terminalCwd) {
|
||||
terminalCwd = await onGetTerminalCwd({
|
||||
preferFreshBackend: true,
|
||||
allowRendererFallback: false,
|
||||
@@ -335,26 +369,36 @@ export function useSftpFollowTerminalCwd({
|
||||
liveTerminalCwd: activeTerminalCwdRef.current,
|
||||
requireLiveTerminalCwd: usesLiveTerminalCwd,
|
||||
});
|
||||
const navigateResult = await sftpRef.current.navigateTo("left", terminalCwd, {
|
||||
if (!connection) return;
|
||||
const targetPath = resolveTerminalCwdForSftp(
|
||||
terminalCwd,
|
||||
connection.homeDir,
|
||||
connection.currentPath,
|
||||
);
|
||||
if (!isSftpFollowTargetPath(targetPath)) return;
|
||||
const navigateResult = await sftpRef.current.navigateTo("left", targetPath, {
|
||||
shouldApply: shouldApplyCurrentFollowSync,
|
||||
quiet: true,
|
||||
});
|
||||
if (!shouldApplyCurrentFollowSync()) return;
|
||||
|
||||
const currentConnection = sftpRef.current.leftPane.connection;
|
||||
if (!currentConnection || currentConnection.id !== connection?.id) return;
|
||||
if (navigateResult === "failed") {
|
||||
blockedFollowRef.current = { connectionId: currentConnection.id, terminalCwd };
|
||||
blockedFollowRef.current = { connectionId: currentConnection.id, terminalCwd: targetPath };
|
||||
} else if (navigateResult === "superseded") {
|
||||
handledFollowRef.current = { connectionId: currentConnection.id, terminalCwd };
|
||||
handledFollowRef.current = { connectionId: currentConnection.id, terminalCwd: targetPath };
|
||||
} else if (navigateResult === "reached") {
|
||||
blockedFollowRef.current = null;
|
||||
handledFollowRef.current = { connectionId: currentConnection.id, terminalCwd };
|
||||
handledFollowRef.current = { connectionId: currentConnection.id, terminalCwd: targetPath };
|
||||
}
|
||||
}, [
|
||||
activeSessionId,
|
||||
activeTerminalCwd,
|
||||
activeTerminalCwdTrusted,
|
||||
canFollowTerminalCwd,
|
||||
effectiveFollowTerminalCwd,
|
||||
focusedSessionId,
|
||||
hasActiveWork,
|
||||
isVisible,
|
||||
onGetTerminalCwd,
|
||||
@@ -380,6 +424,7 @@ export function useSftpFollowTerminalCwd({
|
||||
if (!effectiveFollowTerminalCwd || !canFollowTerminalCwd || !isVisible || hasActiveWork) return;
|
||||
void syncFollowToTerminalCwd();
|
||||
}, [
|
||||
activeSessionId,
|
||||
activeTerminalCwd,
|
||||
activeTerminalCwdTrusted,
|
||||
canFollowTerminalCwd,
|
||||
@@ -387,6 +432,7 @@ export function useSftpFollowTerminalCwd({
|
||||
connectionIsLocal,
|
||||
connectionStatus,
|
||||
effectiveFollowTerminalCwd,
|
||||
focusedSessionId,
|
||||
hasActiveWork,
|
||||
isVisible,
|
||||
syncFollowToTerminalCwd,
|
||||
@@ -533,7 +579,7 @@ export function useSftpFollowTerminalCwd({
|
||||
getConnection: () => sftpRef.current.leftPane.connection,
|
||||
navigate: (cwd, shouldApply) => {
|
||||
navigationStarted = true;
|
||||
return sftpRef.current.navigateTo("left", cwd, { shouldApply });
|
||||
return sftpRef.current.navigateTo("left", cwd, { shouldApply, quiet: true });
|
||||
},
|
||||
setHandled: (value) => { handledFollowRef.current = value; },
|
||||
setBlocked: (value) => { blockedFollowRef.current = value; },
|
||||
|
||||
@@ -205,6 +205,7 @@ export const buildSftpHostCredentials = ({
|
||||
// the original negotiation failure when opening their SFTP pane,
|
||||
// even though the terminal session works.
|
||||
legacyAlgorithms: host.legacyAlgorithms,
|
||||
singleChannelSsh: host.singleChannelSsh === true,
|
||||
skipEcdsaHostKey: host.skipEcdsaHostKey,
|
||||
algorithmOverrides: host.algorithms,
|
||||
};
|
||||
|
||||
@@ -2,6 +2,7 @@ import React, { useCallback, useRef } from "react";
|
||||
import type { Host, SftpFileEntry, SftpFilenameEncoding } from "../../../domain/models";
|
||||
import { netcattyBridge } from "../../../infrastructure/services/netcattyBridge";
|
||||
import { logger } from "../../../lib/logger";
|
||||
import { unwrapSftpIpcError } from "./errors";
|
||||
import { SftpPane } from "./types";
|
||||
import {
|
||||
getFileName,
|
||||
@@ -55,6 +56,8 @@ export type SftpNavigateOptions = {
|
||||
force?: boolean;
|
||||
tabId?: string;
|
||||
shouldApply?: () => boolean;
|
||||
/** Restore the previous listing without showing a pane error. */
|
||||
quiet?: boolean;
|
||||
};
|
||||
|
||||
interface UseSftpPaneActionsResult {
|
||||
@@ -416,8 +419,9 @@ export const useSftpPaneActions = ({
|
||||
files: previousFiles,
|
||||
selectedFiles: previousSelection,
|
||||
filter: getSftpFilterAfterPathChangeError(clearFilterForPathChange, previousFilter, prev.filter),
|
||||
error:
|
||||
err instanceof Error ? err.message : "Failed to list directory",
|
||||
error: options?.quiet
|
||||
? previousError
|
||||
: unwrapSftpIpcError(err) || "Failed to list directory",
|
||||
loading: false,
|
||||
};
|
||||
});
|
||||
@@ -1030,6 +1034,30 @@ export const useSftpPaneActions = ({
|
||||
[getActivePane, refresh, handleSessionError, sftpSessionsRef, isSessionError],
|
||||
);
|
||||
|
||||
const removeListedNames = useCallback((
|
||||
side: "left" | "right",
|
||||
parentPath: string,
|
||||
names: string[],
|
||||
) => {
|
||||
if (names.length === 0) return;
|
||||
const removeSet = new Set(names);
|
||||
updateActiveTab(side, (prev) => {
|
||||
if (!prev.connection || prev.connection.currentPath !== parentPath) return prev;
|
||||
const nextSelection = new Set(prev.selectedFiles);
|
||||
for (const name of names) nextSelection.delete(name);
|
||||
return {
|
||||
...prev,
|
||||
files: prev.files.filter((file) => !removeSet.has(file.name)),
|
||||
selectedFiles: nextSelection,
|
||||
error: null,
|
||||
};
|
||||
});
|
||||
const pane = getActivePane(side);
|
||||
if (pane?.connection && !pane.connection.isLocal) {
|
||||
clearCacheForConnection(pane.connection.id);
|
||||
}
|
||||
}, [clearCacheForConnection, getActivePane, updateActiveTab]);
|
||||
|
||||
return {
|
||||
navigateTo,
|
||||
refresh,
|
||||
@@ -1047,6 +1075,7 @@ export const useSftpPaneActions = ({
|
||||
createFileAtPath,
|
||||
deleteFiles,
|
||||
deleteFilesAtPath,
|
||||
removeListedNames,
|
||||
renameFile,
|
||||
renameFileAtPath,
|
||||
moveEntriesToPath,
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
type Listener = () => void;
|
||||
|
||||
export type TerminalCwdSource = "osc7" | "backend-strict" | "backend" | "snapshot" | "stale" | "unknown";
|
||||
export type TerminalCwdSource = "osc7" | "inferred" | "backend-strict" | "backend" | "snapshot" | "stale" | "unknown";
|
||||
|
||||
/**
|
||||
* Live terminal CWD map + version token.
|
||||
|
||||
@@ -469,6 +469,7 @@ export const useSftpState = (
|
||||
createFileAtPath,
|
||||
deleteFiles,
|
||||
deleteFilesAtPath,
|
||||
removeListedNames,
|
||||
renameFile,
|
||||
renameFileAtPath,
|
||||
moveEntriesToPath,
|
||||
@@ -781,6 +782,7 @@ export const useSftpState = (
|
||||
createFileAtPath,
|
||||
deleteFiles,
|
||||
deleteFilesAtPath,
|
||||
removeListedNames,
|
||||
renameFile,
|
||||
renameFileAtPath,
|
||||
moveEntriesToPath,
|
||||
@@ -857,6 +859,8 @@ export const useSftpState = (
|
||||
deleteFiles: (...args: Parameters<typeof deleteFiles>) => methodsRef.current.deleteFiles(...args),
|
||||
deleteFilesAtPath: (...args: Parameters<typeof deleteFilesAtPath>) =>
|
||||
methodsRef.current.deleteFilesAtPath(...args),
|
||||
removeListedNames: (...args: Parameters<typeof removeListedNames>) =>
|
||||
methodsRef.current.removeListedNames(...args),
|
||||
renameFile: (...args: Parameters<typeof renameFile>) => methodsRef.current.renameFile(...args),
|
||||
renameFileAtPath: (...args: Parameters<typeof renameFileAtPath>) => methodsRef.current.renameFileAtPath(...args),
|
||||
moveEntriesToPath: (...args: Parameters<typeof moveEntriesToPath>) => methodsRef.current.moveEntriesToPath(...args),
|
||||
|
||||
@@ -15,6 +15,9 @@ function delay(ms: number): Promise<void> {
|
||||
function normalizePollingErrorMessage(error: unknown, t: I18nContextValue['t']): string {
|
||||
const message = error instanceof Error ? error.message : String(error || 'Unknown error');
|
||||
const lower = message.toLowerCase();
|
||||
if (lower.includes('extra exec channels')) {
|
||||
return t('systemManager.unsupportedRemote');
|
||||
}
|
||||
if (lower.includes('channel open failure') || lower.includes('unable to exec')) {
|
||||
return t('systemManager.errors.sshChannelUnavailable');
|
||||
}
|
||||
|
||||
@@ -521,7 +521,7 @@ export const useTerminalBackend = () => {
|
||||
|
||||
const getSessionPwd = useCallback(async (
|
||||
sessionId: string,
|
||||
options?: { allowHomeFallback?: boolean; allowLoginShellFallback?: boolean },
|
||||
options?: { allowHomeFallback?: boolean; allowLoginShellFallback?: boolean; timeoutMs?: number },
|
||||
) => {
|
||||
const bridge = netcattyBridge.get();
|
||||
if (!bridge?.getSessionPwd) return { success: false, error: 'getSessionPwd unavailable' };
|
||||
|
||||
@@ -100,7 +100,7 @@ export const hasGroupTelnetFields = (c: Partial<GroupConfig>): boolean =>
|
||||
export const hasGroupSshFields = (c: Partial<GroupConfig>): boolean =>
|
||||
c.protocol === 'ssh' ||
|
||||
c.port !== undefined || !!c.username || !!c.password || !!c.identityFileId ||
|
||||
c.deviceType !== undefined ||
|
||||
c.deviceType !== undefined || c.singleChannelSsh !== undefined ||
|
||||
c.agentForwarding !== undefined || c.authMethod !== undefined || c.identityId !== undefined ||
|
||||
!!c.proxyProfileId || !!c.proxyConfig || !!c.hostChain || !!c.startupCommand || c.startupCommandRunMode !== undefined || c.legacyAlgorithms !== undefined || c.skipEcdsaHostKey !== undefined || c.algorithms !== undefined || c.backspaceBehavior !== undefined ||
|
||||
Boolean(c.environmentVariables && c.environmentVariables.length > 0) ||
|
||||
@@ -260,6 +260,7 @@ const GroupDetailsPanel: React.FC<GroupDetailsPanelPropsWithResize> = ({
|
||||
delete next.identityFileId;
|
||||
delete next.identityFilePaths;
|
||||
delete next.deviceType;
|
||||
delete next.singleChannelSsh;
|
||||
delete next.agentForwarding;
|
||||
delete next.startupCommand;
|
||||
delete next.startupCommandRunMode;
|
||||
@@ -502,6 +503,11 @@ const GroupDetailsPanel: React.FC<GroupDetailsPanelPropsWithResize> = ({
|
||||
return resolveGroupDefaults(parentGroup, groupConfigs).deviceType;
|
||||
}, [groupConfigs, parentGroup]);
|
||||
const effectiveDeviceType = form.deviceType ?? inheritedDeviceType;
|
||||
const inheritedSingleChannelSsh = useMemo(() => {
|
||||
if (!parentGroup || groupConfigs.length === 0) return false;
|
||||
return !!resolveGroupDefaults(parentGroup, groupConfigs).singleChannelSsh;
|
||||
}, [groupConfigs, parentGroup]);
|
||||
const effectiveSingleChannelSsh = form.singleChannelSsh ?? inheritedSingleChannelSsh;
|
||||
const effectiveThemeId = form.themeOverride === false
|
||||
? inheritedThemeId
|
||||
: (form.theme || inheritedThemeId);
|
||||
@@ -561,6 +567,7 @@ const GroupDetailsPanel: React.FC<GroupDetailsPanelPropsWithResize> = ({
|
||||
...(form.identityFileId !== undefined && { identityFileId: form.identityFileId }),
|
||||
...(form.identityFilePaths !== undefined && { identityFilePaths: form.identityFilePaths }),
|
||||
...(form.deviceType !== undefined && { deviceType: form.deviceType }),
|
||||
...(form.singleChannelSsh !== undefined && { singleChannelSsh: form.singleChannelSsh }),
|
||||
...(form.agentForwarding !== undefined && { agentForwarding: form.agentForwarding }),
|
||||
...(form.startupCommand !== undefined && { startupCommand: form.startupCommand }),
|
||||
...(form.startupCommandRunMode !== undefined && { startupCommandRunMode: form.startupCommandRunMode }),
|
||||
@@ -800,6 +807,20 @@ const GroupDetailsPanel: React.FC<GroupDetailsPanelPropsWithResize> = ({
|
||||
</p>
|
||||
</div>
|
||||
)}
|
||||
<ToggleRow
|
||||
label={t("hostDetails.singleChannelSsh")}
|
||||
hint={t("hostDetails.singleChannelSsh.desc")}
|
||||
enabled={!!effectiveSingleChannelSsh}
|
||||
onToggle={() => update("singleChannelSsh", !effectiveSingleChannelSsh)}
|
||||
/>
|
||||
{!!effectiveSingleChannelSsh && (
|
||||
<div className="flex items-start gap-2 p-2 rounded-md bg-yellow-500/10 border border-yellow-500/20">
|
||||
<AlertTriangle size={14} className="text-yellow-500 mt-0.5 flex-shrink-0" />
|
||||
<p className="text-xs text-yellow-600 dark:text-yellow-400 break-words">
|
||||
{t("hostDetails.singleChannelSsh.warning")}
|
||||
</p>
|
||||
</div>
|
||||
)}
|
||||
</HostDetailsSection>
|
||||
)}
|
||||
|
||||
|
||||
@@ -57,6 +57,8 @@ export const HostDetailsAdvancedSections: React.FC<HostDetailsAdvancedSectionsPr
|
||||
}) => {
|
||||
const inheritedDeviceType = effectiveGroupDefaults?.deviceType;
|
||||
const effectiveDeviceType = form.deviceType ?? inheritedDeviceType;
|
||||
const inheritedSingleChannelSsh = effectiveGroupDefaults?.singleChannelSsh;
|
||||
const effectiveSingleChannelSsh = form.singleChannelSsh ?? inheritedSingleChannelSsh;
|
||||
const inheritedStartupCommandRunMode = effectiveGroupDefaults?.startupCommandRunMode ?? "paste";
|
||||
const effectiveStartupCommandRunMode = form.startupCommandRunMode ?? inheritedStartupCommandRunMode;
|
||||
const systemSshAgentSupported = effectiveAuthMethod === "auto" || effectiveAuthMethod === "key";
|
||||
@@ -371,6 +373,20 @@ export const HostDetailsAdvancedSections: React.FC<HostDetailsAdvancedSectionsPr
|
||||
</p>
|
||||
</div>
|
||||
)}
|
||||
<ToggleRow
|
||||
label={t("hostDetails.singleChannelSsh")}
|
||||
hint={t("hostDetails.singleChannelSsh.desc")}
|
||||
enabled={!!effectiveSingleChannelSsh}
|
||||
onToggle={() => update("singleChannelSsh", !effectiveSingleChannelSsh)}
|
||||
/>
|
||||
{!!effectiveSingleChannelSsh && (
|
||||
<div className="flex items-start gap-2 p-2 rounded-md bg-yellow-500/10 border border-yellow-500/20">
|
||||
<AlertTriangle size={14} className="text-yellow-500 mt-0.5 flex-shrink-0" />
|
||||
<p className="text-xs text-yellow-600 dark:text-yellow-400 break-words">
|
||||
{t("hostDetails.singleChannelSsh.warning")}
|
||||
</p>
|
||||
</div>
|
||||
)}
|
||||
</>)}
|
||||
<Collapsible>
|
||||
<CollapsibleTrigger asChild>
|
||||
|
||||
@@ -76,6 +76,7 @@ import {
|
||||
resolveLocateSftpPathSessionId,
|
||||
} from "../domain/sftpLocatePathInTerminal";
|
||||
import { classifyDistroId } from "../domain/host";
|
||||
import { remoteSoftwareRequiresSingleChannel } from "../domain/singleChannelSshBanner.shared.cjs";
|
||||
import { useTerminalBackend } from "../application/state/useTerminalBackend";
|
||||
import { isTerminalSensitiveInputActive } from "./terminal/runtime/terminalSensitiveInputRegistry";
|
||||
import { isTerminalReadyForCommandInjection } from "./terminal/runtime/terminalCommandInjectionReadyRegistry";
|
||||
@@ -95,6 +96,8 @@ import {
|
||||
shouldCancelSettledPendingSftpRebindWithoutTarget,
|
||||
shouldDeferPendingSftpUploadForOriginFocus,
|
||||
shouldDeferSftpSidePanelAutoConnectForSession,
|
||||
isSplitFocusSftpStillConnected,
|
||||
shouldKeepConnectedSftpOnSplitFocus,
|
||||
shouldRebindSftpSidePanelSourceSession,
|
||||
shouldSkipSftpSidePanelAutoConnect,
|
||||
shouldStartPendingSftpUploadRebind,
|
||||
@@ -570,6 +573,41 @@ const SftpSidePanelInner: React.FC<SftpSidePanelProps> = ({
|
||||
const lastBrowsedPathByConnectionKeyRef = useRef<Map<string, string>>(new Map());
|
||||
const [interactiveWorkActive, setInteractiveWorkActive] = useState(false);
|
||||
const [sftpUiReady, setSftpUiReady] = useState(false);
|
||||
const terminalBackend = useTerminalBackend();
|
||||
const [sftpBannerDecision, setSftpBannerDecision] = useState<{
|
||||
sessionId: string;
|
||||
singleChannel: boolean;
|
||||
} | null>(null);
|
||||
const activeSessionStatusForBanner = activeSessionId
|
||||
? sessions.find((session) => session.id === activeSessionId)?.status ?? null
|
||||
: null;
|
||||
|
||||
useEffect(() => {
|
||||
if (!activeSessionId || activeSessionStatusForBanner !== "connected") {
|
||||
setSftpBannerDecision(null);
|
||||
return;
|
||||
}
|
||||
const readRemoteInfo = terminalBackend.getSessionRemoteInfo;
|
||||
if (!readRemoteInfo) {
|
||||
setSftpBannerDecision({ sessionId: activeSessionId, singleChannel: false });
|
||||
return;
|
||||
}
|
||||
let cancelled = false;
|
||||
void readRemoteInfo(activeSessionId).then((info) => {
|
||||
if (cancelled) return;
|
||||
setSftpBannerDecision({
|
||||
sessionId: activeSessionId,
|
||||
singleChannel: remoteSoftwareRequiresSingleChannel(info?.remoteSshVersion),
|
||||
});
|
||||
}).catch(() => {
|
||||
if (!cancelled) {
|
||||
setSftpBannerDecision({ sessionId: activeSessionId, singleChannel: false });
|
||||
}
|
||||
});
|
||||
return () => {
|
||||
cancelled = true;
|
||||
};
|
||||
}, [activeSessionId, activeSessionStatusForBanner, terminalBackend]);
|
||||
|
||||
useEffect(() => {
|
||||
pruneSftpSidePanelState(
|
||||
@@ -693,6 +731,16 @@ const SftpSidePanelInner: React.FC<SftpSidePanelProps> = ({
|
||||
previousStatus: lastSourceSessionStatusRef.current,
|
||||
nextStatus: activeSessionStatus ?? (activeSessionId ? "connected" : null),
|
||||
});
|
||||
// The peer banner is known only after the terminal handshake. Wait for it
|
||||
// before choosing a shared terminal channel, so a one-channel bastion
|
||||
// dials its file login directly.
|
||||
if (
|
||||
activeSessionId
|
||||
&& activeSessionStatus === "connected"
|
||||
&& sftpBannerDecision?.sessionId !== activeSessionId
|
||||
) {
|
||||
return;
|
||||
}
|
||||
|
||||
const hasBackendSession = (connectionId: string) => !!s.getSftpIdForConnection(connectionId);
|
||||
const activeTab = s.leftTabs.tabs.find((tab) => tab.id === s.leftTabs.activeTabId) ?? null;
|
||||
@@ -708,16 +756,33 @@ const SftpSidePanelInner: React.FC<SftpSidePanelProps> = ({
|
||||
// Rebind when the focused terminal SSH session changes: saved host keys can
|
||||
// lag live session endpoints (edited host / unsaved user). Still keep the
|
||||
// browsed path sticky via remembered initialPath below.
|
||||
// Single-channel SFTP does not borrow that terminal, so A -> B -> A must
|
||||
// keep the login that is already up for this endpoint.
|
||||
const backendSessionUp = activeConnectionId ? hasBackendSession(activeConnectionId) : false;
|
||||
const endpointAlreadyConnected = shouldSkipSftpSidePanelAutoConnect(
|
||||
connectionKey,
|
||||
connectedKeyRef.current,
|
||||
activeTab,
|
||||
backendSessionUp,
|
||||
activeTabConnectionKey,
|
||||
);
|
||||
const splitEndpointStillUp = isSplitFocusSftpStillConnected(
|
||||
connectionKey,
|
||||
connectedKeyRef.current,
|
||||
activeTab,
|
||||
backendSessionUp,
|
||||
activeTabConnectionKey,
|
||||
);
|
||||
const keepSplitSftp = shouldKeepConnectedSftpOnSplitFocus({
|
||||
sessionChanged,
|
||||
previousSessionId: lastSourceSessionIdRef.current,
|
||||
nextSessionId: activeSessionId,
|
||||
endpointAlreadyConnected: splitEndpointStillUp,
|
||||
});
|
||||
if (
|
||||
!sessionChanged
|
||||
&& !pendingRequiresForcedRebind
|
||||
&& shouldSkipSftpSidePanelAutoConnect(
|
||||
connectionKey,
|
||||
connectedKeyRef.current,
|
||||
activeTab,
|
||||
activeConnectionId ? hasBackendSession(activeConnectionId) : false,
|
||||
activeTabConnectionKey,
|
||||
)
|
||||
!pendingRequiresForcedRebind
|
||||
&& (endpointAlreadyConnected || splitEndpointStillUp)
|
||||
&& (!sessionChanged || keepSplitSftp)
|
||||
) {
|
||||
if (activeSessionId) {
|
||||
lastSourceSessionIdRef.current = activeSessionId;
|
||||
@@ -869,13 +934,23 @@ const SftpSidePanelInner: React.FC<SftpSidePanelProps> = ({
|
||||
|
||||
connectedKeyRef.current = connectionKey;
|
||||
connectedHostObjRef.current = activeHost;
|
||||
const dedicatedSftpLogin = activeHost.singleChannelSsh === true
|
||||
|| (
|
||||
sftpBannerDecision?.sessionId === activeSessionId
|
||||
&& sftpBannerDecision.singleChannel
|
||||
);
|
||||
const reuseTerminalTransport = !dedicatedSftpLogin && (
|
||||
Boolean(pendingStrictSourceSessionId) || activeSessionStatus === "connected"
|
||||
);
|
||||
const connect = (
|
||||
connectRequestKey?: string,
|
||||
onConnectionCreated?: (target: { tabId: string; connectionId: string }) => void,
|
||||
) => s.connect("left", activeHost, {
|
||||
sourceSessionId: pendingStrictSourceSessionId
|
||||
?? (activeSessionStatus === "connected" ? (activeSessionId ?? undefined) : undefined),
|
||||
requireSourceSessionReuse: Boolean(pendingStrictSourceSessionId),
|
||||
sourceSessionId: reuseTerminalTransport
|
||||
? (pendingStrictSourceSessionId ?? (activeSessionId ?? undefined))
|
||||
: undefined,
|
||||
requireSourceSessionReuse: reuseTerminalTransport && Boolean(pendingStrictSourceSessionId),
|
||||
...(reuseTerminalTransport ? {} : { reuseTransport: false }),
|
||||
...(connectRequestKey ? { connectRequestKey } : undefined),
|
||||
onConnectionCreated,
|
||||
...(initialPath ? { initialPath } : undefined),
|
||||
@@ -907,6 +982,7 @@ const SftpSidePanelInner: React.FC<SftpSidePanelProps> = ({
|
||||
pendingUploadRebindSettledRequestId,
|
||||
pendingUploadRebindStartedIdRef,
|
||||
sessions,
|
||||
sftpBannerDecision,
|
||||
startPendingUploadRebind,
|
||||
]);
|
||||
|
||||
@@ -1308,6 +1384,10 @@ const SftpSidePanelInner: React.FC<SftpSidePanelProps> = ({
|
||||
sftpDefaultViewMode={sftpDefaultViewMode}
|
||||
activeHost={activeHost}
|
||||
activeSessionId={activeSessionId}
|
||||
bannerRequiresSingleChannel={
|
||||
sftpBannerDecision?.sessionId === activeSessionId
|
||||
&& sftpBannerDecision.singleChannel
|
||||
}
|
||||
focusedSessionId={focusedSessionId}
|
||||
showWorkspaceHostHeader={showWorkspaceHostHeader}
|
||||
renderOverlays={renderOverlays}
|
||||
@@ -1356,6 +1436,7 @@ type SftpSidePanelInteractiveBodyProps = {
|
||||
sftpDefaultViewMode: "list" | "tree";
|
||||
activeHost: Host | null;
|
||||
activeSessionId?: string | null;
|
||||
bannerRequiresSingleChannel?: boolean;
|
||||
focusedSessionId?: string | null;
|
||||
showWorkspaceHostHeader: boolean;
|
||||
renderOverlays: boolean;
|
||||
@@ -1406,6 +1487,7 @@ const SftpSidePanelInteractiveBody: React.FC<SftpSidePanelInteractiveBodyProps>
|
||||
sftpDefaultViewMode,
|
||||
activeHost,
|
||||
activeSessionId = null,
|
||||
bannerRequiresSingleChannel = false,
|
||||
focusedSessionId = null,
|
||||
showWorkspaceHostHeader,
|
||||
renderOverlays,
|
||||
@@ -1613,11 +1695,12 @@ const SftpSidePanelInteractiveBody: React.FC<SftpSidePanelInteractiveBodyProps>
|
||||
|
||||
const canFollowTerminalCwd = useMemo(() => {
|
||||
if (!onGetTerminalCwd || !followTerminalCwdHost) return false;
|
||||
if (followTerminalCwdHost.singleChannelSsh === true || bannerRequiresSingleChannel) return false;
|
||||
const proto = followTerminalCwdHost.protocol;
|
||||
if (proto === "local" || proto === "serial") return false;
|
||||
if (followTerminalCwdHost.id?.startsWith("local-") || followTerminalCwdHost.id?.startsWith("serial-")) return false;
|
||||
return true;
|
||||
}, [followTerminalCwdHost, onGetTerminalCwd]);
|
||||
}, [bannerRequiresSingleChannel, followTerminalCwdHost, onGetTerminalCwd]);
|
||||
|
||||
const hasActiveWork = showTextEditor || !!permissionsState || showFileOpenerDialog
|
||||
|| (sftp.activeFileWatchCountRef?.current ?? 0) > 0
|
||||
|
||||
+19
-11
@@ -45,7 +45,7 @@ import {
|
||||
resolveTerminalContextLineWindow,
|
||||
type TerminalContextReader,
|
||||
} from "../domain/terminalContextRead";
|
||||
import { classifyDistroId, shouldProbeSessionCwd } from "../domain/host";
|
||||
import { classifyDistroId, hostRestrictsExtraSshChannels, shouldProbeSessionCwd } from "../domain/host";
|
||||
import { shouldCollectServerStats } from "../domain/systemManager/systemTarget";
|
||||
import { resolveHostSshConnectionTimeouts } from "../domain/sshConnectionTimeouts";
|
||||
import { CONNECTION_PROGRESS_START } from "./terminal/connectionProgress";
|
||||
@@ -201,6 +201,7 @@ import {
|
||||
import {
|
||||
createTerminalCwdTracker,
|
||||
invalidateTerminalCwdAfterCommand,
|
||||
shouldPreserveTerminalCwdAcrossCommand,
|
||||
resolvePreferredTerminalCwd,
|
||||
type TerminalCwdChangeMeta,
|
||||
} from "./terminal/sftpCwd";
|
||||
@@ -1132,6 +1133,7 @@ const TerminalComponent: React.FC<TerminalProps> = ({
|
||||
systemUnknown: resolvedAutocompleteOs === 'unknown',
|
||||
isNetworkDevice: host.deviceType === 'network'
|
||||
|| classifyDistroId(host.distro) === 'network-device',
|
||||
restrictPtyRewrites: host.singleChannelSsh === true,
|
||||
});
|
||||
|
||||
const resolveSftpInitialPath = useCallback(async (options?: {
|
||||
@@ -1139,12 +1141,15 @@ const TerminalComponent: React.FC<TerminalProps> = ({
|
||||
allowRendererFallback?: boolean;
|
||||
requireActiveShellCwd?: boolean;
|
||||
}): Promise<string | undefined> => {
|
||||
const skipBackendPwd = hostRestrictsExtraSshChannels(host);
|
||||
const cwd = await resolvePreferredTerminalCwd({
|
||||
rendererCwd: terminalCwdTracker.getRendererCwd(),
|
||||
rendererCwdSource: terminalCwdTracker.getRendererCwdSource(),
|
||||
sessionId: sessionRef.current,
|
||||
getSessionPwd: (id, options) => terminalBackend.getSessionPwd(id, options),
|
||||
preferFreshBackend: options?.preferFreshBackend,
|
||||
getSessionPwd: skipBackendPwd
|
||||
? async () => ({ success: false })
|
||||
: (id, pwdOptions) => terminalBackend.getSessionPwd(id, pwdOptions),
|
||||
preferFreshBackend: skipBackendPwd ? false : options?.preferFreshBackend,
|
||||
allowRendererFallback: options?.allowRendererFallback,
|
||||
requireActiveShellCwd: options?.requireActiveShellCwd,
|
||||
});
|
||||
@@ -2380,14 +2385,17 @@ const TerminalComponent: React.FC<TerminalProps> = ({
|
||||
const cwdAwareOnCommandSubmitted = useCallback((
|
||||
...args: Parameters<NonNullable<typeof onCommandSubmitted>>
|
||||
) => {
|
||||
invalidateTerminalCwdAfterCommand(
|
||||
terminalCwdTracker,
|
||||
sessionId,
|
||||
() => { knownCwdRef.current = undefined; },
|
||||
onTerminalCwdChange,
|
||||
);
|
||||
onCommandSubmitted?.(...args);
|
||||
}, [onCommandSubmitted, onTerminalCwdChange, sessionId, terminalCwdTracker]);
|
||||
if (!shouldPreserveTerminalCwdAcrossCommand(hostRestrictsExtraSshChannels(host))) {
|
||||
invalidateTerminalCwdAfterCommand(
|
||||
terminalCwdTracker,
|
||||
sessionId,
|
||||
() => { knownCwdRef.current = undefined; },
|
||||
onTerminalCwdChange,
|
||||
);
|
||||
}
|
||||
const [command, hostId, hostLabel, submittedSessionId] = args;
|
||||
onCommandSubmitted?.(command, hostId, hostLabel, submittedSessionId);
|
||||
}, [host, onCommandSubmitted, onTerminalCwdChange, sessionId, terminalCwdTracker]);
|
||||
const pluginAwareOnCommandCompleted = useCallback(() => {
|
||||
pluginTerminalLifecycle.onCommandCompleted();
|
||||
void xtermRuntimeRef.current?.pluginProviderHost?.commandCompleted();
|
||||
|
||||
@@ -117,7 +117,7 @@ import {
|
||||
type RendererCwdSource,
|
||||
type TerminalCwdChangeMeta,
|
||||
} from './terminal/sftpCwd';
|
||||
import { classifyDistroId, shouldProbeSessionCwd } from '../domain/host';
|
||||
import { classifyDistroId, hostRestrictsExtraSshChannels, shouldProbeSessionCwd } from '../domain/host';
|
||||
import {
|
||||
collectSidePanelPanes,
|
||||
sidePanelLayoutHasTool,
|
||||
@@ -1323,6 +1323,7 @@ const TerminalLayerInner: React.FC<TerminalLayerProps> = ({
|
||||
visibleSftpHost,
|
||||
sessionHost,
|
||||
globalSftpFollowTerminalCwd: sftpFollowTerminalCwdRef.current,
|
||||
restrictExtraSshChannels: hostRestrictsExtraSshChannels(sessionHost),
|
||||
})) return;
|
||||
|
||||
const osc7SignalAtCommand = terminalOsc7SignalBySessionRef.current.get(sessionId) ?? 0;
|
||||
@@ -1338,13 +1339,14 @@ const TerminalLayerInner: React.FC<TerminalLayerProps> = ({
|
||||
if (cwdProbeGenerationRef.current.get(sessionId) !== probeGeneration) return false;
|
||||
const host = sessionHostsMapRef.current.get(sessionId);
|
||||
if (!host) return false;
|
||||
const detectedDeviceClass = classifyDistroId(host.distro);
|
||||
const isNetworkDevice =
|
||||
host.deviceType === 'network' || detectedDeviceClass === 'network-device';
|
||||
const hostDeviceClass = classifyDistroId(host.distro);
|
||||
const hostIsNetworkDevice =
|
||||
host.deviceType === 'network' || hostDeviceClass === 'network-device';
|
||||
const info = await terminalBackend.getSessionRemoteInfo?.(sessionId);
|
||||
return shouldProbeSessionCwd({
|
||||
isNetworkDevice,
|
||||
isNetworkDevice: hostIsNetworkDevice,
|
||||
remoteSshVersion: info?.remoteSshVersion,
|
||||
restrictExtraSshChannels: hostRestrictsExtraSshChannels(host),
|
||||
});
|
||||
},
|
||||
onProbedCwd: (cwd) => {
|
||||
@@ -1552,14 +1554,18 @@ const TerminalLayerInner: React.FC<TerminalLayerProps> = ({
|
||||
requireActiveShellCwd?: boolean;
|
||||
}): Promise<string | null> => {
|
||||
const sessionId = getActiveTerminalSessionId();
|
||||
const host = sessionId ? sessionHostsMapRef.current.get(sessionId) : undefined;
|
||||
const skipBackendPwd = hostRestrictsExtraSshChannels(host);
|
||||
return resolvePreferredTerminalCwd({
|
||||
rendererCwd: sessionId ? terminalRendererCwdBySessionRef.current.get(sessionId) : undefined,
|
||||
rendererCwdSource: sessionId
|
||||
? terminalRendererCwdSourceBySessionRef.current.get(sessionId)
|
||||
: undefined,
|
||||
sessionId,
|
||||
getSessionPwd: (id, options) => terminalBackend.getSessionPwd(id, options),
|
||||
preferFreshBackend: options?.preferFreshBackend,
|
||||
getSessionPwd: skipBackendPwd
|
||||
? async () => ({ success: false })
|
||||
: (id, pwdOptions) => terminalBackend.getSessionPwd(id, pwdOptions),
|
||||
preferFreshBackend: skipBackendPwd ? false : options?.preferFreshBackend,
|
||||
allowRendererFallback: options?.allowRendererFallback,
|
||||
requireActiveShellCwd: options?.requireActiveShellCwd,
|
||||
});
|
||||
|
||||
@@ -51,6 +51,7 @@ interface SftpPaneDialogsProps {
|
||||
showDeleteConfirm: boolean;
|
||||
setShowDeleteConfirm: (open: boolean) => void;
|
||||
deleteTargets: string[];
|
||||
deleteError?: string | null;
|
||||
handleDelete: () => void;
|
||||
isDeleting: boolean;
|
||||
// Host picker (connected view)
|
||||
@@ -104,6 +105,7 @@ export const SftpPaneDialogs: React.FC<SftpPaneDialogsProps> = ({
|
||||
showDeleteConfirm,
|
||||
setShowDeleteConfirm,
|
||||
deleteTargets,
|
||||
deleteError,
|
||||
handleDelete,
|
||||
isDeleting,
|
||||
showHostPicker,
|
||||
@@ -351,16 +353,24 @@ export const SftpPaneDialogs: React.FC<SftpPaneDialogsProps> = ({
|
||||
))}
|
||||
</div>
|
||||
) : null}
|
||||
{deleteError ? (
|
||||
<p className="break-words text-xs text-destructive [overflow-wrap:anywhere]">
|
||||
{deleteError}
|
||||
</p>
|
||||
) : null}
|
||||
</div>
|
||||
<DialogFooter>
|
||||
<Button
|
||||
type="button"
|
||||
variant="outline"
|
||||
onClick={() => setShowDeleteConfirm(false)}
|
||||
disabled={isDeleting}
|
||||
>
|
||||
{t("common.cancel")}
|
||||
</Button>
|
||||
<Button
|
||||
ref={deleteConfirmButtonRef}
|
||||
type="button"
|
||||
variant="destructive"
|
||||
onClick={handleDelete}
|
||||
disabled={isDeleting}
|
||||
|
||||
@@ -694,27 +694,48 @@ export const SftpPaneFileList: React.FC<SftpPaneFileListProps> = React.memo(({
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
) : pane.error && !pane.reconnecting ? (
|
||||
) : pane.error && !pane.reconnecting && pane.files.length === 0 ? (
|
||||
<SftpErrorWithLogs
|
||||
error={pane.error}
|
||||
connectionLogs={pane.connectionLogs}
|
||||
onRetry={onRefresh}
|
||||
t={t}
|
||||
/>
|
||||
) : sortedDisplayFiles.length === 0 ? (
|
||||
<div className="flex flex-col items-center justify-center h-full text-muted-foreground">
|
||||
<Folder size={32} className="mb-2 opacity-50" />
|
||||
<span className="text-sm">{t("sftp.emptyDirectory")}</span>
|
||||
</div>
|
||||
) : (
|
||||
<div
|
||||
className={cn(
|
||||
shouldVirtualize ? "relative" : "divide-y divide-border/30",
|
||||
<>
|
||||
{pane.error && !pane.reconnecting ? (
|
||||
<div className="sticky top-0 z-10 flex items-center gap-2 border-b border-destructive/30 bg-destructive/10 px-3 py-1.5 text-xs text-destructive">
|
||||
<span className="min-w-0 flex-1 break-words [overflow-wrap:anywhere]">{t(pane.error)}</span>
|
||||
<Button
|
||||
type="button"
|
||||
variant="ghost"
|
||||
size="sm"
|
||||
className="h-6 shrink-0 px-2 text-xs"
|
||||
onClick={(event) => {
|
||||
event.stopPropagation();
|
||||
onRefresh();
|
||||
}}
|
||||
>
|
||||
{t("sftp.retry")}
|
||||
</Button>
|
||||
</div>
|
||||
) : null}
|
||||
{sortedDisplayFiles.length === 0 ? (
|
||||
<div className="flex flex-col items-center justify-center h-full text-muted-foreground">
|
||||
<Folder size={32} className="mb-2 opacity-50" />
|
||||
<span className="text-sm">{t("sftp.emptyDirectory")}</span>
|
||||
</div>
|
||||
) : (
|
||||
<div
|
||||
className={cn(
|
||||
shouldVirtualize ? "relative" : "divide-y divide-border/30",
|
||||
)}
|
||||
style={shouldVirtualize ? { height: totalHeight } : undefined}
|
||||
>
|
||||
{fileRows}
|
||||
</div>
|
||||
)}
|
||||
style={shouldVirtualize ? { height: totalHeight } : undefined}
|
||||
>
|
||||
{fileRows}
|
||||
</div>
|
||||
</>
|
||||
)}
|
||||
|
||||
{/* Drop overlay */}
|
||||
|
||||
@@ -292,6 +292,7 @@ const SftpPaneViewInner: React.FC<SftpPaneViewProps> = ({
|
||||
renameName,
|
||||
showDeleteConfirm,
|
||||
deleteTargets,
|
||||
deleteError,
|
||||
isCreating,
|
||||
isCreatingFile,
|
||||
isRenaming,
|
||||
@@ -736,6 +737,7 @@ const SftpPaneViewInner: React.FC<SftpPaneViewProps> = ({
|
||||
showDeleteConfirm={showDeleteConfirm}
|
||||
setShowDeleteConfirm={setShowDeleteConfirm}
|
||||
deleteTargets={deleteTargets}
|
||||
deleteError={deleteError}
|
||||
handleDelete={handleDelete}
|
||||
isDeleting={isDeleting}
|
||||
showHostPicker={showHostPicker}
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
import { useCallback, useRef, useState } from "react";
|
||||
import type { SftpPaneCallbacks } from "../SftpContext";
|
||||
import type { SftpPane } from "../../../application/state/sftp/types";
|
||||
import { isSessionError, unwrapSftpIpcError } from "../../../application/state/sftp/errors";
|
||||
import { getFileName, getParentPath } from "../../../application/state/sftp/utils";
|
||||
import { logger } from "../../../lib/logger";
|
||||
|
||||
@@ -62,6 +63,7 @@ interface UseSftpPaneDialogsResult {
|
||||
isCreatingFile: boolean;
|
||||
isRenaming: boolean;
|
||||
isDeleting: boolean;
|
||||
deleteError: string | null;
|
||||
setShowHostPicker: (open: boolean) => void;
|
||||
setHostSearch: (value: string) => void;
|
||||
setShowNewFolderDialog: (open: boolean) => void;
|
||||
@@ -110,8 +112,13 @@ export const useSftpPaneDialogs = ({
|
||||
const [showRenameDialog, setShowRenameDialog] = useState(false);
|
||||
const [renameTarget, setRenameTarget] = useState<string | null>(null);
|
||||
const [renameName, setRenameName] = useState("");
|
||||
const [showDeleteConfirm, setShowDeleteConfirm] = useState(false);
|
||||
const [showDeleteConfirm, setShowDeleteConfirmState] = useState(false);
|
||||
const [deleteTargets, setDeleteTargets] = useState<string[]>([]);
|
||||
const [deleteError, setDeleteError] = useState<string | null>(null);
|
||||
const setShowDeleteConfirm = useCallback((open: boolean) => {
|
||||
if (!open) setDeleteError(null);
|
||||
setShowDeleteConfirmState(open);
|
||||
}, []);
|
||||
const [isCreating, setIsCreating] = useState(false);
|
||||
const [isCreatingFile, setIsCreatingFile] = useState(false);
|
||||
const [isRenaming, setIsRenaming] = useState(false);
|
||||
@@ -262,8 +269,8 @@ export const useSftpPaneDialogs = ({
|
||||
const handleDelete = useCallback(async () => {
|
||||
if (deleteTargetsRef.current.length === 0 || isDeleting) return;
|
||||
setIsDeleting(true);
|
||||
setDeleteError(null);
|
||||
try {
|
||||
// deleteTargets are full paths; group by parent dir and use path-aware variant
|
||||
const byDir = new Map<string, string[]>();
|
||||
for (const fullPath of deleteTargetsRef.current) {
|
||||
const dir = getParentPath(fullPath);
|
||||
@@ -280,15 +287,22 @@ export const useSftpPaneDialogs = ({
|
||||
await onDeleteFilesAtPath(connectionId, dir, names);
|
||||
}
|
||||
onMutateSuccess?.(Array.from(byDir.keys()));
|
||||
setDeleteError(null);
|
||||
setShowDeleteConfirm(false);
|
||||
setDeleteTargets([]);
|
||||
onClearSelection();
|
||||
} catch (err) {
|
||||
logger.warn("Failed to delete files", err);
|
||||
if (isSessionError(err)) {
|
||||
setDeleteTargets([]);
|
||||
setShowDeleteConfirm(false);
|
||||
return;
|
||||
}
|
||||
setDeleteError(`${t("sftp.error.deleteFailed")}: ${unwrapSftpIpcError(err)}`);
|
||||
} finally {
|
||||
setIsDeleting(false);
|
||||
}
|
||||
}, [isDeleting, onDeleteFilesAtPath, onMutateSuccess, onClearSelection]);
|
||||
}, [isDeleting, onClearSelection, onDeleteFilesAtPath, onMutateSuccess, t]);
|
||||
|
||||
// entryPath is the full path; renameName is initialized to the basename
|
||||
const openRenameDialog = useCallback((entryPath: string) => {
|
||||
@@ -325,6 +339,7 @@ export const useSftpPaneDialogs = ({
|
||||
}, []);
|
||||
|
||||
const openDeleteConfirm = useCallback((names: string[]) => {
|
||||
setDeleteError(null);
|
||||
setDeleteTargets(names);
|
||||
setShowDeleteConfirm(true);
|
||||
}, []);
|
||||
@@ -344,6 +359,7 @@ export const useSftpPaneDialogs = ({
|
||||
renameName,
|
||||
showDeleteConfirm,
|
||||
deleteTargets,
|
||||
deleteError,
|
||||
isCreating,
|
||||
isCreatingFile,
|
||||
isRenaming,
|
||||
|
||||
@@ -45,3 +45,12 @@ test("locate-path uses focused session fallback when SFTP cannot reuse the termi
|
||||
/focusedSessionId=\{panelFocusedSessionId\}/,
|
||||
);
|
||||
});
|
||||
|
||||
test("single-channel SFTP opens a dedicated connection instead of the terminal channel", () => {
|
||||
assert.match(sidePanelSource, /activeHost\.singleChannelSsh === true/);
|
||||
assert.match(sidePanelSource, /reuseTransport: false/);
|
||||
assert.match(
|
||||
sidePanelSource,
|
||||
/const reuseTerminalTransport = !dedicatedSftpLogin &&/,
|
||||
);
|
||||
});
|
||||
@@ -16,6 +16,8 @@ import {
|
||||
shouldCancelSettledPendingSftpRebindWithoutTarget,
|
||||
shouldDeferPendingSftpUploadForOriginFocus,
|
||||
shouldDeferSftpSidePanelAutoConnectForSession,
|
||||
isSplitFocusSftpStillConnected,
|
||||
shouldKeepConnectedSftpOnSplitFocus,
|
||||
shouldRebindSftpSidePanelSourceSession,
|
||||
shouldResetSftpSidePanelSourceSession,
|
||||
shouldSkipSftpSidePanelAutoConnect,
|
||||
@@ -770,3 +772,42 @@ test("session change still requires rebind even when the endpoint key matches",
|
||||
tab,
|
||||
);
|
||||
});
|
||||
|
||||
test("split focus keeps an SFTP login that is already connected", () => {
|
||||
assert.equal(shouldKeepConnectedSftpOnSplitFocus({
|
||||
sessionChanged: true,
|
||||
previousSessionId: "pane-a",
|
||||
nextSessionId: "pane-b",
|
||||
endpointAlreadyConnected: true,
|
||||
}), true);
|
||||
assert.equal(shouldKeepConnectedSftpOnSplitFocus({
|
||||
sessionChanged: true,
|
||||
previousSessionId: "pane-b",
|
||||
nextSessionId: "pane-a",
|
||||
endpointAlreadyConnected: true,
|
||||
}), true);
|
||||
assert.equal(shouldKeepConnectedSftpOnSplitFocus({
|
||||
sessionChanged: true,
|
||||
previousSessionId: "pane-a",
|
||||
nextSessionId: "pane-a",
|
||||
endpointAlreadyConnected: true,
|
||||
}), false);
|
||||
assert.equal(shouldKeepConnectedSftpOnSplitFocus({
|
||||
sessionChanged: true,
|
||||
previousSessionId: "pane-a",
|
||||
nextSessionId: "pane-b",
|
||||
endpointAlreadyConnected: false,
|
||||
}), false);
|
||||
});
|
||||
|
||||
test("split focus still counts a listing-in-progress SFTP session as connected", () => {
|
||||
const tab = remoteConnectedTab({ loading: true });
|
||||
assert.equal(
|
||||
isSplitFocusSftpStillConnected("host-key", "host-key", tab, true, "host-key"),
|
||||
true,
|
||||
);
|
||||
assert.equal(
|
||||
shouldSkipSftpSidePanelAutoConnect("host-key", "host-key", tab, true, "host-key"),
|
||||
false,
|
||||
);
|
||||
});
|
||||
@@ -314,6 +314,38 @@ export function findReusableSftpSidePanelTab(
|
||||
return candidate;
|
||||
}
|
||||
|
||||
/**
|
||||
* A directory listing in progress is still the same SSH login. Split focus
|
||||
* must not treat that as "disconnected" and start another handshake.
|
||||
*/
|
||||
export function isSplitFocusSftpStillConnected(
|
||||
connectionKey: string,
|
||||
connectedKey: string | null,
|
||||
activeTab: SftpSidePanelTabHealth | null | undefined,
|
||||
hasBackendSession: boolean,
|
||||
activeTabConnectionKey?: string | null,
|
||||
): boolean {
|
||||
if (connectedKey !== connectionKey) return false;
|
||||
if (!activeTab?.connection || activeTab.connection.isLocal) return false;
|
||||
if (activeTabConnectionKey !== connectionKey) return false;
|
||||
if (activeTab.connection.status !== "connected") return false;
|
||||
return hasBackendSession;
|
||||
}
|
||||
|
||||
/**
|
||||
* Switching Split panes of the same host, including back to a pane that was
|
||||
* already used, must keep the SFTP login that is already up.
|
||||
*/
|
||||
export function shouldKeepConnectedSftpOnSplitFocus(params: {
|
||||
sessionChanged: boolean;
|
||||
previousSessionId?: string | null;
|
||||
nextSessionId?: string | null;
|
||||
endpointAlreadyConnected: boolean;
|
||||
}): boolean {
|
||||
if (!params.sessionChanged || !params.endpointAlreadyConnected) return false;
|
||||
return shouldResetSftpSidePanelSourceSession(params.previousSessionId, params.nextSessionId);
|
||||
}
|
||||
|
||||
/** True when the linked terminal SSH session id changed. */
|
||||
export function shouldResetSftpSidePanelSourceSession(
|
||||
previousSessionId: string | null | undefined,
|
||||
|
||||
@@ -4,6 +4,8 @@ import React, { memo, useCallback, useEffect, useLayoutEffect, useMemo, useState
|
||||
import { useI18n } from '../../application/i18n/I18nProvider';
|
||||
import { SYSTEM_MANAGER_TAB_LAYOUT_DEFAULTS } from '../../application/state/systemManagerTabLayout';
|
||||
import { useSystemManagerBackend } from '../../application/state/useSystemManagerBackend';
|
||||
import { useTerminalBackend } from '../../application/state/useTerminalBackend';
|
||||
import { remoteSoftwareRequiresSingleChannel } from '../../domain/singleChannelSshBanner.shared.cjs';
|
||||
import { useToolbarItemLayout } from '../../application/state/useToolbarItemLayout';
|
||||
import type { TerminalSettings } from '../../domain/models';
|
||||
import type { Host } from '../../domain/models/connection';
|
||||
@@ -14,6 +16,7 @@ import {
|
||||
buildSystemManagerTabs,
|
||||
shouldCollectServerStats,
|
||||
} from '../../domain/systemManager/systemTarget';
|
||||
|
||||
import { partitionToolbarItems } from '../../domain/toolbarItemLayout';
|
||||
import { STORAGE_KEY_SYSTEM_MANAGER_TAB_LAYOUT } from '../../infrastructure/config/storageKeys';
|
||||
import type { Snippet, TerminalSession } from '../../types';
|
||||
@@ -78,12 +81,41 @@ export const SystemManagerSidePanel = memo(function SystemManagerSidePanel({
|
||||
}: SystemManagerSidePanelProps) {
|
||||
const { t } = useI18n();
|
||||
const backend = useSystemManagerBackend();
|
||||
const terminalBackend = useTerminalBackend();
|
||||
const sessionId = session?.id ?? null;
|
||||
const isConnected = session?.status === 'connected';
|
||||
const [peerSingleChannel, setPeerSingleChannel] = useState<boolean | null>(null);
|
||||
|
||||
useEffect(() => {
|
||||
if (!sessionId || !isConnected || sessionHost?.singleChannelSsh === true) {
|
||||
setPeerSingleChannel(sessionHost?.singleChannelSsh === true);
|
||||
return;
|
||||
}
|
||||
let cancelled = false;
|
||||
setPeerSingleChannel(null);
|
||||
void terminalBackend.getSessionRemoteInfo(sessionId).then((info) => {
|
||||
if (cancelled) return;
|
||||
setPeerSingleChannel(remoteSoftwareRequiresSingleChannel(info?.remoteSshVersion));
|
||||
}).catch(() => {
|
||||
if (!cancelled) setPeerSingleChannel(false);
|
||||
});
|
||||
return () => {
|
||||
cancelled = true;
|
||||
};
|
||||
}, [isConnected, sessionHost?.singleChannelSsh, sessionId, terminalBackend]);
|
||||
|
||||
const systemManagerSupported = sessionHost?.singleChannelSsh !== true && peerSingleChannel !== true;
|
||||
const capabilitiesEnabled = systemManagerSupported && peerSingleChannel !== null;
|
||||
|
||||
const capabilitiesTtlMs = terminalSettings.systemManagerProcessRefreshInterval * 1000;
|
||||
|
||||
const { capabilities, refreshCapabilities } = useSessionCapabilities(sessionId, isConnected, backend, isVisible, capabilitiesTtlMs);
|
||||
const { capabilities, refreshCapabilities } = useSessionCapabilities(
|
||||
sessionId,
|
||||
isConnected,
|
||||
backend,
|
||||
isVisible && capabilitiesEnabled,
|
||||
capabilitiesTtlMs,
|
||||
);
|
||||
|
||||
const availableTabs = useMemo(
|
||||
() => buildSystemManagerTabs(sessionHost, capabilities, session),
|
||||
@@ -356,6 +388,15 @@ export const SystemManagerSidePanel = memo(function SystemManagerSidePanel({
|
||||
);
|
||||
}
|
||||
|
||||
if (!systemManagerSupported) {
|
||||
return (
|
||||
<SystemPanelShell section="system-manager-panel">
|
||||
{workspaceHostHeader}
|
||||
<SystemPanelEmpty icon={Activity} message={t('systemManager.unsupportedRemote')} />
|
||||
</SystemPanelShell>
|
||||
);
|
||||
}
|
||||
|
||||
const tmuxReady = capabilities?.hasTmux === true;
|
||||
const dockerReady = capabilities?.hasDocker === true;
|
||||
const gpuReady = capabilities?.hasNvidiaSmi === true || capabilities?.hasNpuSmi === true;
|
||||
|
||||
@@ -251,6 +251,8 @@ export async function getCompletions(
|
||||
sessionId?: string;
|
||||
/** Connection protocol (ssh, local, telnet, serial) */
|
||||
protocol?: string;
|
||||
/** Skip remote/local path listings that need extra SSH exec (single-channel bastions). */
|
||||
skipPathCompletion?: boolean;
|
||||
/** Current working directory (from OSC 7) */
|
||||
cwd?: string;
|
||||
cwdSource?: AutocompleteCwdSource;
|
||||
@@ -339,7 +341,7 @@ export async function getCompletions(
|
||||
}
|
||||
}
|
||||
|
||||
const canQueryPaths = options.protocol === "local" || options.sessionId !== undefined;
|
||||
const canQueryPaths = !options.skipPathCompletion && (options.protocol === "local" || options.sessionId !== undefined);
|
||||
|
||||
const pathEntries = canQueryPaths && pathCheck.shouldComplete
|
||||
? await getPathSuggestionsWithinBudget(
|
||||
|
||||
@@ -16,11 +16,14 @@ type TerminalAutocompleteSettingFields = {
|
||||
export function resolveTerminalAutocompleteSettings(input: {
|
||||
protocol?: string;
|
||||
terminalSettings?: TerminalAutocompleteSettingFields;
|
||||
/** Vendor CLI / network-device session: skip live-preview PTY rewrites (#1193). */
|
||||
/** Vendor CLI / network device: skip live-preview PTY rewrites. */
|
||||
isNetworkDevice?: boolean;
|
||||
/** One-channel bastion: Ctrl-U line replacement kills the session (#1193). */
|
||||
restrictPtyRewrites?: boolean;
|
||||
systemUnknown?: boolean;
|
||||
}): Partial<AutocompleteSettings> | undefined {
|
||||
const { protocol, terminalSettings, isNetworkDevice, systemUnknown } = input;
|
||||
const { protocol, terminalSettings, isNetworkDevice, restrictPtyRewrites, systemUnknown } = input;
|
||||
const blockLivePreview = isNetworkDevice === true || restrictPtyRewrites === true;
|
||||
|
||||
if (protocol === "serial" || systemUnknown) {
|
||||
return {
|
||||
@@ -38,15 +41,19 @@ export function resolveTerminalAutocompleteSettings(input: {
|
||||
}
|
||||
|
||||
if (!terminalSettings) {
|
||||
return isNetworkDevice ? { livePreview: false } : undefined;
|
||||
if (!blockLivePreview && restrictPtyRewrites !== true) return undefined;
|
||||
return {
|
||||
...(blockLivePreview ? { livePreview: false } : {}),
|
||||
...(restrictPtyRewrites ? { allowLineReplacement: false } : {}),
|
||||
};
|
||||
}
|
||||
|
||||
return {
|
||||
enabled: terminalSettings.autocompleteEnabled ?? true,
|
||||
showGhostText: terminalSettings.autocompleteGhostText ?? true,
|
||||
showPopupMenu: terminalSettings.autocompletePopupMenu ?? true,
|
||||
livePreview: shouldWriteAutocompleteLivePreview(true, isNetworkDevice),
|
||||
allowLineReplacement: true,
|
||||
livePreview: shouldWriteAutocompleteLivePreview(true, blockLivePreview),
|
||||
allowLineReplacement: restrictPtyRewrites !== true,
|
||||
debounceMs: terminalSettings.autocompleteDebounceMs ?? 100,
|
||||
minChars: terminalSettings.autocompleteMinChars ?? 1,
|
||||
maxSuggestions: terminalSettings.autocompleteMaxSuggestions ?? 50,
|
||||
|
||||
@@ -554,6 +554,7 @@ export function useTerminalAutocomplete(
|
||||
|
||||
/** Fetch directory listing via IPC. */
|
||||
const fetchDirEntries = useCallback(async (dirPath: string): Promise<SubDirEntry[]> => {
|
||||
if (isNetworkDeviceRef.current) return [];
|
||||
return listDirectoryEntries(dirPath, {
|
||||
sessionId: sessionIdRef.current,
|
||||
protocol: protocolRef.current,
|
||||
@@ -1159,6 +1160,7 @@ export function useTerminalAutocomplete(
|
||||
historyScope: settingsRef.current.historyScope,
|
||||
sessionId: sessionIdRef.current,
|
||||
protocol: protocolRef.current,
|
||||
skipPathCompletion: isNetworkDeviceRef.current,
|
||||
cwd: cwdResolution.cwd,
|
||||
cwdSource: cwdResolution.source,
|
||||
snippets: snippetsRef.current,
|
||||
|
||||
@@ -270,6 +270,24 @@ test("removeCommandHistoryEntry trims command text like recordCommand", async ()
|
||||
);
|
||||
});
|
||||
|
||||
test("getCompletions skips remote path listings when skipPathCompletion is set", async () => {
|
||||
bridgeState.remoteCalls = [];
|
||||
bridgeState.remoteEntriesByPath = new Map([
|
||||
[".", [{ name: "workdir", type: "directory" }]],
|
||||
]);
|
||||
const completions = await getCompletions("ll ", {
|
||||
hostId: "host-1",
|
||||
os: "linux",
|
||||
sessionId: "session-1",
|
||||
protocol: "ssh",
|
||||
cwd: "/root",
|
||||
skipPathCompletion: true,
|
||||
pathBudgetMs: Infinity,
|
||||
});
|
||||
assert.equal(bridgeState.remoteCalls.length, 0);
|
||||
assert.equal(completions.some((entry) => entry.source === "path"), false);
|
||||
});
|
||||
|
||||
test("getCompletions uses the remote shell cwd for relative path arguments instead of stale home", async () => {
|
||||
bridgeState.remoteEntriesByPath.set("~", [{ name: "home-only.txt", type: "file" }]);
|
||||
bridgeState.remoteEntriesByPath.set(".", [{ name: "worktree.txt", type: "file" }]);
|
||||
|
||||
@@ -426,6 +426,13 @@ test("startSSH tells the bridge to skip shell discovery for network devices", as
|
||||
};
|
||||
const ctx = createStarterContext({
|
||||
isNetworkDevice: true,
|
||||
host: {
|
||||
id: "host-1",
|
||||
label: "Target",
|
||||
hostname: "target.example.test",
|
||||
username: "alice",
|
||||
deviceType: "network",
|
||||
},
|
||||
reuseConnectionFromSessionIdRef: { current: "source-session" },
|
||||
terminalBackend,
|
||||
});
|
||||
@@ -434,6 +441,40 @@ test("startSSH tells the bridge to skip shell discovery for network devices", as
|
||||
|
||||
assert.equal(capturedOptions?.sourceSessionId, "source-session");
|
||||
assert.equal(capturedOptions?.skipShellPidDiscovery, true);
|
||||
assert.equal(capturedOptions?.singleChannelSsh, false);
|
||||
});
|
||||
|
||||
test("startSSH opens a fresh connection when copying a single-channel host", async () => {
|
||||
let capturedOptions: Record<string, unknown> | null = null;
|
||||
const terminalBackend = {
|
||||
backendAvailable: () => true,
|
||||
startSSHSession: async (options: Record<string, unknown>) => {
|
||||
capturedOptions = options;
|
||||
return "ssh-session";
|
||||
},
|
||||
onSessionData: () => noop,
|
||||
onSessionExit: () => noop,
|
||||
onChainProgress: () => noop,
|
||||
writeToSession: noop,
|
||||
resizeSession: noop,
|
||||
};
|
||||
const ctx = createStarterContext({
|
||||
host: {
|
||||
id: "host-1",
|
||||
label: "Target",
|
||||
hostname: "target.example.test",
|
||||
username: "alice",
|
||||
singleChannelSsh: true,
|
||||
},
|
||||
reuseConnectionFromSessionIdRef: { current: "source-session" },
|
||||
terminalBackend,
|
||||
});
|
||||
|
||||
await createTerminalSessionStarters(ctx as never).startSSH(createTermStub() as never);
|
||||
|
||||
assert.equal(capturedOptions?.sourceSessionId, undefined);
|
||||
assert.equal(capturedOptions?.reuseTransport, false);
|
||||
assert.equal(capturedOptions?.singleChannelSsh, true);
|
||||
});
|
||||
|
||||
test("startSSH requests a fresh transport for ordinary opens with connection automation", async () => {
|
||||
|
||||
@@ -38,6 +38,7 @@ import {
|
||||
} from "../../../domain/credentials";
|
||||
import { resolveBridgeSshAgentAuth, resolveHostAuth } from "../../../domain/sshAuth";
|
||||
import {
|
||||
hostRestrictsExtraSshChannels,
|
||||
resolveHostKeepalive,
|
||||
resolveTelnetPassword,
|
||||
resolveTelnetPort,
|
||||
@@ -624,7 +625,10 @@ export const createTerminalSessionStarters = (ctx: TerminalSessionStartersContex
|
||||
useSshAgent?: boolean;
|
||||
}): Promise<string> => {
|
||||
// Reconnect supersedes a Copy/Split intent that was still waiting for credentials.
|
||||
const sourceSessionId = ctx.requireFreshConnectionOnReconnectRef?.current
|
||||
const sourceSessionId = (
|
||||
ctx.requireFreshConnectionOnReconnectRef?.current
|
||||
|| ctx.host.singleChannelSsh === true
|
||||
)
|
||||
? undefined
|
||||
: ctx.reuseConnectionFromSessionIdRef?.current;
|
||||
if (ctx.reuseConnectionFromSessionIdRef) {
|
||||
@@ -697,6 +701,7 @@ export const createTerminalSessionStarters = (ctx: TerminalSessionStartersContex
|
||||
charset: ctx.host.charset,
|
||||
// Persist for session-backed SFTP opens (AI tools / clipboard paste).
|
||||
sftpFileProtocol: ctx.host.sftpFileProtocol || "auto",
|
||||
singleChannelSsh: ctx.host.singleChannelSsh === true,
|
||||
env: termEnv,
|
||||
proxy: proxyConfig,
|
||||
jumpHosts: jumpHosts.length > 0 ? jumpHosts : undefined,
|
||||
@@ -726,7 +731,7 @@ export const createTerminalSessionStarters = (ctx: TerminalSessionStartersContex
|
||||
// Only an explicit Copy/Split may share an existing login. Ordinary
|
||||
// opens and reconnects must authenticate again to refresh remote groups.
|
||||
reuseTransport: sourceSessionId ? undefined : false,
|
||||
skipShellPidDiscovery: ctx.isNetworkDevice === true,
|
||||
skipShellPidDiscovery: ctx.isNetworkDevice === true || hostRestrictsExtraSshChannels(ctx.host),
|
||||
});
|
||||
if (!requiresFreshSshConnection) {
|
||||
ctx.onConnectAutomationSnapshotCommitted?.();
|
||||
|
||||
@@ -6,6 +6,68 @@ import {
|
||||
runDistroDetection,
|
||||
} from "./terminalDistroDetection.ts";
|
||||
|
||||
test("runDistroDetection skips POSIX probes when singleChannelSsh is enabled", async () => {
|
||||
let distroProbeCalls = 0;
|
||||
const detected: string[] = [];
|
||||
const token = registerConnectionToken("bastion-session");
|
||||
|
||||
await runDistroDetection({
|
||||
host: {
|
||||
id: "bastion-1",
|
||||
label: "Bastion",
|
||||
hostname: "bastion.example.com",
|
||||
username: "user",
|
||||
singleChannelSsh: true,
|
||||
},
|
||||
terminalBackend: {
|
||||
getSessionRemoteInfo: async () => ({
|
||||
success: true,
|
||||
remoteSshVersion: "CLOUDBILITY-4.14",
|
||||
}),
|
||||
getSessionDistroInfo: async () => {
|
||||
distroProbeCalls += 1;
|
||||
return { success: false, error: "must not probe single-channel SSH" };
|
||||
},
|
||||
},
|
||||
onOsDetected: (_hostId: string, distro: string) => {
|
||||
detected.push(distro);
|
||||
},
|
||||
} as never, "bastion-session", token);
|
||||
|
||||
assert.equal(distroProbeCalls, 0);
|
||||
assert.deepEqual(detected, []);
|
||||
});
|
||||
|
||||
test("runDistroDetection skips POSIX probes when the SSH banner is a one-channel bastion", async () => {
|
||||
let distroProbeCalls = 0;
|
||||
const token = registerConnectionToken("banner-session");
|
||||
|
||||
await runDistroDetection({
|
||||
host: {
|
||||
id: "bastion-banner",
|
||||
label: "Bastion",
|
||||
hostname: "bastion.example.com",
|
||||
username: "user",
|
||||
},
|
||||
terminalBackend: {
|
||||
getSessionRemoteInfo: async () => ({
|
||||
success: true,
|
||||
remoteSshVersion: "BHostSSH_7.0",
|
||||
}),
|
||||
getSessionDistroInfo: async () => {
|
||||
distroProbeCalls += 1;
|
||||
return { success: false, error: "must not probe bastion banner" };
|
||||
},
|
||||
},
|
||||
onOsDetected: () => {
|
||||
throw new Error("banner must not be classified as a distro");
|
||||
},
|
||||
} as never, "banner-session", token);
|
||||
|
||||
assert.equal(distroProbeCalls, 0);
|
||||
});
|
||||
|
||||
|
||||
test("runDistroDetection uses SSH banner but skips POSIX probes for manually marked network devices", async () => {
|
||||
let remoteInfoCalls = 0;
|
||||
let distroProbeCalls = 0;
|
||||
|
||||
@@ -1,8 +1,10 @@
|
||||
import {
|
||||
classifyDistroId,
|
||||
detectVendorFromSshVersion,
|
||||
hostRestrictsExtraSshChannels,
|
||||
normalizeDistroId,
|
||||
} from "../../../domain/host";
|
||||
import { remoteSoftwareRequiresSingleChannel } from "../../../domain/singleChannelSshBanner.shared.cjs";
|
||||
import { logger } from "../../../lib/logger";
|
||||
import type { TerminalSessionStartersContext } from "./createTerminalSessionStarters.types";
|
||||
|
||||
@@ -72,13 +74,16 @@ export const runDistroDetection = async (
|
||||
ctx.onOsDetected?.(ctx.host.id, vendor);
|
||||
return;
|
||||
}
|
||||
if (hostRestrictsExtraSshChannels(ctx.host) || remoteSoftwareRequiresSingleChannel(info?.remoteSshVersion)) {
|
||||
return;
|
||||
}
|
||||
}
|
||||
} catch (err) {
|
||||
logger.warn("SSH banner vendor detection failed", err);
|
||||
}
|
||||
|
||||
if (!isStillCurrent()) return;
|
||||
if (isKnownNetworkDevice) return;
|
||||
if (isKnownNetworkDevice || hostRestrictsExtraSshChannels(ctx.host)) return;
|
||||
|
||||
// Step 2: unknown or generic OpenSSH/Dropbear — fall back to the
|
||||
// /etc/os-release probe to pick a distro-specific icon. We deliberately
|
||||
|
||||
@@ -4,6 +4,7 @@ import assert from "node:assert/strict";
|
||||
import {
|
||||
createTerminalCwdTracker,
|
||||
invalidateTerminalCwdAfterCommand,
|
||||
shouldPreserveTerminalCwdAcrossCommand,
|
||||
probeBackendSessionCwdAfterCommand,
|
||||
resolvePreferredTerminalCwd,
|
||||
} from "./sftpCwd";
|
||||
@@ -285,3 +286,27 @@ test("probeBackendSessionCwdAfterCommand still probes when cwd path is unchanged
|
||||
|
||||
assert.equal(cwd, "/srv/app");
|
||||
});
|
||||
|
||||
test("active-shell cwd resolution trusts an inferred cwd without extra exec", async () => {
|
||||
let backendCalls = 0;
|
||||
const cwd = await resolvePreferredTerminalCwd({
|
||||
rendererCwd: "/data/docker",
|
||||
rendererCwdSource: "inferred",
|
||||
sessionId: "session-1",
|
||||
preferFreshBackend: true,
|
||||
allowRendererFallback: false,
|
||||
requireActiveShellCwd: true,
|
||||
getSessionPwd: async () => {
|
||||
backendCalls += 1;
|
||||
return { success: false, error: "extra exec channels" };
|
||||
},
|
||||
});
|
||||
|
||||
assert.equal(cwd, "/data/docker");
|
||||
assert.equal(backendCalls, 0);
|
||||
});
|
||||
|
||||
test("single-channel command submission keeps the cwd SFTP follow already has", () => {
|
||||
assert.equal(shouldPreserveTerminalCwdAcrossCommand(true), true);
|
||||
assert.equal(shouldPreserveTerminalCwdAcrossCommand(false), false);
|
||||
});
|
||||
@@ -17,6 +17,9 @@ type SessionPwdOptions = {
|
||||
export type RendererCwdSource = TerminalCwdSource;
|
||||
export type TerminalCwdChangeMeta = { source?: RendererCwdSource };
|
||||
|
||||
const isLiveTerminalCwdSource = (source?: RendererCwdSource | null): boolean =>
|
||||
source === "osc7" || source === "inferred";
|
||||
|
||||
type ResolvePreferredTerminalCwdOptions = {
|
||||
rendererCwd?: string | null;
|
||||
rendererCwdSource?: RendererCwdSource;
|
||||
@@ -68,6 +71,15 @@ export const createTerminalCwdTracker = (): TerminalCwdTracker => {
|
||||
};
|
||||
};
|
||||
|
||||
/**
|
||||
* Single-channel shells cannot probe pwd and often have no OSC 7. Clearing the
|
||||
* last inferred directory on an ordinary command makes the next relative cd in
|
||||
* a split pane lose its base, so SFTP follow stays on the old path.
|
||||
*/
|
||||
export const shouldPreserveTerminalCwdAcrossCommand = (
|
||||
restrictExtraSshChannels: boolean,
|
||||
): boolean => restrictExtraSshChannels;
|
||||
|
||||
/** Invalidate both the terminal-local provenance and the shared SFTP-follow cwd. */
|
||||
export const invalidateTerminalCwdAfterCommand = (
|
||||
tracker: TerminalCwdTracker,
|
||||
@@ -90,11 +102,11 @@ export const resolvePreferredTerminalCwd = async ({
|
||||
requireActiveShellCwd = false,
|
||||
}: ResolvePreferredTerminalCwdOptions): Promise<string | null> => {
|
||||
const knownCwd = normalizeCwd(rendererCwd);
|
||||
if (requireActiveShellCwd && knownCwd && rendererCwdSource === "osc7") {
|
||||
if (requireActiveShellCwd && knownCwd && isLiveTerminalCwdSource(rendererCwdSource)) {
|
||||
return knownCwd;
|
||||
}
|
||||
const canUseRendererFallback = allowRendererFallback && (
|
||||
!requireActiveShellCwd || rendererCwdSource === "osc7"
|
||||
!requireActiveShellCwd || isLiveTerminalCwdSource(rendererCwdSource)
|
||||
);
|
||||
if (!preferFreshBackend && knownCwd && canUseRendererFallback) return knownCwd;
|
||||
if (!sessionId) return canUseRendererFallback ? knownCwd : null;
|
||||
|
||||
@@ -69,6 +69,25 @@ test("keeps network-device autocomplete popup but disables live preview (#1193)"
|
||||
);
|
||||
});
|
||||
|
||||
test("single-channel bastions disable live preview and line replacement", () => {
|
||||
const settings = resolveTerminalAutocompleteSettings({
|
||||
protocol: "ssh",
|
||||
restrictPtyRewrites: true,
|
||||
terminalSettings: {
|
||||
autocompleteEnabled: true,
|
||||
},
|
||||
});
|
||||
assert.equal(settings?.livePreview, false);
|
||||
assert.equal(settings?.allowLineReplacement, false);
|
||||
assert.deepEqual(
|
||||
resolveTerminalAutocompleteSettings({
|
||||
protocol: "ssh",
|
||||
restrictPtyRewrites: true,
|
||||
}),
|
||||
{ livePreview: false, allowLineReplacement: false },
|
||||
);
|
||||
});
|
||||
|
||||
test("keeps serial autocomplete available but disables input-line preview and replacement", () => {
|
||||
assert.deepEqual(
|
||||
resolveTerminalAutocompleteSettings({
|
||||
|
||||
@@ -190,7 +190,7 @@ test('terminal view derives the network-device prompt policy before autocomplete
|
||||
);
|
||||
assert.match(
|
||||
terminalSource,
|
||||
/resolveTerminalAutocompleteSettings\(\{[\s\S]*?isNetworkDevice: host\.deviceType === 'network'[\s\S]*?classifyDistroId\(host\.distro\) === 'network-device',/,
|
||||
/resolveTerminalAutocompleteSettings\(\{[\s\S]*?isNetworkDevice: host\.deviceType === 'network'[\s\S]*?classifyDistroId\(host\.distro\) === 'network-device',[\s\S]*?restrictPtyRewrites: host\.singleChannelSsh === true,/,
|
||||
);
|
||||
const autocompleteSource = readFileSync(new URL('./TerminalAutocomplete.tsx', import.meta.url), 'utf8');
|
||||
assert.match(
|
||||
|
||||
@@ -68,3 +68,16 @@ test("mount effect re-arms disposedRef after dispose cleanup (HMR / StrictMode)"
|
||||
assert.ok(source.includes("disposedRef.current = false;"));
|
||||
assert.ok(source.includes("return () => { dispose(); };"));
|
||||
});
|
||||
|
||||
test("single-channel / network-device sessions skip remote path listings", () => {
|
||||
const source = readFileSync(
|
||||
fileURLToPath(new URL("./autocomplete/useTerminalAutocomplete.ts", import.meta.url)),
|
||||
"utf8",
|
||||
);
|
||||
assert.match(
|
||||
source,
|
||||
/const fetchDirEntries = useCallback\(async \(dirPath: string\): Promise<SubDirEntry\[\]> => \{\s*if \(isNetworkDeviceRef\.current\) return \[\];/,
|
||||
);
|
||||
assert.match(source, /skipPathCompletion: isNetworkDeviceRef\.current,/);
|
||||
});
|
||||
|
||||
@@ -25,6 +25,7 @@ import {
|
||||
resolveTerminalHibernateEnabledForProtocol,
|
||||
} from '../../domain/terminalHibernate';
|
||||
import { setTerminalBootEpoch } from '../../domain/terminalBootEpoch';
|
||||
import { hostRestrictsExtraSshChannels } from '../../domain/host';
|
||||
import {
|
||||
applyUserCursorBlinkPreference,
|
||||
snapshotUserCursorPreference,
|
||||
@@ -372,7 +373,11 @@ export function useTerminalEffects(ctx: TerminalEffectsContext) {
|
||||
// SSH banner, which is captured for free at handshake time.
|
||||
const info = await terminalBackend.getSessionRemoteInfo?.(id);
|
||||
if (cancelled || id !== sessionRef.current) return;
|
||||
if (!shouldProbeSessionCwd({ isNetworkDevice, remoteSshVersion: info?.remoteSshVersion })) {
|
||||
if (!shouldProbeSessionCwd({
|
||||
isNetworkDevice,
|
||||
remoteSshVersion: info?.remoteSshVersion,
|
||||
restrictExtraSshChannels: hostRestrictsExtraSshChannels(host),
|
||||
})) {
|
||||
return;
|
||||
}
|
||||
const result = await terminalBackend.getSessionPwd(id);
|
||||
|
||||
@@ -6,6 +6,7 @@ import { sessionCapabilitiesStore } from '../../application/state/sessionCapabil
|
||||
import { useSystemManagerBackend } from '../../application/state/useSystemManagerBackend';
|
||||
import { isTerminalSessionEligibleForSftpReuse } from '../../application/state/terminalConnectionReuse';
|
||||
import { resolveSystemSidebarSession } from '../../domain/systemManager/resolveSystemSession';
|
||||
|
||||
import type { TerminalContextReader } from '../../domain/terminalContextRead';
|
||||
import { useSystemCapabilitiesWarmup } from '../../application/state/useSystemManager';
|
||||
import { cn } from '../../lib/utils';
|
||||
@@ -199,7 +200,8 @@ export function TerminalLayerTabBridge({ stableRef }: { stableRef: StableRef })
|
||||
)
|
||||
: undefined;
|
||||
const activeTerminalCwdTrusted = activeTerminalCwdSource === 'osc7'
|
||||
|| activeTerminalCwdSource === 'backend-strict';
|
||||
|| activeTerminalCwdSource === 'backend-strict'
|
||||
|| activeTerminalCwdSource === 'inferred';
|
||||
void terminalCwdVersion;
|
||||
|
||||
const historySessionId = effectiveFocusedSessionId;
|
||||
@@ -217,8 +219,9 @@ export function TerminalLayerTabBridge({ stableRef }: { stableRef: StableRef })
|
||||
if (!activeTabId || !activeSidePanelTools.has('system')) return [];
|
||||
const session = activeTerminalSessionForSystem;
|
||||
if (!session || session.status !== 'connected') return [];
|
||||
if (activeSystemSessionHost?.singleChannelSsh === true) return [];
|
||||
return [session.id];
|
||||
}, [activeSidePanelTools, activeTabId, activeTerminalSessionForSystem]);
|
||||
}, [activeSidePanelTools, activeSystemSessionHost, activeTabId, activeTerminalSessionForSystem]);
|
||||
|
||||
useSystemCapabilitiesWarmup(
|
||||
systemWarmupSessionIds,
|
||||
|
||||
@@ -50,3 +50,16 @@ test("visible SFTP host override can disable command cwd probing", () => {
|
||||
false,
|
||||
);
|
||||
});
|
||||
|
||||
test("does not probe command cwd on single-channel SSH hosts", () => {
|
||||
assert.equal(
|
||||
shouldProbeCommandCwd({
|
||||
restoreTerminalCwd: true,
|
||||
visibleSftpHost: { sftpFollowTerminalCwd: true },
|
||||
sessionHost: { sftpFollowTerminalCwd: true },
|
||||
globalSftpFollowTerminalCwd: true,
|
||||
restrictExtraSshChannels: true,
|
||||
}),
|
||||
false,
|
||||
);
|
||||
});
|
||||
@@ -9,6 +9,7 @@ type ShouldProbeCommandCwdOptions = {
|
||||
visibleSftpHost?: FollowTerminalCwdHost | null;
|
||||
sessionHost?: FollowTerminalCwdHost | null;
|
||||
globalSftpFollowTerminalCwd: boolean;
|
||||
restrictExtraSshChannels?: boolean;
|
||||
};
|
||||
|
||||
export const shouldProbeCommandCwd = ({
|
||||
@@ -16,7 +17,9 @@ export const shouldProbeCommandCwd = ({
|
||||
visibleSftpHost,
|
||||
sessionHost,
|
||||
globalSftpFollowTerminalCwd,
|
||||
restrictExtraSshChannels = false,
|
||||
}: ShouldProbeCommandCwdOptions): boolean => {
|
||||
if (restrictExtraSshChannels) return false;
|
||||
if (restoreTerminalCwd) return true;
|
||||
|
||||
if (!visibleSftpHost) return false;
|
||||
|
||||
@@ -145,7 +145,7 @@ export function resolveGroupDefaults(
|
||||
|
||||
const INHERITABLE_KEYS: (keyof GroupConfig)[] = [
|
||||
'username', 'password', 'savePassword', 'authMethod', 'identityId', 'identityFileId', 'identityFilePaths',
|
||||
'port', 'protocol', 'deviceType', 'agentForwarding', 'proxyProfileId', 'proxyConfig', 'hostChain', 'startupCommand', 'startupCommandRunMode',
|
||||
'port', 'protocol', 'deviceType', 'singleChannelSsh', 'agentForwarding', 'proxyProfileId', 'proxyConfig', 'hostChain', 'startupCommand', 'startupCommandRunMode',
|
||||
'legacyAlgorithms', 'skipEcdsaHostKey', 'algorithms',
|
||||
'environmentVariables', 'charset', 'moshEnabled', 'moshServerPath',
|
||||
'etEnabled', 'etPort',
|
||||
|
||||
+36
-1
@@ -16,6 +16,7 @@ import {
|
||||
resolveTelnetPassword,
|
||||
resolveTelnetUsername,
|
||||
sanitizeHost,
|
||||
hostRestrictsExtraSshChannels,
|
||||
shouldProbeSessionCwd,
|
||||
shouldSuggestNetworkDeviceMode,
|
||||
upsertHostById,
|
||||
@@ -296,7 +297,7 @@ test("sanitizeHost removes hidden built-in credentials and transport state from
|
||||
"identitiesOnly", "addKeysToAgent", "useKeychain", "agentForwarding", "x11Forwarding",
|
||||
"proxyProfileId", "proxyConfig", "hostChain", "moshEnabled", "moshServerPath", "etEnabled",
|
||||
"etPort", "telnetEnabled", "telnetPort", "telnetIdentityId", "telnetUsername",
|
||||
"telnetPassword", "sftpSudo", "legacyAlgorithms", "skipEcdsaHostKey", "algorithms",
|
||||
"telnetPassword", "sftpSudo", "singleChannelSsh", "legacyAlgorithms", "skipEcdsaHostKey", "algorithms",
|
||||
"keepaliveOverride", "keepaliveInterval", "keepaliveCountMax", "sshTcpConnectTimeoutSeconds",
|
||||
"sshAuthReadyTimeoutSeconds",
|
||||
]) {
|
||||
@@ -644,6 +645,40 @@ test("shouldProbeSessionCwd skips the probe when the SSH banner reveals a networ
|
||||
);
|
||||
});
|
||||
|
||||
test("hostRestrictsExtraSshChannels follows the host toggle and network-device mode", () => {
|
||||
assert.equal(hostRestrictsExtraSshChannels({ singleChannelSsh: true }), true);
|
||||
assert.equal(hostRestrictsExtraSshChannels({ deviceType: "network" }), true);
|
||||
assert.equal(hostRestrictsExtraSshChannels({ singleChannelSsh: false, deviceType: "general" }), false);
|
||||
assert.equal(hostRestrictsExtraSshChannels(undefined), false);
|
||||
});
|
||||
|
||||
test("shouldProbeSessionCwd skips the probe when extra SSH channels are restricted", () => {
|
||||
assert.equal(
|
||||
shouldProbeSessionCwd({ isNetworkDevice: false, remoteSshVersion: "OpenSSH_9.6", restrictExtraSshChannels: true }),
|
||||
false,
|
||||
);
|
||||
assert.equal(
|
||||
shouldProbeSessionCwd({ isNetworkDevice: false, remoteSshVersion: "CLOUDBILITY-4.14" }),
|
||||
false,
|
||||
);
|
||||
assert.equal(
|
||||
shouldProbeSessionCwd({ isNetworkDevice: false, remoteSshVersion: "BHostSSH_7.0" }),
|
||||
false,
|
||||
);
|
||||
assert.equal(
|
||||
shouldProbeSessionCwd({ isNetworkDevice: false, remoteSshVersion: "SSH-2.0-TERM-SSHD" }),
|
||||
false,
|
||||
);
|
||||
assert.equal(
|
||||
shouldProbeSessionCwd({ isNetworkDevice: false, remoteSshVersion: "JumpServer" }),
|
||||
true,
|
||||
);
|
||||
assert.equal(
|
||||
shouldProbeSessionCwd({ isNetworkDevice: false, remoteSshVersion: "superterm-sshd" }),
|
||||
true,
|
||||
);
|
||||
});
|
||||
|
||||
const GLOBAL_KEEPALIVE = { keepaliveInterval: 30, keepaliveCountMax: 10 };
|
||||
|
||||
test("resolveHostKeepalive falls back to global when override is not set", () => {
|
||||
|
||||
+19
-2
@@ -1,3 +1,4 @@
|
||||
import { remoteSoftwareRequiresSingleChannel } from './singleChannelSshBanner.shared.cjs';
|
||||
import { Host, Snippet, TerminalSettings } from './models';
|
||||
import type { HostOperatingSystem, HostOsSelection } from './models/connection';
|
||||
import { sanitizeHostIconFields } from './hostIcon';
|
||||
@@ -269,12 +270,24 @@ export const shouldSuggestNetworkDeviceMode = (opts: {
|
||||
return classifyDistroId(opts.detectedDistro) === 'network-device';
|
||||
};
|
||||
|
||||
/**
|
||||
* True when an extra exec or SFTP channel on the terminal transport is unsafe.
|
||||
* Use this to skip probes. Do not store it as session.singleChannelSsh:
|
||||
* network devices already have their own restrictions, and that flag means
|
||||
* the user opted into a one-channel bastion.
|
||||
*/
|
||||
export const hostRestrictsExtraSshChannels = (
|
||||
host?: Pick<Host, 'singleChannelSsh' | 'deviceType'> | null,
|
||||
): boolean => host?.singleChannelSsh === true || host?.deviceType === 'network';
|
||||
|
||||
/**
|
||||
* Decide whether it is safe to run the post-connect `pwd` probe that
|
||||
* discovers the session's working directory. The probe opens an extra exec
|
||||
* channel running a POSIX-shell script; strict network-device CLIs such as
|
||||
* Huawei VRP respond by closing the whole SSH session (#1043), so it must be
|
||||
* skipped for them.
|
||||
* skipped for them. Hosts with `singleChannelSsh` (bastion / PAM), and
|
||||
* software banners that allow only one session channel per TCP connection,
|
||||
* have the same constraint.
|
||||
*
|
||||
* `isNetworkDevice` covers hosts we already classified (a reconnect, or an
|
||||
* explicit `deviceType: 'network'`). On a brand-new host that field is not
|
||||
@@ -284,8 +297,12 @@ export const shouldSuggestNetworkDeviceMode = (opts: {
|
||||
export const shouldProbeSessionCwd = (opts: {
|
||||
isNetworkDevice: boolean;
|
||||
remoteSshVersion?: string;
|
||||
restrictExtraSshChannels?: boolean;
|
||||
}): boolean =>
|
||||
!opts.isNetworkDevice && !detectVendorFromSshVersion(opts.remoteSshVersion);
|
||||
!opts.isNetworkDevice
|
||||
&& !opts.restrictExtraSshChannels
|
||||
&& !remoteSoftwareRequiresSingleChannel(opts.remoteSshVersion)
|
||||
&& !detectVendorFromSshVersion(opts.remoteSshVersion);
|
||||
|
||||
export const getEffectiveHostDistro = (
|
||||
host?: Pick<Host, 'distro' | 'manualDistro' | 'distroMode'> | null,
|
||||
|
||||
@@ -177,6 +177,9 @@ export interface Host {
|
||||
// Device type: 'general' for standard servers, 'network' for switches/routers/firewalls.
|
||||
// Network devices use raw command execution (no shell wrapping) for AI agent compatibility.
|
||||
deviceType?: 'general' | 'network';
|
||||
// Bastion / PAM hosts that allow only one SSH session channel.
|
||||
// Extra exec/SFTP on the terminal transport disconnects the shell.
|
||||
singleChannelSsh?: boolean;
|
||||
identityFileId?: string; // Reference to SSHKey
|
||||
protocol?: HostProtocol; // Default/primary protocol, including namespaced plugin protocols
|
||||
pluginConnection?: PluginConnectionConfig;
|
||||
@@ -425,6 +428,7 @@ export interface GroupConfig {
|
||||
port?: number;
|
||||
protocol?: 'ssh' | 'telnet';
|
||||
deviceType?: 'general' | 'network';
|
||||
singleChannelSsh?: boolean;
|
||||
agentForwarding?: boolean;
|
||||
proxyProfileId?: string;
|
||||
proxyConfig?: ProxyConfig;
|
||||
|
||||
@@ -152,6 +152,7 @@ export function stripBuiltInConnectionFieldsForPluginHost(host: Host): Host {
|
||||
telnetPassword: _telnetPassword,
|
||||
serialConfig: _serialConfig,
|
||||
sftpSudo: _sftpSudo,
|
||||
singleChannelSsh: _singleChannelSsh,
|
||||
legacyAlgorithms: _legacyAlgorithms,
|
||||
skipEcdsaHostKey: _skipEcdsaHostKey,
|
||||
algorithms: _algorithms,
|
||||
|
||||
@@ -208,6 +208,7 @@ const normalizeHost = (value: JsonValue): Host | null => {
|
||||
etEnabled: optionalBooleanValue(object, 'etEnabled'),
|
||||
telnetEnabled: optionalBooleanValue(object, 'telnetEnabled'),
|
||||
sftpSudo: optionalBooleanValue(object, 'sftpSudo'),
|
||||
singleChannelSsh: optionalBooleanValue(object, 'singleChannelSsh'),
|
||||
requiresMfa: optionalBooleanValue(object, 'requiresMfa'),
|
||||
useSshAgent: optionalBooleanValue(object, 'useSshAgent'),
|
||||
identitiesOnly: optionalBooleanValue(object, 'identitiesOnly'),
|
||||
|
||||
@@ -12,6 +12,9 @@ import {
|
||||
shouldLatchInitialFollowInterruption,
|
||||
shouldReleaseInitialFollowSyncAttempt,
|
||||
shouldResetInitialFollowTerminalCwdSync,
|
||||
resolveTerminalCwdForSftp,
|
||||
guessUnixHomeDirFromPath,
|
||||
fallbackFollowPathForUntrackedSession,
|
||||
} from "./sftpFollowTerminalCwd";
|
||||
|
||||
const base = {
|
||||
@@ -549,3 +552,29 @@ test("first-open probe is latched as interrupted on a hide with the owner panel
|
||||
false,
|
||||
);
|
||||
});
|
||||
|
||||
test("resolveTerminalCwdForSftp expands a prompt home shortcut", () => {
|
||||
assert.equal(resolveTerminalCwdForSftp("~", "/root"), "/root");
|
||||
assert.equal(resolveTerminalCwdForSftp("~/codes", "/root/"), "/root/codes");
|
||||
assert.equal(resolveTerminalCwdForSftp("/var/log", "/root"), "/var/log");
|
||||
assert.equal(resolveTerminalCwdForSftp("~", null, "/root/projects"), "/root");
|
||||
assert.equal(resolveTerminalCwdForSftp("~", null), "~");
|
||||
});
|
||||
|
||||
test("split focus without a cwd returns to that pane home instead of the other pane", () => {
|
||||
assert.equal(fallbackFollowPathForUntrackedSession({
|
||||
originChanged: true,
|
||||
homeDir: "/root",
|
||||
currentPath: "/data/app/doris",
|
||||
}), "/root");
|
||||
assert.equal(fallbackFollowPathForUntrackedSession({
|
||||
originChanged: false,
|
||||
homeDir: "/root",
|
||||
currentPath: "/data/app/doris",
|
||||
}), null);
|
||||
assert.equal(fallbackFollowPathForUntrackedSession({
|
||||
originChanged: true,
|
||||
homeDir: "/root",
|
||||
currentPath: "/root",
|
||||
}), null);
|
||||
});
|
||||
@@ -268,3 +268,48 @@ export const shouldFollowTerminalCwdNavigate = ({
|
||||
if (!currentPath || currentPath === terminalCwd) return false;
|
||||
return true;
|
||||
};
|
||||
|
||||
/**
|
||||
* A split sibling with no cwd of its own is still at login. Do not leave the
|
||||
* previous pane's directory on the shared SFTP panel.
|
||||
*/
|
||||
export const fallbackFollowPathForUntrackedSession = ({
|
||||
originChanged,
|
||||
homeDir,
|
||||
currentPath,
|
||||
}: {
|
||||
originChanged: boolean;
|
||||
homeDir?: string | null;
|
||||
currentPath?: string | null;
|
||||
}): string | null => {
|
||||
if (!originChanged) return null;
|
||||
if (!homeDir || !homeDir.startsWith("/")) return null;
|
||||
if (currentPath === homeDir) return null;
|
||||
return homeDir;
|
||||
};
|
||||
|
||||
/** Best-effort home from an already-open SFTP path when echo ~ is unavailable. */
|
||||
export const isSftpFollowTargetPath = (path: string): boolean => (
|
||||
path.startsWith("/") || /^[A-Za-z]:[\\/]/.test(path)
|
||||
);
|
||||
|
||||
export const guessUnixHomeDirFromPath = (path?: string | null): string | null => {
|
||||
if (!path || !path.startsWith("/")) return null;
|
||||
if (path === "/root" || path.startsWith("/root/")) return "/root";
|
||||
const match = path.match(/^(\/home\/[^/]+)/);
|
||||
return match ? match[1] : null;
|
||||
};
|
||||
|
||||
/** Expand a prompt `~` so SFTP navigate does not turn it into `/~`. */
|
||||
export const resolveTerminalCwdForSftp = (
|
||||
cwd: string,
|
||||
homeDir?: string | null,
|
||||
currentPath?: string | null,
|
||||
): string => {
|
||||
const home = (homeDir && homeDir.startsWith("/")) ? homeDir : guessUnixHomeDirFromPath(currentPath);
|
||||
if (cwd === "~") return home ?? cwd;
|
||||
if (cwd.startsWith("~/") && home) {
|
||||
return `${home.replace(/\/+$/, "")}/${cwd.slice(2)}`;
|
||||
}
|
||||
return cwd;
|
||||
};
|
||||
@@ -0,0 +1,70 @@
|
||||
"use strict";
|
||||
|
||||
// SSH software tokens that allow one session channel per TCP connection.
|
||||
// Match the identification software token only. Do not match product names,
|
||||
// OpenSSH, JumpServer/KoKo, or a "term-sshd" substring inside another token.
|
||||
//
|
||||
// BHostSSH_7.0 — issue #3146 log field remoteSshVersion. PR #3313: a second
|
||||
// exec channel tears down the first interactive session. Multi-character
|
||||
// channel writes are also dropped on this banner.
|
||||
// TERM-SSHD — issue #2923 comment 5314292157 logs remoteSshVersion
|
||||
// "TERM-SSHD". PR #3030: a later session channel on that TCP is accepted
|
||||
// and the shell exits 0.
|
||||
// CLOUDBILITY-4.14 — PR #3458 reproduction note: SSH-2.0-CLOUDBILITY-4.14
|
||||
// drops the interactive shell when another session channel opens. No other
|
||||
// public packet was found; do not add aliases.
|
||||
// JumpServer KoKo advertises Version "JumpServer" and multiplexes. Omit it.
|
||||
|
||||
const SINGLE_CHANNEL_SOFTWARE_TOKENS = Object.freeze([
|
||||
"BHostSSH",
|
||||
"TERM-SSHD",
|
||||
"CLOUDBILITY",
|
||||
]);
|
||||
|
||||
// #3146 also drops multi-character writes. That evidence is only for these
|
||||
// two tokens, not for CLOUDBILITY.
|
||||
const CHUNKED_WRITE_SOFTWARE_TOKENS = Object.freeze([
|
||||
"BHostSSH",
|
||||
"TERM-SSHD",
|
||||
]);
|
||||
|
||||
function escapeRegExp(value) {
|
||||
return String(value).replace(/[.*+?^${}()|[\]\\]/g, "\\$&");
|
||||
}
|
||||
|
||||
function softwareTokenMatches(remoteSshVersion, token) {
|
||||
const software = String(remoteSshVersion || "").trim();
|
||||
if (!software) return false;
|
||||
const pattern = new RegExp(
|
||||
`(?:^|[^A-Za-z0-9])${escapeRegExp(token)}(?:[^A-Za-z0-9]|$)`,
|
||||
"i",
|
||||
);
|
||||
return pattern.test(software);
|
||||
}
|
||||
|
||||
function remoteSoftwareMatchesAny(remoteSshVersion, tokens) {
|
||||
return tokens.some((token) => softwareTokenMatches(remoteSshVersion, token));
|
||||
}
|
||||
|
||||
function remoteSoftwareRequiresSingleChannel(remoteSshVersion) {
|
||||
return remoteSoftwareMatchesAny(remoteSshVersion, SINGLE_CHANNEL_SOFTWARE_TOKENS);
|
||||
}
|
||||
|
||||
function remoteDisallowsChunkedChannelWrite(remoteSshVersion) {
|
||||
return remoteSoftwareMatchesAny(remoteSshVersion, CHUNKED_WRITE_SOFTWARE_TOKENS);
|
||||
}
|
||||
|
||||
function optionsForPeerSingleChannel(options, conn) {
|
||||
const remoteSshVersion = conn && typeof conn._remoteVer === "string" ? conn._remoteVer : "";
|
||||
if (!options || options.singleChannelSsh === true || !remoteSoftwareRequiresSingleChannel(remoteSshVersion)) {
|
||||
return options;
|
||||
}
|
||||
return { ...options, singleChannelSsh: true };
|
||||
}
|
||||
|
||||
module.exports = {
|
||||
SINGLE_CHANNEL_SOFTWARE_TOKENS,
|
||||
remoteSoftwareRequiresSingleChannel,
|
||||
remoteDisallowsChunkedChannelWrite,
|
||||
optionsForPeerSingleChannel,
|
||||
};
|
||||
+17
@@ -0,0 +1,17 @@
|
||||
declare module "./singleChannelSshBanner.shared.cjs" {
|
||||
export function remoteSoftwareRequiresSingleChannel(remoteSshVersion?: string): boolean;
|
||||
export function remoteDisallowsChunkedChannelWrite(remoteSshVersion?: string): boolean;
|
||||
export function optionsForPeerSingleChannel<T extends { singleChannelSsh?: boolean }>(
|
||||
options: T,
|
||||
conn?: { _remoteVer?: string } | null,
|
||||
): T;
|
||||
}
|
||||
|
||||
declare module "@/domain/singleChannelSshBanner.shared.cjs" {
|
||||
export function remoteSoftwareRequiresSingleChannel(remoteSshVersion?: string): boolean;
|
||||
export function remoteDisallowsChunkedChannelWrite(remoteSshVersion?: string): boolean;
|
||||
export function optionsForPeerSingleChannel<T extends { singleChannelSsh?: boolean }>(
|
||||
options: T,
|
||||
conn?: { _remoteVer?: string } | null,
|
||||
): T;
|
||||
}
|
||||
@@ -0,0 +1,71 @@
|
||||
"use strict";
|
||||
|
||||
const test = require("node:test");
|
||||
const assert = require("node:assert/strict");
|
||||
const {
|
||||
remoteSoftwareRequiresSingleChannel,
|
||||
remoteDisallowsChunkedChannelWrite,
|
||||
optionsForPeerSingleChannel,
|
||||
} = require("./singleChannelSshBanner.shared.cjs");
|
||||
|
||||
test("single-channel banners match only the observed software tokens", () => {
|
||||
for (const version of [
|
||||
"BHostSSH_7.0",
|
||||
"SSH-2.0-BHostSSH_7.0",
|
||||
"TERM-SSHD",
|
||||
"SSH-2.0-TERM-SSHD",
|
||||
"term-sshd",
|
||||
"CLOUDBILITY-4.14",
|
||||
"SSH-2.0-CLOUDBILITY-4.14",
|
||||
]) {
|
||||
assert.equal(remoteSoftwareRequiresSingleChannel(version), true, version);
|
||||
}
|
||||
});
|
||||
|
||||
test("single-channel banner match ignores OpenSSH, JumpServer, and lookalike tokens", () => {
|
||||
for (const version of [
|
||||
"",
|
||||
" ",
|
||||
undefined,
|
||||
"OpenSSH_9.6",
|
||||
"SSH-2.0-OpenSSH_9.6",
|
||||
"OpenSSH_for_Windows_9.5",
|
||||
"dropbear_2024.85",
|
||||
"JumpServer",
|
||||
"SSH-2.0-JumpServer",
|
||||
"KoKo",
|
||||
"SSH-2.0-KOKO",
|
||||
"tsshd",
|
||||
"superterm-sshd",
|
||||
"cloudterm-sshd",
|
||||
"openterm-sshd",
|
||||
"CmdbHostSSH",
|
||||
"BHost",
|
||||
"notcloudbility",
|
||||
]) {
|
||||
assert.equal(remoteSoftwareRequiresSingleChannel(version), false, String(version));
|
||||
}
|
||||
});
|
||||
|
||||
test("chunked-write limit stays on BHostSSH and TERM-SSHD", () => {
|
||||
assert.equal(remoteDisallowsChunkedChannelWrite("BHostSSH_7.0"), true);
|
||||
assert.equal(remoteDisallowsChunkedChannelWrite("SSH-2.0-TERM-SSHD"), true);
|
||||
assert.equal(remoteDisallowsChunkedChannelWrite("CLOUDBILITY-4.14"), false);
|
||||
assert.equal(remoteDisallowsChunkedChannelWrite("OpenSSH_9.6"), false);
|
||||
assert.equal(remoteDisallowsChunkedChannelWrite("superterm-sshd"), false);
|
||||
});
|
||||
|
||||
test("optionsForPeerSingleChannel stamps the runtime flag from the peer banner", () => {
|
||||
const requested = { hostname: "bastion", singleChannelSsh: false };
|
||||
const stamped = optionsForPeerSingleChannel(requested, { _remoteVer: "CLOUDBILITY-4.14" });
|
||||
assert.equal(stamped.singleChannelSsh, true);
|
||||
assert.equal(requested.singleChannelSsh, false);
|
||||
assert.equal(
|
||||
optionsForPeerSingleChannel(requested, { _remoteVer: "OpenSSH_9.6" }),
|
||||
requested,
|
||||
);
|
||||
assert.equal(
|
||||
optionsForPeerSingleChannel({ singleChannelSsh: true }, { _remoteVer: "OpenSSH_9.6" }).singleChannelSsh,
|
||||
true,
|
||||
);
|
||||
});
|
||||
@@ -106,6 +106,26 @@ test("network devices hide processes until OS probe confirms a real target", ()
|
||||
);
|
||||
});
|
||||
|
||||
|
||||
test("system overview stats skip single-channel SSH hosts", () => {
|
||||
assert.equal(
|
||||
shouldCollectServerStats(
|
||||
{
|
||||
id: "host-1",
|
||||
label: "Bastion",
|
||||
hostname: "bastion.local",
|
||||
username: "user",
|
||||
tags: [],
|
||||
os: "linux",
|
||||
singleChannelSsh: true,
|
||||
},
|
||||
undefined,
|
||||
null,
|
||||
),
|
||||
false,
|
||||
);
|
||||
});
|
||||
|
||||
test("system overview stats skip network devices even when a Linux icon was selected", () => {
|
||||
assert.equal(
|
||||
shouldCollectServerStats(
|
||||
|
||||
@@ -93,6 +93,7 @@ export function shouldCollectServerStats(
|
||||
_session: TerminalSession | null | undefined,
|
||||
): boolean {
|
||||
const detectedDeviceClass = classifyDistroId(host?.distro);
|
||||
if (host?.singleChannelSsh) return false;
|
||||
if (isNetworkDeviceTarget(host) || detectedDeviceClass === 'network-device') return false;
|
||||
if (capabilities?.targetOs && capabilities.targetOs !== 'unknown') {
|
||||
return capabilities.targetOs === 'linux' || capabilities.targetOs === 'darwin';
|
||||
|
||||
@@ -68,6 +68,9 @@ module.exports = {
|
||||
// Renderer and Mosh's main-process bootstrap share the same
|
||||
// fail-closed prompt classifier at runtime.
|
||||
'domain/terminalPromptSecurity.shared.cjs',
|
||||
// Main-process SSH, SFTP, and port-forward bridges share this matcher.
|
||||
// TypeScript under domain/ is excluded below, so the .cjs must be listed.
|
||||
'domain/singleChannelSshBanner.shared.cjs',
|
||||
'lib/**/*.cjs',
|
||||
'lib/**/*.json',
|
||||
'skills/**/*',
|
||||
|
||||
@@ -58,6 +58,7 @@ function startPtyJob(ptyStream, command, options) {
|
||||
loginShellHint,
|
||||
probeLiveShell = false,
|
||||
bastionKeystrokes = false,
|
||||
skipPendingInputClear = false,
|
||||
onProbeAborted,
|
||||
onInterrupt,
|
||||
chatSessionId,
|
||||
@@ -822,7 +823,7 @@ function startPtyJob(ptyStream, command, options) {
|
||||
|
||||
function writeWrappedCommand() {
|
||||
const wrapped = buildWrappedCommand(command, resolvedShellKind, marker, probeLiveShell);
|
||||
writeInput(`${buildPendingInputClearPrefix(resolvedShellKind)}${wrapped}`);
|
||||
writeInput(`${(skipPendingInputClear ? "" : buildPendingInputClearPrefix(resolvedShellKind))}${wrapped}`);
|
||||
}
|
||||
|
||||
// Prime the renderer's display suppression before the first byte is typed
|
||||
@@ -841,7 +842,7 @@ function startPtyJob(ptyStream, command, options) {
|
||||
}
|
||||
}
|
||||
if (probingShell) {
|
||||
writeInput(`${buildPendingInputClearPrefix(resolvedShellKind)}${buildLiveShellProbe(marker)}`);
|
||||
writeInput(`${(skipPendingInputClear ? "" : buildPendingInputClearPrefix(resolvedShellKind))}${buildLiveShellProbe(marker)}`);
|
||||
} else {
|
||||
writeWrappedCommand();
|
||||
}
|
||||
|
||||
@@ -1461,3 +1461,25 @@ test("a throwing echo suppression prime callback still types the command (#3384)
|
||||
assert.ok(writes.some((data) => data.includes("echo resilient")));
|
||||
job.cancel();
|
||||
});
|
||||
|
||||
test("skipPendingInputClear does not send line-kill keys", async () => {
|
||||
const writes = [];
|
||||
const pty = new EventEmitter();
|
||||
pty.write = (data) => {
|
||||
writes.push(String(data));
|
||||
return true;
|
||||
};
|
||||
const job = startPtyJob(pty, "echo hi", {
|
||||
shellKind: "posix",
|
||||
probeLiveShell: false,
|
||||
skipPendingInputClear: true,
|
||||
timeoutMs: 300,
|
||||
});
|
||||
await new Promise((resolve) => setTimeout(resolve, 30));
|
||||
const input = writes.join("");
|
||||
assert.equal(input.includes("\u0015"), false);
|
||||
assert.equal(input.includes("\u000b"), false);
|
||||
assert.match(input, /echo hi/);
|
||||
job.cancel();
|
||||
});
|
||||
|
||||
@@ -23,6 +23,10 @@
|
||||
"use strict";
|
||||
|
||||
const { executeBoundedSshCommand } = require("../boundedSshExec.cjs");
|
||||
const {
|
||||
remoteDisallowsChunkedChannelWrite,
|
||||
remoteSoftwareRequiresSingleChannel,
|
||||
} = require("../../../domain/singleChannelSshBanner.shared.cjs");
|
||||
|
||||
const crypto = require("node:crypto");
|
||||
const { classifyLocalShellType } = require("../../../lib/localShell.cjs");
|
||||
@@ -60,19 +64,21 @@ function isWindowsOpenSshRemote(remoteSshVersion) {
|
||||
return /openssh_for_windows/i.test(String(remoteSshVersion || ""));
|
||||
}
|
||||
|
||||
// The host flag and a recognized one-channel software token both mean a second
|
||||
// session channel can drop the interactive shell. Keystroke pacing is separate.
|
||||
function sessionDisallowsExtraSshChannel(session) {
|
||||
return session?.singleChannelSsh === true
|
||||
|| remoteSoftwareRequiresSingleChannel(session?.remoteSshVersion);
|
||||
}
|
||||
|
||||
/**
|
||||
* Whether this SSH banner identifies a bastion that binds the transport to the
|
||||
* first interactive session and tears it down when a second channel opens
|
||||
* (BHostSSH, QiZhi TERM-SSHD, and other menu-driven jump hosts). For these the
|
||||
* exec-channel login-shell probe must be skipped so approving an AI command
|
||||
* does not disconnect the session (#3146).
|
||||
* Banners whose channel writes must be paced one chunk at a time (BHostSSH,
|
||||
* TERM-SSHD, issue #3146). The exec-channel login-shell probe is skipped for
|
||||
* these too. CLOUDBILITY is one-channel but has no paced-write evidence; that
|
||||
* case is covered by sessionDisallowsExtraSshChannel, not by this helper.
|
||||
*/
|
||||
function remoteDisallowsExecChannelProbe(remoteSshVersion) {
|
||||
const software = String(remoteSshVersion || "").trim();
|
||||
if (!software) return false;
|
||||
if (/BHostSSH/i.test(software)) return true;
|
||||
if (/TERM-SSHD/i.test(software)) return true;
|
||||
return false;
|
||||
return remoteDisallowsChunkedChannelWrite(remoteSshVersion);
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -205,7 +211,7 @@ function createSessionExecProbe(session) {
|
||||
// down when a second SSH channel opens. Our exec-channel login-shell probe
|
||||
// would disconnect them before the command ever runs, so skip it and let the
|
||||
// PTY live shell probe (probeLiveShell) determine the wrapper (#3146).
|
||||
if (remoteDisallowsExecChannelProbe(session.remoteSshVersion)) return null;
|
||||
if (sessionDisallowsExtraSshChannel(session)) return null;
|
||||
return (
|
||||
createSshConnExecProbe(session.conn)
|
||||
|| createSshConnExecProbe(session.sshClient)
|
||||
@@ -470,6 +476,7 @@ module.exports = {
|
||||
isConfirmedShellKind,
|
||||
isWindowsOpenSshRemote,
|
||||
remoteDisallowsExecChannelProbe,
|
||||
sessionDisallowsExtraSshChannel,
|
||||
classifyShellKindFromRemotePath,
|
||||
buildRemoteLoginShellProbeCommand,
|
||||
buildRemoteWindowsLoginShellProbeCommand,
|
||||
|
||||
@@ -14,6 +14,7 @@ const {
|
||||
parseRemoteWindowsLoginShellProbeOutput,
|
||||
isWindowsOpenSshRemote,
|
||||
remoteDisallowsExecChannelProbe,
|
||||
sessionDisallowsExtraSshChannel,
|
||||
createSshConnExecProbe,
|
||||
createSessionExecProbe,
|
||||
ensureSessionShellKind,
|
||||
@@ -790,6 +791,8 @@ test("remoteDisallowsExecChannelProbe flags bastion banners that drop a second c
|
||||
assert.equal(remoteDisallowsExecChannelProbe("BHostSSH_7.0"), true);
|
||||
assert.equal(remoteDisallowsExecChannelProbe("TERM-SSHD"), true);
|
||||
assert.equal(remoteDisallowsExecChannelProbe("OpenSSH_9.6"), false);
|
||||
assert.equal(remoteDisallowsExecChannelProbe("CLOUDBILITY-4.14"), false);
|
||||
assert.equal(remoteDisallowsExecChannelProbe("SSH-2.0-CLOUDBILITY-4.14"), false);
|
||||
assert.equal(remoteDisallowsExecChannelProbe("dropbear_2022.83"), false);
|
||||
assert.equal(remoteDisallowsExecChannelProbe("OpenSSH_for_Windows_9.5"), false);
|
||||
assert.equal(remoteDisallowsExecChannelProbe(undefined), false);
|
||||
@@ -908,3 +911,28 @@ test(
|
||||
assert.match(result.stdout, new RegExp(`${marker}_E:0`));
|
||||
},
|
||||
);
|
||||
|
||||
test("CLOUDBILITY banner skips the exec probe without paced keystrokes", () => {
|
||||
let execCalls = 0;
|
||||
const session = {
|
||||
remoteSshVersion: "SSH-2.0-CLOUDBILITY-4.14",
|
||||
conn: { exec() { execCalls += 1; } },
|
||||
};
|
||||
assert.equal(remoteDisallowsExecChannelProbe(session.remoteSshVersion), false);
|
||||
assert.equal(sessionDisallowsExtraSshChannel(session), true);
|
||||
assert.equal(createSessionExecProbe(session), null);
|
||||
assert.equal(execCalls, 0);
|
||||
});
|
||||
|
||||
test("createSessionExecProbe skips the exec channel when singleChannelSsh is set", () => {
|
||||
let execCalls = 0;
|
||||
const session = {
|
||||
singleChannelSsh: true,
|
||||
remoteSshVersion: "CLOUDBILITY-4.14",
|
||||
conn: { exec() { execCalls += 1; } },
|
||||
};
|
||||
assert.equal(sessionDisallowsExtraSshChannel(session), true);
|
||||
assert.equal(createSessionExecProbe(session), null);
|
||||
assert.equal(execCalls, 0);
|
||||
});
|
||||
|
||||
@@ -5,7 +5,7 @@
|
||||
const { emitTerminalSessionData } = require("../emitTerminalSessionData.cjs");
|
||||
const { formatSyntheticEcho } = require("../ai/shellUtils.cjs");
|
||||
const { clearSessionFlowState } = require("../terminalFlowAck.cjs");
|
||||
const { remoteDisallowsExecChannelProbe, ensureSessionShellKindForExec } = require("../ai/sessionShellKind.cjs");
|
||||
const { remoteDisallowsExecChannelProbe, sessionDisallowsExtraSshChannel, ensureSessionShellKindForExec } = require("../ai/sessionShellKind.cjs");
|
||||
|
||||
function getWorkerExecutionMeta(mcpServerBridge, sessionId, chatSessionId) {
|
||||
return mcpServerBridge.getSessionMeta?.(sessionId, chatSessionId) || {};
|
||||
@@ -181,6 +181,7 @@ function registerCattyExecHandlers(ctx) {
|
||||
loginShellHint: session._loginShellKind,
|
||||
probeLiveShell: true,
|
||||
bastionKeystrokes: remoteDisallowsExecChannelProbe(session.remoteSshVersion),
|
||||
skipPendingInputClear: session.singleChannelSsh === true,
|
||||
onProbeAborted: (marker) => {
|
||||
const contents = electronModule?.webContents?.fromId?.(session.webContentsId);
|
||||
emitTerminalSessionData(contents, sessionId, `${marker}_R\n`, { session });
|
||||
@@ -216,7 +217,7 @@ function registerCattyExecHandlers(ctx) {
|
||||
|
||||
// Fallback: SSH exec channel (invisible to terminal)
|
||||
const sshClient = session.sshClient || session.conn;
|
||||
if (sshClient && typeof sshClient.exec === "function") {
|
||||
if (!session.singleChannelSsh && sshClient && typeof sshClient.exec === "function") {
|
||||
const { execViaChannel } = require("./ai/ptyExec.cjs");
|
||||
const channelTimeoutMs = mcpServerBridge.getCommandTimeoutMs ? mcpServerBridge.getCommandTimeoutMs() : 60000;
|
||||
return withLockRelease(async () => {
|
||||
|
||||
@@ -444,6 +444,7 @@ async function checkTarAvailable(signal) {
|
||||
});
|
||||
}
|
||||
|
||||
|
||||
/**
|
||||
* Check if tar command is available on remote server
|
||||
*/
|
||||
@@ -451,7 +452,12 @@ async function checkRemoteTarAvailable(sftpId, signal) {
|
||||
try {
|
||||
const client = sftpClients.get(sftpId);
|
||||
if (!client) throw new Error("SFTP session not found");
|
||||
|
||||
// Probing tar would open a second channel and drop this SFTP login.
|
||||
// The renderer then uploads the folder file by file.
|
||||
if (client.__netcattySingleChannelSsh || client.client?.__netcattySingleChannelSsh) {
|
||||
return false;
|
||||
}
|
||||
|
||||
// Try to execute tar --version via SSH
|
||||
const sshClient = client.client; // Get underlying SSH2 client
|
||||
if (!sshClient) throw new Error("SSH client not available");
|
||||
@@ -567,6 +573,11 @@ async function extractRemoteArchive(
|
||||
|
||||
const sshClient = client.client;
|
||||
if (!sshClient) throw new Error("SSH client not available");
|
||||
if (client.__netcattySingleChannelSsh === true || sshClient.__netcattySingleChannelSsh === true) {
|
||||
throw new Error(
|
||||
"This host is configured for single-channel SSH. Compressed extraction cannot open a shell on the file-transfer login.",
|
||||
);
|
||||
}
|
||||
|
||||
// Calculate timeout based on archive size
|
||||
// Base: 60 seconds minimum
|
||||
@@ -842,7 +853,7 @@ async function startCompressedUpload(event, payload) {
|
||||
// pre-existing files whose names happen to begin with "._".
|
||||
try {
|
||||
const client = sftpClients.get(sftpId);
|
||||
if (client && client.client && client.client.writable !== false) {
|
||||
if (client && !client.__netcattySingleChannelSsh && client.client && client.client.writable !== false) {
|
||||
await runRemoteExec(client.client, `rm -f ${escapeShellArg(remoteArchivePath)}`, {
|
||||
timeoutMs: REMOTE_CLEANUP_TIMEOUT_MS,
|
||||
signal: compression.remoteExecAbortController.signal,
|
||||
@@ -1150,6 +1161,7 @@ module.exports = {
|
||||
registerHandlers,
|
||||
pauseCompression,
|
||||
resumeCompression,
|
||||
_checkCompressedUploadSupportForTests: checkCompressedUploadSupport,
|
||||
_runRemoteExecForTests: runRemoteExec,
|
||||
_buildAtomicRemoteExtractionCommandForTests: buildAtomicRemoteExtractionCommand,
|
||||
_buildRemoteArchivePathForTests: buildRemoteArchivePath,
|
||||
|
||||
@@ -530,3 +530,167 @@ test("a late cancel after completion cannot overwrite the completed terminal eve
|
||||
));
|
||||
assert.deepEqual(terminalAfter.map((event) => event.type), ["completed"]);
|
||||
});
|
||||
|
||||
test("compressed upload skips remote tar exec on single-channel SSH", async () => {
|
||||
let execCalls = 0;
|
||||
compressUploadBridge._resetCompressionSupportCacheForTests();
|
||||
compressUploadBridge.init({
|
||||
sftpClients: new Map([
|
||||
["sftp-1", {
|
||||
__netcattySingleChannelSsh: true,
|
||||
client: {
|
||||
exec() {
|
||||
execCalls += 1;
|
||||
throw new Error("must not exec on single-channel SSH");
|
||||
},
|
||||
},
|
||||
}],
|
||||
]),
|
||||
transferBridge: {},
|
||||
});
|
||||
|
||||
const result = await compressUploadBridge._checkCompressedUploadSupportForTests(null, {
|
||||
sftpId: "sftp-1",
|
||||
});
|
||||
|
||||
assert.equal(result.supported, false);
|
||||
assert.equal(result.remoteTar, false);
|
||||
assert.equal(execCalls, 0);
|
||||
});
|
||||
|
||||
test("single-channel compressed upload stays on per-file SFTP even when the terminal is idle", async () => {
|
||||
let execCalls = 0;
|
||||
const prompt = "[dev@host ~]$ ";
|
||||
compressUploadBridge._resetCompressionSupportCacheForTests();
|
||||
compressUploadBridge.init({
|
||||
sftpClients: new Map([
|
||||
["sftp-shell", {
|
||||
__netcattySingleChannelSsh: true,
|
||||
__netcattyEndpointKey: "ep-shell",
|
||||
client: {
|
||||
exec() {
|
||||
execCalls += 1;
|
||||
throw new Error("must not exec on single-channel SSH");
|
||||
},
|
||||
},
|
||||
}],
|
||||
]),
|
||||
sessions: new Map([
|
||||
["term-shell", {
|
||||
singleChannelSsh: true,
|
||||
connRef: { endpointKey: "ep-shell" },
|
||||
_promptTrackTail: prompt,
|
||||
stream: { writable: true, write() {}, on() {}, removeListener() {} },
|
||||
}],
|
||||
]),
|
||||
transferBridge: {},
|
||||
});
|
||||
|
||||
const result = await compressUploadBridge._checkCompressedUploadSupportForTests(null, {
|
||||
sftpId: "sftp-shell",
|
||||
});
|
||||
|
||||
assert.equal(result.supported, false);
|
||||
assert.equal(result.remoteTar, false);
|
||||
assert.equal(execCalls, 0);
|
||||
});
|
||||
|
||||
test("single-channel compressed upload falls back while the terminal line is busy", async () => {
|
||||
let execCalls = 0;
|
||||
compressUploadBridge._resetCompressionSupportCacheForTests();
|
||||
compressUploadBridge.init({
|
||||
sftpClients: new Map([
|
||||
["sftp-busy", {
|
||||
__netcattySingleChannelSsh: true,
|
||||
__netcattyEndpointKey: "ep-busy",
|
||||
client: { exec() { execCalls += 1; throw new Error("must not exec"); } },
|
||||
}],
|
||||
]),
|
||||
sessions: new Map([
|
||||
["term-busy", {
|
||||
singleChannelSsh: true,
|
||||
connRef: { endpointKey: "ep-busy" },
|
||||
_promptTrackTail: "[dev@host ~]$ ll ",
|
||||
stream: { writable: true, write() {}, on() {}, removeListener() {} },
|
||||
}],
|
||||
]),
|
||||
transferBridge: {},
|
||||
});
|
||||
|
||||
const result = await compressUploadBridge._checkCompressedUploadSupportForTests(null, {
|
||||
sftpId: "sftp-busy",
|
||||
});
|
||||
|
||||
assert.equal(result.remoteTar, false);
|
||||
assert.equal(execCalls, 0);
|
||||
});
|
||||
|
||||
test("single-channel compressed upload does not extract through the terminal", async (t) => {
|
||||
const root = fs.mkdtempSync(path.join(os.tmpdir(), "netcatty-compress-shell-"));
|
||||
const folderPath = path.join(root, "folder");
|
||||
fs.mkdirSync(folderPath);
|
||||
fs.writeFileSync(path.join(folderPath, "file.txt"), "payload");
|
||||
t.after(() => fs.rmSync(root, { recursive: true, force: true }));
|
||||
|
||||
let execCalls = 0;
|
||||
const writes = [];
|
||||
const stream = new EventEmitter();
|
||||
stream.writable = true;
|
||||
stream.write = (line) => {
|
||||
writes.push(String(line));
|
||||
const marker = String(line).match(/NETCATTY_EXTRACT_[a-z0-9_]+/i);
|
||||
if (!marker) return true;
|
||||
process.nextTick(() => stream.emit("data", Buffer.from("\n" + marker[0] + " 0\n")));
|
||||
return true;
|
||||
};
|
||||
|
||||
const handlers = new Map();
|
||||
compressUploadBridge._resetCompressionSupportCacheForTests();
|
||||
compressUploadBridge.init({
|
||||
sftpClients: new Map([
|
||||
["sftp-extract", {
|
||||
__netcattySingleChannelSsh: true,
|
||||
__netcattyEndpointKey: "ep-extract",
|
||||
client: {
|
||||
writable: true,
|
||||
exec() {
|
||||
execCalls += 1;
|
||||
throw new Error("must not exec on single-channel SSH");
|
||||
},
|
||||
},
|
||||
}],
|
||||
]),
|
||||
sessions: new Map([
|
||||
["term-extract", {
|
||||
singleChannelSsh: true,
|
||||
connRef: { endpointKey: "ep-extract" },
|
||||
_promptTrackTail: "[dev@host ~]$ ",
|
||||
stream,
|
||||
}],
|
||||
]),
|
||||
transferBridge: {
|
||||
acquireTransferSessionLeases() { return ["sftp-extract"]; },
|
||||
releaseTransferSessionLeases() {},
|
||||
async startInternalTransfer() { return { success: true }; },
|
||||
async cancelTransfer() { return { success: true }; },
|
||||
broadcastGlobalTransferEvent() {},
|
||||
},
|
||||
});
|
||||
compressUploadBridge.registerHandlers({
|
||||
handle(channel, handler) { handlers.set(channel, handler); },
|
||||
});
|
||||
|
||||
const result = await handlers.get("netcatty:compress:start")({ sender: { id: 1, send() {} } }, {
|
||||
compressionId: "shell-extract",
|
||||
folderPath,
|
||||
targetPath: "/tmp",
|
||||
sftpId: "sftp-extract",
|
||||
folderName: "folder",
|
||||
totalBytes: 7,
|
||||
});
|
||||
|
||||
assert.equal(result.success, undefined);
|
||||
assert.match(result.error, /tar command not available on remote server/);
|
||||
assert.equal(execCalls, 0);
|
||||
assert.equal(writes.length, 0);
|
||||
});
|
||||
@@ -1,5 +1,5 @@
|
||||
const assert = require("node:assert/strict");
|
||||
const { execFileSync, spawnSync } = require("node:child_process");
|
||||
const { execFileSync, spawn, spawnSync } = require("node:child_process");
|
||||
const { EventEmitter } = require("node:events");
|
||||
const fs = require("node:fs");
|
||||
const Module = require("node:module");
|
||||
@@ -354,3 +354,62 @@ test("compressed promotion can clean a read-only old directory instead of leakin
|
||||
false,
|
||||
);
|
||||
});
|
||||
|
||||
test("failed compressed extraction leaves the existing folder untouched", (t) => {
|
||||
const root = fs.mkdtempSync(path.join(os.tmpdir(), "netcatty-compress-interactive-fail-"));
|
||||
t.after(() => fs.rmSync(root, { recursive: true, force: true }));
|
||||
const target = path.join(root, "target");
|
||||
const finalDir = path.join(target, "folder");
|
||||
fs.mkdirSync(finalDir, { recursive: true });
|
||||
fs.writeFileSync(path.join(finalDir, "old.txt"), "old");
|
||||
const archive = path.join(target, "truncated.tar.gz");
|
||||
fs.writeFileSync(archive, Buffer.from([0x1f, 0x8b, 0x08, 0x00, 0x00]));
|
||||
|
||||
const command = buildAtomicRemoteExtractionCommand({
|
||||
compressionId: "truncated-archive",
|
||||
archivePath: archive,
|
||||
targetDir: target,
|
||||
folderName: "folder",
|
||||
});
|
||||
assert.throws(() => execFileSync("/bin/sh", ["-c", command], { stdio: "pipe" }));
|
||||
assert.equal(fs.readFileSync(path.join(finalDir, "old.txt"), "utf8"), "old");
|
||||
assert.equal(fs.readdirSync(target).some((name) => name.includes(".netcatty-compress-")), false);
|
||||
});
|
||||
|
||||
test("cancelled compressed extraction leaves the existing folder untouched", async (t) => {
|
||||
if (process.platform === "win32") return t.skip("POSIX fifo test");
|
||||
const root = fs.mkdtempSync(path.join(os.tmpdir(), "netcatty-compress-interactive-cancel-"));
|
||||
t.after(() => fs.rmSync(root, { recursive: true, force: true }));
|
||||
const target = path.join(root, "target");
|
||||
const finalDir = path.join(target, "folder");
|
||||
fs.mkdirSync(finalDir, { recursive: true });
|
||||
fs.writeFileSync(path.join(finalDir, "old.txt"), "old");
|
||||
const archive = path.join(target, "blocked.tar.gz");
|
||||
assert.equal(spawnSync("mkfifo", [archive]).status, 0);
|
||||
|
||||
const command = buildAtomicRemoteExtractionCommand({
|
||||
compressionId: "blocked-archive",
|
||||
archivePath: archive,
|
||||
targetDir: target,
|
||||
folderName: "folder",
|
||||
});
|
||||
const child = spawn("/bin/sh", ["-c", command], { detached: true, stdio: "ignore" });
|
||||
t.after(() => {
|
||||
try { process.kill(-child.pid, "SIGKILL"); } catch { /* already gone */ }
|
||||
});
|
||||
const waitFor = async (predicate) => {
|
||||
const deadline = Date.now() + 5000;
|
||||
while (Date.now() < deadline) {
|
||||
if (predicate()) return true;
|
||||
await new Promise((resolve) => setTimeout(resolve, 20));
|
||||
}
|
||||
return predicate();
|
||||
};
|
||||
assert.equal(await waitFor(() => fs.readdirSync(target).some((name) => name.endsWith(".stage"))), true);
|
||||
process.kill(-child.pid, "SIGTERM");
|
||||
assert.equal(
|
||||
await waitFor(() => !fs.readdirSync(target).some((name) => name.includes(".netcatty-compress-"))),
|
||||
true,
|
||||
);
|
||||
assert.equal(fs.readFileSync(path.join(finalDir, "old.txt"), "utf8"), "old");
|
||||
});
|
||||
@@ -4,7 +4,7 @@
|
||||
const { clearSessionFlowState } = require("../terminalFlowAck.cjs");
|
||||
const {
|
||||
ensureSessionShellKind,
|
||||
remoteDisallowsExecChannelProbe, ensureSessionShellKindForExec,
|
||||
remoteDisallowsExecChannelProbe, sessionDisallowsExtraSshChannel, ensureSessionShellKindForExec,
|
||||
} = require("../ai/sessionShellKind.cjs");
|
||||
|
||||
function createExecHandlerApi(ctx) {
|
||||
@@ -150,6 +150,7 @@ function createExecHandlerApi(ctx) {
|
||||
loginShellHint: session._loginShellKind,
|
||||
probeLiveShell: true,
|
||||
bastionKeystrokes: remoteDisallowsExecChannelProbe(session.remoteSshVersion),
|
||||
skipPendingInputClear: session.singleChannelSsh === true,
|
||||
onProbeAborted: (marker) => echoCommandToSession(session, sessionId, `${marker}_R`, { syntheticEcho: false }),
|
||||
onEchoSuppressionPrime: (marker) => echoCommandToSession(session, sessionId, `${marker}_I`, { syntheticEcho: false }),
|
||||
expectedPrompt: getFreshIdlePrompt(session),
|
||||
@@ -173,7 +174,7 @@ function createExecHandlerApi(ctx) {
|
||||
|
||||
// Fallback: SSH exec channel (invisible to terminal).
|
||||
// At this point ptyStream is not writable (already returned above if it was).
|
||||
if (sshClient && typeof sshClient.exec === "function") {
|
||||
if (!session.singleChannelSsh && sshClient && typeof sshClient.exec === "function") {
|
||||
return runExecution(async () => {
|
||||
const probed = await ensureSessionShellKindForExec(session, {
|
||||
trackForCancellation: activePtyExecs,
|
||||
@@ -322,6 +323,7 @@ function createExecHandlerApi(ctx) {
|
||||
loginShellHint: session._loginShellKind,
|
||||
probeLiveShell: true,
|
||||
bastionKeystrokes: remoteDisallowsExecChannelProbe(session.remoteSshVersion),
|
||||
skipPendingInputClear: session.singleChannelSsh === true,
|
||||
onProbeAborted: (marker) => echoCommandToSession(session, sessionId, `${marker}_R`, { syntheticEcho: false }),
|
||||
onEchoSuppressionPrime: (marker) => echoCommandToSession(session, sessionId, `${marker}_I`, { syntheticEcho: false }),
|
||||
chatSessionId,
|
||||
|
||||
@@ -10,6 +10,7 @@ const { NetcattyAgent } = require("./netcattyAgent.cjs");
|
||||
const keyboardInteractiveHandler = require("./keyboardInteractiveHandler.cjs");
|
||||
const { connectThroughChain, buildAlgorithms } = require("./sshBridge.cjs");
|
||||
const { resolveSshConnectionTimeouts } = require("./sshBridge/startSession.cjs");
|
||||
const { remoteSoftwareRequiresSingleChannel } = require("../../domain/singleChannelSshBanner.shared.cjs");
|
||||
const hostKeyVerifier = require("./hostKeyVerifier.cjs");
|
||||
const { createProxySocket, runWhenProxyConnectionReady } = require("./proxyUtils.cjs");
|
||||
const {
|
||||
@@ -1171,6 +1172,9 @@ async function startPortForward(event, payload) {
|
||||
sshAuthReadyTimeoutMs,
|
||||
reuseTransport = true,
|
||||
} = payload;
|
||||
// One-channel bastions drop the terminal if a forward opens another
|
||||
// channel on that same TCP connection. Dial a private SSH session instead.
|
||||
const allowTransportReuse = reuseTransport !== false && payload.singleChannelSsh !== true;
|
||||
|
||||
// The rule is the durable identity; tunnelId is only one renderer's
|
||||
// attempt. Reuse an in-flight/live tunnel so two windows cannot create
|
||||
@@ -1249,11 +1253,11 @@ async function startPortForward(event, payload) {
|
||||
// transport exists yet. Explicitly dedicated forwards remain isolated and
|
||||
// are not published into the shared pool.
|
||||
let pendingDialCoordination = null;
|
||||
let existingTransport = reuseTransport !== false
|
||||
let existingTransport = allowTransportReuse
|
||||
? findTransportByEndpoint(reuseEndpoint)
|
||||
: null;
|
||||
try {
|
||||
if (!existingTransport && reuseTransport !== false && typeof beginTransportDial === "function") {
|
||||
if (!existingTransport && allowTransportReuse && typeof beginTransportDial === "function") {
|
||||
const coordination = beginTransportDial(reuseEndpoint, { kind: "channel" });
|
||||
if (coordination.role === "reuse") {
|
||||
existingTransport = coordination.transport;
|
||||
@@ -1663,6 +1667,7 @@ async function startPortForward(event, payload) {
|
||||
conn.once('ready', () => {
|
||||
clearAuthReadyTimer();
|
||||
console.log(`[PortForward] SSH connection ready for tunnel ${tunnelId}`);
|
||||
const peerSingleChannelSsh = remoteSoftwareRequiresSingleChannel(conn._remoteVer);
|
||||
|
||||
bindPortForwardChannels({
|
||||
type,
|
||||
@@ -1677,8 +1682,10 @@ async function startPortForward(event, payload) {
|
||||
chainConnections,
|
||||
sendStatus,
|
||||
releaseOnError: false,
|
||||
endpoint: reuseEndpoint,
|
||||
registerTransport: reuseTransport !== false,
|
||||
endpoint: peerSingleChannelSsh
|
||||
? { ...reuseEndpoint, singleChannelSsh: true }
|
||||
: reuseEndpoint,
|
||||
registerTransport: allowTransportReuse && !peerSingleChannelSsh,
|
||||
dialCoordination: pendingDialCoordination,
|
||||
}).then((result) => {
|
||||
if (!result?.success && pendingDialCoordination) {
|
||||
|
||||
@@ -7,6 +7,7 @@ const { EventEmitter } = require("node:events");
|
||||
const Module = require("node:module");
|
||||
const {
|
||||
beginTransportDial,
|
||||
buildConnectionReuseEndpoint,
|
||||
failTransportDial,
|
||||
getTransportStats,
|
||||
resetSshTransportRegistryForTests,
|
||||
@@ -16,6 +17,7 @@ const { shouldRegisterFreshSftpTransport } = require("./sftpBridge/openConnectio
|
||||
test("sudo SFTP connections are never parked in the shared transport registry", () => {
|
||||
assert.equal(shouldRegisterFreshSftpTransport({ sudo: true, reuseTransport: true }), false);
|
||||
assert.equal(shouldRegisterFreshSftpTransport({ sudo: false, reuseTransport: true }), true);
|
||||
assert.equal(shouldRegisterFreshSftpTransport({ sudo: false, reuseTransport: true, singleChannelSsh: true }), false);
|
||||
});
|
||||
|
||||
/** Build a minimal renderer sender for SFTP progress and auth prompt IPC. */
|
||||
@@ -447,3 +449,37 @@ test("openSftp retries keyboard-interactive after dynamic auth password rejectio
|
||||
);
|
||||
assert.equal(sftpClients.has("sftp-edr-mfa-auto"), true);
|
||||
});
|
||||
|
||||
test("single-channel SFTP does not join an in-flight terminal dial", async (t) => {
|
||||
resetSshTransportRegistryForTests({ defaultIdleTtlMs: 0 });
|
||||
t.after(() => resetSshTransportRegistryForTests({ defaultIdleTtlMs: 0 }));
|
||||
const { bridge, MockSftpClient } = loadSftpBridgeWithAuthRetryMocks(t, { alwaysSucceed: true });
|
||||
const sftpClients = new Map();
|
||||
bridge.init({ sftpClients, sessions: new Map(), electronModule: {} });
|
||||
const options = {
|
||||
hostname: "bastion.example",
|
||||
hostId: "host-bastion",
|
||||
port: 22,
|
||||
username: "root",
|
||||
authMethod: "password",
|
||||
password: "saved-login-password",
|
||||
useSshAgent: false,
|
||||
verifyHostKeys: false,
|
||||
fileProtocol: "sftp",
|
||||
singleChannelSsh: true,
|
||||
};
|
||||
const coordination = beginTransportDial(buildConnectionReuseEndpoint(options), { kind: "shell" });
|
||||
assert.equal(coordination.role, "leader");
|
||||
|
||||
const opened = await bridge.openSftp(
|
||||
{ sender: makeSender() },
|
||||
{ ...options, sessionId: "sftp-during-terminal" },
|
||||
);
|
||||
|
||||
assert.equal(opened.sftpId, "sftp-during-terminal");
|
||||
assert.equal(MockSftpClient.instances.length, 1);
|
||||
assert.equal(coordination._record.settled, false);
|
||||
assert.equal(failTransportDial(coordination, new Error("terminal cancelled")), true);
|
||||
assert.notEqual(MockSftpClient.instances[0].client.ended, true);
|
||||
assert.equal(sftpClients.has("sftp-during-terminal"), true);
|
||||
});
|
||||
@@ -11,6 +11,7 @@ const { pipeline } = require("node:stream/promises");
|
||||
const { TextDecoder } = require("node:util");
|
||||
const { StringDecoder } = require("node:string_decoder");
|
||||
const { executeBoundedSshCommand } = require("./boundedSshExec.cjs");
|
||||
const { remoteSoftwareRequiresSingleChannel } = require("../../domain/singleChannelSshBanner.shared.cjs");
|
||||
const { openBoundedSftpChannel } = require("./boundedSftpOpen.cjs");
|
||||
const { invalidateSshTransport } = require("./sshTransportInvalidation.cjs");
|
||||
require("./boringSslDhCompat.cjs").installBoringSslDhCompat();
|
||||
@@ -230,6 +231,11 @@ const getSftpChannel = async (client, options = {}) => {
|
||||
return null;
|
||||
}
|
||||
|
||||
// A second SFTP channel on a single-channel bastion drops the whole login.
|
||||
if (client.__netcattySingleChannelSsh || client.client?.__netcattySingleChannelSsh) {
|
||||
return null;
|
||||
}
|
||||
|
||||
// Do not treat ssh2's "client.sftp" method as a channel object.
|
||||
// Re-open a fresh channel when the cached channel is stale.
|
||||
if (!client.client || typeof client.client.sftp !== "function") {
|
||||
@@ -637,6 +643,8 @@ async function execRemoteShellCommand(sshClient, command, optionsOrSignal = null
|
||||
async function tryFastShellDirectoryDelete(client, remotePath, encoding = "utf-8", signal = null) {
|
||||
const sshClient = client?.client;
|
||||
if (!sshClient || typeof sshClient.exec !== "function") return false;
|
||||
// Extra exec drops a single-channel bastion login. Use the SFTP walk instead.
|
||||
if (client.__netcattySingleChannelSsh || sshClient.__netcattySingleChannelSsh) return false;
|
||||
const enc = !encoding || encoding === "auto" ? "utf-8" : encoding;
|
||||
if (enc !== "utf-8") return false;
|
||||
if (typeof remotePath !== "string" || !remotePath || remotePath === "/" || remotePath === ".") {
|
||||
@@ -1090,7 +1098,12 @@ async function hashReadableForDigest(readable, signal = null) {
|
||||
}
|
||||
}
|
||||
|
||||
async function tryRemoteSha256Sum(sshClient, remotePath, signal = null) {
|
||||
async function tryRemoteSha256Sum(sshClient, remotePath, signal = null, owner = null) {
|
||||
// The dedicated SFTP login already holds the only session channel.
|
||||
// Hash the file through that SFTP stream instead of opening exec.
|
||||
if (sshClient?.__netcattySingleChannelSsh || owner?.__netcattySingleChannelSsh) {
|
||||
return null;
|
||||
}
|
||||
if (!sshClient || typeof sshClient.exec !== "function") return null;
|
||||
const escapedPath = String(remotePath).replace(/'/g, "'\\''");
|
||||
try {
|
||||
@@ -1121,7 +1134,7 @@ async function tryRemoteSha256Sum(sshClient, remotePath, signal = null) {
|
||||
async function computeRemoteContentDigest(client, encodedPath, remotePath, options = {}) {
|
||||
const signal = options.signal || null;
|
||||
throwIfAborted(signal);
|
||||
const digest = await tryRemoteSha256Sum(client?.client, remotePath, signal);
|
||||
const digest = await tryRemoteSha256Sum(client?.client, remotePath, signal, client);
|
||||
if (digest) {
|
||||
throwIfAborted(signal);
|
||||
return digest;
|
||||
@@ -1801,6 +1814,7 @@ function createSessionBackedSftpClient(sessionId, sshClient, options = {}) {
|
||||
client: sshClient,
|
||||
sftp: null,
|
||||
__netcattySessionBacked: true,
|
||||
__netcattySingleChannelSsh: !!options?.singleChannelSsh,
|
||||
__netcattySourceSessionId: options?.sourceSessionId,
|
||||
__netcattyRefHolder: refHolder,
|
||||
__netcattyDisposed: false,
|
||||
@@ -1940,6 +1954,16 @@ async function openSftpForSession(_event, payload) {
|
||||
source = { sessionId, ...ensureRemoteSftpSupport(sessionId) };
|
||||
}
|
||||
const { session, sshClient } = source;
|
||||
if (
|
||||
session.singleChannelSsh
|
||||
|| remoteSoftwareRequiresSingleChannel(session.remoteSshVersion || sshClient?._remoteVer)
|
||||
) {
|
||||
const err = new Error(
|
||||
"This host is configured for single-channel SSH. Opening SFTP on the terminal connection would disconnect it.",
|
||||
);
|
||||
err.code = "ERR_SFTP_SINGLE_CHANNEL_BASTION";
|
||||
throw err;
|
||||
}
|
||||
const actualEndpoint = session._reuseEndpoint || session.connRef?.endpoint;
|
||||
const sftpId = `${sourceSessionId}-sftp-${randomUUID()}`;
|
||||
const refHolder = { id: sftpId, __sshLeaseKind: "sftp" };
|
||||
@@ -1949,6 +1973,7 @@ async function openSftpForSession(_event, payload) {
|
||||
const client = createSessionBackedSftpClient(sourceSessionId, sshClient, {
|
||||
refHolder,
|
||||
sourceSessionId,
|
||||
singleChannelSsh: !!session.singleChannelSsh,
|
||||
});
|
||||
client.__netcattyEndpointKey = session.connRef?.endpointKey || buildEndpointKey(actualEndpoint);
|
||||
const { normalizeFileProtocol } = require("./sftpBridge/scpShell.cjs");
|
||||
@@ -2161,6 +2186,11 @@ async function acquireUploadSftpChannel(client, options = {}) {
|
||||
return { sftp, dispose: false };
|
||||
}
|
||||
const sshClient = client?.client;
|
||||
// Same constraint as openIsolatedSftpChannel: a second subsystem drops the login.
|
||||
if (client?.__netcattySingleChannelSsh || sshClient?.__netcattySingleChannelSsh) {
|
||||
const shared = await requireSftpChannel(client, options);
|
||||
return { sftp: shared, dispose: false };
|
||||
}
|
||||
if (sshClient && typeof sshClient.sftp === "function") {
|
||||
// Prefer a disposable channel for cancel, but never fail the whole upload
|
||||
// when MaxSessions / server policy refuses another subsystem — fall back to
|
||||
@@ -2702,6 +2732,7 @@ module.exports = {
|
||||
extractSftpArchive,
|
||||
getSftpHomeDir,
|
||||
resolveEncodingForRequest,
|
||||
_tryFastShellDirectoryDeleteForTests: tryFastShellDirectoryDelete,
|
||||
_execRemoteShellCommandForTests: execRemoteShellCommand,
|
||||
_tryRemoteSha256SumForTests: tryRemoteSha256Sum,
|
||||
};
|
||||
@@ -269,6 +269,94 @@ test("openSftpForSession holds a shared SSH connection until the SFTP handle clo
|
||||
setDefaultTransportIdleTtlMs(60_000);
|
||||
});
|
||||
|
||||
test("openSftpForSession refuses extra channels when singleChannelSsh is set", async () => {
|
||||
const bridge = loadSftpBridgeWithProxySocket(null);
|
||||
const sftpClients = new Map();
|
||||
let sftpCalls = 0;
|
||||
const conn = {
|
||||
_remoteVer: "CLOUDBILITY-4.14",
|
||||
sftp(cb) {
|
||||
sftpCalls += 1;
|
||||
cb(null, { end() {} });
|
||||
},
|
||||
end() {},
|
||||
};
|
||||
const session = {
|
||||
conn,
|
||||
singleChannelSsh: true, remoteSshVersion: "CLOUDBILITY-4.14",
|
||||
stream: {},
|
||||
};
|
||||
const sessions = new Map([["session-bastion", session]]);
|
||||
bridge.init({ sftpClients, sessions, electronModule: {} });
|
||||
|
||||
await assert.rejects(
|
||||
bridge.openSftpForSession(null, { sessionId: "session-bastion" }),
|
||||
(err) => err && err.code === "ERR_SFTP_SINGLE_CHANNEL_BASTION" && sftpCalls === 0,
|
||||
);
|
||||
});
|
||||
|
||||
test("openSftpForSession refuses a one-channel bastion banner without the host flag", async () => {
|
||||
for (const remoteSshVersion of ["CLOUDBILITY-4.14", "BHostSSH_7.0", "SSH-2.0-TERM-SSHD"]) {
|
||||
const bridge = loadSftpBridgeWithProxySocket(null);
|
||||
const sftpClients = new Map();
|
||||
let sftpCalls = 0;
|
||||
const conn = {
|
||||
_remoteVer: remoteSshVersion,
|
||||
sftp(cb) {
|
||||
sftpCalls += 1;
|
||||
cb(null, { end() {} });
|
||||
},
|
||||
end() {},
|
||||
};
|
||||
const session = {
|
||||
conn,
|
||||
stream: {},
|
||||
remoteSshVersion,
|
||||
};
|
||||
const sessions = new Map([["session-banner", session]]);
|
||||
bridge.init({ sftpClients, sessions, electronModule: {} });
|
||||
|
||||
await assert.rejects(
|
||||
bridge.openSftpForSession(null, { sessionId: "session-banner" }),
|
||||
(err) => err && err.code === "ERR_SFTP_SINGLE_CHANNEL_BASTION" && sftpCalls === 0,
|
||||
remoteSshVersion,
|
||||
);
|
||||
}
|
||||
});
|
||||
|
||||
test("openSftpForSession still shares the terminal transport for JumpServer", async () => {
|
||||
const bridge = loadSftpBridgeWithProxySocket(null);
|
||||
const sftpClients = new Map();
|
||||
let sftpCalls = 0;
|
||||
const fakeSftp = {
|
||||
readdir() {},
|
||||
stat() {},
|
||||
mkdir() {},
|
||||
unlink() {},
|
||||
end() {},
|
||||
};
|
||||
const conn = {
|
||||
_remoteVer: "JumpServer",
|
||||
sftp(cb) {
|
||||
sftpCalls += 1;
|
||||
cb(null, fakeSftp);
|
||||
},
|
||||
end() {},
|
||||
};
|
||||
const session = {
|
||||
conn,
|
||||
stream: {},
|
||||
remoteSshVersion: "SSH-2.0-JumpServer",
|
||||
};
|
||||
const sessions = new Map([["session-jumpserver", session]]);
|
||||
bridge.init({ sftpClients, sessions, electronModule: {} });
|
||||
|
||||
const opened = await bridge.openSftpForSession(null, { sessionId: "session-jumpserver" });
|
||||
assert.equal(opened.ok, true);
|
||||
assert.equal(sftpCalls, 1);
|
||||
await bridge.closeSftp(null, { sftpId: opened.sftpId });
|
||||
});
|
||||
|
||||
test("openSftpForSession honors session.sftpFileProtocol when payload omits fileProtocol", async () => {
|
||||
const bridge = loadSftpBridgeWithProxySocket(null);
|
||||
const sftpClients = new Map();
|
||||
@@ -464,3 +552,19 @@ test("openSftpForSession keeps sudo mode when connectSudoSftp succeeds", async (
|
||||
assert.equal(closeBound, true);
|
||||
await bridge.closeSftp(null, { sftpId: opened.sftpId });
|
||||
});
|
||||
|
||||
test("single-channel directory delete does not open a shell exec", async () => {
|
||||
const bridge = require("./sftpBridge.cjs");
|
||||
let execCalls = 0;
|
||||
const removed = await bridge._tryFastShellDirectoryDeleteForTests({
|
||||
__netcattySingleChannelSsh: true,
|
||||
client: {
|
||||
exec() {
|
||||
execCalls += 1;
|
||||
throw new Error("must not exec on single-channel SSH");
|
||||
},
|
||||
},
|
||||
}, "/home/app/static/folder");
|
||||
assert.equal(removed, false);
|
||||
assert.equal(execCalls, 0);
|
||||
});
|
||||
@@ -156,3 +156,15 @@ test("remote extract exec drains stdout without treating progress as a hard limi
|
||||
|
||||
assert.deepEqual(await running, { stdout: "", stderr: "ok", code: 0 });
|
||||
});
|
||||
|
||||
test("remote sha256 skips exec on a single-channel SFTP login", async () => {
|
||||
const { _tryRemoteSha256SumForTests } = require("./sftpBridge.cjs");
|
||||
let execCalls = 0;
|
||||
const digest = await _tryRemoteSha256SumForTests({
|
||||
__netcattySingleChannelSsh: true,
|
||||
exec() { execCalls += 1; },
|
||||
}, "/tmp/file");
|
||||
assert.equal(digest, null);
|
||||
assert.equal(execCalls, 0);
|
||||
});
|
||||
|
||||
@@ -1061,14 +1061,6 @@ function createFileOpsApi(ctx) {
|
||||
const signal = payload?.abortSignal || null;
|
||||
throwIfAborted(signal);
|
||||
|
||||
const sshClient = client.client;
|
||||
if (!sshClient || typeof sshClient.exec !== "function") {
|
||||
throw new Error("SSH exec unavailable");
|
||||
}
|
||||
if (typeof execRemoteShellCommand !== "function") {
|
||||
throw new Error("SSH exec unavailable");
|
||||
}
|
||||
|
||||
let archiveSize = 0;
|
||||
try {
|
||||
if (!isScpModeClient(client) && typeof lstatAsync === "function") {
|
||||
@@ -1081,6 +1073,21 @@ function createFileOpsApi(ctx) {
|
||||
archiveSize = 0;
|
||||
}
|
||||
|
||||
if (client.__netcattySingleChannelSsh || client.client?.__netcattySingleChannelSsh) {
|
||||
const err = new Error(
|
||||
"This host is configured for single-channel SSH. Remote archive extraction needs a shell channel, which this file-transfer login cannot open.",
|
||||
);
|
||||
err.code = "ERR_SFTP_SINGLE_CHANNEL_EXEC";
|
||||
throw err;
|
||||
}
|
||||
const sshClient = client.client;
|
||||
if (!sshClient || typeof sshClient.exec !== "function") {
|
||||
throw new Error("SSH exec unavailable");
|
||||
}
|
||||
if (typeof execRemoteShellCommand !== "function") {
|
||||
throw new Error("SSH exec unavailable");
|
||||
}
|
||||
|
||||
await execRemoteShellCommand(sshClient, command, {
|
||||
signal,
|
||||
openingTimeoutMs: EXTRACT_OPEN_TIMEOUT_MS,
|
||||
@@ -1127,9 +1134,15 @@ function createFileOpsApi(ctx) {
|
||||
}
|
||||
|
||||
// Method 1: SSH exec `echo ~` (with 5s timeout to avoid hanging on
|
||||
// hosts with blocking shell init scripts or forced commands)
|
||||
// hosts with blocking shell init scripts or forced commands).
|
||||
// Skip on single-channel PAM bastions: the extra exec drops the SFTP
|
||||
// transport (Cloudbility / QiZhi), same class as JumpServer exec denial.
|
||||
const sshClient = client.client;
|
||||
if (sshClient && typeof sshClient.exec === "function") {
|
||||
if (
|
||||
sshClient
|
||||
&& typeof sshClient.exec === "function"
|
||||
&& !client.__netcattySingleChannelSsh
|
||||
) {
|
||||
let execStream = null;
|
||||
try {
|
||||
const result = await executeBoundedSshCommand(sshClient, "echo ~", {
|
||||
|
||||
@@ -56,3 +56,27 @@ test("extractSftpArchive requires an open SFTP session", async () => {
|
||||
/SFTP session not found/,
|
||||
);
|
||||
});
|
||||
|
||||
|
||||
test("extractSftpArchive refuses a second channel on single-channel SSH", async () => {
|
||||
let execCalls = 0;
|
||||
const { api, commands } = createExtractApi({
|
||||
clients: new Map([[
|
||||
"sftp-1",
|
||||
{
|
||||
__netcattySingleChannelSsh: true,
|
||||
__netcattyEndpointKey: "ep-extract",
|
||||
client: { exec() { execCalls += 1; } },
|
||||
},
|
||||
]]),
|
||||
});
|
||||
await assert.rejects(
|
||||
() => api.extractSftpArchive(null, {
|
||||
sftpId: "sftp-1",
|
||||
path: "/home/app/backup.tar.gz",
|
||||
}),
|
||||
(err) => err && err.code === "ERR_SFTP_SINGLE_CHANNEL_EXEC",
|
||||
);
|
||||
assert.equal(commands.length, 0);
|
||||
assert.equal(execCalls, 0);
|
||||
});
|
||||
@@ -47,6 +47,35 @@ test("home discovery rejects non-listable root so candidate probing can run", as
|
||||
assert.match(result.error || "", /Could not determine home directory/);
|
||||
});
|
||||
|
||||
test("home discovery skips SSH exec when singleChannelSsh is set", async () => {
|
||||
let execCalls = 0;
|
||||
const channel = {};
|
||||
const api = createFileOpsApi({
|
||||
sftpClients: new Map([["cloudbility", {
|
||||
sftp: channel,
|
||||
__netcattySingleChannelSsh: true,
|
||||
client: {
|
||||
_remoteVer: "CLOUDBILITY-4.14",
|
||||
exec() {
|
||||
execCalls += 1;
|
||||
throw new Error("must not exec on Cloudbility");
|
||||
},
|
||||
},
|
||||
}]]),
|
||||
throwIfAborted() {},
|
||||
requireSftpChannel: async () => channel,
|
||||
realpathAsync: async () => "/root",
|
||||
readdirAsync: async () => {
|
||||
throw new Error("must not list after non-root realpath");
|
||||
},
|
||||
});
|
||||
|
||||
const result = await api.getSftpHomeDir(null, { sftpId: "cloudbility" });
|
||||
|
||||
assert.deepEqual(result, { success: true, homeDir: "/root" });
|
||||
assert.equal(execCalls, 0);
|
||||
});
|
||||
|
||||
test("home discovery still accepts non-root realpath without listing", async () => {
|
||||
const channel = {};
|
||||
let readdirCalls = 0;
|
||||
|
||||
@@ -10,6 +10,7 @@ const {
|
||||
} = require("../boundedSshExec.cjs");
|
||||
const { openBoundedSftpChannel } = require("../boundedSftpOpen.cjs");
|
||||
const { openBoundedForwardOutCallback } = require("../boundedSshChannelOpen.cjs");
|
||||
const { remoteSoftwareRequiresSingleChannel } = require("../../../domain/singleChannelSshBanner.shared.cjs");
|
||||
|
||||
/** Bound shell/scp probes so a hung remote exec cannot leave the panel connecting forever. */
|
||||
const SCP_PROBE_TIMEOUT_MS = 15_000;
|
||||
@@ -68,8 +69,12 @@ function shouldRetrySftpKeyboardInteractiveFirst(options, authConfig, err) {
|
||||
);
|
||||
}
|
||||
|
||||
function allowsSharedSftpTransport(options) {
|
||||
return options?.reuseTransport !== false && !options?.sudo && options?.singleChannelSsh !== true;
|
||||
}
|
||||
|
||||
function shouldRegisterFreshSftpTransport(options) {
|
||||
return options?.reuseTransport !== false && !options?.sudo;
|
||||
return allowsSharedSftpTransport(options);
|
||||
}
|
||||
|
||||
/** Sudo is part of the requested connection contract; never downgrade it. */
|
||||
@@ -765,6 +770,17 @@ function createOpenConnectionApi(ctx) {
|
||||
let pendingDialCoordination = options._pendingDialCoordination || null;
|
||||
|
||||
const openOnSharedTransport = async (transport, detail = "reusing shared SSH transport") => {
|
||||
if (
|
||||
options.singleChannelSsh
|
||||
|| transport.endpoint?.singleChannelSsh
|
||||
|| remoteSoftwareRequiresSingleChannel(transport?.conn?._remoteVer)
|
||||
) {
|
||||
const err = new Error(
|
||||
"This host is configured for single-channel SSH. Opening SFTP on the terminal connection would disconnect it.",
|
||||
);
|
||||
err.code = "ERR_SFTP_SINGLE_CHANNEL_BASTION";
|
||||
throw err;
|
||||
}
|
||||
const refHolder = {
|
||||
id: connId,
|
||||
__sshLeaseKind: "sftp",
|
||||
@@ -774,6 +790,7 @@ function createOpenConnectionApi(ctx) {
|
||||
const reusedClient = createSessionBackedSftpClient(connId, transport.conn, {
|
||||
refHolder,
|
||||
sourceSessionId: options.sourceSessionId || null,
|
||||
singleChannelSsh: !!(options.singleChannelSsh || transport.endpoint?.singleChannelSsh),
|
||||
});
|
||||
reusedClient.__netcattyEndpointKey = transport.endpointKey || buildEndpointKey(reuseEndpoint);
|
||||
reusedClient.__netcattyTransportManaged = true;
|
||||
@@ -814,8 +831,7 @@ function createOpenConnectionApi(ctx) {
|
||||
// a transfer never silently attaches to a terminal/parked conn.
|
||||
if (
|
||||
!pendingDialCoordination
|
||||
&& options.reuseTransport !== false
|
||||
&& !options.sudo
|
||||
&& allowsSharedSftpTransport(options)
|
||||
&& typeof findTransportByEndpoint === "function"
|
||||
&& typeof createSessionBackedSftpClient === "function"
|
||||
) {
|
||||
@@ -834,8 +850,7 @@ function createOpenConnectionApi(ctx) {
|
||||
|
||||
if (
|
||||
!pendingDialCoordination
|
||||
&& options.reuseTransport !== false
|
||||
&& !options.sudo
|
||||
&& allowsSharedSftpTransport(options)
|
||||
&& typeof beginTransportDial === "function"
|
||||
) {
|
||||
const coordination = beginTransportDial(reuseEndpoint, { kind: "channel" });
|
||||
@@ -846,7 +861,12 @@ function createOpenConnectionApi(ctx) {
|
||||
: await waitForTransportDial(coordination);
|
||||
return await openOnSharedTransport(transport, "reused coordinated transport");
|
||||
} catch (coordinationErr) {
|
||||
if (coordination.role === "join") throw coordinationErr;
|
||||
if (
|
||||
coordination.role === "join"
|
||||
&& coordinationErr?.code !== "ERR_SFTP_SINGLE_CHANNEL_BASTION"
|
||||
) {
|
||||
throw coordinationErr;
|
||||
}
|
||||
console.warn(
|
||||
`[SFTP] Coordinated transport reuse failed for ${connId}; connecting fresh:`,
|
||||
coordinationErr?.message || String(coordinationErr),
|
||||
@@ -866,6 +886,7 @@ function createOpenConnectionApi(ctx) {
|
||||
|
||||
async function openFreshSftp() {
|
||||
const client = new SftpClient();
|
||||
let peerSingleChannelSsh = options.singleChannelSsh === true;
|
||||
client.__netcattyEndpointKey = buildEndpointKey(reuseEndpoint);
|
||||
let freshClientClosed = false;
|
||||
const closeFreshClient = () => {
|
||||
@@ -1206,6 +1227,9 @@ function createOpenConnectionApi(ctx) {
|
||||
sshClient.once('ready', () => {
|
||||
clearAuthReadyTimer();
|
||||
cleanup();
|
||||
if (remoteSoftwareRequiresSingleChannel(sshClient._remoteVer)) {
|
||||
peerSingleChannelSsh = true;
|
||||
}
|
||||
sendSftpProgress(event.sender, connId, options.hostname, 'connected');
|
||||
|
||||
const fileProtocol = normalizeFileProtocol(options.fileProtocol);
|
||||
@@ -1271,6 +1295,12 @@ function createOpenConnectionApi(ctx) {
|
||||
finishSftp(sftp);
|
||||
}).catch((err) => {
|
||||
if (fileProtocol === "auto") {
|
||||
if (peerSingleChannelSsh) {
|
||||
reject(new Error(
|
||||
`SFTP subsystem unavailable (${err.message}). This host is configured for single-channel SSH, so SCP fallback is disabled.`,
|
||||
));
|
||||
return;
|
||||
}
|
||||
void finishScp(`SFTP subsystem unavailable (${err.message})`);
|
||||
return;
|
||||
}
|
||||
@@ -1304,7 +1334,7 @@ function createOpenConnectionApi(ctx) {
|
||||
const sshConn = client.client;
|
||||
if (
|
||||
sshConn
|
||||
&& shouldRegisterFreshSftpTransport(options)
|
||||
&& shouldRegisterFreshSftpTransport({ ...options, singleChannelSsh: peerSingleChannelSsh })
|
||||
&& typeof createTransport === "function"
|
||||
&& typeof borrowTransport === "function"
|
||||
) {
|
||||
@@ -1343,6 +1373,10 @@ function createOpenConnectionApi(ctx) {
|
||||
}
|
||||
}
|
||||
|
||||
client.__netcattySingleChannelSsh = peerSingleChannelSsh;
|
||||
if (client.client) {
|
||||
client.client.__netcattySingleChannelSsh = peerSingleChannelSsh;
|
||||
}
|
||||
sftpClients.set(connId, client);
|
||||
|
||||
// Store jump connections for cleanup when SFTP is closed (legacy path
|
||||
|
||||
@@ -30,6 +30,7 @@ function loadBridgeWithMockedSsh2(t, {
|
||||
const authHelperPath = require.resolve("./sshAuthHelper.cjs");
|
||||
const originalLoad = Module._load;
|
||||
let clientConstructCount = 0;
|
||||
const clients = [];
|
||||
|
||||
class MockSSHClient extends EventEmitter {
|
||||
constructor() {
|
||||
@@ -41,7 +42,9 @@ function loadBridgeWithMockedSsh2(t, {
|
||||
};
|
||||
this._remoteVer = remoteVer;
|
||||
this.openedShells = [];
|
||||
this.lastShellOptions = null;
|
||||
this.ended = 0;
|
||||
clients.push(this);
|
||||
}
|
||||
connect() {
|
||||
clientConstructCount += 1;
|
||||
@@ -77,7 +80,8 @@ function loadBridgeWithMockedSsh2(t, {
|
||||
});
|
||||
callback(null, stream);
|
||||
}
|
||||
shell(_pty, _options, callback) {
|
||||
shell(_pty, options, callback) {
|
||||
this.lastShellOptions = options;
|
||||
const stream = makeStream();
|
||||
this.openedShells.push(stream);
|
||||
setImmediate(() => callback(null, stream));
|
||||
@@ -133,7 +137,11 @@ function loadBridgeWithMockedSsh2(t, {
|
||||
Module._load = originalLoad;
|
||||
});
|
||||
|
||||
return { bridge, getClientConstructCount: () => clientConstructCount };
|
||||
return {
|
||||
bridge,
|
||||
getClientConstructCount: () => clientConstructCount,
|
||||
getClients: () => clients,
|
||||
};
|
||||
}
|
||||
|
||||
test("simultaneous normal opens identify both shells before sharing one SSH connection", async (t) => {
|
||||
@@ -782,10 +790,12 @@ function makeSourceSession(conn, endpoint) {
|
||||
zmodemSentry: { cancel() {} },
|
||||
hostname: endpoint.hostname,
|
||||
username: endpoint.username,
|
||||
singleChannelSsh: endpoint.singleChannelSsh === true,
|
||||
_reuseEndpoint: {
|
||||
hostname: endpoint.hostname,
|
||||
port: endpoint.port || 22,
|
||||
username: endpoint.username,
|
||||
...(endpoint.singleChannelSsh ? { singleChannelSsh: true } : {}),
|
||||
...(Array.isArray(endpoint.jumpHosts) ? { jumpHosts: endpoint.jumpHosts } : {}),
|
||||
},
|
||||
};
|
||||
@@ -2183,3 +2193,128 @@ test("falls back to a fresh connection when the source is gone", async (t) => {
|
||||
[{ sessionId: "copy", sourceSessionId: "missing-source" }],
|
||||
);
|
||||
});
|
||||
|
||||
test("one-channel bastion banners stamp the runtime flag and dial Copy Tab separately", async (t) => {
|
||||
const banners = ["CLOUDBILITY-4.14", "SSH-2.0-BHostSSH_7.0", "TERM-SSHD"];
|
||||
for (const remoteVer of banners) {
|
||||
resetSshTransportRegistryForTests({ defaultIdleTtlMs: 0 });
|
||||
const { bridge, getClientConstructCount, getClients } = loadBridgeWithMockedSsh2(t, {
|
||||
connectReady: true,
|
||||
remoteVer,
|
||||
});
|
||||
const sessions = new Map();
|
||||
const start = registerStartHandler(bridge, sessions);
|
||||
const options = {
|
||||
hostname: "bastion.example",
|
||||
username: "alice",
|
||||
port: 22,
|
||||
authMethod: "password",
|
||||
password: "secret",
|
||||
useSshAgent: false,
|
||||
verifyHostKeys: false,
|
||||
};
|
||||
|
||||
await start({ sender: makeSender() }, { ...options, sessionId: "first" });
|
||||
const first = sessions.get("first");
|
||||
const firstClient = getClients()[0];
|
||||
assert.equal(first.singleChannelSsh, true, remoteVer);
|
||||
assert.equal(first._reuseEndpoint.singleChannelSsh, true, remoteVer);
|
||||
assert.equal(first.connRef.endpoint.singleChannelSsh, true, remoteVer);
|
||||
assert.equal(first.connRef.allowIdlePark, false, remoteVer);
|
||||
assert.equal(firstClient.lastShellOptions.env, undefined, remoteVer);
|
||||
assert.equal(firstClient.openedShells.length, 1, remoteVer);
|
||||
|
||||
await start({ sender: makeSender() }, {
|
||||
...options,
|
||||
sessionId: "copy",
|
||||
sourceSessionId: "first",
|
||||
});
|
||||
assert.equal(getClientConstructCount(), 2, remoteVer);
|
||||
assert.equal(firstClient.openedShells.length, 1, remoteVer);
|
||||
assert.notEqual(sessions.get("copy").conn, first.conn, remoteVer);
|
||||
assert.equal(sessions.get("copy").singleChannelSsh, true, remoteVer);
|
||||
}
|
||||
});
|
||||
|
||||
test("OpenSSH and JumpServer banners do not stamp single-channel mode", async (t) => {
|
||||
for (const remoteVer of ["OpenSSH_9.6", "JumpServer"]) {
|
||||
resetSshTransportRegistryForTests({ defaultIdleTtlMs: 0 });
|
||||
const { bridge, getClientConstructCount, getClients } = loadBridgeWithMockedSsh2(t, {
|
||||
connectReady: true,
|
||||
remoteVer,
|
||||
});
|
||||
const sessions = new Map();
|
||||
const start = registerStartHandler(bridge, sessions);
|
||||
const options = {
|
||||
hostname: "shell.example",
|
||||
username: "alice",
|
||||
port: 22,
|
||||
authMethod: "password",
|
||||
password: "secret",
|
||||
useSshAgent: false,
|
||||
verifyHostKeys: false,
|
||||
};
|
||||
|
||||
await start({ sender: makeSender() }, { ...options, sessionId: "first" });
|
||||
const first = sessions.get("first");
|
||||
assert.equal(first.singleChannelSsh, false, remoteVer);
|
||||
assert.equal(first.connRef.endpoint.singleChannelSsh, false, remoteVer);
|
||||
assert.equal(getClients()[0].lastShellOptions.env.COLORTERM, "truecolor", remoteVer);
|
||||
|
||||
await start({ sender: makeSender() }, {
|
||||
...options,
|
||||
sessionId: "copy",
|
||||
sourceSessionId: "first",
|
||||
skipShellPidDiscovery: true,
|
||||
});
|
||||
assert.equal(getClientConstructCount(), 1, remoteVer);
|
||||
assert.equal(sessions.get("copy").conn, first.conn, remoteVer);
|
||||
assert.equal(sessions.get("copy").singleChannelSsh, false, remoteVer);
|
||||
}
|
||||
});
|
||||
|
||||
test("single-channel Copy Tab dials separately and keeps the original shell", async (t) => {
|
||||
const { bridge, getClientConstructCount } = loadBridgeWithMockedSsh2(t, { connectReady: true });
|
||||
const sessions = new Map();
|
||||
const sourceConn = makeReusableConn();
|
||||
const openShell = sourceConn.shell;
|
||||
sourceConn.shell = (...args) => {
|
||||
sourceConn._sock.destroyed = true;
|
||||
sourceConn.emit("close");
|
||||
return openShell.apply(sourceConn, args);
|
||||
};
|
||||
const source = makeSourceSession(sourceConn, {
|
||||
hostname: "10.0.0.1",
|
||||
username: "alice",
|
||||
singleChannelSsh: true,
|
||||
});
|
||||
const originalStream = source.stream;
|
||||
sessions.set("source", source);
|
||||
|
||||
const start = registerStartHandler(bridge, sessions);
|
||||
const result = await start(
|
||||
{ sender: makeSender() },
|
||||
{
|
||||
sessionId: "copy",
|
||||
hostname: "10.0.0.1",
|
||||
username: "alice",
|
||||
port: 22,
|
||||
authMethod: "password",
|
||||
password: "secret",
|
||||
useSshAgent: false,
|
||||
verifyHostKeys: false,
|
||||
singleChannelSsh: true,
|
||||
sourceSessionId: "source",
|
||||
},
|
||||
);
|
||||
|
||||
assert.equal(result.sessionId, "copy");
|
||||
assert.equal(getClientConstructCount(), 1);
|
||||
assert.equal(sourceConn.openedShells.length, 0);
|
||||
assert.equal(sourceConn._sock.destroyed, false);
|
||||
assert.equal(originalStream.closed, false);
|
||||
assert.equal(source.connRef.endpoint.singleChannelSsh, true);
|
||||
assert.equal(source.connRef.allowIdlePark, false);
|
||||
assert.notEqual(sessions.get("copy").conn, sourceConn);
|
||||
assert.equal(sessions.get("source").conn, sourceConn);
|
||||
});
|
||||
@@ -1,6 +1,14 @@
|
||||
/* eslint-disable no-undef */
|
||||
const { executeBoundedSshCommand } = require("../boundedSshExec.cjs");
|
||||
const { listInteractiveShellPids } = require("../sshInteractiveShells.cjs");
|
||||
function extraExecUnsupportedError(session) {
|
||||
if (!session?.singleChannelSsh) return null;
|
||||
console.log("[SSH] skipped extra exec on single-channel session", session.hostname || "");
|
||||
return {
|
||||
success: false,
|
||||
error: "Remote SSH server does not support extra exec channels",
|
||||
};
|
||||
}
|
||||
function decodeLsofFileName(value) {
|
||||
if (typeof value !== 'string') return null;
|
||||
// lsof's caret form is ambiguous: a BEL byte and the literal characters
|
||||
@@ -149,6 +157,8 @@ function createSessionOpsApi(ctx) {
|
||||
async function getSessionDistroInfo(_event, payload) {
|
||||
const { sessionId } = payload || {};
|
||||
const session = sessions.get(sessionId);
|
||||
const bastionBlock = extraExecUnsupportedError(session);
|
||||
if (bastionBlock) return bastionBlock;
|
||||
if (session?.type === "et") {
|
||||
if (typeof execOnEtSession !== "function") {
|
||||
return { success: false, error: "ET command executor unavailable" };
|
||||
@@ -191,6 +201,8 @@ function createSessionOpsApi(ctx) {
|
||||
if (!session) {
|
||||
return { success: false, error: 'Session not found' };
|
||||
}
|
||||
const bastionHistoryBlock = extraExecUnsupportedError(session);
|
||||
if (bastionHistoryBlock) return bastionHistoryBlock;
|
||||
|
||||
const safeLimit =
|
||||
Number.isFinite(limit) && limit > 0 && limit <= 10000 ? Math.floor(limit) : 1000;
|
||||
@@ -318,6 +330,12 @@ function createSessionOpsApi(ctx) {
|
||||
if (!session || !session.conn) {
|
||||
return { success: false, error: 'Session not found or not connected' };
|
||||
}
|
||||
log('getSessionPwd invoked', {
|
||||
sessionId,
|
||||
singleChannelSsh: !!session.singleChannelSsh,
|
||||
});
|
||||
const bastionPwdBlock = extraExecUnsupportedError(session);
|
||||
if (bastionPwdBlock) return bastionPwdBlock;
|
||||
if (
|
||||
session.blockUntargetedCwdProbe
|
||||
&& session.cwdRecoveryPromise
|
||||
@@ -748,6 +766,7 @@ function createSessionOpsApi(ctx) {
|
||||
if (!session || !session.conn || !Array.isArray(names) || names.length === 0) {
|
||||
return null;
|
||||
}
|
||||
if (session.singleChannelSsh) return null;
|
||||
const script = `SELF=$$
|
||||
find_login_shell() {
|
||||
ps -e -o pid=,ppid=,tty=,comm= 2>/dev/null | awk -v pp="$1" -v self="$SELF" '
|
||||
@@ -806,6 +825,7 @@ function createSessionOpsApi(ctx) {
|
||||
// rm -f the given absolute remote paths (quoted; injection-safe).
|
||||
async function removeRemoteFiles(session, paths, { signal } = {}) {
|
||||
if (!session || !session.conn || !Array.isArray(paths) || paths.length === 0) return;
|
||||
if (session.singleChannelSsh) return;
|
||||
const argv = paths.map((p) => quoteShellArg(p)).join(" ");
|
||||
const commitToken = "NETCATTY_ZMODEM_COMMIT";
|
||||
const command = `exec sh -c ${quoteShellArg(
|
||||
@@ -831,6 +851,7 @@ function createSessionOpsApi(ctx) {
|
||||
// (parameterized; injection-safe). Modes are validated octal before use.
|
||||
async function restoreRemoteModes(session, entries, { signal } = {}) {
|
||||
if (!session || !session.conn || !Array.isArray(entries) || entries.length === 0) return;
|
||||
if (session.singleChannelSsh) return;
|
||||
const args = [];
|
||||
for (const e of entries) {
|
||||
if (!e || !e.path || !/^[0-7]{3,4}$/.test(String(e.mode))) continue;
|
||||
@@ -876,6 +897,8 @@ function createSessionOpsApi(ctx) {
|
||||
if (!session || !session.conn) {
|
||||
return { success: false, entries: [], error: 'Session not found' };
|
||||
}
|
||||
const bastionListBlock = extraExecUnsupportedError(session);
|
||||
if (bastionListBlock) return { ...bastionListBlock, entries: [] };
|
||||
|
||||
if (typeof dirPath !== "string" || dirPath.length === 0) {
|
||||
return { success: false, entries: [], error: 'Invalid directory path' };
|
||||
@@ -998,6 +1021,8 @@ function createSessionOpsApi(ctx) {
|
||||
if (!session) {
|
||||
return { success: false, error: 'Session not found or not connected' };
|
||||
}
|
||||
const bastionStatsBlock = extraExecUnsupportedError(session);
|
||||
if (bastionStatsBlock) return bastionStatsBlock;
|
||||
|
||||
const isEtSession = session.type === "et";
|
||||
const etUsesExecFallback = isEtSession && session.etStatsAuth?.hasJumpHost;
|
||||
|
||||
@@ -820,3 +820,38 @@ test("cwd probe couples login-shell fallback to home fallback when unset", async
|
||||
assert.match(command, /ALLOW_HOME_FALLBACK=0/);
|
||||
assert.match(command, /ALLOW_LOGIN_FALLBACK=0/);
|
||||
});
|
||||
|
||||
test("getSessionPwd skips extra exec when singleChannelSsh is set", async () => {
|
||||
let execCalls = 0;
|
||||
const api = makeApi({
|
||||
singleChannelSsh: true, remoteSshVersion: "SSH-2.0-CLOUDBILITY-4.14",
|
||||
stream: {},
|
||||
conn: {
|
||||
exec() { execCalls += 1; },
|
||||
},
|
||||
});
|
||||
|
||||
const result = await api.getSessionPwd(null, { sessionId: "session-1" });
|
||||
|
||||
assert.equal(result.success, false);
|
||||
assert.match(result.error, /extra exec channels/);
|
||||
assert.equal(execCalls, 0);
|
||||
});
|
||||
|
||||
test("listSessionDir skips extra exec when singleChannelSsh is set", async () => {
|
||||
let execCalls = 0;
|
||||
const api = makeApi({
|
||||
singleChannelSsh: true, remoteSshVersion: "SSH-2.0-CLOUDBILITY-4.14",
|
||||
stream: {},
|
||||
conn: {
|
||||
exec() { execCalls += 1; },
|
||||
},
|
||||
});
|
||||
|
||||
const result = await api.listSessionDir(null, { sessionId: "session-1", path: "." });
|
||||
|
||||
assert.equal(result.success, false);
|
||||
assert.match(result.error, /extra exec channels/);
|
||||
assert.deepEqual(result.entries, []);
|
||||
assert.equal(execCalls, 0);
|
||||
});
|
||||
@@ -1189,6 +1189,72 @@ test("getServerStats wraps probes in a remote watchdog matching the client timeo
|
||||
assert.ok(commands[1].includes('echo "DISKS:$disks"'));
|
||||
});
|
||||
|
||||
test("getServerStats skips extra exec when singleChannelSsh is set", async () => {
|
||||
let execCalls = 0;
|
||||
const sessions = new Map();
|
||||
sessions.set("sid", {
|
||||
type: "ssh",
|
||||
singleChannelSsh: true,
|
||||
conn: {
|
||||
exec() {
|
||||
execCalls += 1;
|
||||
throw new Error("must not exec on single-channel SSH");
|
||||
},
|
||||
},
|
||||
});
|
||||
|
||||
const api = makeSessionOps(sessions);
|
||||
const result = await api.getServerStats({ sender: {} }, { sessionId: "sid" });
|
||||
|
||||
assert.equal(result.success, false);
|
||||
assert.match(result.error, /extra exec channels/);
|
||||
assert.equal(execCalls, 0);
|
||||
});
|
||||
|
||||
test("readRemoteHistory skips extra exec when singleChannelSsh is set", async () => {
|
||||
let execCalls = 0;
|
||||
const sessions = new Map();
|
||||
sessions.set("sid", {
|
||||
type: "ssh",
|
||||
singleChannelSsh: true,
|
||||
conn: {
|
||||
exec() {
|
||||
execCalls += 1;
|
||||
throw new Error("must not exec on single-channel SSH");
|
||||
},
|
||||
},
|
||||
});
|
||||
|
||||
const api = makeSessionOps(sessions);
|
||||
const result = await api.readRemoteHistory({ sender: {} }, { sessionId: "sid" });
|
||||
|
||||
assert.equal(result.success, false);
|
||||
assert.match(result.error, /extra exec channels/);
|
||||
assert.equal(execCalls, 0);
|
||||
});
|
||||
|
||||
test("getSessionDistroInfo skips extra exec when singleChannelSsh is set", async () => {
|
||||
let execCalls = 0;
|
||||
const sessions = new Map();
|
||||
sessions.set("sid", {
|
||||
type: "ssh",
|
||||
singleChannelSsh: true, remoteSshVersion: "CLOUDBILITY-4.14",
|
||||
conn: {
|
||||
exec() {
|
||||
execCalls += 1;
|
||||
throw new Error("must not exec on Cloudbility");
|
||||
},
|
||||
},
|
||||
});
|
||||
|
||||
const api = makeSessionOps(sessions);
|
||||
const result = await api.getSessionDistroInfo({ sender: {} }, { sessionId: "sid" });
|
||||
|
||||
assert.equal(result.success, false);
|
||||
assert.match(result.error, /extra exec channels/);
|
||||
assert.equal(execCalls, 0);
|
||||
});
|
||||
|
||||
test("getSessionDistroInfo wraps the os-release probe in a remote watchdog", async () => {
|
||||
const commands = [];
|
||||
const sessions = new Map();
|
||||
@@ -1302,3 +1368,17 @@ for (const keepaliveInterval of [0, 10000]) {
|
||||
assert.equal(result.stats.latencyMs, keepaliveInterval === 0 ? null : 2);
|
||||
});
|
||||
}
|
||||
|
||||
test("zmodem remote exec helpers do nothing on single-channel SSH", async () => {
|
||||
let execCalls = 0;
|
||||
const session = {
|
||||
singleChannelSsh: true,
|
||||
conn: { exec() { execCalls += 1; } },
|
||||
};
|
||||
const api = makeSessionOps(new Map([["s1", session]]));
|
||||
assert.equal(await api.probeReceiveConflicts(session, ["file.txt"]), null);
|
||||
await api.removeRemoteFiles(session, ["/tmp/file.txt"]);
|
||||
await api.restoreRemoteModes(session, [{ path: "/tmp/file.txt", mode: "644" }]);
|
||||
assert.equal(execCalls, 0);
|
||||
});
|
||||
|
||||
@@ -16,6 +16,7 @@ const {
|
||||
const { runWhenProxyConnectionReady } = require("../proxyUtils.cjs");
|
||||
const { getAttachHomeWebContentsId } = require("../terminalAttachRestore.cjs");
|
||||
const { openBoundedSshShellCallback } = require("../boundedSshChannelOpen.cjs");
|
||||
const { optionsForPeerSingleChannel } = require("../../../domain/singleChannelSshBanner.shared.cjs");
|
||||
const { listInteractiveShellPids: listInteractiveShellPidsShared } = require("../sshInteractiveShells.cjs");
|
||||
const {
|
||||
shouldConfirmReusedShellLiveness,
|
||||
@@ -32,6 +33,20 @@ const SSH_AUTH_READY_TIMEOUT_MS = 120000;
|
||||
const MAX_SSH_CONNECTION_TIMEOUT_MS = 3600000;
|
||||
const COPY_TAB_RATE_LIMIT_RETRY_TIMEOUT_MS = 30000;
|
||||
|
||||
function buildInteractiveShellOptions(options) {
|
||||
const shellOptions = {};
|
||||
// Hosts with singleChannelSsh (bastion/PAM) may drop the transport on
|
||||
// CHANNEL_REQUEST env. Skip COLORTERM / caller env there.
|
||||
if (!options.singleChannelSsh) {
|
||||
shellOptions.env = {
|
||||
COLORTERM: "truecolor",
|
||||
...(options.env || {}),
|
||||
};
|
||||
}
|
||||
return shellOptions;
|
||||
}
|
||||
|
||||
|
||||
/**
|
||||
* Fan out netcatty:exit to the primary contents plus any attach-home owner
|
||||
* (AI observe popup rebind) so neither side is left stale.
|
||||
@@ -302,6 +317,7 @@ function createStartSessionApi(ctx) {
|
||||
chainConnections,
|
||||
isReused,
|
||||
}) {
|
||||
const sessionOptions = optionsForPeerSingleChannel(options, conn);
|
||||
const session = {
|
||||
conn,
|
||||
stream,
|
||||
@@ -332,12 +348,13 @@ function createStartSessionApi(ctx) {
|
||||
// additional exec channels. See domain/host.ts
|
||||
// `detectVendorFromSshVersion`.
|
||||
remoteSshVersion: (conn && typeof conn._remoteVer === 'string') ? conn._remoteVer : '',
|
||||
singleChannelSsh: sessionOptions.singleChannelSsh === true,
|
||||
// The actual SSH target this connection authenticated to. Used to make
|
||||
// sure a "Copy Tab" reuse opens its channel on a connection going to the
|
||||
// *same* host — a saved host edited after the source connected must not
|
||||
// silently run commands on the old machine (issue #1204 review).
|
||||
_reuseEndpoint: normalizeEndpoint(buildConnectionReuseEndpoint(options, {
|
||||
agentForwarding: options._actualAgentForwarding ?? options.agentForwarding,
|
||||
_reuseEndpoint: normalizeEndpoint(buildConnectionReuseEndpoint(sessionOptions, {
|
||||
agentForwarding: sessionOptions._actualAgentForwarding ?? sessionOptions.agentForwarding,
|
||||
})),
|
||||
// Only live SFTP borrowers may use the original password-less profile.
|
||||
// Do not alias the pool: new terminals must still authenticate normally.
|
||||
@@ -747,12 +764,7 @@ function createStartSessionApi(ctx) {
|
||||
|
||||
sendProgress('shell');
|
||||
|
||||
const shellOptions = {
|
||||
env: {
|
||||
COLORTERM: "truecolor",
|
||||
...(options.env || {}),
|
||||
},
|
||||
};
|
||||
const shellOptions = buildInteractiveShellOptions(options);
|
||||
|
||||
// Pin the shared connection *before* issuing the async shell request.
|
||||
// Otherwise, if the source tab is closed while conn.shell() is pending,
|
||||
@@ -1245,6 +1257,7 @@ function createStartSessionApi(ctx) {
|
||||
const canAttemptSourceReuse = Boolean(
|
||||
allowTransportReuse
|
||||
&& options.sourceSessionId
|
||||
&& !options.singleChannelSsh
|
||||
&& !options.x11Forwarding
|
||||
&& (!sourceReuseState || sourceReuseState.attempted !== true),
|
||||
);
|
||||
@@ -2242,12 +2255,14 @@ function createStartSessionApi(ctx) {
|
||||
});
|
||||
}
|
||||
|
||||
const shellOptions = {
|
||||
env: {
|
||||
COLORTERM: "truecolor",
|
||||
...(options.env || {}),
|
||||
},
|
||||
};
|
||||
const shellSessionOptions = optionsForPeerSingleChannel(options, conn);
|
||||
const shellOptions = buildInteractiveShellOptions(shellSessionOptions);
|
||||
if (shellSessionOptions.singleChannelSsh) {
|
||||
log("skipping shell env for single-channel SSH", {
|
||||
sessionId,
|
||||
hostname: options.hostname,
|
||||
});
|
||||
}
|
||||
|
||||
if (options.x11Forwarding) {
|
||||
shellOptions.x11 = {
|
||||
@@ -2294,7 +2309,7 @@ function createStartSessionApi(ctx) {
|
||||
conn,
|
||||
stream,
|
||||
options: {
|
||||
...options,
|
||||
...shellSessionOptions,
|
||||
_actualAgentForwarding: Boolean(connectOpts.agentForward),
|
||||
},
|
||||
sessionId,
|
||||
|
||||
@@ -97,6 +97,11 @@ function endpointAllowsIdlePark(endpointOrKey, remoteSshVersion) {
|
||||
|
||||
function applyIdleParkPolicy(transport, remoteSshVersion) {
|
||||
if (!transport) return false;
|
||||
if (transport.endpoint?.singleChannelSsh) {
|
||||
transport.allowIdlePark = false;
|
||||
if (transport.endpointKey) noIdleParkEndpointKeys.add(transport.endpointKey);
|
||||
return false;
|
||||
}
|
||||
const remoteVer = remoteSshVersion
|
||||
|| (typeof transport.conn?._remoteVer === "string" ? transport.conn._remoteVer : "");
|
||||
const allowed = endpointAllowsIdlePark(transport.endpointKey || transport.endpoint, remoteVer);
|
||||
@@ -552,6 +557,7 @@ function buildConnectionReuseEndpoint(options = {}, overrides = {}) {
|
||||
legacyAlgorithms: options.legacyAlgorithms,
|
||||
skipEcdsaHostKey: options.skipEcdsaHostKey,
|
||||
algorithmOverrides: options.algorithmOverrides,
|
||||
singleChannelSsh: Boolean(options.singleChannelSsh),
|
||||
};
|
||||
}
|
||||
|
||||
@@ -578,6 +584,7 @@ function normalizeEndpoint(endpoint) {
|
||||
username: endpoint.username || "root",
|
||||
protocol: endpoint.protocol || "ssh",
|
||||
sftpSudo: Boolean(endpoint.sftpSudo),
|
||||
singleChannelSsh: Boolean(endpoint.singleChannelSsh),
|
||||
jumpFingerprint: endpoint.jumpFingerprint
|
||||
? String(endpoint.jumpFingerprint)
|
||||
: fingerprintJumpHosts(endpoint.jumpHosts),
|
||||
@@ -679,6 +686,9 @@ function endpointAllowsReuse(requested, existing, kind = "channel") {
|
||||
const req = normalizeEndpoint(requested);
|
||||
const have = normalizeEndpoint(existing);
|
||||
if (!req || !have) return false;
|
||||
// A bastion that allows one session channel must not lend that transport
|
||||
// to another shell, SFTP, or port forward.
|
||||
if (req.singleChannelSsh || have.singleChannelSsh) return false;
|
||||
if (kind === "shell") {
|
||||
return req.agentForwarding === have.agentForwarding
|
||||
&& (!req.agentForwarding
|
||||
@@ -1344,6 +1354,7 @@ function createConnectionRef(session, conn, chainConnections) {
|
||||
keepaliveIntervalMs: session._reuseEndpoint.keepaliveIntervalMs,
|
||||
keepaliveCountMax: session._reuseEndpoint.keepaliveCountMax,
|
||||
authFingerprint: session._reuseEndpoint.authFingerprint,
|
||||
singleChannelSsh: session._reuseEndpoint.singleChannelSsh,
|
||||
}
|
||||
: null;
|
||||
|
||||
@@ -1455,6 +1466,13 @@ function findReusableSession(sessions, sourceSessionId, requestedTarget) {
|
||||
// transport live; refuse so start() dials fresh instead of opening a
|
||||
// channel that will exit 0 immediately.
|
||||
if (source.connRef.allowShellReuse === false) return null;
|
||||
if (
|
||||
source.singleChannelSsh === true
|
||||
|| source._reuseEndpoint?.singleChannelSsh === true
|
||||
|| source.connRef?.endpoint?.singleChannelSsh === true
|
||||
) {
|
||||
return null;
|
||||
}
|
||||
// ssh2 Client exposes no public "is connected" flag; rely on the descriptor
|
||||
// still being attached (it is nulled out on teardown) plus a non-destroyed
|
||||
// underlying socket when ssh2 exposes one.
|
||||
|
||||
@@ -1422,3 +1422,39 @@ test("transferConnectionRef rebinds a lease without changing count", () => {
|
||||
fireIdleTimers(timers);
|
||||
assert.equal(conn.ended, 1);
|
||||
});
|
||||
|
||||
test("findReusableSession refuses a single-channel source", () => {
|
||||
const sessions = new Map();
|
||||
const source = {
|
||||
singleChannelSsh: true,
|
||||
conn: { _sock: { destroyed: false } },
|
||||
stream: {},
|
||||
connRef: { count: 1, state: "live", endpoint: { singleChannelSsh: true } },
|
||||
_reuseEndpoint: { hostname: "10.0.0.1", port: 22, username: "alice", singleChannelSsh: true },
|
||||
};
|
||||
sessions.set("src", source);
|
||||
assert.equal(findReusableSession(sessions, "src"), null);
|
||||
assert.equal(
|
||||
findReusableSession(sessions, "src", { hostname: "10.0.0.1", port: 22, username: "alice", singleChannelSsh: true }),
|
||||
null,
|
||||
);
|
||||
});
|
||||
|
||||
test("createConnectionRef keeps singleChannelSsh and does not idle-park the terminal", () => {
|
||||
const conn = makeConn();
|
||||
const owner = {
|
||||
singleChannelSsh: true,
|
||||
_reuseEndpoint: {
|
||||
hostname: "bastion.example",
|
||||
port: 22,
|
||||
username: "root",
|
||||
singleChannelSsh: true,
|
||||
},
|
||||
};
|
||||
const transport = createConnectionRef(owner, conn, []);
|
||||
assert.equal(transport.endpoint.singleChannelSsh, true);
|
||||
assert.equal(transport.allowIdlePark, false);
|
||||
assert.equal(releaseConnectionRef(owner), true);
|
||||
assert.equal(conn.ended, 1);
|
||||
assert.equal(transport.state, "dead");
|
||||
});
|
||||
@@ -206,6 +206,13 @@ function createExecOnSessionApi(ctx) {
|
||||
execQueues.delete(sessionId);
|
||||
return { success: false, error: "Session not found" };
|
||||
}
|
||||
if (session.singleChannelSsh) {
|
||||
return {
|
||||
success: false,
|
||||
error: "Remote SSH server does not support extra exec channels",
|
||||
code: "ERR_SINGLE_CHANNEL_BASTION",
|
||||
};
|
||||
}
|
||||
|
||||
if (session.protocol === "local" || session.type === "local") {
|
||||
return execOnLocalMachine(command, timeoutMs, execOptions);
|
||||
|
||||
@@ -120,3 +120,25 @@ test("execOnSession settles and releases listeners on SSH stream errors", async
|
||||
assert.equal(stream.listenerCount("data"), 0);
|
||||
assert.equal(stream.stderr.listenerCount("data"), 0);
|
||||
});
|
||||
|
||||
test("execOnSession refuses extra channels when singleChannelSsh is set", async () => {
|
||||
let execCalls = 0;
|
||||
const execApi = createExecOnSessionApi({
|
||||
sessions: {
|
||||
get: () => ({
|
||||
conn: {
|
||||
_remoteVer: "CLOUDBILITY-4.14",
|
||||
exec() { execCalls += 1; },
|
||||
},
|
||||
type: "ssh",
|
||||
singleChannelSsh: true, remoteSshVersion: "CLOUDBILITY-4.14",
|
||||
}),
|
||||
},
|
||||
});
|
||||
|
||||
const result = await execApi.execOnSession(null, "s1", "ps", 1000);
|
||||
|
||||
assert.equal(result.success, false);
|
||||
assert.equal(result.code, "ERR_SINGLE_CHANNEL_BASTION");
|
||||
assert.equal(execCalls, 0);
|
||||
});
|
||||
@@ -834,6 +834,8 @@ async function hashRemotePrefixViaSshCommand(client, remotePath, bytes, options
|
||||
// For a root-only source, head can fail while sha256sum/openssl still emit the
|
||||
// empty-input digest and exit 0 — skip the command path and use elevated SFTP.
|
||||
if (client?.__netcattySudoMode) return null;
|
||||
// Prefix hashing through exec would be a second channel on this login.
|
||||
if (client?.__netcattySingleChannelSsh || client?.client?.__netcattySingleChannelSsh) return null;
|
||||
const sshClient = client?.client;
|
||||
if (!sshClient || typeof sshClient.exec !== "function") return null;
|
||||
|
||||
@@ -895,7 +897,7 @@ async function hashRemoteFile(client, sftpId, filePath, encoding, options = {})
|
||||
// The server-side helper has no portable byte progress and its command stream
|
||||
// is not consistently abortable across SSH backends. Visible/cancellable
|
||||
// verification therefore uses the SFTP stream path below.
|
||||
if (!options.signal && !options.onProgress && sshClient && typeof sshClient.exec === "function") {
|
||||
if (!options.signal && !options.onProgress && sshClient && typeof sshClient.exec === "function" && !client.__netcattySingleChannelSsh && !sshClient.__netcattySingleChannelSsh) {
|
||||
const escapedPath = String(filePath).replace(/'/g, "'\\''");
|
||||
const digest = await executeBoundedSshCommand(
|
||||
sshClient,
|
||||
@@ -1263,7 +1265,7 @@ async function preserveTransferredDestinationMtime(transfer, options = {}) {
|
||||
if (isScpModeClient(client)) {
|
||||
// SCP has no SETSTAT; best-effort touch via the SSH session.
|
||||
const sshClient = client.client;
|
||||
if (!sshClient || typeof sshClient.exec !== "function") return;
|
||||
if (!sshClient || typeof sshClient.exec !== "function" || client.__netcattySingleChannelSsh || sshClient.__netcattySingleChannelSsh) return;
|
||||
const escaped = String(transfer.targetPath).replace(/'/g, "'\\''");
|
||||
const command = `touch -d @${mtimeSec} -- '${escaped}' 2>/dev/null || `
|
||||
+ `touch -t "$(date -u -r ${mtimeSec} +%Y%m%d%H%M.%S 2>/dev/null `
|
||||
@@ -1794,6 +1796,11 @@ function execSshCommandCancellable(sshClient, command, transfer) {
|
||||
}
|
||||
|
||||
async function openIsolatedSftpChannel(client, signal = null) {
|
||||
// The dedicated one-channel login already holds its only session channel.
|
||||
// Opening another SFTP subsystem drops that login, so stay on the browse channel.
|
||||
if (client?.__netcattySingleChannelSsh || client?.client?.__netcattySingleChannelSsh) {
|
||||
return null;
|
||||
}
|
||||
const sshClient = client?.client;
|
||||
return openBoundedSftpChannel(sshClient, { signal });
|
||||
}
|
||||
@@ -6261,7 +6268,8 @@ async function startTransferNow(event, payload, onProgress) {
|
||||
&& srcClient
|
||||
&& !cpUnavailableSet.has(srcClient)) {
|
||||
const sshClient = srcClient?.client;
|
||||
if (sshClient && typeof sshClient.exec === 'function') {
|
||||
const singleChannelCopy = srcClient.__netcattySingleChannelSsh || sshClient?.__netcattySingleChannelSsh;
|
||||
if (sshClient && typeof sshClient.exec === 'function' && !singleChannelCopy) {
|
||||
try {
|
||||
const dir = path.dirname(targetPath).replace(/\\/g, '/');
|
||||
try {
|
||||
@@ -7250,6 +7258,11 @@ async function sameHostCopyDirectory(event, payload) {
|
||||
|
||||
const client = sftpClients.get(sftpId);
|
||||
if (!client) return { success: false };
|
||||
// Remote cp needs an exec channel. That would drop a one-channel SFTP
|
||||
// login, so the caller copies the directory one file at a time.
|
||||
if (client.__netcattySingleChannelSsh || client.client?.__netcattySingleChannelSsh) {
|
||||
return { success: false };
|
||||
}
|
||||
if (cpUnavailableSet.has(client)) return { success: false };
|
||||
|
||||
const sshClient = client.client;
|
||||
|
||||
@@ -381,3 +381,23 @@ for (const action of ["pause", "resume"]) {
|
||||
assert.equal((await older).supersededBy, action === "pause" ? "resume" : "pause");
|
||||
});
|
||||
}
|
||||
|
||||
test("same-host directory copy skips exec on a single-channel SFTP login", async () => {
|
||||
const bridge = require("./transferBridge.cjs");
|
||||
const sftpClients = new Map();
|
||||
bridge.init({ sftpClients });
|
||||
let execCalls = 0;
|
||||
sftpClients.set("single-sftp", {
|
||||
__netcattySingleChannelSsh: true,
|
||||
client: { exec() { execCalls += 1; throw new Error("must not exec"); } },
|
||||
});
|
||||
const result = await bridge.sameHostCopyDirectory({ sender: { send() {} } }, {
|
||||
sftpId: "single-sftp",
|
||||
sourcePath: "/source",
|
||||
targetPath: "/target",
|
||||
encoding: "utf-8",
|
||||
});
|
||||
assert.equal(result.success, false);
|
||||
assert.equal(execCalls, 0);
|
||||
});
|
||||
|
||||
@@ -13008,3 +13008,165 @@ test(`resumable local transfer retries a failed prepared-file timestamp: ${failu
|
||||
});
|
||||
|
||||
}
|
||||
|
||||
test("single-channel SFTP upload does not open a second subsystem", async (t) => {
|
||||
const tempDir = await fs.promises.mkdtemp(path.join(os.tmpdir(), "netcatty-single-channel-upload-"));
|
||||
t.after(async () => fs.promises.rm(tempDir, { recursive: true, force: true }));
|
||||
const localPath = path.join(tempDir, "fresh.bin");
|
||||
const payload = Buffer.from("single-channel");
|
||||
await fs.promises.writeFile(localPath, payload);
|
||||
const targetPath = "/tmp/fresh.bin";
|
||||
const remoteFiles = new Map();
|
||||
let secondChannelOpens = 0;
|
||||
|
||||
const shared = createFastSftp({
|
||||
lstat(remotePath, callback) {
|
||||
const key = String(remotePath);
|
||||
if (!remoteFiles.has(key)) {
|
||||
const error = new Error("ENOENT");
|
||||
error.code = 2;
|
||||
callback(error);
|
||||
return;
|
||||
}
|
||||
callback(null, {
|
||||
size: remoteFiles.get(key).length,
|
||||
mode: 0o100644,
|
||||
isDirectory: () => false,
|
||||
isSymbolicLink: () => false,
|
||||
});
|
||||
},
|
||||
open(remotePath, _flags, callback) {
|
||||
const key = String(remotePath);
|
||||
remoteFiles.set(key, Buffer.alloc(payload.length));
|
||||
callback(null, Buffer.from(key));
|
||||
},
|
||||
write(handle, buffer, offset, length, position, callback) {
|
||||
const key = handle.toString();
|
||||
buffer.copy(remoteFiles.get(key), position, offset, offset + length);
|
||||
setImmediate(() => callback(null));
|
||||
},
|
||||
close(_handle, callback) {
|
||||
callback(null);
|
||||
},
|
||||
});
|
||||
const client = {
|
||||
__netcattySingleChannelSsh: true,
|
||||
sftp: shared,
|
||||
stat: async (remotePath) => {
|
||||
const key = String(remotePath);
|
||||
if (!remoteFiles.has(key)) {
|
||||
const error = new Error("ENOENT");
|
||||
error.code = 2;
|
||||
throw error;
|
||||
}
|
||||
return { size: remoteFiles.get(key).length, isDirectory: false };
|
||||
},
|
||||
delete: async (remotePath) => { remoteFiles.delete(String(remotePath)); },
|
||||
async rename(fromPath, toPath) {
|
||||
remoteFiles.set(String(toPath), remoteFiles.get(String(fromPath)));
|
||||
remoteFiles.delete(String(fromPath));
|
||||
},
|
||||
client: {
|
||||
__netcattySingleChannelSsh: true,
|
||||
sftp(callback) {
|
||||
secondChannelOpens += 1;
|
||||
callback(new Error("second SFTP channel must not open"));
|
||||
},
|
||||
},
|
||||
};
|
||||
transferBridge.init({ sftpClients: new Map([["target", client]]) });
|
||||
|
||||
const result = await transferBridge.startTransfer({ sender: createSender() }, {
|
||||
transferId: "single-channel-upload",
|
||||
sourcePath: localPath,
|
||||
targetPath,
|
||||
sourceType: "local",
|
||||
targetType: "sftp",
|
||||
targetSftpId: "target",
|
||||
totalBytes: payload.length,
|
||||
resumable: false,
|
||||
});
|
||||
|
||||
assert.equal(result.error, undefined, result.error);
|
||||
assert.equal(secondChannelOpens, 0);
|
||||
assert.deepEqual(remoteFiles.get(targetPath), payload);
|
||||
});
|
||||
|
||||
test("single-channel SFTP download does not open a second subsystem", async (t) => {
|
||||
const tempDir = await fs.promises.mkdtemp(path.join(os.tmpdir(), "netcatty-single-channel-download-"));
|
||||
t.after(async () => fs.promises.rm(tempDir, { recursive: true, force: true }));
|
||||
const payload = Buffer.from("downloaded-on-one-channel");
|
||||
const targetPath = path.join(tempDir, "download.bin");
|
||||
let secondChannelOpens = 0;
|
||||
const { sftp } = createPipelinedDownloadSftp(payload);
|
||||
const client = {
|
||||
__netcattySingleChannelSsh: true,
|
||||
sftp,
|
||||
stat: async () => ({
|
||||
size: payload.length,
|
||||
mtimeMs: 1_000,
|
||||
ctimeMs: 1_000,
|
||||
mtime: 1,
|
||||
ctime: 1,
|
||||
}),
|
||||
client: {
|
||||
__netcattySingleChannelSsh: true,
|
||||
sftp(callback) {
|
||||
secondChannelOpens += 1;
|
||||
callback(new Error("second SFTP channel must not open"));
|
||||
},
|
||||
},
|
||||
};
|
||||
transferBridge.init({ sftpClients: new Map([["source", client]]) });
|
||||
|
||||
const result = await transferBridge.startTransfer({ sender: createSender() }, {
|
||||
transferId: "single-channel-download",
|
||||
sourcePath: "/home/user/download.bin",
|
||||
targetPath,
|
||||
sourceType: "sftp",
|
||||
targetType: "local",
|
||||
sourceSftpId: "source",
|
||||
totalBytes: payload.length,
|
||||
resumable: true,
|
||||
});
|
||||
|
||||
assert.equal(result.error, undefined, result.error);
|
||||
assert.equal(secondChannelOpens, 0);
|
||||
assert.deepEqual(await fs.promises.readFile(targetPath), payload);
|
||||
});
|
||||
|
||||
test("single-channel pipelined upload stays on the existing SFTP channel", async (t) => {
|
||||
const tempDir = await fs.promises.mkdtemp(path.join(os.tmpdir(), "netcatty-single-channel-fastput-"));
|
||||
t.after(async () => fs.promises.rm(tempDir, { recursive: true, force: true }));
|
||||
const localPath = path.join(tempDir, "payload.bin");
|
||||
await fs.promises.writeFile(localPath, Buffer.from("fast"));
|
||||
let secondChannelOpens = 0;
|
||||
let sharedPuts = 0;
|
||||
const shared = createFastSftp({
|
||||
fastPut(_localPath, _remotePath, _options, callback) {
|
||||
sharedPuts += 1;
|
||||
callback(null);
|
||||
},
|
||||
});
|
||||
const client = {
|
||||
__netcattySingleChannelSsh: true,
|
||||
sftp: shared,
|
||||
fastPut() {
|
||||
throw new Error("wrapper fastPut must not bypass the channel guard");
|
||||
},
|
||||
client: {
|
||||
__netcattySingleChannelSsh: true,
|
||||
sftp(callback) {
|
||||
secondChannelOpens += 1;
|
||||
callback(new Error("second SFTP channel must not open"));
|
||||
},
|
||||
},
|
||||
};
|
||||
|
||||
await sftpBridge.pipelinedUploadLocalFile(client, localPath, "/tmp/out.bin", {
|
||||
signal: new AbortController().signal,
|
||||
});
|
||||
|
||||
assert.equal(secondChannelOpens, 0);
|
||||
assert.equal(sharedPuts, 1);
|
||||
});
|
||||
@@ -11,7 +11,7 @@ const {
|
||||
const { getFreshIdlePrompt, formatSyntheticEcho } = require("../bridges/ai/shellUtils.cjs");
|
||||
const {
|
||||
ensureSessionShellKind,
|
||||
remoteDisallowsExecChannelProbe, ensureSessionShellKindForExec,
|
||||
remoteDisallowsExecChannelProbe, sessionDisallowsExtraSshChannel, ensureSessionShellKindForExec,
|
||||
} = require("../bridges/ai/sessionShellKind.cjs");
|
||||
const {
|
||||
checkBlocklistForShell,
|
||||
@@ -294,6 +294,7 @@ function createWorkerAiExecHandler({
|
||||
loginShellHint: session._loginShellKind,
|
||||
probeLiveShell: true,
|
||||
bastionKeystrokes: remoteDisallowsExecChannelProbe(session.remoteSshVersion),
|
||||
skipPendingInputClear: session.singleChannelSsh === true,
|
||||
onProbeAborted: (marker) => {
|
||||
event?.sender?.send?.("netcatty:data", {
|
||||
sessionId,
|
||||
@@ -325,7 +326,7 @@ function createWorkerAiExecHandler({
|
||||
}
|
||||
|
||||
const sshClient = session.sshClient || session.conn;
|
||||
if (sshClient && typeof sshClient.exec === "function") {
|
||||
if (!session.singleChannelSsh && sshClient && typeof sshClient.exec === "function") {
|
||||
const probed = await ensureSessionShellKindForExec(session, {
|
||||
trackForCancellation: activePtyExecs,
|
||||
chatSessionId,
|
||||
@@ -505,6 +506,7 @@ function createWorkerAiJobStartHandler({
|
||||
loginShellHint: session._loginShellKind,
|
||||
probeLiveShell: true,
|
||||
bastionKeystrokes: remoteDisallowsExecChannelProbe(session.remoteSshVersion),
|
||||
skipPendingInputClear: session.singleChannelSsh === true,
|
||||
onProbeAborted: (marker) => {
|
||||
event?.sender?.send?.("netcatty:data", {
|
||||
sessionId,
|
||||
|
||||
@@ -563,3 +563,32 @@ test("worker exec on an unclassified posix session still blocks command substitu
|
||||
"blocked commands must not reach the PTY",
|
||||
);
|
||||
});
|
||||
|
||||
test("single-channel worker exec does not open an exec channel or send line-kill keys", async () => {
|
||||
let execCalls = 0;
|
||||
const pty = new FakePty();
|
||||
const sessions = new Map([["ssh-single", {
|
||||
protocol: "ssh",
|
||||
singleChannelSsh: true,
|
||||
remoteSshVersion: "CLOUDBILITY-4.14",
|
||||
stream: pty,
|
||||
shellKind: "",
|
||||
conn: { exec() { execCalls += 1; throw new Error("exec channel"); } },
|
||||
}]]);
|
||||
const ipcMain = createFakeIpcMain();
|
||||
registerWorkerAiExecHandlers(ipcMain, { sessions });
|
||||
const execution = ipcMain.handlers.get("netcatty:ai:exec")(createFakeEvent(), {
|
||||
sessionId: "ssh-single",
|
||||
command: "uname -a",
|
||||
chatSessionId: "chat-single",
|
||||
commandTimeoutMs: 300,
|
||||
});
|
||||
await new Promise((resolve) => setTimeout(resolve, 80));
|
||||
assert.equal(execCalls, 0);
|
||||
const input = pty.writes.join("");
|
||||
assert.equal(input.includes("\u0015"), false);
|
||||
assert.equal(input.includes("\u000b"), false);
|
||||
assert.ok(input.length > 0);
|
||||
await execution.catch(() => {});
|
||||
});
|
||||
|
||||
@@ -19,6 +19,11 @@ class MockSshClient extends EventEmitter {
|
||||
});
|
||||
}
|
||||
|
||||
forwardIn(_bindAddress, _bindPort, callback) {
|
||||
this.forwardInCalls = (this.forwardInCalls || 0) + 1;
|
||||
setImmediate(() => callback(null));
|
||||
}
|
||||
|
||||
end() {
|
||||
if (this._sock.destroyed) return;
|
||||
this._sock.destroyed = true;
|
||||
@@ -205,3 +210,45 @@ test("worker port forwarding with reuseTransport false stays physically independ
|
||||
|
||||
assert.equal(physicalDialCount, 2);
|
||||
});
|
||||
|
||||
test("single-channel local and remote forwards leave the terminal connection alone", async (t) => {
|
||||
physicalDialCount = 0;
|
||||
resetSshTransportRegistryForTests({ defaultIdleTtlMs: 0 });
|
||||
const { parentPort, siblingHolders } = createHarness();
|
||||
const terminalConn = new MockSshClient();
|
||||
const terminalHolder = { id: "terminal-single" };
|
||||
const transport = createTransport({
|
||||
conn: terminalConn,
|
||||
endpoint: { ...endpoint, singleChannelSsh: true },
|
||||
});
|
||||
borrowTransport(transport, {
|
||||
kind: LEASE_KINDS.shell,
|
||||
holder: terminalHolder,
|
||||
leaseId: "shell:terminal-single",
|
||||
});
|
||||
siblingHolders.push(terminalHolder);
|
||||
t.after(async () => {
|
||||
await parentPort.request("netcatty:portforward:stop", { tunnelId: "pf-single-local" }).catch(() => {});
|
||||
await parentPort.request("netcatty:portforward:stop", { tunnelId: "pf-single-remote" }).catch(() => {});
|
||||
await cleanupHarness(parentPort, siblingHolders);
|
||||
});
|
||||
|
||||
const local = await parentPort.request(
|
||||
"netcatty:portforward:start",
|
||||
portForwardPayload("pf-single-local", { type: "local", singleChannelSsh: true, localPort: 0 }),
|
||||
);
|
||||
assert.equal(local.success, true, local.error);
|
||||
const remote = await parentPort.request(
|
||||
"netcatty:portforward:start",
|
||||
portForwardPayload("pf-single-remote", {
|
||||
type: "remote",
|
||||
singleChannelSsh: true,
|
||||
localPort: 19022,
|
||||
bindAddress: "127.0.0.1",
|
||||
}),
|
||||
);
|
||||
assert.equal(remote.success, true, remote.error);
|
||||
assert.equal(physicalDialCount, 2);
|
||||
assert.equal(terminalConn.forwardInCalls || 0, 0);
|
||||
assert.equal(terminalConn.listenerCount("tcp connection"), 0);
|
||||
});
|
||||
Vendored
+2
@@ -175,6 +175,8 @@ declare global {
|
||||
* Default true (normal terminal, browse, and MFA-skip reuse).
|
||||
*/
|
||||
reuseTransport?: boolean;
|
||||
/** Bastion/PAM hosts: do not open extra exec/SFTP channels on the terminal transport. */
|
||||
singleChannelSsh?: boolean;
|
||||
/** Original unsaved-password profile; main retains only its digest for live SFTP borrowing. */
|
||||
sftpReuseOptions?: NetcattySSHOptions;
|
||||
}
|
||||
|
||||
Loaded 100 of 104 files, more files were not shown because too many files have changed in this diff.
Show more
Reference in new issue
Block a user