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/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/features/sftp/components/FileList.tsx b/apps/ui/src/features/sftp/components/FileList.tsx index 9c7fe03..4668930 100644 --- a/apps/ui/src/features/sftp/components/FileList.tsx +++ b/apps/ui/src/features/sftp/components/FileList.tsx @@ -151,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]; @@ -163,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) => { @@ -187,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 }); 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;