Compare commits

...
Author SHA1 Message Date
Cursor Agentand陈大猫 4a44d62511 fix: open SFTP on its own login for network-device SSH
Huawei VRP and Ruijie drop the terminal session when an SFTP channel
is opened on the same SSH connection. Treat recognized vendor banners
and Network Device Mode like the existing single-channel path so the
file panel dials a separate login.

Co-authored-by: 陈大猫 <binaricat@users.noreply.github.com>
2026-10-19 15:20:36 +00:00
9 changed files with 239 additions and 17 deletions

No files matched your search

+21 -7
View File
@@ -22,7 +22,7 @@ import React, {
} from "react";
import { SftpSidePanelDeferredMount } from "./SftpSidePanelDeferredMount";
import { TERMINAL_SIDE_PANEL_INNER_HEADER_CLASS } from "./terminalLayer/terminalSidePanelChrome";
import { formatHostPort } from "../domain/host";
import { classifyDistroId, formatHostPort, sftpRequiresDedicatedConnection } from "../domain/host";
import { useI18n } from "../application/i18n/I18nProvider";
import { useSftpState } from "../application/state/useSftpState";
import {
@@ -76,8 +76,6 @@ import {
resolveLocateSftpPathInTerminalAction,
resolveLocateSftpPathSessionId,
} from "../domain/sftpLocatePathInTerminal";
import { classifyDistroId } from "../domain/host";
import { remoteSoftwareRequiresSingleChannel } from "../domain/singleChannelSshBanner.shared.mjs";
import { useTerminalBackend } from "../application/state/useTerminalBackend";
import { isTerminalSensitiveInputActive } from "./terminal/runtime/terminalSensitiveInputRegistry";
import { isTerminalReadyForCommandInjection } from "./terminal/runtime/terminalCommandInjectionReadyRegistry";
@@ -588,9 +586,19 @@ const SftpSidePanelInner: React.FC<SftpSidePanelProps> = ({
setSftpBannerDecision(null);
return;
}
const deviceType = activeHost?.deviceType;
// Network Device Mode is known before the banner. Do not wait to borrow
// the terminal transport; that extra channel drops Huawei/Ruijie sessions.
if (sftpRequiresDedicatedConnection({ deviceType })) {
setSftpBannerDecision({ sessionId: activeSessionId, singleChannel: true });
return;
}
const readRemoteInfo = terminalBackend.getSessionRemoteInfo;
if (!readRemoteInfo) {
setSftpBannerDecision({ sessionId: activeSessionId, singleChannel: false });
setSftpBannerDecision({
sessionId: activeSessionId,
singleChannel: sftpRequiresDedicatedConnection({ deviceType }),
});
return;
}
let cancelled = false;
@@ -598,17 +606,23 @@ const SftpSidePanelInner: React.FC<SftpSidePanelProps> = ({
if (cancelled) return;
setSftpBannerDecision({
sessionId: activeSessionId,
singleChannel: remoteSoftwareRequiresSingleChannel(info?.remoteSshVersion),
singleChannel: sftpRequiresDedicatedConnection({
remoteSshVersion: info?.remoteSshVersion,
deviceType,
}),
});
}).catch(() => {
if (!cancelled) {
setSftpBannerDecision({ sessionId: activeSessionId, singleChannel: false });
setSftpBannerDecision({
sessionId: activeSessionId,
singleChannel: sftpRequiresDedicatedConnection({ deviceType }),
});
}
});
return () => {
cancelled = true;
};
}, [activeSessionId, activeSessionStatusForBanner, terminalBackend]);
}, [activeHost?.deviceType, activeSessionId, activeSessionStatusForBanner, terminalBackend]);
useEffect(() => {
pruneSftpSidePanelState(
@@ -57,4 +57,9 @@ test("single-channel SFTP opens a dedicated connection instead of the terminal c
sidePanelSource,
/const reuseTerminalTransport = !dedicatedSftpLogin &&/,
);
assert.match(sidePanelSource, /sftpRequiresDedicatedConnection\(\{\s*deviceType\s*\}\)/);
assert.match(
sidePanelSource,
/singleChannel: sftpRequiresDedicatedConnection\(\{\s*remoteSshVersion: info\?\.remoteSshVersion,\s*deviceType,\s*\}\)/,
);
});
+53
View File
@@ -19,6 +19,7 @@ import {
hostRestrictsExtraSshChannels,
shouldProbeSessionCwd,
shouldSuggestNetworkDeviceMode,
sftpRequiresDedicatedConnection,
upsertHostById,
} from "./host.ts";
@@ -662,6 +663,58 @@ test("hostRestrictsExtraSshChannels follows network-device mode only", () => {
assert.equal(hostRestrictsExtraSshChannels(undefined), false);
});
test("sftpRequiresDedicatedConnection uses a fresh login for Huawei and Ruijie banners", () => {
assert.equal(sftpRequiresDedicatedConnection({ remoteSshVersion: "-" }), true);
assert.equal(sftpRequiresDedicatedConnection({ remoteSshVersion: "SSH-2.0--" }), true);
assert.equal(sftpRequiresDedicatedConnection({ remoteSshVersion: "RGOS_SSH" }), true);
assert.equal(sftpRequiresDedicatedConnection({ remoteSshVersion: "SSH-2.0-RGOS_SSH" }), true);
});
test("sftpRequiresDedicatedConnection still shares a normal OpenSSH terminal", () => {
assert.equal(sftpRequiresDedicatedConnection({ remoteSshVersion: "OpenSSH_9.6" }), false);
assert.equal(
sftpRequiresDedicatedConnection({ remoteSshVersion: "SSH-2.0-OpenSSH_9.6p1" }),
false,
);
assert.equal(sftpRequiresDedicatedConnection({ remoteSshVersion: "JumpServer" }), false);
assert.equal(sftpRequiresDedicatedConnection({}), false);
});
test("sftpRequiresDedicatedConnection uses a fresh login for network-device mode", () => {
assert.equal(
sftpRequiresDedicatedConnection({
remoteSshVersion: "OpenSSH_9.6",
deviceType: "network",
}),
true,
);
assert.equal(
sftpRequiresDedicatedConnection({ deviceType: "network" }),
true,
);
assert.equal(
sftpRequiresDedicatedConnection({
remoteSshVersion: "OpenSSH_9.6",
deviceType: "general",
}),
false,
);
});
test("sftpRequiresDedicatedConnection keeps one-channel bastion banners on a fresh login", () => {
assert.equal(
sftpRequiresDedicatedConnection({ remoteSshVersion: "CLOUDBILITY-4.14" }),
true,
);
assert.equal(
sftpRequiresDedicatedConnection({
remoteSshVersion: "OpenSSH_9.6",
singleChannelSsh: true,
}),
true,
);
});
test("shouldProbeSessionCwd skips the probe when extra SSH channels are restricted", () => {
assert.equal(
shouldProbeSessionCwd({ isNetworkDevice: false, remoteSshVersion: "OpenSSH_9.6", restrictExtraSshChannels: true }),
+1
View File
@@ -1,6 +1,7 @@
import { detectVendorFromSshVersion } from './sshServerVendor.mjs';
export { detectVendorFromSshVersion } from './sshServerVendor.mjs';
import { remoteSoftwareRequiresSingleChannel } from './singleChannelSshBanner.shared.mjs';
export { sftpRequiresDedicatedConnection } from './sftpDedicatedConnection.mjs';
import { Host, Snippet, TerminalSettings } from './models';
import type { HostOperatingSystem, HostOsSelection } from './models/connection';
import { sanitizeHostIconFields } from './hostIcon';
+5
View File
@@ -0,0 +1,5 @@
export function sftpRequiresDedicatedConnection(opts?: {
remoteSshVersion?: string | null;
deviceType?: string | null;
singleChannelSsh?: boolean;
}): boolean;
+17
View File
@@ -0,0 +1,17 @@
import { detectVendorFromSshVersion } from "./sshServerVendor.mjs";
import { remoteSoftwareRequiresSingleChannel } from "./singleChannelSshBanner.shared.mjs";
/**
* True when SFTP must dial its own SSH login.
* An extra channel on the terminal transport disconnects Huawei VRP, Ruijie,
* and other gear that advertises a network-vendor banner (#3631, #1043), and
* the same is true for one-channel bastions and Network Device Mode.
*/
export function sftpRequiresDedicatedConnection(opts = {}) {
if (!opts || typeof opts !== "object") return false;
if (opts.singleChannelSsh) return true;
if (opts.deviceType === "network") return true;
const remoteSshVersion = opts.remoteSshVersion || "";
return remoteSoftwareRequiresSingleChannel(remoteSshVersion)
|| Boolean(detectVendorFromSshVersion(remoteSshVersion));
}
+9 -5
View File
@@ -11,7 +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 { sftpRequiresDedicatedConnection } = require("../../domain/sftpDedicatedConnection.mjs");
const { openBoundedSftpChannel } = require("./boundedSftpOpen.cjs");
const { invalidateSshTransport } = require("./sshTransportInvalidation.cjs");
require("./boringSslDhCompat.cjs").installBoringSslDhCompat();
@@ -1954,10 +1954,14 @@ async function openSftpForSession(_event, payload) {
source = { sessionId, ...ensureRemoteSftpSupport(sessionId) };
}
const { session, sshClient } = source;
if (
session.singleChannelSsh
|| remoteSoftwareRequiresSingleChannel(session.remoteSshVersion || sshClient?._remoteVer)
) {
const deviceType = session.deviceType === "network" || payload?.deviceType === "network"
? "network"
: (session.deviceType || payload?.deviceType);
if (sftpRequiresDedicatedConnection({
singleChannelSsh: !!session.singleChannelSsh,
remoteSshVersion: session.remoteSshVersion || sshClient?._remoteVer,
deviceType,
})) {
const err = new Error(
"This host is configured for single-channel SSH. Opening SFTP on the terminal connection would disconnect it.",
);
@@ -324,6 +324,125 @@ test("openSftpForSession refuses a one-channel bastion banner without the host f
}
});
test("openSftpForSession refuses a network-vendor banner before opening a channel", async () => {
for (const remoteSshVersion of ["-", "SSH-2.0--", "RGOS_SSH", "SSH-2.0-RGOS_SSH"]) {
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-vendor", session]]);
bridge.init({ sftpClients, sessions, electronModule: {} });
await assert.rejects(
bridge.openSftpForSession(null, { sessionId: "session-vendor" }),
(err) => err && err.code === "ERR_SFTP_SINGLE_CHANNEL_BASTION" && sftpCalls === 0,
remoteSshVersion,
);
}
});
test("openSftpForSession refuses network-device mode even when the banner is OpenSSH", async () => {
const bridge = loadSftpBridgeWithProxySocket(null);
const sftpClients = new Map();
let sftpCalls = 0;
const conn = {
_remoteVer: "OpenSSH_9.6",
sftp(cb) {
sftpCalls += 1;
cb(null, { end() {} });
},
end() {},
};
const session = {
conn,
stream: {},
remoteSshVersion: "OpenSSH_9.6",
deviceType: "network",
};
const sessions = new Map([["session-network", session]]);
bridge.init({ sftpClients, sessions, electronModule: {} });
await assert.rejects(
bridge.openSftpForSession(null, { sessionId: "session-network" }),
(err) => err && err.code === "ERR_SFTP_SINGLE_CHANNEL_BASTION" && sftpCalls === 0,
);
});
test("openSftpForSession refuses payload deviceType network before opening a channel", async () => {
const bridge = loadSftpBridgeWithProxySocket(null);
const sftpClients = new Map();
let sftpCalls = 0;
const conn = {
_remoteVer: "OpenSSH_9.6",
sftp(cb) {
sftpCalls += 1;
cb(null, { end() {} });
},
end() {},
};
const session = {
conn,
stream: {},
remoteSshVersion: "OpenSSH_9.6",
};
const sessions = new Map([["session-payload-network", session]]);
bridge.init({ sftpClients, sessions, electronModule: {} });
await assert.rejects(
bridge.openSftpForSession(null, {
sessionId: "session-payload-network",
deviceType: "network",
}),
(err) => err && err.code === "ERR_SFTP_SINGLE_CHANNEL_BASTION" && sftpCalls === 0,
);
});
test("openSftpForSession still shares the terminal transport for OpenSSH", async () => {
const bridge = loadSftpBridgeWithProxySocket(null);
const sftpClients = new Map();
let sftpCalls = 0;
const fakeSftp = {
readdir() {},
stat() {},
mkdir() {},
unlink() {},
end() {},
};
const conn = {
_remoteVer: "SSH-2.0-OpenSSH_9.6p1",
sftp(cb) {
sftpCalls += 1;
cb(null, fakeSftp);
},
end() {},
};
const session = {
conn,
stream: {},
remoteSshVersion: "SSH-2.0-OpenSSH_9.6p1",
deviceType: "general",
};
const sessions = new Map([["session-openssh", session]]);
bridge.init({ sftpClients, sessions, electronModule: {} });
const opened = await bridge.openSftpForSession(null, { sessionId: "session-openssh" });
assert.equal(opened.ok, true);
assert.equal(sftpCalls, 1);
await bridge.closeSftp(null, { sftpId: opened.sftpId });
});
test("openSftpForSession still shares the terminal transport for JumpServer", async () => {
const bridge = loadSftpBridgeWithProxySocket(null);
const sftpClients = new Map();
@@ -11,6 +11,7 @@ const {
const { openBoundedSftpChannel } = require("../boundedSftpOpen.cjs");
const { openBoundedForwardOutCallback } = require("../boundedSshChannelOpen.cjs");
const { remoteSoftwareRequiresSingleChannel } = require("../../../domain/singleChannelSshBanner.shared.cjs");
const { sftpRequiresDedicatedConnection } = require("../../../domain/sftpDedicatedConnection.mjs");
/** Bound shell/scp probes so a hung remote exec cannot leave the panel connecting forever. */
const SCP_PROBE_TIMEOUT_MS = 15_000;
@@ -779,11 +780,14 @@ 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 sharedDeviceType = options.deviceType === "network" || transport.endpoint?.deviceType === "network"
? "network"
: (options.deviceType || transport.endpoint?.deviceType);
if (sftpRequiresDedicatedConnection({
singleChannelSsh: !!(options.singleChannelSsh || transport.endpoint?.singleChannelSsh),
remoteSshVersion: transport?.conn?._remoteVer,
deviceType: sharedDeviceType,
})) {
const err = new Error(
"This host is configured for single-channel SSH. Opening SFTP on the terminal connection would disconnect it.",
);