Compare commits

...
25 changed files with 739 additions and 67 deletions

No files matched your search

+1
View File
@@ -188,6 +188,7 @@ export const enTerminalMessages: Messages = {
'terminal.auth.retryMessage': 'Authentication failed. Please check your credentials and try again.',
'terminal.auth.retryLog': 'Authentication failed. Please try again.',
'terminal.auth.noKeysHint': 'No keys available. Add keys in Keychain.',
'terminal.auth.selectIdentity': 'Use saved identity',
'terminal.auth.continueSave': 'Continue & Save',
'terminal.auth.credentialsUnavailable': 'Saved credentials cannot be decrypted on this device. Please re-enter and save them again.',
'terminal.auth.jumpCredentialsUnavailable': 'A jump host has saved credentials that cannot be decrypted on this device. Open host settings and re-enter them.',
+1
View File
@@ -188,6 +188,7 @@ export const esTerminalMessages: Messages = {
'terminal.auth.retryMessage': 'Falló la autenticación. Verifica tus credenciales e inténtalo de nuevo.',
'terminal.auth.retryLog': 'Falló la autenticación. Inténtalo de nuevo.',
'terminal.auth.noKeysHint': 'No hay claves disponibles. Agrega claves en Keychain.',
'terminal.auth.selectIdentity': 'Usar identidad guardada',
'terminal.auth.continueSave': 'Continuar y guardar',
'terminal.auth.credentialsUnavailable': 'Las credenciales guardadas no se pueden descifrar en este dispositivo. Vuelve a ingresarlas y guárdalas de nuevo.',
'terminal.auth.jumpCredentialsUnavailable': 'Un host de salto tiene credenciales guardadas que no se pueden descifrar en este dispositivo. Abre la configuración del host y vuelve a ingresarlas.',
+1
View File
@@ -209,6 +209,7 @@ export const ruTerminalMessages: Messages = {
'terminal.auth.retryMessage': 'Ошибка аутентификации. Проверьте учётные данные и повторите попытку.',
'terminal.auth.retryLog': 'Ошибка аутентификации. Повторите попытку.',
'terminal.auth.noKeysHint': 'Нет доступных ключей. Добавьте ключи в связке ключей.',
'terminal.auth.selectIdentity': 'Использовать сохранённый профиль',
'terminal.auth.continueSave': 'Продолжить и сохранить',
'terminal.auth.credentialsUnavailable': 'Сохранённые учётные данные не могут быть расшифрованы на этом устройстве. Пожалуйста, введите и сохраните их заново.',
'terminal.auth.jumpCredentialsUnavailable': 'У jump-хоста сохранены учётные данные, которые нельзя расшифровать на этом устройстве. Откройте настройки хоста и введите их заново.',
+1
View File
@@ -579,6 +579,7 @@ export const zhCNVaultMessages: Messages = {
'terminal.auth.retryMessage': '身份验证失败。请检查凭据后重试。',
'terminal.auth.retryLog': '身份验证失败,请重试。',
'terminal.auth.noKeysHint': '暂无密钥,请先在钥匙串中添加。',
'terminal.auth.selectIdentity': '使用已保存的身份',
'terminal.auth.continueSave': '继续并保存',
'terminal.auth.credentialsUnavailable': '当前设备无法解密已保存凭据,请重新输入并再次保存。',
'terminal.auth.jumpCredentialsUnavailable': '某个跳板机的已保存凭据无法在当前设备解密,请到主机设置中重新填写。',
+1
View File
@@ -579,6 +579,7 @@ export const zhTWVaultMessages: Messages = {
'terminal.auth.retryMessage': '驗證失敗。請檢查憑證後再試一次。',
'terminal.auth.retryLog': '驗證失敗,請再試一次。',
'terminal.auth.noKeysHint': '暫無金鑰,請先在鑰匙串中新增。',
'terminal.auth.selectIdentity': '使用已儲存的身分',
'terminal.auth.continueSave': '繼續並儲存',
'terminal.auth.credentialsUnavailable': '目前裝置無法解密已儲存憑證,請重新輸入並再次儲存。',
'terminal.auth.jumpCredentialsUnavailable': '某個跳板機的已儲存憑證無法在目前裝置解密,請到主機設定中重新填寫。',
@@ -5,6 +5,7 @@ import {
buildSftpHostCredentials,
} from "./useSftpHostCredentials.ts";
import type { Host, Identity, KnownHost, SSHKey } from "../../../domain/models.ts";
import { resolvePasswordAuthSftpHost } from "../../../domain/authIdentityPicker.ts";
const host = (overrides: Partial<Host> = {}): Host => ({
id: "host-1",
@@ -16,6 +17,22 @@ const host = (overrides: Partial<Host> = {}): Host => ({
...overrides,
});
test("SFTP credentials follow the temporary deploy identity on a root profile", () => {
const identities = [
{ id: "root-id", label: "root", username: "root", authMethod: "password", password: "bad-root", created: 0 },
{ id: "deploy-id", label: "deploy", username: "deploy", authMethod: "password", password: "good-deploy", created: 0 },
] as Identity[];
const savedHost = host({ identityId: "root-id" });
const sftpHost = resolvePasswordAuthSftpHost(savedHost, identities, {
authMethod: "password", username: "deploy", password: "good-deploy", savedToHost: false,
});
const credentials = buildSftpHostCredentials({ host: sftpHost, hosts: [savedHost], keys: [], identities });
assert.equal(credentials.username, "deploy");
assert.equal(credentials.password, "good-deploy");
assert.equal(credentials.hostId, savedHost.id);
assert.equal(savedHost.identityId, "root-id");
});
test("buildSftpHostCredentials forwards system agent settings for target and jump hosts", () => {
const jump = host({
id: "jump-1",
+23 -8
View File
@@ -82,6 +82,7 @@ import { usePluginTerminalProviders } from "../application/state/usePluginTermin
import type { PluginTerminalDecorationRule } from "../domain/pluginTerminalProviders";
import { terminalReconnectRegistry } from "../application/state/terminalReconnectRegistry";
import { resolveSftpReuseSourceSessionId } from "../application/state/terminalConnectionReuse";
import { resolvePasswordAuthSftpHost } from "../domain/authIdentityPicker";
// SFTPModal removed - SFTP is now handled by SftpSidePanel in TerminalLayer
import { Button } from "./ui/button";
import { HoverCard, HoverCardContent, HoverCardTrigger } from "./ui/hover-card";
@@ -1238,6 +1239,7 @@ const TerminalComponent: React.FC<TerminalProps> = ({
}
const auth = useTerminalAuthState({
host,
identities,
pendingAuthRef,
termRef,
onUpdateHost: handleUpdateHostFromTerminal,
@@ -1438,8 +1440,11 @@ const TerminalComponent: React.FC<TerminalProps> = ({
hasEverConnectedRef.current = true;
clearAutoReconnect();
}
onStatusChange?.(sessionId, next);
}, [clearAutoReconnect, onStatusChange, sessionId]);
const sftpHost = next === "connected"
? resolvePasswordAuthSftpHost(host, identities, pendingAuthRef.current)
: host;
onStatusChange?.(sessionId, next, sftpHost === host ? undefined : sftpHost);
}, [clearAutoReconnect, host, identities, onStatusChange, sessionId]);
const updateStatusRef = useRef(updateStatus);
updateStatusRef.current = updateStatus;
@@ -3467,8 +3472,11 @@ const TerminalComponent: React.FC<TerminalProps> = ({
if (onOpenSftp) {
// Delegate to parent (TerminalLayer) for shared SFTP side panel
const initialPath = await resolveSftpInitialPath();
const sftpHost = statusRef.current === 'connected'
? resolvePasswordAuthSftpHost(host, identities, pendingAuthRef.current)
: host;
onOpenSftp(
host,
sftpHost,
initialPath,
undefined,
sessionId,
@@ -3483,7 +3491,7 @@ const TerminalComponent: React.FC<TerminalProps> = ({
return;
}
setShowSFTP(true);
}, [host, onOpenSftp, resolveSftpInitialPath, sessionId, showSFTP]);
}, [host, identities, onOpenSftp, resolveSftpInitialPath, sessionId, showSFTP]);
const handleSendYmodem = useCallback(async () => {
if (!isSerialConnection || statusRef.current !== "connected") return;
@@ -3961,6 +3969,13 @@ const TerminalComponent: React.FC<TerminalProps> = ({
requiresUserInput: auth.needsAuth || needsHostKeyVerification || isConnectionAwaitingUserInput,
});
const dragDropSftpHost = status === 'connected'
? resolvePasswordAuthSftpHost(host, identities, pendingAuthRef.current)
: host;
const dragDropSudoPassword = dragDropSftpHost === host
? resolvedSudoAutofillPassword
: identities.find(identity => identity.id === dragDropSftpHost.identityId)?.password;
const {
handleDragEnter,
handleDragLeave,
@@ -3968,9 +3983,9 @@ const TerminalComponent: React.FC<TerminalProps> = ({
handleDrop,
isDraggingOver,
} = useTerminalDragDrop({
host,
resolvedLoginUsername,
resolvedSudoPassword: resolvedSudoAutofillPassword,
host: dragDropSftpHost,
resolvedLoginUsername: dragDropSftpHost === host ? resolvedLoginUsername : dragDropSftpHost.username,
resolvedSudoPassword: dragDropSudoPassword,
isLocalConnection,
isNetworkDevice,
onOpenSftp,
@@ -4545,7 +4560,7 @@ const TerminalComponent: React.FC<TerminalProps> = ({
return (
<>
<TerminalView ctx={{ Activity, ArrowDownToLine, ArrowUpFromLine, Button, Clock3, Copy, Cpu, HardDrive, HoverCard, HoverCardContent, HoverCardTrigger, Maximize2, MemoryStick, Radio, RefreshCcw, Sparkles, SquareArrowOutUpRight, Unplug, TerminalAutocomplete, TerminalComposeBar, TerminalConnectionDialog, TerminalContextMenu, TerminalSearchBar, Tooltip, TooltipContent, TooltipTrigger, ZmodemOverwriteDialog, ZmodemProgressIndicator, auth, autocompleteAcceptTextRef, autocompleteCloseRef, autocompleteHostOs, autocompleteInputRef, autocompleteKeyEventRef, autocompleteRepositionRef, autocompleteSettings, canUpdateHost: !!onUpdateHost, chainProgress, cn, compactToolbar, lineTimestampsAvailable, containerRef, effectiveFontSize, effectiveFontWeight, effectiveTheme, error, executeSnippet, executeSnippetCommand, handleAddSelectionToAI: onAddSelectionToAI ? handleAddSelectionToAI : undefined, handleCancelConnect, handleCloseDisconnectedSession, handleCloseSearch, handleDisconnect: (attachExistingSession || compactToolbar) ? undefined : handleDisconnect, handleDismissDisconnectedDialog, handleDragEnter, handleDragLeave, handleDragOver, handleDrop, handleFindNext, handleFindPrevious, handleHostKeyAddAndContinue, handleHostKeyClose, handleHostKeyContinue, handleOsc52ReadResponse, handleOsc7SetupConfirm, handleOsc7SetupOpenChange, handleReceiveYmodem, handleRetry, handleSearch, handleSendYmodem, handleTopOverlayMouseDownCapture, hasMouseTracking, host, hotkeyScheme, inWorkspace, isBroadcastEnabled, isCancelling, isComposeBarOpen: effectiveComposeBarOpen, isConnectionAwaitingUserInput, isDraggingOver, isFocusMode, isFocusedPane, isLocalConnection, remoteDragDropUsesZmodem, isPluginTerminalProviderAvailable, isReconnectActive, isSerialConnection, isSearchOpen, isSupportedOs, isSystemSidebarEligible, isVisible, keyBindings, keys, knownCwdRef, needsHostKeyVerification, onAddSelectionToAI, onBroadcastInput, onCloseSession, onDetach, onDetachDragEnd, onDetachDragStart, onDetachPointerDown, onEndSessionDrag, onExpandToFocus, onTogglePaneMagnification, onOpenSystem, onRename, onSplitHorizontal, onSplitVertical, onStartSessionDrag, onToggleBroadcast, onUpdateHost: handleUpdateHostFromTerminal, osc52ReadPromptVisible, osc7SetupOpen, osc7SetupRunning, passwordPromptActiveRef, pendingHostKeyInfo, progressLogs, progressValue, renderControls, resolvedFontFamily, restoreState, scrollToBottomAfterProgrammaticInput, searchMatchCount, searchFocusToken, scriptExecutionOverlay: activeScriptRun ? (
<TerminalView ctx={{ Activity, ArrowDownToLine, ArrowUpFromLine, Button, Clock3, Copy, Cpu, HardDrive, HoverCard, HoverCardContent, HoverCardTrigger, Maximize2, MemoryStick, Radio, RefreshCcw, Sparkles, SquareArrowOutUpRight, Unplug, TerminalAutocomplete, TerminalComposeBar, TerminalConnectionDialog, TerminalContextMenu, TerminalSearchBar, Tooltip, TooltipContent, TooltipTrigger, ZmodemOverwriteDialog, ZmodemProgressIndicator, auth, autocompleteAcceptTextRef, autocompleteCloseRef, autocompleteHostOs, autocompleteInputRef, autocompleteKeyEventRef, autocompleteRepositionRef, autocompleteSettings, canUpdateHost: !!onUpdateHost, chainProgress, cn, compactToolbar, lineTimestampsAvailable, containerRef, effectiveFontSize, effectiveFontWeight, effectiveTheme, error, executeSnippet, executeSnippetCommand, handleAddSelectionToAI: onAddSelectionToAI ? handleAddSelectionToAI : undefined, handleCancelConnect, handleCloseDisconnectedSession, handleCloseSearch, handleDisconnect: (attachExistingSession || compactToolbar) ? undefined : handleDisconnect, handleDismissDisconnectedDialog, handleDragEnter, handleDragLeave, handleDragOver, handleDrop, handleFindNext, handleFindPrevious, handleHostKeyAddAndContinue, handleHostKeyClose, handleHostKeyContinue, handleOsc52ReadResponse, handleOsc7SetupConfirm, handleOsc7SetupOpenChange, handleReceiveYmodem, handleRetry, handleSearch, handleSendYmodem, handleTopOverlayMouseDownCapture, hasMouseTracking, host, hotkeyScheme, inWorkspace, isBroadcastEnabled, isCancelling, isComposeBarOpen: effectiveComposeBarOpen, isConnectionAwaitingUserInput, isDraggingOver, isFocusMode, isFocusedPane, isLocalConnection, remoteDragDropUsesZmodem, isPluginTerminalProviderAvailable, isReconnectActive, isSerialConnection, isSearchOpen, isSupportedOs, isSystemSidebarEligible, isVisible, keyBindings, keys, identities, knownCwdRef, needsHostKeyVerification, onAddSelectionToAI, onBroadcastInput, onCloseSession, onDetach, onDetachDragEnd, onDetachDragStart, onDetachPointerDown, onEndSessionDrag, onExpandToFocus, onTogglePaneMagnification, onOpenSystem, onRename, onSplitHorizontal, onSplitVertical, onStartSessionDrag, onToggleBroadcast, onUpdateHost: handleUpdateHostFromTerminal, osc52ReadPromptVisible, osc7SetupOpen, osc7SetupRunning, passwordPromptActiveRef, pendingHostKeyInfo, progressLogs, progressValue, renderControls, resolvedFontFamily, restoreState, scrollToBottomAfterProgrammaticInput, searchMatchCount, searchFocusToken, scriptExecutionOverlay: activeScriptRun ? (
<ScriptExecutionOverlay
run={activeScriptRun}
onPause={() => { void pauseScriptRun(activeScriptRun.runId); }}
+87 -16
View File
@@ -20,6 +20,7 @@ import {
} from '../application/state/codingCliSessionSignalController';
import { collectSessionIds } from '../domain/workspace';
import { isPluginHostProtocol } from '../domain/pluginConnection';
import { isSameSftpHostSelection } from '../domain/sftpTerminalIdentity';
import { cn, normalizeLineEndings } from '../lib/utils';
import { detectLocalOs } from '../lib/localShell';
@@ -468,7 +469,7 @@ const TerminalLayerInner: React.FC<TerminalLayerProps> = ({
const { resolveDefaultLayoutForSession, saveWorkspaceLayoutAsDefault: persistWorkspaceLayoutAsDefault } =
useWorkspaceLayoutPresetState();
const applyWorkspaceLayoutPresetForSession = useCallback((session: TerminalSession, tabId: string) => {
const applyWorkspaceLayoutPresetForSession = useCallback((session: TerminalSession, tabId: string, sftpHostOverride?: Host) => {
// Preset persistence and resolution live in the application layer; the
// component only mounts the resolved panes into its UI state.
const resolved = resolveDefaultLayoutForSession(session);
@@ -501,6 +502,7 @@ const TerminalLayerInner: React.FC<TerminalLayerProps> = ({
sftpOpeningTabIdsRef.current.add(tabId);
sftpRetainedAfterCloseTabIdsRef.current.delete(tabId);
sftpPaneClosedTabIdsRef.current.delete(tabId);
setSftpHostSourceSessionForTab(prev => new Map(prev).set(tabId, session.id));
const host = hostsRef.current.find(h => h.id === session.hostId);
const hostWithOverrides: Host = host
? {
@@ -520,7 +522,7 @@ const TerminalLayerInner: React.FC<TerminalLayerProps> = ({
} as Host;
setSftpHostForTab(prev => {
const next = new Map(prev);
next.set(tabId, hostWithOverrides);
next.set(tabId, sftpHostOverride ?? hostWithOverrides);
return next;
});
}
@@ -549,11 +551,18 @@ const TerminalLayerInner: React.FC<TerminalLayerProps> = ({
return true;
}, [persistWorkspaceLayoutAsDefault, t]);
const handleStatusChange = useCallback((sessionId: string, status: TerminalSession['status']) => {
const handleStatusChange = useCallback((sessionId: string, status: TerminalSession['status'], sftpHostOverride?: Host) => {
onUpdateSessionStatus(sessionId, status);
if (status !== 'connected') return;
setSftpAuthHostBySessionId(prev => {
const next = new Map(prev);
if (sftpHostOverride) next.set(sessionId, sftpHostOverride);
else next.delete(sessionId);
return next;
});
const session = sessionsRef.current.find(s => s.id === sessionId);
if (!session) return;
const workspaceId = session.workspaceId;
@@ -573,6 +582,19 @@ const TerminalLayerInner: React.FC<TerminalLayerProps> = ({
}
const proto = presetSession.protocol ?? 'ssh';
if (presetSession.id === sessionId && (sftpHostOverride || sftpHostSourceSessionForTabRef.current.get(tabId) === sessionId)) {
const connectedHost = sftpHostOverride ?? sessionHostsMapRef.current.get(sessionId);
if (connectedHost) {
setSftpHostForTab(prev => new Map(prev).set(tabId, connectedHost));
}
setSftpHostSourceSessionForTab(prev => {
if (!sftpHostOverride) return prev;
const next = new Map(prev);
next.set(tabId, sessionId);
return next;
});
}
if (sidePanelOpenTabsRef.current.has(tabId)) return;
const targetPanel = resolveSessionSidePanelAutoOpen({
@@ -588,7 +610,11 @@ const TerminalLayerInner: React.FC<TerminalLayerProps> = ({
// saved default that may omit file transfer — only when SFTP is actually
// available. Ordinary auto-open still yields to the saved default.
if (targetPanel !== "sftp") {
if (applyWorkspaceLayoutPresetForSession(presetSession, tabId)) return;
if (applyWorkspaceLayoutPresetForSession(
presetSession,
tabId,
presetSession.id === sessionId ? sftpHostOverride : undefined,
)) return;
if (!targetPanel) return;
}
@@ -596,6 +622,7 @@ const TerminalLayerInner: React.FC<TerminalLayerProps> = ({
if (targetPanel === 'sftp') {
sftpPaneClosedTabIdsRef.current.delete(tabId);
setSftpHostSourceSessionForTab(prev => new Map(prev).set(tabId, presetSession.id));
const host = hostsRef.current.find(h => h.id === presetSession.hostId);
const hostWithOverrides: Host = host
? {
@@ -616,7 +643,7 @@ const TerminalLayerInner: React.FC<TerminalLayerProps> = ({
setSftpHostForTab(prev => {
const next = new Map(prev);
next.set(tabId, hostWithOverrides);
next.set(tabId, presetSession.id === sessionId && sftpHostOverride ? sftpHostOverride : hostWithOverrides);
return next;
});
} else if (targetPanel === 'ai') {
@@ -753,6 +780,15 @@ const TerminalLayerInner: React.FC<TerminalLayerProps> = ({
// The host to pass to the SFTP panel - stored when the user opens SFTP
const [sftpHostForTab, setSftpHostForTab] = useState<Map<string, Host>>(new Map());
const [sftpHostSourceSessionForTab, setSftpHostSourceSessionForTab] = useState<Map<string, string>>(new Map());
const [sftpAuthHostBySessionId, setSftpAuthHostBySessionId] = useState<Map<string, Host>>(new Map());
useEffect(() => {
setSftpAuthHostBySessionId(prev => {
const liveIds = new Set(sessions.map(session => session.id));
const next = new Map([...prev].filter(([id]) => liveIds.has(id)));
return next.size === prev.size ? prev : next;
});
}, [sessions]);
const [sftpInitialLocationForTab, setSftpInitialLocationForTab] = useState<
Map<string, { hostId: string; path: string }>
>(new Map());
@@ -765,6 +801,8 @@ const TerminalLayerInner: React.FC<TerminalLayerProps> = ({
const notesOpenRequestIdRef = useRef(0);
const sftpHostForTabRef = useRef(sftpHostForTab);
sftpHostForTabRef.current = sftpHostForTab;
const sftpHostSourceSessionForTabRef = useRef(sftpHostSourceSessionForTab);
sftpHostSourceSessionForTabRef.current = sftpHostSourceSessionForTab;
const sftpActiveTransfersByTabRef = useRef<Map<string, number>>(new Map());
const sftpActiveExternalEditsByTabRef = useRef<Map<string, number>>(new Map());
const sftpRetainedAfterCloseTabIdsRef = useRef<Set<string>>(new Set());
@@ -812,6 +850,12 @@ const TerminalLayerInner: React.FC<TerminalLayerProps> = ({
next.delete(tabId);
return next;
});
setSftpHostSourceSessionForTab(prev => {
if (!prev.has(tabId)) return prev;
const next = new Map(prev);
next.delete(tabId);
return next;
});
setSftpPendingUploadsForTab(prev => {
if (!prev.has(tabId)) return prev;
const next = new Map(prev);
@@ -997,14 +1041,7 @@ const TerminalLayerInner: React.FC<TerminalLayerProps> = ({
// Compare full endpoint identity so that session-time overrides
// (different port/protocol for the same host ID) trigger a switch
// instead of toggling the panel closed.
const isSameEndpoint = currentHost
&& currentHost.id === host.id
&& currentHost.hostname === host.hostname
&& currentHost.port === host.port
&& currentHost.protocol === host.protocol
&& currentHost.username === host.username
&& currentHost.sftpSudo === host.sftpSudo
&& (currentHost.sftpFileProtocol || "auto") === (host.sftpFileProtocol || "auto");
const isSameEndpoint = isSameSftpHostSelection(currentHost, host);
const currentLayout = sidePanelLayoutsRef.current.get(tabId);
const paneCount = currentLayout
@@ -1041,6 +1078,18 @@ const TerminalLayerInner: React.FC<TerminalLayerProps> = ({
next.set(tabId, host);
return next;
});
setSftpHostSourceSessionForTab(prev => {
const next = new Map(prev);
if (originSessionId) next.set(tabId, originSessionId);
else next.delete(tabId);
return next;
});
if (originSessionId) {
const sessionHost = sessionHostsMapRef.current.get(originSessionId);
if (sessionHost && (host.username !== sessionHost.username || host.identityId !== sessionHost.identityId)) {
setSftpAuthHostBySessionId(prev => new Map(prev).set(originSessionId, host));
}
}
const { connectionKey, effectiveInitialPath } = resolveSftpOpenTarget({
tabId,
@@ -1575,16 +1624,35 @@ const TerminalLayerInner: React.FC<TerminalLayerProps> = ({
sftpPaneClosedTabIdsRef.current.delete(tabId);
}
if (tab === 'sftp') {
const sourceSessionId = getActiveTerminalSessionId();
const sessionHost = sourceSessionId ? sessionHostsMapRef.current.get(sourceSessionId) : undefined;
const authHost = sourceSessionId ? sftpAuthHostBySessionId.get(sourceSessionId) : undefined;
const storedHost = sftpHostForTabRef.current.get(tabId);
if (sourceSessionId && sessionHost && authHost
&& (!storedHost || sftpHostSourceSessionForTabRef.current.has(tabId)
|| (storedHost.id === sessionHost.id
&& storedHost.hostname === sessionHost.hostname
&& (storedHost.port || 22) === (sessionHost.port || 22)))) {
setSftpHostForTab(prev => new Map(prev).set(tabId, authHost));
setSftpHostSourceSessionForTab(prev => new Map(prev).set(tabId, sourceSessionId));
}
}
// If switching to SFTP and no host is stored yet, resolve it
if (tab === 'sftp' && !sftpHostForTabRef.current.has(tabId)) {
const host = resolveSftpHostForTab(tabId);
const sourceSessionId = getActiveTerminalSessionId();
const host = (sourceSessionId && sftpAuthHostBySessionId.get(sourceSessionId))
?? resolveSftpHostForTab(tabId);
if (!host) return;
if (sourceSessionId) {
setSftpHostSourceSessionForTab(prev => new Map(prev).set(tabId, sourceSessionId));
}
setSftpHostForTab(prev => {
const next = new Map(prev);
next.set(tabId, host);
return next;
});
const sourceSessionId = getActiveTerminalSessionId();
const { effectiveInitialPath } = resolveSftpOpenTarget({
tabId,
host,
@@ -1606,7 +1674,7 @@ const TerminalLayerInner: React.FC<TerminalLayerProps> = ({
// explicitly closes the now-idle panel, preserving the completed history.
markSidePanelSubTabOpened(tabId, tab);
return true;
}, [getActiveTerminalSessionId, markSidePanelSubTabOpened, resolveSftpHostForTab, resolveSftpOpenTarget]);
}, [getActiveTerminalSessionId, markSidePanelSubTabOpened, resolveSftpHostForTab, resolveSftpOpenTarget, sftpAuthHostBySessionId]);
// Switch only the focused pane. If the tool is already open elsewhere in
// the tree, the domain helper focuses that pane instead of duplicating it.
@@ -2544,6 +2612,7 @@ const TerminalLayerInner: React.FC<TerminalLayerProps> = ({
setSidePanelHeight,
setSftpFollowTerminalCwd,
setSftpHostForTab,
setSftpHostSourceSessionForTab,
setSftpInitialLocationForTab,
onSaveWorkspaceLayoutAsDefault: handleSaveWorkspaceLayoutAsDefault,
setSftpPendingUploadsForTab,
@@ -2558,6 +2627,8 @@ const TerminalLayerInner: React.FC<TerminalLayerProps> = ({
sftpDoubleClickBehavior,
sftpFollowTerminalCwd,
sftpHostForTab,
sftpHostSourceSessionForTab,
sftpAuthHostBySessionId,
sftpInitialLocationForTab,
sftpPendingUploadsForTab,
sftpPaneClosedTabIdsRef,
@@ -0,0 +1,128 @@
import assert from 'node:assert/strict';
import test from 'node:test';
import { JSDOM } from 'jsdom';
import type { Identity } from '../../types';
import type { Host } from '../../types';
import type { PendingAuth } from './runtime/createTerminalSessionStarters';
test('saved password identity retries with its username and supports both submit paths', async () => {
const dom = new JSDOM('<!doctype html><html><body><div id="root"></div></body></html>', {
pretendToBeVisual: true,
url: 'http://localhost',
});
const { window } = dom;
const previousGlobals = new Map<string, PropertyDescriptor | undefined>();
const installGlobal = (key: string, value: unknown) => {
previousGlobals.set(key, Object.getOwnPropertyDescriptor(globalThis, key));
Object.defineProperty(globalThis, key, { configurable: true, writable: true, value });
};
class ResizeObserverStub {
observe() {}
unobserve() {}
disconnect() {}
}
for (const key of [
'window', 'document', 'navigator', 'HTMLElement', 'HTMLInputElement',
'HTMLTextAreaElement', 'Element', 'SVGElement', 'Node', 'NodeFilter',
'MutationObserver', 'CustomEvent', 'DOMRect', 'Event', 'KeyboardEvent',
'MouseEvent', 'PointerEvent',
]) {
installGlobal(key, key === 'window' ? window : window[key as keyof typeof window]);
}
installGlobal('getComputedStyle', window.getComputedStyle.bind(window));
installGlobal('requestAnimationFrame', window.requestAnimationFrame.bind(window));
installGlobal('cancelAnimationFrame', window.cancelAnimationFrame.bind(window));
installGlobal('ResizeObserver', ResizeObserverStub);
installGlobal('IS_REACT_ACT_ENVIRONMENT', true);
const { default: React, act } = await import('react');
const { createRoot } = await import('react-dom/client');
const { I18nProvider } = await import('../../application/i18n/I18nProvider.tsx');
const { TerminalAuthDialog } = await import('./TerminalAuthDialog.tsx');
const { useTerminalAuthState } = await import('./hooks/useTerminalAuthState.ts');
const identities: Identity[] = [
{ id: 'saved', label: 'Password B', username: 'deploy', authMethod: 'password', password: 'saved-secret', created: 0 },
{ id: 'unusable', label: 'Unreadable', username: 'root', authMethod: 'password', password: 'enc:v1:djEwdGVzdAAAAAAAAAAAAAAAAA==', created: 1 },
];
const host: Host = {
id: 'host', label: 'Host', hostname: 'localhost', port: 22, username: 'root',
identityId: 'old', authMethod: 'password', password: 'wrong', tags: [], os: 'linux',
};
const submissions: Array<{ username?: string; password?: string; save?: boolean }> = [];
const savedHosts: Host[] = [];
const logs: string[][] = [];
const Form = () => {
const pendingAuthRef = React.useRef<PendingAuth>(null);
const termRef = React.useRef({ clear() {} } as never);
const auth = useTerminalAuthState({
host, identities, pendingAuthRef, termRef,
onUpdateHost: (updated) => savedHosts.push(updated),
onStartSession: () => submissions.push({
username: pendingAuthRef.current?.username,
password: pendingAuthRef.current?.password,
save: pendingAuthRef.current?.savedToHost,
}),
setStatus: () => {}, setProgressLogs: (next) => {
logs.push(typeof next === 'function' ? next([]) : next);
},
});
return <TerminalAuthDialog
authMethod={auth.authMethod} setAuthMethod={auth.setAuthMethod}
authUsername={auth.authUsername} setAuthUsername={auth.setAuthUsername}
authPassword={auth.authPassword} setAuthPassword={auth.setAuthPassword}
selectedIdentityId={auth.selectedIdentityId} onSelectIdentity={auth.selectIdentity}
authKeyId={auth.authKeyId} setAuthKeyId={auth.setAuthKeyId}
authPassphrase={auth.authPassphrase} setAuthPassphrase={auth.setAuthPassphrase}
showAuthPassphrase={auth.showAuthPassphrase} setShowAuthPassphrase={auth.setShowAuthPassphrase}
showAuthPassword={auth.showAuthPassword} setShowAuthPassword={auth.setShowAuthPassword}
authRetryMessage="Authentication failed" keys={[]} identities={identities}
onSubmit={() => auth.submit({ saveToHost: true })}
onSubmitWithoutSave={() => auth.submit({ saveToHost: false })}
onCancel={() => {}} isValid={auth.isValid}
/>;
};
const root = createRoot(window.document.getElementById('root')!);
const button = (label: string) => (Array.from(window.document.querySelectorAll('button')) as HTMLButtonElement[])
.find(item => item.textContent?.includes(label));
try {
await act(async () => root.render(<I18nProvider locale="en"><Form /></I18nProvider>));
assert.ok(button('Use saved identity'));
await act(async () => button('Use saved identity')!.click());
assert.ok(button('Password B'));
assert.equal(button('Unreadable'), undefined);
await act(async () => button('Password B')!.click());
assert.equal((window.document.getElementById('auth-username') as HTMLInputElement).value, 'deploy');
assert.equal((window.document.getElementById('auth-password') as HTMLInputElement).value, '');
assert.equal(window.document.body.textContent?.includes('saved-secret'), false);
assert.equal(window.document.querySelector<HTMLInputElement>('#auth-password')?.outerHTML.includes('saved-secret'), false);
assert.equal((window.document.querySelector('#auth-password + button') as HTMLButtonElement).disabled, true);
await act(async () => button('Continue')!.click());
assert.deepEqual(submissions.at(-1), { username: 'deploy', password: 'saved-secret', save: false });
assert.equal(savedHosts.length, 0);
await act(async () => window.document.querySelector<HTMLButtonElement>('button[aria-haspopup="menu"]')!.click());
await act(async () => button('Continue & Save')!.click());
assert.deepEqual(submissions.at(-1), { username: 'deploy', password: 'saved-secret', save: true });
assert.equal(savedHosts.at(-1)?.identityId, 'saved');
assert.equal(savedHosts.at(-1)?.password, undefined);
assert.equal(savedHosts.at(-1)?.savePassword, true);
assert.equal(logs.flat().some((line) => line.includes('saved-secret')), false);
const passwordInput = window.document.getElementById('auth-password') as HTMLInputElement;
const setInputValue = Object.getOwnPropertyDescriptor(window.HTMLInputElement.prototype, 'value')?.set;
assert.ok(setInputValue);
await act(async () => {
setInputValue.call(passwordInput, 'manual-secret');
passwordInput.dispatchEvent(new window.Event('input', { bubbles: true }));
});
assert.ok(button('Use saved identity'), 'manual edit must clear the saved identity label');
await act(async () => button('Continue')!.click());
assert.deepEqual(submissions.at(-1), { username: 'deploy', password: 'manual-secret', save: false });
} finally {
await act(async () => root.unmount());
window.close();
for (const [key, descriptor] of previousGlobals) {
if (descriptor) Object.defineProperty(globalThis, key, descriptor);
else Reflect.deleteProperty(globalThis, key);
}
}
});
+91 -4
View File
@@ -2,11 +2,12 @@
* Terminal Authentication Dialog
* Displays auth form with password/key selection for SSH connection
*/
import { BadgeCheck, ChevronDown, Eye, EyeOff, Key, Lock, Unplug } from 'lucide-react';
import { BadgeCheck, ChevronDown, Eye, EyeOff, Key, Lock, Unplug, UserRound } from 'lucide-react';
import React from 'react';
import { useI18n } from '../../application/i18n/I18nProvider';
import { cn } from '../../lib/utils';
import { SSHKey } from '../../types';
import { Identity, SSHKey } from '../../types';
import { listPasswordAuthIdentities } from '../../domain/authIdentityPicker';
import { Button } from '../ui/button';
import { Input } from '../ui/input';
import { Label } from '../ui/label';
@@ -32,6 +33,10 @@ export interface TerminalAuthDialogProps {
setShowAuthPassword: (show: boolean) => void;
authRetryMessage: string | null;
keys: SSHKey[];
/** Keychain password identities offered for reuse on re-authentication (#3475). */
identities?: Identity[];
selectedIdentityId?: string | null;
onSelectIdentity?: (identityId: string) => void;
onSubmit: () => void;
onSubmitWithoutSave?: () => void;
onCancel: () => void;
@@ -55,6 +60,9 @@ export const TerminalAuthDialog: React.FC<TerminalAuthDialogProps> = ({
setShowAuthPassword,
authRetryMessage,
keys,
identities,
selectedIdentityId,
onSelectIdentity,
onSubmit,
onSubmitWithoutSave,
onCancel,
@@ -76,9 +84,25 @@ export const TerminalAuthDialog: React.FC<TerminalAuthDialogProps> = ({
const [keyDropdownOpen, setKeyDropdownOpen] = React.useState(false);
const [submitOptionsOpen, setSubmitOptionsOpen] = React.useState(false);
const [identityDropdownOpen, setIdentityDropdownOpen] = React.useState(false);
const selectedKey = authKeyId ? keys.find((k) => k.id === authKeyId) : null;
// Password identities reusable on re-authentication (#3475). Only rendered
// when at least one usable saved password exists; manual entry stays.
const passwordIdentities = React.useMemo(
() => listPasswordAuthIdentities(identities),
[identities],
);
const selectedIdentity = selectedIdentityId
? passwordIdentities.find((identity) => identity.id === selectedIdentityId && identity.username === authUsername)
: null;
const handleSelectIdentity = (identity: Identity) => {
onSelectIdentity?.(identity.id);
setIdentityDropdownOpen(false);
};
return (
<>
{/* Auth method tabs */}
@@ -139,9 +163,9 @@ export const TerminalAuthDialog: React.FC<TerminalAuthDialogProps> = ({
<Input
id="auth-password"
type={showAuthPassword ? 'text' : 'password'}
value={authPassword}
value={selectedIdentity ? '' : authPassword}
onChange={(e) => setAuthPassword(e.target.value)}
placeholder={t("terminal.auth.password.placeholder")}
placeholder={selectedIdentity ? t("terminal.auth.selectIdentity") : t("terminal.auth.password.placeholder")}
className={cn("pr-10", authRetryMessage && "border-destructive/50")}
autoFocus={!!authRetryMessage}
onKeyDown={handleKeyDown}
@@ -150,10 +174,73 @@ export const TerminalAuthDialog: React.FC<TerminalAuthDialogProps> = ({
type="button"
className="absolute right-3 top-1/2 -translate-y-1/2 text-muted-foreground hover:text-foreground"
onClick={() => setShowAuthPassword(!showAuthPassword)}
disabled={Boolean(selectedIdentity)}
>
{showAuthPassword ? <EyeOff size={16} /> : <Eye size={16} />}
</button>
</div>
{onSelectIdentity && passwordIdentities.length > 0 && (
<Popover open={identityDropdownOpen} onOpenChange={setIdentityDropdownOpen}>
<PopoverTrigger asChild>
<button
type="button"
className={cn(
"w-full flex items-center gap-2.5 px-3 py-2 rounded-lg border transition-colors text-left",
selectedIdentity
? "border-primary bg-primary/5"
: "border-border/50 hover:bg-secondary/50"
)}
>
<div className="h-6 w-6 rounded-md flex items-center justify-center shrink-0 bg-primary/20 text-primary">
<UserRound size={12} />
</div>
<span
className={cn(
"flex-1 min-w-0 truncate text-sm",
selectedIdentity ? "font-medium" : "text-muted-foreground"
)}
>
{selectedIdentity
? selectedIdentity.label
: t("terminal.auth.selectIdentity")}
</span>
{selectedIdentity?.username && (
<span className="shrink-0 font-mono text-xs text-muted-foreground">
{selectedIdentity.username}
</span>
)}
<ChevronDown size={14} className="text-muted-foreground shrink-0" />
</button>
</PopoverTrigger>
<PopoverContent className="p-1" align="start" style={{ width: 'var(--radix-popover-trigger-width)' }}>
<div className="max-h-60 overflow-y-auto">
{passwordIdentities.map((identity) => (
<button
key={identity.id}
className={cn(
"w-full flex items-center gap-3 px-3 py-2 rounded-md transition-colors text-left",
selectedIdentity?.id === identity.id
? "bg-primary/10 text-primary"
: "hover:bg-secondary/80"
)}
onClick={() => handleSelectIdentity(identity)}
>
<div className="h-7 w-7 rounded-md flex items-center justify-center shrink-0 bg-primary/20 text-primary">
<UserRound size={12} />
</div>
<div className="flex-1 min-w-0">
<div className="text-sm font-medium truncate">{identity.label}</div>
{identity.username && (
<div className="text-xs text-muted-foreground truncate">{identity.username}</div>
)}
</div>
<span className="shrink-0 font-mono text-xs text-muted-foreground">{'\u2022'.repeat(8)}</span>
</button>
))}
</div>
</PopoverContent>
</Popover>
)}
</div>
) : (
<>
@@ -259,3 +259,57 @@ test("disconnected observer surfaces do not advertise a reconnect they cannot pe
assert.equal(markup.includes("Start over"), false);
assert.match(markup, /Close session/);
});
test("re-auth dialog offers saved password identities from the vault (#3475)", () => {
const baseAuthProps = {
authMethod: "password" as const,
setAuthMethod: () => {},
authUsername: "root",
setAuthUsername: () => {},
authPassword: "",
setAuthPassword: () => {},
selectedIdentityId: null,
onSelectIdentity: () => {},
authKeyId: null,
setAuthKeyId: () => {},
authPassphrase: "",
setAuthPassphrase: () => {},
showAuthPassphrase: false,
setShowAuthPassphrase: () => {},
showAuthPassword: false,
setShowAuthPassword: () => {},
authRetryMessage: "Authentication failed. Please try again.",
onSubmit: () => {},
onCancel: () => {},
isValid: true,
};
const withIdentities = renderDialog({
needsAuth: true,
authProps: {
...baseAuthProps,
identities: [
{
id: "identity-1",
label: "Password B",
username: "deploy",
authMethod: "password",
password: "secret",
created: 0,
},
],
},
});
assert.match(withIdentities, /Use saved identity/);
assert.match(withIdentities, /Authentication failed\. Please try again\./);
// Stored passwords are never rendered, only masked.
assert.equal(withIdentities.includes("secret"), false);
const withoutIdentities = renderDialog({
needsAuth: true,
authProps: { ...baseAuthProps },
});
assert.equal(withoutIdentities.includes("Use saved identity"), false);
});
+4 -1
View File
@@ -391,7 +391,7 @@ function terminalViewCtxEqual(
}
function TerminalViewInner({ ctx, isPaneMagnified = false }: { ctx: TerminalViewContext; isPaneMagnified?: boolean }) {
const { Activity, Button, Clock3, Copy, Maximize2, Radio, RefreshCcw, SquareArrowOutUpRight, TerminalAutocomplete, TerminalComposeBar, TerminalConnectionDialog, TerminalContextMenu, TerminalSearchBar, Tooltip, TooltipContent, TooltipTrigger, Unplug, ZmodemOverwriteDialog, ZmodemProgressIndicator, auth, autocompleteAcceptTextRef, autocompleteCloseRef, autocompleteHostOs, autocompleteInputRef, autocompleteKeyEventRef, autocompleteRepositionRef, autocompleteSettings, canUpdateHost, chainProgress, cn, compactToolbar, lineTimestampsAvailable, containerRef, effectiveFontSize, effectiveFontWeight, effectiveTerminalProtocol, effectiveTheme, error, executeSnippet, executeSnippetCommand, handleAddSelectionToAI, handleCancelConnect, handleCloseDisconnectedSession, handleCloseSearch, handleDisconnect, handleDismissDisconnectedDialog, handleDragEnter, handleDragLeave, handleDragOver, handleDrop, handleFindNext, handleFindPrevious, handleHostKeyAddAndContinue, handleHostKeyClose, handleHostKeyContinue, handleOsc52ReadResponse, handleOsc7SetupConfirm, handleOsc7SetupOpenChange, handleReceiveYmodem, handleRetry, handleSearch, handleSendYmodem, handleTopOverlayMouseDownCapture, hasMouseTracking, host, hotkeyScheme, inWorkspace, isBroadcastEnabled, isCancelling, isComposeBarOpen, isConnectionAwaitingUserInput, isDraggingOver, isFocusMode, isFocusedPane, isLocalConnection, remoteDragDropUsesZmodem, isPluginTerminalProviderAvailable, isReconnectActive, isSerialConnection, isSearchOpen, isSupportedOs, isSystemSidebarEligible, isVisible, keyBindings, keys, knownCwdRef, needsHostKeyVerification, onCloseSession, onDetach, onDetachPointerDown, onExpandToFocus, onTogglePaneMagnification, onOpenSystem, onRename, onSplitHorizontal, onSplitVertical, onToggleBroadcast, onUpdateHost, osc52ReadPromptVisible, osc7SetupOpen, osc7SetupRunning, passwordPromptActiveRef, pendingHostKeyInfo, progressLogs, progressValue, renderControls, resolvedFontFamily, restoreState, scriptExecutionOverlay, searchMatchCount, searchFocusToken, sessionDisplayName, sessionId, workspaceId, sessionRef, setIsComposeBarOpen, setShowLogs, shouldShowConnectionDialog, showDisconnectedTerminalNotice, showConnectionControls, showLogs, showSelectionAIAction, isRestoringSelectionRef, snippets, status, sudoHintRef, sudoHintText, passwordPickerState, onPasswordPickerSelect, passwordPickerTitle, passwordPickerEmptyText, t, termRef, terminalContextActions, terminalCwdTracker, terminalPreviewVars, terminalSettings, terminalReconnectAvailable, reconnectNoticeMessage, timeLeft, toast, zmodem } = ctx;
const { Activity, Button, Clock3, Copy, Maximize2, Radio, RefreshCcw, SquareArrowOutUpRight, TerminalAutocomplete, TerminalComposeBar, TerminalConnectionDialog, TerminalContextMenu, TerminalSearchBar, Tooltip, TooltipContent, TooltipTrigger, Unplug, ZmodemOverwriteDialog, ZmodemProgressIndicator, auth, autocompleteAcceptTextRef, autocompleteCloseRef, autocompleteHostOs, autocompleteInputRef, autocompleteKeyEventRef, autocompleteRepositionRef, autocompleteSettings, canUpdateHost, chainProgress, cn, compactToolbar, lineTimestampsAvailable, containerRef, effectiveFontSize, effectiveFontWeight, effectiveTerminalProtocol, effectiveTheme, error, executeSnippet, executeSnippetCommand, handleAddSelectionToAI, handleCancelConnect, handleCloseDisconnectedSession, handleCloseSearch, handleDisconnect, handleDismissDisconnectedDialog, handleDragEnter, handleDragLeave, handleDragOver, handleDrop, handleFindNext, handleFindPrevious, handleHostKeyAddAndContinue, handleHostKeyClose, handleHostKeyContinue, handleOsc52ReadResponse, handleOsc7SetupConfirm, handleOsc7SetupOpenChange, handleReceiveYmodem, handleRetry, handleSearch, handleSendYmodem, handleTopOverlayMouseDownCapture, hasMouseTracking, host, hotkeyScheme, inWorkspace, isBroadcastEnabled, isCancelling, isComposeBarOpen, isConnectionAwaitingUserInput, isDraggingOver, isFocusMode, isFocusedPane, isLocalConnection, remoteDragDropUsesZmodem, isPluginTerminalProviderAvailable, isReconnectActive, isSerialConnection, isSearchOpen, isSupportedOs, isSystemSidebarEligible, isVisible, keyBindings, keys, identities, knownCwdRef, needsHostKeyVerification, onCloseSession, onDetach, onDetachPointerDown, onExpandToFocus, onTogglePaneMagnification, onOpenSystem, onRename, onSplitHorizontal, onSplitVertical, onToggleBroadcast, onUpdateHost, osc52ReadPromptVisible, osc7SetupOpen, osc7SetupRunning, passwordPromptActiveRef, pendingHostKeyInfo, progressLogs, progressValue, renderControls, resolvedFontFamily, restoreState, scriptExecutionOverlay, searchMatchCount, searchFocusToken, sessionDisplayName, sessionId, workspaceId, sessionRef, setIsComposeBarOpen, setShowLogs, shouldShowConnectionDialog, showDisconnectedTerminalNotice, showConnectionControls, showLogs, showSelectionAIAction, isRestoringSelectionRef, snippets, status, sudoHintRef, sudoHintText, passwordPickerState, onPasswordPickerSelect, passwordPickerTitle, passwordPickerEmptyText, t, termRef, terminalContextActions, terminalCwdTracker, terminalPreviewVars, terminalSettings, terminalReconnectAvailable, reconnectNoticeMessage, timeLeft, toast, zmodem } = ctx;
// Context menu only needs a snapshot at open; avoid selection state lifting into Terminal.
const [contextMenuHasSelection, setContextMenuHasSelection] = useState(false);
const isNetworkDevice = host.deviceType === 'network'
@@ -1262,6 +1262,9 @@ function TerminalViewInner({ ctx, isPaneMagnified = false }: { ctx: TerminalView
showAuthPassword: auth.showAuthPassword,
setShowAuthPassword: auth.setShowAuthPassword,
authRetryMessage: auth.authRetryMessage,
identities,
selectedIdentityId: auth.selectedIdentityId,
onSelectIdentity: auth.selectIdentity,
onSubmit: () => auth.submit(),
onSubmitWithoutSave: () => auth.submit({ saveToHost: false }),
onCancel: handleCancelConnect,
@@ -1,7 +1,8 @@
import type { Terminal as XTerm } from "@xterm/xterm";
import { useCallback, useEffect, useMemo, useState } from "react";
import type { RefObject } from "react";
import type { Host, TerminalSession } from "../../../types";
import type { Host, Identity, TerminalSession } from "../../../types";
import { listPasswordAuthIdentities } from "../../../domain/authIdentityPicker";
import type { PendingAuth } from "../runtime/createTerminalSessionStarters";
import type { TerminalAuthMethod } from "../TerminalAuthDialog";
import { logger } from "../../../lib/logger";
@@ -21,27 +22,27 @@ export const buildSavedAuthHostUpdate = (
username: string;
password: string;
keyId: string | null;
identityId?: string | null;
},
): Host => ({
...host,
username: auth.username,
authMethod: auth.authMethod,
password: auth.authMethod === "password" ? auth.password : undefined,
password: auth.identityId ? undefined : auth.authMethod === "password" ? auth.password : undefined,
savePassword: auth.authMethod === "password" ? true : host.savePassword,
identityFileId:
auth.authMethod === "key" || auth.authMethod === "certificate"
? (auth.keyId ?? undefined)
: undefined,
// Detach stale Keychain identity on explicit credential save (#1956):
// resolveHostAuth prefers identity credentials over host fields.
// Empty string (not undefined) so applyGroupDefaults treats this as an explicit
// host-level override and does not re-inherit a group-level identity; consumers
// check host.identityId truthiness so "" behaves as "no identity".
identityId: "",
// An explicitly chosen saved identity remains linked to the host. Manual
// credentials detach the stale identity; empty string prevents inheriting
// an unrelated group identity on the next connection.
identityId: auth.identityId || "",
});
export const useTerminalAuthState = ({
host,
identities,
pendingAuthRef,
termRef,
onUpdateHost,
@@ -50,6 +51,7 @@ export const useTerminalAuthState = ({
setProgressLogs,
}: {
host: Host;
identities: Identity[];
pendingAuthRef: RefObject<PendingAuth>;
termRef: RefObject<XTerm | null>;
onUpdateHost?: (host: Host) => void;
@@ -62,6 +64,7 @@ export const useTerminalAuthState = ({
const [authUsername, setAuthUsername] = useState(host.username || "root");
const [authMethod, setAuthMethod] = useState<TerminalAuthMethod>("password");
const [authPassword, setAuthPassword] = useState("");
const [selectedIdentityId, setSelectedIdentityId] = useState<string | null>(null);
const [authKeyId, setAuthKeyId] = useState<string | null>(null);
const [authPassphrase, setAuthPassphrase] = useState("");
const [showAuthPassword, setShowAuthPassword] = useState(false);
@@ -73,6 +76,7 @@ export const useTerminalAuthState = ({
setAuthRetryMessage(null);
setAuthUsername(host.username || "root");
setAuthPassword("");
setSelectedIdentityId(null);
setAuthKeyId(null);
setAuthPassphrase("");
setShowAuthPassword(false);
@@ -80,12 +84,38 @@ export const useTerminalAuthState = ({
setSaveCredentials(true);
}, [host.id, host.username]);
const selectedIdentity = useMemo(() =>
selectedIdentityId
? listPasswordAuthIdentities(identities).find((identity) => identity.id === selectedIdentityId && identity.username === authUsername)
: undefined,
[authUsername, identities, selectedIdentityId]);
const isValid = useMemo(() => {
if (!authUsername.trim()) return false;
if (authMethod === "password") return isAuthPasswordProvided(authPassword);
if (authMethod === "password") return Boolean(selectedIdentity) || isAuthPasswordProvided(authPassword);
if (authMethod === "key" || authMethod === "certificate") return !!authKeyId;
return false;
}, [authKeyId, authMethod, authPassword, authUsername]);
}, [authKeyId, authMethod, authPassword, authUsername, selectedIdentity]);
const updateAuthPassword = useCallback((password: string) => {
setSelectedIdentityId(null);
setAuthPassword(password);
}, []);
const updateAuthUsername = useCallback((username: string) => {
setSelectedIdentityId(null);
setAuthUsername(username);
}, []);
const selectIdentity = useCallback((identityId: string) => {
const identity = listPasswordAuthIdentities(identities).find((item) => item.id === identityId);
if (!identity) return;
setAuthMethod("password");
setAuthUsername(identity.username);
setAuthPassword("");
setShowAuthPassword(false);
setSelectedIdentityId(identity.id);
}, [identities]);
const resetForRetry = useCallback(() => {
setNeedsAuth(false);
@@ -98,10 +128,11 @@ export const useTerminalAuthState = ({
if (!isValid) return;
const shouldSave = opts?.saveToHost ?? saveCredentials;
const password = selectedIdentity?.password ?? authPassword;
pendingAuthRef.current = {
authMethod,
username: authUsername,
password: authMethod === "password" ? authPassword : undefined,
password: authMethod === "password" ? password : undefined,
keyId:
authMethod === "key" || authMethod === "certificate"
? (authKeyId ?? undefined)
@@ -118,8 +149,9 @@ export const useTerminalAuthState = ({
buildSavedAuthHostUpdate(host, {
authMethod,
username: authUsername,
password: authPassword,
password,
keyId: authKeyId,
identityId: authMethod === "password" ? selectedIdentity?.id : undefined,
}),
);
}
@@ -152,6 +184,7 @@ export const useTerminalAuthState = ({
onUpdateHost,
pendingAuthRef,
saveCredentials,
selectedIdentity,
setProgressLogs,
setStatus,
termRef,
@@ -164,11 +197,13 @@ export const useTerminalAuthState = ({
authRetryMessage,
setAuthRetryMessage,
authUsername,
setAuthUsername,
setAuthUsername: updateAuthUsername,
authMethod,
setAuthMethod,
authPassword,
setAuthPassword,
setAuthPassword: updateAuthPassword,
selectedIdentityId,
selectIdentity,
authKeyId,
setAuthKeyId,
authPassphrase,
@@ -2869,6 +2869,14 @@ test("startSSH does not use unsaved retry passwords for sudo autofill", async ()
await createTerminalSessionStarters(ctx as never).startSSH(createTermStub() as never);
assert.equal(captured?.password, "temporary-secret");
assert.equal(captured?.sftpReuseOptions, undefined);
// A different login user must never alias the original profile to SFTP.
Object.assign(ctx.host, { password: undefined });
ctx.pendingAuthRef.current.username = "deploy";
captured = undefined;
await createTerminalSessionStarters(ctx as never).startSSH(createTermStub() as never);
assert.equal(captured?.username, "deploy");
assert.equal(captured?.sftpReuseOptions, undefined);
});
test("startSSH uses pending saved auth for sudo autofill on the first saved connection", async () => {
@@ -675,7 +675,9 @@ export const createTerminalSessionStarters = (ctx: TerminalSessionStartersContex
requiresMfa: !!ctx.host.requiresMfa,
port: ctx.host.port || 22,
password: attempt.password,
sftpReuseOptions: sftpReuseOptions?.password ? undefined : sftpReuseOptions,
sftpReuseOptions: sftpReuseOptions?.password || sftpReuseOptions?.username !== effectiveUsername
? undefined
: sftpReuseOptions,
privateKey: attempt.key?.source === 'reference' ? undefined : (sanitizeCredentialValue(attempt.key?.privateKey) || undefined),
certificate: attempt.key?.certificate,
publicKey: attempt.key?.publicKey,
@@ -11,6 +11,7 @@ import {
resolveTerminalDropErrorMessage,
} from "./hooks/useTerminalDragDrop";
import { resolvePreferredTerminalCwd } from "./sftpCwd";
import { resolvePasswordAuthSftpHost } from "../../domain/authIdentityPicker";
const host = {
id: "host-1",
@@ -610,6 +611,36 @@ test("remote SSH folder drop to /root uses a resolved identity password", async
assert.equal(openedHost?.identityId, "id-1");
});
test("folder drop after choosing deploy identity keeps its saved sudo password", async () => {
const rootHost = { ...host, username: "root", identityId: "root-id" };
const identity = {
id: "deploy-id", label: "Deploy", username: "deploy", authMethod: "password" as const,
password: "deploy-secret", created: 0,
};
const sftpHost = resolvePasswordAuthSftpHost(rootHost, [identity], {
authMethod: "password", username: "deploy", password: "deploy-secret", savedToHost: false,
});
let openedHost: Host | undefined;
await handleTerminalDropEntries({
dropEntries: [{ file: null, relativePath: "docs", isDirectory: true }],
host: sftpHost,
resolvedLoginUsername: sftpHost.username,
resolvedSudoPassword: identity.password,
isLocalConnection: false,
onOpenSftp: (nextHost) => { openedHost = nextHost; },
resolveSftpInitialPath: async () => "/root",
scrollToBottomAfterProgrammaticInput: () => {},
sessionId: "session-1",
sessionRef: { current: "session-1" },
terminalBackend: { writeToSession: () => {} },
termRef: { current: null },
});
assert.equal(openedHost?.username, "deploy");
assert.equal(openedHost?.identityId, "deploy-id");
assert.equal(openedHost?.sftpSudo, true);
assert.equal(rootHost.identityId, "root-id");
});
test("remote SSH folder drop to /root fails closed without a saved sudo password", async () => {
let openedSftp = false;
+1 -1
View File
@@ -176,7 +176,7 @@ export interface TerminalProps {
keyBindings?: KeyBinding[];
onHotkeyAction?: (action: string, event: KeyboardEvent) => void;
onTerminalFontSizeChange?: (fontSize: number) => void;
onStatusChange?: (sessionId: string, status: TerminalSession["status"]) => void;
onStatusChange?: (sessionId: string, status: TerminalSession["status"], sftpHost?: Host) => void;
onSessionExit?: (sessionId: string, evt: TerminalSessionExitEvent) => void;
onTerminalDataCapture?: (sessionId: string, data: string) => void;
onOsDetected?: (hostId: string, distro: string) => void;
@@ -805,7 +805,7 @@ interface TerminalPaneProps {
onOpenTheme: () => void;
onOpenSystem?: () => void;
onCloseSession: (sessionId: string) => void;
onStatusChange: (sessionId: string, status: TerminalSession['status']) => void;
onStatusChange: (sessionId: string, status: TerminalSession['status'], sftpHost?: Host) => void;
onSessionExit: (sessionId: string, evt: TerminalSessionExitEvent) => void;
onTerminalDataCapture?: (sessionId: string, data: string) => void;
onOsDetected: (hostId: string, distro: string) => void;
@@ -1707,7 +1707,7 @@ interface TerminalPanesHostProps {
onOpenTheme: () => void;
onOpenSystem?: () => void;
onCloseSession: (sessionId: string) => void;
onStatusChange: (sessionId: string, status: TerminalSession['status']) => void;
onStatusChange: (sessionId: string, status: TerminalSession['status'], sftpHost?: Host) => void;
onSessionExit: (sessionId: string, evt: TerminalSessionExitEvent) => void;
onTerminalDataCapture?: (sessionId: string, data: string) => void;
onOsDetected: (hostId: string, distro: string) => void;
@@ -11,6 +11,7 @@ import { useSystemCapabilitiesWarmup } from '../../application/state/useSystemMa
import { cn } from '../../lib/utils';
import type { Host, TerminalSession, Workspace } from '../../types';
import { resolveTerminalHibernateEnabled } from '../../domain/terminalHibernate';
import { resolveTerminalSftpHost } from '../../domain/sftpTerminalIdentity';
import { shouldMeasureTerminalLayerLayout } from '../terminalPaneVisibility';
import { TerminalLayerView } from './TerminalLayerView';
import { useTerminalAiContexts } from '../../application/state/useTerminalAiContexts';
@@ -47,6 +48,8 @@ export function TerminalLayerTabBridge({ stableRef }: { stableRef: StableRef })
const sessions = s.sessions as TerminalSession[];
const sessionHostsMap = s.sessionHostsMap as Map<string, Host>;
const sftpHostForTab = s.sftpHostForTab as Map<string, Host>;
const sftpHostSourceSessionForTab = s.sftpHostSourceSessionForTab as Map<string, string>;
const sftpAuthHostBySessionId = s.sftpAuthHostBySessionId as Map<string, Host>;
const sidePanelOpenTabs = s.sidePanelOpenTabs as Map<string, SidePanelTab>;
const sidePanelLayouts = s.sidePanelLayouts as Map<string, SidePanelLayout>;
const showHostTreeSidebar = s.showHostTreeSidebar as boolean | undefined;
@@ -134,14 +137,19 @@ export function TerminalLayerTabBridge({ stableRef }: { stableRef: StableRef })
const sftpActiveHost = useMemo((): Host | null => {
if (!isSftpOpenForCurrentTab || !activeTabId) return null;
if (activeWorkspace && focusedSessionId) {
return sessionHostsMap.get(focusedSessionId) ?? sftpHostForTab.get(activeTabId) ?? null;
}
if (activeSession) {
return sessionHostsMap.get(activeSession.id) ?? sftpHostForTab.get(activeTabId) ?? null;
}
return sftpHostForTab.get(activeTabId) ?? null;
}, [activeSession, activeTabId, activeWorkspace, focusedSessionId, isSftpOpenForCurrentTab, sessionHostsMap, sftpHostForTab]);
const sessionId = activeWorkspace ? focusedSessionId : activeSession?.id;
const sessionHost = sessionId ? sessionHostsMap.get(sessionId) : null;
const stored = sftpHostForTab.get(activeTabId);
const authHost = sessionId && sftpHostSourceSessionForTab.has(activeTabId)
? sftpAuthHostBySessionId.get(sessionId)
: undefined;
return resolveTerminalSftpHost({
sessionHost,
storedHost: stored,
authenticatedHost: authHost,
followsTerminal: sftpHostSourceSessionForTab.has(activeTabId),
});
}, [activeSession?.id, activeTabId, activeWorkspace, focusedSessionId, isSftpOpenForCurrentTab, sessionHostsMap, sftpAuthHostBySessionId, sftpHostForTab, sftpHostSourceSessionForTab]);
// Keep the same-endpoint SSH session id across disconnected/connecting so
// SftpSidePanel can observe status transitions and rebind after Start over.
@@ -155,11 +163,14 @@ export function TerminalLayerTabBridge({ stableRef }: { stableRef: StableRef })
const sessionHost = sessionHostsMap.get(session.id);
if (!sessionHost) return null;
const sameEndpoint =
sessionHost.hostname === sftpActiveHost.hostname
sessionHost.id === sftpActiveHost.id
&& sessionHost.hostname === sftpActiveHost.hostname
&& (sessionHost.port || 22) === (sftpActiveHost.port || 22)
&& (sessionHost.username || 'root') === (sftpActiveHost.username || 'root');
&& ((sessionHost.username || 'root') === (sftpActiveHost.username || 'root')
|| (activeTabId && sftpHostSourceSessionForTab.has(activeTabId)
&& sftpAuthHostBySessionId.get(session.id) === sftpActiveHost));
return sameEndpoint ? session.id : null;
}, [activeSession?.id, activeWorkspace, focusedSessionId, isSftpOpenForCurrentTab, sessions, sessionHostsMap, sftpActiveHost]);
}, [activeSession?.id, activeTabId, activeWorkspace, focusedSessionId, isSftpOpenForCurrentTab, sessions, sessionHostsMap, sftpActiveHost, sftpAuthHostBySessionId, sftpHostSourceSessionForTab]);
const linkedTerminalSessionIdForSftp = useMemo((): string | null => {
if (!isSftpOpenForCurrentTab) return null;
@@ -375,6 +386,7 @@ export function TerminalLayerTabBridge({ stableRef }: { stableRef: StableRef })
Set,
setDropHint,
setSftpHostForTab: s.setSftpHostForTab,
setSftpHostSourceSessionForTab: s.setSftpHostSourceSessionForTab,
setSftpInitialLocationForTab: s.setSftpInitialLocationForTab,
setSftpPendingUploadsForTab: s.setSftpPendingUploadsForTab,
setAiMountedTabIds: s.setAiMountedTabIds,
@@ -13,6 +13,7 @@ import {
resolveSftpTransferNavigationTarget,
} from '../../domain/sftpTransferNavigation';
import { collectSidePanelPanes, sidePanelLayoutHasTool } from '../../domain/sidePanelLayout';
import { isSameSftpHostSelection } from '../../domain/sftpTerminalIdentity';
import { collectSessionIds } from '../../domain/workspace';
import {
moveSidePanelTabMap,
@@ -108,7 +109,7 @@ export function pruneTerminalTabMemoryState(
export function useTerminalLayerEffects(ctx: TerminalLayerEffectsContext) {
const { openPath } = useSftpBackend();
const { activeSidePanelTab, activeSidePanelLayout, activeTabId, activeTabIdRef, activeWorkspace, activityTrackedSessions, cancelAnimationFrame, ChunkedEscapeFilter, clearTopTabsPreviewVars, document, dropHint, effectiveHosts, filterTabsMap, focusedSessionId, getSessionActivityIdsToClear, handleToggleAiFromTopBar, handleToggleScriptsSidePanel, handleToggleSidePanel, hasNotifiableTerminalOutput, isComposeBarOpen, isFocusMode, isTerminalLayerVisible, lastSidePanelTabRef, Map, onConnectToHost, onSessionData, onSplitSessionRef, onToggleBroadcastRef, onToggleWorkspaceViewModeRef, prevFocusedSessionIdRef, refocusActiveTerminalSession, requestAnimationFrame, ResizeObserver, sessionActivityStore, sessions, Set, setAiMountedTabIds, setDropHint, setNotesMountedTabIds, setScriptsMountedTabIds, setSystemMountedTabIds, setSftpHostForTab, setSftpInitialLocationForTab, setSftpPendingUploadsForTab, setSidePanelOpenTabs, setSidePanelLayouts, setThemeMountedTabIds, setWorkspaceArea, shouldMeasureTerminalLayerLayout, sidePanelPosition, sidePanelHeight, sidePanelWidth, sftpActiveHost, sftpHostForTab, sftpPaneClosedTabIdsRef, shouldMarkSessionActivity, sidePanelOpenTabs, splitHorizontalHandlersRef, splitVerticalHandlersRef, toggleScriptsSidePanelRef, toggleSidePanelRef, validAIScopeTargetIds, validSessionActivityIds, window, workspaceBroadcastHandlersRef, workspaceFocusHandlersRef, workspaceInnerRef, workspaces } = ctx;
const { activeSidePanelTab, activeSidePanelLayout, activeTabId, activeTabIdRef, activeWorkspace, activityTrackedSessions, cancelAnimationFrame, ChunkedEscapeFilter, clearTopTabsPreviewVars, document, dropHint, effectiveHosts, filterTabsMap, focusedSessionId, getSessionActivityIdsToClear, handleToggleAiFromTopBar, handleToggleScriptsSidePanel, handleToggleSidePanel, hasNotifiableTerminalOutput, isComposeBarOpen, isFocusMode, isTerminalLayerVisible, lastSidePanelTabRef, Map, onConnectToHost, onSessionData, onSplitSessionRef, onToggleBroadcastRef, onToggleWorkspaceViewModeRef, prevFocusedSessionIdRef, refocusActiveTerminalSession, requestAnimationFrame, ResizeObserver, sessionActivityStore, sessions, Set, setAiMountedTabIds, setDropHint, setNotesMountedTabIds, setScriptsMountedTabIds, setSystemMountedTabIds, setSftpHostForTab, setSftpHostSourceSessionForTab, setSftpInitialLocationForTab, setSftpPendingUploadsForTab, setSidePanelOpenTabs, setSidePanelLayouts, setThemeMountedTabIds, setWorkspaceArea, shouldMeasureTerminalLayerLayout, sidePanelPosition, sidePanelHeight, sidePanelWidth, sftpActiveHost, sftpHostForTab, sftpPaneClosedTabIdsRef, shouldMarkSessionActivity, sidePanelOpenTabs, splitHorizontalHandlersRef, splitVerticalHandlersRef, toggleScriptsSidePanelRef, toggleSidePanelRef, validAIScopeTargetIds, validSessionActivityIds, window, workspaceBroadcastHandlersRef, workspaceFocusHandlersRef, workspaceInnerRef, workspaces } = ctx;
const activeWorkspaceId = activeWorkspace?.id;
const activeWorkspaceViewMode = activeWorkspace?.viewMode;
@@ -199,6 +200,13 @@ export function useTerminalLayerEffects(ctx: TerminalLayerEffectsContext) {
}
return next;
});
setSftpHostSourceSessionForTab((prev: Map<string, string>) => {
let next = prev;
for (const remap of remaps) {
next = moveSidePanelTabMap(next, remap);
}
return next;
});
setSftpInitialLocationForTab((prev: Map<string, any>) => {
let next = prev;
for (const remap of remaps) {
@@ -263,6 +271,7 @@ export function useTerminalLayerEffects(ctx: TerminalLayerEffectsContext) {
setNotesMountedTabIds,
setScriptsMountedTabIds,
setSftpHostForTab,
setSftpHostSourceSessionForTab,
setSftpInitialLocationForTab,
setSftpPendingUploadsForTab,
setSidePanelLayouts,
@@ -406,6 +415,7 @@ export function useTerminalLayerEffects(ctx: TerminalLayerEffectsContext) {
useEffect(() => {
setSidePanelOpenTabs(prev => filterTabsMap(prev, validAIScopeTargetIds));
setSftpHostForTab(prev => filterTabsMap(prev, validAIScopeTargetIds));
setSftpHostSourceSessionForTab(prev => filterTabsMap(prev, validAIScopeTargetIds));
setSftpInitialLocationForTab(prev => filterTabsMap(prev, validAIScopeTargetIds));
setSftpPendingUploadsForTab(prev => filterTabsMap(prev, validAIScopeTargetIds));
setAiMountedTabIds((prev) => prev.filter((tabId) => validAIScopeTargetIds.has(tabId)));
@@ -490,10 +500,7 @@ export function useTerminalLayerEffects(ctx: TerminalLayerEffectsContext) {
if (!activeTabId || !sftpActiveHost) return;
if (!sidePanelLayoutHasTool(activeSidePanelLayout, 'sftp')) return;
const stored = sftpHostForTab.get(activeTabId);
if (stored?.id === sftpActiveHost.id
&& stored?.hostname === sftpActiveHost.hostname
&& stored?.port === sftpActiveHost.port
&& stored?.protocol === sftpActiveHost.protocol) return;
if (isSameSftpHostSelection(stored, sftpActiveHost)) return;
setSftpHostForTab(prev => {
const next = new Map(prev);
next.set(activeTabId, sftpActiveHost);
@@ -531,6 +538,11 @@ export function useTerminalLayerEffects(ctx: TerminalLayerEffectsContext) {
// Bump initialLocation even when the host is already selected so the
// path-navigation effect re-runs after reopen.
setSftpHostForTab((prev: Map<string, any>) => new Map(prev).set(tabId, host));
setSftpHostSourceSessionForTab((prev: Map<string, string>) => {
const next = new Map(prev);
next.delete(tabId);
return next;
});
setSftpInitialLocationForTab((prev: Map<string, any>) => {
const next = new Map(prev);
next.delete(tabId);
@@ -657,7 +669,7 @@ export function useTerminalLayerEffects(ctx: TerminalLayerEffectsContext) {
};
window.addEventListener('netcatty:open-sftp-transfer-target', handler);
return () => window.removeEventListener('netcatty:open-sftp-transfer-target', handler);
}, [activeTabIdRef, effectiveHosts, onConnectToHost, openPath, setSftpHostForTab, setSftpInitialLocationForTab, setSidePanelOpenTabs, sftpActiveHost, sftpHostForTab, window]);
}, [activeTabIdRef, effectiveHosts, onConnectToHost, openPath, setSftpHostForTab, setSftpHostSourceSessionForTab, setSftpInitialLocationForTab, setSidePanelOpenTabs, sftpActiveHost, sftpHostForTab, window]);
useEffect(() => {
const sessionIdsToClear = getSessionActivityIdsToClear(activeTabId, sessions);
+58
View File
@@ -0,0 +1,58 @@
import test from "node:test";
import assert from "node:assert/strict";
import { listPasswordAuthIdentities, resolvePasswordAuthSftpHost } from "./authIdentityPicker.ts";
import type { Identity } from "./models.ts";
import type { Host } from "./models.ts";
const passwordIdentity = (overrides: Partial<Identity> = {}): Identity => ({
id: "id-1",
label: "Default password",
username: "root",
authMethod: "password",
password: "secret",
created: 0,
...overrides,
});
test("keeps only password identities with a usable stored password", () => {
const identities: Identity[] = [
passwordIdentity(),
passwordIdentity({ id: "id-2", label: "Password B", username: "deploy" }),
passwordIdentity({ id: "id-3", authMethod: "key", keyId: "key-1" }),
passwordIdentity({ id: "id-4", password: undefined }),
passwordIdentity({ id: "id-5", password: "" }),
passwordIdentity({ id: "id-6", password: "enc:v1:djEwdGVzdAAAAAAAAAAAAAAAAA==" }),
passwordIdentity({ id: "id-7", username: "" }),
passwordIdentity({ id: "id-8", username: " " }),
];
const result = listPasswordAuthIdentities(identities);
assert.deepEqual(
result.map((identity) => identity.id),
["id-1", "id-2"],
);
});
test("tolerates missing identity lists", () => {
assert.deepEqual(listPasswordAuthIdentities(undefined), []);
assert.deepEqual(listPasswordAuthIdentities([]), []);
});
test("temporary deploy identity carries into SFTP without changing the saved host", () => {
const host = { id: "host-1", hostname: "example.test", username: "root", identityId: "root-id" } as Host;
const identities = [
passwordIdentity({ id: "root-id", password: "bad-root" }),
passwordIdentity({ id: "deploy-id", username: "deploy", password: "good-deploy" }),
];
const auth = { authMethod: "password", username: "deploy", password: "good-deploy", savedToHost: false };
assert.deepEqual(resolvePasswordAuthSftpHost(host, identities, auth), {
...host, username: "deploy", identityId: "deploy-id",
});
assert.equal(host.username, "root");
assert.equal(host.identityId, "root-id");
assert.equal(resolvePasswordAuthSftpHost(host, identities, { ...auth, password: "manual" }), host);
assert.equal(resolvePasswordAuthSftpHost(host, identities, { ...auth, savedToHost: true }), host);
assert.equal(resolvePasswordAuthSftpHost(host, identities, null), host);
});
+37
View File
@@ -0,0 +1,37 @@
import type { Host, Identity } from "./models";
import { sanitizeCredentialValue } from "./credentials";
/**
* Saved password identities that can be reused from the terminal
* re-authentication dialog. Only identities carrying a usable stored
* password and username qualify. Key identities are already selectable
* through the existing SSH key picker (#3475). Undecryptable vault ciphertext
* (`enc:v1:` placeholders left when hydration fails) is filtered out so
* those identities fall back to manual password re-entry.
*/
export const listPasswordAuthIdentities = (
identities: readonly Identity[] | undefined,
): Identity[] =>
(identities ?? []).filter(
(identity) =>
identity.authMethod === "password"
&& typeof identity.username === "string"
&& identity.username.trim().length > 0
&& typeof identity.password === "string"
&& (sanitizeCredentialValue(identity.password) ?? "").length > 0,
);
/** Use the temporary saved identity for SFTP opened from its authenticated terminal. */
export const resolvePasswordAuthSftpHost = (
host: Host,
identities: readonly Identity[] | undefined,
auth: { authMethod: string; username: string; password?: string; savedToHost?: boolean } | null,
): Host => {
if (!auth || auth.authMethod !== "password" || auth.savedToHost) return host;
const identity = listPasswordAuthIdentities(identities).find(
(candidate) => candidate.username === auth.username && candidate.password === auth.password,
);
return identity
? { ...host, username: identity.username, identityId: identity.id }
: host;
};
+45
View File
@@ -0,0 +1,45 @@
import assert from "node:assert/strict";
import test from "node:test";
import type { Host } from "./models.ts";
import { isSameSftpHostSelection, resolveTerminalSftpHost } from "./sftpTerminalIdentity.ts";
const host = (id: string, username: string): Host => ({
id, label: id, hostname: `${id}.example.test`, port: 22, username,
tags: [], os: "linux",
});
test("SFTP target changes when a focused session uses another saved identity", () => {
const root = host("shared", "root");
const deploy = { ...root, username: "deploy", identityId: "deploy-id" };
const alternateDeploy = { ...deploy, identityId: "alternate-deploy-id" };
assert.equal(isSameSftpHostSelection(root, deploy), false);
assert.equal(isSameSftpHostSelection(deploy, alternateDeploy), false);
assert.equal(isSameSftpHostSelection(deploy, { ...deploy }), true);
assert.equal(isSameSftpHostSelection(deploy, { ...deploy, port: 2222 }), false);
});
test("SFTP follows each focused terminal's authenticated identity across focus changes", () => {
const root = host("shared", "root");
const deploy = { ...root, username: "deploy", identityId: "deploy-id" };
const other = host("other", "alice");
assert.equal(resolveTerminalSftpHost({
sessionHost: root, storedHost: other, authenticatedHost: deploy, followsTerminal: true,
}), deploy);
assert.equal(resolveTerminalSftpHost({
sessionHost: root, storedHost: root, authenticatedHost: deploy, followsTerminal: true,
}), deploy);
assert.equal(resolveTerminalSftpHost({
sessionHost: other, storedHost: deploy, authenticatedHost: undefined, followsTerminal: true,
}), other);
assert.equal(resolveTerminalSftpHost({
sessionHost: root, storedHost: other, authenticatedHost: deploy, followsTerminal: true,
}), deploy);
assert.equal(resolveTerminalSftpHost({
sessionHost: root, storedHost: other, authenticatedHost: deploy, followsTerminal: false,
}), root);
assert.equal(resolveTerminalSftpHost({
sessionHost: root, authenticatedHost: { ...deploy, port: 2222 }, followsTerminal: true,
}), root);
});
+35
View File
@@ -0,0 +1,35 @@
import type { Host } from "./models";
/** Compare the selected SFTP target, including the temporary login identity. */
export function isSameSftpHostSelection(left: Host | null | undefined, right: Host | null | undefined): boolean {
if (!left || !right) return false;
return left.id === right.id
&& left.hostname === right.hostname
&& left.port === right.port
&& left.protocol === right.protocol
&& left.username === right.username
&& left.identityId === right.identityId
&& left.sftpSudo === right.sftpSudo
&& (left.sftpFileProtocol || "auto") === (right.sftpFileProtocol || "auto");
}
/** Keep a terminal's authenticated identity when its SFTP panel follows that terminal. */
export function resolveTerminalSftpHost({
sessionHost,
storedHost,
authenticatedHost,
followsTerminal,
}: {
sessionHost?: Host | null;
storedHost?: Host | null;
authenticatedHost?: Host | null;
followsTerminal: boolean;
}): Host | null {
if (followsTerminal && sessionHost && authenticatedHost
&& authenticatedHost.id === sessionHost.id
&& authenticatedHost.hostname === sessionHost.hostname
&& (authenticatedHost.port || 22) === (sessionHost.port || 22)) {
return authenticatedHost;
}
return sessionHost ?? storedHost ?? null;
}
@@ -11,7 +11,7 @@ const pool = require("./sshConnectionPool.cjs");
const PASSWORD = "temporary-test-password";
async function fixture(t) {
async function fixture(t, acceptedUsername = "root") {
pool.resetSshTransportRegistryForTests({ defaultIdleTtlMs: 0 });
const { privateKey } = generateKeyPairSync("rsa", { modulusLength: 2048 });
const sockets = new Set();
@@ -25,7 +25,7 @@ async function fixture(t) {
client.on("authentication", (ctx) => {
if (ctx.method === "password") {
authRounds += 1;
if (ctx.username === "root" && ctx.password === PASSWORD) {
if (ctx.username === acceptedUsername && ctx.password === PASSWORD) {
ctx.accept();
return;
}
@@ -97,10 +97,27 @@ async function fixture(t) {
assert.equal(authRounds, 1);
return options;
};
return { original, event, login, sessions, sftpClients,
return { original, event, login, sessions, sftpClients, handlers,
counts: () => ({ authRounds, connections }) };
}
test("selected deploy identity opens SFTP on the live deploy session, never root", { timeout: 60000 }, async (t) => {
const f = await fixture(t, "deploy");
const deploy = { ...f.original, username: "deploy", password: PASSWORD };
await f.handlers.get("netcatty:start")(f.event, {
...deploy, sessionId: "terminal-3351", skipShellPidDiscovery: true,
sftpReuseOptions: f.original,
});
await assert.rejects(sftpBridge.openSftpForSession(f.event, {
sessionId: "terminal-3351", expectedEndpoint: f.original, fileProtocol: "sftp",
}), { code: "ERR_SFTP_SOURCE_ROUTE_MISMATCH" });
const result = await sftpBridge.openSftpForSession(f.event, {
sessionId: "terminal-3351", expectedEndpoint: deploy, fileProtocol: "sftp",
});
assert.equal(await f.sftpClients.get(result.sftpId).realPath("."), "/root");
assert.deepEqual(f.counts(), { authRounds: 1, connections: 1 });
});
test("unsaved terminal password supports pooled and explicit SFTP without another login (#3351)", { timeout: 60000 }, async (t) => {
const f = await fixture(t);
const options = await f.login();