Compare commits

...
33 changed files with 379 additions and 129 deletions

No files matched your search

@@ -225,3 +225,9 @@ test('AppShell uses default memo (store-driven), not always-rerender comparator'
assert.match(appShellSource, /memo\s*\(\s*AppShellView\s*\)/);
assert.doesNotMatch(appShellSource, /memo\s*\(\s*AppShellView\s*,\s*\(\s*\)\s*=>\s*false\s*\)/);
});
test('terminal system detection routes through the owner of temporary hosts', () => {
const sideEffects = readFileSync(join(here, 'AppSideEffects.tsx'), 'utf8');
assert.match(hostSources['TerminalHost.tsx'], /updateHostDistro: handlers\.updateTerminalHostDistro/);
assert.match(sideEffects, /if \(ephemeralHostIds\.has\(hostId\)\) \{\s*setEphemeralHosts\([\s\S]*?applyEphemeralHostDistroUpdate[\s\S]*?return;\s*\}\s*updateHostDistro\(hostId, distro\)/);
});
+12 -1
View File
@@ -43,7 +43,7 @@ import { materializeHostProxyProfile } from '../../domain/proxyProfiles';
import { buildSshDeepLinkConnectionHost, buildSshDeepLinkEphemeralHost, buildSshDeepLinkEphemeralHostFromSaved, buildSshDeepLinkHostDraft, findSshDeepLinkHost, parseSshDeepLink } from '../../domain/sshDeepLink';
import { buildTelnetDeepLinkConnectionHost, buildTelnetDeepLinkEphemeralHostFromSaved, buildTelnetDeepLinkOpenHost, findTelnetDeepLinkHost, materializeTelnetDeepLinkMatchHost, parseTelnetDeepLink } from '../../domain/telnetDeepLink';
import { buildJmsDeepLinkEphemeralHost, isSupportedJmsProtocol, parseJmsDeepLink } from '../../domain/jmsDeepLink';
import { applyEphemeralHostsUpdate, splitHostsUpdateByEphemeral } from '../../domain/ephemeralHosts';
import { applyEphemeralHostDistroUpdate, applyEphemeralHostsUpdate, splitHostsUpdateByEphemeral } from '../../domain/ephemeralHosts';
import { resolveHostAuth } from '../../domain/sshAuth';
import { isEncryptedCredentialPlaceholder, stripSyncPayloadEncryptedCredentials } from '../../domain/credentials';
import {
@@ -202,6 +202,7 @@ export function AppSideEffects() {
addConnectionLog,
updateConnectionLog,
updateHostLastConnected,
updateHostDistro,
importDataFromString,
readPersistedHosts,
groupConfigs,
@@ -1751,6 +1752,14 @@ export function AppSideEffects() {
updateHosts(vaultHosts);
}, [ephemeralHostIds, updateHosts]);
const updateTerminalHostDistro = useCallback((hostId: string, distro: string) => {
if (ephemeralHostIds.has(hostId)) {
setEphemeralHosts((previous) => applyEphemeralHostDistroUpdate(previous, hostId, distro));
return;
}
updateHostDistro(hostId, distro);
}, [ephemeralHostIds, updateHostDistro]);
// Wrapper to create serial session with logging
const handleConnectSerial = useCallback((config: SerialConfig, options?: { charset?: string }) => {
const { username, hostname } = systemInfoRef.current;
@@ -1886,6 +1895,7 @@ export function AppSideEffects() {
handleTerminalDataCapture,
handleUpdateHostFromTerminal,
updateTerminalHosts,
updateTerminalHostDistro,
runSnippet: handleRunSnippet,
splitSessionWithCurrentShell,
toggleScriptsSidePanelRef,
@@ -1962,6 +1972,7 @@ export function AppSideEffects() {
handleTerminalDataCapture,
handleUpdateHostFromTerminal,
updateTerminalHosts,
updateTerminalHostDistro,
handleRunSnippet,
splitSessionWithCurrentShell,
handleEndSessionDrag,
+1 -4
View File
@@ -20,7 +20,6 @@ import {
import { useThemeRuntime, useTerminalAppearanceInjection } from '../../state/useThemeRuntime';
import {
useVaultSnapshot,
useVaultSnapshotActions,
} from '../../state/vaultSnapshotStore';
import { getAppHandlers, subscribeAppHandlers } from '../appHandlersBridge';
import { publishAppShellDomainSlice } from '../appShellPropsStore';
@@ -37,7 +36,6 @@ export function TerminalHost() {
const session = useSessionSnapshot();
const sessionActions = useSessionSnapshotActions();
const vault = useVaultSnapshot();
const vaultActions = useVaultSnapshotActions();
const terminalSettings = useTerminalSettingsStore();
const terminalSettingsActions = useTerminalSettingsActions();
const {
@@ -247,7 +245,7 @@ export function TerminalHost() {
terminalPaneMagnificationRef: handlers.terminalPaneMagnificationRef,
sftpPaneMagnificationRef: handlers.sftpPaneMagnificationRef,
toggleWorkspaceViewMode: sessionActions?.toggleWorkspaceViewMode,
updateHostDistro: vaultActions?.updateHostDistro,
updateHostDistro: handlers.updateTerminalHostDistro,
updateSplitSizes: sessionActions?.updateSplitSizes,
updateSessionFontSize: sessionActions?.updateSessionFontSize,
updateSessionRestoreCwd: sessionActions?.updateSessionRestoreCwd,
@@ -275,7 +273,6 @@ export function TerminalHost() {
terminalHosts,
terminalSettings,
terminalSettingsActions,
vaultActions,
]);
useLayoutEffect(() => {
+7 -2
View File
@@ -710,8 +710,13 @@ export const enVaultMessages: Messages = {
'hostDetails.section.address': 'Address',
'hostDetails.hostname.placeholder': 'IP or Hostname',
'hostDetails.section.general': 'General',
'hostDetails.os.title': 'Operating System',
'hostDetails.os.desc': 'Used for environment reporting and shell-aware behavior.',
'hostDetails.os.title': 'System identification',
'hostDetails.os.desc': 'Prefer automatic detection. Override only when the detected system is incorrect. Network Device Mode is configured separately.',
'hostDetails.os.auto': 'Automatic',
'hostDetails.os.unknown': 'Other / unknown',
'hostDetails.os.selection': 'System selection',
'hostDetails.os.current': 'Current system',
'hostDetails.os.freebsd': 'FreeBSD',
'hostDetails.os.linux': 'Linux',
'hostDetails.os.windows': 'Windows',
'hostDetails.os.macos': 'macOS',
+7 -2
View File
@@ -710,8 +710,13 @@ export const esVaultMessages: Messages = {
'hostDetails.section.address': 'Dirección',
'hostDetails.hostname.placeholder': 'IP o nombre de host',
'hostDetails.section.general': 'General',
'hostDetails.os.title': 'Sistema operativo',
'hostDetails.os.desc': 'Se usa para informar del entorno y adaptar el comportamiento del shell.',
'hostDetails.os.title': 'Identificación del sistema',
'hostDetails.os.desc': 'Usa la detección automática. Selecciona un sistema solo si la detección es incorrecta. El modo de dispositivo de red se configura por separado.',
'hostDetails.os.auto': 'Automático',
'hostDetails.os.unknown': 'Otro / desconocido',
'hostDetails.os.selection': 'Selección del sistema',
'hostDetails.os.current': 'Sistema actual',
'hostDetails.os.freebsd': 'FreeBSD',
'hostDetails.os.linux': 'Linux',
'hostDetails.os.windows': 'Windows',
'hostDetails.os.macos': 'macOS',
+7 -2
View File
@@ -747,8 +747,13 @@ export const ruVaultMessages: Messages = {
'hostDetails.section.address': 'Адрес',
'hostDetails.hostname.placeholder': 'IP или имя хоста',
'hostDetails.section.general': 'Общие',
'hostDetails.os.title': 'Операционная система',
'hostDetails.os.desc': 'Используется для сведений о среде и адаптации командной оболочки.',
'hostDetails.os.title': 'Определение системы',
'hostDetails.os.desc': 'Используйте автоматическое определение. Укажите систему вручную только при ошибке. Режим сетевого устройства настраивается отдельно.',
'hostDetails.os.auto': 'Автоматически',
'hostDetails.os.unknown': 'Другая / неизвестно',
'hostDetails.os.selection': 'Выбор системы',
'hostDetails.os.current': 'Текущая система',
'hostDetails.os.freebsd': 'FreeBSD',
'hostDetails.os.linux': 'Linux',
'hostDetails.os.windows': 'Windows',
'hostDetails.os.macos': 'macOS',
+7 -2
View File
@@ -133,8 +133,13 @@ export const zhCNVaultMessages: Messages = {
'hostDetails.section.address': '地址',
'hostDetails.hostname.placeholder': 'IP 或 主机名',
'hostDetails.section.general': '通用',
'hostDetails.os.title': '操作系统',
'hostDetails.os.desc': '用于环境信息和适配对应的命令行行为。',
'hostDetails.os.title': '系统识别',
'hostDetails.os.desc': '默认自动识别,仅在识别错误时手动指定。网络设备模式单独设置。',
'hostDetails.os.auto': '自动识别',
'hostDetails.os.unknown': '其他 / 未知',
'hostDetails.os.selection': '识别方式',
'hostDetails.os.current': '当前系统',
'hostDetails.os.freebsd': 'FreeBSD',
'hostDetails.os.linux': 'Linux',
'hostDetails.os.windows': 'Windows',
'hostDetails.os.macos': 'macOS',
+7 -2
View File
@@ -132,8 +132,13 @@ export const zhTWVaultMessages: Messages = {
'hostDetails.section.address': '位址',
'hostDetails.hostname.placeholder': 'IP 或 主機名',
'hostDetails.section.general': '通用',
'hostDetails.os.title': '作業系統',
'hostDetails.os.desc': '用於環境資訊和配合對應的命令列行為。',
'hostDetails.os.title': '系統識別',
'hostDetails.os.desc': '預設自動辨識,僅在辨識錯誤時手動指定。網路裝置模式另行設定。',
'hostDetails.os.auto': '自動辨識',
'hostDetails.os.unknown': '其他 / 未知',
'hostDetails.os.selection': '辨識方式',
'hostDetails.os.current': '目前系統',
'hostDetails.os.freebsd': 'FreeBSD',
'hostDetails.os.linux': 'Linux',
'hostDetails.os.windows': 'Windows',
'hostDetails.os.macos': 'macOS',
+49 -15
View File
@@ -1,3 +1,4 @@
import { getHostOsSelection, HOST_OS_SELECTIONS, resolveHostOs } from '../domain/host';
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";
@@ -350,26 +351,59 @@ export const HostDetailsAdvancedSections: React.FC<HostDetailsAdvancedSectionsPr
</HostDetailsSection>
)}
{/* Network Device Mode — only for SSH hosts without Mosh / ET (serial already uses raw mode) */}
{(!form.protocol || form.protocol === 'ssh') && !form.moshEnabled && !form.etEnabled && (
{(!form.protocol || ['ssh', 'telnet', 'mosh', 'et'].includes(form.protocol)) && (
<HostDetailsSection
icon={<Router size={14} className="text-muted-foreground" />}
title={t("hostDetails.section.deviceType")}
>
<ToggleRow
label={t("hostDetails.deviceType")}
hint={t("hostDetails.deviceType.desc")}
enabled={effectiveDeviceType === 'network'}
onToggle={() => update("deviceType", effectiveDeviceType === 'network' ? 'general' : 'network')}
/>
{effectiveDeviceType === 'network' && (
<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.deviceType.warning")}
{(!form.protocol || form.protocol === 'ssh') && !form.moshEnabled && !form.etEnabled && (<>
<ToggleRow
label={t("hostDetails.deviceType")}
hint={t("hostDetails.deviceType.desc")}
enabled={effectiveDeviceType === 'network'}
onToggle={() => update("deviceType", effectiveDeviceType === 'network' ? 'general' : 'network')}
/>
{effectiveDeviceType === 'network' && (
<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.deviceType.warning")}
</p>
</div>
)}
</>)}
<Collapsible>
<CollapsibleTrigger asChild>
<Button
variant="ghost"
className="group w-full justify-between h-8 px-2 hover:bg-accent/50"
aria-label={t("hostDetails.os.title")}
>
<span className="text-xs font-medium text-muted-foreground">{t("hostDetails.os.title")}</span>
<span className="flex items-center gap-1.5 text-xs text-muted-foreground">
{t(`hostDetails.os.${getHostOsSelection(form)}`)}
<ChevronDown size={14} className="group-data-[state=open]:rotate-180" />
</span>
</Button>
</CollapsibleTrigger>
<CollapsibleContent className="mt-2 space-y-2">
<HostDetailsSettingRow label={t("hostDetails.os.selection")} hint={t("hostDetails.os.desc")}>
<Select value={getHostOsSelection(form)} onValueChange={(value) => update("osOverride", value)}>
<SelectTrigger className="h-8 w-36 text-xs" aria-label={t("hostDetails.os.selection")}>
<SelectValue />
</SelectTrigger>
<SelectContent>
{HOST_OS_SELECTIONS.map((value) => (
<SelectItem key={value} value={value}>{t(`hostDetails.os.${value}`)}</SelectItem>
))}
</SelectContent>
</Select>
</HostDetailsSettingRow>
<p className="text-xs text-muted-foreground">
{t("hostDetails.os.current")}: {t(`hostDetails.os.${resolveHostOs({ ...form, deviceType: effectiveDeviceType })}`)}
</p>
</div>
)}
</CollapsibleContent>
</Collapsible>
</HostDetailsSection>
)}
@@ -11,7 +11,6 @@ import {
applyEffectiveHostAuthMethodSelection,
detachEffectiveHostIdentity,
HOST_AUTH_METHOD_CHOICES,
HOST_OS_CHOICES,
HostDetailsConnectionSections,
removeSelectedHostCredential,
shouldForceAuthMethodReselect,
@@ -112,26 +111,9 @@ test("color and icon settings render for non-Linux hosts", () => {
assert.match(markup, /hostDetails\.icon\.manualLabel/);
});
test("host operating system can be selected in the ordinary host editor", () => {
test("basic connection settings do not require operating system selection", () => {
const markup = renderConnectionSections({ os: "windows" });
assert.deepEqual(
HOST_OS_CHOICES.map(([value, labelKey]) => [value, labelKey]),
[
["linux", "hostDetails.os.linux"],
["windows", "hostDetails.os.windows"],
["macos", "hostDetails.os.macos"],
],
);
assert.match(markup, /hostDetails\.os\.title/);
assert.match(markup, /role="combobox"[^>]*aria-label="hostDetails\.os\.title"/);
assert.match(markup, />hostDetails\.os\.windows<\/span>/);
const source = fs.readFileSync(
path.join(path.dirname(fileURLToPath(import.meta.url)), "HostDetailsConnectionSections.tsx"),
"utf8",
);
assert.match(source, /onValueChange=\{\(value\) => update\("os", value as Host\["os"\]\)\}/);
assert.doesNotMatch(markup, /hostDetails\.os\./);
});
test("host credentials expose automatic and password-only choices", () => {
@@ -23,12 +23,6 @@ export const HOST_AUTH_METHOD_CHOICES = [
["certificate", "hostDetails.auth.certificate"],
] as const;
export const HOST_OS_CHOICES = [
["linux", "hostDetails.os.linux"],
["windows", "hostDetails.os.windows"],
["macos", "hostDetails.os.macos"],
] as const;
export function shouldForceAuthMethodReselect(
nextMethod: "auto" | "password" | "key" | "certificate",
currentMethod: "auto" | "password" | "key" | "certificate",
@@ -131,12 +125,6 @@ export const HostDetailsConnectionSections: React.FC<HostDetailsConnectionSectio
const selectedAuthMethodLabel = t(
HOST_AUTH_METHOD_CHOICES.find(([value]) => value === resolvedAuthMethod)?.[1] ?? "hostDetails.auth.auto",
);
const resolvedHostOs = HOST_OS_CHOICES.some(([value]) => value === form.os)
? form.os as Host["os"]
: "linux";
const selectedHostOsLabel = t(
HOST_OS_CHOICES.find(([value]) => value === resolvedHostOs)?.[1] ?? "hostDetails.os.linux",
);
const effectiveEtEnabled = form.etEnabled ?? groupDefaults?.etEnabled;
const effectiveProtocol = form.protocol ?? groupDefaults?.protocol;
@@ -152,31 +140,6 @@ export const HostDetailsConnectionSections: React.FC<HostDetailsConnectionSectio
onChange={(e) => update("hostname", e.target.value)}
className="h-10 w-full"
/>
<HostDetailsSettingRow
label={t("hostDetails.os.title")}
hint={t("hostDetails.os.desc")}
>
<Select
value={resolvedHostOs}
onValueChange={(value) => update("os", value as Host["os"])}
>
<SelectTrigger
className="h-8 w-32 gap-2"
aria-label={t("hostDetails.os.title")}
>
<span className="min-w-0 flex-1 truncate text-left">
{selectedHostOsLabel}
</span>
</SelectTrigger>
<SelectContent>
{HOST_OS_CHOICES.map(([value, labelKey]) => (
<SelectItem key={value} value={value}>
{t(labelKey)}
</SelectItem>
))}
</SelectContent>
</Select>
</HostDetailsSettingRow>
</HostDetailsSection>
<HostDetailsSection
+6 -2
View File
@@ -1,3 +1,4 @@
import { resolveHostOs } from '../domain/host';
import { Terminal as XTerm } from "@xterm/xterm";
import { FitAddon } from "@xterm/addon-fit";
import { SerializeAddon } from "@xterm/addon-serialize";
@@ -1099,12 +1100,15 @@ const TerminalComponent: React.FC<TerminalProps> = ({
// hosts (TerminalLayer) and saved-host defaults (HostDetailsPanel) both
// stamp os: "linux", which mis-routes the autocomplete clear sequence to
// Ctrl-U on Windows where cmd/PowerShell render it literally (#1112).
const autocompleteHostOs: "linux" | "windows" | "macos" = host.protocol === "local"
const resolvedAutocompleteOs = host.protocol === "local"
? detectLocalOs(navigator.userAgent || navigator.platform)
: (host.os || "linux");
: resolveHostOs(host);
const autocompleteHostOs = resolvedAutocompleteOs === "windows" || resolvedAutocompleteOs === "macos"
? resolvedAutocompleteOs : "linux";
const autocompleteSettings = resolveTerminalAutocompleteSettings({
protocol: effectiveTerminalProtocol,
terminalSettings,
systemUnknown: resolvedAutocompleteOs === 'unknown',
isNetworkDevice: host.deviceType === 'network'
|| classifyDistroId(host.distro) === 'network-device',
});
@@ -18,10 +18,11 @@ export function resolveTerminalAutocompleteSettings(input: {
terminalSettings?: TerminalAutocompleteSettingFields;
/** Vendor CLI / network-device session: skip live-preview PTY rewrites (#1193). */
isNetworkDevice?: boolean;
systemUnknown?: boolean;
}): Partial<AutocompleteSettings> | undefined {
const { protocol, terminalSettings, isNetworkDevice } = input;
const { protocol, terminalSettings, isNetworkDevice, systemUnknown } = input;
if (protocol === "serial") {
if (protocol === "serial" || systemUnknown) {
return {
enabled: terminalSettings?.autocompleteEnabled ?? true,
showGhostText: terminalSettings?.autocompleteGhostText ?? true,
@@ -16,6 +16,11 @@ const armSudoPrompt = (
};
test("startMosh enables sudo autofill with the host saved password", async () => {
let detected = "";
let probes = 0;
let ready: (() => void) | undefined;
let resolveDetected!: () => void;
const detectionDone = new Promise<void>((resolve) => { resolveDetected = resolve; });
let onData: ((data: string) => void) | null = null;
const sent: string[] = [];
const terminalBackend = {
@@ -28,6 +33,8 @@ test("startMosh enables sudo autofill with the host saved password", async () =>
startSSHSession: async () => "ssh-session",
startTelnetSession: async () => "telnet-session",
startMoshSession: async () => "mosh-session",
onMoshSessionReady: (_id: string, callback: () => void) => { ready = callback; return noop; },
getSessionDistroInfo: async () => { probes++; return { success: true, stdout: "ID=ubuntu" }; },
startLocalSession: async () => "local-session",
startSerialSession: async () => "serial-session",
execCommand: async () => ({}),
@@ -49,6 +56,7 @@ test("startMosh enables sudo autofill with the host saved password", async () =>
username: "alice",
password: "saved-secret",
},
onOsDetected: (_id: string, distro: string) => { detected = distro; resolveDetected(); },
keys: [],
identities: [],
resolvedChainHosts: [],
@@ -85,6 +93,11 @@ test("startMosh enables sudo autofill with the host saved password", async () =>
};
await createTerminalSessionStarters(ctx as never).startMosh(term as never);
assert.equal(probes, 0, "wait for the Mosh handshake before probing");
ready?.();
await detectionDone;
assert.equal(detected, "ubuntu");
assert.equal(probes, 1);
onData?.(armSudoPrompt(sudoAutofillRef.current));
sudoAutofillRef.current?.confirmFill();
@@ -1208,7 +1208,13 @@ export const createTerminalSessionStarters = (ctx: TerminalSessionStartersContex
cancelPendingStartupCommand?.();
cancelPendingStartupCommand = undefined;
};
const detectMoshSystem = () => {
if (!isCurrentAttempt()) return;
const token = registerConnectionToken(attachedSessionId);
void runDistroDetection(ctx, attachedSessionId, token);
};
const runMoshStartup = () => {
detectMoshSystem();
disposeMoshReady?.();
disposeMoshReady = undefined;
ctx.setIsConnectionAwaitingUserInput?.(false);
@@ -1298,7 +1304,8 @@ export const createTerminalSessionStarters = (ctx: TerminalSessionStartersContex
runMoshStartup();
}
} else {
// Older bridges without the ready event: keep previous behavior.
// Older bridges without the ready event: the start call completed the handshake.
detectMoshSystem();
scheduleStartupCommand(ctx, term, id);
}
} catch (err) {
@@ -106,3 +106,43 @@ test("runDistroDetection normalizes FreeBSD uname output", async () => {
assert.deepEqual(detected, ["freebsd"]);
});
test("Windows OpenSSH is identified without sending POSIX probes", async () => {
const detected: string[] = [];
await runDistroDetection({
host: { id: 'windows', os: 'linux' },
terminalBackend: {
getSessionRemoteInfo: async () => ({ success: true, remoteSshVersion: 'SSH-2.0-OpenSSH_for_Windows_9.5' }),
getSessionDistroInfo: async () => { throw new Error('must not probe Windows with POSIX commands'); },
},
onOsDetected: (_id: string, distro: string) => detected.push(distro),
} as never, 'windows', registerConnectionToken('windows'));
assert.deepEqual(detected, ['windows']);
});
test("failed or unrecognized probes do not invent an operating system", async () => {
const detected: string[] = [];
await runDistroDetection({
host: { id: 'unknown', os: 'linux' },
terminalBackend: {
getSessionDistroInfo: async () => ({ success: true, stdout: '', stderr: 'Linux command not found' }),
},
onOsDetected: (_id: string, distro: string) => detected.push(distro),
} as never, 'unknown', registerConnectionToken('unknown'));
assert.deepEqual(detected, []);
});
test("a superseded Windows detection cannot update the newer connection", async () => {
const detected: string[] = [];
await runDistroDetection({
host: { id: 'windows', os: 'linux' },
terminalBackend: {
getSessionRemoteInfo: async () => {
registerConnectionToken('reconnected');
return { success: true, remoteSshVersion: 'OpenSSH_for_Windows_9.5' };
},
},
onOsDetected: (_id: string, distro: string) => detected.push(distro),
} as never, 'reconnected', registerConnectionToken('reconnected'));
assert.deepEqual(detected, []);
});
@@ -63,6 +63,10 @@ export const runDistroDetection = async (
if (ctx.terminalBackend.getSessionRemoteInfo && sessionId) {
const info = await ctx.terminalBackend.getSessionRemoteInfo(sessionId);
if (!isStillCurrent()) return;
if (!isKnownNetworkDevice && /^(?:SSH-(?:2\.0|1\.99)-)?OpenSSH_for_Windows(?:_|$)/i.test(info?.remoteSshVersion || '')) {
ctx.onOsDetected?.(ctx.host.id, 'windows');
return;
}
const vendor = detectVendorFromSshVersion(info?.remoteSshVersion);
if (vendor) {
ctx.onOsDetected?.(ctx.host.id, vendor);
@@ -89,12 +93,13 @@ export const runDistroDetection = async (
const res = await ctx.terminalBackend.getSessionDistroInfo(sessionId);
if (!isStillCurrent()) return;
if (!res?.success) return;
const data = `${res.stdout || ""}\n${res.stderr || ""}`;
const data = (res.stdout || "").trim();
const idMatch = data.match(/^ID="?([\w-]+)"?$/im);
const rawDistro = idMatch
? idMatch[1]
: (data.split(/\s+/)[0] || "").toLowerCase();
const distro = normalizeDistroId(rawDistro) || rawDistro;
: (data.match(/^(Linux|Darwin|FreeBSD)\b/i)?.[1] || "").toLowerCase();
// An os-release ID confirms Linux even for distributions without a dedicated icon.
const distro = normalizeDistroId(rawDistro) || (idMatch ? 'linux' : '');
if (distro) ctx.onOsDetected?.(ctx.host.id, distro);
}
} catch (err) {
@@ -126,3 +126,12 @@ test("falls back to raised maxSuggestions default when unset", () => {
50,
);
});
test('unknown systems do not rewrite the command line while retaining suggestions', () => {
for (const terminalSettings of [undefined, { autocompleteEnabled: true }]) {
const settings = resolveTerminalAutocompleteSettings({protocol:'ssh', systemUnknown:true, terminalSettings});
assert.equal(settings?.enabled, true);
assert.equal(settings?.livePreview, false);
assert.equal(settings?.allowLineReplacement, false);
}
});
+2 -2
View File
@@ -1,4 +1,4 @@
import { classifyDistroId } from './host';
import { classifyDistroId, resolveHostOs } from './host';
import type { PortForwardingRule } from './models';
import type { Host, TerminalSession } from '../types';
@@ -83,7 +83,7 @@ export const buildAITerminalSessionInfo = (
hostId: session?.hostId || '',
hostname: host?.hostname || session?.hostname || '',
label: host?.label || session?.hostLabel || '',
os: host?.os || (isLocalSession ? localOs : undefined),
os: isLocalSession ? localOs : resolveHostOs(host),
username: host?.username || session?.username,
protocol,
shellType: session?.shellType && session.shellType !== 'unknown' ? session.shellType : undefined,
+16 -1
View File
@@ -1,7 +1,7 @@
import assert from "node:assert/strict";
import test from "node:test";
import { applyEphemeralHostsUpdate, isSavedVaultHost, splitHostsUpdateByEphemeral } from "./ephemeralHosts";
import { applyEphemeralHostDistroUpdate, applyEphemeralHostsUpdate, isSavedVaultHost, splitHostsUpdateByEphemeral } from "./ephemeralHosts";
import type { Host } from "./models";
const makeHost = (id: string, overrides: Partial<Host> = {}): Host => ({
@@ -60,3 +60,18 @@ test("isSavedVaultHost is false for missing or ephemeral hosts", () => {
assert.equal(isSavedVaultHost(null), false);
assert.equal(isSavedVaultHost(undefined), false);
});
test("temporary host detection reaches AI without changing other hosts or reviving closed sessions", async () => {
const { buildAITerminalSessionInfo } = await import('./buildAITerminalSessionInfo');
const other = makeHost('other');
for (const [distro, os] of [['ubuntu', 'linux'], ['darwin', 'macos'], ['windows', 'windows']]) {
const target = makeHost('quick-connect', { ephemeral: true });
const next = applyEphemeralHostDistroUpdate([target, other], target.id, distro);
assert.equal(buildAITerminalSessionInfo(undefined, next[0], 'macos').os, os);
assert.equal(next[0].ephemeral, true);
assert.equal(next[1], other);
assert.equal(applyEphemeralHostDistroUpdate(next, target.id, distro), next);
const closed = [other];
assert.equal(applyEphemeralHostDistroUpdate(closed, target.id, distro), closed);
}
});
+8
View File
@@ -1,3 +1,4 @@
import { normalizeDistroId } from "./host";
import type { Host } from "./models";
/**
@@ -37,3 +38,10 @@ export const applyEphemeralHostsUpdate = (
const updatedById = new Map(updated.map((host) => [host.id, host]));
return previous.map((host) => updatedById.get(host.id) ?? host);
};
/** Detection updates stay in memory and never create a host after its session closes. */
export const applyEphemeralHostDistroUpdate = (previous: Host[], hostId: string, distro: string): Host[] => {
const normalized = normalizeDistroId(distro);
if (!previous.some((host) => host.id === hostId && host.distro !== normalized)) return previous;
return previous.map((host) => host.id === hostId ? { ...host, distro: normalized } : host);
};
+28
View File
@@ -1,4 +1,5 @@
import { Host, Snippet, TerminalSettings } from './models';
import type { HostOperatingSystem, HostOsSelection } from './models/connection';
import { sanitizeHostIconFields } from './hostIcon';
import { migrateHostConnectScriptIds } from './hostConnectScripts.ts';
import { migrateDeprecatedFontOverride } from '../infrastructure/config/fonts';
@@ -105,6 +106,7 @@ export const normalizeDistroId = (value?: string) => {
) {
return 'macos';
}
if (v === 'windows' || v === 'win32' || /^openssh_for_windows(?:_|$)/.test(v)) return 'windows';
if (v.includes('freebsd')) return 'freebsd';
if (v.includes('ubuntu')) return 'ubuntu';
if (v.includes('debian')) return 'debian';
@@ -215,6 +217,29 @@ export const classifyDistroId = (distroId?: string): DeviceClass => {
return 'other';
};
export const HOST_OS_SELECTIONS = ['auto', 'linux', 'windows', 'macos', 'freebsd', 'unknown'] as const;
export function getHostOsSelection(host?: Pick<Host, 'os' | 'osOverride'> | null): HostOsSelection {
if (host?.osOverride && (HOST_OS_SELECTIONS as readonly string[]).includes(host.osOverride)) {
return host.osOverride;
}
return host?.os === 'windows' || host?.os === 'macos' ? host.os : 'auto';
}
/** Resolve system facts separately from cosmetic manualDistro/icon choices. */
export function resolveHostOs(
host?: Pick<Host, 'os' | 'osOverride' | 'distro' | 'deviceType' | 'protocol'> | null,
): HostOperatingSystem {
if (host?.protocol === 'local') return host.os;
const selection = getHostOsSelection(host);
if (selection !== 'auto') return selection;
if (host?.deviceType === 'network' || classifyDistroId(host?.distro) === 'network-device') return 'unknown';
const distro = normalizeDistroId(host?.distro);
if (distro === 'windows' || distro === 'macos' || distro === 'freebsd') return distro;
if ((LINUX_DISTRO_OPTIONS as readonly string[]).includes(distro)) return 'linux';
return 'unknown';
}
/**
* Decide whether to offer the "enable Network Device Mode" suggestion after
* distro/vendor detection. We only nag once per host, and never when the user
@@ -430,6 +455,7 @@ export const sanitizeHost = (host: Host, snippets: Snippet[] = []): Host => {
? 'auto'
: undefined;
const cleanHostIcon = sanitizeHostIconFields(host);
const osSelection = getHostOsSelection(host);
const migrated = migrateDeprecatedFontOverride(host);
// Before explicit per-host authentication modes existed, new hosts were
// persisted with authMethod="password" even though an empty password still
@@ -468,6 +494,8 @@ export const sanitizeHost = (host: Host, snippets: Snippet[] = []): Host => {
: migrated.authMethod ?? inferredLegacyAuthMethod,
authPolicyVersion: 1,
hostname: cleanHostname,
osOverride: osSelection,
os: osSelection === 'linux' || osSelection === 'windows' || osSelection === 'macos' ? osSelection : host.os,
distro: cleanDistro,
distroMode: cleanDistroMode,
manualDistro: cleanManualDistro || undefined,
+64
View File
@@ -0,0 +1,64 @@
import assert from 'node:assert/strict';
import test from 'node:test';
import { getHostOsSelection, resolveHostOs, sanitizeHost } from './host';
import { buildAITerminalSessionInfo } from './buildAITerminalSessionInfo';
import { applyVaultHostUpdate, buildVaultHostFromDraft } from './vaultHostCreate';
import type { Host } from './models';
const host = (changes: Partial<Host> = {}): Host => ({
id: 'host', label: 'Host', hostname: 'example.test', username: 'user', tags: [], os: 'linux', ...changes,
});
test('legacy Linux defaults are not evidence; Windows and macOS corrections survive loading', () => {
for (const os of ['linux', 'windows', 'macos'] as const) {
const restored = sanitizeHost(host({ os }));
assert.equal(getHostOsSelection(restored), os === 'linux' ? 'auto' : os);
assert.equal(resolveHostOs(restored), os === 'linux' ? 'unknown' : os);
assert.deepEqual(sanitizeHost(restored), restored);
}
});
test('detected facts drive AI and ignore cosmetic icon overrides', () => {
for (const [distro, expected] of [['ubuntu','linux'],['windows','windows'],['darwin','macos'],['freebsd','freebsd'],['cisco','unknown']]) {
const target = host({ distro, manualDistro: 'macos', distroMode: 'manual' });
assert.equal(resolveHostOs(target), expected);
assert.equal(buildAITerminalSessionInfo(undefined, target, 'macos').os, expected);
}
});
test('explicit corrections survive detection and can return to automatic', () => {
const target = host({ os: 'windows', osOverride: 'linux', distro: 'windows' });
assert.equal(resolveHostOs(target), 'linux');
assert.equal(resolveHostOs({ ...target, osOverride: 'auto' }), 'windows');
assert.equal(resolveHostOs({ ...target, osOverride: 'unknown' }), 'unknown');
});
test('network device protection is independent of a chosen operating system', () => {
const target = host({ deviceType: 'network', distro: 'ubuntu' });
assert.equal(resolveHostOs(target), 'unknown');
const info = buildAITerminalSessionInfo(undefined, {...target, osOverride: 'linux'}, 'macos');
assert.equal(info.os, 'linux');
assert.equal(info.deviceType, 'network');
});
test('local sessions report the actual local OS rather than a saved default', () => {
assert.equal(buildAITerminalSessionInfo(undefined, host({protocol:'local'}), 'windows').os, 'windows');
});
test('vault create and update share explicit selection and auto reset', () => {
const created = buildVaultHostFromDraft({hostname:'example.test',username:'user',os:'freebsd'});
assert.equal(created.ok, true);
if (!created.ok) return;
assert.equal(resolveHostOs(created.host), 'freebsd');
const updated = applyVaultHostUpdate([created.host], [], created.host.id, {os:'auto'});
assert.equal(updated.ok, true);
if (!updated.ok) return;
assert.equal(resolveHostOs(updated.updatedHost), 'unknown');
});
test('manual selections retain compatible OS values for older readers', () => {
assert.equal(sanitizeHost(host({osOverride:'windows'})).os, 'windows');
const restored = sanitizeHost(host({os:'windows',osOverride:'auto'}));
assert.equal(getHostOsSelection(restored), 'auto');
assert.equal(resolveHostOs(restored), 'unknown');
});
+6
View File
@@ -151,6 +151,9 @@ export interface SftpBookmark {
export type HostAuthMethod = 'auto' | 'password' | 'key' | 'certificate';
export type HostOperatingSystem = 'linux' | 'windows' | 'macos' | 'freebsd' | 'unknown';
export type HostOsSelection = 'auto' | HostOperatingSystem;
export interface Host {
id: string;
label: string;
@@ -161,7 +164,10 @@ export interface Host {
identityId?: string;
group?: string;
tags: string[];
// Legacy compatibility value; use resolveHostOs for runtime decisions.
os: 'linux' | 'windows' | 'macos';
// Absent on old records: preserve Windows/macOS, treat old Linux defaults as auto.
osOverride?: HostOsSelection;
// 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';
@@ -131,6 +131,7 @@ test("system overview stats run for Linux and macOS targets", () => {
{
id: "host-1",
label: "Linux",
distro: "ubuntu",
hostname: "linux.local",
username: "root",
tags: [],
@@ -172,3 +173,11 @@ test("FreeBSD icon detection does not enable unsupported system features", () =>
assert.equal(shouldCollectServerStats(host, undefined, null), false);
assert.deepEqual(buildSystemManagerTabs(host, undefined, null), ["overview", "processes"]);
});
test('default Linux and cosmetic icons cannot enable Linux commands before detection', () => {
const host = {id:'unknown', label:'Host', hostname:'host', username:'user', tags:[], os:'linux' as const, manualDistro:'ubuntu', distroMode:'manual' as const};
assert.equal(shouldCollectServerStats(host, undefined, null), false);
assert.deepEqual(buildSystemManagerTabs(host, undefined, null), ['overview','processes']);
assert.equal(shouldCollectServerStats({...host,distro:'ubuntu'},undefined,null),true);
assert.equal(shouldCollectServerStats({...host,distro:'ubuntu',osOverride:'windows'},undefined,null),false);
});
+10 -19
View File
@@ -1,13 +1,8 @@
import { classifyDistroId, getEffectiveHostDistro, normalizeDistroId } from '../host';
import { classifyDistroId, resolveHostOs } from '../host';
import type { Host } from '../models/connection';
import type { TerminalSession } from '../../types';
import type { SessionCapabilities, SystemManagerSubTab } from './types';
const hasKnownUnsupportedSystemDistro = (host: Host | null | undefined): boolean => {
const detectedDistro = normalizeDistroId(host?.distro);
return !!detectedDistro && classifyDistroId(detectedDistro) === 'other';
};
export function isNetworkDeviceTarget(host: Host | null | undefined): boolean {
if (host?.deviceType === 'network') return true;
return classifyDistroId(host?.distro) === 'network-device';
@@ -16,14 +11,11 @@ export function isNetworkDeviceTarget(host: Host | null | undefined): boolean {
export function isDefiniteLinuxTarget(
host: Host | null | undefined,
capabilities: SessionCapabilities | undefined,
session: TerminalSession | null | undefined,
_session: TerminalSession | null | undefined,
): boolean {
if (capabilities?.targetOs === 'linux') return true;
if (capabilities?.targetOs && capabilities.targetOs !== 'unknown') return capabilities.targetOs === 'linux';
if (isNetworkDeviceTarget(host)) return false;
if (hasKnownUnsupportedSystemDistro(host)) return false;
if (host?.os === 'linux') return true;
if (classifyDistroId(getEffectiveHostDistro(host)) === 'linux-like') return true;
if (session?.protocol === 'local' && host?.os === 'linux') return true;
if (resolveHostOs(host) === 'linux') return true;
return false;
}
@@ -48,7 +40,7 @@ export function shouldShowTmuxTab(
if (isNetworkDeviceTarget(host)) return capabilities?.hasTmux === true;
if (isDefiniteLinuxTarget(host, capabilities, session)) return true;
if (capabilities?.targetOs === 'darwin') return true;
if (host?.os === 'macos') return true;
if (resolveHostOs(host) === 'macos') return true;
return false;
}
@@ -98,15 +90,14 @@ export function shouldShowServicesTab(
export function shouldCollectServerStats(
host: Host | null | undefined,
capabilities: SessionCapabilities | undefined,
session: TerminalSession | null | undefined,
_session: TerminalSession | null | undefined,
): boolean {
const detectedDeviceClass = classifyDistroId(host?.distro);
if (isNetworkDeviceTarget(host) || detectedDeviceClass === 'network-device') return false;
if (capabilities?.targetOs === 'linux' || capabilities?.targetOs === 'darwin') return true;
if (hasKnownUnsupportedSystemDistro(host)) return false;
if (host?.os === 'linux' || host?.os === 'macos') return true;
if (detectedDeviceClass === 'linux-like') return true;
if (session?.protocol === 'local' && host?.os === 'linux') return true;
if (capabilities?.targetOs && capabilities.targetOs !== 'unknown') {
return capabilities.targetOs === 'linux' || capabilities.targetOs === 'darwin';
}
if (resolveHostOs(host) === 'linux' || resolveHostOs(host) === 'macos') return true;
return false;
}
+9 -5
View File
@@ -1,5 +1,6 @@
import type { GroupConfig, Host, HostProtocol, Identity, ManagedSource, ProxyProfile } from './models';
import { sanitizeHost } from './host';
import type { HostOsSelection } from './models/connection';
import {
findIntroducedVaultJumpGraphIssue,
findVaultGroupConfigJumpReference,
@@ -93,9 +94,10 @@ const normalizeProtocol = (raw: unknown): VaultHostDraftProtocol | undefined =>
return undefined;
};
const normalizeOs = (raw: unknown): Host['os'] | undefined => {
const normalizeOs = (raw: unknown): HostOsSelection | undefined => {
if (typeof raw !== 'string') return undefined;
const value = raw.trim().toLowerCase();
if (value === 'auto' || value === 'freebsd' || value === 'unknown') return value;
if (value === 'linux') return 'linux';
if (value === 'windows' || value === 'win') return 'windows';
if (value === 'macos' || value === 'mac' || value === 'osx' || value === 'darwin') return 'macos';
@@ -229,9 +231,9 @@ export function buildVaultHostFromDraft(
&& draft.os !== null
&& !(typeof draft.os === 'string' && !draft.os.trim());
if (hasOsInput && parsedOs === undefined) {
return { ok: false, error: 'os must be linux, windows, or macos.' };
return { ok: false, error: 'os must be auto, linux, windows, macos, freebsd, or unknown.' };
}
const os = parsedOs ?? 'linux';
const os = parsedOs === 'windows' || parsedOs === 'macos' ? parsedOs : 'linux';
const parsedPort = parsePort(draft.port);
const hasPortInput = draft.port !== undefined
&& draft.port !== null
@@ -308,6 +310,7 @@ export function buildVaultHostFromDraft(
group: normalizeGroupPath(draft.group),
tags: tags.tags,
os,
osOverride: parsedOs ?? 'auto',
protocol,
createdAt: now,
...(keyPath
@@ -438,9 +441,10 @@ export function applyVaultHostUpdate(
if (os.provided) {
const nextOs = normalizeOs(os.value);
if (!nextOs) {
return { ok: false, error: 'os must be linux, windows, or macos.' };
return { ok: false, error: 'os must be auto, linux, windows, macos, freebsd, or unknown.' };
}
updated.os = nextOs;
updated.osOverride = nextOs;
updated.os = nextOs === 'windows' || nextOs === 'macos' ? nextOs : 'linux';
}
if (identityId.provided) {
@@ -307,3 +307,21 @@ test("readRemoteHistory reports pending while the Mosh handshake is still in pro
assert.equal(result.pending, true);
assert.equal(session.conn, undefined);
});
test("getSessionDistroInfo uses and reuses the Mosh stats companion", async () => {
const session = { type: "mosh", moshStatsAuth: { hostname: "h" } };
let connections = 0;
const api = makeApi(session, async () => { throw new Error("not ET"); }, {
ensureMoshStatsConnection: async (target) => {
connections++;
target.moshStatsConn = fakeConn('ID=ubuntu');
},
});
for (let i = 0; i < 2; i++) {
const result = await api.getSessionDistroInfo(null, { sessionId: 'et-1' });
assert.equal(result.success, true);
assert.equal(result.stdout, 'ID=ubuntu');
}
assert.equal(connections, 1);
assert.equal(session.conn, undefined);
});
+10 -2
View File
@@ -171,7 +171,15 @@ function createSessionOpsApi(ctx) {
knownHosts: session.etStatsAuth?.knownHosts,
});
}
if (!session || !session.conn) {
if (session?.type === 'mosh' && !session.moshStatsConn && typeof ensureMoshStatsConnection === 'function') {
try {
await ensureMoshStatsConnection(session, sessionId, _event?.sender);
} catch (err) {
return { success: false, error: err?.message || String(err) };
}
}
const conn = session?.conn || session?.moshStatsConn;
if (!conn) {
return { success: false, error: 'Session not found or not connected' };
}
const command = withPosixRemoteWatchdog(
@@ -179,7 +187,7 @@ function createSessionOpsApi(ctx) {
5000,
);
try {
const { stdout, stderr } = await executeBoundedSshCommand(session.conn, command, {
const { stdout, stderr } = await executeBoundedSshCommand(conn, command, {
openingTimeoutMs: 5000,
runTimeoutMs: 5000,
maxOutputBytes: 256 * 1024,
+1 -1
View File
@@ -119,7 +119,7 @@ const TOOL_INPUT_FIELDS = Object.freeze({
tags: { type: "string", optional: true, description: "JSON array or comma-separated tag names. Empty string clears tags." },
notes: { type: "string", optional: true, description: "Host Details remarks. Empty string clears notes." },
protocol: { type: "string", optional: true, description: "New protocol: ssh, telnet, local, or serial." },
os: { type: "string", optional: true, description: "Host operating system: linux, windows, or macos. Drives AI environment reporting and shell-aware behavior." },
os: { type: "string", optional: true, description: "Operating system override: auto (default), linux, windows, macos, freebsd, or unknown. Use auto to use detected system information; network device mode is separate." },
identityId: { type: "string", optional: true, description: "Reusable identity ID from vault_identities_list. Empty string detaches the identity." },
jumpHostIds: { type: "string", optional: true, description: "JSON array of vault host IDs in jump order. Empty array clears the chain." },
proxyProfileId: { type: "string", optional: true, description: "Reusable proxy ID from vault_proxy_profiles_list. Empty string clears it." },
@@ -687,7 +687,7 @@
"os": {
"type": "string",
"optional": true,
"description": "Host operating system: linux, windows, or macos. Drives AI environment reporting and shell-aware behavior."
"description": "Operating system override: auto (default), linux, windows, macos, freebsd, or unknown. Use auto to use detected system information; network device mode is separate."
},
"identityId": {
"type": "string",
@@ -733,7 +733,7 @@
"os": {
"type": "string",
"optional": true,
"description": "Host operating system: linux, windows, or macos. Drives AI environment reporting and shell-aware behavior."
"description": "Operating system override: auto (default), linux, windows, macos, freebsd, or unknown. Use auto to use detected system information; network device mode is separate."
},
"identityId": {
"type": "string",
+4 -2
View File
@@ -1,3 +1,4 @@
import { resolveHostOs } from '../../domain/host';
import type { GroupConfig, Host, Identity, KnownHost, ManagedSource, PortForwardingRule, ProxyProfile, Snippet, SSHKey, TerminalSettings, VaultNote } from '../../domain/models';
import type { RememberImportedKeyPassphraseResult } from '../../application/defaultKeyPassphrases';
import {
@@ -127,6 +128,7 @@ export function sanitizeHostForAgent(host: Host): Record<string, unknown> {
}
sanitized[key] = value;
}
sanitized.os = resolveHostOs(host);
return sanitized;
}
@@ -140,7 +142,7 @@ function summarizeHostForList(host: Host) {
protocol: host.protocol,
group: host.group,
tags: host.tags,
os: host.os,
os: resolveHostOs(host),
createdAt: host.createdAt,
connectScriptIds: host.connectScriptIds,
loginScriptId: host.loginScriptId,
@@ -531,7 +533,7 @@ async function registerOpenedSessionInMcpScope(
hostId: host.id,
hostname: host.hostname || '',
label: host.label || host.hostname || sessionId,
os: host.os || '',
os: resolveHostOs(host),
username: host.username || '',
protocol,
deviceType: host.deviceType || '',