Compare commits

...
Author SHA1 Message Date
Cursor Agentand陈大猫 5cfd0dc855 fix: show inherited X11 from the effective host transport
The host editor treated a raw group X11 default as on even when
applyGroupDefaults suppresses that inheritance for Mosh, ET, or a
non-SSH protocol. Drive the switch and its visibility from that result.

Co-authored-by: 陈大猫 <binaricat@users.noreply.github.com>
2026-10-20 04:21:11 +00:00
Cursor Agentand陈大猫 7a0e2f4127 feat: inherit X11 forwarding from group defaults
Hosts that leave the toggle unset pick up the nearest group's X11
forwarding setting through applyGroupDefaults. An explicit host value
still wins, and the default stays off.

Co-authored-by: 陈大猫 <binaricat@users.noreply.github.com>
2026-10-20 03:56:17 +00:00
9 changed files with 154 additions and 6 deletions

No files matched your search

+5
View File
@@ -15,6 +15,11 @@ test("GroupDetailsPanel treats an empty SSH identity as an explicit setting", ()
assert.equal(hasGroupSshFields({}), false);
});
test("GroupDetailsPanel keeps the SSH section when X11 forwarding is set", () => {
assert.equal(hasGroupSshFields({ x11Forwarding: true }), true);
assert.equal(hasGroupSshFields({ x11Forwarding: false }), true);
});
test("GroupDetailsPanel treats cleared telnet credentials as explicit settings", () => {
assert.equal(hasGroupTelnetFields({ telnetUsername: "" }), true);
assert.equal(hasGroupTelnetFields({ telnetPassword: "" }), true);
+8 -1
View File
@@ -102,7 +102,7 @@ export const hasGroupSshFields = (c: Partial<GroupConfig>): boolean =>
c.protocol === 'ssh' ||
c.port !== undefined || !!c.username || !!c.password || !!c.identityFileId ||
c.deviceType !== undefined ||
c.agentForwarding !== undefined || c.authMethod !== undefined || c.identityId !== undefined ||
c.agentForwarding !== undefined || c.x11Forwarding !== 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) ||
c.moshEnabled !== undefined || !!c.moshServerPath ||
@@ -262,6 +262,7 @@ const GroupDetailsPanel: React.FC<GroupDetailsPanelPropsWithResize> = ({
delete next.identityFilePaths;
delete next.deviceType;
delete next.agentForwarding;
delete next.x11Forwarding;
delete next.startupCommand;
delete next.startupCommandRunMode;
delete next.legacyAlgorithms;
@@ -499,6 +500,10 @@ const GroupDetailsPanel: React.FC<GroupDetailsPanelPropsWithResize> = ({
if (!parentGroup || groupConfigs.length === 0) return false;
return !!resolveGroupDefaults(parentGroup, groupConfigs).skipEcdsaHostKey;
}, [groupConfigs, parentGroup]);
const inheritedX11Forwarding = useMemo(() => {
if (!parentGroup || groupConfigs.length === 0) return false;
return !!resolveGroupDefaults(parentGroup, groupConfigs).x11Forwarding;
}, [groupConfigs, parentGroup]);
const inheritedStartupCommandRunMode = useMemo(() => {
if (!parentGroup || groupConfigs.length === 0) return "paste";
return resolveGroupDefaults(parentGroup, groupConfigs).startupCommandRunMode ?? "paste";
@@ -568,6 +573,7 @@ const GroupDetailsPanel: React.FC<GroupDetailsPanelPropsWithResize> = ({
...(form.identityFilePaths !== undefined && { identityFilePaths: form.identityFilePaths }),
...(form.deviceType !== undefined && { deviceType: form.deviceType }),
...(form.agentForwarding !== undefined && { agentForwarding: form.agentForwarding }),
...(form.x11Forwarding !== undefined && { x11Forwarding: form.x11Forwarding }),
...(form.startupCommand !== undefined && { startupCommand: form.startupCommand }),
...(form.startupCommandRunMode !== undefined && { startupCommandRunMode: form.startupCommandRunMode }),
...(form.legacyAlgorithms !== undefined && { legacyAlgorithms: form.legacyAlgorithms }),
@@ -778,6 +784,7 @@ const GroupDetailsPanel: React.FC<GroupDetailsPanelPropsWithResize> = ({
setNewKeyFilePath={setNewKeyFilePath}
inheritedLegacyAlgorithms={inheritedLegacyAlgorithms}
inheritedSkipEcdsaHostKey={inheritedSkipEcdsaHostKey}
inheritedX11Forwarding={inheritedX11Forwarding}
inheritedStartupCommandRunMode={inheritedStartupCommandRunMode}
showAlgorithmOverrides={showAlgorithmOverrides}
setShowAlgorithmOverrides={setShowAlgorithmOverrides}
+11
View File
@@ -47,6 +47,7 @@ export const GroupSshSettingsSection: React.FC<GroupSshSettingsSectionProps> = (
setNewKeyFilePath,
inheritedLegacyAlgorithms,
inheritedSkipEcdsaHostKey,
inheritedX11Forwarding,
inheritedStartupCommandRunMode,
showAlgorithmOverrides,
setShowAlgorithmOverrides,
@@ -395,6 +396,16 @@ export const GroupSshSettingsSection: React.FC<GroupSshSettingsSectionProps> = (
onToggle={() => update("agentForwarding", !form.agentForwarding)}
/>
<ToggleRow
label={t("hostDetails.x11Forwarding")}
hint={t("hostDetails.x11Forwarding.desc")}
enabled={!!(form.x11Forwarding ?? inheritedX11Forwarding)}
onToggle={() => update(
"x11Forwarding",
!(form.x11Forwarding ?? inheritedX11Forwarding),
)}
/>
{/* Startup Command — Textarea so multi-line sequences are typeable
here just like on the per-host details panel (#1083 follow-up). */}
<Textarea
@@ -30,6 +30,17 @@ test('enabling SSH agent login clears an imported none sentinel', () => {
assert.match(source, /resolveSshAgentToggleUpdate/);
});
test('X11 forwarding display follows the effective host after group defaults', () => {
assert.match(source, /applyGroupDefaults\(form, effectiveGroupDefaults\)/);
assert.match(source, /const showX11Forwarding = \(!effectiveConnectionHost\.protocol \|\| effectiveConnectionHost\.protocol === "ssh"\)/);
assert.match(source, /!effectiveConnectionHost\.moshEnabled/);
assert.match(source, /!effectiveConnectionHost\.etEnabled/);
assert.match(source, /const effectiveX11Forwarding = !!effectiveConnectionHost\.x11Forwarding/);
assert.match(source, /\{showX11Forwarding && \(/);
assert.match(source, /enabled=\{effectiveX11Forwarding\}/);
assert.doesNotMatch(source, /effectiveGroupDefaults\?\.x11Forwarding/);
});
test('login and forwarding warnings use independent agent status', () => {
assert.match(source, /systemSshAgentEnabled && sshAgentStatus && !sshAgentStatus\.running/);
assert.match(source, /form\.agentForwarding && sshForwardingAgentStatus && !sshForwardingAgentStatus\.running/);
+13 -3
View File
@@ -1,4 +1,5 @@
import { getHostOsSelection, HOST_OS_SELECTIONS, resolveHostOs } from '../domain/host';
import { applyGroupDefaults } from "../domain/groupConfig";
import React from "react";
import { AlertTriangle, ChevronDown, ChevronUp, Forward, Globe, HeartPulse, KeyRound, Link2, Palette, Plus, Router, ShieldAlert, TerminalSquare, Timer, Wifi, X, Variable } from "lucide-react";
import { customThemeStore } from "../application/state/customThemeStore";
@@ -63,6 +64,15 @@ export const HostDetailsAdvancedSections: React.FC<HostDetailsAdvancedSectionsPr
const systemSshAgentEnabled = effectiveAuthMethod === "auto"
? form.useSshAgent !== false
: effectiveAuthMethod === "key" && form.useSshAgent === true;
// Match the connection: inherited X11 is dropped when the effective transport
// is Mosh, Eternal Terminal, or anything other than plain SSH.
const effectiveConnectionHost = effectiveGroupDefaults
? applyGroupDefaults(form, effectiveGroupDefaults)
: form;
const showX11Forwarding = (!effectiveConnectionHost.protocol || effectiveConnectionHost.protocol === "ssh")
&& !effectiveConnectionHost.moshEnabled
&& !effectiveConnectionHost.etEnabled;
const effectiveX11Forwarding = !!effectiveConnectionHost.x11Forwarding;
return (
<>
@@ -337,7 +347,7 @@ export const HostDetailsAdvancedSections: React.FC<HostDetailsAdvancedSectionsPr
</HostDetailsSection>
{/* X11 Forwarding */}
{(!form.protocol || form.protocol === "ssh") && !form.moshEnabled && !form.etEnabled && (
{showX11Forwarding && (
<HostDetailsSection
icon={<TerminalSquare size={14} className="text-muted-foreground" />}
title={t("hostDetails.section.x11Forwarding")}
@@ -345,8 +355,8 @@ export const HostDetailsAdvancedSections: React.FC<HostDetailsAdvancedSectionsPr
<ToggleRow
label={t("hostDetails.x11Forwarding")}
hint={t("hostDetails.x11Forwarding.desc")}
enabled={!!form.x11Forwarding}
onToggle={() => update("x11Forwarding", !form.x11Forwarding)}
enabled={effectiveX11Forwarding}
onToggle={() => update("x11Forwarding", !effectiveX11Forwarding)}
/>
</HostDetailsSection>
)}
+54
View File
@@ -595,6 +595,58 @@ test("applyGroupDefaults keeps host-level skipEcdsaHostKey instead of group defa
assert.equal(result.skipEcdsaHostKey, false);
});
test("resolveGroupDefaults uses the nearest group X11 toggle and stays off otherwise", () => {
const configs: GroupConfig[] = [
{ path: "prod", x11Forwarding: true },
{ path: "prod/edge", x11Forwarding: false },
];
assert.equal(resolveGroupDefaults("prod", configs).x11Forwarding, true);
assert.equal(resolveGroupDefaults("prod/web", configs).x11Forwarding, true);
assert.equal(resolveGroupDefaults("prod/edge", configs).x11Forwarding, false);
assert.equal(resolveGroupDefaults("prod/edge/api", configs).x11Forwarding, false);
assert.equal(resolveGroupDefaults("other", configs).x11Forwarding, undefined);
});
test("applyGroupDefaults inherits group X11 forwarding when the host leaves it unset", () => {
assert.equal(applyGroupDefaults(host(), { x11Forwarding: true }).x11Forwarding, true);
});
test("applyGroupDefaults keeps an explicit host X11 toggle ahead of the group", () => {
assert.equal(
applyGroupDefaults(host({ x11Forwarding: false }), { x11Forwarding: true }).x11Forwarding,
false,
);
assert.equal(
applyGroupDefaults(host({ x11Forwarding: true }), { x11Forwarding: false }).x11Forwarding,
true,
);
});
test("applyGroupDefaults leaves X11 forwarding off when neither host nor group sets it", () => {
assert.equal(applyGroupDefaults(host(), {}).x11Forwarding, undefined);
});
test("applyGroupDefaults does not inherit X11 forwarding onto non-SSH transports", () => {
const groupDefaults = { x11Forwarding: true as const };
assert.equal(applyGroupDefaults(host({ moshEnabled: true }), groupDefaults).x11Forwarding, undefined);
assert.equal(applyGroupDefaults(host({ etEnabled: true }), groupDefaults).x11Forwarding, undefined);
assert.equal(applyGroupDefaults(host({ protocol: "telnet" }), groupDefaults).x11Forwarding, undefined);
assert.equal(applyGroupDefaults(host({ protocol: "serial" }), groupDefaults).x11Forwarding, undefined);
assert.equal(applyGroupDefaults(host({ protocol: "local" }), groupDefaults).x11Forwarding, undefined);
assert.equal(
applyGroupDefaults(host(), { ...groupDefaults, moshEnabled: true }).x11Forwarding,
undefined,
);
assert.equal(
applyGroupDefaults(host(), { ...groupDefaults, protocol: "telnet" }).x11Forwarding,
undefined,
);
assert.equal(
applyGroupDefaults(host({ moshEnabled: false }), { ...groupDefaults, moshEnabled: true }).x11Forwarding,
true,
);
});
test("applyGroupDefaults inherits algorithm overrides from the group", () => {
const overrides = { serverHostKey: ["ssh-rsa", "ssh-dss"] };
const result = applyGroupDefaults(host(), { algorithms: overrides });
@@ -678,6 +730,7 @@ test("applyGroupDefaults does not inherit SSH transport/input defaults into loca
legacyAlgorithms: true,
skipEcdsaHostKey: true,
agentForwarding: true,
x11Forwarding: true,
hostChain: { hostIds: ["bastion-1"] },
charset: "UTF-8",
fontSize: 14,
@@ -702,6 +755,7 @@ test("applyGroupDefaults does not inherit SSH transport/input defaults into loca
assert.equal(result.legacyAlgorithms, undefined);
assert.equal(result.skipEcdsaHostKey, undefined);
assert.equal(result.agentForwarding, undefined);
assert.equal(result.x11Forwarding, undefined);
assert.equal(result.hostChain, undefined);
// Display/appearance defaults still apply to local shells.
assert.equal(result.charset, "UTF-8");
+11 -2
View File
@@ -149,7 +149,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', 'agentForwarding', 'x11Forwarding', 'proxyProfileId', 'proxyConfig', 'hostChain', 'startupCommand', 'startupCommandRunMode',
'legacyAlgorithms', 'skipEcdsaHostKey', 'algorithms',
'environmentVariables', 'charset', 'moshEnabled', 'moshServerPath',
'etEnabled', 'etPort',
@@ -178,12 +178,13 @@ const SSH_CREDENTIAL_KEYS = new Set<keyof GroupConfig>([
// SSH transport/input settings the local-shell editor does not expose: input
// remapping (backspace), Mosh/Eternal-Terminal transports, and SSH-only
// algorithm/agent/chain flags. A local shell never runs through SSH, so these
// algorithm/agent/X11/chain flags. A local shell never runs through SSH, so these
// defaults would either remap local PTY input (`backspaceBehavior`,
// createXTermRuntime) or make flag-first consumers report a purely local
// session as Mosh/ET — with no way to override them in the local editor.
const SSH_ONLY_TRANSPORT_KEYS = new Set<keyof GroupConfig>([
'agentForwarding',
'x11Forwarding',
'legacyAlgorithms',
'skipEcdsaHostKey',
'algorithms',
@@ -246,11 +247,19 @@ export function applyGroupDefaults(
// local CMD/PowerShell/POSIX shell be treated as a network-device CLI
// (no normal autocomplete, network semantics for AI/command execution).
const isLocalShellHost = host.protocol === 'local';
// X11 forwarding only applies to plain SSH. Use the host value when it is
// set, otherwise the group transport, so a group Mosh/ET/Telnet default
// does not also turn X11 on.
const effectiveProtocol = host.protocol ?? groupDefaults.protocol;
const inheritsX11Forwarding = (!effectiveProtocol || effectiveProtocol === 'ssh')
&& !(host.moshEnabled ?? groupDefaults.moshEnabled)
&& !(host.etEnabled ?? groupDefaults.etEnabled);
for (const key of INHERITABLE_KEYS) {
if (shouldSkipGroupSshCredentialBundle && SSH_CREDENTIAL_KEYS.has(key)) continue;
if (isSerialHost && SSH_CREDENTIAL_KEYS.has(key)) continue;
if (isLocalShellHost && (SSH_CREDENTIAL_KEYS.has(key) || SSH_ONLY_TRANSPORT_KEYS.has(key) || key === 'startupCommand' || key === 'deviceType')) continue;
if (key === 'x11Forwarding' && !inheritsX11Forwarding) continue;
if (key === 'password' && effective.savePassword === false) continue;
if (key === 'telnetIdentityId' && hostHasManualTelnetCredentials) continue;
if (key === 'proxyProfileId') {
+1
View File
@@ -427,6 +427,7 @@ export interface GroupConfig {
protocol?: 'ssh' | 'telnet';
deviceType?: 'general' | 'network';
agentForwarding?: boolean;
x11Forwarding?: boolean;
proxyProfileId?: string;
proxyConfig?: ProxyConfig;
hostChain?: HostChainConfig;
+40
View File
@@ -3,6 +3,7 @@ import test from "node:test";
import type { GroupConfig, Host, ProxyProfile, TerminalSession } from "./models";
import {
resolveEffectiveTerminalHost,
resolveTerminalChainHosts,
resolveTerminalSessionHost,
} from "./terminalHostResolution";
@@ -84,6 +85,45 @@ test("resolveTerminalSessionHost applies group default proxy profiles before ope
assert.deepEqual(resolved.proxyConfig, proxyProfiles[0].config);
});
test("resolveEffectiveTerminalHost applies the nearest group X11 default on the connect path", () => {
const host: Host = {
id: "target",
label: "Target",
hostname: "target.example.test",
username: "alice",
port: 22,
protocol: "ssh",
tags: [],
os: "linux",
group: "prod/web",
};
const groupConfigs: GroupConfig[] = [
{ path: "prod", x11Forwarding: true },
{ path: "prod/edge", x11Forwarding: false },
];
assert.equal(
resolveEffectiveTerminalHost({ host, groupConfigs, proxyProfiles: [] }).x11Forwarding,
true,
);
assert.equal(
resolveEffectiveTerminalHost({
host: { ...host, x11Forwarding: false },
groupConfigs,
proxyProfiles: [],
}).x11Forwarding,
false,
);
assert.equal(
resolveEffectiveTerminalHost({
host: { ...host, group: "prod/edge/api" },
groupConfigs,
proxyProfiles: [],
}).x11Forwarding,
false,
);
});
test("resolveTerminalSessionHost defaults missing saved remote sessions to SSH", () => {
const resolved = resolveTerminalSessionHost({
session: {