mirror of
https://github.com/binaricat/Netcatty.git
synced 2026-10-29 18:11:40 +00:00
Compare commits
4
Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
e57ec0b476 | ||
|
|
999f85763f | ||
|
|
70a68900ce | ||
|
|
361dc6117f |
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\)/);
|
||||
});
|
||||
@@ -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,
|
||||
|
||||
@@ -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(() => {
|
||||
|
||||
@@ -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',
|
||||
|
||||
@@ -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',
|
||||
|
||||
@@ -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',
|
||||
|
||||
@@ -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',
|
||||
|
||||
@@ -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',
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
});
|
||||
@@ -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,
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
});
|
||||
@@ -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);
|
||||
};
|
||||
@@ -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,
|
||||
|
||||
@@ -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');
|
||||
});
|
||||
@@ -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);
|
||||
});
|
||||
@@ -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;
|
||||
}
|
||||
|
||||
|
||||
@@ -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);
|
||||
});
|
||||
@@ -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,
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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 || '',
|
||||
|
||||
Reference in new issue
Block a user