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.match(appShellSource, /memo\s*\(\s*AppShellView\s*\)/);
assert.doesNotMatch(appShellSource, /memo\s*\(\s*AppShellView\s*,\s*\(\s*\)\s*=>\s*false\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 { buildSshDeepLinkConnectionHost, buildSshDeepLinkEphemeralHost, buildSshDeepLinkEphemeralHostFromSaved, buildSshDeepLinkHostDraft, findSshDeepLinkHost, parseSshDeepLink } from '../../domain/sshDeepLink';
import { buildTelnetDeepLinkConnectionHost, buildTelnetDeepLinkEphemeralHostFromSaved, buildTelnetDeepLinkOpenHost, findTelnetDeepLinkHost, materializeTelnetDeepLinkMatchHost, parseTelnetDeepLink } from '../../domain/telnetDeepLink'; import { buildTelnetDeepLinkConnectionHost, buildTelnetDeepLinkEphemeralHostFromSaved, buildTelnetDeepLinkOpenHost, findTelnetDeepLinkHost, materializeTelnetDeepLinkMatchHost, parseTelnetDeepLink } from '../../domain/telnetDeepLink';
import { buildJmsDeepLinkEphemeralHost, isSupportedJmsProtocol, parseJmsDeepLink } from '../../domain/jmsDeepLink'; 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 { resolveHostAuth } from '../../domain/sshAuth';
import { isEncryptedCredentialPlaceholder, stripSyncPayloadEncryptedCredentials } from '../../domain/credentials'; import { isEncryptedCredentialPlaceholder, stripSyncPayloadEncryptedCredentials } from '../../domain/credentials';
import { import {
@@ -202,6 +202,7 @@ export function AppSideEffects() {
addConnectionLog, addConnectionLog,
updateConnectionLog, updateConnectionLog,
updateHostLastConnected, updateHostLastConnected,
updateHostDistro,
importDataFromString, importDataFromString,
readPersistedHosts, readPersistedHosts,
groupConfigs, groupConfigs,
@@ -1751,6 +1752,14 @@ export function AppSideEffects() {
updateHosts(vaultHosts); updateHosts(vaultHosts);
}, [ephemeralHostIds, updateHosts]); }, [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 // Wrapper to create serial session with logging
const handleConnectSerial = useCallback((config: SerialConfig, options?: { charset?: string }) => { const handleConnectSerial = useCallback((config: SerialConfig, options?: { charset?: string }) => {
const { username, hostname } = systemInfoRef.current; const { username, hostname } = systemInfoRef.current;
@@ -1886,6 +1895,7 @@ export function AppSideEffects() {
handleTerminalDataCapture, handleTerminalDataCapture,
handleUpdateHostFromTerminal, handleUpdateHostFromTerminal,
updateTerminalHosts, updateTerminalHosts,
updateTerminalHostDistro,
runSnippet: handleRunSnippet, runSnippet: handleRunSnippet,
splitSessionWithCurrentShell, splitSessionWithCurrentShell,
toggleScriptsSidePanelRef, toggleScriptsSidePanelRef,
@@ -1962,6 +1972,7 @@ export function AppSideEffects() {
handleTerminalDataCapture, handleTerminalDataCapture,
handleUpdateHostFromTerminal, handleUpdateHostFromTerminal,
updateTerminalHosts, updateTerminalHosts,
updateTerminalHostDistro,
handleRunSnippet, handleRunSnippet,
splitSessionWithCurrentShell, splitSessionWithCurrentShell,
handleEndSessionDrag, handleEndSessionDrag,
+1 -4
View File
@@ -20,7 +20,6 @@ import {
import { useThemeRuntime, useTerminalAppearanceInjection } from '../../state/useThemeRuntime'; import { useThemeRuntime, useTerminalAppearanceInjection } from '../../state/useThemeRuntime';
import { import {
useVaultSnapshot, useVaultSnapshot,
useVaultSnapshotActions,
} from '../../state/vaultSnapshotStore'; } from '../../state/vaultSnapshotStore';
import { getAppHandlers, subscribeAppHandlers } from '../appHandlersBridge'; import { getAppHandlers, subscribeAppHandlers } from '../appHandlersBridge';
import { publishAppShellDomainSlice } from '../appShellPropsStore'; import { publishAppShellDomainSlice } from '../appShellPropsStore';
@@ -37,7 +36,6 @@ export function TerminalHost() {
const session = useSessionSnapshot(); const session = useSessionSnapshot();
const sessionActions = useSessionSnapshotActions(); const sessionActions = useSessionSnapshotActions();
const vault = useVaultSnapshot(); const vault = useVaultSnapshot();
const vaultActions = useVaultSnapshotActions();
const terminalSettings = useTerminalSettingsStore(); const terminalSettings = useTerminalSettingsStore();
const terminalSettingsActions = useTerminalSettingsActions(); const terminalSettingsActions = useTerminalSettingsActions();
const { const {
@@ -247,7 +245,7 @@ export function TerminalHost() {
terminalPaneMagnificationRef: handlers.terminalPaneMagnificationRef, terminalPaneMagnificationRef: handlers.terminalPaneMagnificationRef,
sftpPaneMagnificationRef: handlers.sftpPaneMagnificationRef, sftpPaneMagnificationRef: handlers.sftpPaneMagnificationRef,
toggleWorkspaceViewMode: sessionActions?.toggleWorkspaceViewMode, toggleWorkspaceViewMode: sessionActions?.toggleWorkspaceViewMode,
updateHostDistro: vaultActions?.updateHostDistro, updateHostDistro: handlers.updateTerminalHostDistro,
updateSplitSizes: sessionActions?.updateSplitSizes, updateSplitSizes: sessionActions?.updateSplitSizes,
updateSessionFontSize: sessionActions?.updateSessionFontSize, updateSessionFontSize: sessionActions?.updateSessionFontSize,
updateSessionRestoreCwd: sessionActions?.updateSessionRestoreCwd, updateSessionRestoreCwd: sessionActions?.updateSessionRestoreCwd,
@@ -275,7 +273,6 @@ export function TerminalHost() {
terminalHosts, terminalHosts,
terminalSettings, terminalSettings,
terminalSettingsActions, terminalSettingsActions,
vaultActions,
]); ]);
useLayoutEffect(() => { useLayoutEffect(() => {
+7 -2
View File
@@ -710,8 +710,13 @@ export const enVaultMessages: Messages = {
'hostDetails.section.address': 'Address', 'hostDetails.section.address': 'Address',
'hostDetails.hostname.placeholder': 'IP or Hostname', 'hostDetails.hostname.placeholder': 'IP or Hostname',
'hostDetails.section.general': 'General', 'hostDetails.section.general': 'General',
'hostDetails.os.title': 'Operating System', 'hostDetails.os.title': 'System identification',
'hostDetails.os.desc': 'Used for environment reporting and shell-aware behavior.', '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.linux': 'Linux',
'hostDetails.os.windows': 'Windows', 'hostDetails.os.windows': 'Windows',
'hostDetails.os.macos': 'macOS', 'hostDetails.os.macos': 'macOS',
+7 -2
View File
@@ -710,8 +710,13 @@ export const esVaultMessages: Messages = {
'hostDetails.section.address': 'Dirección', 'hostDetails.section.address': 'Dirección',
'hostDetails.hostname.placeholder': 'IP o nombre de host', 'hostDetails.hostname.placeholder': 'IP o nombre de host',
'hostDetails.section.general': 'General', 'hostDetails.section.general': 'General',
'hostDetails.os.title': 'Sistema operativo', 'hostDetails.os.title': 'Identificación del sistema',
'hostDetails.os.desc': 'Se usa para informar del entorno y adaptar el comportamiento del shell.', '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.linux': 'Linux',
'hostDetails.os.windows': 'Windows', 'hostDetails.os.windows': 'Windows',
'hostDetails.os.macos': 'macOS', 'hostDetails.os.macos': 'macOS',
+7 -2
View File
@@ -747,8 +747,13 @@ export const ruVaultMessages: Messages = {
'hostDetails.section.address': 'Адрес', 'hostDetails.section.address': 'Адрес',
'hostDetails.hostname.placeholder': 'IP или имя хоста', 'hostDetails.hostname.placeholder': 'IP или имя хоста',
'hostDetails.section.general': 'Общие', 'hostDetails.section.general': 'Общие',
'hostDetails.os.title': 'Операционная система', 'hostDetails.os.title': 'Определение системы',
'hostDetails.os.desc': 'Используется для сведений о среде и адаптации командной оболочки.', '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.linux': 'Linux',
'hostDetails.os.windows': 'Windows', 'hostDetails.os.windows': 'Windows',
'hostDetails.os.macos': 'macOS', 'hostDetails.os.macos': 'macOS',
+7 -2
View File
@@ -133,8 +133,13 @@ export const zhCNVaultMessages: Messages = {
'hostDetails.section.address': '地址', 'hostDetails.section.address': '地址',
'hostDetails.hostname.placeholder': 'IP 或 主机名', 'hostDetails.hostname.placeholder': 'IP 或 主机名',
'hostDetails.section.general': '通用', 'hostDetails.section.general': '通用',
'hostDetails.os.title': '操作系统', 'hostDetails.os.title': '系统识别',
'hostDetails.os.desc': '用于环境信息和适配对应的命令行行为。', '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.linux': 'Linux',
'hostDetails.os.windows': 'Windows', 'hostDetails.os.windows': 'Windows',
'hostDetails.os.macos': 'macOS', 'hostDetails.os.macos': 'macOS',
+7 -2
View File
@@ -132,8 +132,13 @@ export const zhTWVaultMessages: Messages = {
'hostDetails.section.address': '位址', 'hostDetails.section.address': '位址',
'hostDetails.hostname.placeholder': 'IP 或 主機名', 'hostDetails.hostname.placeholder': 'IP 或 主機名',
'hostDetails.section.general': '通用', 'hostDetails.section.general': '通用',
'hostDetails.os.title': '作業系統', 'hostDetails.os.title': '系統識別',
'hostDetails.os.desc': '用於環境資訊和配合對應的命令列行為。', '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.linux': 'Linux',
'hostDetails.os.windows': 'Windows', 'hostDetails.os.windows': 'Windows',
'hostDetails.os.macos': 'macOS', '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 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 { 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"; import { customThemeStore } from "../application/state/customThemeStore";
@@ -350,26 +351,59 @@ export const HostDetailsAdvancedSections: React.FC<HostDetailsAdvancedSectionsPr
</HostDetailsSection> </HostDetailsSection>
)} )}
{/* Network Device Mode — only for SSH hosts without Mosh / ET (serial already uses raw mode) */} {(!form.protocol || ['ssh', 'telnet', 'mosh', 'et'].includes(form.protocol)) && (
{(!form.protocol || form.protocol === 'ssh') && !form.moshEnabled && !form.etEnabled && (
<HostDetailsSection <HostDetailsSection
icon={<Router size={14} className="text-muted-foreground" />} icon={<Router size={14} className="text-muted-foreground" />}
title={t("hostDetails.section.deviceType")} title={t("hostDetails.section.deviceType")}
> >
<ToggleRow {(!form.protocol || form.protocol === 'ssh') && !form.moshEnabled && !form.etEnabled && (<>
label={t("hostDetails.deviceType")} <ToggleRow
hint={t("hostDetails.deviceType.desc")} label={t("hostDetails.deviceType")}
enabled={effectiveDeviceType === 'network'} hint={t("hostDetails.deviceType.desc")}
onToggle={() => update("deviceType", effectiveDeviceType === 'network' ? 'general' : 'network')} 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"> {effectiveDeviceType === 'network' && (
<AlertTriangle size={14} className="text-yellow-500 mt-0.5 flex-shrink-0" /> <div className="flex items-start gap-2 p-2 rounded-md bg-yellow-500/10 border border-yellow-500/20">
<p className="text-xs text-yellow-600 dark:text-yellow-400 break-words"> <AlertTriangle size={14} className="text-yellow-500 mt-0.5 flex-shrink-0" />
{t("hostDetails.deviceType.warning")} <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> </p>
</div> </CollapsibleContent>
)} </Collapsible>
</HostDetailsSection> </HostDetailsSection>
)} )}
@@ -11,7 +11,6 @@ import {
applyEffectiveHostAuthMethodSelection, applyEffectiveHostAuthMethodSelection,
detachEffectiveHostIdentity, detachEffectiveHostIdentity,
HOST_AUTH_METHOD_CHOICES, HOST_AUTH_METHOD_CHOICES,
HOST_OS_CHOICES,
HostDetailsConnectionSections, HostDetailsConnectionSections,
removeSelectedHostCredential, removeSelectedHostCredential,
shouldForceAuthMethodReselect, shouldForceAuthMethodReselect,
@@ -112,26 +111,9 @@ test("color and icon settings render for non-Linux hosts", () => {
assert.match(markup, /hostDetails\.icon\.manualLabel/); 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" }); const markup = renderConnectionSections({ os: "windows" });
assert.doesNotMatch(markup, /hostDetails\.os\./);
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"\]\)\}/);
}); });
test("host credentials expose automatic and password-only choices", () => { test("host credentials expose automatic and password-only choices", () => {
@@ -23,12 +23,6 @@ export const HOST_AUTH_METHOD_CHOICES = [
["certificate", "hostDetails.auth.certificate"], ["certificate", "hostDetails.auth.certificate"],
] as const; ] as const;
export const HOST_OS_CHOICES = [
["linux", "hostDetails.os.linux"],
["windows", "hostDetails.os.windows"],
["macos", "hostDetails.os.macos"],
] as const;
export function shouldForceAuthMethodReselect( export function shouldForceAuthMethodReselect(
nextMethod: "auto" | "password" | "key" | "certificate", nextMethod: "auto" | "password" | "key" | "certificate",
currentMethod: "auto" | "password" | "key" | "certificate", currentMethod: "auto" | "password" | "key" | "certificate",
@@ -131,12 +125,6 @@ export const HostDetailsConnectionSections: React.FC<HostDetailsConnectionSectio
const selectedAuthMethodLabel = t( const selectedAuthMethodLabel = t(
HOST_AUTH_METHOD_CHOICES.find(([value]) => value === resolvedAuthMethod)?.[1] ?? "hostDetails.auth.auto", 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 effectiveEtEnabled = form.etEnabled ?? groupDefaults?.etEnabled;
const effectiveProtocol = form.protocol ?? groupDefaults?.protocol; const effectiveProtocol = form.protocol ?? groupDefaults?.protocol;
@@ -152,31 +140,6 @@ export const HostDetailsConnectionSections: React.FC<HostDetailsConnectionSectio
onChange={(e) => update("hostname", e.target.value)} onChange={(e) => update("hostname", e.target.value)}
className="h-10 w-full" 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>
<HostDetailsSection <HostDetailsSection
+6 -2
View File
@@ -1,3 +1,4 @@
import { resolveHostOs } from '../domain/host';
import { Terminal as XTerm } from "@xterm/xterm"; import { Terminal as XTerm } from "@xterm/xterm";
import { FitAddon } from "@xterm/addon-fit"; import { FitAddon } from "@xterm/addon-fit";
import { SerializeAddon } from "@xterm/addon-serialize"; import { SerializeAddon } from "@xterm/addon-serialize";
@@ -1099,12 +1100,15 @@ const TerminalComponent: React.FC<TerminalProps> = ({
// hosts (TerminalLayer) and saved-host defaults (HostDetailsPanel) both // hosts (TerminalLayer) and saved-host defaults (HostDetailsPanel) both
// stamp os: "linux", which mis-routes the autocomplete clear sequence to // stamp os: "linux", which mis-routes the autocomplete clear sequence to
// Ctrl-U on Windows where cmd/PowerShell render it literally (#1112). // 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) ? detectLocalOs(navigator.userAgent || navigator.platform)
: (host.os || "linux"); : resolveHostOs(host);
const autocompleteHostOs = resolvedAutocompleteOs === "windows" || resolvedAutocompleteOs === "macos"
? resolvedAutocompleteOs : "linux";
const autocompleteSettings = resolveTerminalAutocompleteSettings({ const autocompleteSettings = resolveTerminalAutocompleteSettings({
protocol: effectiveTerminalProtocol, protocol: effectiveTerminalProtocol,
terminalSettings, terminalSettings,
systemUnknown: resolvedAutocompleteOs === 'unknown',
isNetworkDevice: host.deviceType === 'network' isNetworkDevice: host.deviceType === 'network'
|| classifyDistroId(host.distro) === 'network-device', || classifyDistroId(host.distro) === 'network-device',
}); });
@@ -18,10 +18,11 @@ export function resolveTerminalAutocompleteSettings(input: {
terminalSettings?: TerminalAutocompleteSettingFields; terminalSettings?: TerminalAutocompleteSettingFields;
/** Vendor CLI / network-device session: skip live-preview PTY rewrites (#1193). */ /** Vendor CLI / network-device session: skip live-preview PTY rewrites (#1193). */
isNetworkDevice?: boolean; isNetworkDevice?: boolean;
systemUnknown?: boolean;
}): Partial<AutocompleteSettings> | undefined { }): Partial<AutocompleteSettings> | undefined {
const { protocol, terminalSettings, isNetworkDevice } = input; const { protocol, terminalSettings, isNetworkDevice, systemUnknown } = input;
if (protocol === "serial") { if (protocol === "serial" || systemUnknown) {
return { return {
enabled: terminalSettings?.autocompleteEnabled ?? true, enabled: terminalSettings?.autocompleteEnabled ?? true,
showGhostText: terminalSettings?.autocompleteGhostText ?? true, showGhostText: terminalSettings?.autocompleteGhostText ?? true,
@@ -16,6 +16,11 @@ const armSudoPrompt = (
}; };
test("startMosh enables sudo autofill with the host saved password", async () => { 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; let onData: ((data: string) => void) | null = null;
const sent: string[] = []; const sent: string[] = [];
const terminalBackend = { const terminalBackend = {
@@ -28,6 +33,8 @@ test("startMosh enables sudo autofill with the host saved password", async () =>
startSSHSession: async () => "ssh-session", startSSHSession: async () => "ssh-session",
startTelnetSession: async () => "telnet-session", startTelnetSession: async () => "telnet-session",
startMoshSession: async () => "mosh-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", startLocalSession: async () => "local-session",
startSerialSession: async () => "serial-session", startSerialSession: async () => "serial-session",
execCommand: async () => ({}), execCommand: async () => ({}),
@@ -49,6 +56,7 @@ test("startMosh enables sudo autofill with the host saved password", async () =>
username: "alice", username: "alice",
password: "saved-secret", password: "saved-secret",
}, },
onOsDetected: (_id: string, distro: string) => { detected = distro; resolveDetected(); },
keys: [], keys: [],
identities: [], identities: [],
resolvedChainHosts: [], 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); 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)); onData?.(armSudoPrompt(sudoAutofillRef.current));
sudoAutofillRef.current?.confirmFill(); sudoAutofillRef.current?.confirmFill();
@@ -1208,7 +1208,13 @@ export const createTerminalSessionStarters = (ctx: TerminalSessionStartersContex
cancelPendingStartupCommand?.(); cancelPendingStartupCommand?.();
cancelPendingStartupCommand = undefined; cancelPendingStartupCommand = undefined;
}; };
const detectMoshSystem = () => {
if (!isCurrentAttempt()) return;
const token = registerConnectionToken(attachedSessionId);
void runDistroDetection(ctx, attachedSessionId, token);
};
const runMoshStartup = () => { const runMoshStartup = () => {
detectMoshSystem();
disposeMoshReady?.(); disposeMoshReady?.();
disposeMoshReady = undefined; disposeMoshReady = undefined;
ctx.setIsConnectionAwaitingUserInput?.(false); ctx.setIsConnectionAwaitingUserInput?.(false);
@@ -1298,7 +1304,8 @@ export const createTerminalSessionStarters = (ctx: TerminalSessionStartersContex
runMoshStartup(); runMoshStartup();
} }
} else { } 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); scheduleStartupCommand(ctx, term, id);
} }
} catch (err) { } catch (err) {
@@ -106,3 +106,43 @@ test("runDistroDetection normalizes FreeBSD uname output", async () => {
assert.deepEqual(detected, ["freebsd"]); 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) { if (ctx.terminalBackend.getSessionRemoteInfo && sessionId) {
const info = await ctx.terminalBackend.getSessionRemoteInfo(sessionId); const info = await ctx.terminalBackend.getSessionRemoteInfo(sessionId);
if (!isStillCurrent()) return; 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); const vendor = detectVendorFromSshVersion(info?.remoteSshVersion);
if (vendor) { if (vendor) {
ctx.onOsDetected?.(ctx.host.id, vendor); ctx.onOsDetected?.(ctx.host.id, vendor);
@@ -89,12 +93,13 @@ export const runDistroDetection = async (
const res = await ctx.terminalBackend.getSessionDistroInfo(sessionId); const res = await ctx.terminalBackend.getSessionDistroInfo(sessionId);
if (!isStillCurrent()) return; if (!isStillCurrent()) return;
if (!res?.success) 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 idMatch = data.match(/^ID="?([\w-]+)"?$/im);
const rawDistro = idMatch const rawDistro = idMatch
? idMatch[1] ? idMatch[1]
: (data.split(/\s+/)[0] || "").toLowerCase(); : (data.match(/^(Linux|Darwin|FreeBSD)\b/i)?.[1] || "").toLowerCase();
const distro = normalizeDistroId(rawDistro) || rawDistro; // 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); if (distro) ctx.onOsDetected?.(ctx.host.id, distro);
} }
} catch (err) { } catch (err) {
@@ -126,3 +126,12 @@ test("falls back to raised maxSuggestions default when unset", () => {
50, 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 { PortForwardingRule } from './models';
import type { Host, TerminalSession } from '../types'; import type { Host, TerminalSession } from '../types';
@@ -83,7 +83,7 @@ export const buildAITerminalSessionInfo = (
hostId: session?.hostId || '', hostId: session?.hostId || '',
hostname: host?.hostname || session?.hostname || '', hostname: host?.hostname || session?.hostname || '',
label: host?.label || session?.hostLabel || '', label: host?.label || session?.hostLabel || '',
os: host?.os || (isLocalSession ? localOs : undefined), os: isLocalSession ? localOs : resolveHostOs(host),
username: host?.username || session?.username, username: host?.username || session?.username,
protocol, protocol,
shellType: session?.shellType && session.shellType !== 'unknown' ? session.shellType : undefined, shellType: session?.shellType && session.shellType !== 'unknown' ? session.shellType : undefined,
+16 -1
View File
@@ -1,7 +1,7 @@
import assert from "node:assert/strict"; import assert from "node:assert/strict";
import test from "node:test"; import test from "node:test";
import { applyEphemeralHostsUpdate, isSavedVaultHost, splitHostsUpdateByEphemeral } from "./ephemeralHosts"; import { applyEphemeralHostDistroUpdate, applyEphemeralHostsUpdate, isSavedVaultHost, splitHostsUpdateByEphemeral } from "./ephemeralHosts";
import type { Host } from "./models"; import type { Host } from "./models";
const makeHost = (id: string, overrides: Partial<Host> = {}): Host => ({ 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(null), false);
assert.equal(isSavedVaultHost(undefined), 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"; import type { Host } from "./models";
/** /**
@@ -37,3 +38,10 @@ export const applyEphemeralHostsUpdate = (
const updatedById = new Map(updated.map((host) => [host.id, host])); const updatedById = new Map(updated.map((host) => [host.id, host]));
return previous.map((host) => updatedById.get(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 { Host, Snippet, TerminalSettings } from './models';
import type { HostOperatingSystem, HostOsSelection } from './models/connection';
import { sanitizeHostIconFields } from './hostIcon'; import { sanitizeHostIconFields } from './hostIcon';
import { migrateHostConnectScriptIds } from './hostConnectScripts.ts'; import { migrateHostConnectScriptIds } from './hostConnectScripts.ts';
import { migrateDeprecatedFontOverride } from '../infrastructure/config/fonts'; import { migrateDeprecatedFontOverride } from '../infrastructure/config/fonts';
@@ -105,6 +106,7 @@ export const normalizeDistroId = (value?: string) => {
) { ) {
return 'macos'; return 'macos';
} }
if (v === 'windows' || v === 'win32' || /^openssh_for_windows(?:_|$)/.test(v)) return 'windows';
if (v.includes('freebsd')) return 'freebsd'; if (v.includes('freebsd')) return 'freebsd';
if (v.includes('ubuntu')) return 'ubuntu'; if (v.includes('ubuntu')) return 'ubuntu';
if (v.includes('debian')) return 'debian'; if (v.includes('debian')) return 'debian';
@@ -215,6 +217,29 @@ export const classifyDistroId = (distroId?: string): DeviceClass => {
return 'other'; 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 * 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 * 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' ? 'auto'
: undefined; : undefined;
const cleanHostIcon = sanitizeHostIconFields(host); const cleanHostIcon = sanitizeHostIconFields(host);
const osSelection = getHostOsSelection(host);
const migrated = migrateDeprecatedFontOverride(host); const migrated = migrateDeprecatedFontOverride(host);
// Before explicit per-host authentication modes existed, new hosts were // Before explicit per-host authentication modes existed, new hosts were
// persisted with authMethod="password" even though an empty password still // 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, : migrated.authMethod ?? inferredLegacyAuthMethod,
authPolicyVersion: 1, authPolicyVersion: 1,
hostname: cleanHostname, hostname: cleanHostname,
osOverride: osSelection,
os: osSelection === 'linux' || osSelection === 'windows' || osSelection === 'macos' ? osSelection : host.os,
distro: cleanDistro, distro: cleanDistro,
distroMode: cleanDistroMode, distroMode: cleanDistroMode,
manualDistro: cleanManualDistro || undefined, 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 HostAuthMethod = 'auto' | 'password' | 'key' | 'certificate';
export type HostOperatingSystem = 'linux' | 'windows' | 'macos' | 'freebsd' | 'unknown';
export type HostOsSelection = 'auto' | HostOperatingSystem;
export interface Host { export interface Host {
id: string; id: string;
label: string; label: string;
@@ -161,7 +164,10 @@ export interface Host {
identityId?: string; identityId?: string;
group?: string; group?: string;
tags: string[]; tags: string[];
// Legacy compatibility value; use resolveHostOs for runtime decisions.
os: 'linux' | 'windows' | 'macos'; 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. // Device type: 'general' for standard servers, 'network' for switches/routers/firewalls.
// Network devices use raw command execution (no shell wrapping) for AI agent compatibility. // Network devices use raw command execution (no shell wrapping) for AI agent compatibility.
deviceType?: 'general' | 'network'; deviceType?: 'general' | 'network';
@@ -131,6 +131,7 @@ test("system overview stats run for Linux and macOS targets", () => {
{ {
id: "host-1", id: "host-1",
label: "Linux", label: "Linux",
distro: "ubuntu",
hostname: "linux.local", hostname: "linux.local",
username: "root", username: "root",
tags: [], tags: [],
@@ -172,3 +173,11 @@ test("FreeBSD icon detection does not enable unsupported system features", () =>
assert.equal(shouldCollectServerStats(host, undefined, null), false); assert.equal(shouldCollectServerStats(host, undefined, null), false);
assert.deepEqual(buildSystemManagerTabs(host, undefined, null), ["overview", "processes"]); 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 { Host } from '../models/connection';
import type { TerminalSession } from '../../types'; import type { TerminalSession } from '../../types';
import type { SessionCapabilities, SystemManagerSubTab } 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 { export function isNetworkDeviceTarget(host: Host | null | undefined): boolean {
if (host?.deviceType === 'network') return true; if (host?.deviceType === 'network') return true;
return classifyDistroId(host?.distro) === 'network-device'; return classifyDistroId(host?.distro) === 'network-device';
@@ -16,14 +11,11 @@ export function isNetworkDeviceTarget(host: Host | null | undefined): boolean {
export function isDefiniteLinuxTarget( export function isDefiniteLinuxTarget(
host: Host | null | undefined, host: Host | null | undefined,
capabilities: SessionCapabilities | undefined, capabilities: SessionCapabilities | undefined,
session: TerminalSession | null | undefined, _session: TerminalSession | null | undefined,
): boolean { ): boolean {
if (capabilities?.targetOs === 'linux') return true; if (capabilities?.targetOs && capabilities.targetOs !== 'unknown') return capabilities.targetOs === 'linux';
if (isNetworkDeviceTarget(host)) return false; if (isNetworkDeviceTarget(host)) return false;
if (hasKnownUnsupportedSystemDistro(host)) return false; if (resolveHostOs(host) === 'linux') return true;
if (host?.os === 'linux') return true;
if (classifyDistroId(getEffectiveHostDistro(host)) === 'linux-like') return true;
if (session?.protocol === 'local' && host?.os === 'linux') return true;
return false; return false;
} }
@@ -48,7 +40,7 @@ export function shouldShowTmuxTab(
if (isNetworkDeviceTarget(host)) return capabilities?.hasTmux === true; if (isNetworkDeviceTarget(host)) return capabilities?.hasTmux === true;
if (isDefiniteLinuxTarget(host, capabilities, session)) return true; if (isDefiniteLinuxTarget(host, capabilities, session)) return true;
if (capabilities?.targetOs === 'darwin') return true; if (capabilities?.targetOs === 'darwin') return true;
if (host?.os === 'macos') return true; if (resolveHostOs(host) === 'macos') return true;
return false; return false;
} }
@@ -98,15 +90,14 @@ export function shouldShowServicesTab(
export function shouldCollectServerStats( export function shouldCollectServerStats(
host: Host | null | undefined, host: Host | null | undefined,
capabilities: SessionCapabilities | undefined, capabilities: SessionCapabilities | undefined,
session: TerminalSession | null | undefined, _session: TerminalSession | null | undefined,
): boolean { ): boolean {
const detectedDeviceClass = classifyDistroId(host?.distro); const detectedDeviceClass = classifyDistroId(host?.distro);
if (isNetworkDeviceTarget(host) || detectedDeviceClass === 'network-device') return false; if (isNetworkDeviceTarget(host) || detectedDeviceClass === 'network-device') return false;
if (capabilities?.targetOs === 'linux' || capabilities?.targetOs === 'darwin') return true; if (capabilities?.targetOs && capabilities.targetOs !== 'unknown') {
if (hasKnownUnsupportedSystemDistro(host)) return false; return capabilities.targetOs === 'linux' || capabilities.targetOs === 'darwin';
if (host?.os === 'linux' || host?.os === 'macos') return true; }
if (detectedDeviceClass === 'linux-like') return true; if (resolveHostOs(host) === 'linux' || resolveHostOs(host) === 'macos') return true;
if (session?.protocol === 'local' && host?.os === 'linux') return true;
return false; return false;
} }
+9 -5
View File
@@ -1,5 +1,6 @@
import type { GroupConfig, Host, HostProtocol, Identity, ManagedSource, ProxyProfile } from './models'; import type { GroupConfig, Host, HostProtocol, Identity, ManagedSource, ProxyProfile } from './models';
import { sanitizeHost } from './host'; import { sanitizeHost } from './host';
import type { HostOsSelection } from './models/connection';
import { import {
findIntroducedVaultJumpGraphIssue, findIntroducedVaultJumpGraphIssue,
findVaultGroupConfigJumpReference, findVaultGroupConfigJumpReference,
@@ -93,9 +94,10 @@ const normalizeProtocol = (raw: unknown): VaultHostDraftProtocol | undefined =>
return undefined; return undefined;
}; };
const normalizeOs = (raw: unknown): Host['os'] | undefined => { const normalizeOs = (raw: unknown): HostOsSelection | undefined => {
if (typeof raw !== 'string') return undefined; if (typeof raw !== 'string') return undefined;
const value = raw.trim().toLowerCase(); const value = raw.trim().toLowerCase();
if (value === 'auto' || value === 'freebsd' || value === 'unknown') return value;
if (value === 'linux') return 'linux'; if (value === 'linux') return 'linux';
if (value === 'windows' || value === 'win') return 'windows'; if (value === 'windows' || value === 'win') return 'windows';
if (value === 'macos' || value === 'mac' || value === 'osx' || value === 'darwin') return 'macos'; if (value === 'macos' || value === 'mac' || value === 'osx' || value === 'darwin') return 'macos';
@@ -229,9 +231,9 @@ export function buildVaultHostFromDraft(
&& draft.os !== null && draft.os !== null
&& !(typeof draft.os === 'string' && !draft.os.trim()); && !(typeof draft.os === 'string' && !draft.os.trim());
if (hasOsInput && parsedOs === undefined) { 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 parsedPort = parsePort(draft.port);
const hasPortInput = draft.port !== undefined const hasPortInput = draft.port !== undefined
&& draft.port !== null && draft.port !== null
@@ -308,6 +310,7 @@ export function buildVaultHostFromDraft(
group: normalizeGroupPath(draft.group), group: normalizeGroupPath(draft.group),
tags: tags.tags, tags: tags.tags,
os, os,
osOverride: parsedOs ?? 'auto',
protocol, protocol,
createdAt: now, createdAt: now,
...(keyPath ...(keyPath
@@ -438,9 +441,10 @@ export function applyVaultHostUpdate(
if (os.provided) { if (os.provided) {
const nextOs = normalizeOs(os.value); const nextOs = normalizeOs(os.value);
if (!nextOs) { 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) { 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(result.pending, true);
assert.equal(session.conn, undefined); 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, 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' }; return { success: false, error: 'Session not found or not connected' };
} }
const command = withPosixRemoteWatchdog( const command = withPosixRemoteWatchdog(
@@ -179,7 +187,7 @@ function createSessionOpsApi(ctx) {
5000, 5000,
); );
try { try {
const { stdout, stderr } = await executeBoundedSshCommand(session.conn, command, { const { stdout, stderr } = await executeBoundedSshCommand(conn, command, {
openingTimeoutMs: 5000, openingTimeoutMs: 5000,
runTimeoutMs: 5000, runTimeoutMs: 5000,
maxOutputBytes: 256 * 1024, 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." }, 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." }, 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." }, 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." }, 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." }, 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." }, proxyProfileId: { type: "string", optional: true, description: "Reusable proxy ID from vault_proxy_profiles_list. Empty string clears it." },
@@ -687,7 +687,7 @@
"os": { "os": {
"type": "string", "type": "string",
"optional": true, "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": { "identityId": {
"type": "string", "type": "string",
@@ -733,7 +733,7 @@
"os": { "os": {
"type": "string", "type": "string",
"optional": true, "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": { "identityId": {
"type": "string", "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 { GroupConfig, Host, Identity, KnownHost, ManagedSource, PortForwardingRule, ProxyProfile, Snippet, SSHKey, TerminalSettings, VaultNote } from '../../domain/models';
import type { RememberImportedKeyPassphraseResult } from '../../application/defaultKeyPassphrases'; import type { RememberImportedKeyPassphraseResult } from '../../application/defaultKeyPassphrases';
import { import {
@@ -127,6 +128,7 @@ export function sanitizeHostForAgent(host: Host): Record<string, unknown> {
} }
sanitized[key] = value; sanitized[key] = value;
} }
sanitized.os = resolveHostOs(host);
return sanitized; return sanitized;
} }
@@ -140,7 +142,7 @@ function summarizeHostForList(host: Host) {
protocol: host.protocol, protocol: host.protocol,
group: host.group, group: host.group,
tags: host.tags, tags: host.tags,
os: host.os, os: resolveHostOs(host),
createdAt: host.createdAt, createdAt: host.createdAt,
connectScriptIds: host.connectScriptIds, connectScriptIds: host.connectScriptIds,
loginScriptId: host.loginScriptId, loginScriptId: host.loginScriptId,
@@ -531,7 +533,7 @@ async function registerOpenedSessionInMcpScope(
hostId: host.id, hostId: host.id,
hostname: host.hostname || '', hostname: host.hostname || '',
label: host.label || host.hostname || sessionId, label: host.label || host.hostname || sessionId,
os: host.os || '', os: resolveHostOs(host),
username: host.username || '', username: host.username || '',
protocol, protocol,
deviceType: host.deviceType || '', deviceType: host.deviceType || '',