Compare commits

...
Author SHA1 Message Date
Cursor Agentand陈大猫 2564c7be5e fix: reject control bytes in interactive terminal cd paths
Co-authored-by: 陈大猫 <binaricat@users.noreply.github.com>
2026-08-09 02:18:13 +00:00
netcatty-bot e124bd51b5 fix: address Codex review on PR #2825 2026-08-08 22:20:22 +00:00
netcatty-bot 23b9216572 fix: address Codex review on PR #2825 2026-08-08 21:51:57 +00:00
netcatty-bot 4a7d29d354 fix: address Codex review on PR #2825 2026-08-08 21:16:48 +00:00
Cursor Agentand陈大猫 079ff38a7b fix(sftp): wire locate-path to focused mosh/et/local sessions
activeTerminalSessionIdForSftp is null when the terminal cannot be reused
for SFTP (mosh/et/local). Fall back to the focused session for locate
writes and reject same-hostId endpoint mismatches.

Co-authored-by: 陈大猫 <binaricat@users.noreply.github.com>
2026-08-08 07:30:15 +00:00
Cursor Agentand陈大猫 4fd1a425c2 fix(sftp): block locate-path writes on sensitive prompts
Skip injecting cd when the linked terminal is at a password/sudo prompt,
matching snippet/broadcast guards. Also treat protocol mosh/et as eligible
for interactive locate (not only ssh + transport flags).

Co-authored-by: 陈大猫 <binaricat@users.noreply.github.com>
2026-08-08 07:26:20 +00:00
netcatty-bot bbf93fa73c fix(#2822): automated Cursor CLI fix 2026-08-08 02:10:09 +00:00
18 changed files with 651 additions and 2 deletions

No files matched your search

+1
View File
@@ -325,6 +325,7 @@ export const enVaultMessages: Messages = {
'sftp.transfers.dragToResize': 'Drag to resize',
'sftp.goUp': 'Go up',
'sftp.goToTerminalCwd': 'Go to terminal directory',
'sftp.locatePathInTerminal': 'Open path in terminal',
'sftp.followTerminalCwd': 'Follow terminal directory',
'sftp.followTerminalCwd.enable': 'Enable follow terminal directory',
'sftp.followTerminalCwd.disable': 'Disable follow terminal directory',
+1
View File
@@ -361,6 +361,7 @@ export const ruVaultMessages: Messages = {
'sftp.transfers.dragToResize': 'Перетащите для изменения размера',
'sftp.goUp': 'Наверх',
'sftp.goToTerminalCwd': 'Перейти в каталог терминала',
'sftp.locatePathInTerminal': 'Открыть путь в терминале',
'sftp.followTerminalCwd': 'Следовать за каталогом терминала',
'sftp.followTerminalCwd.enable': 'Включить следование за каталогом терминала',
'sftp.followTerminalCwd.disable': 'Отключить следование за каталогом терминала',
+1
View File
@@ -919,6 +919,7 @@ export const zhCNCoreMessages: Messages = {
'sftp.transfers.dragToResize': '拖拽调整高度',
'sftp.goUp': '上一级',
'sftp.goToTerminalCwd': '定位到终端当前目录',
'sftp.locatePathInTerminal': '定位路径到终端',
'sftp.followTerminalCwd': '追随终端目录',
'sftp.followTerminalCwd.enable': '开启追随终端目录',
'sftp.followTerminalCwd.disable': '关闭追随终端目录',
+1
View File
@@ -919,6 +919,7 @@ export const zhTWCoreMessages: Messages = {
'sftp.transfers.dragToResize': '拖拽調整高度',
'sftp.goUp': '上一級',
'sftp.goToTerminalCwd': '定位到終端目前目錄',
'sftp.locatePathInTerminal': '定位路徑到終端',
'sftp.followTerminalCwd': '追隨終端目錄',
'sftp.followTerminalCwd.enable': '開啟追隨終端目錄',
'sftp.followTerminalCwd.disable': '關閉追隨終端目錄',
+136
View File
@@ -72,6 +72,17 @@ import {
shouldFollowTerminalCwdNavigate,
type SftpFollowTerminalCwdBlock,
} from "./sftp/sftpFollowTerminalCwd";
import {
canLocateSftpPathInTerminal,
resolveLocateSftpPathInTerminalAction,
resolveLocateSftpPathSessionId,
} from "../domain/sftpLocatePathInTerminal";
import { classifyDistroId } from "../domain/host";
import { useTerminalBackend } from "../application/state/useTerminalBackend";
import { isTerminalSensitiveInputActive } from "./terminal/runtime/terminalSensitiveInputRegistry";
import { isTerminalReadyForCommandInjection } from "./terminal/runtime/terminalCommandInjectionReadyRegistry";
import { getNextSftpToolbarDisplayPath } from "./sftp/SftpPaneToolbar";
import { scheduleDeferredTerminalFocus } from "./systemManager/tmuxActionFocus";
import {
connectionKeyMatchesHost,
findReusableSftpSidePanelTab,
@@ -107,6 +118,8 @@ interface SftpSidePanelProps {
activeHost: Host | null;
/** The terminal session id whose SSH connection can be reused for SFTP */
activeSessionId?: string | null;
/** Focused terminal session (includes mosh/et/local) for locate-path writes */
focusedSessionId?: string | null;
initialLocation?: { hostId: string; path: string } | null;
onInitialLocationApplied?: (location: { hostId: string; path: string }) => void;
onCurrentPathChange?: (location: { hostId: string; connectionKey: string; path: string }) => void;
@@ -156,6 +169,7 @@ const SftpSidePanelInner: React.FC<SftpSidePanelProps> = ({
sftpDefaultViewMode,
activeHost,
activeSessionId,
focusedSessionId = null,
initialLocation,
onInitialLocationApplied,
onCurrentPathChange,
@@ -881,11 +895,14 @@ const SftpSidePanelInner: React.FC<SftpSidePanelProps> = ({
hosts={hosts}
hostWriteSource={hostWriteSource}
connectedHosts={connectedHosts}
sessions={sessions}
updateHosts={updateHosts}
sftp={sftp}
sftpRef={sftpRef}
sftpDefaultViewMode={sftpDefaultViewMode}
activeHost={activeHost}
activeSessionId={activeSessionId}
focusedSessionId={focusedSessionId}
showWorkspaceHostHeader={showWorkspaceHostHeader}
renderOverlays={renderOverlays}
sftpDoubleClickBehavior={sftpDoubleClickBehavior}
@@ -923,11 +940,14 @@ type SftpSidePanelInteractiveBodyProps = {
hosts: Host[];
hostWriteSource: Host[];
connectedHosts: import("../domain/sftpConnectedHosts").SftpConnectedHostEntry[];
sessions: TerminalSession[];
updateHosts: (hosts: Host[]) => void;
sftp: ReturnType<typeof useSftpState>;
sftpRef: MutableRefObject<ReturnType<typeof useSftpState>>;
sftpDefaultViewMode: "list" | "tree";
activeHost: Host | null;
activeSessionId?: string | null;
focusedSessionId?: string | null;
showWorkspaceHostHeader: boolean;
renderOverlays: boolean;
sftpDoubleClickBehavior: "open" | "transfer";
@@ -965,11 +985,14 @@ const SftpSidePanelInteractiveBody: React.FC<SftpSidePanelInteractiveBodyProps>
hosts,
hostWriteSource,
connectedHosts,
sessions,
updateHosts,
sftp,
sftpRef,
sftpDefaultViewMode,
activeHost,
activeSessionId = null,
focusedSessionId = null,
showWorkspaceHostHeader,
renderOverlays,
hotkeyScheme,
@@ -999,6 +1022,7 @@ const SftpSidePanelInteractiveBody: React.FC<SftpSidePanelInteractiveBodyProps>
}) => {
const panelRootRef = useRef<HTMLDivElement>(null);
const dialogActionScopeIdRef = useRef(`sftp-side-panel:${crypto.randomUUID()}`);
const terminalBackend = useTerminalBackend();
const [hasPaneFocus, setHasPaneFocus] = useState(false);
const [pendingFollowOverride, setPendingFollowOverride] = useState<{
hostId: string;
@@ -1234,6 +1258,27 @@ const SftpSidePanelInteractiveBody: React.FC<SftpSidePanelInteractiveBodyProps>
}
}, [connectionId, connectionPath, sftp.leftPane.loading]);
// Match toolbar path semantics: keep the last confirmed path while navigateTo
// has optimistically replaced connection.currentPath during an uncached load.
const confirmedLocatePathRef = useRef(connectionPath ?? "");
const prevLocateConnectionIdRef = useRef(connectionId ?? undefined);
const [confirmedLocatePath, setConfirmedLocatePath] = useState(connectionPath ?? "");
useEffect(() => {
const previousConnectionId = prevLocateConnectionIdRef.current;
prevLocateConnectionIdRef.current = connectionId ?? undefined;
setConfirmedLocatePath((previousDisplayPath) => {
const next = getNextSftpToolbarDisplayPath({
previousDisplayPath,
previousConnectionId,
connectionId: connectionId ?? undefined,
currentPath: connectionPath ?? undefined,
loading: sftp.leftPane.loading,
});
confirmedLocatePathRef.current = next;
return next;
});
}, [connectionId, connectionPath, sftp.leftPane.loading]);
const handleGoToTerminalCwd = useCallback(async () => {
if (!onGetTerminalCwd) return;
const cwd = await onGetTerminalCwd({ preferFreshBackend: true });
@@ -1248,6 +1293,95 @@ const SftpSidePanelInteractiveBody: React.FC<SftpSidePanelInteractiveBodyProps>
}
}, [onGetTerminalCwd, sftpRef]);
const locatePathInTerminalContext = useMemo(() => {
const connection = sftp.leftPane.connection;
const locateSessionId = resolveLocateSftpPathSessionId({
activeSessionId,
focusedSessionId,
});
const session = sessions.find((candidate) => candidate.id === locateSessionId) ?? null;
const host = displayHost ?? activeHost;
const isNetworkDevice = host?.deviceType === "network"
|| classifyDistroId(host?.distro) === "network-device";
return {
path: confirmedLocatePath || connection?.currentPath,
sessionId: locateSessionId,
sessionStatus: session?.status,
sessionHostId: session?.hostId,
sftpHostId: connection?.hostId,
sftpIsLocal: Boolean(connection?.isLocal),
protocol: session?.protocol ?? host?.protocol,
shellType: session?.shellType,
isNetworkDevice,
moshEnabled: session?.moshEnabled,
etEnabled: session?.etEnabled,
sessionHostname: session?.hostname,
sessionUsername: session?.username,
sessionPort: session?.port,
sftpHostname: host?.hostname,
sftpUsername: host?.username,
sftpPort: host?.port,
};
}, [
activeHost,
activeSessionId,
confirmedLocatePath,
displayHost,
focusedSessionId,
sessions,
sftp.leftPane.connection,
]);
const canLocatePathInTerminal = canLocateSftpPathInTerminal(locatePathInTerminalContext);
const handleLocatePathInTerminal = useCallback(() => {
const connection = sftpRef.current.leftPane.connection;
const locateSessionId = resolveLocateSftpPathSessionId({
activeSessionId,
focusedSessionId,
});
const session = sessions.find((candidate) => candidate.id === locateSessionId) ?? null;
const host = displayHost ?? activeHost;
const isNetworkDevice = host?.deviceType === "network"
|| classifyDistroId(host?.distro) === "network-device";
const action = resolveLocateSftpPathInTerminalAction({
// Prefer the path shown in the toolbar, not an in-flight optimistic cwd.
path: confirmedLocatePathRef.current || connection?.currentPath,
sessionId: locateSessionId,
sessionStatus: session?.status,
sessionHostId: session?.hostId,
sftpHostId: connection?.hostId,
sftpIsLocal: Boolean(connection?.isLocal),
protocol: session?.protocol ?? host?.protocol,
shellType: session?.shellType,
isNetworkDevice,
moshEnabled: session?.moshEnabled,
etEnabled: session?.etEnabled,
sessionHostname: session?.hostname,
sessionUsername: session?.username,
sessionPort: session?.port,
sftpHostname: host?.hostname,
sftpUsername: host?.username,
sftpPort: host?.port,
});
if (!action) return;
// Never inject cd into a password/sudo prompt (same guard as snippets/broadcast).
if (isTerminalSensitiveInputActive(action.sessionId)) return;
// Only submit at an idle shell prompt -- never append into typed input or a TUI.
if (!isTerminalReadyForCommandInjection(action.sessionId)) return;
terminalBackend.writeToSession(action.sessionId, action.data, { automated: true });
scheduleDeferredTerminalFocus(onRequestTerminalFocus);
}, [
activeHost,
activeSessionId,
displayHost,
focusedSessionId,
onRequestTerminalFocus,
sessions,
sftpRef,
terminalBackend,
]);
const syncFollowToTerminalCwd = useCallback(async () => {
if (!onGetTerminalCwd || !effectiveFollowTerminalCwd || !canFollowTerminalCwd) {
return;
@@ -1644,6 +1778,7 @@ const SftpSidePanelInteractiveBody: React.FC<SftpSidePanelInteractiveBodyProps>
forceActive
onToggleShowHiddenFiles={() => handleToggleHiddenFiles(pane.id)}
onGoToTerminalCwd={onGetTerminalCwd ? handleGoToTerminalCwd : undefined}
onLocatePathInTerminal={canLocatePathInTerminal ? handleLocatePathInTerminal : undefined}
followTerminalCwd={canFollowTerminalCwd ? effectiveFollowTerminalCwd : undefined}
onToggleFollowTerminalCwd={canFollowTerminalCwd ? handleToggleFollowTerminalCwd : undefined}
/>
@@ -1723,6 +1858,7 @@ const sidePanelAreEqual = (prev: SftpSidePanelProps, next: SftpSidePanelProps):
prev.sftpDefaultViewMode === next.sftpDefaultViewMode &&
prev.activeHost === next.activeHost &&
prev.activeSessionId === next.activeSessionId &&
prev.focusedSessionId === next.focusedSessionId &&
prev.showWorkspaceHostHeader === next.showWorkspaceHostHeader &&
prev.isVisible === next.isVisible &&
prev.renderOverlays === next.renderOverlays &&
+16
View File
@@ -145,6 +145,9 @@ import {
} from "./terminal/runtime/createXTermRuntime";
import { clearKittyKeyboardBroadcastSession } from "./terminal/runtime/kittyKeyboardBroadcast";
import { registerTerminalSensitiveInputReader } from "./terminal/runtime/terminalSensitiveInputRegistry";
import { registerTerminalCommandInjectionReadyReader } from "./terminal/runtime/terminalCommandInjectionReadyRegistry";
import { isIdleShellReadyForCommandInjection } from "../domain/terminalCommandInjectionReady";
import { detectPrompt } from "./terminal/autocomplete/promptDetector";
import { applyUserCursorPreference } from "./terminal/runtime/cursorPreference";
import { terminalAltKeyOptions } from "./terminal/runtime/altKeyOptions";
import {
@@ -535,6 +538,19 @@ const TerminalComponent: React.FC<TerminalProps> = ({
const onTerminalDataCaptureRef = useRef(onTerminalDataCapture);
const onSessionExitRef = useRef(onSessionExit);
const commandBufferRef = useRef<string>("");
useEffect(() => registerTerminalCommandInjectionReadyReader(sessionId, () => {
const term = termRef.current;
if (!term) return false;
const prompt = detectPrompt(term);
return isIdleShellReadyForCommandInjection({
sensitiveInputActive: passwordPromptActiveRef.current,
hasLiveTerminal: true,
alternateScreenActive: isTerminalAlternateScreenActive(term),
isAtPrompt: prompt.isAtPrompt,
userInputLength: prompt.userInput.length,
pendingTypedInputLength: commandBufferRef.current.length,
});
}), [sessionId]);
const promptLineBreakStateRef = useRef<PromptLineBreakState>(createPromptLineBreakState());
const [hasMouseTracking, setHasMouseTracking] = useState(false);
const mouseTrackingRef = useRef(false);
+78
View File
@@ -166,6 +166,84 @@ test("toolbar renders one view-mode toggle instead of separate list and tree but
assert.match(markup, /aria-label="Bookmarked paths"/);
});
test("toolbar exposes locate-path-in-terminal when the callback is provided", () => {
const pane: SftpPane = {
id: "pane-1",
connection: {
id: "conn-1",
hostId: "host-1",
name: "Example",
currentPath: "/var/www/app",
homeDir: "/home/app",
isLocal: false,
},
files: [],
loading: false,
reconnecting: false,
error: null,
connectionLogs: [],
selectedFiles: new Set(),
filter: "",
filenameEncoding: "auto",
showHiddenFiles: false,
transferMutationToken: 0,
};
const markup = renderToStaticMarkup(
React.createElement(TooltipProvider, {
children: React.createElement(SftpPaneToolbar, {
t: (key: string) => ({
"sftp.locatePathInTerminal": "Open path in terminal",
"sftp.viewMode.switchToTree": "Switch to tree view",
"sftp.bookmark.add": "Bookmark current path",
}[key] ?? key),
pane,
onNavigateTo: () => {},
onSetFilter: () => {},
onSetFilenameEncoding: () => {},
onRefresh: () => {},
showFilterBar: false,
setShowFilterBar: () => {},
filterInputRef: { current: null },
isEditingPath: false,
editingPathValue: "",
setEditingPathValue: () => {},
setShowPathSuggestions: () => {},
showPathSuggestions: false,
setPathSuggestionIndex: () => {},
pathSuggestions: [],
pathSuggestionIndex: -1,
pathInputRef: { current: null },
pathDropdownRef: { current: null },
handlePathBlur: () => {},
handlePathKeyDown: () => {},
handlePathDoubleClick: () => {},
handlePathSubmit: () => {},
getNextUntitledName: () => "untitled",
setNewFileName: () => {},
setFileNameError: () => {},
setShowNewFileDialog: () => {},
setShowNewFolderDialog: () => {},
setNewFolderName: () => {},
bookmarks: [],
isCurrentPathBookmarked: false,
onToggleBookmark: () => {},
onAddGlobalBookmark: () => {},
isCurrentPathGlobalBookmarked: false,
onNavigateToBookmark: () => {},
onDeleteBookmark: () => {},
showHiddenFiles: false,
onToggleShowHiddenFiles: () => {},
onLocatePathInTerminal: () => {},
viewMode: "list",
onSetViewMode: () => {},
}),
}),
);
assert.match(markup, /aria-label="Open path in terminal"/);
});
test("toolbar exposes copy-current-path action for the active directory", () => {
const pane: SftpPane = {
id: "pane-1",
+36 -2
View File
@@ -1,5 +1,5 @@
import React, { useCallback, useEffect, useMemo, useRef, useState } from "react";
import { Bookmark, Check, ClipboardCopy, Eye, EyeOff, FilePlus, Folder, FolderPlus, FolderSync, Globe, Home, Languages, List, ListTree, RefreshCw, Search, TerminalSquare, Trash2, X } from "lucide-react";
import { Bookmark, Check, ClipboardCopy, Eye, EyeOff, FilePlus, Folder, FolderPlus, FolderSync, Globe, Home, Languages, List, ListTree, RefreshCw, Search, Terminal, TerminalSquare, Trash2, X } from "lucide-react";
import { useToolbarItemLayout } from "../../application/state/useToolbarItemLayout";
import type { ToolbarItemLayoutDefaults } from "../../domain/toolbarItemLayout";
import { STORAGE_KEY_SFTP_TOOLBAR_LAYOUT } from "../../infrastructure/config/storageKeys";
@@ -28,6 +28,7 @@ import { toast } from "../ui/toast";
export const SFTP_TOOLBAR_ITEM_IDS = [
"bookmark",
"goToTerminalCwd",
"locatePathInTerminal",
"followTerminalCwd",
"copyPath",
"viewMode",
@@ -46,6 +47,7 @@ export const SFTP_TOOLBAR_LAYOUT_DEFAULTS: ToolbarItemLayoutDefaults = {
placement: {
bookmark: "show",
goToTerminalCwd: "show",
locatePathInTerminal: "show",
followTerminalCwd: "show",
copyPath: "show",
viewMode: "show",
@@ -67,6 +69,7 @@ export const SFTP_TOOLBAR_LAYOUT_DEFAULTS: ToolbarItemLayoutDefaults = {
export const SFTP_TOOLBAR_NARROW_INLINE_IDS = new Set<SftpToolbarItemId>([
"bookmark",
"goToTerminalCwd",
"locatePathInTerminal",
"followTerminalCwd",
"copyPath",
"viewMode",
@@ -215,6 +218,7 @@ interface SftpPaneToolbarProps {
showHiddenFiles: boolean;
onToggleShowHiddenFiles?: () => void;
onGoToTerminalCwd?: () => void;
onLocatePathInTerminal?: () => void;
followTerminalCwd?: boolean;
onToggleFollowTerminalCwd?: () => void;
viewMode: SftpPaneViewMode;
@@ -323,6 +327,7 @@ export const SftpPaneToolbar: React.FC<SftpPaneToolbarProps> = React.memo(({
showHiddenFiles,
onToggleShowHiddenFiles,
onGoToTerminalCwd,
onLocatePathInTerminal,
followTerminalCwd,
onToggleFollowTerminalCwd,
viewMode,
@@ -469,15 +474,17 @@ export const SftpPaneToolbar: React.FC<SftpPaneToolbarProps> = React.memo(({
"refresh",
];
if (onGoToTerminalCwd) ids.push("goToTerminalCwd");
if (onLocatePathInTerminal) ids.push("locatePathInTerminal");
if (onToggleFollowTerminalCwd) ids.push("followTerminalCwd");
if (isRemote) ids.push("encoding");
return ids;
}, [isRemote, onGoToTerminalCwd, onToggleFollowTerminalCwd]);
}, [isRemote, onGoToTerminalCwd, onLocatePathInTerminal, onToggleFollowTerminalCwd]);
const itemLabels = useMemo(
(): Record<SftpToolbarItemId, string> => ({
bookmark: bookmarkButtonLabel,
goToTerminalCwd: t("sftp.goToTerminalCwd"),
locatePathInTerminal: t("sftp.locatePathInTerminal"),
followTerminalCwd: t("sftp.followTerminalCwd"),
copyPath: t("sftp.copyCurrentPath"),
viewMode: viewModeToggleLabel,
@@ -495,6 +502,7 @@ export const SftpPaneToolbar: React.FC<SftpPaneToolbarProps> = React.memo(({
(): Record<SftpToolbarItemId, React.ReactNode> => ({
bookmark: <Bookmark size={14} />,
goToTerminalCwd: <TerminalSquare size={14} />,
locatePathInTerminal: <Terminal size={14} />,
followTerminalCwd: <FolderSync size={14} />,
copyPath: <ClipboardCopy size={14} />,
viewMode: viewMode === "list" ? <List size={14} /> : <ListTree size={14} />,
@@ -681,6 +689,24 @@ export const SftpPaneToolbar: React.FC<SftpPaneToolbarProps> = React.memo(({
<TooltipContent>{t("sftp.goToTerminalCwd")}</TooltipContent>
</Tooltip>
);
case "locatePathInTerminal":
if (!onLocatePathInTerminal) return null;
return (
<Tooltip key={id}>
<TooltipTrigger asChild>
<Button
variant="ghost"
size="icon"
className="h-6 w-6"
aria-label={t("sftp.locatePathInTerminal")}
onClick={onLocatePathInTerminal}
>
<Terminal size={14} />
</Button>
</TooltipTrigger>
<TooltipContent>{t("sftp.locatePathInTerminal")}</TooltipContent>
</Tooltip>
);
case "followTerminalCwd":
if (!onToggleFollowTerminalCwd) return null;
return (
@@ -830,6 +856,14 @@ export const SftpPaneToolbar: React.FC<SftpPaneToolbarProps> = React.memo(({
{t("sftp.goToTerminalCwd")}
</button>
);
case "locatePathInTerminal":
if (!onLocatePathInTerminal) return null;
return (
<button key={id} type="button" className={menuItemClass} onClick={onLocatePathInTerminal}>
<Terminal size={14} className="shrink-0" />
{t("sftp.locatePathInTerminal")}
</button>
);
case "followTerminalCwd":
if (!onToggleFollowTerminalCwd) return null;
return (
+4
View File
@@ -79,6 +79,7 @@ interface SftpPaneViewProps {
showEmptyHeader?: boolean;
onToggleShowHiddenFiles?: () => void;
onGoToTerminalCwd?: () => void;
onLocatePathInTerminal?: () => void;
followTerminalCwd?: boolean;
onToggleFollowTerminalCwd?: () => void;
/** When true, treat this pane as always active (used by SftpSidePanel which manages visibility itself) */
@@ -95,6 +96,7 @@ const SftpPaneViewInner: React.FC<SftpPaneViewProps> = ({
showEmptyHeader = true,
onToggleShowHiddenFiles,
onGoToTerminalCwd,
onLocatePathInTerminal,
followTerminalCwd,
onToggleFollowTerminalCwd,
forceActive,
@@ -567,6 +569,7 @@ const SftpPaneViewInner: React.FC<SftpPaneViewProps> = ({
showHiddenFiles={pane.showHiddenFiles}
onToggleShowHiddenFiles={onToggleShowHiddenFiles}
onGoToTerminalCwd={onGoToTerminalCwd}
onLocatePathInTerminal={onLocatePathInTerminal}
followTerminalCwd={followTerminalCwd}
onToggleFollowTerminalCwd={onToggleFollowTerminalCwd}
viewMode={viewMode}
@@ -732,6 +735,7 @@ const sftpPaneViewAreEqual = (
if (prev.followTerminalCwd !== next.followTerminalCwd) return false;
if (prev.onToggleFollowTerminalCwd !== next.onToggleFollowTerminalCwd) return false;
if (prev.onGoToTerminalCwd !== next.onGoToTerminalCwd) return false;
if (prev.onLocatePathInTerminal !== next.onLocatePathInTerminal) return false;
if (prev.onToggleShowHiddenFiles !== next.onToggleShowHiddenFiles) return false;
return true;
@@ -0,0 +1,47 @@
import assert from "node:assert/strict";
import { readFileSync } from "node:fs";
import test from "node:test";
const sidePanelSource = readFileSync(new URL("../SftpSidePanel.tsx", import.meta.url), "utf8");
const slotSource = readFileSync(
new URL("../terminalLayer/terminalLayerSidePanelSlots.tsx", import.meta.url),
"utf8",
);
test("locate-path write skips sessions waiting on sensitive/password prompts", () => {
assert.match(
sidePanelSource,
/isTerminalSensitiveInputActive\(action\.sessionId\)[\s\S]*?writeToSession\(action\.sessionId, action\.data/,
);
assert.match(
sidePanelSource,
/if \(isTerminalSensitiveInputActive\(action\.sessionId\)\) return;/,
);
});
test("locate-path write requires an idle shell prompt before PTY injection", () => {
assert.match(
sidePanelSource,
/isTerminalReadyForCommandInjection\(action\.sessionId\)[\s\S]*?writeToSession\(action\.sessionId, action\.data/,
);
assert.match(
sidePanelSource,
/if \(!isTerminalReadyForCommandInjection\(action\.sessionId\)\) return;/,
);
});
test("locate-path uses the confirmed toolbar path rather than an optimistic navigate target", () => {
assert.match(sidePanelSource, /getNextSftpToolbarDisplayPath\(/);
assert.match(
sidePanelSource,
/path: confirmedLocatePathRef\.current \|\| connection\?\.currentPath/,
);
});
test("locate-path uses focused session fallback when SFTP cannot reuse the terminal", () => {
assert.match(sidePanelSource, /resolveLocateSftpPathSessionId\(\{\s*activeSessionId,\s*focusedSessionId,/);
assert.match(
slotSource,
/focusedSessionId=\{isVisible \? live\.focusedSessionId : null\}/,
);
});
@@ -0,0 +1,18 @@
const commandInjectionReadyReaders = new Map<string, () => boolean>();
export function registerTerminalCommandInjectionReadyReader(
sessionId: string,
reader: () => boolean,
): () => void {
commandInjectionReadyReaders.set(sessionId, reader);
return () => {
if (commandInjectionReadyReaders.get(sessionId) === reader) {
commandInjectionReadyReaders.delete(sessionId);
}
};
}
/** True when the live terminal reports an idle shell prompt ready for injection. */
export function isTerminalReadyForCommandInjection(sessionId: string): boolean {
return commandInjectionReadyReaders.get(sessionId)?.() === true;
}
@@ -187,6 +187,7 @@ function SidePanelSftpSlotInner({
sftpDefaultViewMode={sftpDefaultViewMode}
activeHost={panelActiveHost}
activeSessionId={isVisible ? live.activeTerminalSessionIdForSftp : null}
focusedSessionId={isVisible ? live.focusedSessionId : null}
initialLocation={isVisible ? (sftpInitialLocationForTab.get(tabId) ?? null) : null}
onInitialLocationApplied={handleInitialLocationApplied}
onCurrentPathChange={handleCurrentPathChange}
+27
View File
@@ -7,6 +7,7 @@ import {
quoteRestoreCwdForShell,
resolveRestoredActiveTabId,
resolveInheritedCwdIntent,
resolveInteractiveTerminalCdIntent,
resolveRestoreCwdIntent,
sanitizeSessionRestorePayload,
shouldAttemptRestoreCwd,
@@ -680,6 +681,32 @@ test("quoteRestoreCwdForShell treats cwd as shell data", () => {
assert.equal(quoteRestoreCwdForShell("/tmp/it's ok; $(rm -rf ~)"), "'/tmp/it'\\''s ok; $(rm -rf ~)'");
});
test("resolveInteractiveTerminalCdIntent quotes path-only cd without session transport checks", () => {
assert.deepEqual(resolveInteractiveTerminalCdIntent("/srv/app dir"), {
cwd: "/srv/app dir",
command: "cd -- '/srv/app dir'",
});
assert.deepEqual(resolveInteractiveTerminalCdIntent("/srv/project "), {
cwd: "/srv/project ",
command: "cd -- '/srv/project '",
});
assert.equal(resolveInteractiveTerminalCdIntent("C:\\Users\\alice"), null);
});
test("resolveInteractiveTerminalCdIntent rejects control bytes that readline would interpret", () => {
// Tab / ESC / Ctrl-U / newline / DEL must not be typed into an interactive PTY.
assert.equal(resolveInteractiveTerminalCdIntent("/srv/app\tdir"), null);
assert.equal(resolveInteractiveTerminalCdIntent("/srv/app\u001bdir"), null);
assert.equal(resolveInteractiveTerminalCdIntent("/srv/app\u0015dir"), null);
assert.equal(resolveInteractiveTerminalCdIntent("/srv/app\ndir"), null);
assert.equal(resolveInteractiveTerminalCdIntent("/srv/app\u007fdir"), null);
// Trailing spaces remain valid POSIX names and must still quote exactly.
assert.deepEqual(resolveInteractiveTerminalCdIntent("/srv/project "), {
cwd: "/srv/project ",
command: "cd -- '/srv/project '",
});
});
test("resolveRestoreCwdIntent captures a one-shot restore command", () => {
assert.deepEqual(resolveRestoreCwdIntent({
enabled: true,
+21
View File
@@ -246,6 +246,27 @@ function quoteRestoreCwdArgument(cwd: string): string {
return quoteRestoreCwdForShell(cwd);
}
/** True when a path contains C0/DEL bytes that interactive readline would act on. */
function pathHasInteractivePtyControlBytes(path: string): boolean {
for (let i = 0; i < path.length; i++) {
const code = path.charCodeAt(i);
if (code <= 0x1f || code === 0x7f) return true;
}
return false;
}
/** Build a user-initiated `cd` for an absolute/home path (SFTP -> terminal). */
export function resolveInteractiveTerminalCdIntent(
cwd: string | null | undefined,
): { cwd: string; command: string } | null {
// Eligibility uses a normalized view; keep the exact path for quoting so
// trailing whitespace in POSIX names is preserved.
if (!isRestoreCwdPathEligible(cwd)) return null;
// Shell quoting does not protect tab/ESC/Ctrl-U while readline processes keystrokes.
if (pathHasInteractivePtyControlBytes(cwd)) return null;
return { cwd, command: `cd -- ${quoteRestoreCwdArgument(cwd)}` };
}
export function resolveRestoreCwdIntent(options: {
enabled: boolean;
session: RestoreCwdSession;
+115
View File
@@ -0,0 +1,115 @@
import test from "node:test";
import assert from "node:assert/strict";
import {
canLocateSftpPathInTerminal,
resolveLocateSftpPathInTerminalAction,
resolveLocateSftpPathSessionId,
} from "./sftpLocatePathInTerminal.ts";
const base = {
path: "/home/user/project",
sessionId: "sess-1",
sessionStatus: "connected" as const,
sessionHostId: "host-1",
sftpHostId: "host-1",
sftpIsLocal: false,
protocol: "ssh" as const,
shellType: "posix" as const,
isNetworkDevice: false,
};
test("canLocateSftpPathInTerminal allows connected SSH sessions on the same host", () => {
assert.equal(canLocateSftpPathInTerminal(base), true);
});
test("canLocateSftpPathInTerminal allows mosh/et interactive cd (unlike silent restore)", () => {
assert.equal(canLocateSftpPathInTerminal({ ...base, moshEnabled: true }), true);
assert.equal(canLocateSftpPathInTerminal({ ...base, etEnabled: true }), true);
assert.equal(canLocateSftpPathInTerminal({ ...base, protocol: "mosh" }), true);
assert.equal(canLocateSftpPathInTerminal({ ...base, protocol: "et" }), true);
});
test("canLocateSftpPathInTerminal rejects missing session, disconnected session, or host mismatch", () => {
assert.equal(canLocateSftpPathInTerminal({ ...base, sessionId: null }), false);
assert.equal(canLocateSftpPathInTerminal({ ...base, sessionStatus: "disconnected" }), false);
assert.equal(canLocateSftpPathInTerminal({ ...base, sftpHostId: "other-host" }), false);
});
test("canLocateSftpPathInTerminal rejects same hostId with different live endpoints", () => {
assert.equal(
canLocateSftpPathInTerminal({
...base,
sessionHostname: "a.example.test",
sessionUsername: "root",
sessionPort: 22,
sftpHostname: "b.example.test",
sftpUsername: "root",
sftpPort: 22,
}),
false,
);
});
test("resolveLocateSftpPathSessionId prefers reusable active session then focused fallback", () => {
assert.equal(
resolveLocateSftpPathSessionId({ activeSessionId: "ssh-reuse", focusedSessionId: "focused" }),
"ssh-reuse",
);
assert.equal(
resolveLocateSftpPathSessionId({ activeSessionId: null, focusedSessionId: "mosh-1" }),
"mosh-1",
);
assert.equal(
resolveLocateSftpPathSessionId({ activeSessionId: null, focusedSessionId: null }),
null,
);
});
test("canLocateSftpPathInTerminal rejects network devices, telnet/serial, and Windows local shells", () => {
assert.equal(canLocateSftpPathInTerminal({ ...base, isNetworkDevice: true }), false);
assert.equal(canLocateSftpPathInTerminal({ ...base, protocol: "telnet" }), false);
assert.equal(canLocateSftpPathInTerminal({ ...base, protocol: "serial" }), false);
assert.equal(
canLocateSftpPathInTerminal({
...base,
protocol: "local",
shellType: "powershell",
sftpIsLocal: true,
sessionHostId: "local-1",
sftpHostId: "local-1",
}),
false,
);
});
test("canLocateSftpPathInTerminal rejects ineligible paths", () => {
assert.equal(canLocateSftpPathInTerminal({ ...base, path: "" }), false);
assert.equal(canLocateSftpPathInTerminal({ ...base, path: "C:\\Users\\alice" }), false);
assert.equal(canLocateSftpPathInTerminal({ ...base, path: "relative/path" }), false);
assert.equal(canLocateSftpPathInTerminal({ ...base, path: "/srv/app\tdir" }), false);
assert.equal(canLocateSftpPathInTerminal({ ...base, path: "/srv/app\u001bdir" }), false);
});
test("resolveLocateSftpPathInTerminalAction builds a quoted cd payload", () => {
assert.deepEqual(
resolveLocateSftpPathInTerminalAction({
...base,
path: "/srv/app dir",
}),
{
sessionId: "sess-1",
data: "cd -- '/srv/app dir'\r",
},
);
});
test("resolveLocateSftpPathInTerminalAction returns null when locate is unavailable", () => {
assert.equal(
resolveLocateSftpPathInTerminalAction({
...base,
sessionStatus: "connecting",
}),
null,
);
});
+79
View File
@@ -0,0 +1,79 @@
import { resolveInteractiveTerminalCdIntent } from "./sessionRestore";
export type LocateSftpPathInTerminalContext = {
path?: string | null;
sessionId?: string | null;
sessionStatus?: string | null;
sessionHostId?: string | null;
sftpHostId?: string | null;
sftpIsLocal?: boolean;
protocol?: string | null;
shellType?: string | null;
isNetworkDevice?: boolean;
moshEnabled?: boolean;
etEnabled?: boolean;
sessionHostname?: string | null;
sessionUsername?: string | null;
sessionPort?: number | null;
sftpHostname?: string | null;
sftpUsername?: string | null;
sftpPort?: number | null;
};
/**
* Prefer the SFTP-reusable SSH session id when present; otherwise use the
* focused terminal (mosh/et/local) so locate is not stuck behind connection reuse.
*/
export function resolveLocateSftpPathSessionId(options: {
activeSessionId?: string | null;
focusedSessionId?: string | null;
}): string | null {
return options.activeSessionId ?? options.focusedSessionId ?? null;
}
function remoteEndpointsMatch(options: LocateSftpPathInTerminalContext): boolean {
if (!options.sessionHostname || !options.sftpHostname) return true;
return options.sessionHostname === options.sftpHostname
&& (options.sessionPort ?? 22) === (options.sftpPort ?? 22)
&& (options.sessionUsername || "root") === (options.sftpUsername || "root");
}
/** Whether the SFTP current path can be sent as `cd` to the linked terminal. */
export function canLocateSftpPathInTerminal(
options: LocateSftpPathInTerminalContext,
): boolean {
if (!options.sessionId || options.sessionStatus !== "connected") return false;
if (options.isNetworkDevice) return false;
if (!resolveInteractiveTerminalCdIntent(options.path)) return false;
const protocol = options.protocol ?? "ssh";
if (protocol === "telnet" || protocol === "serial") return false;
if (protocol === "local" && (options.shellType === "powershell" || options.shellType === "cmd")) {
return false;
}
if (options.sftpIsLocal) {
return protocol === "local";
}
if (!options.sftpHostId || !options.sessionHostId) return false;
if (options.sftpHostId !== options.sessionHostId) return false;
if (!remoteEndpointsMatch(options)) return false;
// Interactive locate allows mosh/et (unlike silent restore). Accept both the
// transport protocol strings and ssh+flag forms used by session factories.
return protocol === "ssh"
|| protocol === "mosh"
|| protocol === "et"
|| protocol === "local";
}
/** Session write payload for locating the SFTP path in the linked terminal. */
export function resolveLocateSftpPathInTerminalAction(
options: LocateSftpPathInTerminalContext,
): { sessionId: string; data: string } | null {
if (!canLocateSftpPathInTerminal(options) || !options.sessionId) return null;
const intent = resolveInteractiveTerminalCdIntent(options.path);
if (!intent) return null;
return { sessionId: options.sessionId, data: `${intent.command}\r` };
}
@@ -0,0 +1,49 @@
import assert from "node:assert/strict";
import test from "node:test";
import { isIdleShellReadyForCommandInjection } from "./terminalCommandInjectionReady.ts";
test("idle shell is ready only at a clean primary-screen prompt", () => {
assert.equal(
isIdleShellReadyForCommandInjection({
hasLiveTerminal: true,
alternateScreenActive: false,
isAtPrompt: true,
userInputLength: 0,
}),
true,
);
});
test("rejects sensitive prompts, alt-screen TUIs, partial input, and missing terminals", () => {
const ready = {
hasLiveTerminal: true,
alternateScreenActive: false,
isAtPrompt: true,
userInputLength: 0,
};
assert.equal(
isIdleShellReadyForCommandInjection({ ...ready, sensitiveInputActive: true }),
false,
);
assert.equal(
isIdleShellReadyForCommandInjection({ ...ready, alternateScreenActive: true }),
false,
);
assert.equal(
isIdleShellReadyForCommandInjection({ ...ready, isAtPrompt: false }),
false,
);
assert.equal(
isIdleShellReadyForCommandInjection({ ...ready, userInputLength: 3 }),
false,
);
assert.equal(
isIdleShellReadyForCommandInjection({ ...ready, pendingTypedInputLength: 2 }),
false,
);
assert.equal(
isIdleShellReadyForCommandInjection({ ...ready, hasLiveTerminal: false }),
false,
);
});
+20
View File
@@ -0,0 +1,20 @@
/**
* Whether an automated PTY write (e.g. SFTP "open path in terminal") may run
* at a clean interactive shell prompt -- not mid-line, not in an editor/TUI.
*/
export function isIdleShellReadyForCommandInjection(options: {
sensitiveInputActive?: boolean;
hasLiveTerminal: boolean;
alternateScreenActive: boolean;
isAtPrompt: boolean;
userInputLength: number;
pendingTypedInputLength?: number;
}): boolean {
if (options.sensitiveInputActive) return false;
if (!options.hasLiveTerminal) return false;
if (options.alternateScreenActive) return false;
if (!options.isAtPrompt) return false;
if (options.userInputLength > 0) return false;
if ((options.pendingTypedInputLength ?? 0) > 0) return false;
return true;
}