Compare commits

...
Author SHA1 Message Date
bincxz 4c3ee4474e merge origin/main into fix/drop-single-channel-ssh-toggle
The reconnect scan assertions already match. Keep the comment that includes a pinned strict source id.
2026-09-29 19:54:26 +08:00
bincxz f4b5417909 test(sftp): 对齐单通道登录后的重连扫描
专用 SFTP 登录改为 reuseTerminalTransport 之后,旧的 sourceSessionId 三元表达式扫描不再匹配,lint-and-test 因此失败。扫描改为锁定当前门槛:已连接,或已经钉住严格来源,才复用终端传输。
2026-09-29 18:27:36 +08:00
bincxz 1bb6bc895c fix(ssh): 去掉单通道 SSH 的手动开关
单通道只在握手后匹配 BHostSSH、TERM-SSHD、CLOUDBILITY 时生效。
主机和分组不再保存这个选项,读到旧字段时直接丢掉。
2026-09-29 18:15:12 +08:00
30 changed files with 94 additions and 124 deletions

No files matched your search

-3
View File
@@ -900,9 +900,6 @@ 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',
-3
View File
@@ -900,9 +900,6 @@ 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',
-3
View File
@@ -935,9 +935,6 @@ 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': 'Показывать время вывода',
-3
View File
@@ -316,9 +316,6 @@ 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': '显示输出时间',
-3
View File
@@ -316,9 +316,6 @@ 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': '顯示輸出時間',
@@ -205,7 +205,6 @@ 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,
};
+1 -22
View File
@@ -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.singleChannelSsh !== undefined ||
c.deviceType !== 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,7 +260,6 @@ 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;
@@ -503,11 +502,6 @@ 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);
@@ -567,7 +561,6 @@ 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 }),
@@ -807,20 +800,6 @@ 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,8 +57,6 @@ 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";
@@ -373,20 +371,6 @@ 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>
+3 -6
View File
@@ -934,11 +934,8 @@ const SftpSidePanelInner: React.FC<SftpSidePanelProps> = ({
connectedKeyRef.current = connectionKey;
connectedHostObjRef.current = activeHost;
const dedicatedSftpLogin = activeHost.singleChannelSsh === true
|| (
sftpBannerDecision?.sessionId === activeSessionId
&& sftpBannerDecision.singleChannel
);
const dedicatedSftpLogin = sftpBannerDecision?.sessionId === activeSessionId
&& sftpBannerDecision.singleChannel;
const reuseTerminalTransport = !dedicatedSftpLogin && (
Boolean(pendingStrictSourceSessionId) || activeSessionStatus === "connected"
);
@@ -1695,7 +1692,7 @@ const SftpSidePanelInteractiveBody: React.FC<SftpSidePanelInteractiveBodyProps>
const canFollowTerminalCwd = useMemo(() => {
if (!onGetTerminalCwd || !followTerminalCwdHost) return false;
if (followTerminalCwdHost.singleChannelSsh === true || bannerRequiresSingleChannel) return false;
if (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;
-1
View File
@@ -1133,7 +1133,6 @@ 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?: {
@@ -47,7 +47,11 @@ test("locate-path uses focused session fallback when SFTP cannot reuse the termi
});
test("single-channel SFTP opens a dedicated connection instead of the terminal channel", () => {
assert.match(sidePanelSource, /activeHost\.singleChannelSsh === true/);
assert.match(
sidePanelSource,
/const dedicatedSftpLogin = sftpBannerDecision\?\.sessionId === activeSessionId\s*&& sftpBannerDecision\.singleChannel;/,
);
assert.doesNotMatch(sidePanelSource, /activeHost\.singleChannelSsh/);
assert.match(sidePanelSource, /reuseTransport: false/);
assert.match(
sidePanelSource,
@@ -16,8 +16,9 @@ test("SFTP side panel rebinds after same-tab SSH start-over", () => {
sidePanelSource,
/previousStatus:\s*lastSourceSessionStatusRef\.current/,
);
// Reuse the terminal transport only after SSH is connected. A linked id can
// arrive while reconnecting. Single-channel bastions dial a separate login.
// Reuse the terminal transport only when SSH is connected, or when a strict
// pending source id is already pinned. A dedicated single-channel login never
// reuses that transport. A linked id may arrive while reconnecting.
assert.match(
sidePanelSource,
/const reuseTerminalTransport = !dedicatedSftpLogin && \(\s*Boolean\(pendingStrictSourceSessionId\) \|\| activeSessionStatus === "connected"\s*\);/,
@@ -87,8 +87,8 @@ export const SystemManagerSidePanel = memo(function SystemManagerSidePanel({
const [peerSingleChannel, setPeerSingleChannel] = useState<boolean | null>(null);
useEffect(() => {
if (!sessionId || !isConnected || sessionHost?.singleChannelSsh === true) {
setPeerSingleChannel(sessionHost?.singleChannelSsh === true);
if (!sessionId || !isConnected) {
setPeerSingleChannel(false);
return;
}
let cancelled = false;
@@ -102,9 +102,9 @@ export const SystemManagerSidePanel = memo(function SystemManagerSidePanel({
return () => {
cancelled = true;
};
}, [isConnected, sessionHost?.singleChannelSsh, sessionId, terminalBackend]);
}, [isConnected, sessionId, terminalBackend]);
const systemManagerSupported = sessionHost?.singleChannelSsh !== true && peerSingleChannel !== true;
const systemManagerSupported = peerSingleChannel !== true;
const capabilitiesEnabled = systemManagerSupported && peerSingleChannel !== null;
const capabilitiesTtlMs = terminalSettings.systemManagerProcessRefreshInterval * 1000;
@@ -536,7 +536,7 @@ export const SystemManagerSidePanel = memo(function SystemManagerSidePanel({
<SystemOverviewTab
sessionId={sessionId}
isVisible={isVisible && resolvedTab === 'overview'}
isSupportedOs={isStatsSupportedOs}
isSupportedOs={isStatsSupportedOs && peerSingleChannel !== true}
refreshIntervalSec={terminalSettings.serverStatsRefreshInterval}
/>
</div>
@@ -441,10 +441,10 @@ 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);
assert.equal(capturedOptions?.singleChannelSsh, undefined);
});
test("startSSH opens a fresh connection when copying a single-channel host", async () => {
test("startSSH ignores a legacy single-channel host field when copying a tab", async () => {
let capturedOptions: Record<string, unknown> | null = null;
const terminalBackend = {
backendAvailable: () => true,
@@ -472,9 +472,8 @@ test("startSSH opens a fresh connection when copying a single-channel host", asy
await createTerminalSessionStarters(ctx as never).startSSH(createTermStub() as never);
assert.equal(capturedOptions?.sourceSessionId, undefined);
assert.equal(capturedOptions?.reuseTransport, false);
assert.equal(capturedOptions?.singleChannelSsh, true);
assert.equal(capturedOptions?.sourceSessionId, "source-session");
assert.equal(capturedOptions?.singleChannelSsh, undefined);
});
test("startSSH requests a fresh transport for ordinary opens with connection automation", async () => {
@@ -625,10 +625,7 @@ 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
|| ctx.host.singleChannelSsh === true
)
const sourceSessionId = ctx.requireFreshConnectionOnReconnectRef?.current
? undefined
: ctx.reuseConnectionFromSessionIdRef?.current;
if (ctx.reuseConnectionFromSessionIdRef) {
@@ -701,7 +698,6 @@ 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,
@@ -6,10 +6,9 @@ import {
runDistroDetection,
} from "./terminalDistroDetection.ts";
test("runDistroDetection skips POSIX probes when singleChannelSsh is enabled", async () => {
test("runDistroDetection still probes when only a legacy host flag is set", async () => {
let distroProbeCalls = 0;
const detected: string[] = [];
const token = registerConnectionToken("bastion-session");
const token = registerConnectionToken("legacy-flag-session");
await runDistroDetection({
host: {
@@ -22,20 +21,17 @@ test("runDistroDetection skips POSIX probes when singleChannelSsh is enabled", a
terminalBackend: {
getSessionRemoteInfo: async () => ({
success: true,
remoteSshVersion: "CLOUDBILITY-4.14",
remoteSshVersion: "OpenSSH_9.6",
}),
getSessionDistroInfo: async () => {
distroProbeCalls += 1;
return { success: false, error: "must not probe single-channel SSH" };
return { success: true, stdout: 'ID="ubuntu"\n' };
},
},
onOsDetected: (_hostId: string, distro: string) => {
detected.push(distro);
},
} as never, "bastion-session", token);
onOsDetected: () => undefined,
} as never, "legacy-flag-session", token);
assert.equal(distroProbeCalls, 0);
assert.deepEqual(detected, []);
assert.equal(distroProbeCalls, 1);
});
test("runDistroDetection skips POSIX probes when the SSH banner is a one-channel bastion", async () => {
@@ -190,8 +190,9 @@ 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',[\s\S]*?restrictPtyRewrites: host\.singleChannelSsh === true,/,
/resolveTerminalAutocompleteSettings\(\{[\s\S]*?isNetworkDevice: host\.deviceType === 'network'[\s\S]*?classifyDistroId\(host\.distro\) === 'network-device',\s*\}\);/,
);
assert.doesNotMatch(terminalSource, /singleChannelSsh/);
const autocompleteSource = readFileSync(new URL('./TerminalAutocomplete.tsx', import.meta.url), 'utf8');
assert.match(
autocompleteSource,
@@ -219,9 +219,8 @@ 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, activeSystemSessionHost, activeTabId, activeTerminalSessionForSystem]);
}, [activeSidePanelTools, activeTabId, activeTerminalSessionForSystem]);
useSystemCapabilitiesWarmup(
systemWarmupSessionIds,
+23
View File
@@ -14,6 +14,16 @@ const host = (overrides: Partial<Host> = {}): Host => ({
...overrides,
});
test("applyGroupDefaults ignores a legacy single-channel group flag", () => {
const result = applyGroupDefaults(
host(),
{ deviceType: "network", singleChannelSsh: true } as Partial<GroupConfig>,
);
assert.equal(result.deviceType, "network");
assert.equal("singleChannelSsh" in result, false);
});
test("applyGroupDefaults lets a host proxy profile override a group custom proxy", () => {
const groupDefaults: Partial<GroupConfig> = {
proxyConfig: { type: "http", host: "group-proxy.example.com", port: 3128 },
@@ -513,6 +523,19 @@ test("sanitizeGroupConfig keeps a still-valid fontFamily untouched", () => {
assert.equal(after.fontFamilyOverride, true);
});
test("sanitizeGroupConfig drops a legacy single-channel SSH flag", () => {
const source = {
path: "team",
username: "alice",
singleChannelSsh: true,
} as Parameters<typeof sanitizeGroupConfig>[0];
const after = sanitizeGroupConfig(source);
assert.equal("singleChannelSsh" in after, false);
assert.equal(after.username, "alice");
assert.equal("singleChannelSsh" in source, true);
});
test("sanitizeGroupConfig preserves legacy group passwords as password-only", () => {
const after = sanitizeGroupConfig({
path: "team",
+7 -3
View File
@@ -15,9 +15,13 @@ export function sanitizeGroupConfig(config: GroupConfig): GroupConfig {
&& !migrated.identityFileId
&& !migrated.identityFilePaths?.length;
return hasLegacyPasswordOnlyCredentials
? { ...migrated, authMethod: 'password' }
const next = hasLegacyPasswordOnlyCredentials
? { ...migrated, authMethod: 'password' as const }
: migrated;
if (!Object.prototype.hasOwnProperty.call(next, 'singleChannelSsh')) return next;
const cleaned = { ...next };
delete (cleaned as { singleChannelSsh?: unknown }).singleChannelSsh;
return cleaned;
}
export interface ApplyGroupDefaultsOptions {
@@ -145,7 +149,7 @@ export function resolveGroupDefaults(
const INHERITABLE_KEYS: (keyof GroupConfig)[] = [
'username', 'password', 'savePassword', 'authMethod', 'identityId', 'identityFileId', 'identityFilePaths',
'port', 'protocol', 'deviceType', 'singleChannelSsh', 'agentForwarding', 'proxyProfileId', 'proxyConfig', 'hostChain', 'startupCommand', 'startupCommandRunMode',
'port', 'protocol', 'deviceType', 'agentForwarding', 'proxyProfileId', 'proxyConfig', 'hostChain', 'startupCommand', 'startupCommandRunMode',
'legacyAlgorithms', 'skipEcdsaHostKey', 'algorithms',
'environmentVariables', 'charset', 'moshEnabled', 'moshServerPath',
'etEnabled', 'etPort',
+13 -3
View File
@@ -305,6 +305,16 @@ test("sanitizeHost removes hidden built-in credentials and transport state from
}
});
test("sanitizeHost drops a legacy single-channel SSH flag", () => {
const sanitized = sanitizeHost({
...makeHost(),
singleChannelSsh: true,
} as Host);
assert.equal("singleChannelSsh" in sanitized, false);
assert.equal(sanitized.hostname, "127.0.0.1");
});
test("sanitizeHost keeps legacy empty-password hosts on automatic authentication", () => {
const sanitized = sanitizeHost(makeHost({
password: undefined,
@@ -645,10 +655,10 @@ 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);
test("hostRestrictsExtraSshChannels follows network-device mode only", () => {
assert.equal(hostRestrictsExtraSshChannels({ deviceType: "network" }), true);
assert.equal(hostRestrictsExtraSshChannels({ singleChannelSsh: false, deviceType: "general" }), false);
assert.equal(hostRestrictsExtraSshChannels({ deviceType: "general" }), false);
assert.equal(hostRestrictsExtraSshChannels({ singleChannelSsh: true } as never), false);
assert.equal(hostRestrictsExtraSshChannels(undefined), false);
});
+9 -9
View File
@@ -271,23 +271,22 @@ export const shouldSuggestNetworkDeviceMode = (opts: {
};
/**
* 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.
* True when an extra exec channel on the terminal transport is unsafe because
* the host is a network device. Use this to skip probes. Do not store it as
* session.singleChannelSsh: that runtime flag is stamped only after a
* recognized one-channel bastion banner.
*/
export const hostRestrictsExtraSshChannels = (
host?: Pick<Host, 'singleChannelSsh' | 'deviceType'> | null,
): boolean => host?.singleChannelSsh === true || host?.deviceType === 'network';
host?: Pick<Host, 'deviceType'> | null,
): boolean => 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. Hosts with `singleChannelSsh` (bastion / PAM), and
* software banners that allow only one session channel per TCP connection,
* have the same constraint.
* skipped for them. 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
@@ -532,5 +531,6 @@ export const sanitizeHost = (host: Host, snippets: Snippet[] = []): Host => {
connectScriptIds: connectScriptIds && connectScriptIds.length > 0 ? connectScriptIds : undefined,
pluginConnection,
};
delete (sanitized as { singleChannelSsh?: unknown }).singleChannelSsh;
return stripBuiltInConnectionFieldsForPluginHost(sanitized);
};
-4
View File
@@ -177,9 +177,6 @@ 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;
@@ -428,7 +425,6 @@ export interface GroupConfig {
port?: number;
protocol?: 'ssh' | 'telnet';
deviceType?: 'general' | 'network';
singleChannelSsh?: boolean;
agentForwarding?: boolean;
proxyProfileId?: string;
proxyConfig?: ProxyConfig;
+1 -1
View File
@@ -152,7 +152,6 @@ export function stripBuiltInConnectionFieldsForPluginHost(host: Host): Host {
telnetPassword: _telnetPassword,
serialConfig: _serialConfig,
sftpSudo: _sftpSudo,
singleChannelSsh: _singleChannelSsh,
legacyAlgorithms: _legacyAlgorithms,
skipEcdsaHostKey: _skipEcdsaHostKey,
algorithms: _algorithms,
@@ -163,5 +162,6 @@ export function stripBuiltInConnectionFieldsForPluginHost(host: Host): Host {
sshAuthReadyTimeoutSeconds: _sshAuthReadyTimeoutSeconds,
...pluginHost
} = host;
delete (pluginHost as { singleChannelSsh?: unknown }).singleChannelSsh;
return pluginHost;
}
-1
View File
@@ -208,7 +208,6 @@ 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'),
+4 -3
View File
@@ -107,7 +107,7 @@ test("network devices hide processes until OS probe confirms a real target", ()
});
test("system overview stats skip single-channel SSH hosts", () => {
test("system overview stats ignore a legacy single-channel host field", () => {
assert.equal(
shouldCollectServerStats(
{
@@ -117,12 +117,13 @@ test("system overview stats skip single-channel SSH hosts", () => {
username: "user",
tags: [],
os: "linux",
distro: "ubuntu",
singleChannelSsh: true,
},
} as never,
undefined,
null,
),
false,
true,
);
});
-1
View File
@@ -93,7 +93,6 @@ 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';
Vendored
+1 -1
View File
@@ -175,7 +175,7 @@ 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. */
/** Runtime stamp for one session channel on this TCP. Set after a recognized bastion banner, not from saved host settings. */
singleChannelSsh?: boolean;
/** Original unsaved-password profile; main retains only its digest for live SFTP borrowing. */
sftpReuseOptions?: NetcattySSHOptions;
@@ -1518,18 +1518,18 @@ test("startPortForward forwards target and jump-host timeouts", async () => {
assert.equal(jumpHosts[0]?.sshAuthReadyTimeoutMs, 360_000);
});
test("startPortForward tells a single-channel host not to reuse the terminal", async () => {
test("startPortForward does not send a saved single-channel host flag", async () => {
const bridge = installBridgeStub();
const result = await startPortForward(
rule({ id: "rule-single-channel" }),
host({ singleChannelSsh: true }),
{ ...host(), singleChannelSsh: true } as Host,
[],
[],
[],
() => {},
);
assert.equal(result.success, true);
assert.equal(bridge.getOptions()?.singleChannelSsh, true);
assert.equal(bridge.getOptions()?.singleChannelSsh, undefined);
const plain = installBridgeStub();
const plainResult = await startPortForward(
@@ -1541,7 +1541,7 @@ test("startPortForward tells a single-channel host not to reuse the terminal", a
() => {},
);
assert.equal(plainResult.success, true);
assert.equal(plain.getOptions()?.singleChannelSsh, false);
assert.equal(plain.getOptions()?.singleChannelSsh, undefined);
});
test("startPortForward rejects missing proxy identities before starting", async () => {
@@ -1133,7 +1133,6 @@ export const startPortForward = async (
algorithmOverrides: host.algorithms,
keepaliveInterval: resolveHostKeepalive(host, globalTerminalSettings).interval,
keepaliveCountMax: resolveHostKeepalive(host, globalTerminalSettings).countMax,
singleChannelSsh: host.singleChannelSsh === true,
sshTcpConnectTimeoutMs: connectionTimeouts.tcpConnectTimeoutSeconds * 1000,
sshAuthReadyTimeoutMs: connectionTimeouts.authReadyTimeoutSeconds * 1000,
});