From e9258945e835b04f95aa72a7303db8295b6d36b7 Mon Sep 17 00:00:00 2001 From: Tomer Vaknin Date: Sat, 11 Apr 2026 23:32:43 +0300 Subject: [PATCH 1/2] feat(sftp): status bar with file stats and mouse-drag multi-select Add SftpStatusBar component showing folder/file count and total size per pane, with selection-aware stats. Implement click-and-drag range selection in FileList by tracking mousedown/mouseenter gestures and making draggable conditional on existing selection. Also address codebase audit findings: add stream error handler in execCommand, catch unhandled dialog promise, return value from setSignals IPC handler, remove debug console.log statements, fix useState initializers, type-safe protocol cast, and update .gitignore. Co-Authored-By: Claude Opus 4.6 (1M context) --- .gitignore | 2 + CHANGELOG.md | 5 + README.md | 4 + apps/desktop/src/main/ipc/registerIpc.ts | 1 + .../src/main/windows/createMainWindow.ts | 3 +- apps/ui/src/app/App.tsx | 4 +- .../src/features/hosts/PuttyImportDialog.tsx | 2 +- .../features/hosts/SshManagerImportDialog.tsx | 6 +- .../src/features/sftp/components/FileList.tsx | 67 +++++--- .../features/sftp/components/LocalPane.tsx | 2 + .../features/sftp/components/RemotePane.tsx | 22 +-- .../sftp/components/SftpStatusBar.tsx | 53 ++++++ apps/ui/src/features/tunnels/TunnelForm.tsx | 2 +- packages/session-core/src/sessionManager.ts | 155 +++++++++--------- .../session-core/src/ssh2ConnectionPool.ts | 2 +- 15 files changed, 205 insertions(+), 125 deletions(-) create mode 100644 apps/ui/src/features/sftp/components/SftpStatusBar.tsx diff --git a/.gitignore b/.gitignore index db99aee..e01f7a5 100644 --- a/.gitignore +++ b/.gitignore @@ -13,3 +13,5 @@ AGENTS.md .claude/ docs/plans/ docs/_archive/ +.DS_Store +*.log diff --git a/CHANGELOG.md b/CHANGELOG.md index 3bb1deb..07fcc57 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -6,6 +6,11 @@ The format is based on [Keep a Changelog](https://keepachangelog.com/) and this ## [Unreleased] +### Added + +- **SFTP status bar** — each pane now shows a footer with folder count, file count, and total size. When items are selected, selection stats are shown on the right side. +- **SFTP mouse-drag multi-select** — click and drag across rows to select a range of files/folders. Ctrl+Click (toggle) and Shift+Click (range) continue to work. Drag-and-drop file transfer is preserved for already-selected items. + ## [0.1.3] - 2026-04-11 ### Changed diff --git a/README.md b/README.md index bca5ae4..df8315e 100644 --- a/README.md +++ b/README.md @@ -77,6 +77,7 @@ - [pnpm](https://pnpm.io/) 10.8+ - Windows: Visual Studio Build Tools (for native modules) - macOS: Xcode Command Line Tools +- Linux: `build-essential`, `python3` (for native modules) ### Install and Run @@ -138,6 +139,9 @@ pnpm release:windows:unsigned # macOS DMG pnpm release:mac:unsigned + +# Linux AppImage + deb +pnpm release:linux:unsigned ``` ## Tech Stack diff --git a/apps/desktop/src/main/ipc/registerIpc.ts b/apps/desktop/src/main/ipc/registerIpc.ts index c2d3d5b..55b3dec 100644 --- a/apps/desktop/src/main/ipc/registerIpc.ts +++ b/apps/desktop/src/main/ipc/registerIpc.ts @@ -1372,6 +1372,7 @@ export function registerIpc( ipcMain.handle(ipcChannels.session.setSignals, (_event, request) => { const parsed = setSignalsRequestSchema.parse(request); manager.setSignals(parsed.sessionId, parsed.signals); + return { ok: true }; }); ipcMain.handle(ipcChannels.session.hostStats, (event, request) => hostStatsHandler(event, request, manager) diff --git a/apps/desktop/src/main/windows/createMainWindow.ts b/apps/desktop/src/main/windows/createMainWindow.ts index 7e90cb0..2a3f27c 100644 --- a/apps/desktop/src/main/windows/createMainWindow.ts +++ b/apps/desktop/src/main/windows/createMainWindow.ts @@ -225,7 +225,8 @@ export function createMainWindow(): BrowserWindow { closeConfirmed = true; win.close(); } - }); + }) + .catch(() => { /* window destroyed before user responded */ }); }); return win; diff --git a/apps/ui/src/app/App.tsx b/apps/ui/src/app/App.tsx index 73b9edb..5d4cc0e 100644 --- a/apps/ui/src/app/App.tsx +++ b/apps/ui/src/app/App.tsx @@ -113,7 +113,6 @@ async function loadHosts(): Promise { } try { const dbHosts = await window.hypershell.listHosts(); - console.log("[hypershell] loaded hosts from DB:", dbHosts.length); return dbHosts.map((h: Record) => mapDbHostToUiHost(h)); } catch (err) { console.error("[hypershell] failed to load hosts:", err); @@ -259,7 +258,6 @@ async function persistHost(host: HostRecord): Promise { ? { password: (host.password ?? "").trim() } : {}) }); - console.log("[hypershell] persisted host:", result); return mapDbHostToUiHost(result as unknown as Record); } catch (err) { console.error("[hypershell] failed to persist host:", err); @@ -324,7 +322,7 @@ function MainApp() { const [sftpAuthPassword, setSftpAuthPassword] = useState(""); const [sftpAuthError, setSftpAuthError] = useState(null); const [sftpAuthSubmitting, setSftpAuthSubmitting] = useState(false); - const [connectingHostIds, setConnectingHostIds] = useState>(new Set()); + const [connectingHostIds, setConnectingHostIds] = useState>(() => new Set()); const [lastConnectedAtByHostId, setLastConnectedAtByHostId] = useState>({}); const [connectionHistoryHost, setConnectionHistoryHost] = useState(null); const [hostKeyVerifyOpen, setHostKeyVerifyOpen] = useState(false); diff --git a/apps/ui/src/features/hosts/PuttyImportDialog.tsx b/apps/ui/src/features/hosts/PuttyImportDialog.tsx index 16e924c..f091c0c 100644 --- a/apps/ui/src/features/hosts/PuttyImportDialog.tsx +++ b/apps/ui/src/features/hosts/PuttyImportDialog.tsx @@ -8,7 +8,7 @@ export interface PuttyImportDialogProps { export function PuttyImportDialog({ onImport, onClose }: PuttyImportDialogProps) { const [sessions, setSessions] = useState([]); - const [selected, setSelected] = useState>(new Set()); + const [selected, setSelected] = useState>(() => new Set()); const [loading, setLoading] = useState(true); const [error, setError] = useState(null); diff --git a/apps/ui/src/features/hosts/SshManagerImportDialog.tsx b/apps/ui/src/features/hosts/SshManagerImportDialog.tsx index 026c274..b9e7e3b 100644 --- a/apps/ui/src/features/hosts/SshManagerImportDialog.tsx +++ b/apps/ui/src/features/hosts/SshManagerImportDialog.tsx @@ -11,9 +11,9 @@ export function SshManagerImportDialog({ onImported, onClose }: SshManagerImport const [hosts, setHosts] = useState([]); const [groups, setGroups] = useState([]); const [snippets, setSnippets] = useState([]); - const [selectedHosts, setSelectedHosts] = useState>(new Set()); - const [selectedGroups, setSelectedGroups] = useState>(new Set()); - const [selectedSnippets, setSelectedSnippets] = useState>(new Set()); + const [selectedHosts, setSelectedHosts] = useState>(() => new Set()); + const [selectedGroups, setSelectedGroups] = useState>(() => new Set()); + const [selectedSnippets, setSelectedSnippets] = useState>(() => new Set()); const [loading, setLoading] = useState(true); const [importing, setImporting] = useState(false); const [error, setError] = useState(null); diff --git a/apps/ui/src/features/sftp/components/FileList.tsx b/apps/ui/src/features/sftp/components/FileList.tsx index 9c7fe03..06b9894 100644 --- a/apps/ui/src/features/sftp/components/FileList.tsx +++ b/apps/ui/src/features/sftp/components/FileList.tsx @@ -116,25 +116,44 @@ export function FileList({ [entries, sortBy.column, sortBy.direction] ); + + // --- Mouse-drag range selection --- + const dragSelectRef = useRef<{ startIndex: number } | null>(null); + const didDragSelectRef = useRef(false); + + const handleRowMouseDown = useCallback( + (index: number, entry: FileListEntry, event: MouseEvent) => { + if (event.button !== 0) return; // left-click only + // If the item is already selected, let native drag handle it (file transfer) + if (selection.has(entry.path)) return; + dragSelectRef.current = { startIndex: index }; + }, + [selection] + ); + + const handleRowMouseEnter = useCallback( + (index: number) => { + if (!dragSelectRef.current) return; + // Only activate drag-select after moving to a different row + if (dragSelectRef.current.startIndex === index) return; + didDragSelectRef.current = true; + const from = Math.min(dragSelectRef.current.startIndex, index); + const to = Math.max(dragSelectRef.current.startIndex, index); + const paths = sortedEntries.slice(from, to + 1).map((e) => e.path); + onSelect(new Set(paths)); + }, + [onSelect, sortedEntries] + ); + useEffect(() => { - const container = containerRef.current; - const domRows = container?.querySelectorAll("tbody tr") ?? []; - const firstRow = domRows.item(0) as HTMLTableRowElement | null; - const firstRowHeight = firstRow?.getBoundingClientRect().height ?? 0; - const containerHeight = container?.getBoundingClientRect().height ?? 0; - - console.log( - "[sftp-ui] file list render:", - `pane=${paneType}`, - `entries=${entries.length}`, - `sorted=${sortedEntries.length}`, - `loading=${isLoading}`, - `error=${error ?? "(none)"}`, - `domRows=${domRows.length}`, - `firstRowH=${firstRowHeight.toFixed(1)}`, - `containerH=${containerHeight.toFixed(1)}` - ); - }, [entries.length, error, isLoading, paneType, sortedEntries.length]); + const handleMouseUp = () => { + dragSelectRef.current = null; + // Reset after a microtask so onClick fires first + queueMicrotask(() => { didDragSelectRef.current = false; }); + }; + document.addEventListener("mouseup", handleMouseUp); + return () => document.removeEventListener("mouseup", handleMouseUp); + }, []); const handleHeaderClick = (column: SortColumn) => { const direction = @@ -143,6 +162,12 @@ export function FileList({ }; const handleRowClick = (entry: FileListEntry, event: MouseEvent) => { + // Skip click if a drag-select gesture just completed + if (didDragSelectRef.current) { + didDragSelectRef.current = false; + return; + } + if (event.metaKey || event.ctrlKey) { const next = new Set(selection); if (next.has(entry.path)) { @@ -323,7 +348,7 @@ export function FileList({ handleRowMouseDown(index, entry, event)} + onMouseEnter={() => handleRowMouseEnter(index)} onClick={(event) => handleRowClick(entry, event)} onDoubleClick={() => handleRowDoubleClick(entry)} onContextMenu={(event) => { @@ -341,7 +368,7 @@ export function FileList({ } onContextMenu(event, entry); }} - draggable + draggable={selection.has(entry.path)} onDragStart={(event) => handleDragStart(event, entry)} > diff --git a/apps/ui/src/features/sftp/components/LocalPane.tsx b/apps/ui/src/features/sftp/components/LocalPane.tsx index e8f052c..e18421f 100644 --- a/apps/ui/src/features/sftp/components/LocalPane.tsx +++ b/apps/ui/src/features/sftp/components/LocalPane.tsx @@ -10,6 +10,7 @@ import { DriveSelector } from "./DriveSelector"; import { FileContextMenu, type FileContextMenuAction } from "./FileContextMenu"; import { FileList } from "./FileList"; import { PathBreadcrumb, type PathBreadcrumbHandle } from "./PathBreadcrumb"; +import { SftpStatusBar } from "./SftpStatusBar"; export interface LocalPaneProps { store: StoreApi; @@ -252,6 +253,7 @@ export function LocalPane({ store, onTransfer, onDownload, isActive, onActivate, paneType="local" cursorIndex={localCursorIndex} /> + {contextMenu && ( ; @@ -110,18 +111,6 @@ export function RemotePane({ return remoteEntries.filter((entry) => entry.name.toLowerCase().includes(lower)); }, [remoteEntries, remoteFilterText, filterCaseSensitive, filterRegex]); - useEffect(() => { - console.log( - "[sftp-ui] remote pane state:", - `path=${remotePath}`, - `entries=${remoteEntries.length}`, - `filtered=${filteredEntries.length}`, - `filter="${remoteFilterText || ""}"`, - `loading=${isLoading}`, - `error=${error ?? "(none)"}` - ); - }, [error, filteredEntries.length, isLoading, remoteEntries.length, remoteFilterText, remotePath]); - useEffect(() => { const maxIndex = Math.max(0, filteredEntries.length - 1); if (remoteCursorIndex > maxIndex) { @@ -147,14 +136,6 @@ export function RemotePane({ } const response = await sftpList({ sftpSessionId, path }); const entries = extractRemoteEntries(response); - console.log( - "[sftp-ui] loadDirectory:", - path, - "→", - entries.length, - "entries", - entries.length > 0 ? `(first: ${entries[0].name})` : "" - ); setRemoteEntries(entries); } catch (loadError) { const message = @@ -320,6 +301,7 @@ export function RemotePane({ cursorIndex={remoteCursorIndex} sftpSessionId={sftpSessionId} /> + {contextMenu && ( ; +} + +function summarize(items: FileListEntry[]) { + let files = 0; + let folders = 0; + let totalSize = 0; + for (const e of items) { + if (e.isDirectory) { + folders++; + } else { + files++; + totalSize += e.size; + } + } + return { files, folders, totalSize }; +} + +function formatCounts(files: number, folders: number): string { + const parts: string[] = []; + if (folders > 0) parts.push(`${folders} folder${folders !== 1 ? "s" : ""}`); + if (files > 0) parts.push(`${files} file${files !== 1 ? "s" : ""}`); + return parts.join(", ") || "Empty"; +} + +export function SftpStatusBar({ entries, selection }: SftpStatusBarProps) { + const stats = useMemo(() => summarize(entries), [entries]); + + const selectionStats = useMemo(() => { + if (selection.size === 0) return null; + const selected = entries.filter((e) => selection.has(e.path)); + return summarize(selected); + }, [entries, selection]); + + return ( +
+ + {formatCounts(stats.files, stats.folders)} — {formatFileSize(stats.totalSize)} + + {selectionStats && ( + + Selected: {formatCounts(selectionStats.files, selectionStats.folders)} — {formatFileSize(selectionStats.totalSize)} + + )} +
+ ); +} diff --git a/apps/ui/src/features/tunnels/TunnelForm.tsx b/apps/ui/src/features/tunnels/TunnelForm.tsx index 94c19e3..5ad337e 100644 --- a/apps/ui/src/features/tunnels/TunnelForm.tsx +++ b/apps/ui/src/features/tunnels/TunnelForm.tsx @@ -40,7 +40,7 @@ export function TunnelForm({ onSubmit, onCancel }: TunnelFormProps) { setForm({ ...form, port: e.target.value })} placeholder="SSH Port" className={inputClasses} />
- setForm({ ...form, protocol: e.target.value as "local" | "remote" | "dynamic" })} className={inputClasses}> diff --git a/packages/session-core/src/sessionManager.ts b/packages/session-core/src/sessionManager.ts index 8028eee..049037b 100644 --- a/packages/session-core/src/sessionManager.ts +++ b/packages/session-core/src/sessionManager.ts @@ -1,20 +1,20 @@ -import type { - OpenSessionRequest, - SessionState, - SessionTransportEvent, +import type { + OpenSessionRequest, + SessionState, + SessionTransportEvent, SessionTransportKind, SshConnectionOptions, SerialConnectionOptions, TelnetConnectionOptions, TransportHandle -} from "./transports/transportEvents"; -import { Client } from "ssh2"; -import { readFileSync } from "node:fs"; -import { createHash, timingSafeEqual } from "node:crypto"; -import { createSerialTransport } from "./transports/serialTransport"; -import { createSshPtyTransport } from "./transports/sshPtyTransport"; -import { createTelnetTransport } from "./transports/telnetTransport"; -import type { NetworkMonitor } from "./networkMonitor"; +} from "./transports/transportEvents"; +import { Client } from "ssh2"; +import { readFileSync } from "node:fs"; +import { createHash, timingSafeEqual } from "node:crypto"; +import { createSerialTransport } from "./transports/serialTransport"; +import { createSshPtyTransport } from "./transports/sshPtyTransport"; +import { createTelnetTransport } from "./transports/telnetTransport"; +import type { NetworkMonitor } from "./networkMonitor"; export interface SessionSnapshot { sessionId: string; @@ -47,28 +47,28 @@ export interface OpenSessionInput { reconnectBaseInterval?: number; } -export interface OpenSessionResult { - sessionId: string; - state: SessionState; -} - -export interface ExecCommandOptions { - expectedHostFingerprints?: string[]; -} - -export interface SessionManager { - open(input: OpenSessionInput): OpenSessionResult; - write(sessionId: string, data: string): void; +export interface OpenSessionResult { + sessionId: string; + state: SessionState; +} + +export interface ExecCommandOptions { + expectedHostFingerprints?: string[]; +} + +export interface SessionManager { + open(input: OpenSessionInput): OpenSessionResult; + write(sessionId: string, data: string): void; resize(sessionId: string, cols: number, rows: number): void; close(sessionId: string): void; destroyAll(): void; getSession(sessionId: string): SessionSnapshot | undefined; listSessions(): SessionSnapshot[]; - onEvent(listener: (event: SessionTransportEvent) => void): () => void; - setSignals(sessionId: string, signals: { dtr?: boolean; rts?: boolean }): void; - getSessionInput(sessionId: string): OpenSessionInput | undefined; - execCommand(sessionId: string, command: string, options?: ExecCommandOptions): Promise; -} + onEvent(listener: (event: SessionTransportEvent) => void): () => void; + setSignals(sessionId: string, signals: { dtr?: boolean; rts?: boolean }): void; + getSessionInput(sessionId: string): OpenSessionInput | undefined; + execCommand(sessionId: string, command: string, options?: ExecCommandOptions): Promise; +} interface ManagedSession { snapshot: SessionSnapshot; @@ -146,16 +146,16 @@ function createDefaultTransport(request: OpenSessionRequest): TransportHandle { return createNoopTransport(request.sessionId); } -export function createSessionManager( - deps: SessionManagerDeps = {} -): SessionManager { - const sessions = new Map(); - const listeners = new Set<(event: SessionTransportEvent) => void>(); - let nextSessionId = 1; - const sessionIdFactory = - deps.sessionIdFactory ?? (() => `session-${nextSessionId++}`); - const createTransport = deps.createTransport ?? createDefaultTransport; - const networkMonitor = deps.networkMonitor; +export function createSessionManager( + deps: SessionManagerDeps = {} +): SessionManager { + const sessions = new Map(); + const listeners = new Set<(event: SessionTransportEvent) => void>(); + let nextSessionId = 1; + const sessionIdFactory = + deps.sessionIdFactory ?? (() => `session-${nextSessionId++}`); + const createTransport = deps.createTransport ?? createDefaultTransport; + const networkMonitor = deps.networkMonitor; function updateSession( sessionId: string, @@ -404,22 +404,22 @@ export function createSessionManager( return { ...rest, sshOptions: safeSshOptions }; }, - execCommand(sessionId: string, command: string, options: ExecCommandOptions = {}): Promise { - const session = sessions.get(sessionId); - if (!session) return Promise.reject(new Error("Session not found")); - const opts = session.input.sshOptions; - if (!opts) return Promise.reject(new Error("Not an SSH session")); - const expectedFingerprints = (options.expectedHostFingerprints ?? []) - .map((value) => value.trim()) - .filter((value) => value.length > 0) - .map((value) => Buffer.from(value, "utf8")); - - if (expectedFingerprints.length === 0) { - return Promise.reject(new Error("Host key verification is required for SSH exec")); - } - - return new Promise((resolve, reject) => { - const client = new Client(); + execCommand(sessionId: string, command: string, options: ExecCommandOptions = {}): Promise { + const session = sessions.get(sessionId); + if (!session) return Promise.reject(new Error("Session not found")); + const opts = session.input.sshOptions; + if (!opts) return Promise.reject(new Error("Not an SSH session")); + const expectedFingerprints = (options.expectedHostFingerprints ?? []) + .map((value) => value.trim()) + .filter((value) => value.length > 0) + .map((value) => Buffer.from(value, "utf8")); + + if (expectedFingerprints.length === 0) { + return Promise.reject(new Error("Host key verification is required for SSH exec")); + } + + return new Promise((resolve, reject) => { + const client = new Client(); const timeout = setTimeout(() => { client.destroy(); reject(new Error("SSH exec timed out")); @@ -436,6 +436,11 @@ export function createSessionManager( let stdout = ""; stream.on("data", (chunk: Buffer) => { stdout += chunk.toString(); }); stream.stderr.on("data", () => { /* ignore stderr */ }); + stream.on("error", (err: Error) => { + clearTimeout(timeout); + client.end(); + reject(err); + }); stream.on("close", () => { clearTimeout(timeout); client.end(); @@ -460,28 +465,28 @@ export function createSessionManager( connectConfig.password = opts.password; } - if (opts.identityFile) { - try { - connectConfig.privateKey = readFileSync(opts.identityFile); + if (opts.identityFile) { + try { + connectConfig.privateKey = readFileSync(opts.identityFile); } catch { // Identity file not readable — fall through to other auth - } - } - - connectConfig.hostVerifier = (key: Buffer) => { - const actual = Buffer.from( - `SHA256:${createHash("sha256").update(key).digest("base64")}`, - "utf8" - ); - return expectedFingerprints.some( - (expected) => - expected.length === actual.length - && timingSafeEqual(expected, actual) - ); - }; - - client.connect(connectConfig); - }); + } + } + + connectConfig.hostVerifier = (key: Buffer) => { + const actual = Buffer.from( + `SHA256:${createHash("sha256").update(key).digest("base64")}`, + "utf8" + ); + return expectedFingerprints.some( + (expected) => + expected.length === actual.length + && timingSafeEqual(expected, actual) + ); + }; + + client.connect(connectConfig); + }); } }; } diff --git a/packages/session-core/src/ssh2ConnectionPool.ts b/packages/session-core/src/ssh2ConnectionPool.ts index e44b2af..e30e5e2 100644 --- a/packages/session-core/src/ssh2ConnectionPool.ts +++ b/packages/session-core/src/ssh2ConnectionPool.ts @@ -102,7 +102,7 @@ export function createSsh2ConnectionPool(): Ssh2ConnectionPool { if (!entry) return; if (entry.idleTimer) clearTimeout(entry.idleTimer); - try { entry.client.end(); } catch {} + try { entry.client.end(); } catch (e) { console.warn("[ssh2-pool] cleanup error:", e); } entries.delete(connectionId); const key = poolKey(entry.target); From 9224075de838ec1d966fc96e7ed6dff93eaed12b Mon Sep 17 00:00:00 2001 From: Tomer Vaknin Date: Sun, 12 Apr 2026 21:02:50 +0300 Subject: [PATCH 2/2] fix: security hardening and SFTP drag-out to OS Security hardening: - SFTP drag-out path traversal protection (resolveSafeDragOutPath) - Renderer URL allowlisting (only localhost/file:// origins) - Navigation guards (will-navigate blocks cross-origin, deny all popups) - SFTP host key verification via trusted fingerprints from DB SFTP drag-out fixes: - Use native SCP instead of ssh2 streams (which fail with SSH_FX_FAILURE) - Pre-cache files on selection so startDrag fires instantly on drag - Support directory drag-out with scp -r - Fallback icon when app.getFileIcon fails - Graceful error handling for SCP failures - Platform-agnostic test paths Co-Authored-By: Claude Opus 4.6 (1M context) --- .gitignore | 2 - CHANGELOG.md | 5 - README.md | 4 - apps/desktop/src/main/ipc/registerIpc.ts | 1 - .../src/main/ipc/sftpIpc.security.test.ts | 17 + apps/desktop/src/main/ipc/sftpIpc.ts | 111 +- apps/desktop/src/main/main.lifecycle.test.ts | 4 +- apps/desktop/src/main/main.ts | 34 +- apps/desktop/src/main/mainLifecycle.ts | 5 +- .../src/main/windows/createEditorWindow.ts | 2 + .../src/main/windows/createMainWindow.ts | 3 +- .../src/main/windows/windowSecurity.test.ts | 58 + .../src/main/windows/windowSecurity.ts | 42 + apps/ui/src/app/App.tsx | 4 +- .../src/features/hosts/PuttyImportDialog.tsx | 2 +- .../features/hosts/SshManagerImportDialog.tsx | 6 +- .../src/features/sftp/components/FileList.tsx | 89 +- .../features/sftp/components/LocalPane.tsx | 2 - .../features/sftp/components/RemotePane.tsx | 22 +- .../sftp/components/SftpStatusBar.tsx | 53 - apps/ui/src/features/tunnels/TunnelForm.tsx | 2 +- packages/session-core/src/sessionManager.ts | 155 ++- .../session-core/src/ssh2ConnectionPool.ts | 2 +- .../transports/sftpTransport.security.test.ts | 31 + .../src/transports/sftpTransport.ts | 1102 +++++++++-------- .../src/transports/transportEvents.ts | 196 +-- packages/shared/src/ipc/sftpSchemas.ts | 2 + 27 files changed, 1065 insertions(+), 891 deletions(-) create mode 100644 apps/desktop/src/main/ipc/sftpIpc.security.test.ts create mode 100644 apps/desktop/src/main/windows/windowSecurity.test.ts create mode 100644 apps/desktop/src/main/windows/windowSecurity.ts delete mode 100644 apps/ui/src/features/sftp/components/SftpStatusBar.tsx create mode 100644 packages/session-core/src/transports/sftpTransport.security.test.ts diff --git a/.gitignore b/.gitignore index e01f7a5..db99aee 100644 --- a/.gitignore +++ b/.gitignore @@ -13,5 +13,3 @@ AGENTS.md .claude/ docs/plans/ docs/_archive/ -.DS_Store -*.log diff --git a/CHANGELOG.md b/CHANGELOG.md index 07fcc57..3bb1deb 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -6,11 +6,6 @@ The format is based on [Keep a Changelog](https://keepachangelog.com/) and this ## [Unreleased] -### Added - -- **SFTP status bar** — each pane now shows a footer with folder count, file count, and total size. When items are selected, selection stats are shown on the right side. -- **SFTP mouse-drag multi-select** — click and drag across rows to select a range of files/folders. Ctrl+Click (toggle) and Shift+Click (range) continue to work. Drag-and-drop file transfer is preserved for already-selected items. - ## [0.1.3] - 2026-04-11 ### Changed diff --git a/README.md b/README.md index df8315e..bca5ae4 100644 --- a/README.md +++ b/README.md @@ -77,7 +77,6 @@ - [pnpm](https://pnpm.io/) 10.8+ - Windows: Visual Studio Build Tools (for native modules) - macOS: Xcode Command Line Tools -- Linux: `build-essential`, `python3` (for native modules) ### Install and Run @@ -139,9 +138,6 @@ pnpm release:windows:unsigned # macOS DMG pnpm release:mac:unsigned - -# Linux AppImage + deb -pnpm release:linux:unsigned ``` ## Tech Stack diff --git a/apps/desktop/src/main/ipc/registerIpc.ts b/apps/desktop/src/main/ipc/registerIpc.ts index 55b3dec..c2d3d5b 100644 --- a/apps/desktop/src/main/ipc/registerIpc.ts +++ b/apps/desktop/src/main/ipc/registerIpc.ts @@ -1372,7 +1372,6 @@ export function registerIpc( ipcMain.handle(ipcChannels.session.setSignals, (_event, request) => { const parsed = setSignalsRequestSchema.parse(request); manager.setSignals(parsed.sessionId, parsed.signals); - return { ok: true }; }); ipcMain.handle(ipcChannels.session.hostStats, (event, request) => hostStatsHandler(event, request, manager) diff --git a/apps/desktop/src/main/ipc/sftpIpc.security.test.ts b/apps/desktop/src/main/ipc/sftpIpc.security.test.ts new file mode 100644 index 0000000..2fa5505 --- /dev/null +++ b/apps/desktop/src/main/ipc/sftpIpc.security.test.ts @@ -0,0 +1,17 @@ +import path from "node:path"; +import { describe, expect, it } from "vitest"; + +import { resolveSafeDragOutPath } from "./sftpIpc"; + +describe("resolveSafeDragOutPath", () => { + it("keeps valid filenames inside the temp directory", () => { + const tempDir = path.join("tmp", "hypershell-drag"); + const result = resolveSafeDragOutPath(tempDir, "server.log"); + expect(result).toBe(path.resolve(tempDir, "server.log")); + }); + + it("rejects traversal and path separator payloads", () => { + expect(() => resolveSafeDragOutPath("/tmp/hypershell-drag", "../escape.txt")).toThrow(/invalid drag-out filename/i); + expect(() => resolveSafeDragOutPath("/tmp/hypershell-drag", "nested/escape.txt")).toThrow(/invalid drag-out filename/i); + }); +}); diff --git a/apps/desktop/src/main/ipc/sftpIpc.ts b/apps/desktop/src/main/ipc/sftpIpc.ts index 82e92d2..23591bc 100644 --- a/apps/desktop/src/main/ipc/sftpIpc.ts +++ b/apps/desktop/src/main/ipc/sftpIpc.ts @@ -47,7 +47,8 @@ import { type KeyboardInteractiveRequest, } from "@hypershell/shared"; import type { SessionManager, SftpConnectionOptions, KeyboardInteractiveCallback } from "@hypershell/session-core"; -import { createSyncEngine, probeHostKey } from "@hypershell/session-core"; +import { createSyncEngine, probeHostKey, buildScpCommand } from "@hypershell/session-core"; +import { execFile } from "node:child_process"; import { timingSafeEqual } from "node:crypto"; import type { IpcMainInvokeEvent } from "electron"; @@ -57,6 +58,28 @@ import { createSftpSessionManager, type SftpSessionManager } from "../sftp/sftpS import { createTransferManager, type TransferManager } from "../sftp/transferManager"; import { createTransferManifest } from "../sftp/transferManifest"; +export function resolveSafeDragOutPath(tempDir: string, fileName: string): string { + const trimmed = fileName.trim(); + if (!trimmed) { + throw new Error("Invalid drag-out filename"); + } + + const baseName = path.basename(trimmed); + const hasPathSeparators = trimmed.includes(path.posix.sep) || trimmed.includes(path.win32.sep); + const hasControlChars = /[\0-\x1f]/.test(trimmed); + if (hasPathSeparators || hasControlChars || baseName !== trimmed || baseName === "." || baseName === "..") { + throw new Error("Invalid drag-out filename"); + } + + const resolvedTempDir = path.resolve(tempDir); + const resolvedTarget = path.resolve(resolvedTempDir, baseName); + if (resolvedTarget !== path.join(resolvedTempDir, baseName)) { + throw new Error("Invalid drag-out filename"); + } + + return resolvedTarget; +} + /** * Error subclass thrown when host key verification fails. * Contains the fingerprint details so the renderer can show the appropriate dialog. @@ -188,6 +211,9 @@ export function registerSftpIpc( const bookmarksRepo = createSftpBookmarksRepository(db); const fingerprintRepo = createHostFingerprintRepositoryFromDatabase(db); + // Drag-out cache: pre-downloaded files keyed by "sessionId:remotePath" + const dragCache = new Map(); + // Keyboard-interactive auth relay: pending requests keyed by requestId const pendingKbdInteractive = new Map void; @@ -237,6 +263,11 @@ export function registerSftpIpc( const hostname = connectOptions.hostname; const port = connectOptions.port ?? 22; + const trustedFingerprints = fingerprintRepo + .findByHost(hostname, port) + .filter((record) => record.isTrusted) + .map((record) => record.fingerprint); + try { const { algorithm, fingerprint } = await probeHostKey(hostname, port); const existing = fingerprintRepo.findByHostAndAlgorithm(hostname, port, algorithm); @@ -294,6 +325,7 @@ export function registerSftpIpc( const sftpSessionId = await sftpSessionManager.connect(hostId, connectOptions, { onKeyboardInteractive: createKeyboardInteractiveCallback(), + trustedHostFingerprints: trustedFingerprints, }); options.onConnected?.({ sftpSessionId, @@ -479,28 +511,65 @@ export function registerSftpIpc( const handleDragOut = async (event: IpcMainInvokeEvent, rawRequest: unknown) => { const request = sftpDragOutRequestSchema.parse(rawRequest); - const transport = sftpSessionManager.getTransport(request.sftpSessionId); + const cacheKey = `${request.sftpSessionId}:${request.remotePath}`; + + // Check cache first — file may have been pre-downloaded on selection + let tempPath = dragCache.get(cacheKey); + + if (!tempPath || !fs.existsSync(tempPath)) { + const session = sftpSessionManager.getSession(request.sftpSessionId); + if (!session) throw new Error(`SFTP session ${request.sftpSessionId} not found`); + + const tempDir = path.join(app.getPath("temp"), "hypershell-drag"); + fs.mkdirSync(tempDir, { recursive: true }); + tempPath = resolveSafeDragOutPath(tempDir, request.fileName); + + const connOpts = session.connectionOptions; + const keyPath = connOpts.privateKeyPath ?? connOpts.fallbackKeyPaths?.[0]; + const scpCmd = buildScpCommand({ + hostname: connOpts.hostname, + port: connOpts.port, + username: connOpts.username, + privateKeyPath: keyPath, + proxyJump: connOpts.proxyJump, + direction: "download", + remotePath: request.remotePath, + localPath: tempPath, + }); + if (request.isDirectory) { + scpCmd.args.unshift("-r"); + } - // Download to a temp directory - const tempDir = path.join(app.getPath("temp"), "hypershell-drag"); - fs.mkdirSync(tempDir, { recursive: true }); - const tempPath = path.join(tempDir, request.fileName); - - // Stream remote file to local temp - await new Promise((resolve, reject) => { - const readStream = transport.createReadStream(request.remotePath); - const writeStream = fs.createWriteStream(tempPath); - readStream.pipe(writeStream); - writeStream.on("finish", resolve); - writeStream.on("error", reject); - readStream.on("error", reject); - }); + try { + await new Promise((resolve, reject) => { + execFile(scpCmd.command, scpCmd.args, { windowsHide: true }, (error) => { + if (error) reject(error); + else resolve(); + }); + }); + dragCache.set(cacheKey, tempPath); + } catch { + // SCP failed (directory, permission denied, etc.) — skip caching + return { tempPath: "" }; + } + } - // Initiate native OS drag from the temp file - event.sender.startDrag({ - file: tempPath, - icon: await app.getFileIcon(tempPath, { size: "small" }), - }); + // prepareOnly: just cache the file, don't initiate OS drag + if (request.prepareOnly) { + return { tempPath }; + } + + // Initiate native OS drag from the cached temp file + let icon: Electron.NativeImage; + try { + icon = await app.getFileIcon(tempPath, { size: "small" }); + } catch { + const { nativeImage } = await import("electron"); + icon = nativeImage.createFromBuffer( + Buffer.from("iVBORw0KGgoAAAANSUhEUgAAABAAAAAQCAYAAAAf8/9hAAAADklEQVQ4jWNgGAWDEwAAAhAAATHKfqoAAAAASUVORK5CYII=", "base64") + ); + } + event.sender.startDrag({ file: tempPath, icon }); return { tempPath }; }; diff --git a/apps/desktop/src/main/main.lifecycle.test.ts b/apps/desktop/src/main/main.lifecycle.test.ts index d292e47..e617a02 100644 --- a/apps/desktop/src/main/main.lifecycle.test.ts +++ b/apps/desktop/src/main/main.lifecycle.test.ts @@ -38,7 +38,9 @@ function createWindow(id: string) { return this.destroyed; }, webContents: { - send: vi.fn() + send: vi.fn(), + on: vi.fn(), + setWindowOpenHandler: vi.fn() } }; } diff --git a/apps/desktop/src/main/main.ts b/apps/desktop/src/main/main.ts index da5dab6..1b87688 100644 --- a/apps/desktop/src/main/main.ts +++ b/apps/desktop/src/main/main.ts @@ -15,6 +15,7 @@ import { performAutoBackup } from "./ipc/backupIpc"; import { createAppMenu } from "./menu/createAppMenu"; import { createTray } from "./tray/createTray"; import { createMainWindow } from "./windows/createMainWindow"; +import { assertAllowedRendererUrl } from "./windows/windowSecurity"; import { createMainProcessLifecycle } from "./mainLifecycle"; import { clearAll as clearCredentialCache } from "./security/credentialCache"; import { getOrCreateDatabase, getOrCreateHostsRepo } from "./ipc/hostsIpc"; @@ -86,22 +87,23 @@ function persistSessionRecoverySnapshot(): void { } function getRendererUrl(): string { - if (process.env.SSHTERM_RENDERER_URL) { - return process.env.SSHTERM_RENDERER_URL; - } - - const bundledRendererEntry = path.join( - import.meta.dirname, - "..", - "renderer", - "index.html" - ); - - if (existsSync(bundledRendererEntry)) { - return pathToFileURL(bundledRendererEntry).toString(); - } - - return "http://localhost:5173"; + const rawUrl = process.env.SSHTERM_RENDERER_URL + ?? (() => { + const bundledRendererEntry = path.join( + import.meta.dirname, + "..", + "renderer", + "index.html" + ); + + if (existsSync(bundledRendererEntry)) { + return pathToFileURL(bundledRendererEntry).toString(); + } + + return "http://localhost:5173"; + })(); + + return assertAllowedRendererUrl(rawUrl).toString(); } const mainProcessLifecycle = createMainProcessLifecycle({ diff --git a/apps/desktop/src/main/mainLifecycle.ts b/apps/desktop/src/main/mainLifecycle.ts index a6a3cd9..e50cd1d 100644 --- a/apps/desktop/src/main/mainLifecycle.ts +++ b/apps/desktop/src/main/mainLifecycle.ts @@ -4,6 +4,7 @@ import type { RegisterIpcOptions } from "./ipc/registerIpc"; import { editorWindowManager } from "./windows/editorWindowManager"; +import { attachWindowSecurityGuards } from "./windows/windowSecurity"; import type { TrayWindowLike } from "./tray/createTray"; interface ElectronAppLike { @@ -81,7 +82,9 @@ export function createMainProcessLifecycle( loadURL(url: string): Promise | void; } { const window = deps.createMainWindow(); - void window.loadURL(deps.getRendererUrl()); + const rendererUrl = deps.getRendererUrl(); + attachWindowSecurityGuards(window as unknown as Electron.BrowserWindow, rendererUrl); + void window.loadURL(rendererUrl); mainWindow = window; return window; } diff --git a/apps/desktop/src/main/windows/createEditorWindow.ts b/apps/desktop/src/main/windows/createEditorWindow.ts index 168cfa5..a81dce1 100644 --- a/apps/desktop/src/main/windows/createEditorWindow.ts +++ b/apps/desktop/src/main/windows/createEditorWindow.ts @@ -1,6 +1,7 @@ import { BrowserWindow } from "electron"; import path from "node:path"; import { resolveAppIconPath } from "./resolveAppIconPath"; +import { attachWindowSecurityGuards } from "./windowSecurity"; export interface CreateEditorWindowOptions { sftpSessionId: string; @@ -34,6 +35,7 @@ export function createEditorWindow(options: CreateEditorWindowOptions): BrowserW const url = new URL(rendererUrl); url.searchParams.set("window", "editor"); url.searchParams.set("sftpSessionId", sftpSessionId); + attachWindowSecurityGuards(win, rendererUrl); void win.loadURL(url.toString()); return win; diff --git a/apps/desktop/src/main/windows/createMainWindow.ts b/apps/desktop/src/main/windows/createMainWindow.ts index 2a3f27c..7e90cb0 100644 --- a/apps/desktop/src/main/windows/createMainWindow.ts +++ b/apps/desktop/src/main/windows/createMainWindow.ts @@ -225,8 +225,7 @@ export function createMainWindow(): BrowserWindow { closeConfirmed = true; win.close(); } - }) - .catch(() => { /* window destroyed before user responded */ }); + }); }); return win; diff --git a/apps/desktop/src/main/windows/windowSecurity.test.ts b/apps/desktop/src/main/windows/windowSecurity.test.ts new file mode 100644 index 0000000..2d76cd8 --- /dev/null +++ b/apps/desktop/src/main/windows/windowSecurity.test.ts @@ -0,0 +1,58 @@ +import { describe, expect, it, vi } from "vitest"; + +import { + assertAllowedRendererUrl, + attachWindowSecurityGuards, + isAllowedNavigationTarget, +} from "./windowSecurity"; + +describe("assertAllowedRendererUrl", () => { + it("accepts local dev and file renderer URLs", () => { + expect(assertAllowedRendererUrl("http://localhost:5173").toString()).toBe("http://localhost:5173/"); + expect(assertAllowedRendererUrl("http://127.0.0.1:5173").toString()).toBe("http://127.0.0.1:5173/"); + expect(assertAllowedRendererUrl("file:///tmp/renderer/index.html").toString()).toBe("file:///tmp/renderer/index.html"); + }); + + it("rejects remote renderer URLs", () => { + expect(() => assertAllowedRendererUrl("https://evil.example/app")).toThrow(/renderer url/i); + }); +}); + +describe("isAllowedNavigationTarget", () => { + it("allows same-origin dev navigation and same-file production navigation", () => { + expect(isAllowedNavigationTarget("http://localhost:5173", "http://localhost:5173/?window=editor")).toBe(true); + expect(isAllowedNavigationTarget("file:///tmp/renderer/index.html", "file:///tmp/renderer/index.html?window=editor")).toBe(true); + }); + + it("rejects cross-origin and cross-file navigation", () => { + expect(isAllowedNavigationTarget("http://localhost:5173", "https://evil.example/")).toBe(false); + expect(isAllowedNavigationTarget("file:///tmp/renderer/index.html", "file:///tmp/renderer/other.html")).toBe(false); + }); +}); + +describe("attachWindowSecurityGuards", () => { + it("blocks unexpected navigation and denies new windows", () => { + const willNavigateHandlers: Array<(event: { preventDefault: () => void }, url: string) => void> = []; + const setWindowOpenHandler = vi.fn(); + + const fakeWindow = { + webContents: { + on: vi.fn((event: string, handler: (event: { preventDefault: () => void }, url: string) => void) => { + if (event === "will-navigate") { + willNavigateHandlers.push(handler); + } + }), + setWindowOpenHandler, + }, + } as const; + + attachWindowSecurityGuards(fakeWindow as never, "http://localhost:5173"); + + const preventDefault = vi.fn(); + willNavigateHandlers[0]?.({ preventDefault }, "https://evil.example/"); + expect(preventDefault).toHaveBeenCalledTimes(1); + + const handler = setWindowOpenHandler.mock.calls[0]?.[0] as ((details: { url: string }) => { action: string }); + expect(handler({ url: "http://localhost:5173/help" })).toEqual({ action: "deny" }); + }); +}); diff --git a/apps/desktop/src/main/windows/windowSecurity.ts b/apps/desktop/src/main/windows/windowSecurity.ts new file mode 100644 index 0000000..cf33080 --- /dev/null +++ b/apps/desktop/src/main/windows/windowSecurity.ts @@ -0,0 +1,42 @@ +export function assertAllowedRendererUrl(rawUrl: string): URL { + const parsed = new URL(rawUrl); + + if (parsed.protocol === "file:") { + return parsed; + } + + const isLocalHttp = (parsed.protocol === "http:" || parsed.protocol === "https:") + && (parsed.hostname === "localhost" || parsed.hostname === "127.0.0.1"); + + if (isLocalHttp) { + return parsed; + } + + throw new Error(`Renderer URL is not allowed: ${rawUrl}`); +} + +export function isAllowedNavigationTarget(rendererUrl: string, targetUrl: string): boolean { + const renderer = assertAllowedRendererUrl(rendererUrl); + const target = new URL(targetUrl); + + if (renderer.protocol === "file:") { + return target.protocol === "file:" && target.pathname === renderer.pathname; + } + + return target.origin === renderer.origin; +} + +export function attachWindowSecurityGuards( + win: Pick, + rendererUrl: string +): void { + const allowedRendererUrl = assertAllowedRendererUrl(rendererUrl).toString(); + + win.webContents.on("will-navigate", (event, url) => { + if (!isAllowedNavigationTarget(allowedRendererUrl, url)) { + event.preventDefault(); + } + }); + + win.webContents.setWindowOpenHandler(() => ({ action: "deny" })); +} diff --git a/apps/ui/src/app/App.tsx b/apps/ui/src/app/App.tsx index 5d4cc0e..73b9edb 100644 --- a/apps/ui/src/app/App.tsx +++ b/apps/ui/src/app/App.tsx @@ -113,6 +113,7 @@ async function loadHosts(): Promise { } try { const dbHosts = await window.hypershell.listHosts(); + console.log("[hypershell] loaded hosts from DB:", dbHosts.length); return dbHosts.map((h: Record) => mapDbHostToUiHost(h)); } catch (err) { console.error("[hypershell] failed to load hosts:", err); @@ -258,6 +259,7 @@ async function persistHost(host: HostRecord): Promise { ? { password: (host.password ?? "").trim() } : {}) }); + console.log("[hypershell] persisted host:", result); return mapDbHostToUiHost(result as unknown as Record); } catch (err) { console.error("[hypershell] failed to persist host:", err); @@ -322,7 +324,7 @@ function MainApp() { const [sftpAuthPassword, setSftpAuthPassword] = useState(""); const [sftpAuthError, setSftpAuthError] = useState(null); const [sftpAuthSubmitting, setSftpAuthSubmitting] = useState(false); - const [connectingHostIds, setConnectingHostIds] = useState>(() => new Set()); + const [connectingHostIds, setConnectingHostIds] = useState>(new Set()); const [lastConnectedAtByHostId, setLastConnectedAtByHostId] = useState>({}); const [connectionHistoryHost, setConnectionHistoryHost] = useState(null); const [hostKeyVerifyOpen, setHostKeyVerifyOpen] = useState(false); diff --git a/apps/ui/src/features/hosts/PuttyImportDialog.tsx b/apps/ui/src/features/hosts/PuttyImportDialog.tsx index f091c0c..16e924c 100644 --- a/apps/ui/src/features/hosts/PuttyImportDialog.tsx +++ b/apps/ui/src/features/hosts/PuttyImportDialog.tsx @@ -8,7 +8,7 @@ export interface PuttyImportDialogProps { export function PuttyImportDialog({ onImport, onClose }: PuttyImportDialogProps) { const [sessions, setSessions] = useState([]); - const [selected, setSelected] = useState>(() => new Set()); + const [selected, setSelected] = useState>(new Set()); const [loading, setLoading] = useState(true); const [error, setError] = useState(null); diff --git a/apps/ui/src/features/hosts/SshManagerImportDialog.tsx b/apps/ui/src/features/hosts/SshManagerImportDialog.tsx index b9e7e3b..026c274 100644 --- a/apps/ui/src/features/hosts/SshManagerImportDialog.tsx +++ b/apps/ui/src/features/hosts/SshManagerImportDialog.tsx @@ -11,9 +11,9 @@ export function SshManagerImportDialog({ onImported, onClose }: SshManagerImport const [hosts, setHosts] = useState([]); const [groups, setGroups] = useState([]); const [snippets, setSnippets] = useState([]); - const [selectedHosts, setSelectedHosts] = useState>(() => new Set()); - const [selectedGroups, setSelectedGroups] = useState>(() => new Set()); - const [selectedSnippets, setSelectedSnippets] = useState>(() => new Set()); + const [selectedHosts, setSelectedHosts] = useState>(new Set()); + const [selectedGroups, setSelectedGroups] = useState>(new Set()); + const [selectedSnippets, setSelectedSnippets] = useState>(new Set()); const [loading, setLoading] = useState(true); const [importing, setImporting] = useState(false); const [error, setError] = useState(null); diff --git a/apps/ui/src/features/sftp/components/FileList.tsx b/apps/ui/src/features/sftp/components/FileList.tsx index 06b9894..4668930 100644 --- a/apps/ui/src/features/sftp/components/FileList.tsx +++ b/apps/ui/src/features/sftp/components/FileList.tsx @@ -116,44 +116,25 @@ export function FileList({ [entries, sortBy.column, sortBy.direction] ); - - // --- Mouse-drag range selection --- - const dragSelectRef = useRef<{ startIndex: number } | null>(null); - const didDragSelectRef = useRef(false); - - const handleRowMouseDown = useCallback( - (index: number, entry: FileListEntry, event: MouseEvent) => { - if (event.button !== 0) return; // left-click only - // If the item is already selected, let native drag handle it (file transfer) - if (selection.has(entry.path)) return; - dragSelectRef.current = { startIndex: index }; - }, - [selection] - ); - - const handleRowMouseEnter = useCallback( - (index: number) => { - if (!dragSelectRef.current) return; - // Only activate drag-select after moving to a different row - if (dragSelectRef.current.startIndex === index) return; - didDragSelectRef.current = true; - const from = Math.min(dragSelectRef.current.startIndex, index); - const to = Math.max(dragSelectRef.current.startIndex, index); - const paths = sortedEntries.slice(from, to + 1).map((e) => e.path); - onSelect(new Set(paths)); - }, - [onSelect, sortedEntries] - ); - useEffect(() => { - const handleMouseUp = () => { - dragSelectRef.current = null; - // Reset after a microtask so onClick fires first - queueMicrotask(() => { didDragSelectRef.current = false; }); - }; - document.addEventListener("mouseup", handleMouseUp); - return () => document.removeEventListener("mouseup", handleMouseUp); - }, []); + const container = containerRef.current; + const domRows = container?.querySelectorAll("tbody tr") ?? []; + const firstRow = domRows.item(0) as HTMLTableRowElement | null; + const firstRowHeight = firstRow?.getBoundingClientRect().height ?? 0; + const containerHeight = container?.getBoundingClientRect().height ?? 0; + + console.log( + "[sftp-ui] file list render:", + `pane=${paneType}`, + `entries=${entries.length}`, + `sorted=${sortedEntries.length}`, + `loading=${isLoading}`, + `error=${error ?? "(none)"}`, + `domRows=${domRows.length}`, + `firstRowH=${firstRowHeight.toFixed(1)}`, + `containerH=${containerHeight.toFixed(1)}` + ); + }, [entries.length, error, isLoading, paneType, sortedEntries.length]); const handleHeaderClick = (column: SortColumn) => { const direction = @@ -162,12 +143,6 @@ export function FileList({ }; const handleRowClick = (entry: FileListEntry, event: MouseEvent) => { - // Skip click if a drag-select gesture just completed - if (didDragSelectRef.current) { - didDragSelectRef.current = false; - return; - } - if (event.metaKey || event.ctrlKey) { const next = new Set(selection); if (next.has(entry.path)) { @@ -176,10 +151,7 @@ export function FileList({ next.add(entry.path); } onSelect(next); - return; - } - - if (event.shiftKey && selection.size > 0) { + } else if (event.shiftKey && selection.size > 0) { const paths = sortedEntries.map((item) => item.path); const selectedPaths = Array.from(selection); const lastSelected = selectedPaths[selectedPaths.length - 1]; @@ -188,10 +160,20 @@ export function FileList({ const [from, to] = startIndex <= endIndex ? [startIndex, endIndex] : [endIndex, startIndex]; onSelect(new Set(paths.slice(from, to + 1))); - return; + } else { + onSelect(new Set([entry.path])); } - onSelect(new Set([entry.path])); + // Pre-cache single file for drag-out (background download to temp) + if (sftpSessionId) { + void window.hypershell?.sftpDragOut?.({ + sftpSessionId, + remotePath: entry.path, + fileName: entry.name, + isDirectory: entry.isDirectory, + prepareOnly: true, + }).catch(() => {}); + } }; const handleRowDoubleClick = (entry: FileListEntry) => { @@ -212,11 +194,12 @@ export function FileList({ event.dataTransfer.setData("text/plain", paths.join("\n")); // For remote pane: fire-and-forget IPC to initiate native OS drag - if (sftpSessionId && paths.length === 1 && !entry.isDirectory) { + if (sftpSessionId && paths.length === 1) { void window.hypershell?.sftpDragOut?.({ sftpSessionId, remotePath: paths[0], fileName: entry.name, + isDirectory: entry.isDirectory, }).catch(() => { // Drag-out is best-effort; internal drag still works }); @@ -348,7 +331,7 @@ export function FileList({ handleRowMouseDown(index, entry, event)} - onMouseEnter={() => handleRowMouseEnter(index)} onClick={(event) => handleRowClick(entry, event)} onDoubleClick={() => handleRowDoubleClick(entry)} onContextMenu={(event) => { @@ -368,7 +349,7 @@ export function FileList({ } onContextMenu(event, entry); }} - draggable={selection.has(entry.path)} + draggable onDragStart={(event) => handleDragStart(event, entry)} > diff --git a/apps/ui/src/features/sftp/components/LocalPane.tsx b/apps/ui/src/features/sftp/components/LocalPane.tsx index e18421f..e8f052c 100644 --- a/apps/ui/src/features/sftp/components/LocalPane.tsx +++ b/apps/ui/src/features/sftp/components/LocalPane.tsx @@ -10,7 +10,6 @@ import { DriveSelector } from "./DriveSelector"; import { FileContextMenu, type FileContextMenuAction } from "./FileContextMenu"; import { FileList } from "./FileList"; import { PathBreadcrumb, type PathBreadcrumbHandle } from "./PathBreadcrumb"; -import { SftpStatusBar } from "./SftpStatusBar"; export interface LocalPaneProps { store: StoreApi; @@ -253,7 +252,6 @@ export function LocalPane({ store, onTransfer, onDownload, isActive, onActivate, paneType="local" cursorIndex={localCursorIndex} /> - {contextMenu && ( ; @@ -111,6 +110,18 @@ export function RemotePane({ return remoteEntries.filter((entry) => entry.name.toLowerCase().includes(lower)); }, [remoteEntries, remoteFilterText, filterCaseSensitive, filterRegex]); + useEffect(() => { + console.log( + "[sftp-ui] remote pane state:", + `path=${remotePath}`, + `entries=${remoteEntries.length}`, + `filtered=${filteredEntries.length}`, + `filter="${remoteFilterText || ""}"`, + `loading=${isLoading}`, + `error=${error ?? "(none)"}` + ); + }, [error, filteredEntries.length, isLoading, remoteEntries.length, remoteFilterText, remotePath]); + useEffect(() => { const maxIndex = Math.max(0, filteredEntries.length - 1); if (remoteCursorIndex > maxIndex) { @@ -136,6 +147,14 @@ export function RemotePane({ } const response = await sftpList({ sftpSessionId, path }); const entries = extractRemoteEntries(response); + console.log( + "[sftp-ui] loadDirectory:", + path, + "→", + entries.length, + "entries", + entries.length > 0 ? `(first: ${entries[0].name})` : "" + ); setRemoteEntries(entries); } catch (loadError) { const message = @@ -301,7 +320,6 @@ export function RemotePane({ cursorIndex={remoteCursorIndex} sftpSessionId={sftpSessionId} /> - {contextMenu && ( ; -} - -function summarize(items: FileListEntry[]) { - let files = 0; - let folders = 0; - let totalSize = 0; - for (const e of items) { - if (e.isDirectory) { - folders++; - } else { - files++; - totalSize += e.size; - } - } - return { files, folders, totalSize }; -} - -function formatCounts(files: number, folders: number): string { - const parts: string[] = []; - if (folders > 0) parts.push(`${folders} folder${folders !== 1 ? "s" : ""}`); - if (files > 0) parts.push(`${files} file${files !== 1 ? "s" : ""}`); - return parts.join(", ") || "Empty"; -} - -export function SftpStatusBar({ entries, selection }: SftpStatusBarProps) { - const stats = useMemo(() => summarize(entries), [entries]); - - const selectionStats = useMemo(() => { - if (selection.size === 0) return null; - const selected = entries.filter((e) => selection.has(e.path)); - return summarize(selected); - }, [entries, selection]); - - return ( -
- - {formatCounts(stats.files, stats.folders)} — {formatFileSize(stats.totalSize)} - - {selectionStats && ( - - Selected: {formatCounts(selectionStats.files, selectionStats.folders)} — {formatFileSize(selectionStats.totalSize)} - - )} -
- ); -} diff --git a/apps/ui/src/features/tunnels/TunnelForm.tsx b/apps/ui/src/features/tunnels/TunnelForm.tsx index 5ad337e..94c19e3 100644 --- a/apps/ui/src/features/tunnels/TunnelForm.tsx +++ b/apps/ui/src/features/tunnels/TunnelForm.tsx @@ -40,7 +40,7 @@ export function TunnelForm({ onSubmit, onCancel }: TunnelFormProps) { setForm({ ...form, port: e.target.value })} placeholder="SSH Port" className={inputClasses} />
- setForm({ ...form, protocol: e.target.value as any })} className={inputClasses}> diff --git a/packages/session-core/src/sessionManager.ts b/packages/session-core/src/sessionManager.ts index 049037b..8028eee 100644 --- a/packages/session-core/src/sessionManager.ts +++ b/packages/session-core/src/sessionManager.ts @@ -1,20 +1,20 @@ -import type { - OpenSessionRequest, - SessionState, - SessionTransportEvent, +import type { + OpenSessionRequest, + SessionState, + SessionTransportEvent, SessionTransportKind, SshConnectionOptions, SerialConnectionOptions, TelnetConnectionOptions, TransportHandle -} from "./transports/transportEvents"; -import { Client } from "ssh2"; -import { readFileSync } from "node:fs"; -import { createHash, timingSafeEqual } from "node:crypto"; -import { createSerialTransport } from "./transports/serialTransport"; -import { createSshPtyTransport } from "./transports/sshPtyTransport"; -import { createTelnetTransport } from "./transports/telnetTransport"; -import type { NetworkMonitor } from "./networkMonitor"; +} from "./transports/transportEvents"; +import { Client } from "ssh2"; +import { readFileSync } from "node:fs"; +import { createHash, timingSafeEqual } from "node:crypto"; +import { createSerialTransport } from "./transports/serialTransport"; +import { createSshPtyTransport } from "./transports/sshPtyTransport"; +import { createTelnetTransport } from "./transports/telnetTransport"; +import type { NetworkMonitor } from "./networkMonitor"; export interface SessionSnapshot { sessionId: string; @@ -47,28 +47,28 @@ export interface OpenSessionInput { reconnectBaseInterval?: number; } -export interface OpenSessionResult { - sessionId: string; - state: SessionState; -} - -export interface ExecCommandOptions { - expectedHostFingerprints?: string[]; -} - -export interface SessionManager { - open(input: OpenSessionInput): OpenSessionResult; - write(sessionId: string, data: string): void; +export interface OpenSessionResult { + sessionId: string; + state: SessionState; +} + +export interface ExecCommandOptions { + expectedHostFingerprints?: string[]; +} + +export interface SessionManager { + open(input: OpenSessionInput): OpenSessionResult; + write(sessionId: string, data: string): void; resize(sessionId: string, cols: number, rows: number): void; close(sessionId: string): void; destroyAll(): void; getSession(sessionId: string): SessionSnapshot | undefined; listSessions(): SessionSnapshot[]; - onEvent(listener: (event: SessionTransportEvent) => void): () => void; - setSignals(sessionId: string, signals: { dtr?: boolean; rts?: boolean }): void; - getSessionInput(sessionId: string): OpenSessionInput | undefined; - execCommand(sessionId: string, command: string, options?: ExecCommandOptions): Promise; -} + onEvent(listener: (event: SessionTransportEvent) => void): () => void; + setSignals(sessionId: string, signals: { dtr?: boolean; rts?: boolean }): void; + getSessionInput(sessionId: string): OpenSessionInput | undefined; + execCommand(sessionId: string, command: string, options?: ExecCommandOptions): Promise; +} interface ManagedSession { snapshot: SessionSnapshot; @@ -146,16 +146,16 @@ function createDefaultTransport(request: OpenSessionRequest): TransportHandle { return createNoopTransport(request.sessionId); } -export function createSessionManager( - deps: SessionManagerDeps = {} -): SessionManager { - const sessions = new Map(); - const listeners = new Set<(event: SessionTransportEvent) => void>(); - let nextSessionId = 1; - const sessionIdFactory = - deps.sessionIdFactory ?? (() => `session-${nextSessionId++}`); - const createTransport = deps.createTransport ?? createDefaultTransport; - const networkMonitor = deps.networkMonitor; +export function createSessionManager( + deps: SessionManagerDeps = {} +): SessionManager { + const sessions = new Map(); + const listeners = new Set<(event: SessionTransportEvent) => void>(); + let nextSessionId = 1; + const sessionIdFactory = + deps.sessionIdFactory ?? (() => `session-${nextSessionId++}`); + const createTransport = deps.createTransport ?? createDefaultTransport; + const networkMonitor = deps.networkMonitor; function updateSession( sessionId: string, @@ -404,22 +404,22 @@ export function createSessionManager( return { ...rest, sshOptions: safeSshOptions }; }, - execCommand(sessionId: string, command: string, options: ExecCommandOptions = {}): Promise { - const session = sessions.get(sessionId); - if (!session) return Promise.reject(new Error("Session not found")); - const opts = session.input.sshOptions; - if (!opts) return Promise.reject(new Error("Not an SSH session")); - const expectedFingerprints = (options.expectedHostFingerprints ?? []) - .map((value) => value.trim()) - .filter((value) => value.length > 0) - .map((value) => Buffer.from(value, "utf8")); - - if (expectedFingerprints.length === 0) { - return Promise.reject(new Error("Host key verification is required for SSH exec")); - } - - return new Promise((resolve, reject) => { - const client = new Client(); + execCommand(sessionId: string, command: string, options: ExecCommandOptions = {}): Promise { + const session = sessions.get(sessionId); + if (!session) return Promise.reject(new Error("Session not found")); + const opts = session.input.sshOptions; + if (!opts) return Promise.reject(new Error("Not an SSH session")); + const expectedFingerprints = (options.expectedHostFingerprints ?? []) + .map((value) => value.trim()) + .filter((value) => value.length > 0) + .map((value) => Buffer.from(value, "utf8")); + + if (expectedFingerprints.length === 0) { + return Promise.reject(new Error("Host key verification is required for SSH exec")); + } + + return new Promise((resolve, reject) => { + const client = new Client(); const timeout = setTimeout(() => { client.destroy(); reject(new Error("SSH exec timed out")); @@ -436,11 +436,6 @@ export function createSessionManager( let stdout = ""; stream.on("data", (chunk: Buffer) => { stdout += chunk.toString(); }); stream.stderr.on("data", () => { /* ignore stderr */ }); - stream.on("error", (err: Error) => { - clearTimeout(timeout); - client.end(); - reject(err); - }); stream.on("close", () => { clearTimeout(timeout); client.end(); @@ -465,28 +460,28 @@ export function createSessionManager( connectConfig.password = opts.password; } - if (opts.identityFile) { - try { - connectConfig.privateKey = readFileSync(opts.identityFile); + if (opts.identityFile) { + try { + connectConfig.privateKey = readFileSync(opts.identityFile); } catch { // Identity file not readable — fall through to other auth - } - } - - connectConfig.hostVerifier = (key: Buffer) => { - const actual = Buffer.from( - `SHA256:${createHash("sha256").update(key).digest("base64")}`, - "utf8" - ); - return expectedFingerprints.some( - (expected) => - expected.length === actual.length - && timingSafeEqual(expected, actual) - ); - }; - - client.connect(connectConfig); - }); + } + } + + connectConfig.hostVerifier = (key: Buffer) => { + const actual = Buffer.from( + `SHA256:${createHash("sha256").update(key).digest("base64")}`, + "utf8" + ); + return expectedFingerprints.some( + (expected) => + expected.length === actual.length + && timingSafeEqual(expected, actual) + ); + }; + + client.connect(connectConfig); + }); } }; } diff --git a/packages/session-core/src/ssh2ConnectionPool.ts b/packages/session-core/src/ssh2ConnectionPool.ts index e30e5e2..e44b2af 100644 --- a/packages/session-core/src/ssh2ConnectionPool.ts +++ b/packages/session-core/src/ssh2ConnectionPool.ts @@ -102,7 +102,7 @@ export function createSsh2ConnectionPool(): Ssh2ConnectionPool { if (!entry) return; if (entry.idleTimer) clearTimeout(entry.idleTimer); - try { entry.client.end(); } catch (e) { console.warn("[ssh2-pool] cleanup error:", e); } + try { entry.client.end(); } catch {} entries.delete(connectionId); const key = poolKey(entry.target); diff --git a/packages/session-core/src/transports/sftpTransport.security.test.ts b/packages/session-core/src/transports/sftpTransport.security.test.ts new file mode 100644 index 0000000..bf4f03d --- /dev/null +++ b/packages/session-core/src/transports/sftpTransport.security.test.ts @@ -0,0 +1,31 @@ +import { describe, expect, it } from "vitest"; + +import { buildConnectConfig, type SftpConnectionOptions } from "./sftpTransport"; + +describe("buildConnectConfig", () => { + const baseOptions: SftpConnectionOptions = { + hostname: "example.com", + port: 22, + username: "testuser", + authMethod: "password", + password: "testpass", + }; + + it("adds a hostVerifier when trusted fingerprints are provided", () => { + const config = buildConnectConfig(baseOptions, undefined, { + trustedHostFingerprints: ["SHA256:abc123"], + }); + + expect(config.hostVerifier).toBeTypeOf("function"); + }); + + it("accepts only trusted SHA256 host fingerprints", () => { + const config = buildConnectConfig(baseOptions, undefined, { + trustedHostFingerprints: ["SHA256:T17j3bElrAp7GM9254XJ8U9Pwjzk4JP5vC2CcWW6mGM="], + }); + + const hostVerifier = config.hostVerifier as (key: Buffer) => boolean; + expect(hostVerifier(Buffer.from("trusted-host-key"))).toBe(true); + expect(hostVerifier(Buffer.from("different-host-key"))).toBe(false); + }); +}); diff --git a/packages/session-core/src/transports/sftpTransport.ts b/packages/session-core/src/transports/sftpTransport.ts index 5d96090..6ba1c99 100644 --- a/packages/session-core/src/transports/sftpTransport.ts +++ b/packages/session-core/src/transports/sftpTransport.ts @@ -1,544 +1,558 @@ -import { readFileSync } from "node:fs"; -import type { Readable, Writable } from "node:stream"; -import { Client, type ConnectConfig, type OpenMode, type SFTPWrapper, type Stats } from "ssh2"; - -import type { - SessionState, - SessionTransportEvent, - SftpConnectionOptions -} from "./transportEvents"; -import type { Ssh2ConnectionPool, Ssh2PoolTarget, ResolvedAuth } from "../ssh2ConnectionPool"; - -export type { SftpConnectionOptions } from "./transportEvents"; - -export interface KeyboardInteractivePrompt { - prompt: string; - echo: boolean; -} - -export type KeyboardInteractiveCallback = ( - name: string, - instructions: string, - prompts: KeyboardInteractivePrompt[] -) => Promise; - -export interface SftpTransportOptions { - pool?: Ssh2ConnectionPool; - onKeyboardInteractive?: KeyboardInteractiveCallback; -} - -export interface SftpEntry { - name: string; - path: string; - size: number; - modifiedAt: string; - isDirectory: boolean; - permissions: number; - owner: number; - group: number; -} - -export interface SftpTransportHandle { - connect(): Promise; - disconnect(): void; - list(remotePath: string): Promise; - stat(remotePath: string): Promise; - chmod(remotePath: string, permissions: number): Promise; - mkdir(remotePath: string): Promise; - rename(oldPath: string, newPath: string): Promise; - remove(remotePath: string, recursive?: boolean): Promise; - readFile(remotePath: string): Promise; - writeFile(remotePath: string, data: Buffer): Promise; - createReadStream(remotePath: string, options?: { start?: number }): Readable; - createWriteStream(remotePath: string, options?: { start?: number; flags?: OpenMode }): Writable; - onEvent(listener: (event: SessionTransportEvent) => void): () => void; -} - -/** Collect all candidate key file paths in priority order. */ -function collectKeyPaths(options: SftpConnectionOptions): string[] { - const paths: string[] = []; - if (options.privateKeyPath) paths.push(options.privateKeyPath); - if (options.fallbackKeyPaths) { - for (const p of options.fallbackKeyPaths) { - if (!paths.includes(p)) paths.push(p); - } - } - return paths; -} - -function buildConnectConfig(options: SftpConnectionOptions, keyPath?: string): ConnectConfig { - // Strip Windows domain prefix (e.g. "DOMAIN\user" → "user") — SSH servers - // don't understand Windows domain usernames. - let sshUsername = options.username; - if (sshUsername && sshUsername.includes("\\")) { - sshUsername = sshUsername.split("\\").pop(); - } - - const config: ConnectConfig = { - host: options.hostname, - port: options.port ?? 22, - username: sshUsername, - keepaliveInterval: (options.keepAliveSeconds ?? 60) * 1000 - }; - - if (keyPath) { - try { - config.privateKey = readFileSync(keyPath); - } catch { - // Key file unreadable — skip. - } - } - - if (options.passphrase) { - config.passphrase = options.passphrase; - } - - const agentPath = options.agentPath ?? process.env.SSH_AUTH_SOCK; - if (agentPath) { - config.agent = agentPath; - } - - if (options.password) { - config.password = options.password; - } - - // Enable keyboard-interactive auth to support 2FA/MFA prompts - config.tryKeyboard = true; - - return config; -} - -function buildEntry(path: string, attrs: Stats): SftpEntry { - const name = path.split("/").filter(Boolean).at(-1) ?? path; - const mode = attrs.mode ?? 0; - return { - name, - path, - size: attrs.size ?? 0, - modifiedAt: new Date((attrs.mtime ?? 0) * 1000).toISOString(), - isDirectory: (mode & 0o40000) !== 0, - permissions: mode & 0o7777, - owner: attrs.uid ?? 0, - group: attrs.gid ?? 0 - }; -} - -function combineRemotePath(parentPath: string, name: string): string { - const normalizedParent = parentPath.endsWith("/") - ? parentPath.slice(0, -1) - : parentPath; - if (normalizedParent.length === 0) { - return `/${name}`; - } - - return `${normalizedParent}/${name}`; -} - -function resolveAuth(options: SftpConnectionOptions): ResolvedAuth { - if (options.authMethod === "password" && options.password) { - return { type: "password", password: options.password }; - } - if (options.authMethod === "agent" || options.agentPath || process.env.SSH_AUTH_SOCK) { - return { type: "agent", agent: options.agentPath ?? process.env.SSH_AUTH_SOCK ?? "" }; - } - if (options.privateKeyPath) { - try { - const privateKey = readFileSync(options.privateKeyPath); - return { type: "key", privateKey, passphrase: options.passphrase }; - } catch { - // Fall through - } - } - // Default to password if available - if (options.password) { - return { type: "password", password: options.password }; - } - return { type: "agent", agent: process.env.SSH_AUTH_SOCK ?? "" }; -} - -export function createSftpTransport( - sessionId: string, - options: SftpConnectionOptions, - transportOptions?: SftpTransportOptions -): SftpTransportHandle { - const listeners = new Set<(event: SessionTransportEvent) => void>(); - let client: Client | null = null; - let sftp: SFTPWrapper | null = null; - let poolConnectionId: string | null = null; - let poolConsumerId: string | null = null; - const pool = transportOptions?.pool; - - const emit = (event: SessionTransportEvent) => { - queueMicrotask(() => { - for (const listener of listeners) { - listener(event); - } - }); - }; - - const emitStatus = (state: SessionState) => { - emit({ type: "status", sessionId, state }); - }; - - const emitError = (message: string) => { - emit({ type: "error", sessionId, message }); - }; - - const requireSftp = (): SFTPWrapper => { - if (!sftp) { - throw new Error("SFTP session not connected"); - } - - return sftp; - }; - - function tryConnect(connectConfig: ConnectConfig): Promise { - return new Promise((resolve, reject) => { - const conn = new Client(); - let settled = false; - - const fail = (error: Error) => { - if (settled) return; - settled = true; - conn.removeAllListeners(); - reject(error); - }; - - conn.on("ready", () => { - conn.sftp((error, sftpSession) => { - if (error) { - fail(error); - return; - } - if (settled) return; - - settled = true; - client = conn; - sftp = sftpSession; - - conn.on("close", () => { - sftp = null; - client = null; - emitStatus("disconnected"); - }); - - resolve(); - }); - }); - - conn.on("error", fail); - - // Handle keyboard-interactive auth (2FA, TOTP, etc.) - const onKbdInteractive = transportOptions?.onKeyboardInteractive; - if (onKbdInteractive) { - conn.on("keyboard-interactive", (name, instructions, _instructionsLang, prompts, finish) => { - const mappedPrompts = prompts.map((p) => ({ - prompt: p.prompt, - echo: p.echo ?? false, - })); - onKbdInteractive(name, instructions, mappedPrompts) - .then((responses) => { - finish(responses); - }) - .catch(() => { - // User cancelled or error — send empty responses so server rejects - finish(prompts.map(() => "")); - }); - }); - } - - conn.connect(connectConfig); - }); - } - - async function connect(): Promise { - emitStatus("connecting"); - - // If pool is provided, use pooled connection - if (pool) { - try { - const target: Ssh2PoolTarget = { - hostname: options.hostname, - port: options.port ?? 22, - username: options.username ?? "", - auth: resolveAuth(options), - keepAliveSeconds: options.keepAliveSeconds, - }; - - const pooled = await pool.acquire(target); - poolConnectionId = pooled.connectionId; - poolConsumerId = pooled.consumerId; - client = pooled.client; - - // Get SFTP session from the pooled client - sftp = await new Promise((resolve, reject) => { - pooled.client.sftp((err, sftpSession) => { - if (err) reject(err); - else resolve(sftpSession); - }); - }); - - emitStatus("connected"); - return; - } catch (error) { - const message = error instanceof Error ? error.message : String(error); - emitError(message); - emitStatus("failed"); - throw error; - } - } - - // Collect all candidate key paths and try each one sequentially, - // just like the system ssh binary does. - const keyPaths = collectKeyPaths(options); - const attempts = keyPaths.length > 0 ? keyPaths : [undefined]; - - let lastError: Error | null = null; - for (const keyPath of attempts) { - const config = buildConnectConfig(options, keyPath); - - try { - await tryConnect(config); - emitStatus("connected"); - return; - } catch (error) { - lastError = error instanceof Error ? error : new Error(String(error)); - } - } - - emitError(lastError?.message ?? "All authentication methods failed"); - emitStatus("failed"); - throw lastError ?? new Error("All authentication methods failed"); - } - - function disconnect(): void { - if (pool && poolConnectionId && poolConsumerId) { - const activeSftp = sftp as { end?: () => void } | null; - try { - activeSftp?.end?.(); - } catch { - // Best effort: the underlying pooled SSH client is still released below. - } - - // Release back to pool — don't end the client - pool.release(poolConnectionId, poolConsumerId); - poolConnectionId = null; - poolConsumerId = null; - client = null; - sftp = null; - return; - } - - if (!client) { - return; - } - - client.end(); - client = null; - sftp = null; - } - - async function list(remotePath: string): Promise { - const sftpSession = requireSftp(); - return await new Promise((resolve, reject) => { - sftpSession.readdir(remotePath, (error, entries) => { - if (error) { - reject(error); - return; - } - - resolve( - (entries ?? []) - .filter((entry) => entry.filename !== "." && entry.filename !== "..") - .map((entry) => { - const path = combineRemotePath(remotePath, entry.filename); - return { - ...buildEntry(path, entry.attrs), - name: entry.filename - }; - }) - ); - }); - }); - } - - async function stat(remotePath: string): Promise { - const sftpSession = requireSftp(); - return await new Promise((resolve, reject) => { - sftpSession.stat(remotePath, (error, attrs) => { - if (error) { - reject(error); - return; - } - - resolve(buildEntry(remotePath, attrs)); - }); - }); - } - - async function mkdir(remotePath: string): Promise { - const sftpSession = requireSftp(); - await new Promise((resolve, reject) => { - sftpSession.mkdir(remotePath, (error) => { - if (error) { - reject(error); - return; - } - - resolve(); - }); - }); - } - - async function chmod(remotePath: string, permissions: number): Promise { - const sftpSession = requireSftp(); - await new Promise((resolve, reject) => { - sftpSession.chmod(remotePath, permissions & 0o7777, (error) => { - if (error) { - reject(error); - return; - } - - resolve(); - }); - }); - } - - async function rename(oldPath: string, newPath: string): Promise { - const sftpSession = requireSftp(); - await new Promise((resolve, reject) => { - sftpSession.rename(oldPath, newPath, (error) => { - if (error) { - reject(error); - return; - } - - resolve(); - }); - }); - } - - async function remove(remotePath: string, recursive = false): Promise { - const sftpSession = requireSftp(); - if (recursive) { - const entries = await list(remotePath); - for (const entry of entries) { - if (entry.isDirectory) { - await remove(entry.path, true); - continue; - } - - await new Promise((resolve, reject) => { - sftpSession.unlink(entry.path, (error) => { - if (error) { - reject(error); - return; - } - - resolve(); - }); - }); - } - - await new Promise((resolve, reject) => { - sftpSession.rmdir(remotePath, (error) => { - if (error) { - reject(error); - return; - } - - resolve(); - }); - }); - return; - } - - const entry = await stat(remotePath); - await new Promise((resolve, reject) => { - if (entry.isDirectory) { - sftpSession.rmdir(remotePath, (error) => { - if (error) { - reject(error); - return; - } - - resolve(); - }); - return; - } - - sftpSession.unlink(remotePath, (error) => { - if (error) { - reject(error); - return; - } - - resolve(); - }); - }); - } - - async function readFile(remotePath: string): Promise { - const MAX_READ_SIZE = 10 * 1024 * 1024; // 10 MB - const entry = await stat(remotePath); - if (entry.size > MAX_READ_SIZE) { - throw new Error( - `File too large to open in editor (${(entry.size / 1024 / 1024).toFixed(1)} MB, max ${MAX_READ_SIZE / 1024 / 1024} MB)` - ); - } - - const sftpSession = requireSftp(); - const stream = sftpSession.createReadStream(remotePath); - return await new Promise((resolve, reject) => { - const chunks: Buffer[] = []; - stream.on("data", (chunk: string | Buffer) => { - chunks.push(Buffer.isBuffer(chunk) ? chunk : Buffer.from(chunk)); - }); - stream.on("error", reject); - stream.on("end", () => { - resolve(Buffer.concat(chunks)); - }); - }); - } - - async function writeFile(remotePath: string, data: Buffer): Promise { - const sftpSession = requireSftp(); - const stream = sftpSession.createWriteStream(remotePath); - await new Promise((resolve, reject) => { - stream.on("error", reject); - stream.on("close", () => resolve()); - stream.end(data); - }); - } - - function createReadStream(remotePath: string, options?: { start?: number }): Readable { - return requireSftp().createReadStream(remotePath, options); - } - - function createWriteStream(remotePath: string, options?: { start?: number; flags?: OpenMode }): Writable { - return requireSftp().createWriteStream(remotePath, options); - } - - function onEvent(listener: (event: SessionTransportEvent) => void): () => void { - listeners.add(listener); - return () => { - listeners.delete(listener); - }; - } - - return { - connect, - disconnect, - list, - stat, - chmod, - mkdir, - rename, - remove, - readFile, - writeFile, - createReadStream, - createWriteStream, - onEvent - }; -} +import { readFileSync } from "node:fs"; +import { createHash } from "node:crypto"; +import type { Readable, Writable } from "node:stream"; +import { Client, type ConnectConfig, type OpenMode, type SFTPWrapper, type Stats } from "ssh2"; + +import type { + SessionState, + SessionTransportEvent, + SftpConnectionOptions, + SftpSecurityOptions +} from "./transportEvents"; +import type { Ssh2ConnectionPool, Ssh2PoolTarget, ResolvedAuth } from "../ssh2ConnectionPool"; + +export type { SftpConnectionOptions } from "./transportEvents"; + +export interface KeyboardInteractivePrompt { + prompt: string; + echo: boolean; +} + +export type KeyboardInteractiveCallback = ( + name: string, + instructions: string, + prompts: KeyboardInteractivePrompt[] +) => Promise; + +export interface SftpTransportOptions extends SftpSecurityOptions { + pool?: Ssh2ConnectionPool; + onKeyboardInteractive?: KeyboardInteractiveCallback; +} + +export interface SftpEntry { + name: string; + path: string; + size: number; + modifiedAt: string; + isDirectory: boolean; + permissions: number; + owner: number; + group: number; +} + +export interface SftpTransportHandle { + connect(): Promise; + disconnect(): void; + list(remotePath: string): Promise; + stat(remotePath: string): Promise; + chmod(remotePath: string, permissions: number): Promise; + mkdir(remotePath: string): Promise; + rename(oldPath: string, newPath: string): Promise; + remove(remotePath: string, recursive?: boolean): Promise; + readFile(remotePath: string): Promise; + writeFile(remotePath: string, data: Buffer): Promise; + createReadStream(remotePath: string, options?: { start?: number }): Readable; + createWriteStream(remotePath: string, options?: { start?: number; flags?: OpenMode }): Writable; + onEvent(listener: (event: SessionTransportEvent) => void): () => void; +} + +/** Collect all candidate key file paths in priority order. */ +function collectKeyPaths(options: SftpConnectionOptions): string[] { + const paths: string[] = []; + if (options.privateKeyPath) paths.push(options.privateKeyPath); + if (options.fallbackKeyPaths) { + for (const p of options.fallbackKeyPaths) { + if (!paths.includes(p)) paths.push(p); + } + } + return paths; +} + +export function buildConnectConfig( + options: SftpConnectionOptions, + keyPath?: string, + securityOptions?: SftpSecurityOptions +): ConnectConfig { + // Strip Windows domain prefix (e.g. "DOMAIN\user" → "user") — SSH servers + // don't understand Windows domain usernames. + let sshUsername = options.username; + if (sshUsername && sshUsername.includes("\\")) { + sshUsername = sshUsername.split("\\").pop(); + } + + const config: ConnectConfig = { + host: options.hostname, + port: options.port ?? 22, + username: sshUsername, + keepaliveInterval: (options.keepAliveSeconds ?? 60) * 1000 + }; + + if (keyPath) { + try { + config.privateKey = readFileSync(keyPath); + } catch { + // Key file unreadable — skip. + } + } + + if (options.passphrase) { + config.passphrase = options.passphrase; + } + + const agentPath = options.agentPath ?? process.env.SSH_AUTH_SOCK; + if (agentPath) { + config.agent = agentPath; + } + + if (options.password) { + config.password = options.password; + } + + // Enable keyboard-interactive auth to support 2FA/MFA prompts + config.tryKeyboard = true; + + const trustedFingerprints = new Set(securityOptions?.trustedHostFingerprints ?? []); + if (trustedFingerprints.size > 0) { + config.hostVerifier = (key: Buffer) => { + const fingerprint = `SHA256:${createHash("sha256").update(key).digest("base64")}`; + return trustedFingerprints.has(fingerprint); + }; + } + + return config; +} + +function buildEntry(path: string, attrs: Stats): SftpEntry { + const name = path.split("/").filter(Boolean).at(-1) ?? path; + const mode = attrs.mode ?? 0; + return { + name, + path, + size: attrs.size ?? 0, + modifiedAt: new Date((attrs.mtime ?? 0) * 1000).toISOString(), + isDirectory: (mode & 0o40000) !== 0, + permissions: mode & 0o7777, + owner: attrs.uid ?? 0, + group: attrs.gid ?? 0 + }; +} + +function combineRemotePath(parentPath: string, name: string): string { + const normalizedParent = parentPath.endsWith("/") + ? parentPath.slice(0, -1) + : parentPath; + if (normalizedParent.length === 0) { + return `/${name}`; + } + + return `${normalizedParent}/${name}`; +} + +function resolveAuth(options: SftpConnectionOptions): ResolvedAuth { + if (options.authMethod === "password" && options.password) { + return { type: "password", password: options.password }; + } + if (options.authMethod === "agent" || options.agentPath || process.env.SSH_AUTH_SOCK) { + return { type: "agent", agent: options.agentPath ?? process.env.SSH_AUTH_SOCK ?? "" }; + } + if (options.privateKeyPath) { + try { + const privateKey = readFileSync(options.privateKeyPath); + return { type: "key", privateKey, passphrase: options.passphrase }; + } catch { + // Fall through + } + } + // Default to password if available + if (options.password) { + return { type: "password", password: options.password }; + } + return { type: "agent", agent: process.env.SSH_AUTH_SOCK ?? "" }; +} + +export function createSftpTransport( + sessionId: string, + options: SftpConnectionOptions, + transportOptions?: SftpTransportOptions +): SftpTransportHandle { + const listeners = new Set<(event: SessionTransportEvent) => void>(); + let client: Client | null = null; + let sftp: SFTPWrapper | null = null; + let poolConnectionId: string | null = null; + let poolConsumerId: string | null = null; + const pool = transportOptions?.pool; + + const emit = (event: SessionTransportEvent) => { + queueMicrotask(() => { + for (const listener of listeners) { + listener(event); + } + }); + }; + + const emitStatus = (state: SessionState) => { + emit({ type: "status", sessionId, state }); + }; + + const emitError = (message: string) => { + emit({ type: "error", sessionId, message }); + }; + + const requireSftp = (): SFTPWrapper => { + if (!sftp) { + throw new Error("SFTP session not connected"); + } + + return sftp; + }; + + function tryConnect(connectConfig: ConnectConfig): Promise { + return new Promise((resolve, reject) => { + const conn = new Client(); + let settled = false; + + const fail = (error: Error) => { + if (settled) return; + settled = true; + conn.removeAllListeners(); + reject(error); + }; + + conn.on("ready", () => { + conn.sftp((error, sftpSession) => { + if (error) { + fail(error); + return; + } + if (settled) return; + + settled = true; + client = conn; + sftp = sftpSession; + + conn.on("close", () => { + sftp = null; + client = null; + emitStatus("disconnected"); + }); + + resolve(); + }); + }); + + conn.on("error", fail); + + // Handle keyboard-interactive auth (2FA, TOTP, etc.) + const onKbdInteractive = transportOptions?.onKeyboardInteractive; + if (onKbdInteractive) { + conn.on("keyboard-interactive", (name, instructions, _instructionsLang, prompts, finish) => { + const mappedPrompts = prompts.map((p) => ({ + prompt: p.prompt, + echo: p.echo ?? false, + })); + onKbdInteractive(name, instructions, mappedPrompts) + .then((responses) => { + finish(responses); + }) + .catch(() => { + // User cancelled or error — send empty responses so server rejects + finish(prompts.map(() => "")); + }); + }); + } + + conn.connect(connectConfig); + }); + } + + async function connect(): Promise { + emitStatus("connecting"); + + // If pool is provided, use pooled connection + if (pool) { + try { + const target: Ssh2PoolTarget = { + hostname: options.hostname, + port: options.port ?? 22, + username: options.username ?? "", + auth: resolveAuth(options), + keepAliveSeconds: options.keepAliveSeconds, + }; + + const pooled = await pool.acquire(target); + poolConnectionId = pooled.connectionId; + poolConsumerId = pooled.consumerId; + client = pooled.client; + + // Get SFTP session from the pooled client + sftp = await new Promise((resolve, reject) => { + pooled.client.sftp((err, sftpSession) => { + if (err) reject(err); + else resolve(sftpSession); + }); + }); + + emitStatus("connected"); + return; + } catch (error) { + const message = error instanceof Error ? error.message : String(error); + emitError(message); + emitStatus("failed"); + throw error; + } + } + + // Collect all candidate key paths and try each one sequentially, + // just like the system ssh binary does. + const keyPaths = collectKeyPaths(options); + const attempts = keyPaths.length > 0 ? keyPaths : [undefined]; + + let lastError: Error | null = null; + for (const keyPath of attempts) { + const config = buildConnectConfig(options, keyPath, transportOptions); + + try { + await tryConnect(config); + emitStatus("connected"); + return; + } catch (error) { + lastError = error instanceof Error ? error : new Error(String(error)); + } + } + + emitError(lastError?.message ?? "All authentication methods failed"); + emitStatus("failed"); + throw lastError ?? new Error("All authentication methods failed"); + } + + function disconnect(): void { + if (pool && poolConnectionId && poolConsumerId) { + const activeSftp = sftp as { end?: () => void } | null; + try { + activeSftp?.end?.(); + } catch { + // Best effort: the underlying pooled SSH client is still released below. + } + + // Release back to pool — don't end the client + pool.release(poolConnectionId, poolConsumerId); + poolConnectionId = null; + poolConsumerId = null; + client = null; + sftp = null; + return; + } + + if (!client) { + return; + } + + client.end(); + client = null; + sftp = null; + } + + async function list(remotePath: string): Promise { + const sftpSession = requireSftp(); + return await new Promise((resolve, reject) => { + sftpSession.readdir(remotePath, (error, entries) => { + if (error) { + reject(error); + return; + } + + resolve( + (entries ?? []) + .filter((entry) => entry.filename !== "." && entry.filename !== "..") + .map((entry) => { + const path = combineRemotePath(remotePath, entry.filename); + return { + ...buildEntry(path, entry.attrs), + name: entry.filename + }; + }) + ); + }); + }); + } + + async function stat(remotePath: string): Promise { + const sftpSession = requireSftp(); + return await new Promise((resolve, reject) => { + sftpSession.stat(remotePath, (error, attrs) => { + if (error) { + reject(error); + return; + } + + resolve(buildEntry(remotePath, attrs)); + }); + }); + } + + async function mkdir(remotePath: string): Promise { + const sftpSession = requireSftp(); + await new Promise((resolve, reject) => { + sftpSession.mkdir(remotePath, (error) => { + if (error) { + reject(error); + return; + } + + resolve(); + }); + }); + } + + async function chmod(remotePath: string, permissions: number): Promise { + const sftpSession = requireSftp(); + await new Promise((resolve, reject) => { + sftpSession.chmod(remotePath, permissions & 0o7777, (error) => { + if (error) { + reject(error); + return; + } + + resolve(); + }); + }); + } + + async function rename(oldPath: string, newPath: string): Promise { + const sftpSession = requireSftp(); + await new Promise((resolve, reject) => { + sftpSession.rename(oldPath, newPath, (error) => { + if (error) { + reject(error); + return; + } + + resolve(); + }); + }); + } + + async function remove(remotePath: string, recursive = false): Promise { + const sftpSession = requireSftp(); + if (recursive) { + const entries = await list(remotePath); + for (const entry of entries) { + if (entry.isDirectory) { + await remove(entry.path, true); + continue; + } + + await new Promise((resolve, reject) => { + sftpSession.unlink(entry.path, (error) => { + if (error) { + reject(error); + return; + } + + resolve(); + }); + }); + } + + await new Promise((resolve, reject) => { + sftpSession.rmdir(remotePath, (error) => { + if (error) { + reject(error); + return; + } + + resolve(); + }); + }); + return; + } + + const entry = await stat(remotePath); + await new Promise((resolve, reject) => { + if (entry.isDirectory) { + sftpSession.rmdir(remotePath, (error) => { + if (error) { + reject(error); + return; + } + + resolve(); + }); + return; + } + + sftpSession.unlink(remotePath, (error) => { + if (error) { + reject(error); + return; + } + + resolve(); + }); + }); + } + + async function readFile(remotePath: string): Promise { + const MAX_READ_SIZE = 10 * 1024 * 1024; // 10 MB + const entry = await stat(remotePath); + if (entry.size > MAX_READ_SIZE) { + throw new Error( + `File too large to open in editor (${(entry.size / 1024 / 1024).toFixed(1)} MB, max ${MAX_READ_SIZE / 1024 / 1024} MB)` + ); + } + + const sftpSession = requireSftp(); + const stream = sftpSession.createReadStream(remotePath); + return await new Promise((resolve, reject) => { + const chunks: Buffer[] = []; + stream.on("data", (chunk: string | Buffer) => { + chunks.push(Buffer.isBuffer(chunk) ? chunk : Buffer.from(chunk)); + }); + stream.on("error", reject); + stream.on("end", () => { + resolve(Buffer.concat(chunks)); + }); + }); + } + + async function writeFile(remotePath: string, data: Buffer): Promise { + const sftpSession = requireSftp(); + const stream = sftpSession.createWriteStream(remotePath); + await new Promise((resolve, reject) => { + stream.on("error", reject); + stream.on("close", () => resolve()); + stream.end(data); + }); + } + + function createReadStream(remotePath: string, options?: { start?: number }): Readable { + return requireSftp().createReadStream(remotePath, options); + } + + function createWriteStream(remotePath: string, options?: { start?: number; flags?: OpenMode }): Writable { + return requireSftp().createWriteStream(remotePath, options); + } + + function onEvent(listener: (event: SessionTransportEvent) => void): () => void { + listeners.add(listener); + return () => { + listeners.delete(listener); + }; + } + + return { + connect, + disconnect, + list, + stat, + chmod, + mkdir, + rename, + remove, + readFile, + writeFile, + createReadStream, + createWriteStream, + onEvent + }; +} diff --git a/packages/session-core/src/transports/transportEvents.ts b/packages/session-core/src/transports/transportEvents.ts index 74014dd..6865430 100644 --- a/packages/session-core/src/transports/transportEvents.ts +++ b/packages/session-core/src/transports/transportEvents.ts @@ -1,96 +1,100 @@ -export type SessionTransportKind = "ssh" | "serial" | "sftp" | "telnet"; - -export type SessionState = - | "connecting" - | "connected" - | "reconnecting" - | "waiting_for_network" - | "disconnected" - | "failed"; - -export type SessionTransportEvent = - | { - type: "data"; - sessionId: string; - data: string; - } - | { - type: "status"; - sessionId: string; - state: SessionState; - } - | { - type: "exit"; - sessionId: string; - exitCode: number | null; - } - | { - type: "error"; - sessionId: string; - message: string; - }; - -export interface TransportHandle { - write(data: string): void; - resize(cols: number, rows: number): void; - close(): void; - onEvent(listener: (event: SessionTransportEvent) => void): () => void; - setSignals?(signals: { dtr?: boolean; rts?: boolean }): void; -} - -export interface SshConnectionOptions { - hostname: string; - username?: string; - port?: number; - identityFile?: string; - password?: string; - proxyJump?: string; - keepAliveSeconds?: number; - envVars?: Record; -} - -export interface SerialConnectionOptions { - path: string; - baudRate?: number; - dataBits?: 5 | 6 | 7 | 8; - stopBits?: 1 | 2; - parity?: "none" | "even" | "odd" | "mark" | "space"; - flowControl?: "none" | "hardware" | "software"; - localEcho?: boolean; - dtr?: boolean; - rts?: boolean; -} - -export interface TelnetConnectionOptions { - hostname: string; - port: number; - mode: "telnet" | "raw"; - terminalType?: string; -} - -export interface SftpConnectionOptions { - hostname: string; - port?: number; - username?: string; - authMethod: "password" | "key" | "agent"; - password?: string; - privateKeyPath?: string; - /** Additional key paths to try if the primary key fails */ - fallbackKeyPaths?: string[]; - agentPath?: string; - passphrase?: string; - proxyJump?: string; - keepAliveSeconds?: number; -} - -export type OpenSessionRequest = { - sessionId: string; - transport: SessionTransportKind; - profileId: string; - cols: number; - rows: number; - sshOptions?: SshConnectionOptions; - serialOptions?: SerialConnectionOptions; - sftpOptions?: SftpConnectionOptions; - telnetOptions?: TelnetConnectionOptions; -}; +export type SessionTransportKind = "ssh" | "serial" | "sftp" | "telnet"; + +export type SessionState = + | "connecting" + | "connected" + | "reconnecting" + | "waiting_for_network" + | "disconnected" + | "failed"; + +export type SessionTransportEvent = + | { + type: "data"; + sessionId: string; + data: string; + } + | { + type: "status"; + sessionId: string; + state: SessionState; + } + | { + type: "exit"; + sessionId: string; + exitCode: number | null; + } + | { + type: "error"; + sessionId: string; + message: string; + }; + +export interface TransportHandle { + write(data: string): void; + resize(cols: number, rows: number): void; + close(): void; + onEvent(listener: (event: SessionTransportEvent) => void): () => void; + setSignals?(signals: { dtr?: boolean; rts?: boolean }): void; +} + +export interface SshConnectionOptions { + hostname: string; + username?: string; + port?: number; + identityFile?: string; + password?: string; + proxyJump?: string; + keepAliveSeconds?: number; + envVars?: Record; +} + +export interface SerialConnectionOptions { + path: string; + baudRate?: number; + dataBits?: 5 | 6 | 7 | 8; + stopBits?: 1 | 2; + parity?: "none" | "even" | "odd" | "mark" | "space"; + flowControl?: "none" | "hardware" | "software"; + localEcho?: boolean; + dtr?: boolean; + rts?: boolean; +} + +export interface TelnetConnectionOptions { + hostname: string; + port: number; + mode: "telnet" | "raw"; + terminalType?: string; +} + +export interface SftpConnectionOptions { + hostname: string; + port?: number; + username?: string; + authMethod: "password" | "key" | "agent"; + password?: string; + privateKeyPath?: string; + /** Additional key paths to try if the primary key fails */ + fallbackKeyPaths?: string[]; + agentPath?: string; + passphrase?: string; + proxyJump?: string; + keepAliveSeconds?: number; +} + +export interface SftpSecurityOptions { + trustedHostFingerprints?: string[]; +} + +export type OpenSessionRequest = { + sessionId: string; + transport: SessionTransportKind; + profileId: string; + cols: number; + rows: number; + sshOptions?: SshConnectionOptions; + serialOptions?: SerialConnectionOptions; + sftpOptions?: SftpConnectionOptions; + telnetOptions?: TelnetConnectionOptions; +}; diff --git a/packages/shared/src/ipc/sftpSchemas.ts b/packages/shared/src/ipc/sftpSchemas.ts index 2f79770..ab33224 100644 --- a/packages/shared/src/ipc/sftpSchemas.ts +++ b/packages/shared/src/ipc/sftpSchemas.ts @@ -356,6 +356,8 @@ export const sftpDragOutRequestSchema = z.object({ sftpSessionId: z.string(), remotePath: z.string(), fileName: z.string(), + isDirectory: z.boolean().optional(), + prepareOnly: z.boolean().optional(), }); export type SftpDragOutRequest = z.infer;